From d73e44b096272d69567545b37e97ee4aeb1c92a7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 07:25:56 -0700 Subject: [PATCH 001/116] chore: pin @tanstack/oracles and eslint-plugin-oracles at 84cd34a The lockfile changes only the two pkg.pr.new entries: their URLs and integrity, the plugin's dropped @typescript-eslint/parser dependency, and @tanstack/oracles' TypeScript alias (typescript-for-tanstack-oracles). Co-authored-by: Isaac --- package.json | 4 ++-- packages/db/package.json | 2 +- pnpm-lock.yaml | 30 +++++++++++++----------------- 3 files changed, 16 insertions(+), 20 deletions(-) diff --git a/package.json b/package.json index f135930d90..457c483b3e 100644 --- a/package.json +++ b/package.json @@ -52,8 +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@d1b508a", - "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a", + "@tanstack/eslint-plugin-oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a", "@tanstack/typedoc-config": "0.3.5", "@tanstack/vite-config": "0.5.2", "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/db/package.json b/packages/db/package.json index 074717fbf2..fb2b995ea4 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@playwright/test": "^1.65.0", "@tanstack/config": "^0.22.2", - "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a", "@vitest/coverage-istanbul": "^5.1.0", "arktype": "^2.2.9", "fake-indexeddb": "^6.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 995a1afb4f..308765ccc3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,11 +61,11 @@ importers: 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@d1b508a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a(eslint@9.39.5(jiti@2.7.0))(typescript@5.9.3) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a + version: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a(eslint@9.39.5(jiti@2.7.0)) '@tanstack/oracles': - specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a(fast-check@4.10.2)(vitest@5.1.0) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0) '@tanstack/typedoc-config': specifier: 0.3.5 version: 0.3.5(typescript@5.9.3) @@ -1313,8 +1313,8 @@ importers: 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@d1b508a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a(fast-check@4.10.2)(vitest@5.1.0) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0) '@vitest/coverage-istanbul': specifier: ^5.1.0 version: 5.1.0(vitest@5.1.0) @@ -6323,8 +6323,8 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 - '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a': - resolution: {integrity: sha512-qjW5wQbicLQTID6iILaEn632NuTI+l58ZMRDTv4+EFlLOJe2NWG2CTrk3tyq1vVVkxOuTTfiJ+4dBqya3gAD+g==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a} + '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a': + resolution: {integrity: sha512-e+rlsviqbUckek4jKgcuujqyQzTR1LYVvU7B6LafwhTiKTgQhYIZUaQbhMUG74G/ldnCUgmI8G6XCxfe9ZPOrQ==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a} version: 0.0.0 engines: {node: '>=20'} peerDependencies: @@ -6334,8 +6334,8 @@ packages: resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} - '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a': - resolution: {integrity: sha512-i+D+r7zcr1yHsrp8GzLYrbJ4F+Nxg+gfPQO42XcRJQfgBdHyoHW1YywFinC6RD2uyaWqhFgBhTmuvl2Rf0qOPA==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a} + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a': + resolution: {integrity: sha512-TxEfjXnOW8VrmRe+CZd0HVfeYtU88ciGqzTS+WZ+FyTtfYQ9tvhT9h7bp3Jj3f3T1IY89RUimr+KtV6EVTk8eg==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a} version: 0.0.0 engines: {node: '>=20'} hasBin: true @@ -18606,20 +18606,16 @@ snapshots: - supports-color - typescript - '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@d1b508a(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@84cd34a(eslint@9.39.5(jiti@2.7.0))': dependencies: - '@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) - transitivePeerDependencies: - - supports-color - - typescript '@tanstack/history@1.162.5': {} - '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@d1b508a(fast-check@4.10.2)(vitest@5.1.0)': + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0)': dependencies: fast-check: 4.10.2 - typescript: 5.9.3 + typescript-for-tanstack-oracles: typescript@5.9.3 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)) '@tanstack/pacer-lite@0.3.0': {} From 3b8217f8e50e3320b42d87c045a6e9e03822c259 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 07:58:52 -0700 Subject: [PATCH 002/116] test: drop the removed legacyCampaigns setting and its merge-base gate @tanstack/oracles 84cd34a removed `settings.oracles.legacyCampaigns` (TanStack/oracles #23): oracle/oracle-property-only reports any value of it, `[]` included, at line 1 of every oracle file, and exempts no file. Delete the empty list from scripts/oracle-lint-settings.mjs and the override in scripts/oracle-campaign-inventory.mjs. The list no longer exists, so delete the gate that kept it from regrowing, scripts/check-legacy-oracle-campaigns.mjs, and its oracle, scripts/legacy-campaign-baseline-oracle.property.test.js (scripts.legacy-campaign-baseline, run budget 20), with its replay manifest and property registry entries. `pnpm lint:oracles` now runs only the oracle lint over every oracle file, which reports any raw campaign or legacy import. Update the coverage map and the rubric. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 73 ++-- docs/contributing/oracle-rubric.md | 9 +- package.json | 2 +- packages/db/tests/oracle-config.ts | 1 - packages/db/tests/oracle-replay-manifest.ts | 9 - scripts/check-legacy-oracle-campaigns.mjs | 47 --- ...-campaign-baseline-oracle.property.test.js | 355 ------------------ scripts/oracle-campaign-inventory.mjs | 8 +- scripts/oracle-lint-settings.mjs | 4 - 9 files changed, 35 insertions(+), 473 deletions(-) delete mode 100644 scripts/check-legacy-oracle-campaigns.mjs delete mode 100644 scripts/legacy-campaign-baseline-oracle.property.test.js diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 68202eab3d..4cbe31ba5b 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4432,10 +4432,12 @@ helpers and their self-tests are deleted (maintainer decision, 2026-11-10). The framework's own self-oracle owns the declared-reach, replay and shrink-fidelity laws for every `oracleProperty`. -The `legacyCampaigns` list in `scripts/oracle-lint-settings.mjs` is empty: no -file keeps a raw or legacy campaign. `scripts/check-legacy-oracle-campaigns.mjs` -still rejects any addition relative to the merge-base, so the empty list cannot -regrow; the setting itself leaves with the framework's later removal of it. +No file keeps a raw or legacy campaign. The framework removed the +`legacyCampaigns` setting (TanStack/oracles #23), so `oracle/oracle-property-only` +exempts no file and reports a raw campaign or legacy import wherever it +appears. `scripts/oracle-lint-settings.mjs` no longer sets it, and the +merge-base gate that kept the empty list from regrowing is deleted (see +"Legacy oracle campaign migration" below). The last callers converted as follows. A per-history reach on an enumerated source (the documented-mutations receivers, the Query reload-housekeeping @@ -8406,53 +8408,26 @@ value/key contracts and their stated limits. The commit-pinned `@tanstack/oracles` framework supplies `oracleProperty` as its only campaign API. DB retained the two removed helpers and their self-tests -locally while the explicit `legacyCampaigns` baseline shrank. The baseline +locally while an explicit `legacyCampaigns` baseline shrank. The baseline contained 199 files at adoption, including three restored compatibility test -files. It is now empty, and the vendored helpers and their self-tests are -deleted (2026-11-10). `node scripts/oracle-campaign-inventory.mjs` surveys the current files -with the lint plugin's own legacy-use classifier; its shape and fixed-history -flags are planning hints, not evidence of a completed conversion. - +files. Every file converted, and the vendored helpers and their self-tests are +deleted (2026-11-10). The framework then removed the setting (TanStack/oracles +#23, maintainer decision 2026-11-10): `oracle/oracle-property-only` reports any +value of `settings.oracles.legacyCampaigns`, `[]` included, in every oracle +file, and exempts no file. TanStack DB no longer sets it. The merge-base gate +`scripts/check-legacy-oracle-campaigns.mjs` and its oracle, `scripts/legacy-campaign-baseline-oracle.property.test.js` -(`scripts.legacy-campaign-baseline`) owns the migration gate in -`scripts/check-legacy-oracle-campaigns.mjs`. AGENTS.md's migration ratchet is its -authority: an adopted baseline may only lose exact file identities. The model -uses membership in a four-file universe. The driver commits base settings in a -temporary Git repository, writes head settings, runs the actual CLI, and -compares the full additions report and exit status after process completion. -The grammar also varies the setting's presence and the gate's presence at base. -Fixture histories cross inherited pre-commit/prepare-commit-msg hooks with -plain or strict CRLF global configuration. A constructed no-hook history with -strict CRLF settings requires fixture setup and the completed gate report to -remain independent of developer global configuration. -Only absence of both permits initial adoption; after the setting is removed, -the gate's presence preserves an empty allowed set. Constructed histories cover -an addition, an addition after an empty adopted baseline, an addition after the -setting was removed, a removal, and initial adoption. Both 20-history campaigns -run these same laws and constructions. A local association records each -builder's declared states on every invocation. The shared check compares those -declarations with actual state observations for seeded, generated, and shrunk -constructed outputs; it does not depend on the pinned framework's seeded-only -association. The checker rejects both an always-accept report and a trace that -omits a required constructed state at the virtual `constructed states` -checkpoint. This checkpoint is unrecorded, so an incomplete driver trace keeps -its actual driver failure primary rather than inventing an earlier state -omission. Actual gate-report mismatches still use `completed`. Production -mutants that always accept or allow a removed baseline -to restart fail the named `only shrinks` law at `completed`, including direct -seed/path replay. - -Limits: valid settings with unique file names; the settings module exists at -base and exports literal settings without imports. The external lint plugin -owns malformed entries, stale entries in files that still exist, and the -classification of legacy API uses. Deleted files have no lint invocation; -remove their baseline entries when deleting them. The gate compares file -identities and permits reason edits. No existing -product law or campaign is converted by the repin. Each later conversion needs -its own same-seed before/after gap comparison; this adoption does not supply -that evidence. Gaps in changed functions close by a kill or an explicit -maintainer allowance for their anchored gaps; a coverage-map entry alone does -not close them. +(`scripts.legacy-campaign-baseline`, two 20-history campaigns), are deleted +with it: they guarded a list that no longer exists. Without an exemption list, +`oracle/oracle-property-only` reports every raw campaign and legacy campaign +import in every oracle file that `pnpm lint:oracles` lints, so no file can +regrow one. `node scripts/oracle-campaign-inventory.mjs` surveys the current +files with the rule's own classifier; its shape and fixed-history flags are +planning hints, not evidence of a completed conversion. + +Each conversion needs its own same-seed before/after gap comparison. Gaps in +changed functions close by a kill or an explicit maintainer allowance for +their anchored gaps; a coverage-map entry alone does not close them. The reversed-index owner now registers generated reads through `reverse-index.reads` and its four existing large histories through the finite diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index 7c78053f3a..50a85f129b 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -322,8 +322,13 @@ presence of a preferred heading, class, comment template, or helper. random hits alone do not enforce either obligation. Literal fixed histories do not count toward required reach. A passing run count does not satisfy this requirement. - The `legacyCampaigns` list is empty: every campaign uses `oracleProperty`, - and the vendored legacy reach helpers are deleted. + An enumerated source runs every history, so it declares its states with + `histories: { enumerated, states }` and needs no constructed history. Its + `reach` test fails when no enumerated history enters a declared state + (maintainer decision, 2026-11-10, in the `oracleProperty` contract). + Every campaign uses `oracleProperty`. The framework removed the + `legacyCampaigns` exemption list, and the vendored legacy reach helpers are + deleted. - **Acceptance evidence:** The pull request reports each declared state and how many histories reached it in the normal test run. A state that only a larger run budget reaches is reported as such. diff --git a/package.json b/package.json index 457c483b3e..162130a0af 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint": "pnpm --filter \"./packages/**\" lint", "lint:check": "pnpm --filter \"./packages/**\" lint --fix=false", "lint:changed": "node scripts/agent-lint-feedback.mjs", - "lint:oracles": "node scripts/check-legacy-oracle-campaigns.mjs && node scripts/agent-lint-feedback.mjs --all", + "lint:oracles": "node scripts/agent-lint-feedback.mjs --all", "lint:run-budgets": "node scripts/check-oracle-run-counts.mjs", "gap-report": "oracle-gaps --env-prefix TANSTACK_DB_ORACLE", "test:lint-rules": "vitest run --config scripts/oracle-scripts.vitest.config.mjs", diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index c916178af3..5a963c2979 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -101,7 +101,6 @@ const staticOracleProperties = [ `browser-coordinator.routing`, `browser.shared-driver-fairness`, `live-query.output-indexing`, - `scripts.legacy-campaign-baseline`, `query.public-container-copy`, `compiler.group-by.batch`, `pagination.limit-admission-replay-pending-negative`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index d24e1a6fd4..a54700b969 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -326,11 +326,6 @@ const ownerGroups: ReadonlyArray< `shared-driver-fairness`, ], - [ - `../scripts/legacy-campaign-baseline-oracle.property.test.js`, - `scripts`, - `legacy-campaign-baseline`, - ], [ `db/tests/query/pagination-oracle.property.test.ts`, `pagination`, @@ -1692,10 +1687,6 @@ const replayTestNames = new Map([ `sqlite-persistence.binding-capacity`, `sqlite-persistence\\.binding-capacity > random seed`, ], - [ - `scripts.legacy-campaign-baseline`, - `scripts\\.legacy-campaign-baseline > random seed`, - ], ...ownerGroups .filter(([, prefix]) => prefix === `adapter-conformance.eager-live-source`) .flatMap(([, prefix, suffixes]) => diff --git a/scripts/check-legacy-oracle-campaigns.mjs b/scripts/check-legacy-oracle-campaigns.mjs deleted file mode 100644 index f6d761062c..0000000000 --- a/scripts/check-legacy-oracle-campaigns.mjs +++ /dev/null @@ -1,47 +0,0 @@ -// A migration baseline may only shrink after its initial adoption. Compare -// file identities with the merge-base; each file's lint pass checks its use. -import { execFileSync } from 'node:child_process' -import { readFileSync } from 'node:fs' -import { resolve } from 'node:path' -import { parseArgs } from 'node:util' - -const { values } = parseArgs({ options: { base: { type: `string` } } }) -const root = process.cwd() -const git = (...args) => - execFileSync(`git`, args, { cwd: root, encoding: `utf8` }).trim() -const base = values.base ?? git(`merge-base`, `origin/main`, `HEAD`) -const file = `scripts/oracle-lint-settings.mjs` -const readSettings = async (source) => - ( - await import( - `data:text/javascript;base64,${Buffer.from(source).toString(`base64`)}` - ) - ).oracleLintSettings.oracles -const previous = await readSettings(git(`show`, `${base}:${file}`)) -const current = await readSettings(readFileSync(resolve(root, file), `utf8`)) - -// Only the first gate commit may adopt a baseline. Removing the setting after -// the last conversion leaves an empty allowed set, so it cannot be restarted. -const gateExisted = - git( - `ls-tree`, - `--name-only`, - base, - `--`, - `scripts/check-legacy-oracle-campaigns.mjs`, - ) !== `` -const allowed = - previous?.legacyCampaigns === undefined && !gateExisted - ? undefined - : new Set((previous?.legacyCampaigns ?? []).map(({ file }) => file)) -const added = - allowed === undefined - ? [] - : [...new Set((current.legacyCampaigns ?? []).map(({ file }) => file))] - .filter((file) => !allowed.has(file)) - .sort() -console.log(JSON.stringify({ added })) -if (added.length > 0) { - console.error(`Legacy oracle campaigns may only shrink: ${added.join(`, `)}`) - process.exitCode = 1 -} diff --git a/scripts/legacy-campaign-baseline-oracle.property.test.js b/scripts/legacy-campaign-baseline-oracle.property.test.js deleted file mode 100644 index 21750107ae..0000000000 --- a/scripts/legacy-campaign-baseline-oracle.property.test.js +++ /dev/null @@ -1,355 +0,0 @@ -/** - * The migration baseline may only shrink after adoption (AGENTS.md, "The - * oracle migration and gap ratchet"). Its file names are exact identities. - * Only the initial gate adoption has no baseline to grow. Once adopted, an - * empty or removed list still forbids every addition. Reordering and removals - * remain legal. - * - * The model classifies each file in a finite universe by membership at base - * and head. The driver writes those lists into a real temporary Git checkout, - * commits base, and runs the gate on the uncommitted head. It compares the - * exact added files and exit status at process completion. The grammar varies - * both sets, their order, the setting's presence, and the gate's presence at - * base. An inherited pre-commit or prepare-commit-msg hook would block the - * fixture commit; both must leave the gate's completed report unchanged. - * The grammar also crosses those hooks with plain and strict CRLF global - * configuration. Neither developer setting may decide fixture setup or the - * gate report, including histories with no inherited hook. Every constructed - * output also owes its declared state observations. The shared comparison - * enforces them for seeded, generated, and shrunk builder outputs. - * It excludes duplicate entries and malformed settings, which the lint plugin - * owns. It does not judge the lint rule's legacy-use classifier or changes to - * an entry's reason. - */ -import { execFileSync, spawnSync } from 'node:child_process' -import { - chmodSync, - mkdirSync, - mkdtempSync, - rmSync, - writeFileSync, -} from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import fc from 'fast-check' -import { - differs, - oracleProperty, - pass, - violation, -} from '../packages/db/tests/oracle-config.js' - -const gate = fileURLToPath( - new URL(`./check-legacy-oracle-campaigns.mjs`, import.meta.url), -) -const files = [ - `packages/db/tests/a-oracle.test.ts`, - `packages/db/tests/a-oracle.test.tsx`, - `packages/db/tests/b-oracle.test.ts`, - `packages/react-db/tests/a-oracle.test.tsx`, -] -// The pinned framework associates only seeded constructions with their state -// requirements. Record declarations on every builder call, including generated -// and shrunk outputs. Fresh objects keep the association unambiguous; the -// driver independently reports which states the actual CLI history entered. -const constructionStates = new WeakMap() -const historyArbitrary = fc.record({ - adopted: fc.boolean(), - enforced: fc.boolean(), - base: fc.uniqueArray(fc.constantFrom(...files)), - head: fc.uniqueArray(fc.constantFrom(...files)), - hook: fc.constantFrom(`none`, `pre-commit`, `prepare-commit-msg`), - globalConfig: fc.constantFrom(`plain`, `strict CRLF`), -}) - -// `adopted` maps to the setting's presence and `enforced` to the gate's presence -// at base. Either means adoption already happened; absent settings then mean -// no allowed files. A file contributes precisely when head contains it and -// the adopted base does not. Membership predicts rejection and the full report. -function expectedReport(history) { - const added = - history.adopted || history.enforced - ? files - .filter( - (file) => - history.head.includes(file) && - !(history.adopted ? history.base : []).includes(file), - ) - .sort() - : [] - return { - status: added.length > 0 ? 1 : 0, - added, - requiredStates: constructionStates.get(history) ?? [], - } -} - -function settings(entries) { - return `export const oracleLintSettings = { oracles: {${entries === undefined ? `` : `legacyCampaigns: ${JSON.stringify(entries.map((file) => ({ file, reason: `migration` })))}`} } }\n` -} - -function drive(history, observe) { - const directory = mkdtempSync(join(tmpdir(), `oracle-campaign-baseline-`)) - const globalConfig = join(directory, `global.gitconfig`) - const inheritedConfig = join(directory, `inherited.gitconfig`) - const inheritedEnvironment = { - ...process.env, - GIT_CONFIG_GLOBAL: inheritedConfig, - } - const fixtureEnvironment = { - ...inheritedEnvironment, - GIT_CONFIG_GLOBAL: globalConfig, - } - const git = (...args) => - execFileSync(`git`, args, { - cwd: directory, - stdio: `pipe`, - env: fixtureEnvironment, - }) - const settingsFile = join(directory, `scripts/oracle-lint-settings.mjs`) - try { - writeFileSync( - inheritedConfig, - history.globalConfig === `strict CRLF` - ? `[core]\n\tautocrlf = true\n\tsafecrlf = true\n` - : ``, - ) - mkdirSync(join(directory, `scripts`)) - git(`init`, `-q`) - if (history.hook !== `none`) { - const hookDirectory = join(directory, `inherited-hooks`) - mkdirSync(hookDirectory) - const hook = join(hookDirectory, history.hook) - writeFileSync( - hook, - `#!/bin/sh\necho "blocking inherited hook" >&2\nexit 1\n`, - ) - chmodSync(hook, 0o755) - git(`config`, `--file`, globalConfig, `core.hooksPath`, hookDirectory) - } - writeFileSync( - settingsFile, - settings(history.adopted ? history.base : undefined), - ) - if (history.enforced) - writeFileSync( - join(directory, `scripts/check-legacy-oracle-campaigns.mjs`), - `// Previous gate exists.\n`, - ) - git(`add`, `.`) - git( - `-c`, - `user.name=Oracle`, - `-c`, - `user.email=oracle@example.com`, - `-c`, - `commit.gpgsign=false`, - // This temporary commit establishes input data, so no developer hook - // may decide whether the gate's production path runs. - `-c`, - `core.hooksPath=${join(directory, `.no-hooks`)}`, - `commit`, - `-qm`, - `base`, - ) - writeFileSync(settingsFile, settings(history.head)) - const result = spawnSync(process.execPath, [gate, `--base`, `HEAD`], { - cwd: directory, - encoding: `utf8`, - timeout: 10_000, - env: fixtureEnvironment, - }) - if (result.error) throw result.error - observe(`completed`, { - status: result.status, - ...JSON.parse(result.stdout), - }) - if (history.hook !== `none`) - observe(`reach`, `inherited ${history.hook} hook`) - if (history.hook === `none` && history.globalConfig === `strict CRLF`) - observe(`reach`, `strict global config without hooks`) - if ( - history.adopted && - history.head.some( - (file) => !(history.adopted ? history.base : []).includes(file), - ) - ) - observe(`reach`, `addition`) - if ( - history.adopted && - history.base.some((file) => !history.head.includes(file)) && - history.head.every((file) => history.base.includes(file)) - ) - observe(`reach`, `removal`) - if (history.adopted && history.base.length === 0 && history.head.length > 0) - observe(`reach`, `empty baseline addition`) - if (!history.adopted && !history.enforced) observe(`reach`, `adoption`) - if (!history.adopted && history.enforced && history.head.length > 0) - observe(`reach`, `retired baseline addition`) - } finally { - rmSync(directory, { recursive: true, force: true }) - } -} - -function checkReport(_history, trace, expected) { - const completed = trace.find(({ checkpoint }) => checkpoint === `completed`) - if (!completed) - return violation(`completed`, `the gate did not complete`, `only shrinks`) - const mismatch = differs(`completed`, `only shrinks`, completed.value, { - status: expected.status, - added: expected.added, - }) - if (mismatch) return mismatch - const reached = new Set( - trace - .filter(({ checkpoint }) => checkpoint === `reach`) - .map(({ value }) => value), - ) - const missing = expected.requiredStates.filter((state) => !reached.has(state)) - // Completeness is known only after the driver returns. An unrecorded - // checkpoint keeps a driver failure primary when it cuts this trace short. - return missing.length > 0 - ? violation( - `constructed states`, - `constructed history did not enter ${missing.join(`, `)}`, - `constructed reach`, - ) - : pass -} - -oracleProperty({ - id: `scripts.legacy-campaign-baseline`, - law: { - statement: `After adoption, the legacy campaign baseline only shrinks by exact file identity.`, - authority: `AGENTS.md: The oracle migration and gap ratchet; maintainer decision 2026-11-10 (handoff).`, - limits: `Four file names, including a shared stem and cross-package names; valid unique entries; no reason changes or lint classification.`, - }, - histories: { - arbitrary: historyArbitrary, - runs: 20, - fixedSeeds: [20261110], - states: [ - `addition`, - `empty baseline addition`, - `retired baseline addition`, - `removal`, - `adoption`, - `inherited pre-commit hook`, - `inherited prepare-commit-msg hook`, - `strict global config without hooks`, - ], - constructed: [ - { - name: `cannot restart a removed baseline`, - enters: [`retired baseline addition`], - build: (h) => ({ - ...h, - adopted: false, - enforced: true, - head: [files[0], ...h.head.filter((file) => file !== files[0])], - }), - }, - { - name: `adds after the last conversion`, - enters: [`addition`, `empty baseline addition`], - build: (h) => ({ - ...h, - adopted: true, - base: [], - head: [files[0], ...h.head.filter((file) => file !== files[0])], - }), - }, - { - name: `adds a new file`, - enters: [`addition`], - build: (h) => ({ - ...h, - adopted: true, - base: h.base.filter((file) => file !== files[0]), - head: [files[0], ...h.head.filter((file) => file !== files[0])], - }), - }, - { - name: `removes a file`, - enters: [`removal`], - build: (h) => ({ - ...h, - adopted: true, - base: [files[0], ...h.base.filter((file) => file !== files[0])], - head: h.head.filter( - (file) => file !== files[0] && h.base.includes(file), - ), - }), - }, - { - name: `initial adoption`, - enters: [`adoption`], - build: (h) => ({ ...h, adopted: false, enforced: false }), - }, - { - name: `strict global config without hooks`, - enters: [`strict global config without hooks`], - build: (h) => ({ ...h, hook: `none`, globalConfig: `strict CRLF` }), - }, - ...[`pre-commit`, `prepare-commit-msg`].map((hook) => ({ - name: `inherited ${hook} hook`, - enters: [`inherited ${hook} hook`], - build: (h) => ({ ...h, hook }), - })), - ].map((construction) => ({ - ...construction, - build: (history) => { - const built = construction.build(history) - constructionStates.set(built, construction.enters) - return built - }, - })), - fixed: [ - { - name: `removed baseline stays closed`, - history: { - adopted: false, - enforced: true, - base: [], - head: [files[0]], - hook: `none`, - globalConfig: `plain`, - }, - }, - ], - }, - model: expectedReport, - driver: drive, - check: checkReport, - calibrations: [ - { - name: `omits a constructed history's required state`, - law: `constructed reach`, - corrupt: (trace, history) => { - const [required] = constructionStates.get(history) ?? [] - return required === undefined - ? undefined - : trace.filter( - ({ checkpoint, value }) => - checkpoint !== `reach` || value !== required, - ) - }, - }, - { - name: `accepts an added legacy campaign`, - law: `only shrinks`, - corrupt: (trace) => - trace.some( - ({ checkpoint, value }) => - checkpoint === `completed` && value.added.length > 0, - ) - ? trace.map((entry) => - entry.checkpoint === `completed` - ? { ...entry, value: { status: 0, added: [] } } - : entry, - ) - : undefined, - }, - ], -}) diff --git a/scripts/oracle-campaign-inventory.mjs b/scripts/oracle-campaign-inventory.mjs index 85d27e3b5c..7836ad1bd2 100644 --- a/scripts/oracle-campaign-inventory.mjs +++ b/scripts/oracle-campaign-inventory.mjs @@ -1,4 +1,5 @@ -// Survey the shrinking migration baseline using the package's own classifier. +// Survey the oracle files that still run a raw campaign or import a legacy +// campaign API, using the package's own classifier. // Shape and fixed-history flags are planning hints; conversion still reads // each law, grammar, driver, comparison, and its gap reports. import { execFileSync } from 'node:child_process' @@ -10,9 +11,6 @@ import rule from '@tanstack/eslint-plugin-oracles/rules/oracle-property-only' import { oracleLintSettings } from './oracle-lint-settings.mjs' const linter = new Linter() -const settings = { - oracles: { ...oracleLintSettings.oracles, legacyCampaigns: [] }, -} const paths = execFileSync( `git`, [`ls-files`, `--cached`, `--others`, `--exclude-standard`, `-z`], @@ -36,7 +34,7 @@ for (const file of new Set(paths)) { languageOptions: { parser }, plugins: { oracle: { rules: { 'oracle-property-only': rule } } }, rules: { 'oracle/oracle-property-only': `error` }, - settings, + settings: oracleLintSettings, }, ], { filename: file }, diff --git a/scripts/oracle-lint-settings.mjs b/scripts/oracle-lint-settings.mjs index 17959f65a8..85d6d63183 100644 --- a/scripts/oracle-lint-settings.mjs +++ b/scripts/oracle-lint-settings.mjs @@ -8,7 +8,6 @@ * 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. - * - `legacyCampaigns`: the files that retain legacy campaigns, now none. * - `replayWitnessModules`: the module that writes a replay witness, whose * record the standard JSON serializer writes. */ @@ -19,9 +18,6 @@ export const oracleLintSettings = { `packages/db/tests/oracle-config.ts`, `packages/db/tests/oracle-property.ts`, ], - // Frozen migration baseline, now empty: every file converted. Never add - // one; check-legacy-oracle-campaigns.mjs rejects any addition. - legacyCampaigns: [], replayWitnessModules: [`packages/db/tests/oracle-replay-witness.ts`], }, } From 499f257a5eea78c5231e6b6f1bd1d693bd55c31f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 07:59:10 -0700 Subject: [PATCH 003/116] test(query-db-collection): declare the failing-Query reach on the enumerated source query-db.failing-queries checked its aggregate reach, four model-derived states over the 2592-history enumeration, with a grammar calibration, because an enumerated source could not declare states. @tanstack/oracles 84cd34a lets it (TanStack/oracles #19), so declare them as `histories: { enumerated, states }` and read each history's states from the model's reach classifier through `reached`. The property's `reach` test now fails when no enumerated history enters a declared state, so the calibration and the file's deprecated declareOracle call are deleted. The law, enumeration, driver, model, and check are unchanged. The file now registers only through oracleProperty, so the type-test collector excludes it, as it does reload-housekeeping-oracle.test.ts. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 11 ++++-- .../tests/failing-queries-oracle.test.ts | 38 +++++-------------- packages/query-db-collection/vitest.config.ts | 7 ++-- 3 files changed, 21 insertions(+), 35 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 4cbe31ba5b..402b788455 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4445,10 +4445,13 @@ kinds in `packages/query-db-collection/tests/reload-housekeeping-oracle.test.ts` (`query-db.reload-housekeeping`), and the real Electric GC receiver `collection-lifecycle.gc-retention-boundaries.electric-postgres`) is a check law, `declared reach`, because an enumerated source runs every history. The one aggregate reach over -an enumeration, the four model-derived states of the failing-Query histories, -is a grammar calibration in `failing-queries-oracle.test.ts` that runs the -model's reach classifier over the oracle's own enumeration without production, -until `oracleProperty` declares states on enumerated sources. The SQLite +an enumeration, the four model-derived states of the failing-Query histories +in `failing-queries-oracle.test.ts`, is declared on the enumerated source, +`histories: { enumerated, states }` (TanStack/oracles #19). The property's +`reached` reads each history's states from the model's reach classifier, and +its `reach` test fails when no enumerated history enters a declared state. +It replaced a grammar calibration that ran the same classifier over the same +enumeration without production. The SQLite sync-metadata session campaigns declare their states with constructed histories; the persisted nesting-and-seeds campaigns' "at least five random histories per state" became "at least one constructed history per state, plus diff --git a/packages/query-db-collection/tests/failing-queries-oracle.test.ts b/packages/query-db-collection/tests/failing-queries-oracle.test.ts index 4e55959bcc..c1db83437c 100644 --- a/packages/query-db-collection/tests/failing-queries-oracle.test.ts +++ b/packages/query-db-collection/tests/failing-queries-oracle.test.ts @@ -24,9 +24,8 @@ * recovery whose result applies late is an ordinary example in query.test.ts. */ import { createCollection } from '@tanstack/db' -import { expect, it, vi } from 'vitest' +import { vi } from 'vitest' import { QueryClient } from '@tanstack/query-core' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -148,9 +147,10 @@ export function modelFailingQueries( /** * Declared reach: the states a fetch enters, read from the model before it. * They are model-derived, so whether the exhaustive enumeration enters each - * one is a fact about the grammar and the model alone. The grammar - * calibration at the end of this file checks it without production, until - * oracleProperty declares states on enumerated sources. + * one is a fact about the grammar and the model alone. The property declares + * them on its enumerated source and reads each history's states from this + * classifier, so its `reach` test fails when no enumerated history enters + * one of them. */ export const failingQueryStates = [ `a Query recovers while another's failure stays listed`, @@ -423,34 +423,16 @@ oracleProperty({ limits: `One Collection, two subsets, every history of four fetches, on Query Collection and behind the persisted wrapper. A recovery whose result applies late is an ordinary example in query.test.ts.`, }, owns: [`src/query.ts#queryCollectionOptions`], - histories: failingQueryHistories(), + histories: { + enumerated: failingQueryHistories(), + states: failingQueryStates, + }, label: ({ persisted, fetches }) => `${persisted ? `persisted` : `plain`} ${fetches .map(({ subset, fails }) => `${subset}:${fails || `ok`}`) .join(` `)}`, model: modelFailingQueries, + reached: ({ fetches }) => failingQueryReach(fetches), driver: recordFailingQueries, check: checkFailingQueries, }) - -/** - * Grammar calibration: the enumeration enters every declared state. It runs - * the model's reach classifier over the oracle's own enumeration, with no - * production, and names each state that no history enters. - */ -it(`grammar calibration: the four-fetch enumeration enters every declared state`, () => { - const reached = new Set( - failingQueryFetches().flatMap((fetches) => [...failingQueryReach(fetches)]), - ) - expect( - failingQueryStates.filter((state) => !reached.has(state)), - `declared states that no enumerated history enters`, - ).toEqual([]) -}) - -declareOracle({ - owns: [`src/query.ts#queryCollectionOptions`], - model: [modelFailingQueries], - check: [checkFailingQueries], - grammar: [failingQueryFetches, failingQueryHistories, failingQueryReach], -}) diff --git a/packages/query-db-collection/vitest.config.ts b/packages/query-db-collection/vitest.config.ts index ce5fa2f944..b07a8d8e33 100644 --- a/packages/query-db-collection/vitest.config.ts +++ b/packages/query-db-collection/vitest.config.ts @@ -20,10 +20,11 @@ export default defineConfig({ `tests/ownership-upper.oracle.test.ts`, `tests/ownership-confirmation.oracle.test.ts`, `tests/documented-mutations-oracle.test.ts`, - // Registers its histories only through oracleProperty, so the static - // type-test collector finds no suite in it; `tsc --noEmit` and the - // typecheck program still check it. + // These register their histories only through oracleProperty, so the + // static type-test collector finds no suite in them; `tsc --noEmit` + // and the typecheck program still check them. `tests/reload-housekeeping-oracle.test.ts`, + `tests/failing-queries-oracle.test.ts`, ], }, }, From ee08da214d8f54d1c5a2a822ed1b22882ca59185 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:16:36 -0700 Subject: [PATCH 004/116] docs: delete the stale legacyCampaigns instructions legacyCampaigns, its merge-base gate, and the vendored campaign helpers are gone, so AGENTS.md and the oracle-authoring skill no longer describe them. Approved by the maintainer (2026-11-11). Co-authored-by: Isaac --- .agents/skills/oracle-authoring/SKILL.md | 3 +-- AGENTS.md | 10 ++-------- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index 81ed7f45d9..293727831d 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -104,8 +104,7 @@ 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. -For a bug fix or feature, convert its owning legacy oracles to `oracleProperty` -and remove their `legacyCampaigns` entries. Close gaps in changed functions by +For a bug fix or feature, close gaps in changed functions by an oracle kill or an explicit maintainer allowance for the anchored gaps. A coverage-map entry, equivalent-mutant claim, or unobservable-effect claim does not close a gap. For a pure conversion, compare before/after gap reports with diff --git a/AGENTS.md b/AGENTS.md index 59a4a0705d..82b719efce 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,17 +57,11 @@ paths and observation checkpoints. The pinned `oracleProperty` registers Vitest tests; importing it into a Playwright spec does not convert that spec. Supply a compatible registration path or a Vitest production driver that still runs the real browser and provider before changing the functions that witness owns. -Keep the existing Playwright witness and its legacy entry until that replacement +Keep the existing Playwright witness until that replacement passes. An unavailable runner path blocks the affected production change; it does not waive conversion or permit a Node-only substitute for real-host evidence. -`oracleProperty` is the only campaign API for new or converted oracles. The -`legacyCampaigns` entries in `scripts/oracle-lint-settings.mjs` temporarily -permit the listed files to keep their old campaigns. The list may only shrink. -CI runs `scripts/check-legacy-oracle-campaigns.mjs` through `pnpm lint:oracles` -to reject additions relative to the merge-base. Remove a file's entry when it -converts. Remove the vendored compatibility helpers when their last caller -converts. +`oracleProperty` is the only campaign API. A conversion preserves laws, law statements, declared states, and run budgets. Replace literal-example reach with constructed histories that keep randomness. From 869019e7868e211d6e7bf2b62b0a44351d640681 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:23:59 -0700 Subject: [PATCH 005/116] test: give each declareOracle conversion group its own property registry module Parallel conversion branches add oracleProperty ids and replay owners. Each group edits only its file under converted-properties/, so the branches do not conflict in oracle-config.ts or the replay manifest. Integration folds the lists back and deletes the directory. Co-authored-by: Isaac --- .../db/tests/converted-properties/adapters.ts | 10 ++++++ .../converted-properties/db-collection-e2e.ts | 10 ++++++ .../converted-properties/db-collection.ts | 10 ++++++ .../db/tests/converted-properties/db-core.ts | 10 ++++++ .../db-indexeddb-conformance.ts | 10 ++++++ .../db/tests/converted-properties/db-query.ts | 10 ++++++ .../converted-properties/frameworks-ivm.ts | 10 ++++++ .../db/tests/converted-properties/index.ts | 32 +++++++++++++++++++ .../db/tests/converted-properties/sqlite.ts | 10 ++++++ packages/db/tests/oracle-config.ts | 2 ++ packages/db/tests/oracle-replay-manifest.ts | 2 ++ 11 files changed, 116 insertions(+) create mode 100644 packages/db/tests/converted-properties/adapters.ts create mode 100644 packages/db/tests/converted-properties/db-collection-e2e.ts create mode 100644 packages/db/tests/converted-properties/db-collection.ts create mode 100644 packages/db/tests/converted-properties/db-core.ts create mode 100644 packages/db/tests/converted-properties/db-indexeddb-conformance.ts create mode 100644 packages/db/tests/converted-properties/db-query.ts create mode 100644 packages/db/tests/converted-properties/frameworks-ivm.ts create mode 100644 packages/db/tests/converted-properties/index.ts create mode 100644 packages/db/tests/converted-properties/sqlite.ts diff --git a/packages/db/tests/converted-properties/adapters.ts b/packages/db/tests/converted-properties/adapters.ts new file mode 100644 index 0000000000..e9a7db9cf8 --- /dev/null +++ b/packages/db/tests/converted-properties/adapters.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "adapters" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/db-collection-e2e.ts b/packages/db/tests/converted-properties/db-collection-e2e.ts new file mode 100644 index 0000000000..def7ed8e1c --- /dev/null +++ b/packages/db/tests/converted-properties/db-collection-e2e.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "db-collection-e2e" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/db-collection.ts b/packages/db/tests/converted-properties/db-collection.ts new file mode 100644 index 0000000000..e47c21a45d --- /dev/null +++ b/packages/db/tests/converted-properties/db-collection.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "db-collection" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts new file mode 100644 index 0000000000..31dd2d3a39 --- /dev/null +++ b/packages/db/tests/converted-properties/db-core.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "db-core" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/db-indexeddb-conformance.ts b/packages/db/tests/converted-properties/db-indexeddb-conformance.ts new file mode 100644 index 0000000000..7c3c827e02 --- /dev/null +++ b/packages/db/tests/converted-properties/db-indexeddb-conformance.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "db-indexeddb-conformance" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/db-query.ts b/packages/db/tests/converted-properties/db-query.ts new file mode 100644 index 0000000000..1d7edb2860 --- /dev/null +++ b/packages/db/tests/converted-properties/db-query.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "db-query" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts new file mode 100644 index 0000000000..7dc7d558bc --- /dev/null +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "frameworks-ivm" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/converted-properties/index.ts b/packages/db/tests/converted-properties/index.ts new file mode 100644 index 0000000000..c8d1bc1f66 --- /dev/null +++ b/packages/db/tests/converted-properties/index.ts @@ -0,0 +1,32 @@ +// Collects each conversion group's property ids and replay owners. See the +// header of any group file in this directory. +import * as adapters from './adapters.js' +import * as dbCollection from './db-collection.js' +import * as dbCollectionE2e from './db-collection-e2e.js' +import * as dbCore from './db-core.js' +import * as dbIndexeddbConformance from './db-indexeddb-conformance.js' +import * as dbQuery from './db-query.js' +import * as frameworksIvm from './frameworks-ivm.js' +import * as sqlite from './sqlite.js' + +/** [owner file, prefix, suffixes] or, with the module that names them, a fourth entry. */ +export type ConvertedOwnerGroup = + | readonly [string, string, string] + | readonly [string, string, string, string] + +const groups = [ + adapters, + dbCollection, + dbCollectionE2e, + dbCore, + dbIndexeddbConformance, + dbQuery, + frameworksIvm, + sqlite, +] + +export const convertedProperties: ReadonlyArray = groups.flatMap( + (group) => group.properties, +) +export const convertedOwners: ReadonlyArray = + groups.flatMap((group) => group.owners) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts new file mode 100644 index 0000000000..2cdbc4e421 --- /dev/null +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -0,0 +1,10 @@ +// The oracleProperty ids that the declareOracle removal adds for the +// "sqlite" conversion group, and their replay owners. Only that group's branch +// edits this file, so parallel groups do not conflict in oracle-config.ts +// or oracle-replay-manifest.ts. Integration folds both lists into those +// files and deletes this directory. +import type { ConvertedOwnerGroup } from './index.js' + +export const properties: ReadonlyArray = [] + +export const owners: ReadonlyArray = [] diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 5a963c2979..4059d701bb 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -8,6 +8,7 @@ import { configureOracles, validateOraclePropertyRegistry, } from '@tanstack/oracles' +import { convertedProperties } from './converted-properties/index.js' export * from '@tanstack/oracles' @@ -893,6 +894,7 @@ export const registeredOracleProperties = validateOraclePropertyRegistry([ ...comparisonProperties, ...paginationMatrixProperties, ...includesMatrixProperties, + ...convertedProperties, ]) /** diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index a54700b969..29721d87e7 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -1,4 +1,5 @@ import { registeredOracleProperties } from './oracle-config.js' +import { convertedOwners } from './converted-properties/index.js' /** * This manifest maps a replay name to the source file that can prove it ran. @@ -1418,6 +1419,7 @@ const ownerGroups: ReadonlyArray< `adapter-conformance.eager-live-source`, `convex convex.react convex.wrapped`, ], + ...convertedOwners, ] const owners = new Map() From 051c36d0ffa30ffb30cf42042877d6525cd55528 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:24:06 -0700 Subject: [PATCH 006/116] style: format the conversion registry index Co-authored-by: Isaac --- packages/db/tests/converted-properties/index.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/db/tests/converted-properties/index.ts b/packages/db/tests/converted-properties/index.ts index c8d1bc1f66..8bb7187770 100644 --- a/packages/db/tests/converted-properties/index.ts +++ b/packages/db/tests/converted-properties/index.ts @@ -11,8 +11,7 @@ import * as sqlite from './sqlite.js' /** [owner file, prefix, suffixes] or, with the module that names them, a fourth entry. */ export type ConvertedOwnerGroup = - | readonly [string, string, string] - | readonly [string, string, string, string] + readonly [string, string, string] | readonly [string, string, string, string] const groups = [ adapters, From b854b35f99cbf4fc33356c878d35c768187aea07 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:25:21 -0700 Subject: [PATCH 007/116] test: split the query conversion group in two and fold indexed-db into db-core Co-authored-by: Isaac --- .../{db-indexeddb-conformance.ts => db-query-includes.ts} | 2 +- .../{db-query.ts => db-query-ordered.ts} | 2 +- packages/db/tests/converted-properties/index.ts | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) rename packages/db/tests/converted-properties/{db-indexeddb-conformance.ts => db-query-includes.ts} (81%) rename packages/db/tests/converted-properties/{db-query.ts => db-query-ordered.ts} (82%) diff --git a/packages/db/tests/converted-properties/db-indexeddb-conformance.ts b/packages/db/tests/converted-properties/db-query-includes.ts similarity index 81% rename from packages/db/tests/converted-properties/db-indexeddb-conformance.ts rename to packages/db/tests/converted-properties/db-query-includes.ts index 7c3c827e02..a50fda6c0b 100644 --- a/packages/db/tests/converted-properties/db-indexeddb-conformance.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -1,5 +1,5 @@ // The oracleProperty ids that the declareOracle removal adds for the -// "db-indexeddb-conformance" conversion group, and their replay owners. Only that group's branch +// "db-query-includes" conversion group, and their replay owners. Only that group's branch // edits this file, so parallel groups do not conflict in oracle-config.ts // or oracle-replay-manifest.ts. Integration folds both lists into those // files and deletes this directory. diff --git a/packages/db/tests/converted-properties/db-query.ts b/packages/db/tests/converted-properties/db-query-ordered.ts similarity index 82% rename from packages/db/tests/converted-properties/db-query.ts rename to packages/db/tests/converted-properties/db-query-ordered.ts index 1d7edb2860..115f47958d 100644 --- a/packages/db/tests/converted-properties/db-query.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -1,5 +1,5 @@ // The oracleProperty ids that the declareOracle removal adds for the -// "db-query" conversion group, and their replay owners. Only that group's branch +// "db-query-ordered" conversion group, and their replay owners. Only that group's branch // edits this file, so parallel groups do not conflict in oracle-config.ts // or oracle-replay-manifest.ts. Integration folds both lists into those // files and deletes this directory. diff --git a/packages/db/tests/converted-properties/index.ts b/packages/db/tests/converted-properties/index.ts index 8bb7187770..6c5292e98b 100644 --- a/packages/db/tests/converted-properties/index.ts +++ b/packages/db/tests/converted-properties/index.ts @@ -4,8 +4,8 @@ import * as adapters from './adapters.js' import * as dbCollection from './db-collection.js' import * as dbCollectionE2e from './db-collection-e2e.js' import * as dbCore from './db-core.js' -import * as dbIndexeddbConformance from './db-indexeddb-conformance.js' -import * as dbQuery from './db-query.js' +import * as dbQueryIncludes from './db-query-includes.js' +import * as dbQueryOrdered from './db-query-ordered.js' import * as frameworksIvm from './frameworks-ivm.js' import * as sqlite from './sqlite.js' @@ -18,8 +18,8 @@ const groups = [ dbCollection, dbCollectionE2e, dbCore, - dbIndexeddbConformance, - dbQuery, + dbQueryIncludes, + dbQueryOrdered, frameworksIvm, sqlite, ] From e500ddfe2e00eb191202f4442d9e02ea44894f55 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:34:59 -0700 Subject: [PATCH 008/116] test(db): delete redundant declareOracle calls in four query oracles Co-authored-by: Isaac --- packages/db/tests/query/distinct-collision-oracle.test.ts | 5 ----- packages/db/tests/query/effect-callback-oracle.ts | 6 ------ .../tests/query/group-result-key-oracle.property.test.ts | 6 ------ .../db/tests/query/public-container-copy-oracle.test.ts | 7 ------- 4 files changed, 24 deletions(-) diff --git a/packages/db/tests/query/distinct-collision-oracle.test.ts b/packages/db/tests/query/distinct-collision-oracle.test.ts index b44bae8e3e..082832cf8e 100644 --- a/packages/db/tests/query/distinct-collision-oracle.test.ts +++ b/packages/db/tests/query/distinct-collision-oracle.test.ts @@ -21,7 +21,6 @@ import { import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' -import { declareOracle } from '../oracle-declaration.js' import { oracleProperty } from '../oracle-config.js' import type { DistinctHistory, @@ -34,10 +33,6 @@ import type { } from '../oracle-config.js' const values = distinctCollisionValues(hash) -declareOracle({ - check: [checkDistinctSnapshot], - grammar: [distinctRecipe], -}) type Plan = { worlds: DistinctHistory; requiredStates: ReadonlyArray } type ReceivingOptions = Omit< diff --git a/packages/db/tests/query/effect-callback-oracle.ts b/packages/db/tests/query/effect-callback-oracle.ts index 638ea6deff..d2f3a30e2b 100644 --- a/packages/db/tests/query/effect-callback-oracle.ts +++ b/packages/db/tests/query/effect-callback-oracle.ts @@ -30,7 +30,6 @@ * must not add traversal of projected values. This is not a Set latency law. */ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' import { checkWithCleanup } from './virtual-row-fields-support.js' import type { DeltaEvent, EffectConfig } from '../../src/query/effect.js' @@ -225,11 +224,6 @@ function checkDelivery( a.channel.localeCompare(b.channel) expect([...actual].sort(order)).toStrictEqual([...expected].sort(order)) } -declareOracle({ - model: [expectedPayload, expectedDelivery], - grammar: [callbackHistories], - check: [checkDelivery], -}) export async function runCallbackHistory( history: CallbackHistory, 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 aa4550a84c..0c91e53321 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 @@ -61,7 +61,6 @@ 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 { declareOracle } from '../oracle-declaration.js' import { differs, oracleProperty, pass, violation } from '../oracle-property.js' import { asProductionError, @@ -398,11 +397,6 @@ function snapshotAt(trace: ReadonlyArray, index: number) { return isErrorObservation(value) ? undefined : (value as Snapshot) } -declareOracle({ - check: [judgeGroups], - model: [expectedGroups], -}) - oracleProperty>, Plan>({ id: `group-result-key.groups`, law: { 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 a5fca21c0c..506593329e 100644 --- a/packages/db/tests/query/public-container-copy-oracle.test.ts +++ b/packages/db/tests/query/public-container-copy-oracle.test.ts @@ -11,7 +11,6 @@ import { import { materializeCompilation } from '../../src/query/live/materialized-pipeline.js' import { Query, eq, include } from '../../src/query/index.js' import { oracleProperty, pass, violation } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' import { createControlledCollection } from './includes-oracle-helpers.js' import type { Observation, Verdict } from '../oracle-config.js' import type { ValueResolver } from '../../src/query/live/materialized-pipeline.js' @@ -81,12 +80,6 @@ function expectCopyFollowsLaw( } } -declareOracle({ - model: [expectedCopy], - check: [expectCopyFollowsLaw, checkCopyPrograms, checkCopyCampaign], - grammar: [programs, inputGrammar], -}) - // Compile a real include plan, then test its resolver on supplied public // values. The generic path also places the array below a record, so the driver // exercises graph propagation instead of only a root replacement. From 2f5699784c040bf8733f9213ba7790b50dc816d3 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:37:16 -0700 Subject: [PATCH 009/116] test(db): drop declareOracle from includes companion modules and redundant compiler oracles Co-authored-by: Isaac --- .../query/compiler/evaluators-oracle.test.ts | 7 ------- .../compiler/group-by-pipeline-oracle.test.ts | 7 ------- .../query/includes-collection-driver-oracle.ts | 6 ------ .../query/includes-scope-identity-oracle.ts | 3 --- .../query/virtual-row-fields-grammar-oracle.ts | 9 --------- .../query/virtual-row-fields-input-oracle.ts | 17 ----------------- .../query/virtual-row-fields-source-oracle.ts | 4 ---- 7 files changed, 53 deletions(-) diff --git a/packages/db/tests/query/compiler/evaluators-oracle.test.ts b/packages/db/tests/query/compiler/evaluators-oracle.test.ts index b1a03c8853..2a2bbdd93e 100644 --- a/packages/db/tests/query/compiler/evaluators-oracle.test.ts +++ b/packages/db/tests/query/compiler/evaluators-oracle.test.ts @@ -7,7 +7,6 @@ import { pass, violation, } from '../../oracle-config.js' -import { declareOracle } from '../../oracle-declaration.js' import type { ConstructedHistory, Observation, @@ -219,12 +218,6 @@ function checkLike( ) } -declareOracle({ - model: [referenceLike, modelLike], - grammar: [likeCase], - check: [checkLike], -}) - oracleProperty({ id: `evaluators.like`, law: { 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 4a78134e7d..3149aeae3b 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 @@ -6,7 +6,6 @@ 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 { oracleProperty, pass, violation } from '../../oracle-config.js' -import { declareOracle } from '../../oracle-declaration.js' import type { Observation, Verdict } from '../../oracle-config.js' import type { Select } from '../../../src/query/ir.js' import type { KeyedNamespacedRow } from '../../../src/types.js' @@ -257,12 +256,6 @@ function checkGroupHistory( return pass } -declareOracle({ - model: [expectedGroupRows, modelGroupHistory], - check: [compareGroupRows, checkGroupHistory], - grammar: [cases, snapshots], -}) - oracleProperty({ id: `compiler.group-by.batch`, law: { diff --git a/packages/db/tests/query/includes-collection-driver-oracle.ts b/packages/db/tests/query/includes-collection-driver-oracle.ts index ac7221fc92..100bfa0a17 100644 --- a/packages/db/tests/query/includes-collection-driver-oracle.ts +++ b/packages/db/tests/query/includes-collection-driver-oracle.ts @@ -12,7 +12,6 @@ import { materialize, toArray, } from '../../src/query/index.js' -import { declareOracle } from '../oracle-declaration.js' import { createControlledCollection } from './includes-collection-support.js' import type { Collection } from '../../src/collection/index.js' import type { TraceDriver } from '../trace-runner.js' @@ -219,11 +218,6 @@ export function checkFacadeIntervals( expect(facade.toArray, `retired facade remains empty`).toEqual([]) } } -declareOracle({ - model: [recompute, advanceFacadeIntervals], - check: [checkFacadeIntervals], -}) - export function createCollectionDriver( initialParents: ReadonlyArray, initialChildren: ReadonlyArray, diff --git a/packages/db/tests/query/includes-scope-identity-oracle.ts b/packages/db/tests/query/includes-scope-identity-oracle.ts index 621c4bdf09..61b740c0c1 100644 --- a/packages/db/tests/query/includes-scope-identity-oracle.ts +++ b/packages/db/tests/query/includes-scope-identity-oracle.ts @@ -1,5 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from '../oracle-declaration.js' import { createCollection } from '../../src/collection/index.js' import { createFilterFunctionFromExpression } from '../../src/collection/change-events.js' import { @@ -851,5 +850,3 @@ export function sortedRequests(sources: ScopedSources) { notes: [...sources.notes.requests].sort(), } } - -declareOracle({ model: [recomputeScopedRows] }) diff --git a/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts b/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts index f54e49c521..364f823194 100644 --- a/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts +++ b/packages/db/tests/query/virtual-row-fields-grammar-oracle.ts @@ -4,7 +4,6 @@ * Composition changes where that same syntax is consumed. Each receiver owns * its model and legal boundary-specific extensions. */ -import { declareOracle } from '../oracle-declaration.js' export const metadataProjectionShapes = [ `structured-projection`, @@ -39,11 +38,3 @@ export const metadataCollisionCases = [ : [false] ).map((authoredPending) => ({ collision, authoredPending })), ) - -declareOracle({ - grammar: [ - metadataProjectionShapes, - metadataCompositions, - metadataCollisionCases, - ], -}) diff --git a/packages/db/tests/query/virtual-row-fields-input-oracle.ts b/packages/db/tests/query/virtual-row-fields-input-oracle.ts index 1fef7bc0c1..e7f4a95322 100644 --- a/packages/db/tests/query/virtual-row-fields-input-oracle.ts +++ b/packages/db/tests/query/virtual-row-fields-input-oracle.ts @@ -18,7 +18,6 @@ * objects/descriptors and same-key overlapping transactions are outside it. */ import { isDeepStrictEqual } from 'node:util' -import { declareOracle } from '../oracle-declaration.js' import { virtualFieldNames } from './virtual-row-fields-support.js' import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' import { @@ -285,19 +284,3 @@ export function metadataOutputViolations( ? [] : [`derived ordinary output differs from the callback-input law`] } - -declareOracle({ - model: [ - expectedSourceVirtualFields, - expectedMetadataInput, - expectedMetadataRecord, - ], - check: [metadataInputViolations, metadataOutputViolations], - grammar: [ - metadataInputDescriptions, - metadataCallbackHistories, - metadataCallbackReceiverHistories, - metadataInlineHistories, - metadataCompilerCacheHistories, - ], -}) diff --git a/packages/db/tests/query/virtual-row-fields-source-oracle.ts b/packages/db/tests/query/virtual-row-fields-source-oracle.ts index 55a0b74c87..f2aec5f867 100644 --- a/packages/db/tests/query/virtual-row-fields-source-oracle.ts +++ b/packages/db/tests/query/virtual-row-fields-source-oracle.ts @@ -1,5 +1,3 @@ -import { declareOracle } from '../oracle-declaration.js' - /** * Source metadata is a source fact, separate from output-row attribution. * VirtualRowProps supplies the authored key and Collection ID. An optimistic @@ -21,5 +19,3 @@ export function expectedSourceMetadata( $origin: pending ? (`local` as const) : appliedOrigin, } } - -declareOracle({ model: [expectedSourceMetadata] }) From 50ef69672d4b5bdff9a8797a25439cb2e21830bc Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:37:23 -0700 Subject: [PATCH 010/116] test(sqlite): delete declareOracle calls whose files already register through oracleProperty M1 for the sqlite group's companion modules and redundant declarations. Co-authored-by: Isaac --- .../tests/electric-convergence-oracle.ts | 2 -- .../tests/opfs-open-outcome-oracle.ts | 6 ------ .../tests/opfs-page-lifecycle-oracle.test.ts | 5 ----- .../tests/opfs-request-order-oracle.test.ts | 5 ----- .../tests/opfs-request-order-oracle.ts | 5 ----- .../tests/reload-class-oracle.test.ts | 7 ------- .../tests/shared-driver-fairness-oracle.ts | 8 -------- .../tests/collection-setup-reopen-oracle.test.ts | 6 ------ .../tests/persisted-demand-coverage-model-oracle.ts | 2 -- ...rsisted-optimistic-history-oracle.property.test.ts | 6 ------ .../tests/persisted-readiness-oracle.test.ts | 5 ----- .../tests/persisted-reload-replacement-oracle.test.ts | 7 ------- .../tests/persisted-sync-problem-oracle.test.ts | 5 ----- .../tests/reload-class-oracle.ts | 5 ----- .../reserved-commit-sqlite-adapter-oracle.test.ts | 6 ------ .../tests/shared-logical-scheduling-oracle.test.ts | 7 ------- .../tests/sqlite-core-adapter-oracle.test.ts | 7 ------- .../tests/sqlite-resume-snapshot-oracle.test.ts | 11 ----------- .../tests/temporal-value-oracle.ts | 2 -- .../tests/reload-class-oracle.test.ts | 7 ------- .../tests/op-sqlite-alias-oracle.ts | 7 ------- .../tests/op-sqlite-driver-oracle.test.ts | 6 ------ 22 files changed, 127 deletions(-) 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 5d0d04697a..805e5d0729 100644 --- a/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts @@ -17,7 +17,6 @@ * ../e2e/electric-source-prefix-oracle.opfs.spec.ts and its page module. * Each driver calls this comparison after recovery and complete source delivery. */ -import { declareOracle } from '../../db/tests/oracle-declaration' type SourceRow = { id: string | number; label: string } @@ -57,4 +56,3 @@ export function sourceHeadViolation( // 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/opfs-open-outcome-oracle.ts b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.ts index 79d8fab217..8c7a769eef 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 @@ -35,7 +35,6 @@ * than the WASM response belong to the OPFS page lifecycle oracle. */ import { expect } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' /* * ## History grammar @@ -286,8 +285,3 @@ export function describeHistory(history: OpenHistory): string { // 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], - grammar: [openHistories], -}) 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 072e368f19..f60528dcf3 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 @@ -7,7 +7,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' import { openBrowserWASQLiteOPFSDatabase } from '../src/opfs-database' import { ControlledWorker, @@ -912,10 +911,6 @@ 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({ - check: [runFailureHistory], - model: [expectedCloseSettlements], -}) // Each named helper keeps its independent settlement model and assertions at // the original cuts; native registration changes only campaign mechanics. const lifecycleLaw = { 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 e859af0dc3..85fce7d23d 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 @@ -20,7 +20,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' import { openBrowserWASQLiteOPFSDatabase } from '../src/opfs-database' import { findRequestOrderViolation, @@ -251,10 +250,6 @@ const requestOrderHistory = fc }), ) const requestOrderProperty = `opfs.request-order` -declareOracle({ - check: [runRequestOrderHistory], - grammar: [requestOrderHistory], -}) const requestOrderLaw = { statement: `The existing request-order model judges ordered request completion, close, and late execute behavior through the real worker module.`, authority: `The shared opfs-request-order-oracle.ts contract and worker protocol.`, 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 cd27ae4e30..2dd8b85d07 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 @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration.js' /** * # Does an OPFS connection answer requests in order and close after them? * @@ -231,7 +230,3 @@ 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/reload-class-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/reload-class-oracle.test.ts index f2ef10e942..c4c75b9acd 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,6 @@ import { join } from 'node:path' import BetterSqlite3 from 'better-sqlite3' import { onTestFinished } from 'vitest' import { oracleProperty, pass, violation } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedReload, fixedReloadHistories, @@ -172,12 +171,6 @@ function checkRecordedReload( ) } -declareOracle({ - model: [model], - check: [check, checkRecordedReload], - grammar: [reloadHistoryArb], -}) - oracleProperty({ id: `sqlite-reload.class`, law: { 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 2255b9a168..d2f6073aee 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 @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration.js' /** * # When does a cold hydrate get a turn on a shared SQLite driver? * @@ -967,10 +966,3 @@ export async function observeSharedDriverFairness( // 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, - findSharedDriverFairnessLawViolations, - ], - model: [createFairnessReference], -}) 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 68887bd1b4..7103f98ab0 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 @@ -6,7 +6,6 @@ import { 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' @@ -475,11 +474,6 @@ function checkReopen( ) } -declareOracle({ - check: [checkReopen], - model: [setupModel], -}) - oracleProperty({ id: `sqlite-persistence.setup-reopen`, law: { 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 cc858f25a5..aaf3039bb1 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 @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' import type { CollectionStatus, LoadSubsetOptions } from '@tanstack/db' /** @@ -556,4 +555,3 @@ export function coverageViolations( // 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-optimistic-history-oracle.property.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-optimistic-history-oracle.property.test.ts index f598f9cf9a..3274dc0379 100644 --- 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 @@ -15,7 +15,6 @@ import { 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' @@ -265,11 +264,6 @@ const abortReleasingHold = ( ] } -declareOracle({ - check: [checkOptimisticHistory], - model: [optimisticHistoryModel], -}) - oracleProperty({ id: `sqlite-persistence.optimistic-history`, law: { 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 86b70baf73..a7df1fa117 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 @@ -1,6 +1,5 @@ import { createLiveQueryCollection } from '@tanstack/db' import { afterAll, afterEach, beforeEach, expect, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -419,10 +418,6 @@ function describe_(events: ReadonlyArray): string { ) .join(` > `) } -declareOracle({ - model: [expected], - grammar: [histories], -}) // The existing helper retains its independent expected() model and every // observation-time assertion. Native registration transports its judgment. oracleProperty({ 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 a5b697f82c..f633ac94a7 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 @@ -1,6 +1,5 @@ import { createCollection } from '@tanstack/db' import { afterAll, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedCollectionOptions } from '../src/index.js' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import type { @@ -338,9 +337,3 @@ oracleProperty({ driver: drive, check, }) - -declareOracle({ - model: [expected], - check: [check], - grammar: [histories], -}) 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 2b71102878..515aad32da 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 @@ -1,6 +1,5 @@ import { createCollection } from '@tanstack/db' import { afterAll, expect, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedCollectionOptions } from '../src/index.js' import { oracleProperty } from '../../db/tests/oracle-property' import { @@ -581,10 +580,6 @@ async function judgeHistory(history: (typeof histories)[number]) { await collection.cleanup().catch(() => undefined) } } -declareOracle({ - model: [expected], - grammar: [histories], -}) // The existing helper retains its independent expected() model and every // observation-time assertion. Native registration transports its judgment. oracleProperty({ 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 438e5db6f4..4f5447e176 100644 --- a/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts @@ -104,7 +104,6 @@ */ import fc from 'fast-check' import { createCollection, createLiveQueryCollection } from '@tanstack/db' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { SQLiteCorePersistenceAdapter, createPersistedTableName, @@ -1693,7 +1692,3 @@ 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, expectedEagerRowScans], -}) 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 b095ac1dff..0f742f758b 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 @@ -41,7 +41,6 @@ import { DatabaseSync } from 'node:sqlite' import { createCollection } from '@tanstack/db' import { afterEach, describe, expect } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { SQLITE_DRIVER_SHARED_LOGICAL_SCHEDULING_KEY, SQLiteCorePersistenceAdapter, @@ -489,11 +488,6 @@ describe('reserved commits across real-adapter hydrations', () => { }).toEqual(expected.observation), ) } - declareOracle({ - check: [runAdapterHistory], - model: [expectedAdapterOutcome], - grammar: [adapterHistories], - }) oracleProperty({ id: `sqlite.reserved-commit-hydration`, law: { 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 a2414ff5a4..5294467038 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 @@ -47,7 +47,6 @@ */ import fc from 'fast-check' import { createSQLiteCorePersistenceAdapter } from '../src' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -135,12 +134,6 @@ function check( ) } -declareOracle({ - model: [expected], - grammar: [historyArbitrary], - check: [check], -}) - oracleProperty({ id: `sqlite-persistence.logical-scheduling`, law: { 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 bd7e52ebde..6530fc696c 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 @@ -26,13 +26,11 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { bigintBoundary, chunkBoundary, dual, escapedStringCase, - expectedBindingRows, legalSpec, mixed, nested, @@ -46,11 +44,6 @@ import type { Todo, } from './sqlite-core-adapter-support' -declareOracle({ - check: [expectResetResumeLaw, violation], - model: [expectedResetResumeMetadata, expectedBindingRows], -}) - type ResetResumeHistory = { fromSchemaVersion: number rows: Array 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 ec13769518..5436e78a0d 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 @@ -9,7 +9,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' import { SQLiteCorePersistenceAdapter, createPersistedTableName, @@ -1221,16 +1220,6 @@ describe(`SQLite resume snapshots`, () => { // models of retained generations and claim intervals. The fixed and generated // cases share those checks. The startup grammar is the generator whose coverage // the fixed startup corpus calibrates. - declareOracle({ - check: [ - assertRetiredStorageHistory, - assertManagedClaimReadHistory, - runProgressHistory, - ], - model: [expectedRetainedGenerations], - grammar: [startupHistoryArbitrary, progressHistory], - }) - oracleProperty({ id: `sqlite-resume.startup-generation`, law: { 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 8c718247d8..8b4a13dd0b 100644 --- a/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/temporal-value-oracle.ts @@ -8,7 +8,6 @@ */ import { Temporal } from 'temporal-polyfill' import { IR } from '@tanstack/db' -import { declareOracle } from '../../db/tests/oracle-declaration.js' export { Temporal } @@ -184,4 +183,3 @@ 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/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts b/packages/electron-db-sqlite-persistence/tests/reload-class-oracle.test.ts index 24c1feb1dd..c43e0a36bb 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,6 @@ import { onTestFinished } from 'vitest' import { createNodeSQLitePersistence } from '@tanstack/node-db-sqlite-persistence' import { BetterSqlite3SQLiteDriver } from '../../node-db-sqlite-persistence/src/node-driver' import { oracleProperty, pass, violation } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedReload, fixedReloadHistories, @@ -257,12 +256,6 @@ function checkRecordedReload( ) } -declareOracle({ - model: [model], - check: [check, checkRecordedReload], - grammar: [reloadHistoryArb], -}) - oracleProperty({ id: `sqlite-reload.class.electron`, law: { diff --git a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-alias-oracle.ts b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-alias-oracle.ts index e1c432c62c..b9f7eb31a1 100644 --- a/packages/react-native-db-sqlite-persistence/tests/op-sqlite-alias-oracle.ts +++ b/packages/react-native-db-sqlite-persistence/tests/op-sqlite-alias-oracle.ts @@ -5,7 +5,6 @@ * separate observation because exact row data alone does not retain it. */ import { fc } from '@fast-check/vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, pass, violation } from '../../db/tests/oracle-property.js' import { isRecord, @@ -144,9 +143,3 @@ export function aliasQueryCase(aliases: ReadonlyArray): { ), } } - -declareOracle({ - check: [checkAliasRows, checkAliasGrammar], - model: [aliasQueryCase], - grammar: [writeResultAliasesArbitrary, statementResultAliasesArbitrary], -}) 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 a0340a29c4..e590304238 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,5 +1,4 @@ import { afterEach, beforeEach } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -570,8 +569,3 @@ oracleProperty({ judge(aliases, trace, expected, `statement`), calibrations: [...calibrations, constructionReachCalibration], }) - -declareOracle({ - model: [aliasQueryCase], - grammar: [writeResultAliasesArbitrary, statementResultAliasesArbitrary], -}) From b2b2f09cb5900c42160cb75c9d4911e4ea4038df Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:37:57 -0700 Subject: [PATCH 011/116] test(frameworks-ivm): delete declareOracle calls and their dead parts Removes every declareOracle call in the framework adapters, db-ivm, and scripts groups (M1). The parts that only those calls named, and that nothing else references, are deleted: expectCyclicPair, expectDigestsMatchReference, and distinctBucketHistory. The owns lists that the deleted calls named move to oracleProperty in later commits. Co-authored-by: Isaac --- ...incrementalization-oracle.property.test.ts | 5 --- .../distinct-incrementalization-oracle.ts | 16 -------- .../db-ivm/tests/distinct-property-oracle.ts | 3 -- ...nct-representative-oracle.property.test.ts | 6 --- ...hash-failure-retry-oracle.property.test.ts | 6 --- .../tests/hash-graph-oracle.property.test.ts | 7 ---- .../hash-identity-oracle.property.test.ts | 18 --------- .../hash-mixed-graph-oracle.property.test.ts | 8 ---- .../tests/hash-mixed-graph-shared-oracle.ts | 7 ---- .../db-ivm/tests/hash-oracle.property.test.ts | 6 --- packages/db-ivm/tests/hash-value-oracle.ts | 7 ---- ...ementalization-law-oracle.property.test.ts | 6 --- .../tests/incrementalization-law-oracle.ts | 5 --- .../index-refinement-oracle.property.test.ts | 6 --- .../murmur-stream-oracle.property.test.ts | 16 +------- .../tests/murmur-stream-reference-oracle.ts | 3 -- .../topKWithFractionalIndex-oracle.test.ts | 5 --- .../tests/operators/topk-property-oracle.ts | 2 - .../topk-relation-calibration.test.ts | 2 - .../tests/operators/topk-relation-oracle.ts | 3 -- .../topk-support-window-oracle.test.ts | 13 ------- ...tructural-identity-oracle.property.test.ts | 7 ---- .../tests/temporal-group-key-oracle.test.ts | 5 --- .../tests/bound-options-oracle.test.ts | 7 ---- .../tests/bound-schema-options-oracle.test.ts | 8 +--- .../tests/window-render-oracle.test.tsx | 7 ---- .../bound-schema-options-oracle.test.tsx | 16 +------- .../cache-first-registration-oracle.test.tsx | 11 ------ .../tests/cache-options-oracle.test.tsx | 7 ---- .../tests/client-window-oracle.test.tsx | 6 +-- .../tests/convex-lazy-paging-oracle.test.tsx | 6 --- .../descriptor-query-binding-oracle.test.tsx | 5 --- .../descriptor-start-release-oracle.test.tsx | 7 ---- .../tests/effect-callback-oracle.test.tsx | 6 --- ...infinite-query-render-cuts-oracle.test.tsx | 11 ------ .../tests/source-id-reuse-oracle.test.tsx | 16 +------- .../react-db/tests/ssr-effects-oracle.test.ts | 5 --- .../ssr-runtime-error-handoff-oracle.test.tsx | 7 ---- .../suspense-indexing-config-oracle.test.tsx | 10 ----- .../tests/suspense-lifecycle-oracle.test.tsx | 16 +------- .../suspense-local-options-oracle.test.tsx | 5 --- ...PacedMutations-render-cuts-oracle.test.tsx | 7 ---- .../tests/window-render-oracle.test.tsx | 10 +---- .../tests/row-materialization-oracle.test.tsx | 37 ------------------- .../tests/cache-options-oracle.svelte.test.ts | 6 --- .../tests/client-window-oracle.svelte.test.ts | 16 +------- ...riptor-start-release-oracle.svelte.test.ts | 7 ---- .../ssr-runtime-error-oracle.svelte.test.ts | 6 --- ...r-snapshot-admission-oracle.svelte.test.ts | 6 --- ...ssr-snapshot-handoff-oracle.svelte.test.ts | 6 --- .../useLiveQuery-publication-oracle.test.ts | 6 --- .../release-comments-oracle.property.test.js | 6 --- 52 files changed, 8 insertions(+), 423 deletions(-) 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 24e0e2e383..bf974d32fe 100644 --- a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts @@ -11,7 +11,6 @@ import { hash } from '../src/hashing/hash.js' import { distinct } from '../src/operators/distinct.js' import { map } from '../src/operators/map.js' import { oracleRoute } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { oracleProperty } from '../../db/tests/oracle-property.js' import { assertUnaryIncrementalization, @@ -48,10 +47,6 @@ const policy = { clone: (value: DistinctValue) => ({ ...value }), } -declareOracle({ - check: [assertUnaryIncrementalization, checkDistinctSnapshot], -}) - type Plan = { worlds: DistinctHistory; requiredStates: ReadonlyArray } type CellOptions = Omit< OraclePropertyOptions, Plan>, diff --git a/packages/db-ivm/tests/distinct-incrementalization-oracle.ts b/packages/db-ivm/tests/distinct-incrementalization-oracle.ts index fa24b64544..d04c262ac6 100644 --- a/packages/db-ivm/tests/distinct-incrementalization-oracle.ts +++ b/packages/db-ivm/tests/distinct-incrementalization-oracle.ts @@ -12,7 +12,6 @@ * ordering, readiness, or intermediate source-transaction publications. */ import { fc } from '@fast-check/vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { OracleComparisonMismatch } from './incrementalization-law-oracle.js' import type { Weighted } from './incrementalization-law-oracle.js' @@ -168,12 +167,6 @@ export function checkDistinctSnapshot( ) } -declareOracle({ - model: [recomputeDistinct], - grammar: [distinctHistory], - check: [checkDistinctSnapshot], -}) - /** * Wider bucket histories separate selected identity from the emitted payload. * The model's member numbers name fixture-defined unequal selections; variant @@ -234,10 +227,6 @@ export function resolveDistinctBucketRecipe([ ] } -export const distinctBucketHistory = distinctBucketRecipe.map( - resolveDistinctBucketRecipe, -) - export const DISTINCT_BUCKET_REACH_STATES = [ `unequal unselected payloads`, `three colliding members`, @@ -360,8 +349,3 @@ export function checkDistinctRepresentatives( expected, ) } - -declareOracle({ - grammar: [distinctBucketHistory], - check: [checkDistinctRepresentatives], -}) diff --git a/packages/db-ivm/tests/distinct-property-oracle.ts b/packages/db-ivm/tests/distinct-property-oracle.ts index 2b7f7473d6..4914d8f4e4 100644 --- a/packages/db-ivm/tests/distinct-property-oracle.ts +++ b/packages/db-ivm/tests/distinct-property-oracle.ts @@ -4,7 +4,6 @@ * cause survives. Missing construction reach is checked last at an unrecorded * checkpoint, which cannot turn a graph or setup error into a semantic kill. */ -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { pass, violation } from '../../db/tests/oracle-property.js' import { OracleComparisonMismatch } from './incrementalization-law-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' @@ -76,5 +75,3 @@ export function checkDistinctCampaign( ) return pass } - -declareOracle({ check: [checkDistinctCampaign] }) 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 76e5161c5e..4bc5f4f68a 100644 --- a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts @@ -13,7 +13,6 @@ import { expect, it } from 'vitest' import { D2 } from '../src/d2.js' import { hash } from '../src/hashing/hash.js' import { distinct } from '../src/operators/distinct.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { oracleReplayTest } from '../../db/tests/oracle-config.js' import { oracleProperty } from '../../db/tests/oracle-property.js' import { @@ -41,11 +40,6 @@ import type { Weighted } from './incrementalization-law-oracle.js' const family = distinctCollisionFamily(hash) -declareOracle({ - check: [checkDistinctRepresentatives], - grammar: [distinctBucketRecipe], -}) - function drive(worlds: DistinctBucketHistory, split: boolean) { const graph = new D2() const input = graph.newInput<[number, DistinctBucketRow]>() 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 d88d2b2849..f28a4b37fe 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 @@ -11,7 +11,6 @@ */ import fc from 'fast-check' import { hash } from '../src/hashing/hash' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -163,11 +162,6 @@ function enforceConstructed( })) } -declareOracle({ - model: [expectedFailedReads, expectedSuccessReads, expectedTrace], - check: [checkRetryTrace], -}) - // Hash the same root on every attempt, then at the successful and cached // checkpoints. The rejecting getter throws the exact source error. Observing // its identity as a Boolean keeps substituted errors distinguishable even 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 d6b6825e88..82e96b9842 100644 --- a/packages/db-ivm/tests/hash-graph-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-graph-oracle.property.test.ts @@ -6,7 +6,6 @@ * carriers, collision freedom, and work bounds remain separate owners. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { oracleProperty, pass, @@ -150,12 +149,6 @@ function checkCampaign( return pass } -declareOracle({ - model: [isAcyclic, expectedGraph, expectedCampaign], - check: [checkGraph, checkCampaign], - grammar: [graphArbitrary, historyArbitrary], -}) - oracleProperty({ id: `ivm.hash-graph`, law: { 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 c0fd53ce7a..215fdd1180 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -11,7 +11,6 @@ import { oracleReplayTest, oracleRoute, } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -870,27 +869,10 @@ function checkCyclicPair( return pass } -function expectCyclicPair(plan: CyclicPair): void { - const trace: Array = [] - driveCyclicPair(plan, (checkpoint, value) => - trace.push({ checkpoint, value }), - ) - const verdict = checkCyclicPair(plan, trace, expectedCyclicPair()) - if (verdict.kind === `violation`) - expect(verdict.values?.actual, verdict.difference).toBe( - verdict.values?.expected, - ) -} - // 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({ - check: [expectPair, expectCyclicPair, checkPair, checkCyclicPair], - model: [identity, canon, expectedPair, expectedCyclicPair], - grammar: [pairArb, cyclicArb], -}) // --------------------------------------------------------------------------- // Campaigns. Each generated property has a fixed campaign and a random 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 10cdd5b059..10545cfbf7 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 @@ -6,7 +6,6 @@ * references, work limits, and failed-traversal atomicity stay with other owners. */ import { hash } from '../src/hashing/hash' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { oracleReplayRequested } from '../../db/tests/oracle-config.js' import { oracleProperty, @@ -20,7 +19,6 @@ import { expectedMixedGraph, graphArbitrary, isAcyclic, - unfoldGraph, } from './hash-mixed-graph-shared-oracle' import type { ConstructedHistory, @@ -321,12 +319,6 @@ function checkCampaign( return pass } -declareOracle({ - model: [isAcyclic, unfoldGraph, expectedCampaign], - check: [checkMixedGraph, checkCampaign], - grammar: [graphArbitrary], -}) - oracleProperty({ id: `ivm.hash-mixed-graph`, law: { diff --git a/packages/db-ivm/tests/hash-mixed-graph-shared-oracle.ts b/packages/db-ivm/tests/hash-mixed-graph-shared-oracle.ts index 28313d689c..9a233bf285 100644 --- a/packages/db-ivm/tests/hash-mixed-graph-shared-oracle.ts +++ b/packages/db-ivm/tests/hash-mixed-graph-shared-oracle.ts @@ -30,7 +30,6 @@ import fc from 'fast-check' import { expect } from 'vitest' import { hash } from '../src/hashing/hash' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, pass, violation } from '../../db/tests/oracle-property.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' @@ -270,9 +269,3 @@ export function expectMixedGraph( `pass`, ) } - -declareOracle({ - model: [isAcyclic, unfoldGraph, expectedMixedGraph], - check: [checkMixedGraph], - grammar: [graphArbitrary], -}) diff --git a/packages/db-ivm/tests/hash-oracle.property.test.ts b/packages/db-ivm/tests/hash-oracle.property.test.ts index e4e289a5fd..79c9cfcd93 100644 --- a/packages/db-ivm/tests/hash-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-oracle.property.test.ts @@ -2,7 +2,6 @@ import { describe } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' import { oracleProperty } from '../../db/tests/oracle-property.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { arbitraryNonIndexEntries, checkHashComparisons, @@ -51,11 +50,6 @@ const arbitrarySimpleArray = fc.array(fc.integer(), { maxLength: 10 }) // The flat-value law remains a differential comparison between independent // equivalent constructors; each cell records its original pair of results. -declareOracle({ - check: [checkHashComparisons], - model: [comparisonReferences], - grammar: [arbitraryNonIndexEntries], -}) // 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` diff --git a/packages/db-ivm/tests/hash-value-oracle.ts b/packages/db-ivm/tests/hash-value-oracle.ts index d6368aa943..d0ebac1548 100644 --- a/packages/db-ivm/tests/hash-value-oracle.ts +++ b/packages/db-ivm/tests/hash-value-oracle.ts @@ -2,7 +2,6 @@ import { expect } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash.js' import { differs, pass } from '../../db/tests/oracle-property.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' import type { HashValue } from './hash-support.js' @@ -109,9 +108,3 @@ export function expectPermutedHashes( verdict.kind === `violation` ? verdict.difference : undefined, ).toEqual(pass) } - -declareOracle({ - check: [checkHashComparisons, expectPermutedHashes], - model: [comparisonReferences], - grammar: [arbitraryNonIndexEntries], -}) 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 991434fb3c..82a80c2b75 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -21,7 +21,6 @@ import { oracleRoute, oracleRuns, } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -553,11 +552,6 @@ const orderedHistory: fc.Arbitrary>> = fc // generated steps reach row promotions, which the ordered laws need. // What this oracle owns: the gap report judges these entries, and their // reference trees within the package, by this file's tests. -declareOracle({ - grammar: [orderedHistory], - check: [checkIncrementalization], - model: [incrementalizationReferences], -}) function firstThree(input: Weighted): Weighted { return keyedIdentity(input) diff --git a/packages/db-ivm/tests/incrementalization-law-oracle.ts b/packages/db-ivm/tests/incrementalization-law-oracle.ts index 7b1fc760ca..706a5da6fb 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.ts @@ -1,5 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { D2 } from '../src/d2.js' import { MultiSet } from '../src/multiset.js' import { output } from '../src/operators/output.js' @@ -545,7 +544,3 @@ export function assertBinaryIncrementalization( splitDeliveries: split.deliveries, } } - -declareOracle({ - check: [assertUnaryIncrementalization, assertBinaryIncrementalization], -}) 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 73f85d916b..31a41341d0 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -59,7 +59,6 @@ import { oracleReplayTest, oracleRoute, } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -307,11 +306,6 @@ function expectRefinement( // 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({ - check: [expectRefinement, checkIndex], - model: [expectedIndex, indexExpectations], - grammar: [historyArbitrary], -}) // --------------------------------------------------------------------------- // Campaigns. The fixed and random campaigns run the same property, grammar, 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 cddefca699..6371c7d416 100644 --- a/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts +++ b/packages/db-ivm/tests/murmur-stream-oracle.property.test.ts @@ -1,11 +1,10 @@ -import { afterAll, beforeAll, expect, vi } from 'vitest' +import { afterAll, beforeAll, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' /** * # Does the murmur stream hash the bytes it is given? @@ -86,19 +85,6 @@ function digests( }) } -// The law of the byte framing: the stream digests each history as the -// byte-at-a-time reference stream does, for every prefix. -function expectDigestsMatchReference( - Stream: typeof Current, - Model: typeof Current, - chunks: Array, -): void { - expect(digests(Stream, chunks)).toEqual(digests(Model, chunks)) -} -declareOracle({ - check: [expectDigestsMatchReference], -}) - oracleProperty({ id: `ivm.murmur-stream`, law: { diff --git a/packages/db-ivm/tests/murmur-stream-reference-oracle.ts b/packages/db-ivm/tests/murmur-stream-reference-oracle.ts index d745ea5f50..697400c1e0 100644 --- a/packages/db-ivm/tests/murmur-stream-reference-oracle.ts +++ b/packages/db-ivm/tests/murmur-stream-reference-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration.js' /** * Reference model for the murmur stream oracle: the stream as it was before * it wrote whole words, with one `writeByte` call per byte. Load it after @@ -105,5 +104,3 @@ export class ReferenceByteStream { return this.hash >>> 0 } } - -declareOracle({ model: [ReferenceByteStream] }) diff --git a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts index 5dd277cbf7..1056f46d64 100644 --- a/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topKWithFractionalIndex-oracle.test.ts @@ -6,7 +6,6 @@ import { MultiSet } from '../../src/multiset.js' import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalIndex.js' import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' import { join, map, output } from '../../src/operators/index.js' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' import { oracleProperty } from '../../../db/tests/oracle-property.js' import { TopKRelation } from './topk-relation-oracle.js' import { @@ -25,10 +24,6 @@ import { * collisions. Multiple groups and downstream acquisition remain outside this * owner. The ordinary examples live in topKWithFractionalIndex-examples.test.ts. */ -declareOracle({ - check: [TopKRelation, checkTopKComparisons], - model: [topKReferences], -}) afterEach(() => vi.restoreAllMocks()) diff --git a/packages/db-ivm/tests/operators/topk-property-oracle.ts b/packages/db-ivm/tests/operators/topk-property-oracle.ts index 280fbc4979..5d47ab5b2a 100644 --- a/packages/db-ivm/tests/operators/topk-property-oracle.ts +++ b/packages/db-ivm/tests/operators/topk-property-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../../../db/tests/oracle-declaration.js' import { differs, pass } from '../../../db/tests/oracle-property.js' import type { Observation, Verdict } from '../../../db/tests/oracle-property.js' import type { TopKComparisonRecorder } from './topk-relation-oracle.js' @@ -69,4 +68,3 @@ export function topKRecorder( }, } } -declareOracle({ check: [checkTopKComparisons], model: [topKReferences] }) diff --git a/packages/db-ivm/tests/operators/topk-relation-calibration.test.ts b/packages/db-ivm/tests/operators/topk-relation-calibration.test.ts index dae71f9294..c4d7cf970c 100644 --- a/packages/db-ivm/tests/operators/topk-relation-calibration.test.ts +++ b/packages/db-ivm/tests/operators/topk-relation-calibration.test.ts @@ -1,13 +1,11 @@ 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] }) /** * # How do we know the top-K oracle can detect a fault? diff --git a/packages/db-ivm/tests/operators/topk-relation-oracle.ts b/packages/db-ivm/tests/operators/topk-relation-oracle.ts index 8d7b00cf99..f4b5788274 100644 --- a/packages/db-ivm/tests/operators/topk-relation-oracle.ts +++ b/packages/db-ivm/tests/operators/topk-relation-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' import { MessageTracker } from '../test-utils.js' import type { MultiSet } from '../../src/multiset.js' @@ -103,5 +102,3 @@ export class TopKMessageTracker< super.addMessage(message) } } - -declareOracle({ check: [TopKRelation] }) 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 85ccac499c..67c635b9fa 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 @@ -11,7 +11,6 @@ import { oracleReplayTest, oracleRoute, } from '../../../db/tests/oracle-config.js' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' import { oracleProperty } from '../../../db/tests/oracle-property.js' import { atOracleCheckpoint } from '../../../db/tests/oracle-failure-fidelity.js' import { @@ -23,11 +22,6 @@ import { TopKRelation } from './topk-relation-oracle.js' import type { TopKComparisonRecorder } from './topk-relation-oracle.js' import type { TopKWithFractionalIndexOptions } from '../../src/operators/topKWithFractionalIndex.js' -declareOracle({ - check: [TopKRelation, checkNumericWindow], - model: [selectedWindowKeys, requestedWindowChanged], -}) - /** * A top-K window selects support from a signed, typed-key relation. * @@ -384,8 +378,6 @@ class NumericWindowMismatch extends Error { } } -declareOracle({ grammar: [numericWindowInput] }) - describe.each([ { name: `array`, operator: topKWithFractionalIndex }, { name: `grouped`, operator: grouped }, @@ -732,11 +724,6 @@ function checkNegativeWindowHistory( }) } -declareOracle({ - grammar: [negativeWindowHistory], - check: [checkNegativeWindowHistory], -}) - // Rival interpretations must disagree with the rank predicate. Feed their // wrong rows to the same signed-output checker; no production helper supplies // the expected answer, and no output normalization removes the distinction. 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 88cb56bb9c..1932cf7613 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,6 @@ import { describe, expect, it as vitestIt } from 'vitest' import { fc } from '@fast-check/vitest' import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -431,12 +430,6 @@ function expectAgreement( ) } -declareOracle({ - check: [expectAgreement, checkAgreement], - model: [normal, expectedAgreement], - grammar: [specArbitrary, equalSpecPairArbitrary], -}) - function decisions(...values: Array): () => boolean { let index = 0 return () => values[index++] ?? false 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 f6558a8cc5..1958243ac0 100644 --- a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts +++ b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts @@ -8,7 +8,6 @@ import { output, serializeValue, } from '../src/index.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { oracleRoute } from '../../db/tests/oracle-config.js' /** @@ -80,10 +79,6 @@ function expectedGroupCounts(classes: ReadonlyArray): Array { } // 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() diff --git a/packages/preact-db/tests/bound-options-oracle.test.ts b/packages/preact-db/tests/bound-options-oracle.test.ts index 227439be5f..4c038d2f25 100644 --- a/packages/preact-db/tests/bound-options-oracle.test.ts +++ b/packages/preact-db/tests/bound-options-oracle.test.ts @@ -19,7 +19,6 @@ import { checkBoundOptions, } from '../../db/tests/live-query-bound-options-oracle' import { - declareOracle, differs, oracleProperty, pass, @@ -286,9 +285,3 @@ oracleProperty({ ), })), }) - -declareOracle({ - check: [checkBoundOptionHistory], - grammar: [histories], - model: [modelBoundOptions], -}) 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 3b54b0cb4b..8a50332ee1 100644 --- a/packages/preact-db/tests/bound-schema-options-oracle.test.ts +++ b/packages/preact-db/tests/bound-schema-options-oracle.test.ts @@ -7,7 +7,7 @@ */ import { act } from 'preact/test-utils' import { useLiveQuery } from '../src/useLiveQuery' -import { declareOracle, oracleProperty } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { boundSchemaGrammar, boundSchemaHistories, @@ -139,9 +139,3 @@ oracleProperty({ }, ], }) - -declareOracle({ - grammar: [histories], - model: [modelBoundSchemaReceiver], - check: [checkBoundSchemaReceiver], -}) diff --git a/packages/preact-db/tests/window-render-oracle.test.tsx b/packages/preact-db/tests/window-render-oracle.test.tsx index fd929728ba..e8f71e2022 100644 --- a/packages/preact-db/tests/window-render-oracle.test.tsx +++ b/packages/preact-db/tests/window-render-oracle.test.tsx @@ -26,7 +26,6 @@ import { } from '../../db/tests/conformance/window-history-oracle' import { OracleCheckpointFailure, - declareOracle, differs, oracleProperty, pass, @@ -417,9 +416,3 @@ for (const [id, remote, keyed, move] of renderProperties) { ], }) } - -declareOracle({ - grammar: [renderHistoryArbitrary], - model: [expectedRender], - check: [checkRender], -}) 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 7d219f12ba..eebf6328ee 100644 --- a/packages/react-db/tests/bound-schema-options-oracle.test.tsx +++ b/packages/react-db/tests/bound-schema-options-oracle.test.tsx @@ -10,11 +10,7 @@ import { act, renderHook } from '@testing-library/react' import { expect, test } from 'vitest' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' -import { - declareOracle, - oracleProperty, - oracleRoute, -} from '../../db/tests/oracle-config' +import { oracleProperty, oracleRoute } from '../../db/tests/oracle-config' import { boundSchemaGrammar, boundSchemaHistories, @@ -344,13 +340,3 @@ test.each(boundSchemaHistories)( } }, ) -declareOracle({ - grammar: [histories, boundSchemaHistories], - model: [modelBoundSchemaReceiver], - check: [ - checkBoundSchemaReceiver, - checkBoundSchemaMutation, - checkBoundSchemaRows, - checkBoundSchemaValues, - ], -}) 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 c44a335f79..7aff76f57c 100644 --- a/packages/react-db/tests/cache-first-registration-oracle.test.tsx +++ b/packages/react-db/tests/cache-first-registration-oracle.test.tsx @@ -36,7 +36,6 @@ import { checkCacheOptionRows, conflictingCacheOptions, } from '../../db/tests/live-query-cache-options-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, @@ -410,13 +409,3 @@ oracleProperty({ ), check: forwardAssertionJudgment, }) -declareOracle({ - check: [ - checkFirstRegistrationHistory, - checkCacheOptionAdmission, - checkCacheOptionPromise, - checkCacheOptionRows, - ], - model: [conflictingCacheOptions], - grammar: [cacheOptionHistories, firstReaders, secondReaders], -}) diff --git a/packages/react-db/tests/cache-options-oracle.test.tsx b/packages/react-db/tests/cache-options-oracle.test.tsx index 39008e456b..98fab641bb 100644 --- a/packages/react-db/tests/cache-options-oracle.test.tsx +++ b/packages/react-db/tests/cache-options-oracle.test.tsx @@ -29,7 +29,6 @@ import { checkCacheOptionRows, conflictingCacheOptions, } from '../../db/tests/live-query-cache-options-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -398,9 +397,3 @@ for (const path of paths) { ], }) } - -declareOracle({ - grammar: [histories], - model: [modelCacheOptions, conflictingCacheOptions], - check: [checkCacheOptions, checkCacheOptionAdmission, checkCacheOptionRows], -}) diff --git a/packages/react-db/tests/client-window-oracle.test.tsx b/packages/react-db/tests/client-window-oracle.test.tsx index 5f3f18839e..2b1e5bff56 100644 --- a/packages/react-db/tests/client-window-oracle.test.tsx +++ b/packages/react-db/tests/client-window-oracle.test.tsx @@ -33,7 +33,7 @@ import { windowRows, } from '../../db/tests/conformance/window-history-oracle' import { ScenarioLifetime } from '../../db/tests/conformance/scenario-lifetime' -import { declareOracle, oracleProperty } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -472,7 +472,3 @@ describe(`client page identity`, () => { }) }) }) -declareOracle({ - grammar: [clientWindowArbitrary], - check: [checkClientWindowHistory, checkClientSwitch], -}) 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 ec46b9ba78..49c07de0e1 100644 --- a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx +++ b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx @@ -12,7 +12,6 @@ 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])( @@ -118,8 +117,3 @@ 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 049d34563b..d041e53826 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -130,7 +130,6 @@ import { modelSubscriberStartCleanup, subscriberStartCleanupHistories, } from '../../db/tests/collection-subscription-lifecycle-grammar-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { renderSsr } from './descriptor-query-binding-support' import type { Row } from './descriptor-query-binding-support' import type { DehydratedRouterDbState } from '../../react-router-with-db/src' @@ -182,10 +181,6 @@ function expectedRows(rows: ReadonlyMap): Array { function applyModelWrite(model: Map, next: Row): void { model.set(next.id, { ...next }) } -declareOracle({ - check: [checkStreamCut], - model: [expectedRows, applyModelWrite], -}) describe(`standalone descriptor query binding`, () => { // A suspended render owns no provider demand. A later independent reader // does: ordinary subscriptions and preloads start exactly one sync run, 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 0da2cceecd..644676493c 100644 --- a/packages/react-db/tests/descriptor-start-release-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx @@ -16,7 +16,6 @@ import { descriptorStartHistories, expectedDescriptorStart, } from '../../db/tests/descriptor-start-release-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -169,9 +168,3 @@ it.each([ if (fault === `wrong-row`) actual.rows[2] = [{ id: `bad`, value: `bad` }] expect(() => checkDescriptorStart(actual, history, `cleaned`)).toThrow() }) - -declareOracle({ - check: [checkDescriptorStart, checkRecordedHistory], - 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 3e5e486a92..24bd6d750f 100644 --- a/packages/react-db/tests/effect-callback-oracle.test.tsx +++ b/packages/react-db/tests/effect-callback-oracle.test.tsx @@ -15,7 +15,6 @@ 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 { @@ -104,8 +103,3 @@ test.each(callbackHistories)( ) }, ) - -declareOracle({ - owns: [`src/useLiveQueryEffect.ts#useLiveQueryEffect`], - grammar: [callbackHistories], -}) 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 8d8bbfe7a8..859ac27a2b 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 @@ -39,7 +39,6 @@ import { createLiveQueryCollection, } 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' @@ -184,14 +183,6 @@ async function checkAbandonedRenderAcquisitions( ) } -declareOracle({ - owns: [ - `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, - `src/useLiveQuery.ts#useLiveQuery`, - ], - check: [checkAbandonedRenderAcquisitions], -}) - describe(`on-demand sources match useLiveQuery`, () => { it.each([ { form: `query` as const, wrapped: false }, @@ -226,8 +217,6 @@ function expectedReadyFirstCommit(count: number): { return { status: `ready`, ids: rows(count).map((row) => row.id) } } -declareOracle({ model: [expectedReadyFirstCommit] }) - describe(`agreement with useLiveQuery`, () => { it(`commits the same ready rows first as useLiveQuery for a synchronous source`, async () => { oracleRoute(`fixed history`) 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 d12ef7eb6b..97ff17eb66 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -62,11 +62,7 @@ import { DbProvider } from '../src/DbProvider' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' -import { - declareOracle, - oracleProperty, - oracleRoute, -} from '../../db/tests/oracle-config' +import { oracleProperty, oracleRoute } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -286,11 +282,6 @@ function checkRerender( ) } } -declareOracle({ - check: [checkRerender, checkReleaseHistory, checkBoundOptions], - model: [expectedCollision], - grammar: [boundOptionHistories], -}) /* * Release law. A hook in one DbClient scope first reads only its own source, * then rerenders into a rejected query that also reads a shared descriptor. @@ -927,11 +918,6 @@ it.each([ ).toThrow(), ) }) -declareOracle({ - check: [checkReleaseObservation], - model: [expectedReleaseObservation], - grammar: [releaseCauses, releaseHooks, releaseReaders], -}) describe(`React source ID reuse`, () => { it(`rejects a different active source with the same ID before exposing stale rows`, async () => { oracleRoute(`fixed history`) diff --git a/packages/react-db/tests/ssr-effects-oracle.test.ts b/packages/react-db/tests/ssr-effects-oracle.test.ts index af0d306601..2e8e72fdae 100644 --- a/packages/react-db/tests/ssr-effects-oracle.test.ts +++ b/packages/react-db/tests/ssr-effects-oracle.test.ts @@ -28,7 +28,6 @@ import { ssrSnapshotExamples, ssrSnapshotHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { OracleCheckpointFailure, differs, @@ -174,10 +173,6 @@ function checkRender( }) } -declareOracle({ - check: [checkRender], - grammar: [histories], -}) // The child process records native server facts. The same admission/diagnostic // judgment runs afterward, with runtime identity checked first as before. function checkEffects( 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 2ae0a56096..5b1147cdf3 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 @@ -29,7 +29,6 @@ import { pass, violation, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { DbProvider } from '../src/DbProvider' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveQuery } from '../src/useLiveQuery' @@ -303,12 +302,6 @@ function checkRuntimeTrace( return pass } -declareOracle({ - model: [expectedSsrRuntimeErrorViews], - check: [checkRuntimeTrace], - grammar: [ssrRuntimeErrorHistoryArbitrary], -}) - for (const hook of [`ordinary`, `infinite`] as const) { const law = { statement: `An SSR runtime-error view remains readable and survives JSON transport into the first hydration view; the committed browser view follows its own source outcome.`, 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 c976f4227e..09235abadb 100644 --- a/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx +++ b/packages/react-db/tests/suspense-indexing-config-oracle.test.tsx @@ -49,7 +49,6 @@ import { indexOptionAbsencePairs, } from '../../db/tests/conformance/indexing-config-oracle' import { expectUnorderedRows } from '../../db/tests/conformance/result-laws' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, @@ -63,15 +62,6 @@ import type { ReactNode } from 'react' import type { IndexSettings } from '../../db/tests/conformance/indexing-config-oracle' import type { Row } from '../../db/tests/conformance/contract' -declareOracle({ - grammar: [indexConfigurationPairs, indexOptionAbsencePairs], - model: [expectedIndexing], - check: [ - expectIndexingAdmission, - expectIndexingConfiguration, - expectIndependentIndexingOutputs, - ], -}) function createSameNameIndex() { return class SameNameIndex extends BasicIndex {} } diff --git a/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx b/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx index e7999819b9..5ba1f80e11 100644 --- a/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx +++ b/packages/react-db/tests/suspense-lifecycle-oracle.test.tsx @@ -44,7 +44,7 @@ import { expectUnorderedRows, } from '../../db/tests/conformance/result-laws' import { atOracleCheckpoint } from '../../db/tests/oracle-failure-fidelity' -import { declareOracle, oracleProperty } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -923,11 +923,6 @@ async function checkPreload( reach.reach(`restart ready`) }) } -declareOracle({ - model: [advance], - grammar: [retryArbitrary, replacementArbitrary, preloadArbitrary], - check: [compare, checkRetry, checkReplacement, checkPreload], -}) // Registration adapts the complete original helpers. Their model transitions, // public observations and assertions stay at the original host cuts. describe(`Suspense lifecycle histories`, () => { @@ -1986,12 +1981,3 @@ for (const scope of [`none`, `provider`, `explicit`] as const) timeout: 60000, }) }) -declareOracle({ - grammar: [siblingsArbitrary, siblingHistories], - check: [ - checkSiblings, - checkSiblingOwnership, - checkSiblingHealth, - checkSiblingRequestIsolation, - ], -}) 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 25330cf43f..9805a8ecab 100644 --- a/packages/react-db/tests/suspense-local-options-oracle.test.tsx +++ b/packages/react-db/tests/suspense-local-options-oracle.test.tsx @@ -30,7 +30,6 @@ import { equalLocalDeclarations, localOptionPairs, } from '../../db/tests/live-query-cache-options-oracle' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, @@ -43,10 +42,6 @@ import type { Collection, Context, InitialQueryBuilder } from '@tanstack/db' import type { UseLiveQueryConfig } from '../src/useLiveQuery' import type { ReactNode } from 'react' -declareOracle({ - grammar: [localOptionPairs], - check: [checkLocalOutputOwnership], -}) const histories = localOptionPairs.flatMap((history, cell) => // Callback/reference and scalar equality are distinct premises. Keep the // full ready matrix; cross only these representatives with held delivery. 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 a550403e5e..91cadf06d1 100644 --- a/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx @@ -38,7 +38,6 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' 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, @@ -304,9 +303,3 @@ describe.each([false, true])( }) }, ) - -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 716335908c..fd45ca312b 100644 --- a/packages/react-db/tests/window-render-oracle.test.tsx +++ b/packages/react-db/tests/window-render-oracle.test.tsx @@ -30,7 +30,7 @@ import { windowRows, } from '../../db/tests/conformance/window-history-oracle' import { atOracleCheckpoint } from '../../db/tests/oracle-failure-fidelity' -import { declareOracle, oracleProperty } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -343,10 +343,6 @@ describe(`window changes at React commit`, () => { }) }) }) -declareOracle({ - grammar: [renderHistoryArbitrary], - check: [checkRenderHistory], -}) /** A mounted binding can suspend on a replacement while another hook finishes * that same preload first. React preserves the mounted binding's refs in this * case. On resumption it still owes its own output, not the peer's mutable page. @@ -718,7 +714,3 @@ for (const client of [false, true]) check: forwardAssertionJudgment, }) }) -declareOracle({ - grammar: [renderHistoryArbitrary], - check: [checkRetryOwnershipHistory], -}) diff --git a/packages/solid-db/tests/row-materialization-oracle.test.tsx b/packages/solid-db/tests/row-materialization-oracle.test.tsx index ee49852f06..d972c734e9 100644 --- a/packages/solid-db/tests/row-materialization-oracle.test.tsx +++ b/packages/solid-db/tests/row-materialization-oracle.test.tsx @@ -28,7 +28,6 @@ import { expect, vi } from 'vitest' 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 { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, @@ -562,11 +561,6 @@ oracleProperty({ check: forwardAssertionJudgment, }) -declareOracle({ - grammar: [histories, nativeParentHistories], - check: [compare, compareArray], -}) - /** * The materializer's own data-field boundary preserves descriptor visibility * and supplies native shape notifications. This is a helper-only law: Core @@ -720,11 +714,6 @@ oracleProperty({ check: forwardAssertionJudgment, }) -declareOracle({ - grammar: [visibilityHistories], - check: [compareVisibility], -}) - /** * Maintainer decision, 2026-10-30: preserve live row getters. A signal change * must reach a getter consumer without a Collection publication. Getter-returned @@ -959,12 +948,6 @@ oracleProperty({ check: forwardAssertionJudgment, }) -declareOracle({ - model: [expectedLabel], - grammar: [getterHistories], - check: [compareLabel], -}) - /** * Maintainer decision, 2026-10-30: shared acyclic graphs must materialize * efficiently. This cost judgment counts source enumerations, not elapsed time. @@ -1288,11 +1271,6 @@ oracleProperty({ model: recordedAssertionJudgment, check: forwardAssertionJudgment, }) -declareOracle({ - model: [enumerationBudget, semanticGraph], - grammar: [graphHistories], - check: [compareGraph, compareGraphValues], -}) /** * Callable user fields remain usable in Solid tracking scopes. The public @@ -1677,11 +1655,6 @@ oracleProperty({ model: recordedAssertionJudgment, check: forwardAssertionJudgment, }) -declareOracle({ - model: [expectedCallable], - grammar: [callableHistories], - check: [compareCallable], -}) /** * An own user data field keeps its ordinary current-result meaning even when @@ -1985,11 +1958,6 @@ oracleProperty({ model: recordedAssertionJudgment, check: forwardAssertionJudgment, }) -declareOracle({ - model: [expectedReservedField], - grammar: [reservedFieldHistories], - check: [compareReservedField], -}) /** * Arrays retain their admitted inherited methods in a Solid listener. The @@ -2210,8 +2178,3 @@ oracleProperty({ model: recordedAssertionJudgment, check: forwardAssertionJudgment, }) -declareOracle({ - 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 c793ac512d..e9919d8227 100644 --- a/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/cache-options-oracle.svelte.test.ts @@ -18,7 +18,6 @@ import { conflictingCacheOptions, } from '../../db/tests/live-query-cache-options-oracle' import { withOracleCleanup } from '../../db/tests/utils' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty, pass, violation } from '../../db/tests/oracle-config' import type { Observation, Verdict } from '../../db/tests/oracle-config' import type { @@ -26,11 +25,6 @@ import type { CacheRow, } from '../../db/tests/live-query-cache-options-oracle' -declareOracle({ - check: [checkCacheOptionAdmission, checkCacheOptionRows], - model: [conflictingCacheOptions], - grammar: [cacheOptionHistories], -}) async function recordHistory( history: CacheOptionHistory, observe: (checkpoint: string, value: unknown) => void, 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 2dec01e148..ecbc0a2e36 100644 --- a/packages/svelte-db/tests/client-window-oracle.svelte.test.ts +++ b/packages/svelte-db/tests/client-window-oracle.svelte.test.ts @@ -28,12 +28,7 @@ import { import { ScenarioLifetime } from '../../db/tests/conformance/scenario-lifetime' import { OracleCheckpointFailure } from '../../db/tests/oracle-failure-fidelity' import { selectedRow } from '../../db/tests/conformance/result-laws' -import { - declareOracle, - oracleProperty, - pass, - violation, -} from '../../db/tests/oracle-config' +import { oracleProperty, pass, violation } from '../../db/tests/oracle-config' import type { Observation, Verdict } from '../../db/tests/oracle-config' import type { InitialQueryBuilder, @@ -498,10 +493,6 @@ describe(`Svelte client page identity`, () => { }), ) }) -declareOracle({ - model: [modelClientWindow], - 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 @@ -783,8 +774,3 @@ oracleProperty({ }, check: checkRecovery, }) -declareOracle({ - grammar: [recoveryArbitrary, recoveryHistories], - model: [modelRecovery], - check: [checkRecovery], -}) 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 332d829110..70564e5ded 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 @@ -13,7 +13,6 @@ import { expectedDescriptorStart, } from '../../db/tests/descriptor-start-release-oracle' import { descriptorStartFixture } from '../../db/tests/descriptor-start-release-support' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -116,9 +115,3 @@ oracleProperty({ driver: recordHistory, check: checkHistory, }) - -declareOracle({ - check: [checkHistory], - model: [expectedDescriptorStart], - grammar: [descriptorStartHistories], -}) 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 6fb7310f84..b0b81dad6f 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 @@ -28,7 +28,6 @@ import { pass, violation, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import SsrDbApp from './SsrDbApp.svelte' import type { Observation, Verdict } from '../../db/tests/oracle-config' import type { createServer } from 'vite' @@ -354,8 +353,3 @@ oracleProperty({ check: checkRuntimeTrace, timeout: 30_000, }) -declareOracle({ - model: [expectedSsrRuntimeErrorViews], - check: [checkRuntimeTrace], - grammar: [ssrRuntimeErrorHistoryArbitrary], -}) 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 4e00941fce..252dbe3cd5 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 @@ -7,7 +7,6 @@ import { pass, violation, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedSsrSnapshot, ssrSnapshotExamples, @@ -271,8 +270,3 @@ oracleProperty({ driver: recordHistory, check: checkHistory, }) -declareOracle({ - model: [expectedSsrSnapshot], - grammar: [ssrSnapshotHistoryArbitrary], - check: [checkHistory], -}) 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 31187e48d1..85a6035710 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 @@ -16,7 +16,6 @@ import { pass, violation, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { peopleQuery } from './ssr-test-utils' import SsrDbApp from './SsrDbApp.svelte' import type { Observation, Verdict } from '../../db/tests/oracle-config' @@ -765,8 +764,3 @@ oracleProperty({ check: checkReceivingHistory, timeout: 30_000, }) -declareOracle({ - model: [expectedSsrSnapshot, expectedSsrReceivingViews], - check: [checkHandoff, checkReceivingHistory], - grammar: [ssrSnapshotHistoryArbitrary, ssrReceivingHistoryArbitrary], -}) diff --git a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts index 38a5770778..d4d9b370af 100644 --- a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts +++ b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts @@ -30,7 +30,6 @@ 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' @@ -198,8 +197,3 @@ it.each(cases)( if (primaryFailure !== undefined) throw primaryFailure }, ) - -declareOracle({ - owns: [`src/useLiveQuery.ts#useLiveQuery`], - check: [expectCoherentPublication], -}) diff --git a/scripts/release-comments-oracle.property.test.js b/scripts/release-comments-oracle.property.test.js index fd01406518..bef5d0968e 100644 --- a/scripts/release-comments-oracle.property.test.js +++ b/scripts/release-comments-oracle.property.test.js @@ -27,7 +27,6 @@ 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 // version suffixes loses the reported RC witness; removing neighboring sections @@ -777,11 +776,6 @@ if (args[0] === 'api' && args[1] === 'graphql') { // 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], - grammar: [histories], -}) const replay = process.env.TANSTACK_DB_RELEASE_COMMENT_CASE if ( replay !== undefined && From 02ee633a87b60733212338fa8660c03852de33f5 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:39:24 -0700 Subject: [PATCH 012/116] test(db): delete declareOracle calls from db-core companion modules M1 for the db-core group: remove each declareOracle call and its import from companion modules and from test files whose owns already sit on oracleProperty. Every declared part keeps another reference. Co-authored-by: Isaac --- .../e2e/indexed-db/browser-evidence-oracle.ts | 3 --- packages/db/tests/SortedMap-oracle.test.ts | 7 ------- .../db/tests/bound-schema-receiver-oracle.ts | 7 +------ packages/db/tests/btree-map-oracle.ts | 4 ---- packages/db/tests/comparison-laws-oracle.ts | 12 ------------ .../client-window-history-oracle.ts | 3 --- .../conformance/indexing-config-oracle.ts | 15 --------------- .../db/tests/conformance/page-laws-oracle.ts | 2 -- .../tests/conformance/public-field-oracle.ts | 19 ------------------- .../conformance/publication-laws-oracle.ts | 2 -- .../tests/conformance/result-shape-oracle.ts | 6 ------ .../tests/conformance/row-snapshot-oracle.ts | 6 ------ .../tests/descriptor-start-release-oracle.ts | 7 ------- .../db/tests/documented-mutations-oracle.ts | 15 --------------- packages/db/tests/error-sites-oracle.ts | 3 --- packages/db/tests/index-identity-oracle.ts | 3 --- .../db/tests/indexed-db/cross-tab-oracle.ts | 11 ----------- .../indexed-db/structured-clone-oracle.ts | 3 --- .../tests/live-query-bound-options-oracle.ts | 18 ------------------ ...observer-hydration-oracle.property.test.ts | 7 ------- ...live-query-preload-indexing-oracle.test.ts | 6 ------ .../db/tests/mutation-confirmation-oracle.ts | 7 ------- .../db/tests/optimistic-history-oracle.ts | 3 --- packages/db/tests/proxy-detachment-fixture.ts | 10 ---------- .../db/tests/reference-expression-oracle.ts | 3 --- .../reverse-index-oracle.property.test.ts | 7 ------- .../db/tests/sparse-array-value-oracle.ts | 6 ------ .../db/tests/ssr-snapshot-admission-oracle.ts | 3 --- .../ssr-stream-record-wait-oracle.test.ts | 6 ------ .../db/tests/utils-campaign-grammar-oracle.ts | 3 --- .../db/tests/utils-oracle.property.test.ts | 7 ------- 31 files changed, 1 insertion(+), 213 deletions(-) diff --git a/packages/db/e2e/indexed-db/browser-evidence-oracle.ts b/packages/db/e2e/indexed-db/browser-evidence-oracle.ts index dc58a4cebb..7a3543805b 100644 --- a/packages/db/e2e/indexed-db/browser-evidence-oracle.ts +++ b/packages/db/e2e/indexed-db/browser-evidence-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../../tests/oracle-declaration.js' import { OracleMismatch } from '../../tests/indexed-db/cross-tab-oracle' import type { BrowserEvidence } from './browser' @@ -22,5 +21,3 @@ export function retainedDocument( ) return retained } - -declareOracle({ check: [retainedDocument] }) diff --git a/packages/db/tests/SortedMap-oracle.test.ts b/packages/db/tests/SortedMap-oracle.test.ts index 5c980b18b0..03668bf7e8 100644 --- a/packages/db/tests/SortedMap-oracle.test.ts +++ b/packages/db/tests/SortedMap-oracle.test.ts @@ -8,7 +8,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter' -import { declareOracle } from './oracle-declaration' import { expectMap, expectView, modelOrder } from './sorted-map-support' import type { ModelKey, Order, Row } from './sorted-map-support' @@ -417,12 +416,6 @@ function withDeferredChange(actions: Array): Array { ] } -declareOracle({ - check: [expectMap, expectView], - model: [modelOrder, restoreBound], - grammar: [withDeferredChange], -}) - // Native campaigns keep their own ledgers. Their original four examples run // through the unchanged helper before that campaign's generated histories, so // each campaign supplies its own evidence for the original minimum of five. diff --git a/packages/db/tests/bound-schema-receiver-oracle.ts b/packages/db/tests/bound-schema-receiver-oracle.ts index 5595eee959..4305c9308a 100644 --- a/packages/db/tests/bound-schema-receiver-oracle.ts +++ b/packages/db/tests/bound-schema-receiver-oracle.ts @@ -18,7 +18,7 @@ import { modelBoundSchemaMutation, modelBoundSchemaRows, } from './live-query-bound-options-oracle' -import { declareOracle, differs, pass, violation } from './oracle-config' +import { differs, pass, violation } from './oracle-config' import type { BoundSchemaHistory, BoundSchemaObservation, @@ -209,8 +209,3 @@ export function checkBoundSchemaReceiver( ) return pass } - -declareOracle({ - model: [modelBoundSchemaReceiver], - check: [checkBoundSchemaReceiver], -}) diff --git a/packages/db/tests/btree-map-oracle.ts b/packages/db/tests/btree-map-oracle.ts index 416da9786d..71078d9e6e 100644 --- a/packages/db/tests/btree-map-oracle.ts +++ b/packages/db/tests/btree-map-oracle.ts @@ -8,7 +8,6 @@ import { expect } from 'vitest' import { fc } from '@fast-check/vitest' import { BTree } from '../src/utils/btree.js' import { oracleAssertion } from './oracle-assertion-adapter.js' -import { declareOracle } from './oracle-declaration.js' export type Payload = { v: number } export type Stored = Readonly<{ reference: Payload; v: number }> @@ -211,6 +210,3 @@ export function expectRefinement( expectPair(tree.nextHigherPair(undefined), sorted[0], model) expectPair(tree.nextLowerPair(undefined), sorted[sorted.length - 1], model) } - -// The shared definition is used by both the product driver and calibration. -declareOracle({ check: [expectRefinement], model: [stepModel] }) diff --git a/packages/db/tests/comparison-laws-oracle.ts b/packages/db/tests/comparison-laws-oracle.ts index bcb22adffb..d7b31dcc47 100644 --- a/packages/db/tests/comparison-laws-oracle.ts +++ b/packages/db/tests/comparison-laws-oracle.ts @@ -9,7 +9,6 @@ import { expect } from 'vitest' import { fc } from '@fast-check/vitest' import { ascComparator, defaultComparator } from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' -import { declareOracle } from './oracle-declaration.js' // Input grammar: finite numbers, strings, booleans, valid Dates, and bounded // arrays of those primitives; same-type pairs/triples isolate order laws. // Null placement, direction, and string mode are separate option axes. Removing @@ -237,17 +236,6 @@ function assertTransitive(ab: number, bc: number, ac: number): void { // This companion holds the shared order and equality checks. The native owner // retains the direct function-return campaigns and their fixed inputs. -declareOracle({ - owns: [`src/utils/comparison.ts`], - check: [ - checkAntisymmetry, - assertNonFiniteOrder, - assertChangedBytes, - assertTransitive, - ], - model: [taggedOrderValues, taggedOrderSign], -}) - export { arbitraryComparablePrimitive, arbitraryDate, diff --git a/packages/db/tests/conformance/client-window-history-oracle.ts b/packages/db/tests/conformance/client-window-history-oracle.ts index c946336ea0..d9e521f3f4 100644 --- a/packages/db/tests/conformance/client-window-history-oracle.ts +++ b/packages/db/tests/conformance/client-window-history-oracle.ts @@ -5,7 +5,6 @@ * owns input grammar. It imports no adapter or production identity machinery. */ import fc from 'fast-check' -import { declareOracle } from '../oracle-declaration' // Width/offset vary independently, including pages beyond the final row. // Every history moves twice and returns to the first page. Removing the held // readiness cut loses the distinction between a matching seed and local rows. @@ -38,5 +37,3 @@ export const clientWindowStates = [ `client scope switch`, `fresh old-page lookup`, ] as const - -declareOracle({ grammar: [clientWindowArbitrary] }) diff --git a/packages/db/tests/conformance/indexing-config-oracle.ts b/packages/db/tests/conformance/indexing-config-oracle.ts index d5df8750db..556e6b7eca 100644 --- a/packages/db/tests/conformance/indexing-config-oracle.ts +++ b/packages/db/tests/conformance/indexing-config-oracle.ts @@ -15,7 +15,6 @@ * not judge ordering, rendering before flush, or changes to mounted options. */ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration' import { expectUnorderedRows } from './result-laws' import { ScenarioLifetime } from './scenario-lifetime' import type { Collection, CollectionConfig } from '@tanstack/db' @@ -140,20 +139,6 @@ 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, - indexConfigurationPairs, - indexOptionAbsencePairs, - ], - model: [expectedIndexing], - check: [ - expectIndexing, - expectIndexingAdmission, - expectIndexingConfiguration, - expectIndependentIndexingOutputs, - ], -}) export function registerIndexingConfigScenarios( driver: LiveQueryDriver, diff --git a/packages/db/tests/conformance/page-laws-oracle.ts b/packages/db/tests/conformance/page-laws-oracle.ts index 97ececedd7..a0c75222f6 100644 --- a/packages/db/tests/conformance/page-laws-oracle.ts +++ b/packages/db/tests/conformance/page-laws-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' import { expectOrderedRows } from './result-laws' /** Page labels and request timing are separate laws, not inferred from rows. */ @@ -25,4 +24,3 @@ export function expectPageRows( // 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 d8f5e26a61..f7539ae748 100644 --- a/packages/db/tests/conformance/public-field-oracle.ts +++ b/packages/db/tests/conformance/public-field-oracle.ts @@ -12,7 +12,6 @@ * changes. A version field makes every stage a distinct source update. */ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration' import { expectRowSnapshot } from './row-snapshot-oracle' import { ScenarioLifetime } from './scenario-lifetime' import type { @@ -693,21 +692,3 @@ export async function runPublicFieldOverlapHistories( // 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, - expectedSelectedPublicField, - expectedPublicFieldOverlapSource, - expectedSelectedPublicFieldOverlap, - ], - grammar: [ - publicFieldCells, - publicFieldOverlapCells, - publicFieldOverlapFunctionalCells, - ], - check: [ - runPublicFieldHistories, - runPublicFieldOverlapCell, - runPublicFieldOverlapHistories, - ], -}) diff --git a/packages/db/tests/conformance/publication-laws-oracle.ts b/packages/db/tests/conformance/publication-laws-oracle.ts index 11b4b01884..f171fb72af 100644 --- a/packages/db/tests/conformance/publication-laws-oracle.ts +++ b/packages/db/tests/conformance/publication-laws-oracle.ts @@ -14,7 +14,6 @@ * arbitrary scheduling, status traces, or changes hidden within one turn. */ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' import { expectUnorderedRows } from './result-laws' export function expectPublishedRows( @@ -41,4 +40,3 @@ export function expectPublishedRows( // 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/result-shape-oracle.ts b/packages/db/tests/conformance/result-shape-oracle.ts index 58085b79c6..444068caa6 100644 --- a/packages/db/tests/conformance/result-shape-oracle.ts +++ b/packages/db/tests/conformance/result-shape-oracle.ts @@ -17,7 +17,6 @@ */ import fc from 'fast-check' import { oracleRuns } from '../oracle-config' -import { declareOracle } from '../oracle-declaration' import { differs, oracleProperty, pass, violation } from '../oracle-property' import { selectedRowKeys } from './result-laws' import { ScenarioLifetime } from './scenario-lifetime' @@ -526,8 +525,3 @@ export function registerResultShapeOracle(driver: LiveQueryDriver): void { // 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 2a7e740354..2c6e7372bd 100644 --- a/packages/db/tests/conformance/row-snapshot-oracle.ts +++ b/packages/db/tests/conformance/row-snapshot-oracle.ts @@ -15,7 +15,6 @@ * symbols and callback atomicity are not judged. */ import { expect } from 'vitest' -import { declareOracle } from '../oracle-declaration' import { ScenarioLifetime } from './scenario-lifetime' import { selectedRow } from './result-laws' import type { @@ -590,8 +589,3 @@ 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], - check: [expectRowSnapshot, expectSnapshots, expectFact], -}) diff --git a/packages/db/tests/descriptor-start-release-oracle.ts b/packages/db/tests/descriptor-start-release-oracle.ts index cfd3e6698b..1b892b4c88 100644 --- a/packages/db/tests/descriptor-start-release-oracle.ts +++ b/packages/db/tests/descriptor-start-release-oracle.ts @@ -21,7 +21,6 @@ * supply native receiving drivers for the same judgment. */ import { expect } from 'vitest' -import { declareOracle } from './oracle-declaration' export type DescriptorStartHistory = { acquired: number @@ -108,9 +107,3 @@ export function checkDescriptorStart( ): void { expect(actual, cut).toEqual(expectedDescriptorStart(history, cut)) } - -declareOracle({ - check: [checkDescriptorStart], - model: [expectedDescriptorStart], - grammar: [descriptorStartHistories], -}) diff --git a/packages/db/tests/documented-mutations-oracle.ts b/packages/db/tests/documented-mutations-oracle.ts index b2249b9f53..0315d7c6c1 100644 --- a/packages/db/tests/documented-mutations-oracle.ts +++ b/packages/db/tests/documented-mutations-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from './oracle-declaration' import { pass, violation } from './oracle-config' import { checkMutationConfirmation, @@ -922,17 +921,3 @@ export const documentedMutationCalibrations: Array<{ }, }, ] - -declareOracle({ - check: [ - checkDocumentedMutation, - checkDocumentedRequest, - checkDocumentedMutationTrace, - ], - grammar: [documentedMutationHistories, documentedHandlers], - model: [ - documentedMutationInputs, - modelDocumentedMutation, - owedDocumentedCuts, - ], -}) diff --git a/packages/db/tests/error-sites-oracle.ts b/packages/db/tests/error-sites-oracle.ts index fc83b9d288..5573af2c6d 100644 --- a/packages/db/tests/error-sites-oracle.ts +++ b/packages/db/tests/error-sites-oracle.ts @@ -31,7 +31,6 @@ import { readFileSync, readdirSync } from 'node:fs' import { dirname, join, relative } from 'node:path' import ts from 'typescript' -import { declareOracle } from './oracle-declaration.js' export type CodedSite = { file: string @@ -662,5 +661,3 @@ export function findErrorSites( } return { coded, plain, classGuards, developmentOnly } } - -declareOracle({ model: [findErrorSites] }) diff --git a/packages/db/tests/index-identity-oracle.ts b/packages/db/tests/index-identity-oracle.ts index f7ec6480ce..70a9de0e62 100644 --- a/packages/db/tests/index-identity-oracle.ts +++ b/packages/db/tests/index-identity-oracle.ts @@ -1,7 +1,6 @@ // Exact index identity membership is determined by live source descriptors. import { expect } from 'vitest' import { oracleAssertion } from './oracle-assertion-adapter' -import { declareOracle } from './oracle-declaration' import type { BaseIndex } from '../src/indexes/base-index.js' type GroupRow = Readonly<{ key: string; value: T; groupId: number }> @@ -25,5 +24,3 @@ export function expectExactIdentities( ) } } - -declareOracle({ check: [expectExactIdentities] }) diff --git a/packages/db/tests/indexed-db/cross-tab-oracle.ts b/packages/db/tests/indexed-db/cross-tab-oracle.ts index a9b3b0c9e1..b5ba150883 100644 --- a/packages/db/tests/indexed-db/cross-tab-oracle.ts +++ b/packages/db/tests/indexed-db/cross-tab-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../oracle-declaration.js' /** * Independent cross-tab publication model. * @@ -254,13 +253,3 @@ export function assertReplay(record: ReplayRecord) { record, ) } - -declareOracle({ - check: [ - assertSnapshot, - assertStatuses, - assertPublicationHistory, - assertReplay, - ], - model: [CrossTabModel], -}) diff --git a/packages/db/tests/indexed-db/structured-clone-oracle.ts b/packages/db/tests/indexed-db/structured-clone-oracle.ts index 35a6f0fe0b..c4a58a9315 100644 --- a/packages/db/tests/indexed-db/structured-clone-oracle.ts +++ b/packages/db/tests/indexed-db/structured-clone-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../oracle-declaration.js' /** * Value fidelity is independent of lifecycle ordering. IndexedDB persists * structured-clone values; changing a scalar sibling must preserve an untouched @@ -135,5 +134,3 @@ export async function observeValueRows(rows: Iterable) { export function expectedValueRows(kind: ValueKind, name: string, id = 1) { return [{ id, name, nested: { value: expectedValue(kind) } }] } - -declareOracle({ model: [expectedValue, expectedValueRows] }) diff --git a/packages/db/tests/live-query-bound-options-oracle.ts b/packages/db/tests/live-query-bound-options-oracle.ts index 956f6c84f8..db8ac3b742 100644 --- a/packages/db/tests/live-query-bound-options-oracle.ts +++ b/packages/db/tests/live-query-bound-options-oracle.ts @@ -6,7 +6,6 @@ * compare identity are outside this law. React and Preact are receiving drivers. */ import { expect } from 'vitest' -import { declareOracle } from './oracle-declaration.js' export const boundOptionHistories = ( [`getKey`, `schema`, `both`] as const @@ -239,20 +238,3 @@ export function boundSchemaGrammar(history: BoundSchemaHistory) { export const boundSchemaReachStates = boundSchemaHistories.flatMap((history) => boundSchemaCuts.map((cut) => `${boundSchemaGrammar(history)}/${cut}`), ) - -declareOracle({ - grammar: [boundOptionHistories, boundSchemaHistories], - model: [ - modelBoundSchemaBinding, - modelBoundSchemaKey, - modelBoundSchemaLabel, - modelBoundSchemaRows, - modelBoundSchemaMutation, - ], - check: [ - checkBoundOptions, - checkBoundSchemaRows, - checkBoundSchemaValues, - checkBoundSchemaMutation, - ], -}) 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 6a4a563bd8..05b13ce7d4 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 @@ -2,7 +2,6 @@ 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 { declareOracle } from './oracle-declaration.js' import { differs, oracleProperty, pass, violation } from './oracle-property.js' import { ScenarioLifetime } from './conformance/scenario-lifetime.js' import type { @@ -1256,9 +1255,3 @@ for (const [mode, id] of [ }, ], }) - -declareOracle({ - grammar: [subsetHandoffArbitrary], - model: [subsetHandoffModel], - check: [checkSubsetHandoff], -}) 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 63ef823679..930fcffdb9 100644 --- a/packages/db/tests/live-query-preload-indexing-oracle.test.ts +++ b/packages/db/tests/live-query-preload-indexing-oracle.test.ts @@ -23,7 +23,6 @@ import { indexConfigurationLabel, indexConfigurations, } from './conformance/indexing-config-oracle' -import { declareOracle } from './oracle-declaration' import { oracleProperty } from './oracle-property' import { forwardAssertionJudgment, @@ -55,11 +54,6 @@ function preloadIndexHistories() { ), ) } -declareOracle({ - grammar: [preloadIndexHistories], - model: [expectedIndexing], - check: [expectIndexingAdmission], -}) // Constructor functions remain in the original fixture table. Data histories // name each fixture by its position and stable constructor role; resolve retrieves diff --git a/packages/db/tests/mutation-confirmation-oracle.ts b/packages/db/tests/mutation-confirmation-oracle.ts index 1470107307..c149e75bd0 100644 --- a/packages/db/tests/mutation-confirmation-oracle.ts +++ b/packages/db/tests/mutation-confirmation-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from './oracle-declaration.js' /** * A handler's Promise decides its transaction outcome. Its discarded return @@ -236,9 +235,3 @@ export function checkMutationConfirmation( error: undefined, }) } - -declareOracle({ - check: [checkMutationConfirmation], - model: [expectedMutationConfirmation, mutationConfirmationRows], - grammar: [mutationConfirmationHistories], -}) diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index f11137b4b9..6ae632d219 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -7,7 +7,6 @@ import { createDeferred } from '../src/deferred.js' 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' @@ -1867,8 +1866,6 @@ export async function runOptimisticHistory( ) } -declareOracle({ check: [runOptimisticHistory], model: [HistoryModel] }) - // =========================================================================== // The optimistic history as oracleProperty parts // =========================================================================== diff --git a/packages/db/tests/proxy-detachment-fixture.ts b/packages/db/tests/proxy-detachment-fixture.ts index 9fd8ef1ec0..2b52e467ed 100644 --- a/packages/db/tests/proxy-detachment-fixture.ts +++ b/packages/db/tests/proxy-detachment-fixture.ts @@ -6,7 +6,6 @@ */ import { expect } from 'vitest' import { createCollection } from '../src/collection/index.js' -import { declareOracle } from './oracle-declaration.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { CollectionConfig } from '../src/types.js' @@ -170,12 +169,3 @@ export function expectDetachedBinaryClone( expect(actual.byteLength).toBe(expected.byteLength) } } - -declareOracle({ - check: [ - expectCycleSnapshot, - expectOwnDataProperty, - expectDetachedPayloadJson, - expectDetachedBinaryClone, - ], -}) diff --git a/packages/db/tests/reference-expression-oracle.ts b/packages/db/tests/reference-expression-oracle.ts index 9f11c166e4..839a7de33b 100644 --- a/packages/db/tests/reference-expression-oracle.ts +++ b/packages/db/tests/reference-expression-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from './oracle-declaration.js' import type { BasicExpression } from '../src/query/ir.js' function compareReferenceValues(left: unknown, right: unknown): number { @@ -63,5 +62,3 @@ export function evaluateReferenceExpression( throw new Error(`unsupported reference expression: ${expression.name}`) } } - -declareOracle({ model: [evaluateReferenceExpression] }) diff --git a/packages/db/tests/reverse-index-oracle.property.test.ts b/packages/db/tests/reverse-index-oracle.property.test.ts index 7bdedf2649..5e1f6606e6 100644 --- a/packages/db/tests/reverse-index-oracle.property.test.ts +++ b/packages/db/tests/reverse-index-oracle.property.test.ts @@ -3,7 +3,6 @@ import { BTreeIndex } from '../src/indexes/btree-index.js' import { ReverseIndex } from '../src/indexes/reverse-index.js' import { PropRef } from '../src/query/ir.js' import { differs, oracleProperty, pass, violation } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import type { ConstructedHistory, Observation, @@ -282,12 +281,6 @@ const readCase: fc.Arbitrary = fc.record({ rejected: fc.uniqueArray(fc.integer({ min: 0, max: 15 }), { maxLength: 6 }), }) -declareOracle({ - check: [checkRead], - model: [modelOrder, after, expectedRead, modelRead], - grammar: [readCase], -}) - // The four large regressions have a separate finite grammar. They retain the // same model, driver, and named judgments, and supply no generated reach. oracleProperty({ diff --git a/packages/db/tests/sparse-array-value-oracle.ts b/packages/db/tests/sparse-array-value-oracle.ts index a0bb08f2f2..84bfb273fb 100644 --- a/packages/db/tests/sparse-array-value-oracle.ts +++ b/packages/db/tests/sparse-array-value-oracle.ts @@ -8,7 +8,6 @@ */ import { fc } from '@fast-check/vitest' import { expect } from 'vitest' -import { declareOracle } from './oracle-declaration.js' export const sparseArrayCase = fc.record({ cells: fc.array( @@ -84,8 +83,3 @@ export function checkSparseArray( expected, ) } -declareOracle({ - grammar: [sparseArrayCase], - model: [sparseArrayValues], - check: [checkSparseArray], -}) diff --git a/packages/db/tests/ssr-snapshot-admission-oracle.ts b/packages/db/tests/ssr-snapshot-admission-oracle.ts index bc39d37e7e..8f1617c619 100644 --- a/packages/db/tests/ssr-snapshot-admission-oracle.ts +++ b/packages/db/tests/ssr-snapshot-admission-oracle.ts @@ -1,5 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from './oracle-declaration.js' /** * SSR may expose only a snapshot the first browser render can reproduce. @@ -115,8 +114,6 @@ export function expectedSsrSnapshot(history: SsrSnapshotHistory) { } as const } -declareOracle({ model: [expectedSsrSnapshot, expectedSsrRuntimeErrorViews] }) - // Named replays reconstruct the admission boundary before random shrinking can // stop a campaign at its first mismatch. They use the same driver and judgment. export const ssrSnapshotExamples: Array = [ 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 e52f1b7980..60ee852f28 100644 --- a/packages/db/tests/ssr-stream-record-wait-oracle.test.ts +++ b/packages/db/tests/ssr-stream-record-wait-oracle.test.ts @@ -26,7 +26,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter.js' -import { declareOracle } from './oracle-declaration.js' import { withOracleCleanup } from './utils.js' import type { Verdict } from './oracle-property.js' import type { DehydratedLiveQueryResult } from '../src/client.js' @@ -362,8 +361,3 @@ describe(`SSR stream record lifetime`, () => { check: forwardAssertionJudgment, }) }) - -declareOracle({ - check: [checkRecordHistory], - grammar: [grammar], -}) diff --git a/packages/db/tests/utils-campaign-grammar-oracle.ts b/packages/db/tests/utils-campaign-grammar-oracle.ts index d3037e0c2b..273754fea9 100644 --- a/packages/db/tests/utils-campaign-grammar-oracle.ts +++ b/packages/db/tests/utils-campaign-grammar-oracle.ts @@ -6,7 +6,6 @@ */ import { fc } from '@fast-check/vitest' import { edgeCarriers } from './deep-equals-support' -import { declareOracle } from './oracle-declaration' export const arbitraryExtraProperty = fc .tuple( @@ -38,5 +37,3 @@ export const ringArbitrary = fc.array( // them with the shared grammar preserves source-level run-budget tracking. export const ringNoSkipsCampaign = { seed: 101301, numRuns: 100 } export const ringComparatorCalibration = { seed: 101302, numRuns: 10 } - -declareOracle({ grammar: [arbitraryExtraProperty, ringArbitrary] }) diff --git a/packages/db/tests/utils-oracle.property.test.ts b/packages/db/tests/utils-oracle.property.test.ts index 86c437500a..3c44db86a4 100644 --- a/packages/db/tests/utils-oracle.property.test.ts +++ b/packages/db/tests/utils-oracle.property.test.ts @@ -23,7 +23,6 @@ import { arbitraryExtraProperty, ringArbitrary, } from './utils-campaign-grammar-oracle.js' -import { declareOracle } from './oracle-declaration.js' import { makeSparseArray, sparseArrayCarriers, @@ -1110,11 +1109,6 @@ function checkSetBag( cause: errors[0], }) } -declareOracle({ - model: [setBag, setBagsEqual], - check: [checkSetBag], - grammar: [ambiguousSets], -}) // Match the existing describe registration order to preserve IDs: utils.42. describe(`object Set matching work`, () => { utilsProperty([ambiguousSets])( @@ -1190,7 +1184,6 @@ const pairedSets = fc.record({ width: fc.integer({ min: 1024, max: 3072 }), value: fc.integer({ min: 1, max: 5 }), }) -declareOracle({ grammar: [pairedSets] }) const pairedReachStates = [ ...setReachStates.filter((state) => [ From d857a0610b8639d2be85d03f3bb366a4b7afce5f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:39:42 -0700 Subject: [PATCH 013/116] test(sqlite): rename the Playwright oracle specs to witnesses and delete their declareOracle calls Playwright and Chromium-driven specs cannot register through oracleProperty, so they become *-witness.* files. Configs and comments name the new files. Co-authored-by: Isaac --- ...ts => eager-gap-conformance-witness.opfs.spec.ts} | 2 -- ...s => electric-source-prefix-witness.opfs.spec.ts} | 6 ------ ...2e.test.ts => install-layout-witness.e2e.test.ts} | 6 ------ ...pfs.spec.ts => leader-close-witness.opfs.spec.ts} | 7 ------- .../e2e/leader-close.opfs.spec.ts | 2 +- .../e2e/leader-close.opfs.ts | 2 +- ...test.ts => opfs-open-outcome-witness.e2e.test.ts} | 9 --------- ...ec.ts => opfs-request-order-witness.opfs.spec.ts} | 6 ------ ...rsisted-startup-conformance-witness.opfs.spec.ts} | 2 -- ...pfs.spec.ts => reload-class-witness.opfs.spec.ts} | 6 ------ ...s => shared-driver-fairness-witness.opfs.spec.ts} | 3 --- .../e2e/wasm-package-consumer.e2e.test.ts | 2 +- .../playwright.opfs-electric.config.ts | 2 +- .../playwright.opfs.config.ts | 12 ++++++------ .../tests/electric-convergence-oracle.ts | 2 +- .../tests/opfs-open-outcome-oracle.test.ts | 2 +- .../tests/shared-driver-fairness-oracle.test.ts | 2 +- 17 files changed, 13 insertions(+), 60 deletions(-) rename packages/browser-db-sqlite-persistence/e2e/{eager-gap-conformance-oracle.opfs.spec.ts => eager-gap-conformance-witness.opfs.spec.ts} (94%) rename packages/browser-db-sqlite-persistence/e2e/{electric-source-prefix-oracle.opfs.spec.ts => electric-source-prefix-witness.opfs.spec.ts} (98%) rename packages/browser-db-sqlite-persistence/e2e/{install-layout-oracle.e2e.test.ts => install-layout-witness.e2e.test.ts} (99%) rename packages/browser-db-sqlite-persistence/e2e/{leader-close-oracle.opfs.spec.ts => leader-close-witness.opfs.spec.ts} (98%) rename packages/browser-db-sqlite-persistence/e2e/{opfs-open-outcome-oracle.e2e.test.ts => opfs-open-outcome-witness.e2e.test.ts} (98%) rename packages/browser-db-sqlite-persistence/e2e/{opfs-request-order-oracle.opfs.spec.ts => opfs-request-order-witness.opfs.spec.ts} (90%) rename packages/browser-db-sqlite-persistence/e2e/{persisted-startup-conformance-oracle.opfs.spec.ts => persisted-startup-conformance-witness.opfs.spec.ts} (97%) rename packages/browser-db-sqlite-persistence/e2e/{reload-class-oracle.opfs.spec.ts => reload-class-witness.opfs.spec.ts} (89%) rename packages/browser-db-sqlite-persistence/e2e/{shared-driver-fairness-oracle.opfs.spec.ts => shared-driver-fairness-witness.opfs.spec.ts} (95%) diff --git a/packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-witness.opfs.spec.ts similarity index 94% rename from packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-witness.opfs.spec.ts index bebfc4cad3..529851ba01 100644 --- a/packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-witness.opfs.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from '@playwright/test' -import { declareOracle } from '../../db/tests/oracle-declaration' import { precedenceHistories } from '../../query-db-collection/tests/persisted-precedence-oracle' import { cachedPeerClaimHistories, @@ -31,7 +30,6 @@ function checkEagerGapReceiving(result: EagerGapResult) { expect(result.results.flatMap((r) => r.violations)).toEqual([]) expect(result.artifacts).toEqual([]) } -declareOracle({ check: [checkEagerGapReceiving] }) test(`persisted precedence, retired hydration, and unwrapped reload obey their owners in Chromium`, async ({ page, }) => { 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-witness.opfs.spec.ts similarity index 98% rename from packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-witness.opfs.spec.ts index 8d868ec397..df0bfde577 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-witness.opfs.spec.ts @@ -11,7 +11,6 @@ import { expect, test } from '@playwright/test' import { makePgClient } from '../../db-collection-e2e/support/global-setup' import { sourceHeadViolation } from '../tests/electric-convergence-oracle' import { requiredBrowserObservations } from '../tests/browser-required-observations' -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' @@ -89,11 +88,6 @@ const observe = (page: Page) => const rowIds = async (page: Page) => (await observe(page)).rows.map(({ id }) => id) -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, diff --git a/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts b/packages/browser-db-sqlite-persistence/e2e/install-layout-witness.e2e.test.ts similarity index 99% rename from packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts rename to packages/browser-db-sqlite-persistence/e2e/install-layout-witness.e2e.test.ts index fb75c71b49..a695cccf0d 100644 --- a/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts +++ b/packages/browser-db-sqlite-persistence/e2e/install-layout-witness.e2e.test.ts @@ -52,7 +52,6 @@ import { dirname, join } from 'node:path' 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' @@ -220,11 +219,6 @@ const promisedOutcomes: Record> = { } // Owns nothing: this file checks the installed package layout (build outputs), not a src function. -declareOracle({ - check: [expectLayoutOutcome], - model: [expectedOutcome], - grammar: [layoutCases, promisedOutcomes], -}) // The check: the installed copy's observed outcome equals the layout model's // prediction for the same case and install facts. diff --git a/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/leader-close-witness.opfs.spec.ts similarity index 98% rename from packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/leader-close-witness.opfs.spec.ts index 15130734c2..10da9e2d32 100644 --- a/packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/leader-close-witness.opfs.spec.ts @@ -20,7 +20,6 @@ * detector for this no-pruning fixture, not a permanent storage contract. */ 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' @@ -344,12 +343,6 @@ async function runHistory( expect(cleanupFailures).toEqual([]) } -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, }) => { diff --git a/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.spec.ts index cf8404d80b..db837d3fa3 100644 --- a/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.spec.ts @@ -1,6 +1,6 @@ /** * Cold-successor checkpoint for the leader-close law in - * `leader-close-oracle.opfs.spec.ts`, which owns the law and its histories. + * `leader-close-witness.opfs.spec.ts`, which owns the law and its histories. * This scenario states its own hand-written term and row expectations. */ import { expect, test } from '@playwright/test' diff --git a/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.ts b/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.ts index 5d14d30785..bb9d4b9a2b 100644 --- a/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.ts +++ b/packages/browser-db-sqlite-persistence/e2e/leader-close.opfs.ts @@ -1,6 +1,6 @@ /** * Host driver for the #2085 source-Collection law in - * `leader-close-oracle.opfs.spec.ts`. A real Chromium page closes after its + * `leader-close-witness.opfs.spec.ts`. A real Chromium page closes after its * OPFS adapter commits a follower RPC but before the leader answers. This * controls the response boundary; it does not stand in for Electric delivery. */ 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-witness.e2e.test.ts similarity index 98% rename from packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts rename to packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-witness.e2e.test.ts index c876ae4dd7..dcfed108c9 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-witness.e2e.test.ts @@ -29,11 +29,9 @@ 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, @@ -410,13 +408,6 @@ 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( 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-witness.opfs.spec.ts similarity index 90% rename from packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/opfs-request-order-witness.opfs.spec.ts index 428df4bcb6..3d2ab86042 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-witness.opfs.spec.ts @@ -6,7 +6,6 @@ */ 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' @@ -22,11 +21,6 @@ function expectRequestOrderLawsHold(result: { ).toEqual([]) } -declareOracle({ - owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], - check: [expectRequestOrderLawsHold], -}) - test(`real Chromium OPFS connection answers requests in order and closes after them`, async ({ page, }) => { diff --git a/packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-witness.opfs.spec.ts similarity index 97% rename from packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-witness.opfs.spec.ts index 5232b5b289..d26448bcc5 100644 --- a/packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-witness.opfs.spec.ts @@ -1,5 +1,4 @@ import { expect, test } from '@playwright/test' -import { declareOracle } from '../../db/tests/oracle-declaration' import { queryStartupHistories } from '../../query-db-collection/tests/persisted-startup-driver' import { persistedStartupBatchHistories, @@ -36,7 +35,6 @@ function checkStartupConformance(result: StartupConformanceResult) { expect(result.artifacts).toEqual([]) expect(result.results.flatMap(({ violations }) => violations)).toEqual([]) } -declareOracle({ check: [checkStartupConformance] }) test(`complete source results replace persisted rows across OPFS startup`, async ({ page, }) => { diff --git a/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts b/packages/browser-db-sqlite-persistence/e2e/reload-class-witness.opfs.spec.ts similarity index 89% rename from packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/reload-class-witness.opfs.spec.ts index 29287a0aaf..98d1d2847c 100644 --- a/packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts +++ b/packages/browser-db-sqlite-persistence/e2e/reload-class-witness.opfs.spec.ts @@ -6,7 +6,6 @@ * stays distinct from a law violation. */ 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' @@ -24,11 +23,6 @@ function expectReloadClassLawsHold( ).toEqual([]) } -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, }) => { 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-witness.opfs.spec.ts similarity index 95% rename from packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts rename to packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-witness.opfs.spec.ts index 6c24fb0c97..fdcee97f26 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-witness.opfs.spec.ts @@ -11,7 +11,6 @@ * the scheduling laws. */ 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' @@ -67,8 +66,6 @@ function expectLawsHold(result: OPFSOracleResult): void { expect(result.opfsCleanupFailures).toEqual([]) } -declareOracle({ owns: [`src/wa-sqlite-driver.ts`], check: [expectLawsHold] }) - test(`real Chromium OPFS fixture reaches and cleans up cold hydration`, async ({ page, }) => { diff --git a/packages/browser-db-sqlite-persistence/e2e/wasm-package-consumer.e2e.test.ts b/packages/browser-db-sqlite-persistence/e2e/wasm-package-consumer.e2e.test.ts index 33f7446526..5e03d0d9f5 100644 --- a/packages/browser-db-sqlite-persistence/e2e/wasm-package-consumer.e2e.test.ts +++ b/packages/browser-db-sqlite-persistence/e2e/wasm-package-consumer.e2e.test.ts @@ -3,7 +3,7 @@ * Packaged-consumer checks for the separate WASM asset. A consumer app installs * the built package and runs under a Vite dev server, a dev server with a * custom cache directory, and a production preview. The open outcome oracle in - * `opfs-open-outcome-oracle.e2e.test.ts` owns how an open settles when its + * `opfs-open-outcome-witness.e2e.test.ts` owns how an open settles when its * steps fail or are interrupted; these tests own the build output, the export * map, and opening from page shapes that the oracle's driver does not create. */ diff --git a/packages/browser-db-sqlite-persistence/playwright.opfs-electric.config.ts b/packages/browser-db-sqlite-persistence/playwright.opfs-electric.config.ts index d5ff75a8cb..ba89816e40 100644 --- a/packages/browser-db-sqlite-persistence/playwright.opfs-electric.config.ts +++ b/packages/browser-db-sqlite-persistence/playwright.opfs-electric.config.ts @@ -4,7 +4,7 @@ import opfsConfig from './playwright.opfs.config' export default defineConfig({ ...opfsConfig, testMatch: [ - `electric-source-prefix-oracle.opfs.spec.ts`, + `electric-source-prefix-witness.opfs.spec.ts`, `electric-resume-two-tab.opfs.spec.ts`, `electric-hydration-straddle.opfs.spec.ts`, `electric-immediate-reload.opfs.spec.ts`, diff --git a/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts b/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts index 6925b29ca4..1b9c02870a 100644 --- a/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts +++ b/packages/browser-db-sqlite-persistence/playwright.opfs.config.ts @@ -7,17 +7,17 @@ const browserChannel = export default defineConfig({ testDir: `./e2e`, testMatch: [ - `shared-driver-fairness-oracle.opfs.spec.ts`, + `shared-driver-fairness-witness.opfs.spec.ts`, `remote-subset-two-tab.opfs.spec.ts`, `cache-claim-notice.opfs.spec.ts`, `open-timeout.opfs.spec.ts`, `hydration-commit.opfs.spec.ts`, - `leader-close-oracle.opfs.spec.ts`, + `leader-close-witness.opfs.spec.ts`, `leader-close.opfs.spec.ts`, - `opfs-request-order-oracle.opfs.spec.ts`, - `reload-class-oracle.opfs.spec.ts`, - `persisted-startup-conformance-oracle.opfs.spec.ts`, - `eager-gap-conformance-oracle.opfs.spec.ts`, + `opfs-request-order-witness.opfs.spec.ts`, + `reload-class-witness.opfs.spec.ts`, + `persisted-startup-conformance-witness.opfs.spec.ts`, + `eager-gap-conformance-witness.opfs.spec.ts`, `persisted-on-demand-coverage.opfs.spec.ts`, ], timeout: 60_000, 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 805e5d0729..b2b04124f9 100644 --- a/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/electric-convergence-oracle.ts @@ -14,7 +14,7 @@ * row occurrence and requiring each tab to be ready. The legal history grammar * and Node production driver live in multi-tab-electric-oracle.test.ts. The * receiving grammar and Chromium driver live in - * ../e2e/electric-source-prefix-oracle.opfs.spec.ts and its page module. + * ../e2e/electric-source-prefix-witness.opfs.spec.ts and its page module. * Each driver calls this comparison after recovery and complete source delivery. */ 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 eecd60941f..078499f13d 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 @@ -9,7 +9,7 @@ * initialization requests that a loaded worker receives. * * No asset lookup runs here, because only the package build defines it. The - * Chromium driver in `e2e/opfs-open-outcome-oracle.e2e.test.ts` supplies the + * Chromium driver in `e2e/opfs-open-outcome-witness.e2e.test.ts` supplies the * lookup and the real worker's WASM fetch. */ import { afterEach, describe, expect, it, vi } from 'vitest' 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 6398aa7844..78ff898ab3 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 @@ -14,7 +14,7 @@ * tail-order variation loses hydrate-before-later-persist histories. * The shared fixture holds the first `BEGIN IMMEDIATE` while cold `preload()` * calls queue. This file supplies BetterSQLite; the receiving witness in - * `../e2e/shared-driver-fairness-oracle.opfs.spec.ts` uses the same fixture with a + * `../e2e/shared-driver-fairness-witness.opfs.spec.ts` uses the same fixture with a * Chromium OPFSCoopSyncVFS worker after release. It checks rows and K=1 cuts. * Generated failures record the original and reduced histories with their * violated laws and checkpoints; the report says whether shrinking retained From 868c49145510aced66a26c12ce4abe3c70330d5d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:40:21 -0700 Subject: [PATCH 014/116] test(db): rename IndexedDB Playwright oracle specs to witnesses value-oracle.spec.ts and cross-tab-oracle.spec.ts are Playwright receiving witnesses, so they become *-witness.spec.ts. The value witness loses its declareOracle call; configs, the replay manifest owner, and comments follow the new names. Co-authored-by: Isaac --- packages/db/e2e/indexed-db/cross-tab-support.ts | 2 +- .../{cross-tab-oracle.spec.ts => cross-tab-witness.spec.ts} | 0 packages/db/e2e/indexed-db/cross-tab.spec.ts | 2 +- .../indexed-db/{value-oracle.spec.ts => value-witness.spec.ts} | 3 --- packages/db/e2e/indexed-db/value.spec.ts | 2 +- packages/db/playwright.indexed-db.config.ts | 2 +- packages/db/tests/indexed-db/persistence-values-oracle.test.ts | 2 +- packages/db/tests/oracle-replay-manifest.ts | 2 +- 8 files changed, 6 insertions(+), 9 deletions(-) rename packages/db/e2e/indexed-db/{cross-tab-oracle.spec.ts => cross-tab-witness.spec.ts} (100%) rename packages/db/e2e/indexed-db/{value-oracle.spec.ts => value-witness.spec.ts} (98%) diff --git a/packages/db/e2e/indexed-db/cross-tab-support.ts b/packages/db/e2e/indexed-db/cross-tab-support.ts index 592967d602..bc162005b6 100644 --- a/packages/db/e2e/indexed-db/cross-tab-support.ts +++ b/packages/db/e2e/indexed-db/cross-tab-support.ts @@ -1,6 +1,6 @@ // Shared by the cross-tab native oracle spec and cross-tab.spec.ts: the page // open, observation, cleanup, and evidence wrapper that every native cross-tab -// scenario uses. The laws they serve are stated in cross-tab-oracle.spec.ts. +// scenario uses. The laws they serve are stated in cross-tab-witness.spec.ts. import { expect, test } from '@playwright/test' import type { BrowserContext, Page } from '@playwright/test' import type { BrowserEvidence } from './browser' diff --git a/packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts b/packages/db/e2e/indexed-db/cross-tab-witness.spec.ts similarity index 100% rename from packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts rename to packages/db/e2e/indexed-db/cross-tab-witness.spec.ts diff --git a/packages/db/e2e/indexed-db/cross-tab.spec.ts b/packages/db/e2e/indexed-db/cross-tab.spec.ts index 34a584dee0..4344d9aa41 100644 --- a/packages/db/e2e/indexed-db/cross-tab.spec.ts +++ b/packages/db/e2e/indexed-db/cross-tab.spec.ts @@ -1,4 +1,4 @@ -// Ordinary native cross-tab scenarios. cross-tab-oracle.spec.ts owns the +// Ordinary native cross-tab scenarios. cross-tab-witness.spec.ts owns the // browser-side history and publication laws; these two tests compare with // literal observations, and one is a harness self-test of the primary-mismatch // cleanup. Their helpers are in cross-tab-support.ts. diff --git a/packages/db/e2e/indexed-db/value-oracle.spec.ts b/packages/db/e2e/indexed-db/value-witness.spec.ts similarity index 98% rename from packages/db/e2e/indexed-db/value-oracle.spec.ts rename to packages/db/e2e/indexed-db/value-witness.spec.ts index 2668dd33a6..0f0281a902 100644 --- a/packages/db/e2e/indexed-db/value-oracle.spec.ts +++ b/packages/db/e2e/indexed-db/value-witness.spec.ts @@ -10,7 +10,6 @@ import { expectedValueRows, valueKinds, } from '../../tests/indexed-db/structured-clone-oracle' -import { declareOracle } from '../../tests/oracle-declaration' import { test } from './browser-test' import type {} from './coverage-browser' import { withCoverage } from './coverage-cleanup' @@ -151,8 +150,6 @@ function expectedFirstOpenRows( return rows.map((row) => (shared ? rows : [row])) } -declareOracle({ model: [expectedFirstOpenRows] }) - // Same/independent names distinguish queue sharing from accidental global // ownership. Prototype and Unicode store names also receive native creation, // dictionary lookup, broadcast and restore; the wider renaming grammar stays diff --git a/packages/db/e2e/indexed-db/value.spec.ts b/packages/db/e2e/indexed-db/value.spec.ts index 88176be707..824f95cc48 100644 --- a/packages/db/e2e/indexed-db/value.spec.ts +++ b/packages/db/e2e/indexed-db/value.spec.ts @@ -1,4 +1,4 @@ -// Ordinary native value scenarios. value-oracle.spec.ts owns the structured-clone +// Ordinary native value scenarios. value-witness.spec.ts owns the structured-clone // value laws and their models; this foreign-realm witness compares with literal // bytes and offsets, so it is supplemental coverage. import { expect } from '@playwright/test' diff --git a/packages/db/playwright.indexed-db.config.ts b/packages/db/playwright.indexed-db.config.ts index 4176d1e45d..dd1b2441f0 100644 --- a/packages/db/playwright.indexed-db.config.ts +++ b/packages/db/playwright.indexed-db.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ })), { name: 'webkit-persistent', - testMatch: ['**/value-oracle.spec.ts', '**/value.spec.ts'], + testMatch: ['**/value-witness.spec.ts', '**/value.spec.ts'], use: { browserName: 'webkit' }, }, ], 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 a4510c3960..e8b8b3d70b 100644 --- a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts @@ -9,7 +9,7 @@ * * The driver uses real Collection APIs and fake-IDB, with controlled delivery. * Every checkpoint compares public, peer, export and raw durable values, plus - * version records after rejection. Native receiving lives in e2e/indexed-db/value-oracle.spec.ts. + * version records after rejection. Native receiving lives in e2e/indexed-db/value-witness.spec.ts. */ import { expect, it } from 'vitest' import { diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 29721d87e7..fb6c263399 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -1148,7 +1148,7 @@ const ownerGroups: ReadonlyArray< ], [`db/tests/proxy-oracle.test.ts`, `proxy`, `callback-history`], [ - `db/e2e/indexed-db/cross-tab-oracle.spec.ts`, + `db/e2e/indexed-db/cross-tab-witness.spec.ts`, `indexeddb`, `browser-native-history`, ], From 49e43ad0f692fc517c14f2d9d15607a23c79c784 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:40:52 -0700 Subject: [PATCH 015/116] test(db-ivm): register the Temporal group key cases through oracleProperty Converts the six plain-test sites in temporal-group-key-oracle.test.ts (P2) into one enumerated property, ivm.temporal-group-key, whose 20 histories name the former cases. Each former body runs unchanged through recordAssertionJudgment, and owns moves from the deleted declareOracle call to the property (M2). Co-authored-by: Isaac --- .../tests/temporal-group-key-oracle.test.ts | 238 ++++++++++++------ .../converted-properties/frameworks-ivm.ts | 18 +- 2 files changed, 176 insertions(+), 80 deletions(-) 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 1958243ac0..6ef63f6180 100644 --- a/packages/db-ivm/tests/temporal-group-key-oracle.test.ts +++ b/packages/db-ivm/tests/temporal-group-key-oracle.test.ts @@ -1,5 +1,5 @@ import { Temporal } from 'temporal-polyfill' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { D2, MultiSet, @@ -8,7 +8,12 @@ import { output, serializeValue, } from '../src/index.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-property.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter.js' /** * Temporal values are valid db-ivm group keys. The established hash key domain @@ -110,91 +115,168 @@ function observedGroupCounts(rows: Array<{ date: object }>): Array { return observed.map(({ count }) => count).sort((left, right) => left - right) } -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']), - ) - expect(serializeValue(rows[0]!.date)).not.toBe( - serializeValue(rows[1]!.date), - ) - }, +// Each enumerated history names one fixture. The two temporal laws run once +// per Temporal kind; the four remaining fixtures are the bounded tag and +// representation boundaries described above. Each body below is the former +// test body, unchanged, and is judged by the same model and assertions. +type TemporalKind = (typeof temporalCases)[number]['name'] +type TemporalGroupKeyHistory = + | { law: 'distinct values' | 'fresh values'; kind: TemporalKind } + | { + law: + | 'equal-comparing Duration representations' + | 'tagged plain object' + | 'own-tagged empty plain object' + | 'own matching tag' + } + +const temporalGroupKeyHistories: Array = [ + ...temporalCases.map(({ name }) => ({ + law: 'distinct values' as const, + kind: name, + })), + ...temporalCases.map(({ name }) => ({ + law: 'fresh values' as const, + kind: name, + })), + { law: 'equal-comparing Duration representations' }, + { law: 'tagged plain object' }, + { law: 'own-tagged empty plain object' }, + { law: 'own matching tag' }, +] + +function temporalGroupKeyLabel(history: TemporalGroupKeyHistory): string { + switch (history.law) { + case 'distinct values': + return `${history.kind} keeps distinct values in distinct groups` + case 'fresh values': + return `${history.kind} groups matching fresh values together` + case 'equal-comparing Duration representations': + return 'keeps equal-comparing Duration representations in separate groups' + case 'tagged plain object': + return 'keeps a plain object with a Temporal tag distinct from a Temporal value' + case 'own-tagged empty plain object': + return 'keeps an own-tagged plain object with no enumerable fields distinct' + case 'own matching tag': + return 'still groups a Temporal value with an own matching tag' + } +} + +function temporalCase(kind: TemporalKind) { + return temporalCases.find(({ name }) => name === kind)! +} + +function runDistinctValuesCase({ + first, + second, +}: (typeof temporalCases)[number]): void { + const rows = [{ date: first() }, { date: second() }] + expect(observedGroupCounts(rows)).toEqual( + expectedGroupCounts(['first', 'second']), ) + expect(serializeValue(rows[0]!.date)).not.toBe(serializeValue(rows[1]!.date)) +} - 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( - expectedGroupCounts(['first', 'first']), - ) - }, +function runFreshValuesCase({ first }: (typeof temporalCases)[number]): void { + const rows = [{ date: first() }, { date: first() }] + expect(serializeValue(rows[0]!.date)).toBe(serializeValue(rows[1]!.date)) + expect(observedGroupCounts(rows)).toEqual( + expectedGroupCounts(['first', 'first']), ) +} - 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) - expect(observedGroupCounts([{ date: hour }, { date: minutes }])).toEqual( - expectedGroupCounts(['hour', 'minutes']), - ) +function runDurationRepresentationsCase(): void { + const hour = Temporal.Duration.from('PT1H') + const minutes = Temporal.Duration.from('PT60M') + expect(Temporal.Duration.compare(hour, minutes)).toBe(0) + expect(observedGroupCounts([{ date: hour }, { date: minutes }])).toEqual( + expectedGroupCounts(['hour', 'minutes']), + ) +} + +function runTaggedPlainObjectCase(): void { + const date = Temporal.PlainDate.from('2024-01-15') + const taggedObject = { + [Symbol.toStringTag]: 'Temporal.PlainDate', + extra: 1, + } + Object.defineProperty(taggedObject, 'toString', { + value: () => '2024-01-15', }) + expect(observedGroupCounts([{ date }, { date: taggedObject }])).toEqual( + expectedGroupCounts(['Temporal', 'plain object']), + ) + expect(serializeValue({ date })).not.toBe( + serializeValue({ date: taggedObject }), + ) +} - 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', - extra: 1, - } - Object.defineProperty(taggedObject, 'toString', { - value: () => '2024-01-15', - }) - expect(observedGroupCounts([{ date }, { date: taggedObject }])).toEqual( - expectedGroupCounts(['Temporal', 'plain object']), - ) - expect(serializeValue({ date })).not.toBe( - serializeValue({ date: taggedObject }), - ) +function runOwnTaggedEmptyPlainObjectCase(): void { + const date = Temporal.PlainDate.from('2024-01-15') + const taggedObject = {} + Object.defineProperty(taggedObject, Symbol.toStringTag, { + value: 'Temporal.PlainDate', + }) + Object.defineProperty(taggedObject, 'toString', { + value: () => '2024-01-15', }) - 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, { - value: 'Temporal.PlainDate', - }) - Object.defineProperty(taggedObject, 'toString', { - value: () => '2024-01-15', - }) - - expect(Object.keys(taggedObject)).toEqual([]) - expect(observedGroupCounts([{ date }, { date: taggedObject }])).toEqual( - expectedGroupCounts(['Temporal', 'plain object']), - ) + expect(Object.keys(taggedObject)).toEqual([]) + expect(observedGroupCounts([{ date }, { date: taggedObject }])).toEqual( + expectedGroupCounts(['Temporal', 'plain object']), + ) +} + +function runOwnMatchingTagCase(): void { + const date = Temporal.PlainDate.from('2024-01-15') + const matchingDate = Temporal.PlainDate.from('2024-01-15') + Object.defineProperty(matchingDate, Symbol.toStringTag, { + value: 'Temporal.PlainDate', }) - 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, { - value: 'Temporal.PlainDate', - }) - - expect(observedGroupCounts([{ date }, { date: matchingDate }])).toEqual( - expectedGroupCounts(['same date', 'same date']), - ) - expect(serializeValue({ date })).toBe( - serializeValue({ date: matchingDate }), - ) + expect(observedGroupCounts([{ date }, { date: matchingDate }])).toEqual( + expectedGroupCounts(['same date', 'same date']), + ) + expect(serializeValue({ date })).toBe(serializeValue({ date: matchingDate })) +} + +function runTemporalGroupKeyCase(history: TemporalGroupKeyHistory): void { + switch (history.law) { + case 'distinct values': + return runDistinctValuesCase(temporalCase(history.kind)) + case 'fresh values': + return runFreshValuesCase(temporalCase(history.kind)) + case 'equal-comparing Duration representations': + return runDurationRepresentationsCase() + case 'tagged plain object': + return runTaggedPlainObjectCase() + case 'own-tagged empty plain object': + return runOwnTaggedEmptyPlainObjectCase() + case 'own matching tag': + return runOwnMatchingTagCase() + } +} + +const TEMPORAL_GROUP_KEY_LAW = + 'Temporal group keys with distinct kind or representation form distinct groups; matching fresh values coalesce.' + +describe('Temporal group keys', () => { + oracleProperty({ + id: `ivm.temporal-group-key`, + law: { + statement: TEMPORAL_GROUP_KEY_LAW, + authority: `The established hash key domain documented above: Temporal kind and string representation; expectedGroupCounts.`, + limits: `One initial batch of two values per fixture; altered Temporal prototypes, separate polyfill copies, symbols, cycles, and mutable RegExp state are outside this contract.`, + }, + owns: [`src/operators/groupBy.ts#groupBy`], + histories: temporalGroupKeyHistories, + label: temporalGroupKeyLabel, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(TEMPORAL_GROUP_KEY_LAW, plan, observe, () => + runTemporalGroupKeyCase(plan.history), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 7dc7d558bc..8da56a334f 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -5,6 +5,20 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [ + // db-ivm + `ivm.temporal-group-key`, + // react-db + // vue-db and scripts +] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + // db-ivm + [ + `db-ivm/tests/temporal-group-key-oracle.test.ts`, + `ivm`, + `temporal-group-key`, + ], + // react-db + // vue-db and scripts +] From 29b0a04d2321c534968d6a67fbd944149848fb66 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:41:25 -0700 Subject: [PATCH 016/116] test(db): register the includes space, outer-join route work, and IN calibration tests through oracleProperty Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 24 +- .../in-evaluator-oracle.property.test.ts | 110 +++---- ...ludes-outer-join-route-work-oracle.test.ts | 305 +++++++++--------- .../tests/query/includes-space-oracle.test.ts | 104 +++--- 4 files changed, 292 insertions(+), 251 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index a50fda6c0b..21519308c6 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -5,6 +5,26 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [ + `includes-space.reachable-facades`, + `includes-outer-join-route-work.main-correlation`, + `evaluators.in-original-calibration`, +] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + [ + `db/tests/query/includes-space-oracle.test.ts`, + `includes-space`, + `reachable-facades`, + ], + [ + `db/tests/query/includes-outer-join-route-work-oracle.test.ts`, + `includes-outer-join-route-work`, + `main-correlation`, + ], + [ + `db/tests/query/compiler/in-evaluator-oracle.property.test.ts`, + `evaluators`, + `in-original-calibration`, + ], +] 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 5253eee329..d7b0270911 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 @@ -1,17 +1,13 @@ import fc from 'fast-check' import { Temporal } from 'temporal-polyfill' -import { expect, it } from 'vitest' import { compileExpression } from '../../../src/query/compiler/evaluators.js' import { Func, PropRef, Value } from '../../../src/query/ir.js' import { differs, oracleProperty, - oracleReplayTest, - oracleRoute, pass, violation, } from '../../oracle-config.js' -import { declareOracle } from '../../oracle-declaration.js' import type { ConstructedHistory, Observation, @@ -390,12 +386,6 @@ function checkIn( ) } -declareOracle({ - model: [referenceIn, modelIn], - grammar: [inCase], - check: [checkIn], -}) - oracleProperty({ id: `evaluators.in`, law: { @@ -519,58 +509,56 @@ oracleProperty({ // searches the original 28-value pool with its original arbitrary, rather than // the enlarged product grammar. The search stops at its first rejection, as // the old fc.check did. Both clean and raw observations use the shared law. -oracleReplayTest(it)( - `rejects raw equality within the original calibration search`, - () => { - oracleRoute(`calibration`) - const calibrationItem = fc.constantFrom(...originalPool) - const calibrationCase: fc.Arbitrary = fc.record({ - value: calibrationItem, - list: fc.array(calibrationItem, { maxLength: 6 }), - listFromRow: fc.boolean(), - }) - const candidates = fc.sample(calibrationCase, { - seed: 0x2029, - numRuns: 500, - }) - for (const [index, candidate] of candidates.entries()) { - // Resolution restores the same exact pool references, including opaque - // values and distinct objects. It does not serialize or copy those leaves. - const plan = resolveIn({ - value: indexOf(candidate.value), - list: candidate.list.map(indexOf), - listFromRow: candidate.listFromRow, - }) - const trace: Array = [] - driveIn(plan, (checkpoint, value) => trace.push({ checkpoint, value })) - const expected = modelIn(plan) - expect(checkIn(plan, trace, expected)).toEqual(pass) - const raw = - plan.value == null - ? null - : plan.list.some((entry) => entry === plan.value) - const wrong = checkIn( - plan, - trace.map((entry) => +// The candidates are enumerated in sample order through the same model, +// driver, and check, and the calibration skips a candidate whose raw answer +// equals the reference, as the old search continued past it. +const calibrationItem = fc.constantFrom(...originalPool) +const calibrationCase: fc.Arbitrary = fc.record({ + value: calibrationItem, + list: fc.array(calibrationItem, { maxLength: 6 }), + listFromRow: fc.boolean(), +}) +// Resolution restores the same exact pool references, including opaque +// values and distinct objects. It does not serialize or copy those leaves. +const originalCalibrationCandidates: ReadonlyArray = fc + .sample(calibrationCase, { seed: 0x2029, numRuns: 500 }) + .map((candidate) => ({ + value: indexOf(candidate.value), + list: candidate.list.map(indexOf), + listFromRow: candidate.listFromRow, + })) + +oracleProperty({ + id: `evaluators.in-original-calibration`, + law: { + statement: `IN returns the independent three-valued membership result for each constant or changing row list, with normalized value equality and exact reference leaves.`, + authority: `The IN and equality contracts in evaluators.ts, stated at the start of this owner.`, + limits: `The original calibration search: 500 candidates sampled at seed 0x2029 from the original 28-value pool, replayed through the shared model, driver, and check. No non-array-list, WHERE publication, index, or subset-loading claim.`, + }, + owns: [`src/query/compiler/evaluators.ts#compileExpression`], + histories: originalCalibrationCandidates, + label: (history) => + `original calibration candidate ${originalCalibrationCandidates.indexOf(history) + 1} of 500`, + resolve: resolveIn, + model: modelIn, + driver: driveIn, + check: checkIn, + calibrations: [ + { + name: `rejects raw equality within the original calibration search`, + law: `IN result`, + corrupt: (trace, plan) => { + const raw = + plan.value == null + ? null + : plan.list.some((entry) => entry === plan.value) + if (raw === referenceIn(plan.value, plan.list)) return undefined + return trace.map((entry) => entry.checkpoint === `evaluation returned` ? { ...entry, value: raw } : entry, - ), - expected, - ) - if ( - wrong.kind === `violation` && - wrong.law === `IN result` && - wrong.checkpoint === `evaluation returned` - ) { - process.stdout.write( - `IN raw-equality calibration rejected candidate ${index + 1} of 500 at seed 8233\n`, ) - return - } - } - throw new Error( - `the shared IN check accepted every raw-equality candidate within the original 500-candidate search`, - ) - }, -) + }, + }, + ], +}) 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 6874a75f02..d6c128a5c0 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 @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { Query, createLiveQueryCollection, @@ -8,8 +8,12 @@ import { 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createScopedSource } from './includes-scope-identity-oracle.js' import type { crossJoinParentRoutes } from '../../src/query/compiler/parent-routes.js' @@ -112,154 +116,167 @@ const 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], -}) +// The existing judgment of one fixture cell: the plain-row model at each cut, +// and equal route assembly for one and three joined anchors. +async function judgeOuterJoinRouteWork( + kind: Kind, + mode: `eager` | `onDemand`, +): Promise { + const parentRows: Array = [{ id: 1 }, { id: 2 }] + const mainRows: Array
= [ + { id: 10, parentId: 1, anchorId: 7 }, + { id: 11, parentId: 1, anchorId: 99 }, + ] + const observe = async (anchorRows: Array, size: string) => { + const currentMains = new Map(mainRows.map((row) => [row.id, row])) + const parents = createScopedSource( + `route-work-parent-${kind}-${mode}-${size}`, + parentRows, + `eager`, + ) + const mains = createScopedSource( + `route-work-main-${kind}-${mode}-${size}`, + mainRows, + mode, + ) + const anchors = createScopedSource( + `route-work-anchor-${kind}-${mode}-${size}`, + anchorRows, + mode, + ) + mains.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + mains.collection.createIndex((row) => row.anchorId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ parent: parents.collection }) + .select(({ parent }) => ({ + id: parent.id, + rows: toArray( + new Query() + .from({ main: mains.collection }) + .join( + { anchor: anchors.collection }, + ({ main, anchor }) => eq(main.anchorId, anchor.id), + kind, + ) + .where(({ main }) => eq(main.parentId, parent.id)) + .select(({ main, anchor }) => ({ + mainId: main.id, + anchorId: anchor.id, + })), + ), + })), + }) -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 }, - { id: 11, parentId: 1, anchorId: 99 }, - ] - const observe = async (anchorRows: Array, size: string) => { - const currentMains = new Map(mainRows.map((row) => [row.id, row])) - const parents = createScopedSource( - `route-work-parent-${kind}-${mode}-${size}`, - parentRows, - `eager`, - ) - const mains = createScopedSource( - `route-work-main-${kind}-${mode}-${size}`, - mainRows, - mode, + return withHistoryCleanup( + async () => { + const cuts: Array<{ + cut: string + rows: Array + assemblies: number + }> = [] + const check = (cut: string) => { + const rows = sortRows( + live.toArray.flatMap(({ id, rows: children }) => + children.map(({ mainId, anchorId }) => ({ + parentId: id, + mainId, + anchorId, + })), + ), ) - const anchors = createScopedSource( - `route-work-anchor-${kind}-${mode}-${size}`, - anchorRows, - mode, + expect(rows, `${size} at ${cut}`).toEqual( + model(parentRows, [...currentMains.values()], anchorRows, kind), ) - mains.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - mains.collection.createIndex((row) => row.anchorId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ parent: parents.collection }) - .select(({ parent }) => ({ - id: parent.id, - rows: toArray( - new Query() - .from({ main: mains.collection }) - .join( - { anchor: anchors.collection }, - ({ main, anchor }) => eq(main.anchorId, anchor.id), - kind, - ) - .where(({ main }) => eq(main.parentId, parent.id)) - .select(({ main, anchor }) => ({ - mainId: main.id, - anchorId: anchor.id, - })), - ), - })), - }) + cuts.push({ cut, rows, assemblies: routeWork.assemblies }) + } + routeWork.assemblies = 0 + await live.preload() + check(`preload`) - return withHistoryCleanup( - async () => { - const cuts: Array<{ - cut: string - rows: Array - assemblies: number - }> = [] - const check = (cut: string) => { - const rows = sortRows( - live.toArray.flatMap(({ id, rows: children }) => - children.map(({ mainId, anchorId }) => ({ - parentId: id, - mainId, - anchorId, - })), - ), - ) - expect(rows, `${size} at ${cut}`).toEqual( - model( - parentRows, - [...currentMains.values()], - anchorRows, - kind, - ), - ) - cuts.push({ cut, rows, assemblies: routeWork.assemblies }) - } - routeWork.assemblies = 0 - await live.preload() - check(`preload`) + const moved = { id: 11, parentId: 1, anchorId: 7 } + currentMains.set(11, moved) + routeWork.assemblies = 0 + mains.put(moved) + await flushPromises() + check(`main key moves into match`) - const moved = { id: 11, parentId: 1, anchorId: 7 } - currentMains.set(11, moved) - routeWork.assemblies = 0 - mains.put(moved) - await flushPromises() - check(`main key moves into match`) + currentMains.delete(10) + routeWork.assemblies = 0 + mains.remove(10) + await flushPromises() + check(`matched main row disappears`) - currentMains.delete(10) - routeWork.assemblies = 0 - mains.remove(10) - await flushPromises() - check(`matched main row disappears`) + const restored = { id: 10, parentId: 1, anchorId: 7 } + currentMains.set(10, restored) + routeWork.assemblies = 0 + mains.put(restored) + await flushPromises() + check(`matched main row returns`) - const restored = { id: 10, parentId: 1, anchorId: 7 } - currentMains.set(10, restored) - routeWork.assemblies = 0 - mains.put(restored) - await flushPromises() - check(`matched main row returns`) + const movedBack = { id: 11, parentId: 1, anchorId: 99 } + currentMains.set(11, movedBack) + routeWork.assemblies = 0 + mains.put(movedBack) + await flushPromises() + check(`main key moves out of match`) + return cuts + }, + () => [ + () => live.cleanup(), + () => parents.collection.cleanup(), + () => mains.collection.cleanup(), + () => anchors.collection.cleanup(), + ], + ) + } - const movedBack = { id: 11, parentId: 1, anchorId: 99 } - currentMains.set(11, movedBack) - routeWork.assemblies = 0 - mains.put(movedBack) - await flushPromises() - check(`main key moves out of match`) - return cuts - }, - () => [ - () => live.cleanup(), - () => parents.collection.cleanup(), - () => mains.collection.cleanup(), - () => anchors.collection.cleanup(), - ], - ) - } + const baseline = await observe([{ id: 7 }], `baseline`) + const scaled = await observe([{ id: 7 }, { id: 8 }, { id: 9 }], `scaled`) + expect(scaled.map(({ rows }) => rows)).toEqual( + baseline.map(({ rows }) => rows), + ) + expect(scaled.map(({ assemblies }) => assemblies)).toEqual( + baseline.map(({ assemblies }) => assemblies), + ) +} - const baseline = await observe([{ id: 7 }], `baseline`) - const scaled = await observe( - [{ id: 7 }, { id: 8 }, { id: 9 }], - `scaled`, - ) - expect(scaled.map(({ rows }) => rows)).toEqual( - baseline.map(({ rows }) => rows), - ) - expect(scaled.map(({ assemblies }) => assemblies)).toEqual( - baseline.map(({ assemblies }) => assemblies), - ) - }) - } - } +const outerJoinRouteWorkLaw = `A RIGHT or FULL join correlated on its main source publishes the plain-row model and does not increase parent-route assembly when unrelated joined rows are added.` + +const outerJoinRouteWorkCells = ([`right`, `full`] as const).flatMap((kind) => + ([`eager`, `onDemand`] as const).map((mode) => ({ kind, mode })), +) + +describe(`direct outer-join route work`, () => { + oracleProperty({ + id: `includes-outer-join-route-work.main-correlation`, + law: { + statement: outerJoinRouteWorkLaw, + authority: `ARCHITECTURE.md law 12 (work) in packages/db/src/query/live/ARCHITECTURE.md, stated above.`, + limits: `The four enumerated RIGHT/FULL by eager/on-demand cells of the finite fixture; no index traversal, real-provider delivery, QueryRef routing, or exact operation-count claim.`, + }, + owns: [ + `src/collection/change-events.ts#createFilterFunctionFromExpression`, + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: outerJoinRouteWorkCells, + label: ({ kind, mode }) => + `${kind} main correlation with ${mode} sources avoids work from unrelated joined rows`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(outerJoinRouteWorkLaw, plan, observe, () => + judgeOuterJoinRouteWork(plan.kind, plan.mode), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/query/includes-space-oracle.test.ts b/packages/db/tests/query/includes-space-oracle.test.ts index fdc93b9459..c5613eed8c 100644 --- a/packages/db/tests/query/includes-space-oracle.test.ts +++ b/packages/db/tests/query/includes-space-oracle.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' -import { oracleRoute } from '../oracle-config.js' +import { describe, expect } from 'vitest' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { countEntries, withSpaceFixture } from './includes-space-helpers.js' import type { AdapterState } from './includes-space-helpers.js' @@ -35,48 +39,60 @@ function expectedFacadeCount(rootCount: number): number { return rootCount + rootCount * 2 + rootCount * 2 * 5 } -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], -}) +// The existing judgment of one fixed topology: preload it, then compare every +// constructed and retained facade with the reachable-bucket count above. +async function judgeReachableFacades(rootCount: number): Promise { + await withSpaceFixture(rootCount, async (fixture, entries) => { + await fixture.live.preload() -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() + const adapters = new Set(entries.mock.contexts as Array) + const created = new Set( + entries.mock.results + .filter((result) => result.type === `return`) + .map((result) => result.value), + ) + expect(created.size).toBe(expectedFacadeCount(rootCount)) + expect(new Set([...created].map((entry) => entry.collection)).size).toBe( + expectedFacadeCount(rootCount), + ) + expect( + [...adapters].reduce((n, adapter) => n + countEntries(adapter.entries), 0), + ).toBe(expectedFacadeCount(rootCount)) + expect( + [...adapters].reduce( + (n, adapter) => n + countEntries(adapter.retiredEntries), + 0, + ), + ).toBe(0) + }) +} - const adapters = new Set(entries.mock.contexts as Array) - const created = new Set( - entries.mock.results - .filter((result) => result.type === `return`) - .map((result) => result.value), - ) - expect(created.size).toBe(expectedFacadeCount(rootCount)) - expect( - new Set([...created].map((entry) => entry.collection)).size, - ).toBe(expectedFacadeCount(rootCount)) - expect( - [...adapters].reduce( - (n, adapter) => n + countEntries(adapter.entries), - 0, - ), - ).toBe(expectedFacadeCount(rootCount)) - expect( - [...adapters].reduce( - (n, adapter) => n + countEntries(adapter.retiredEntries), - 0, - ), - ).toBe(0) - }) +const reachableFacadesLaw = `Nested Collection materialization constructs and retains exactly one facade per reachable bucket at initial publication, independent of leaf volume.` + +describe(`nested Collection materialization space oracle`, () => { + oracleProperty({ + id: `includes-space.reachable-facades`, + law: { + statement: reachableFacadesLaw, + authority: `The nested-materialization and space laws (law 13) in packages/db/src/query/live/ARCHITECTURE.md, stated above.`, + limits: `The three enumerated root counts of the fixed two-branch, five-twig topology at initial publication; no retained D2 row, demand, route-churn, or general heap claim.`, }, - ) + 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`, + ], + histories: [0, 1, 20], + label: (rootCount) => + `constructs exactly one facade per reachable bucket for ${rootCount} roots`, + resolve: (rootCount) => ({ rootCount }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(reachableFacadesLaw, plan, observe, () => + judgeReachableFacades(plan.rootCount), + ), + check: forwardAssertionJudgment, + }) }) From 0916176ecea464fadbe46862c5cb5d7b200d9f1a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:42:42 -0700 Subject: [PATCH 017/116] test(adapters): remove declareOracle calls and pass owns through wrapper calls Delete each declareOracle call and its import in the adapters group (M1). Move each declared owns list to the wrapper call that registers the file's properties, and give the five shared wrapper suites an owns option that they forward to oracleProperty (M2). The executor-environment module now exports its registration, which its test file calls with owns. Delete the two persisted-startup arbitraries that only the deleted call named. Co-authored-by: Isaac --- .../e2e/auth-app-oracle.test.ts | 3 - .../e2e/backend-oracle.test.ts | 3 - .../e2e/public-pagination-oracle.test.ts | 5 - ...source-conformance-oracle.property.test.ts | 14 +- ...aging-interleaving-oracle.property.test.ts | 4 - ...aging-load-outcome-oracle.property.test.ts | 4 - .../tests/lazy-paging-model-oracle.ts | 13 -- .../tests/lazy-paging-receipt-model.ts | 5 - ...azy-paging-receipt-oracle.property.test.ts | 4 - .../tests/lazy-paging-version-model-oracle.ts | 4 - .../lazy-paging-write-model-oracle.test.ts | 4 - .../tests/lazy-paging-write-model-oracle.ts | 4 - .../tests/pagination-oracle.property.test.ts | 8 - ...tartup-conformance-oracle.property.test.ts | 10 +- .../tests/sdk-cache-oracle.test.ts | 4 - .../eager-live-source-property-oracle.ts | 12 -- .../tests/eager-reload-oracle-suite.ts | 3 + .../tests/eager-reload-oracle.ts | 6 - .../neon-realtime-oracle.property.test.ts | 16 +- ...rsisted-baseline-lifecycle-oracle-suite.ts | 9 +- .../persisted-baseline-lifecycle-oracle.ts | 10 -- ...sted-capability-conformance-oracle.test.ts | 6 - .../persisted-capability-oracle-suite.ts | 3 + .../tests/persisted-capability-oracle.ts | 6 - .../tests/persisted-startup-oracle-suite.ts | 4 + .../tests/persisted-startup-oracle.ts | 34 ---- .../reference-adapter-oracle.property.test.ts | 16 +- ...sql-predicate-semantics-oracle.e2e.test.ts | 6 - ...source-conformance-oracle.property.test.ts | 14 +- ...ectric-descriptor-isolation-oracle.test.ts | 5 - .../tests/electric-oracle.property.test.ts | 40 ----- .../tests/electric-recovery-oracle.test.ts | 13 -- ...ctric-sdk-delivery-oracle.property.test.ts | 21 --- ...tartup-conformance-oracle.property.test.ts | 10 +- .../pg-serializer-oracle.property.test.ts | 6 - .../tests/pg-serializer-property-oracle.ts | 3 - .../tests/connectivity-replay-oracle.test.ts | 12 -- .../tests/executor-api-oracle.ts | 6 - ...ecutor-environment-oracle.property.test.ts | 5 +- .../tests/executor-environment-oracle.ts | 163 +++++++++--------- .../tests/executor-lifecycle-oracle.ts | 6 - .../tests/executor-refresh-barrier-oracle.ts | 6 - .../tests/interruption-handoff-oracle.ts | 9 - .../leadership-replay-oracle.property.test.ts | 5 - .../tests/settled-id-window-oracle.ts | 6 - ...saction-serializer-oracle.property.test.ts | 3 - .../tests/transaction-serializer-oracle.ts | 17 -- ...saction-settlement-oracle.property.test.ts | 20 --- ...source-conformance-oracle.property.test.ts | 10 -- ...reload-conformance-oracle.property.test.ts | 9 +- ...ersisted-baseline-lifecycle-oracle.test.ts | 40 ++--- ...sted-capability-conformance-oracle.test.ts | 10 +- ...tartup-conformance-oracle.property.test.ts | 10 +- ...rsor-pagination-oracle.integration.test.ts | 5 - ...ination-oracle.no-peek.integration.test.ts | 7 - .../cursor-pagination.cache-oracle.test.ts | 6 - .../tests/cursor-pagination.oracle.test.ts | 6 - ...rsor-pagination.publication-oracle.test.ts | 6 - .../tests/cursor-pagination/model-oracle.ts | 4 - .../tests/direct-write-precedence-oracle.ts | 3 - .../immediate-eager-materialization-oracle.ts | 9 - .../includes-work-counter-oracle.test.ts | 15 -- .../offline-executor-refresh-oracle.test.ts | 2 - .../optimistic-ordered-refetch-oracle.test.ts | 7 - .../tests/ownership-bottom.oracle.test.ts | 10 -- .../ownership-confirmation.oracle.test.ts | 7 - .../tests/ownership-lifecycle.oracle.test.ts | 11 -- .../tests/ownership-settlement.oracle.test.ts | 18 -- .../tests/ownership-upper.oracle.test.ts | 7 - .../persisted-direct-ownership-oracle.ts | 6 - ...rsisted-direct-write-window.oracle.test.ts | 10 -- .../tests/persisted-precedence-oracle.ts | 6 - .../persisted-rejected-write-cutoff-oracle.ts | 6 - ...rsisted-startup-conformance-oracle.test.ts | 10 +- .../refetch-ordered-prefix-oracle.test.ts | 6 - .../tests/retained-scoped-hydration-oracle.ts | 11 -- .../scope-omission-reacquisition-oracle.ts | 6 - .../tests/ssr-dehydration-oracle.test.ts | 8 - ...source-conformance-oracle.property.test.ts | 10 -- ...reload-conformance-oracle.property.test.ts | 9 +- ...ersisted-baseline-lifecycle-oracle.test.ts | 40 ++--- ...sted-capability-conformance-oracle.test.ts | 10 +- ...tartup-conformance-oracle.property.test.ts | 10 +- ...source-conformance-oracle.property.test.ts | 26 +-- .../tests/eager-live-source-oracle-suite.ts | 8 +- .../tests/lifecycle-hydration-oracle.ts | 7 - .../tests/lifecycle-judgment-oracle.ts | 3 - .../tests/lifecycle-oracle.property.test.ts | 9 - ...tartup-conformance-oracle.property.test.ts | 10 +- 89 files changed, 182 insertions(+), 821 deletions(-) diff --git a/packages/convex-db-collection/e2e/auth-app-oracle.test.ts b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts index 4ced1700e0..604f5b7095 100644 --- a/packages/convex-db-collection/e2e/auth-app-oracle.test.ts +++ b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts @@ -19,7 +19,6 @@ import { makeFunctionReference } from 'convex/server' import { createCollection } from '@tanstack/db' import { expect, inject, it } from 'vitest' import { convexCollectionOptions } from '../src/index' -import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from '../tests/helpers' declare module 'vitest' { @@ -77,8 +76,6 @@ function stepModel( return { allowed, next } } -declareOracle({ model: [stepModel] }) - const history: ReadonlyArray = [ { role: `read`, operation: `send` }, { role: `write`, operation: `send` }, diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index df1a8d4ead..0bce46af4e 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -43,7 +43,6 @@ import { import { oracleProperty } from '../../db/tests/oracle-property' import { convexPaginatedCollectionOptions } from '../src/index' import { withTestCleanup } from '../tests/helpers' -import { declareOracle } from '../../db/tests/oracle-declaration' import { all, seed } from './backend-support' import type { Task } from './backend-support' import type { Value } from 'convex/values' @@ -97,8 +96,6 @@ async function expectMirrorsAllTasks( ) } -declareOracle({ check: [expectMirrorsAllTasks] }) - describe.each([`javascript`, `react`] as const)( `real backend with %s client`, (kind) => { diff --git a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts index a872f5579f..07ca2345e1 100644 --- a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts +++ b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts @@ -44,7 +44,6 @@ import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' import { describe, expect, inject, it } from 'vitest' import { convexPaginatedCollectionOptions } from '../src/index' -import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from '../tests/helpers' import { createNetworkGate } from './network-gate' import type { PaginationOptions, PaginationResult } from 'convex/server' @@ -73,10 +72,6 @@ function expectedRows( ) } -// The model: the append-only ledger in reverse insertion order, with the -// query's author predicate and text transformation. -declareOracle({ model: [expectedRows] }) - // The backend starts with a new runtime directory and empty database. File and // test execution are sequential, so this ledger includes all successful writes // across the six histories, including rows outside the requested author scope. 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 9a1a26d6b3..9bf4492cd7 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,11 +1,6 @@ import { describe } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceHistoryArb, - eagerLiveSourceModel, -} from '../../db-adapter-conformance/src' +import {} from '../../db-adapter-conformance/src' import { checkEagerLiveSourceObservations, eagerLiveSourceHistories, @@ -35,7 +30,6 @@ import { convexConformanceDriver } from './conformance-driver' describe(`eager live-source conformance: convex`, () => { const property = `adapter-conformance.eager-live-source.convex` const driver = convexConformanceDriver() - const historyArb = eagerLiveSourceHistoryArb(driver) const runs = 400 const campaignTimeoutMs = 120_000 @@ -69,10 +63,4 @@ describe(`eager live-source conformance: convex`, () => { timeout: campaignTimeoutMs, }) } - - declareOracle({ - check: [checkEagerLiveSourceHistory], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [historyArb], - }) }) 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 71a413c922..634583992a 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 @@ -26,7 +26,6 @@ import { } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' import { oracleRoute } 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' @@ -37,9 +36,6 @@ type AssertionReach = { reach: (state: string) => void } -// 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 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 8da629363b..704922d5f1 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 @@ -29,7 +29,6 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { lazyOracleBaseRuns } from './lazy-paging-campaign' import { expectedLazyRows } from './lazy-paging-model-oracle' @@ -43,9 +42,6 @@ type AssertionReach = { reach: (state: string) => void } -// 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-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts index 57cce670e3..967c5bcec6 100644 --- a/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts +++ b/packages/convex-db-collection/tests/lazy-paging-model-oracle.ts @@ -13,7 +13,6 @@ * Limits: finite numeric rank, unique string IDs, one aligned query; provider * cache coherence, native cursor correctness and timing need receiving drivers. */ -import { declareOracle } from '../../db/tests/oracle-declaration' export type LazyRow = { id: string; rank: number; text: string } export type LazyScope = { @@ -162,15 +161,3 @@ export function assertLazyPublications( ) } } - -// The row, coverage, completeness, and cleanup models predict; the assertions -// compare production observations with them. -declareOracle({ - model: [ - expectedLazyRows, - expectedLazyCoverage, - deliveredScopeComplete, - LazyTerminalGraphModel, - ], - check: [assertLazyCoverage, assertLazySourceFacts, assertLazyPublications], -}) diff --git a/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts b/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts index 25159edcdc..0ee401903d 100644 --- a/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts +++ b/packages/convex-db-collection/tests/lazy-paging-receipt-model.ts @@ -21,7 +21,6 @@ * actual pending receipts to reject as abandoned (SyncAppliedReceipt). Merely * calling cleanup does not discharge a receipt; its rejection must be observed. */ -import { declareOracle } from '../../db/tests/oracle-declaration' export class LazyReceiptModel { readonly events: Array = [] @@ -162,7 +161,3 @@ export class LazyReceiptModel { throw new Error(`receipt law: accepted work ${id} abandoned`) } } - -// The settlement model that the receipt, interleaving, and revalidation oracles -// step: it decides which caller outcomes each history permits. -declareOracle({ model: [LazyReceiptModel] }) 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 b7bd57584e..017579655d 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 @@ -30,7 +30,6 @@ import { } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' import { oracleRoute } 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 { @@ -45,9 +44,6 @@ type AssertionReach = { reach: (state: string) => void } -// 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 diff --git a/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts index d33915a719..8ce2e6a87d 100644 --- a/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts +++ b/packages/convex-db-collection/tests/lazy-paging-version-model-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' import type { LazyRow } from './lazy-paging-model-oracle' /** @@ -54,6 +53,3 @@ export class LazyVersionModel { ) } } - -// The revision model: it decides which public observations a history permits. -declareOracle({ model: [LazyVersionModel] }) 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 63bc1896e8..cc0d098c96 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,11 +1,7 @@ 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. */ diff --git a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts index 1b31390fc9..abd1bb1eaa 100644 --- a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts +++ b/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.ts @@ -1,4 +1,3 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' import { copyLazyRows, expectedLazyRows } from './lazy-paging-model-oracle' import type { LazyRow, LazyScope } from './lazy-paging-model-oracle' @@ -134,6 +133,3 @@ export class LazyWriteModel { ) } } - -// The write ledger: it decides which public windows a write history permits. -declareOracle({ model: [LazyWriteModel] }) 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 54eff1551b..9ba2019786 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -33,7 +33,6 @@ import { import { oracleProperty } from '../../db/tests/oracle-property' import { convexPaginatedCollectionOptions } from '../src/index' import { oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { withTestCleanup } from './helpers' import { copyRows, keyedRows, provider, query } from './pagination-support' import type { Row } from './pagination-support' @@ -151,13 +150,6 @@ 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], -}) - // Calibrate both recorders' shared trace relation without imposing one callback // per revision. Repeated old/new snapshots are legal; prefixes and rollback // after the new snapshot are not, even when a final callback repairs the result. diff --git a/packages/convex-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts b/packages/convex-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts index a33a306756..5544d238ea 100644 --- a/packages/convex-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts @@ -3,7 +3,6 @@ * omissions, overlapping updates and empty results cross both restore orders. */ import { describe } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedStartupHistories, persistedStartupHistoryArb, @@ -12,18 +11,15 @@ import { registerPersistedStartupConformance } from '../../db-adapter-conformanc import { convexStartupAdapter } from './persisted-startup-driver' const historyArb = persistedStartupHistoryArb(`collection`) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/options.ts#convexCollectionOptions`], - grammar: [historyArb], -}) // Keep registration visible to Vitest's static typecheck collector as well // as its runtime collector; the shared suite registers the actual law checks. describe(`persisted startup conformance: Convex`, () => { registerPersistedStartupConformance({ property: `adapter.convex.persisted-startup`, adapter: convexStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/options.ts#convexCollectionOptions`], histories: persistedStartupHistories, arbitrary: historyArb, }) 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 63bcc71a94..6386431b20 100644 --- a/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts +++ b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts @@ -10,7 +10,6 @@ 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, @@ -19,9 +18,6 @@ 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 diff --git a/packages/db-adapter-conformance/tests/eager-live-source-property-oracle.ts b/packages/db-adapter-conformance/tests/eager-live-source-property-oracle.ts index 82b8cf2f18..5df3e211b8 100644 --- a/packages/db-adapter-conformance/tests/eager-live-source-property-oracle.ts +++ b/packages/db-adapter-conformance/tests/eager-live-source-property-oracle.ts @@ -7,14 +7,12 @@ * separately from its primary law violation (ORC-010). */ import { pass, violation } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import { ConformanceViolation, checkEagerLiveSourceHistory, describeValue, eagerLiveSourceConstructedHistories, eagerLiveSourceDataHistoryArb, - eagerLiveSourceModel, reachStatesFor, resolveEagerLiveSourceHistory, } from '../src' @@ -171,13 +169,3 @@ export function eagerLiveSourceOracle(driver: EagerLiveSourceDriver) { check: checkEagerLiveSourceObservations, } } - -declareOracle({ - check: [checkEagerLiveSourceHistory, checkEagerLiveSourceObservations], - model: [ - eagerLiveSourceModel.step, - eagerLiveSourceModel.expected, - eagerLiveSourceRequiredStates, - ], - grammar: [eagerLiveSourceDataHistoryArb, eagerLiveSourceConstructedHistories], -}) diff --git a/packages/db-adapter-conformance/tests/eager-reload-oracle-suite.ts b/packages/db-adapter-conformance/tests/eager-reload-oracle-suite.ts index 3d4bcea657..05927c23ba 100644 --- a/packages/db-adapter-conformance/tests/eager-reload-oracle-suite.ts +++ b/packages/db-adapter-conformance/tests/eager-reload-oracle-suite.ts @@ -44,6 +44,8 @@ function checkedReloadResult( export function registerEagerReloadConformance( property: string, adapter: EagerReloadAdapter, + /** The receiving adapter's production entry, forwarded to oracleProperty. */ + { owns }: { owns?: ReadonlyArray } = {}, ) { let sequence = 0 // Each construction keeps the generated coordinates that its premise does @@ -181,6 +183,7 @@ export function registerEagerReloadConformance( } oracleProperty({ id: property, + owns, law: { statement: `A complete eager source result replaces retained source membership and values; before source entry the previous public rows remain, and a persisting optimistic overlay follows the accepted-versus-applied sync contract until settlement.`, authority: `Glossary reload, snapshot transaction and accepted sync transaction; eager-source READY-3/PUB-7; optimistic-history-oracle.ts.`, diff --git a/packages/db-adapter-conformance/tests/eager-reload-oracle.ts b/packages/db-adapter-conformance/tests/eager-reload-oracle.ts index 7e1376ab20..1c669f081b 100644 --- a/packages/db-adapter-conformance/tests/eager-reload-oracle.ts +++ b/packages/db-adapter-conformance/tests/eager-reload-oracle.ts @@ -22,7 +22,6 @@ * publication claim. Drivers record before-source and fulfillment cuts. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedPersistedStartup, sortedStartupRows, @@ -206,8 +205,3 @@ export function eagerReloadViolations( if (!equal(observed.final, expectedRows)) violations.push(`final rows`) return violations } -declareOracle({ - grammar: [eagerReloadHistoryArb, eagerReloadHistories], - model: [eagerReloadModel], - check: [eagerReloadViolations], -}) 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 400ea293f0..9129797c5c 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,11 +1,6 @@ import { describe } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceHistoryArb, - eagerLiveSourceModel, -} from '../src' +import {} from '../src' import { checkEagerLiveSourceObservations, eagerLiveSourceHistories, @@ -33,7 +28,6 @@ import { neonRealtimeDriver } from './neon/neon-realtime-driver' describe(`eager live-source conformance: Neon Realtime adapter`, () => { const property = `adapter-conformance.eager-live-source.neon-realtime` const driver = neonRealtimeDriver() - const historyArb = eagerLiveSourceHistoryArb(driver) // Preserve the old 400-history fixed/fresh budgets and minimum five // declared-state entries through the shared constructed-history recipes. const runs = 400 @@ -49,12 +43,4 @@ describe(`eager live-source conformance: Neon Realtime adapter`, () => { }, timeout: campaignTimeoutMs, }) - - // The adapter under test is vendored in tests/neon; this package's src is - // the shared oracle. This receiving path owns no shipped entry point. - declareOracle({ - check: [checkEagerLiveSourceHistory], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [historyArb], - }) }) diff --git a/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite.ts b/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite.ts index 345286cabc..bc4d7ad3cf 100644 --- a/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite.ts +++ b/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite.ts @@ -13,7 +13,13 @@ import type { import type { Observation } from '../../db/tests/oracle-property' export function registerBaselineLifecycle( - config: { property: string; defaultRuns: number; streamFailure: boolean }, + config: { + property: string + defaultRuns: number + streamFailure: boolean + /** The receiving adapter's production entry, forwarded to oracleProperty. */ + owns?: ReadonlyArray + }, receive: ( h: BaselineLifecycleHistory, id: string, @@ -109,6 +115,7 @@ export function registerBaselineLifecycle( let sequence = 0 oracleProperty({ id, + owns: config.owns, law: { statement: `Cleanup retires source authority while persisted-baseline completion or an adapter acknowledgment is held. Accepted rows remain owed, replacement results are complete, resources release once, and failures remain handled.`, authority: `Shared eager-source LIFE-2/5/10, READY-4, HEALTH-5; glossary accepted sync transaction, cleanup and reload; existing TrailBase persisted-hydration row projection.`, diff --git a/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle.ts b/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle.ts index 6cea147f36..543db9eb80 100644 --- a/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle.ts +++ b/packages/db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle.ts @@ -17,7 +17,6 @@ * bound, provider service, arbitrary concurrent mutation or I/O crash claim. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedHydrationLifecycle, hydrationLifecycleRows, @@ -264,12 +263,3 @@ export function baselineLifecycleViolations( ) return violations } -declareOracle({ - grammar: [ - baselineLifecycleHistoryArb, - baselineLifecycleArbitrary, - baselineLifecycleHistories, - ], - model: [baselineLifecycleExpected], - check: [baselineLifecycleViolations], -}) diff --git a/packages/db-adapter-conformance/tests/persisted-capability-conformance-oracle.test.ts b/packages/db-adapter-conformance/tests/persisted-capability-conformance-oracle.test.ts index 45753307d6..799aec4bd3 100644 --- a/packages/db-adapter-conformance/tests/persisted-capability-conformance-oracle.test.ts +++ b/packages/db-adapter-conformance/tests/persisted-capability-conformance-oracle.test.ts @@ -1,13 +1,7 @@ /** The structural capability law receives both actual adapters and storage hosts. */ -import { declareOracle } from '../../db/tests/oracle-declaration' import { rxdbStartupAdapter } from '../../rxdb-db-collection/tests/persisted-startup-driver' import { powerSyncStartupAdapter } from '../../powersync-db-collection/tests/persisted-startup-driver' import { registerCapabilityConformance } from './persisted-capability-oracle-suite' -import { - capabilityHistories, - capabilityViolations, -} from './persisted-capability-oracle' -declareOracle({ check: [capabilityViolations], grammar: [capabilityHistories] }) registerCapabilityConformance(`rxdb`, rxdbStartupAdapter) registerCapabilityConformance(`powersync`, powerSyncStartupAdapter) diff --git a/packages/db-adapter-conformance/tests/persisted-capability-oracle-suite.ts b/packages/db-adapter-conformance/tests/persisted-capability-oracle-suite.ts index bd1927811e..e5cac1fdf7 100644 --- a/packages/db-adapter-conformance/tests/persisted-capability-oracle-suite.ts +++ b/packages/db-adapter-conformance/tests/persisted-capability-oracle-suite.ts @@ -13,10 +13,13 @@ import type { PersistedStartupHistory } from './persisted-startup-oracle' export function registerCapabilityConformance( name: string, adapter: PersistedStartupAdapter, + /** The receiving adapter's production entry, forwarded to oracleProperty. */ + { owns }: { owns?: ReadonlyArray } = {}, ) { for (const host of [`controlled`, `node-sqlite`] as const) oracleProperty({ id: `adapter.${name}.capability.${host}`, + owns, law: { statement: `An advertised persistence capability must satisfy its structural protocol before source delivery; absent metadata and an explicit null capability remain legal.`, authority: `SyncMetadataApi.persistence and sync-persistence.ts's cross-package protocol contract.`, diff --git a/packages/db-adapter-conformance/tests/persisted-capability-oracle.ts b/packages/db-adapter-conformance/tests/persisted-capability-oracle.ts index 079c89d74a..8d08869307 100644 --- a/packages/db-adapter-conformance/tests/persisted-capability-oracle.ts +++ b/packages/db-adapter-conformance/tests/persisted-capability-oracle.ts @@ -11,7 +11,6 @@ * source delivery; accepted inputs additionally complete a real source result. * This owner checks protocol admission, not row replacement or I/O failure. */ -import { declareOracle } from '../../db/tests/oracle-declaration' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' import type { PersistedCollectionPersistence } from '../../db-sqlite-persistence-core/src' import type { PersistedStartupAdapter } from './persisted-startup-driver' @@ -115,8 +114,3 @@ export async function observeCapability( return error }, [() => source.dispose()]) } -declareOracle({ - model: [capabilityMustAccept], - grammar: [capabilityHistories], - check: [capabilityViolations], -}) diff --git a/packages/db-adapter-conformance/tests/persisted-startup-oracle-suite.ts b/packages/db-adapter-conformance/tests/persisted-startup-oracle-suite.ts index 85486ddbef..865a2744ec 100644 --- a/packages/db-adapter-conformance/tests/persisted-startup-oracle-suite.ts +++ b/packages/db-adapter-conformance/tests/persisted-startup-oracle-suite.ts @@ -51,6 +51,7 @@ export function registerPersistedStartupConformance< origin = () => undefined, scopedStartupCommit = false, timeout = 60_000, + owns, }: { property: string adapter: PersistedStartupAdapter @@ -60,6 +61,8 @@ export function registerPersistedStartupConformance< timeout?: number origins?: Array origin?: (history: H) => string | undefined + /** The receiving adapter's production entry, forwarded to oracleProperty. */ + owns?: ReadonlyArray }) { const states = [ ...startupRequiredStates( @@ -364,6 +367,7 @@ export function registerPersistedStartupConformance< oracleProperty({ id: property, + owns, law: { statement: `A complete eager source result replaces its persisted baseline and is durable before later refresh; network-first fallback preserves the saved snapshot. Opted-in receivers preserve ordinary startup commits without a full replacement.`, authority: `READY-3/PUB-7; docs/collections/query-collection.md, Direct Writes and Query Sync and Initial Data; glossary, network-first initial render; maintainer shared-owner decision 2026-11-09 and batching decision 2026-11-10.`, diff --git a/packages/db-adapter-conformance/tests/persisted-startup-oracle.ts b/packages/db-adapter-conformance/tests/persisted-startup-oracle.ts index cd9d95f603..ac47d9f9ea 100644 --- a/packages/db-adapter-conformance/tests/persisted-startup-oracle.ts +++ b/packages/db-adapter-conformance/tests/persisted-startup-oracle.ts @@ -34,7 +34,6 @@ * persisted-startup-driver.ts and persisted-startup-host.ts. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { expectedEagerRowScans } from '../../db-sqlite-persistence-core/tests/reload-class-oracle' export type PersistedStartupHistory = { @@ -163,25 +162,11 @@ export function expectedPersistedStartup( // axis loses the startup race; dropping peers admits a scoped global truncate. // A cached result is already available, so its receiving grammar restricts it // to before-restore. Duplicate keys and collection-wide peers are not legal. -export const persistedStartupMembershipArb = (scope: StartupScope) => - fc.oneof( - fc.record({ - previous: fc.constant(0), - next: fc.integer({ min: 0, max: 7 }), - peer: fc.constant(false), - }), - fc.record({ - previous: fc.integer({ min: 1, max: 7 }), - next: fc.oneof(fc.constant(0), fc.integer({ min: 0, max: 7 })), - peer: scope === `scoped` ? fc.boolean() : fc.constant(false), - }), - ) // A deadline fallback requires a nonempty saved baseline (empty restore stays // loading), network-first enabled, and a held // source result. Ordinary responses retain both restore orders. The deadline's // exact threshold belongs to the persisted-readiness oracle; here zero expires // the wait while the source is held, and 60 seconds keeps it pending otherwise. -export const persistedStartupRenderArb = fc.boolean() export function withStartupRender( h: H, networkFirst: boolean, @@ -498,22 +483,3 @@ export function persistedStartupViolations( ) return violations } - -declareOracle({ - model: [expectedPersistedStartup, expectedStartupFallback], - grammar: [ - persistedStartupMembershipArb, - persistedStartupRenderArb, - withStartupRender, - withStartupMutations, - uniqueStartupHistories, - persistedStartupHistoryArb, - ], - check: [ - persistedStartupFallbackViolations, - persistedStartupCutViolations, - persistedStartupPremiseViolations, - persistedStartupViolations, - persistedStartupBatchViolations, - ], -}) 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 dc9e8007d4..f570c952e4 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,13 +1,7 @@ import { describe, expect, it } from 'vitest' import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty, pass, violation } from '../../db/tests/oracle-property' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceHistoryArb, - eagerLiveSourceModel, - reachStatesFor, -} from '../src' +import { checkEagerLiveSourceHistory, reachStatesFor } from '../src' import { checkEagerLiveSourceObservations, eagerLiveSourceHistories, @@ -56,7 +50,6 @@ import type { ReferenceMutant } from './reference-adapter' describe(`eager live-source oracle campaigns`, () => { const property = `adapter-conformance.eager-live-source.reference` const driver = referenceDriver() - const historyArb = eagerLiveSourceHistoryArb(driver) const runs = 600 const campaignTimeoutMs = 120_000 @@ -644,11 +637,4 @@ describe(`eager live-source oracle campaigns`, () => { step.kind === `snapshot` ? { kind: step.kind, rows: step.rows } : step, ), ) - - // Owns no production entry: the reference adapter is a test fixture. - declareOracle({ - check: [checkEagerLiveSourceHistory, checkEagerLiveSourceObservations], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [historyArb], - }) }) 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 65e0285e66..b12f66d01d 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 @@ -18,7 +18,6 @@ import { oracleRoute, oracleRuns, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { checkExistingElectricComparison, @@ -275,10 +274,6 @@ function evaluate(value: Predicate, row: Row): Truth { return actual <= value.value } -declareOracle({ - model: [evaluate], -}) - describe(`Electric predicate compiler semantics`, () => { let client: Client let table: string @@ -375,7 +370,6 @@ describe(`Electric predicate compiler semantics`, () => { expect(result.rows.map(({ id }) => id)).toEqual(expected) executedCells++ } - declareOracle({ check: [checkPostgresPredicate] }) beforeEach(async ({ task }) => { if (task.suite?.name !== `electric.sql-predicate-semantics`) return 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 7d059b9869..764ec447df 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,11 +1,6 @@ import { describe } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceHistoryArb, - eagerLiveSourceModel, -} from '../../db-adapter-conformance/src' +import {} from '../../db-adapter-conformance/src' import { checkEagerLiveSourceObservations, eagerLiveSourceHistories, @@ -35,7 +30,6 @@ import { electricConformanceDriver } from './conformance-driver' describe(`eager live-source conformance: electric`, () => { const property = `adapter-conformance.eager-live-source.electric` const driver = electricConformanceDriver() - const historyArb = eagerLiveSourceHistoryArb(driver) const runs = 400 const campaignTimeoutMs = 120_000 @@ -66,10 +60,4 @@ describe(`eager live-source conformance: electric`, () => { }, timeout: campaignTimeoutMs, }) - - declareOracle({ - check: [checkEagerLiveSourceHistory], - 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 e9456ed575..035ad39721 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,7 +4,6 @@ import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { checkExistingElectricComparison, @@ -635,7 +634,3 @@ oracleProperty({ // Reusing one wrapper itself is rejected before a second owner starts. The // 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], -}) 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 e92450befb..24a8285039 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -20,7 +20,6 @@ import { oracleReplayTest, oracleRoute, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { checkMutationConfirmation, @@ -4544,7 +4543,6 @@ describe(`Electric adapter laws`, () => { // The former fast-check examples include authored retry schedules outside // the mapped arbitrary's shrink domain. Keep the same shared comparison and // both former campaign executions without changing the random generator. - declareOracle({ check: [checkRetryHistory] }) oracleReplayTest(it).each(retryExamples)( `electric.retry-decision: retained fixed history %#`, checkRetryHistory, @@ -5133,41 +5131,3 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) }) - -// The checks run a history through production and compare each observation -// with the model (or the authored snapshots they are given). The models are -// independent folds of the same histories. The grammar generates the queued -// presence and persistence interleaving campaigns. -declareOracle({ - check: [ - checkMutationConfirmation, - runProcessGrammar, - runQueuedPresenceCampaign, - expectQueuedPresenceAtDurabilityHold, - checkInitialMoveOut, - runRejectedDurablePrefixWitness, - runPublicationBeforePersistenceWitness, - runPersistenceFailureCrashOnlyWitness, - assertPublicationEpochHistory, - runSchedulerPermutation, - runPersistenceInterleavingCampaign, - expectPersistenceInterleavingObservation, - ], - model: [ - expectedSnapshots, - recomputeCommittedRows, - recomputedSnapshots, - expectedQueuedPresenceRows, - foldSourceLedgerThrough, - isSdkResetFramedPartition, - applyReferenceBatch, - createRetryModel, - applyRetryModel, - expectedRetryObservation, - expectedInitialMoveOutSnapshots, - ], - grammar: [ - queuedPresenceArbitraries, - persistenceInterleavingCampaignArbitrary, - ], -}) 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 b5f1f93a8f..c418e78b89 100644 --- a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts @@ -5,7 +5,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' import { oracleFixedSeed, oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { SQLiteCorePersistenceAdapter, @@ -757,18 +756,6 @@ function expectedReplacementRows(empty: boolean): Array { return empty ? [] : [freshRow] } -declareOracle({ - check: [observePersistedRestart, assertPublicationHistory], - model: [ - expectedPostRestartObservation, - expectedDurableRow, - applyPublicationCommandToModel, - applyStreamUpdateToModel, - expectedReplacementRows, - ], - grammar: [publicationHistoryArbitrary], -}) - describe(`persisted Electric recovery laws`, () => { // The Map adapter is the driver's durable boundary. It must enact SQLite's // distinct row operations before its rows can serve as evidence for a peer 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 7b76acc690..8cb0eaed57 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 @@ -15,7 +15,6 @@ import { oracleRoute, oracleRuns, } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty, violation as oracleViolation, @@ -142,26 +141,6 @@ if ( throw new Error(`Unknown Electric SDK delivery oracle fault`) } -// The checks are the drivers that run a finite history and compare production -// with the model state at each checkpoint. The retry model is the shared -// module `electric-retry-decision-model.ts`, which predicts each observation. -declareOracle({ - check: [ - checkSnapshots, - checkMembership, - runClientRetryCell, - checkSourceRelease, - checkManagedRotation, - checkResumeCheckpoint, - ], - model: [ - createRetryModel, - applyRetryModel, - expectedRetryObservation, - expectedResumeCheckpoint, - ], -}) - // Scaled with the run multiplier, so stress campaigns keep their budget. const campaignTimeoutMs = oracleRuns(30_000) diff --git a/packages/electric-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts b/packages/electric-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts index 203a4b5f0a..b63ec8e8e0 100644 --- a/packages/electric-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts @@ -3,7 +3,6 @@ * changed overlap values and both restore orders on each storage host. */ import { describe } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedStartupHistories, persistedStartupHistoryArb, @@ -12,16 +11,13 @@ import { registerPersistedStartupConformance } from '../../db-adapter-conformanc import { electricStartupAdapter } from './persisted-startup-driver' const historyArb = persistedStartupHistoryArb(`collection`) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/electric.ts#electricCollectionOptions`], - grammar: [historyArb], -}) describe(`persisted startup conformance: Electric`, () => { registerPersistedStartupConformance({ property: `adapter.electric.persisted-startup`, adapter: electricStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/electric.ts#electricCollectionOptions`], histories: persistedStartupHistories, arbitrary: historyArb, }) 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 dcf43a7418..adac89542c 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 @@ -2,7 +2,6 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { parseFiniteNumber } from './pg-serializer-finite-decoder' import { expectFiniteArrayContents, @@ -53,11 +52,6 @@ const example = oracleReplayTest(it) * flat finite-value model. Malformed array text is a nearby rejected output. */ -declareOracle({ - check: [expectFiniteArrayContents], - owns: [`src/pg-serializer.ts#serialize`], -}) - describe(`pg-serializer property-based tests`, () => { describe(`string serialization`, () => { serializerProp(`pg-serializer.string-passthrough`, [fc.string()])( diff --git a/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts b/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts index 72ed71ce65..da6b8eadb6 100644 --- a/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts +++ b/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { oracleFixedSeed } from '../../db/tests/oracle-config' import { parseFiniteArray } from './pg-serializer-finite-decoder' @@ -48,8 +47,6 @@ export function expectFiniteArrayContents( expect(parseFiniteArray(output)).toEqual(input.map((value) => value ?? null)) } -declareOracle({ check: [expectFiniteArrayContents] }) - // Keep the moved calibration's original 25-candidate search visible to budget accounting. export const omissionCalibrationOptions: { numRuns: number; seed: number } = oracleFixedSeed(90701, { diff --git a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts index c4a31bff47..ac87e11846 100644 --- a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts +++ b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts @@ -1,6 +1,5 @@ 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 { @@ -48,17 +47,6 @@ function expectedAt(phase: `hidden` | `visible`): { : { providerCalls: 1, outboxEntries: 0, callerState: `fulfilled` } } -// 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() diff --git a/packages/offline-transactions/tests/executor-api-oracle.ts b/packages/offline-transactions/tests/executor-api-oracle.ts index a07dc54fbc..2288a5aadd 100644 --- a/packages/offline-transactions/tests/executor-api-oracle.ts +++ b/packages/offline-transactions/tests/executor-api-oracle.ts @@ -2,7 +2,6 @@ import fc from 'fast-check' import { createCollection } from '@tanstack/db' import { vi } from 'vitest' import { startOfflineExecutor } from '../src/OfflineExecutor' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { FakeStorageAdapter, gate, turn } from './harness' import { cleanupOfflineOracle } from './oracle-lifecycle' @@ -748,8 +747,3 @@ oracleProperty>({ }, ], }) -declareOracle({ - grammar: [apiHistory, controlHistory], - model: [apiModel, controlsModel], - check: [checkApi, checkControls], -}) diff --git a/packages/offline-transactions/tests/executor-environment-oracle.property.test.ts b/packages/offline-transactions/tests/executor-environment-oracle.property.test.ts index acaed23b0e..56ed22baa9 100644 --- a/packages/offline-transactions/tests/executor-environment-oracle.property.test.ts +++ b/packages/offline-transactions/tests/executor-environment-oracle.property.test.ts @@ -1,7 +1,6 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' -import './executor-environment-oracle' +import { registerExecutorEnvironment } from './executor-environment-oracle' // Keep bounded initialization observations in a separate receiving unit. // A different lifecycle owner's missing-init wait can time out without erasing // this owner's named initialization-completion or unhandled-rejection verdict. -declareOracle({ owns: [`src/OfflineExecutor.ts`] }) +registerExecutorEnvironment({ owns: [`src/OfflineExecutor.ts`] }) diff --git a/packages/offline-transactions/tests/executor-environment-oracle.ts b/packages/offline-transactions/tests/executor-environment-oracle.ts index 8d943a770f..dde4806813 100644 --- a/packages/offline-transactions/tests/executor-environment-oracle.ts +++ b/packages/offline-transactions/tests/executor-environment-oracle.ts @@ -5,7 +5,6 @@ import { IndexedDBAdapter } from '../src/storage/IndexedDBAdapter' import { LocalStorageAdapter } from '../src/storage/LocalStorageAdapter' import { WebLocksLeader } from '../src/coordination/WebLocksLeader' import { BroadcastChannelLeader } from '../src/coordination/BroadcastChannelLeader' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -736,11 +735,6 @@ function checkEnvironment( return pass } -declareOracle({ - grammar: [environmentHistory], - model: [environmentModel], - check: [checkEnvironment], -}) const environmentStates = [ `storage custom`, `storage indexeddb`, @@ -842,80 +836,89 @@ function constructEnvironment( } return base } -oracleProperty>({ - id: `offline.executor-environment`, - law: { - statement: `Initialization selects configured or preferred available storage and coordination, reports public diagnostics and callbacks, gates offline admission on leadership, and settles across disposal or dependency failure.`, - authority: `Offline transactions guide Storage and leadership/React Native and Expo; README storage configuration; public StorageDiagnostic and waitForInit contracts; established storage-failure and unread-init tests.`, - limits: `Controlled adapter probes and election collaborators; no native browser durability/election claim, no exact diagnostic wording, no mixed security/quota diagnostic precedence, no startup non-Error normalization promise or failed-replay availability law.`, - }, - histories: { - arbitrary: environmentHistory, - runs: 100, - fixedSeeds: [20261110], - states: environmentStates, - constructed: environmentStates.map((state) => ({ - name: state, - enters: [state], - build: (generated) => constructEnvironment(state, generated), - })), - }, - model: environmentModel, - driver: driveEnvironment, - check: checkEnvironment, - calibrations: [ - { - name: `initialization silently leaves its caller pending`, - law: `initialization completion`, - corrupt: (trace) => - trace.map((entry) => - entry.checkpoint === `initialized` - ? { - ...entry, - value: { - ...(entry.value as EnvironmentCut), - initialized: `pending`, - }, - } - : entry, - ), +/** Registers the environment campaign. The receiving test file names the + * production code that it owns. */ +export function registerExecutorEnvironment({ + owns, +}: { + owns: ReadonlyArray +}): void { + oracleProperty>({ + owns, + id: `offline.executor-environment`, + law: { + statement: `Initialization selects configured or preferred available storage and coordination, reports public diagnostics and callbacks, gates offline admission on leadership, and settles across disposal or dependency failure.`, + authority: `Offline transactions guide Storage and leadership/React Native and Expo; README storage configuration; public StorageDiagnostic and waitForInit contracts; established storage-failure and unread-init tests.`, + limits: `Controlled adapter probes and election collaborators; no native browser durability/election claim, no exact diagnostic wording, no mixed security/quota diagnostic precedence, no startup non-Error normalization promise or failed-replay availability law.`, }, - { - name: `provisional diagnostics omit their public message`, - law: `diagnostic shape`, - corrupt: (trace) => - trace.map((entry) => - entry.checkpoint === `constructed` - ? { - ...entry, - value: { - ...(entry.value as EnvironmentCut), - diagnostic: { - ...(entry.value as EnvironmentCut).diagnostic, - message: undefined, - }, - }, - } - : entry, - ), + histories: { + arbitrary: environmentHistory, + runs: 100, + fixedSeeds: [20261110], + states: environmentStates, + constructed: environmentStates.map((state) => ({ + name: state, + enters: [state], + build: (generated) => constructEnvironment(state, generated), + })), }, - { - name: `leadership becomes enabled after disposal`, - law: `leadership admission`, - corrupt: (trace, plan) => - plan.fault !== `none` - ? undefined - : trace.map((entry) => - entry.checkpoint === `disposed` - ? { - ...entry, - value: { - ...(entry.value as EnvironmentCut), - enabled: true, + model: environmentModel, + driver: driveEnvironment, + check: checkEnvironment, + calibrations: [ + { + name: `initialization silently leaves its caller pending`, + law: `initialization completion`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `initialized` + ? { + ...entry, + value: { + ...(entry.value as EnvironmentCut), + initialized: `pending`, + }, + } + : entry, + ), + }, + { + name: `provisional diagnostics omit their public message`, + law: `diagnostic shape`, + corrupt: (trace) => + trace.map((entry) => + entry.checkpoint === `constructed` + ? { + ...entry, + value: { + ...(entry.value as EnvironmentCut), + diagnostic: { + ...(entry.value as EnvironmentCut).diagnostic, + message: undefined, }, - } - : entry, - ), - }, - ], -}) + }, + } + : entry, + ), + }, + { + name: `leadership becomes enabled after disposal`, + law: `leadership admission`, + corrupt: (trace, plan) => + plan.fault !== `none` + ? undefined + : trace.map((entry) => + entry.checkpoint === `disposed` + ? { + ...entry, + value: { + ...(entry.value as EnvironmentCut), + enabled: true, + }, + } + : entry, + ), + }, + ], + }) +} diff --git a/packages/offline-transactions/tests/executor-lifecycle-oracle.ts b/packages/offline-transactions/tests/executor-lifecycle-oracle.ts index 19deae511e..54c3778166 100644 --- a/packages/offline-transactions/tests/executor-lifecycle-oracle.ts +++ b/packages/offline-transactions/tests/executor-lifecycle-oracle.ts @@ -2,7 +2,6 @@ import fc from 'fast-check' import { createCollection } from '@tanstack/db' import { vi } from 'vitest' import { startOfflineExecutor } from '../src/OfflineExecutor' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { FakeStorageAdapter, gate, turn } from './harness' import { cleanupOfflineOracle } from './oracle-lifecycle' @@ -341,11 +340,6 @@ const lifecycleStates = [ `duplicate report`, `stale report`, ] -declareOracle({ - grammar: [lifecycleHistory], - model: [lifecycleModel], - check: [checkLifecycle], -}) oracleProperty>({ id: `offline.executor-coordination-lifecycle`, law: { diff --git a/packages/offline-transactions/tests/executor-refresh-barrier-oracle.ts b/packages/offline-transactions/tests/executor-refresh-barrier-oracle.ts index 89e9891d62..3b3027c3a1 100644 --- a/packages/offline-transactions/tests/executor-refresh-barrier-oracle.ts +++ b/packages/offline-transactions/tests/executor-refresh-barrier-oracle.ts @@ -1,5 +1,4 @@ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, pass } from '../../db/tests/oracle-property' import type { Observation } from '../../db/tests/oracle-property' @@ -141,8 +140,3 @@ export function checkBarrier( } return pass } -declareOracle({ - grammar: [barrierHistory], - model: [barrierModel], - check: [checkBarrier], -}) diff --git a/packages/offline-transactions/tests/interruption-handoff-oracle.ts b/packages/offline-transactions/tests/interruption-handoff-oracle.ts index 25578d0efe..eb069d4ced 100644 --- a/packages/offline-transactions/tests/interruption-handoff-oracle.ts +++ b/packages/offline-transactions/tests/interruption-handoff-oracle.ts @@ -5,7 +5,6 @@ import { vi } from 'vitest' import { startOfflineExecutor } from '../src/OfflineExecutor' import { OutboxManager } from '../src/outbox/OutboxManager' import { NonRetriableError } from '../src/types' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, @@ -802,11 +801,6 @@ function checkHandoff( return pass } -declareOracle({ - grammar: [handoffHistory], - model: [handoffModel], - check: [checkHandoff], -}) const states = [ ...paths.map((path) => `path ${path}`), `origin live`, @@ -1119,7 +1113,6 @@ const issuedHandoffHistory: fc.Arbitrary = fc.record({ replacements: fc.integer({ min: 1, max: 3 }), replaceCollection: fc.constant(false), }) -declareOracle({ grammar: [issuedHandoffHistory] }) const issuedHandoffStates = [ ...paths.map((path) => `path ${path}`), `origin live`, @@ -1232,7 +1225,6 @@ const reviewHandoffStates = [ `peer after subscriber exception`, `live offline predecessor`, ] -declareOracle({ grammar: [reviewHandoffHistory] }) oracleProperty>({ id: `offline.disposal-review-boundaries`, law: { @@ -1376,7 +1368,6 @@ async function driveHandoffResources( } } -declareOracle({ grammar: [resourceHistory] }) const resourceStates = [ ...paths.map((path) => `path ${path}`), `origin live`, 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 19570b7800..3f1d1da40b 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -7,7 +7,6 @@ import { OfflineExecutor } from '../src/OfflineExecutor' import { TransactionExecutor } from '../src/executor/TransactionExecutor' import { oracleRoute } from '../../db/tests/oracle-config' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import { FakeStorageAdapter, createTestOfflineEnvironment, @@ -205,10 +204,6 @@ function storedCalls(count: number, payload: string) { metadata: { payload }, })) } -declareOracle({ - check: [checkLeadershipTrace], - model: [foldReplayLedger], -}) describe.each([`construction`, `leadership`, `outbox read`, `retry hook`])( `disposed startup at %s`, diff --git a/packages/offline-transactions/tests/settled-id-window-oracle.ts b/packages/offline-transactions/tests/settled-id-window-oracle.ts index f21eaf7283..eed2973dab 100644 --- a/packages/offline-transactions/tests/settled-id-window-oracle.ts +++ b/packages/offline-transactions/tests/settled-id-window-oracle.ts @@ -1,5 +1,4 @@ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { createTestOfflineEnvironment, turn } from './harness' import { cleanupOfflineOracle } from './oracle-lifecycle' @@ -153,11 +152,6 @@ function checkSettledWindow( return pass } -declareOracle({ - grammar: [history], - model: [settledWindowModel], - check: [checkSettledWindow], -}) const states = [ ...paths.map((path) => `path ${path}`), `below horizon`, 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 0049781bcc..85bb82b6c9 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -5,7 +5,6 @@ import { describe, expect, it, vi } from 'vitest' import { OutboxManager } from '../src/outbox/OutboxManager' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' import { oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -87,8 +86,6 @@ import type { * Retained-storage checks use FakeStorageAdapter, not browser storage. */ -declareOracle({ check: [checkRoundtrip, checkCodec] }) - it(`preserves marker-shaped user data through current wire encoding`, async () => { oracleRoute(`case`) const runtime = { diff --git a/packages/offline-transactions/tests/transaction-serializer-oracle.ts b/packages/offline-transactions/tests/transaction-serializer-oracle.ts index 591e58d9d5..2c5f4b3623 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.ts @@ -3,7 +3,6 @@ import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' import { expect } from 'vitest' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' import { cleanupOfflineOracle } from './oracle-lifecycle' import { readOfflineOracleConfig } from './oracle-config' @@ -511,22 +510,6 @@ async function checkRoundtrip( if (cleanupFailure !== undefined) throw cleanupFailure } -// 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. -// 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`, wire: `2024-01-01T00:00:00.000Z`, 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 2b33b0ed26..9157bc66ae 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -12,7 +12,6 @@ import { import { NonRetriableError } from '../src/types' import { OutboxManager } from '../src/outbox/OutboxManager' import { oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -2564,22 +2563,3 @@ oracleProperty({ ), check: forwardAssertionJudgment, }) - -// The checks run generated histories and compare production with each model's -// prediction at every checkpoint. The models state the settlement rules: the -// 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({ - check: [ - checkSettlement, - checkRetryDecision, - checkRetryRecordFailure, - checkDurableAdmissionFailure, - checkRestoredState, - ], - model: [expectedRetryDecision, expectedRestoredState, expectedStartedPeer], - grammar: [restoredRows, restoredHistories], -}) 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 8df0671e1a..465076af7b 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 @@ -9,11 +9,6 @@ * builder returns fresh identities and preserves generated input variation. */ import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceModel, -} from '../../db-adapter-conformance/src' import { eagerLiveSourceCampaign } from '../../db-adapter-conformance/tests/eager-live-source-oracle-suite' import { constructPowerSyncEagerHistory } from './eager-live-source-history-oracle' import { powerSyncConformanceDriver } from './conformance-driver' @@ -43,8 +38,3 @@ oracleProperty({ calibrations: campaign.calibrations, timeout: 600000, }) -declareOracle({ - check: [checkEagerLiveSourceHistory, campaign.check], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [campaign.arbitrary], -}) diff --git a/packages/powersync-db-collection/tests/eager-reload-conformance-oracle.property.test.ts b/packages/powersync-db-collection/tests/eager-reload-conformance-oracle.property.test.ts index b9f796b999..82d0d73bdd 100644 --- a/packages/powersync-db-collection/tests/eager-reload-conformance-oracle.property.test.ts +++ b/packages/powersync-db-collection/tests/eager-reload-conformance-oracle.property.test.ts @@ -1,14 +1,11 @@ /** PowerSync receives complete eager reload replacement through local SQLite. */ -import { declareOracle } from '../../db/tests/oracle-declaration' -import { eagerReloadHistoryArb } from '../../db-adapter-conformance/tests/eager-reload-oracle' import { registerEagerReloadConformance } from '../../db-adapter-conformance/tests/eager-reload-oracle-suite' import { powerSyncEagerReloadAdapter } from './eager-reload-driver' -declareOracle({ - owns: [`src/powersync.ts#powerSyncCollectionOptions`], - grammar: [eagerReloadHistoryArb], -}) registerEagerReloadConformance( `adapter.powersync.eager-reload`, powerSyncEagerReloadAdapter, + { + owns: [`src/powersync.ts#powerSyncCollectionOptions`], + }, ) diff --git a/packages/powersync-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts b/packages/powersync-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts index 4ca97e5c24..7f73e01325 100644 --- a/packages/powersync-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts +++ b/packages/powersync-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts @@ -1,16 +1,9 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' -import { baselineLifecycleViolations } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' import { createStartupHost } from '../../db-adapter-conformance/tests/persisted-startup-host' import { observeBaselineLifecycle } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-driver' import { registerBaselineLifecycle } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite' import { powerSyncBaselineLifecycleAdapter } from './persisted-baseline-lifecycle-driver' -declareOracle({ - owns: [`src/powersync.ts#powerSyncCollectionOptions`], - check: [baselineLifecycleViolations], -}) - for (const { host, config } of [ { host: `controlled`, @@ -29,19 +22,22 @@ for (const { host, config } of [ }, }, ] as const) { - registerBaselineLifecycle(config, async (h, id) => { - const storage = createStartupHost(host) - return withOracleCleanup( - () => - observeBaselineLifecycle( - powerSyncBaselineLifecycleAdapter, - storage.persistence, - storage.holdBaseline, - h, - id, - process.env.TANSTACK_DB_BASELINE_EXPECT_HYDRATE !== `0`, - ), - [() => storage.close()], - ) - }) + registerBaselineLifecycle( + { ...config, owns: [`src/powersync.ts#powerSyncCollectionOptions`] }, + async (h, id) => { + const storage = createStartupHost(host) + return withOracleCleanup( + () => + observeBaselineLifecycle( + powerSyncBaselineLifecycleAdapter, + storage.persistence, + storage.holdBaseline, + h, + id, + process.env.TANSTACK_DB_BASELINE_EXPECT_HYDRATE !== `0`, + ), + [() => storage.close()], + ) + }, + ) } diff --git a/packages/powersync-db-collection/tests/persisted-capability-conformance-oracle.test.ts b/packages/powersync-db-collection/tests/persisted-capability-conformance-oracle.test.ts index bc66c821f6..2c8c00aebe 100644 --- a/packages/powersync-db-collection/tests/persisted-capability-conformance-oracle.test.ts +++ b/packages/powersync-db-collection/tests/persisted-capability-conformance-oracle.test.ts @@ -1,15 +1,7 @@ /** Package-local ownership makes the real protocol receiver visible to gap audits. */ -import { declareOracle } from '../../db/tests/oracle-declaration' import { registerCapabilityConformance } from '../../db-adapter-conformance/tests/persisted-capability-oracle-suite' -import { - capabilityHistories, - capabilityViolations, -} from '../../db-adapter-conformance/tests/persisted-capability-oracle' import { powerSyncStartupAdapter } from './persisted-startup-driver' -declareOracle({ +registerCapabilityConformance(`powersync`, powerSyncStartupAdapter, { owns: [`src/powersync.ts#powerSyncCollectionOptions`], - check: [capabilityViolations], - grammar: [capabilityHistories], }) -registerCapabilityConformance(`powersync`, powerSyncStartupAdapter) diff --git a/packages/powersync-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts b/packages/powersync-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts index 1c0ef10707..9e230e647d 100644 --- a/packages/powersync-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts +++ b/packages/powersync-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts @@ -3,7 +3,6 @@ * changed overlap values and both restore orders on each storage host. */ import { describe } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedStartupBatchHistories, persistedStartupHistoryArb, @@ -12,16 +11,13 @@ import { registerPersistedStartupConformance } from '../../db-adapter-conformanc import { powerSyncStartupAdapter } from './persisted-startup-driver' const historyArb = persistedStartupHistoryArb(`collection`, true, false, true) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/powersync.ts#powerSyncCollectionOptions`], - grammar: [historyArb], -}) describe(`persisted startup conformance: PowerSync`, () => { registerPersistedStartupConformance({ property: `adapter.powersync.persisted-startup`, adapter: powerSyncStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/powersync.ts#powerSyncCollectionOptions`], histories: persistedStartupBatchHistories, arbitrary: historyArb, scopedStartupCommit: true, 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 6d72e1a218..a616b8a3b9 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 @@ -8,7 +8,6 @@ import { oracleProperty, pass, } from '../../db/tests/oracle-property.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 { @@ -119,10 +118,6 @@ function checkManualWriteTrace( ) } -declareOracle({ - 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 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 fe233b39a0..13897dc0fb 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,7 +22,6 @@ 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' @@ -32,12 +31,6 @@ 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 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 de9a90aa67..cded4c79b8 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 @@ -8,7 +8,6 @@ import { oracleProperty, pass, } from '../../db/tests/oracle-property.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' @@ -56,11 +55,6 @@ async function checkWindow( ).toEqual(expectedRows(source, scope, { offset: 0, limit: width })) } -declareOracle({ - check: [checkWindow], - model: [expectedRows], -}) - // The reference above still owns row truth. These native traces carry the // same values, identity witnesses and request counts at the old checkpoints. const cacheLaw = { 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 2a2faf075b..45f6ee3ea2 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -7,7 +7,6 @@ import { } from '../../db/tests/oracle-property.js' import { createDeferred } from '../../db/src/deferred.js' import { oracleRoute } 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' @@ -405,11 +404,6 @@ describe(`opaque cursor adapter`, () => { }) }) -declareOracle({ - check: [checkRead], - model: [expectedRows], -}) - // --------------------------------------------------------------------------- // Read histories over one pager, on oracleProperty // --------------------------------------------------------------------------- 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 79b6f71ef3..a90e252fc4 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 @@ -8,7 +8,6 @@ import { pass, violation, } from '../../db/tests/oracle-property.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' @@ -98,11 +97,6 @@ async function driveCachedSlice( } } -declareOracle({ - check: [checkPublicationTrace], - model: [expectedRows], -}) - // The native check compares the same public facts in checkpoint order. It // accepts a cut trace, so a later runtime or cleanup error cannot hide an // already recorded mismatch. The final count checks normal driver completion. diff --git a/packages/query-db-collection/tests/cursor-pagination/model-oracle.ts b/packages/query-db-collection/tests/cursor-pagination/model-oracle.ts index 0ee0e05e1c..3c7582d644 100644 --- a/packages/query-db-collection/tests/cursor-pagination/model-oracle.ts +++ b/packages/query-db-collection/tests/cursor-pagination/model-oracle.ts @@ -1,5 +1,3 @@ -import { declareOracle } from '../../../db/tests/oracle-declaration.js' - export type Row = { id: number; rank: number; group: number } export type Scope = { group: number | undefined; descending: boolean } export type Window = { offset: number; limit: number | undefined } @@ -36,5 +34,3 @@ export function expectedWindow( const all = expectedRows(source, scope, { offset: 0, limit: undefined }) return { rows: all.slice(0, n), hasNextPage: all.length > n } } - -declareOracle({ model: [expectedRows, expectedWindow] }) diff --git a/packages/query-db-collection/tests/direct-write-precedence-oracle.ts b/packages/query-db-collection/tests/direct-write-precedence-oracle.ts index ab1caab7d7..81e6526f31 100644 --- a/packages/query-db-collection/tests/direct-write-precedence-oracle.ts +++ b/packages/query-db-collection/tests/direct-write-precedence-oracle.ts @@ -6,7 +6,6 @@ * oracle's existing overlay judgment, shared with its persisted receivers. * It models authored rows and writes, never Query generations or cache events. */ -import { declareOracle } from '../../db/tests/oracle-declaration' export type MergeRow = { id: string; value: Value } export type PrecedenceWrite = @@ -25,5 +24,3 @@ export function mergeDirectWritesOverFetch( [...rows.values()].map((row) => [row.id, row.value]), ) } - -declareOracle({ model: [mergeDirectWritesOverFetch] }) diff --git a/packages/query-db-collection/tests/immediate-eager-materialization-oracle.ts b/packages/query-db-collection/tests/immediate-eager-materialization-oracle.ts index a07b2e1915..2ab15d61e4 100644 --- a/packages/query-db-collection/tests/immediate-eager-materialization-oracle.ts +++ b/packages/query-db-collection/tests/immediate-eager-materialization-oracle.ts @@ -19,7 +19,6 @@ * No persistence, mutations, stale data, fetching, or promise timing is claimed. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import type { ConstructedHistory, Observation, @@ -319,11 +318,3 @@ export function immediateEagerMaterializationOptions( ], } } -declareOracle({ - model: [expectedImmediateMaterialization], - grammar: [ - immediateMaterializationHistoryArb, - immediateMaterializationHistories, - ], - check: [checkImmediateMaterialization], -}) 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 d51b7bc487..5295d65216 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,7 +8,6 @@ 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' @@ -504,20 +503,6 @@ 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. diff --git a/packages/query-db-collection/tests/offline-executor-refresh-oracle.test.ts b/packages/query-db-collection/tests/offline-executor-refresh-oracle.test.ts index f219a8b706..2d8b449673 100644 --- a/packages/query-db-collection/tests/offline-executor-refresh-oracle.test.ts +++ b/packages/query-db-collection/tests/offline-executor-refresh-oracle.test.ts @@ -1,5 +1,4 @@ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty } from '../../db/tests/oracle-property' import { barrierModel, @@ -23,7 +22,6 @@ const queryBarrierHistory: fc.Arbitrary = fc.record({ retire: fc.constant(false), notifications: fc.integer({ min: 1, max: 3 }), }) -declareOracle({ grammar: [queryBarrierHistory] }) const queryBarrierStates = [ `pending refresh barrier`, `stale refetch delivered`, 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 f4c72e3aa4..10a397d577 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 @@ -9,7 +9,6 @@ import { expect, it, vi } from 'vitest' 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 { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { withOracleCleanup } from '../../db/tests/utils' @@ -355,12 +354,6 @@ function checkOrderedMutationObservation( ) } -declareOracle({ - check: [checkOrderedMutationTrace, checkOrderedMutationObservation], - model: [expectedPublicWindow], - grammar: [orderedMutationHistories], -}) - // Recompute the original immediate and settled windows from the authored // operation and handler ending. Request shapes stay observations of the real // adapter; they do not decide the independent row ledger. diff --git a/packages/query-db-collection/tests/ownership-bottom.oracle.test.ts b/packages/query-db-collection/tests/ownership-bottom.oracle.test.ts index b24fd90b66..e19cbcc28f 100644 --- a/packages/query-db-collection/tests/ownership-bottom.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-bottom.oracle.test.ts @@ -18,7 +18,6 @@ import { hashKey } from '@tanstack/query-core' import { createCollection } from '@tanstack/db' import { afterEach, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -286,15 +285,6 @@ function supersededCheck( ) } -declareOracle({ - model: [ - mergeCutModel, - emptyBaselineModel, - coldBaselineModel, - supersededModel, - ], - check: [checkCuts, supersededCheck], -}) oracleProperty({ id: 'query-db.ownership.older-fetch-merge', law: { diff --git a/packages/query-db-collection/tests/ownership-confirmation.oracle.test.ts b/packages/query-db-collection/tests/ownership-confirmation.oracle.test.ts index 9847532e03..0361e4f057 100644 --- a/packages/query-db-collection/tests/ownership-confirmation.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-confirmation.oracle.test.ts @@ -7,7 +7,6 @@ import { pass, violation, } from '../../db/tests/oracle-property.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { withOracleCleanup } from '../../db/tests/utils.js' import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support.js' @@ -1069,9 +1068,3 @@ oracleProperty({ ), ], }) - -declareOracle({ - model: [mutationModel, postModel], - check: [checkMutation, checkPost], - grammar: [mutationArbitrary, postArbitrary], -}) 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 a2f39753eb..9ed078b5ff 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -27,7 +27,6 @@ import { getLoadSubsetDemandKey, } from '@tanstack/db' import { afterEach, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -297,16 +296,6 @@ const publishedOwnerHistories: Array = ( * dehydrate/hydrate histories. Neither family had a generated-run budget or * literal reach table to retire. Publication holds and row observations are * the original real QueryClient cuts, now judged by the framework check. */ -declareOracle({ - model: [ - modelReacquisition, - modelPublishedOwners, - lifetimeModel, - directOrderModel, - overlapModel, - ], - check: [checkCuts], -}) oracleProperty({ id: 'query-db.ownership.reacquisition', law: { diff --git a/packages/query-db-collection/tests/ownership-settlement.oracle.test.ts b/packages/query-db-collection/tests/ownership-settlement.oracle.test.ts index 2c90f0efe7..1a0bda5f5c 100644 --- a/packages/query-db-collection/tests/ownership-settlement.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-settlement.oracle.test.ts @@ -20,7 +20,6 @@ import { hashKey, isCancelledError } from '@tanstack/query-core' import { createCollection } from '@tanstack/db' import { afterEach, vi } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { differs, oracleProperty, @@ -515,23 +514,6 @@ function invalidResultCheck( ) ?? pass ) } -declareOracle({ - model: [ - emptySettlementModel, - deferredSettlementModel, - cleanupSettlementModel, - recoveredSettlementModel, - eagerRecoveredSettlementModel, - retainedInvalidSettlementModel, - overlappingDeferralModel, - independentSettlementModel, - applicationFailureModel, - handlerFailureModel, - reentrantSettlementModel, - invalidResultModel, - ], - check: [checkCuts, invalidResultCheck], -}) oracleProperty({ id: 'query-db.ownership.application-failure', law: { diff --git a/packages/query-db-collection/tests/ownership-upper.oracle.test.ts b/packages/query-db-collection/tests/ownership-upper.oracle.test.ts index 2ea833caa2..301f54784b 100644 --- a/packages/query-db-collection/tests/ownership-upper.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-upper.oracle.test.ts @@ -14,7 +14,6 @@ import { pass, violation, } from '../../db/tests/oracle-property.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { createDeferred } from '../../db/src/deferred.js' import { queryCollectionOptions } from '../src/query.js' import { @@ -2017,9 +2016,3 @@ oracleProperty({ }, ], }) - -declareOracle({ - model: [phaseModel, explicitModel, refreshModel, identityModel, workModel], - check: [compareCuts], - grammar: [explicitHistories, refreshHistories, identityHistories], -}) diff --git a/packages/query-db-collection/tests/persisted-direct-ownership-oracle.ts b/packages/query-db-collection/tests/persisted-direct-ownership-oracle.ts index a29f8acd8a..368b3fb2fe 100644 --- a/packages/query-db-collection/tests/persisted-direct-ownership-oracle.ts +++ b/packages/query-db-collection/tests/persisted-direct-ownership-oracle.ts @@ -23,7 +23,6 @@ * or delete. This boundary law constrains neither write counts nor ordering. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { mergeDirectWritesOverFetch } from './direct-write-precedence-oracle' import type { ConstructedHistory, @@ -512,8 +511,3 @@ export function persistedDirectOwnershipOptions( ], } } -declareOracle({ - model: [expectedDirectOwnership], - grammar: [directOwnershipHistoryArb, directOwnershipHistories], - check: [checkDirectOwnership], -}) 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 961bce38e9..57fd6f5bb0 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 @@ -2,7 +2,6 @@ import { describe, expect, it, vi } from 'vitest' 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, @@ -449,15 +448,6 @@ async function compareWriteWithReference( ) } -declareOracle({ - owns: [`src/query.ts#queryCollectionOptions`], - check: [ - compareWriteWithReference, - compareLaterRefetchOrder, - compareDuplicateInsert, - ], -}) - // A mutant that reports readiness but discards the first source transaction // commits its empty metadata transaction and must fail the same comparison. // Selecting its causal schedule diff --git a/packages/query-db-collection/tests/persisted-precedence-oracle.ts b/packages/query-db-collection/tests/persisted-precedence-oracle.ts index 50bed8a6a6..ec82c73097 100644 --- a/packages/query-db-collection/tests/persisted-precedence-oracle.ts +++ b/packages/query-db-collection/tests/persisted-precedence-oracle.ts @@ -10,7 +10,6 @@ * independently. These legality constraints avoid duplicate or invalid histories. * Three saved keys, one inserted key, one sync run, no failed I/O or peer claims. */ -import { declareOracle } from '../../db/tests/oracle-declaration' import { mergeDirectWritesOverFetch } from './direct-write-precedence-oracle' import type { MergeRow, @@ -200,8 +199,3 @@ export const precedenceReached = (h: PrecedenceHistory) => [ h.op, ...(h.laterFetch ? [`later-fetch`] : []), ] -declareOracle({ - model: [expectedPersistedPrecedence], - grammar: [precedenceHistories], - check: [precedenceViolations], -}) diff --git a/packages/query-db-collection/tests/persisted-rejected-write-cutoff-oracle.ts b/packages/query-db-collection/tests/persisted-rejected-write-cutoff-oracle.ts index 19c1859dce..7b21b55e60 100644 --- a/packages/query-db-collection/tests/persisted-rejected-write-cutoff-oracle.ts +++ b/packages/query-db-collection/tests/persisted-rejected-write-cutoff-oracle.ts @@ -24,7 +24,6 @@ import { untilStartup, } from '../../db-adapter-conformance/tests/persisted-startup-driver' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' -import { declareOracle } from '../../db/tests/oracle-declaration' import type { PersistedCollectionPersistence } from '../../db-sqlite-persistence-core/src' import type { ConstructedHistory } from '../../db/tests/oracle-property' import type { TimeoutProvider } from '@tanstack/query-core' @@ -511,8 +510,3 @@ export function cutoffViolations( '; secondary browser errors: ' + JSON.stringify(diagnostics) return failures } -declareOracle({ - model: [expected], - grammar: [arbitrary], - check: [cutoffViolations], -}) diff --git a/packages/query-db-collection/tests/persisted-startup-conformance-oracle.test.ts b/packages/query-db-collection/tests/persisted-startup-conformance-oracle.test.ts index 14d58478da..736389f48a 100644 --- a/packages/query-db-collection/tests/persisted-startup-conformance-oracle.test.ts +++ b/packages/query-db-collection/tests/persisted-startup-conformance-oracle.test.ts @@ -5,7 +5,6 @@ */ import { describe } from 'vitest' import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' import { registerPersistedStartupConformance } from '../../db-adapter-conformance/tests/persisted-startup-oracle-suite' import { queryStartupAdapter, @@ -50,18 +49,15 @@ const queryStartupHistoryArb: fc.Arbitrary = fc (h.initialRender === `network-first` && h.previous !== 0)), ) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/query.ts#queryCollectionOptions`], - grammar: [queryStartupHistoryArb], -}) // Keep registration visible to Vitest's static typecheck collector as well // as its runtime collector; the shared suite registers the actual law checks. describe(`persisted startup conformance: Query`, () => { registerPersistedStartupConformance({ property: `query.startup-ownership`, adapter: queryStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/query.ts#queryCollectionOptions`], histories: queryStartupHistories, arbitrary: queryStartupHistoryArb, origins: [ 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 9c2cdcee5a..30a53f0fde 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 @@ -15,7 +15,6 @@ import { oracleReplayRequested, oracleRoute, } from '../../db/tests/oracle-config.js' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { observeComparison, recordedComparison, @@ -1254,11 +1253,6 @@ async function withDeadline(work: Promise): Promise { } const property = `query-db.refetch-ordered-prefix` -declareOracle({ - check: [checkCase], - model: [expectedWindow, expectedCoverage], - grammar: [caseArbitrary, heldCaseArbitrary, sameKeyEndCaseArbitrary], -}) describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { // The issue's trace (rank desc, limit 2). Removing rank 10's marker makes diff --git a/packages/query-db-collection/tests/retained-scoped-hydration-oracle.ts b/packages/query-db-collection/tests/retained-scoped-hydration-oracle.ts index 1a761e4839..ff15e17f07 100644 --- a/packages/query-db-collection/tests/retained-scoped-hydration-oracle.ts +++ b/packages/query-db-collection/tests/retained-scoped-hydration-oracle.ts @@ -19,7 +19,6 @@ * cache rotation, or intermediate-publication claim beyond the held response. */ import fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' export type RetainedScope = `a` | `b` export type ScopedHydrationRow = { @@ -210,11 +209,6 @@ export function scopedHydrationReached( : []), ] } -declareOracle({ - model: [expectedScopedHydration], - grammar: [scopedHydrationHistoryArb, scopedHydrationHistories], - check: [scopedHydrationViolations], -}) /** * A newly acquired cached Query does not override a continuously owned row @@ -288,8 +282,3 @@ export function cachedPeerClaimViolations( violations.push(`cached claim started an unexpected physical fetch`) return violations } -declareOracle({ - model: [expectedCachedPeerClaim], - grammar: [cachedPeerClaimHistoryArb, cachedPeerClaimHistories], - check: [cachedPeerClaimViolations], -}) diff --git a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts index 7c9ffc060c..daae66d545 100644 --- a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts +++ b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts @@ -18,7 +18,6 @@ import { untilStartup } from '../../db-adapter-conformance/tests/persisted-start import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { queryCollectionOptions } from '../src/query' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' -import { declareOracle } from '../../db/tests/oracle-declaration' import { mergeDirectWritesOverFetch } from './direct-write-precedence-oracle' import type { SyncPersistenceCapabilityV1 } from '@tanstack/db' import type { PersistedCollectionPersistence } from '../../db-sqlite-persistence-core/src' @@ -495,8 +494,3 @@ export async function calibrateGeneratedConstructions( } } } -declareOracle({ - model: [expected], - grammar: [arbitrary], - check: [check, calibrateGeneratedConstructions], -}) 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 672254b040..1440882c62 100644 --- a/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts +++ b/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts @@ -9,7 +9,6 @@ import { 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' @@ -119,13 +118,6 @@ function expectHydratedRow( browserClient.clear() } -// 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, cleanups: ReadonlyArray<() => void | Promise>, 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 e66a648ba0..8653f18cf0 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 @@ -10,11 +10,6 @@ * builder returns fresh identities and preserves generated input variation. */ import { oracleProperty } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceModel, -} from '../../db-adapter-conformance/src' import { eagerLiveSourceCampaign } from '../../db-adapter-conformance/tests/eager-live-source-oracle-suite' import { constructRxdbEagerHistory } from './eager-live-source-history-oracle' import { rxdbConformanceDriver } from './conformance-driver' @@ -44,8 +39,3 @@ oracleProperty({ calibrations: campaign.calibrations, timeout: 120000, }) -declareOracle({ - check: [checkEagerLiveSourceHistory, campaign.check], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [campaign.arbitrary], -}) diff --git a/packages/rxdb-db-collection/tests/eager-reload-conformance-oracle.property.test.ts b/packages/rxdb-db-collection/tests/eager-reload-conformance-oracle.property.test.ts index e67b31d877..8dea1064f6 100644 --- a/packages/rxdb-db-collection/tests/eager-reload-conformance-oracle.property.test.ts +++ b/packages/rxdb-db-collection/tests/eager-reload-conformance-oracle.property.test.ts @@ -1,14 +1,11 @@ /** RxDB receives complete eager reload replacement through its real local SDK. */ -import { declareOracle } from '../../db/tests/oracle-declaration' -import { eagerReloadHistoryArb } from '../../db-adapter-conformance/tests/eager-reload-oracle' import { registerEagerReloadConformance } from '../../db-adapter-conformance/tests/eager-reload-oracle-suite' import { rxdbEagerReloadAdapter } from './eager-reload-driver' -declareOracle({ - owns: [`src/rxdb.ts#rxdbCollectionOptions`], - grammar: [eagerReloadHistoryArb], -}) registerEagerReloadConformance( `adapter.rxdb.eager-reload`, rxdbEagerReloadAdapter, + { + owns: [`src/rxdb.ts#rxdbCollectionOptions`], + }, ) diff --git a/packages/rxdb-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts b/packages/rxdb-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts index 0f0ec7444f..491d36da0c 100644 --- a/packages/rxdb-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts +++ b/packages/rxdb-db-collection/tests/persisted-baseline-lifecycle-oracle.test.ts @@ -1,16 +1,9 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' -import { baselineLifecycleViolations } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' import { createStartupHost } from '../../db-adapter-conformance/tests/persisted-startup-host' import { observeBaselineLifecycle } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-driver' import { registerBaselineLifecycle } from '../../db-adapter-conformance/tests/persisted-baseline-lifecycle-oracle-suite' import { rxdbBaselineLifecycleAdapter } from './persisted-baseline-lifecycle-driver' -declareOracle({ - owns: [`src/rxdb.ts#rxdbCollectionOptions`], - check: [baselineLifecycleViolations], -}) - for (const { host, config } of [ { host: `controlled`, @@ -29,19 +22,22 @@ for (const { host, config } of [ }, }, ] as const) { - registerBaselineLifecycle(config, async (h, id) => { - const storage = createStartupHost(host) - return withOracleCleanup( - () => - observeBaselineLifecycle( - rxdbBaselineLifecycleAdapter, - storage.persistence, - storage.holdBaseline, - h, - id, - process.env.TANSTACK_DB_BASELINE_EXPECT_HYDRATE !== `0`, - ), - [() => storage.close()], - ) - }) + registerBaselineLifecycle( + { ...config, owns: [`src/rxdb.ts#rxdbCollectionOptions`] }, + async (h, id) => { + const storage = createStartupHost(host) + return withOracleCleanup( + () => + observeBaselineLifecycle( + rxdbBaselineLifecycleAdapter, + storage.persistence, + storage.holdBaseline, + h, + id, + process.env.TANSTACK_DB_BASELINE_EXPECT_HYDRATE !== `0`, + ), + [() => storage.close()], + ) + }, + ) } diff --git a/packages/rxdb-db-collection/tests/persisted-capability-conformance-oracle.test.ts b/packages/rxdb-db-collection/tests/persisted-capability-conformance-oracle.test.ts index ac9c321aaa..1f9521cbff 100644 --- a/packages/rxdb-db-collection/tests/persisted-capability-conformance-oracle.test.ts +++ b/packages/rxdb-db-collection/tests/persisted-capability-conformance-oracle.test.ts @@ -1,15 +1,7 @@ /** Package-local ownership makes the real protocol receiver visible to gap audits. */ -import { declareOracle } from '../../db/tests/oracle-declaration' import { registerCapabilityConformance } from '../../db-adapter-conformance/tests/persisted-capability-oracle-suite' -import { - capabilityHistories, - capabilityViolations, -} from '../../db-adapter-conformance/tests/persisted-capability-oracle' import { rxdbStartupAdapter } from './persisted-startup-driver' -declareOracle({ +registerCapabilityConformance(`rxdb`, rxdbStartupAdapter, { owns: [`src/rxdb.ts#rxdbCollectionOptions`], - check: [capabilityViolations], - grammar: [capabilityHistories], }) -registerCapabilityConformance(`rxdb`, rxdbStartupAdapter) diff --git a/packages/rxdb-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts b/packages/rxdb-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts index a2059e6275..19c9b8fe18 100644 --- a/packages/rxdb-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts +++ b/packages/rxdb-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts @@ -3,7 +3,6 @@ * changed overlap values and both restore orders on each storage host. */ import { describe } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedStartupHistoryArb, persistedStartupSingleCommitHistories, @@ -12,16 +11,13 @@ import { registerPersistedStartupConformance } from '../../db-adapter-conformanc import { rxdbStartupAdapter } from './persisted-startup-driver' const historyArb = persistedStartupHistoryArb(`collection`, false, true) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/rxdb.ts#rxdbCollectionOptions`], - grammar: [historyArb], -}) describe(`persisted startup conformance: RxDB`, () => { registerPersistedStartupConformance({ property: `adapter.rxdb.persisted-startup`, adapter: rxdbStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/rxdb.ts#rxdbCollectionOptions`], histories: persistedStartupSingleCommitHistories, arbitrary: historyArb, scopedStartupCommit: true, 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 39e869e3b0..f63a9cce4a 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,15 +1,5 @@ -import { declareOracle } from '../../db/tests/oracle-declaration' -import { - checkEagerLiveSourceHistory, - eagerLiveSourceHistoryArb, - eagerLiveSourceModel, -} from '../../db-adapter-conformance/src' import { trailBaseConformanceDriver } from './conformance-driver' -import { - constructTrailBaseEagerHistory, - observeTrailBaseEagerHistory, - trailBaseEagerVerdict, -} from './eager-live-source-framework-oracle' +import { observeTrailBaseEagerHistory } from './eager-live-source-framework-oracle' import { registerTrailBaseEager } from './eager-live-source-oracle-suite' /** @@ -33,18 +23,14 @@ import { registerTrailBaseEager } from './eager-live-source-oracle-suite' const property = `adapter-conformance.eager-live-source.trailbase` const driver = trailBaseConformanceDriver() -const historyArb = eagerLiveSourceHistoryArb(driver) registerTrailBaseEager( - { property, defaultRuns: 600 }, + { + property, + defaultRuns: 600, + owns: [`src/trailbase.ts#trailBaseCollectionOptions`], + }, driver, (history, corruptValues) => observeTrailBaseEagerHistory(driver, history, corruptValues), ) - -declareOracle({ - owns: [`src/trailbase.ts#trailBaseCollectionOptions`], - check: [checkEagerLiveSourceHistory, trailBaseEagerVerdict], - model: [eagerLiveSourceModel.step, eagerLiveSourceModel.expected], - grammar: [historyArb, constructTrailBaseEagerHistory], -}) diff --git a/packages/trailbase-db-collection/tests/eager-live-source-oracle-suite.ts b/packages/trailbase-db-collection/tests/eager-live-source-oracle-suite.ts index e9c23c3bf2..b675aba62f 100644 --- a/packages/trailbase-db-collection/tests/eager-live-source-oracle-suite.ts +++ b/packages/trailbase-db-collection/tests/eager-live-source-oracle-suite.ts @@ -20,7 +20,12 @@ type Receive = ( corruptValues?: boolean, ) => Promise export function registerTrailBaseEager( - campaign: { property: string; defaultRuns: number }, + campaign: { + property: string + defaultRuns: number + /** The receiving adapter's production entry, forwarded to oracleProperty. */ + owns?: ReadonlyArray + }, driver: EagerLiveSourceDriver, receive: Receive, ) { @@ -50,6 +55,7 @@ export function registerTrailBaseEager( } oracleProperty({ id: campaign.property, + owns: campaign.owns, law: { statement: `The eager source's initial gate, complete rows and field values, per-change publications, failure identity, post-ready health, restart and exactly-once resource release follow the shared eager live-source laws.`, authority: `db-adapter-conformance/src/eager-live-source-oracle.ts, adapter law set READY-2/3/4, HEALTH-1/5, PUB-1/2/3, FID-1/4, LIFE-2/5/10; TrailBase tests/ORACLE.md.`, diff --git a/packages/trailbase-db-collection/tests/lifecycle-hydration-oracle.ts b/packages/trailbase-db-collection/tests/lifecycle-hydration-oracle.ts index c026dcbdf5..f693831673 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-hydration-oracle.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-hydration-oracle.ts @@ -13,7 +13,6 @@ * The production driver adds real persisted hosts to that same boundary. */ import * as fc from 'fast-check' -import { declareOracle } from '../../db/tests/oracle-declaration' export type HydrationLifecycleRow = { id: string; value: number } export type HydrationLifecycleHistory = { @@ -159,9 +158,3 @@ export const hydrationLifecycleHistories: Array = ( ), ), ) - -declareOracle({ - model: [expectedHydrationLifecycle], - grammar: [hydrationLifecycleHistoryArb, hydrationLifecycleHistories], - check: [hydrationLifecycleViolations], -}) diff --git a/packages/trailbase-db-collection/tests/lifecycle-judgment-oracle.ts b/packages/trailbase-db-collection/tests/lifecycle-judgment-oracle.ts index edfaccb0f8..e308677682 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-judgment-oracle.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-judgment-oracle.ts @@ -12,7 +12,6 @@ * the first mismatching cut. No Vitest assertion executes in the driver. */ import { isDeepStrictEqual } from 'node:util' -import { declareOracle } from '../../db/tests/oracle-declaration' import { pass, violation } from '../../db/tests/oracle-property' import type { Observation, Verdict } from '../../db/tests/oracle-property' @@ -233,5 +232,3 @@ export function checkLawFacts( ? pass : violation(`complete`, `history did not finish`, law) } - -declareOracle({ model: [referenceFacts], check: [checkLawFacts] }) 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 5e6c537000..79b653becc 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -23,7 +23,6 @@ import { vi } from 'vitest' import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' import { createStartupHost } from '../../db-adapter-conformance/tests/persisted-startup-host' -import { declareOracle } from '../../db/tests/oracle-declaration' import { oracleProperty, pass, violation } from '../../db/tests/oracle-property' import { trailBaseCollectionOptions } from '../src/trailbase' import { observeHydrationLifecycle } from './lifecycle-hydration-driver' @@ -2075,14 +2074,6 @@ oracleProperty({ ], }) -// The reusable judgments own this production boundary. The framework handles -// campaign registration; this declaration retains gap-report discovery. -declareOracle({ - check: [checkLawFacts], - model: [referenceFacts, expectedEarlyDemand], - grammar: [scenarioArb, pageHistoryArb], -}) - /** The same cleanup law receives controlled and real Node SQLite hydration. * The shared hydration suite owns its model, generated grammar and cuts. */ for (const host of [`controlled`, `node-sqlite`] as const) diff --git a/packages/trailbase-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts b/packages/trailbase-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts index 8d57c632c4..7b66cb8dd5 100644 --- a/packages/trailbase-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/persisted-startup-conformance-oracle.property.test.ts @@ -3,7 +3,6 @@ * changed overlap values and both restore orders on each storage host. */ import { describe } from 'vitest' -import { declareOracle } from '../../db/tests/oracle-declaration' import { persistedStartupHistories, persistedStartupHistoryArb, @@ -12,16 +11,13 @@ import { registerPersistedStartupConformance } from '../../db-adapter-conformanc import { trailBaseStartupAdapter } from './persisted-startup-driver' const historyArb = persistedStartupHistoryArb(`collection`) -// The receiving path judges persisted startup through this adapter entry. -// The shared model owns replacement; this list lets the gap CLI credit it. -declareOracle({ - owns: [`src/trailbase.ts#trailBaseCollectionOptions`], - grammar: [historyArb], -}) describe(`persisted startup conformance: TrailBase`, () => { registerPersistedStartupConformance({ property: `adapter.trailbase.persisted-startup`, adapter: trailBaseStartupAdapter, + // The receiving path judges persisted startup through this adapter entry. + // The shared model owns replacement; this list lets the gap CLI credit it. + owns: [`src/trailbase.ts#trailBaseCollectionOptions`], histories: persistedStartupHistories, arbitrary: historyArb, }) From 0d913fa316ca06fe38c0eb2c037c3eceddcebec6 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:43:59 -0700 Subject: [PATCH 018/116] test(sqlite): register the jsdom OPFS open outcome histories through oracleProperty P2: the it.each over open histories becomes the enumerated property opfs.open-outcome. P6: the grammar reach check moves to opfs-open-outcome-jsdom-grammar.test.ts, with jsdomReach in a test-free companion module. Co-authored-by: Isaac --- .../opfs-open-outcome-jsdom-grammar-oracle.ts | 13 ++++ .../opfs-open-outcome-jsdom-grammar.test.ts | 20 ++++++ .../tests/opfs-open-outcome-oracle.test.ts | 70 +++++++++---------- .../db/tests/converted-properties/sqlite.ts | 10 ++- 4 files changed, 73 insertions(+), 40 deletions(-) create mode 100644 packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar-oracle.ts create mode 100644 packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar.test.ts diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar-oracle.ts b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar-oracle.ts new file mode 100644 index 0000000000..8c0b36375e --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar-oracle.ts @@ -0,0 +1,13 @@ +// Owns nothing: this module holds the grammar input for the jsdom driver in +// opfs-open-outcome-oracle.test.ts, the steps that driver controls. Its open +// histories come from this reach, and opfs-open-outcome-jsdom-grammar.test.ts +// checks that they reach every outcome. +import type { OpenDriverReach } from './opfs-open-outcome-oracle' + +export const jsdomReach: OpenDriverReach = { + lookup: [`not needed`], + workerStart: [`starts`, `throws`, `fails to load`], + wasm: [`loads`, `fails`], + init: [`answers`, `silent`], + interrupt: [`none`, `deadline`, `abort`], +} diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar.test.ts new file mode 100644 index 0000000000..0f38c8a1d2 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar.test.ts @@ -0,0 +1,20 @@ +// A check of test code: the open histories that the jsdom driver in +// opfs-open-outcome-oracle.test.ts runs reach every outcome that it controls. +// It runs no production, so it is an ordinary test. +import { describe, expect, it } from 'vitest' +import { openHistories, reachedOutcomes } from './opfs-open-outcome-oracle' +import { jsdomReach } from './opfs-open-outcome-jsdom-grammar-oracle' + +const histories = openHistories(jsdomReach) + +describe(`OPFS open outcome oracle (jsdom driver)`, () => { + it(`reaches every outcome that this driver controls`, () => { + expect(reachedOutcomes(histories)).toEqual([ + `initialization: AbortError`, + `initialization: TimeoutError`, + `none: opened`, + `wasm: PersistenceUnavailableError`, + `worker start: PersistenceUnavailableError`, + ]) + }) +}) 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 078499f13d..0ff8734320 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 @@ -12,19 +12,21 @@ * Chromium driver in `e2e/opfs-open-outcome-witness.e2e.test.ts` supplies the * 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 { afterEach, describe, vi } from 'vitest' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { openBrowserWASQLiteOPFSDatabase } from '../src/opfs-database' import { describeHistory, expectOpenOutcome, - expectedOpenOutcome, openHistories, - reachedOutcomes, } from './opfs-open-outcome-oracle' +import { jsdomReach } from './opfs-open-outcome-jsdom-grammar-oracle' import type { - OpenDriverReach, OpenHistory, OpenObservation, OpenSettlement, @@ -35,21 +37,9 @@ import type { } 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`], - wasm: [`loads`, `fails`], - init: [`answers`, `silent`], - interrupt: [`none`, `deadline`, `abort`], -} +// production path that its open histories reach. The grammar check that these +// histories reach every outcome the driver controls is an ordinary test of +// test code, in opfs-open-outcome-jsdom-grammar.test.ts. const histories = openHistories(jsdomReach) const deadlineMs = 40 @@ -220,23 +210,27 @@ async function runOpenHistory(history: OpenHistory): Promise { return observation } +// Each enumerated open history runs through the jsdom driver, and the helper +// module's refinement check, expectOpenOutcome, judges the observation against +// its expectedOpenOutcome model. The adapter transports that judgment. +const openOutcomeLaw = `OPFS open outcome` 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`, - `none: opened`, - `wasm: PersistenceUnavailableError`, - `worker start: PersistenceUnavailableError`, - ]) - }) - - it.each(histories.map((history) => [describeHistory(history), history]))( - `%s`, - async (_name, history) => { - oracleRoute(`case`) - expectOpenOutcome(history, await runOpenHistory(history)) + oracleProperty({ + id: `opfs.open-outcome`, + law: { + statement: `An OPFS open settles as the open outcome model predicts: a capability failure rejects with PersistenceUnavailableError, the deadline with TimeoutError, and an abort with its reason; a failed or interrupted open terminates the worker it started, and a later open can still succeed.`, + authority: `The README's open settlement promise, as opfs-open-outcome-oracle.ts states it.`, + limits: `The enumerated open histories that the jsdom driver controls; no asset lookup and no real worker WASM fetch (the Chromium witness supplies both).`, }, - ) + owns: [`src/opfs-database.ts#openBrowserWASQLiteOPFSDatabase`], + histories, + label: describeHistory, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(openOutcomeLaw, plan, observe, async () => { + expectOpenOutcome(plan.history, await runOpenHistory(plan.history)) + }), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 2cdbc4e421..6193b256bb 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -5,6 +5,12 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [`opfs.open-outcome`] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + [ + `browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts`, + `opfs`, + `open-outcome`, + ], +] From 688a47b07a83db4b924f0396ce9a06112ac050d2 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:44:12 -0700 Subject: [PATCH 019/116] test(db): register functional projection oracle cells through oracleProperty Move the cell-table size checks to the ordinary functional projection tests. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 8 + ...ludes-functional-projection-oracle.test.ts | 1250 +++++++++-------- .../includes-functional-projection.test.ts | 35 + 3 files changed, 679 insertions(+), 614 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 21519308c6..42cca82b63 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -9,6 +9,9 @@ export const properties: ReadonlyArray = [ `includes-space.reachable-facades`, `includes-outer-join-route-work.main-correlation`, `evaluators.in-original-calibration`, + `includes-functional-projection.operator`, + `includes-functional-projection.renamed`, + `includes-functional-projection.boundary`, ] export const owners: ReadonlyArray = [ @@ -27,4 +30,9 @@ export const owners: ReadonlyArray = [ `evaluators`, `in-original-calibration`, ], + [ + `db/tests/query/includes-functional-projection-oracle.test.ts`, + `includes-functional-projection`, + `operator renamed boundary`, + ], ] 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 ca6bd7ca7c..a8e9c47e84 100644 --- a/packages/db/tests/query/includes-functional-projection-oracle.test.ts +++ b/packages/db/tests/query/includes-functional-projection-oracle.test.ts @@ -1,5 +1,4 @@ -import { describe, expect, it } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' +import { describe, expect } from 'vitest' import { createLiveQueryCollection, eq, @@ -7,7 +6,12 @@ import { materialize, toArray, } from '../../src/query/index.js' -import { oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { Projection, @@ -22,7 +26,6 @@ import { rejectsCollectionInput, renamedCells, selectedRows, - valueCells, } from './includes-functional-projection-support.js' import type { Child, @@ -64,15 +67,6 @@ import type { * provider timing, or every recursive query shape. */ -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/query/live-query-collection.ts#createLiveQueryCollection', - ], - model: [expectedGroupChildren, expectedOperatorRows], - grammar: [cells, orderedPairCells, valueCells, renamedCells, operatorCells], -}) - // The public rows of the children that a route group owns, predicted from the // source truth. A route move changes which group a parent reads, so the // prediction follows the group that the caller passes. @@ -105,624 +99,652 @@ function expectedOperatorRows( return expected } -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) - expect(new Set(valueCells.map((cell) => JSON.stringify(cell))).size).toBe( - 48, - ) - expect(new Set(renamedCells.map((cell) => JSON.stringify(cell))).size).toBe( - 24, - ) - expect( - new Set(operatorCells.map((cell) => JSON.stringify(cell))).size, - ).toBe(18) - }) +// Each judge below is the former test body, unchanged: it drives one cell +// through the public API and compares it with the truth Maps above. +const operatorLaw = `A downstream custom-key, selected-order, or distinct operator consumes the functional callback's projected value, whether or not the callback reads its include input.` - 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 }, - ]) - const children = createControlledCollection(`operator-child`, [ - { id: 10, parentGroup: 1, value: 3 }, - { id: 20, parentGroup: 2, value: 5 }, - ]) - // This model stores the scalar computed on a parent projection. A live - // Collection handle does not make its scalar reads child dependencies. - const expectedScores = new Map([ - [1, 3], - [2, 5], +async function judgeOperatorCell({ + form, + operator, + readsInclude, +}: (typeof operatorCells)[number]): Promise { + const parents = createControlledCollection(`operator-parent`, [ + { id: 1, group: 1, base: 3 }, + { id: 2, group: 2, base: 5 }, + ]) + const children = createControlledCollection(`operator-child`, [ + { id: 10, parentGroup: 1, value: 3 }, + { id: 20, parentGroup: 2, value: 5 }, + ]) + // This model stores the scalar computed on a parent projection. A live + // Collection handle does not make its scalar reads child dependencies. + const expectedScores = new Map([ + [1, 3], + [2, 5], + ]) + const observed: Array = [] + const buildQuery = () => + createLiveQueryCollection({ + query: (q) => { + const source = q + .from({ parent: parents.collection }) + .select(({ parent }) => { + const childRows = q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + return { + id: parent.id, + base: parent.base, + children: + form === `collection` + ? include(childRows) + : form === `array` + ? toArray(childRows) + : materialize(childRows), + } + }) + const projected = q.from({ row: source }).fn.select(({ row }) => { + const view = readsInclude + ? readChildren(row.children, form) + : undefined + if (view) observed.push({ ...view, rows: selectedRows(view.rows) }) + return { + id: operator === `distinct` ? 0 : row.id, + score: view + ? view.rows.reduce((sum, child) => sum + child.value, 0) + : row.base, + } + }) + if (operator === `distinct`) return projected.distinct() + if (operator === `selected-order`) + return projected + .orderBy(({ $selected }) => $selected.score, `desc`) + .orderBy(({ $selected }) => $selected.id) + .limit(1) + return projected + }, + getKey: + operator === `custom-key` ? (row) => `result:${row.id}` : undefined, + }) + if (rejectsCollectionInput(form, true)) { + let primaryFailure: unknown = noPrimaryFailure + try { + expect(buildQuery).toThrow(collectionInputError) + expect(observed).toEqual([]) + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => parents.collection.cleanup(), + () => children.collection.cleanup(), ]) - const observed: Array = [] - const buildQuery = () => - createLiveQueryCollection({ - query: (q) => { - const source = q - .from({ parent: parents.collection }) - .select(({ parent }) => { - const childRows = q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - return { - id: parent.id, - base: parent.base, - children: + } + return + } + const live = buildQuery() + const check = () => { + const expected = expectedOperatorRows(expectedScores, operator) + const sort = (rows: Array<{ id: number; score: number }>) => + rows.sort((left, right) => left.id - right.id || left.score - right.score) + expect + .soft(sort(live.toArray.map(({ id, score }) => ({ id, score })))) + .toEqual(sort(expected)) + if (operator === `custom-key`) + expect + .soft([...live.keys()].sort()) + .toEqual(expected.map((row) => `result:${row.id}`).sort()) + for (const view of observed) { + expect.soft(view.valid, `operator callback input form`).toBe(true) + if (form === `collection`) + expect.soft(view.ready, `operator callback input readiness`).toBe(true) + } + observed.length = 0 + } + let primaryFailure: unknown = noPrimaryFailure + try { + await live.preload() + check() + if (readsInclude && form !== `collection`) expectedScores.set(1, 7) + children.write(`update`, { id: 10, parentGroup: 1, value: 7 }) + check() + expectedScores.set(1, 5) + parents.write(`update`, { id: 1, group: 2, base: 5 }) + check() + expectedScores.delete(1) + parents.write(`delete`, { id: 1, group: 2, base: 5 }) + check() + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => live.cleanup(), + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + ]) + } +} + +const renamedLaw = `A functional or expression projection materializes its include inputs before it renames them, and a sibling include keeps its own value.` + +async function judgeRenamedCell({ + form, + consumer, + projection, + withSibling, +}: (typeof renamedCells)[number]): Promise { + const parents = createControlledCollection(`renamed-parent`, [ + { id: 1, group: 1, siblingGroup: 2 }, + ]) + const initial: Array = [ + { id: 10, parentGroup: 1, value: 3 }, + { id: 20, parentGroup: 2, value: 5 }, + ] + const children = createControlledCollection(`renamed-child`, initial) + const truth = new Map(initial.map((row) => [row.id, row])) + let group = 1 + let phase: Phase = `initial` + const calls: Array<{ + phase: Phase + stage: `projection` | `consumer` + primary: ChildView + sibling?: ChildView + }> = [] + const inspect = ( + stage: `projection` | `consumer`, + primary: unknown, + sibling: unknown, + ) => { + const view = readChildren(primary, form) + const second = withSibling ? readChildren(sibling, form) : undefined + calls.push({ + phase, + stage, + primary: { ...view, rows: selectedRows(view.rows) }, + sibling: second && { ...second, rows: selectedRows(second.rows) }, + }) + return ( + view.rows.reduce((sum, row) => sum + row.value, 0) + + (second?.rows.reduce((sum, row) => sum + row.value, 0) ?? 0) + ) + } + const buildQuery = () => + createLiveQueryCollection((q) => { + const source = q + .from({ parent: parents.collection }) + .select(({ parent }) => { + const primary = q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + const sibling = q + .from({ other: children.collection }) + .where(({ other }) => eq(other.parentGroup, parent.siblingGroup)) + return { + id: parent.id, + children: + form === `collection` + ? include(primary) + : form === `array` + ? toArray(primary) + : materialize(primary), + ...(withSibling + ? { + sibling: form === `collection` - ? include(childRows) + ? include(sibling) : form === `array` - ? toArray(childRows) - : materialize(childRows), + ? toArray(sibling) + : materialize(sibling), } - }) - const projected = q.from({ row: source }).fn.select(({ row }) => { - const view = readsInclude - ? readChildren(row.children, form) - : undefined - if (view) - observed.push({ ...view, rows: selectedRows(view.rows) }) - return { - id: operator === `distinct` ? 0 : row.id, - score: view - ? view.rows.reduce((sum, child) => sum + child.value, 0) - : row.base, - } - }) - if (operator === `distinct`) return projected.distinct() - if (operator === `selected-order`) - return projected - .orderBy(({ $selected }) => $selected.score, `desc`) - .orderBy(({ $selected }) => $selected.id) - .limit(1) - return projected - }, - getKey: - operator === `custom-key` ? (row) => `result:${row.id}` : undefined, + : {}), + } }) - if (rejectsCollectionInput(form, true)) { - let primaryFailure: unknown = noPrimaryFailure - try { - expect(buildQuery).toThrow(collectionInputError) - expect(observed).toEqual([]) - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ]) - } - return - } - const live = buildQuery() - const check = () => { - const expected = expectedOperatorRows(expectedScores, operator) - const sort = (rows: Array<{ id: number; score: number }>) => - rows.sort( - (left, right) => left.id - right.id || left.score - right.score, + const input = q.from({ row: source }) + const projected = + projection === `expression` + ? input.select(({ row }) => ({ + id: row.id, + renamed: { primary: row.children, sibling: row.sibling }, + total: 0, + })) + : input.fn.select(({ row }) => ({ + id: row.id, + renamed: { primary: row.children, sibling: row.sibling }, + total: inspect(`projection`, row.children, row.sibling), + })) + const outer = q.from({ result: projected }) + return consumer === `expression` + ? outer.select(({ result }) => ({ value: result })) + : outer.fn.select(({ result }) => ({ + value: { + id: result.id, + renamed: result.renamed, + total: inspect( + `consumer`, + result.renamed.primary, + result.renamed.sibling, + ), + }, + })) + }) + if ( + rejectsCollectionInput( + form, + projection === `functional`, + consumer === `functional`, + ) + ) { + let primaryFailure: unknown = noPrimaryFailure + try { + expect(buildQuery).toThrow(collectionInputError) + expect(calls).toEqual([]) + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + ]) + } + return + } + const live = buildQuery() + const check = () => { + const row = live.toArray[0]?.value + expect.soft(live.toArray, `${phase}: row count`).toHaveLength(1) + expect.soft(row?.id, `${phase}: public id`).toBe(1) + const expectedPrimary = expectedGroupChildren(truth, group) + const expectedSibling = withSibling ? expectedGroupChildren(truth, 2) : [] + for (const [value, expected] of [ + [row?.renamed.primary, expectedPrimary], + ...(withSibling + ? [[row?.renamed.sibling, expectedSibling] as const] + : []), + ] as const) { + const view = readChildren(value, form) + expect.soft(view.valid, `${phase}: renamed public form`).toBe(true) + expect + .soft(publicRows(view.rows), `${phase}: renamed public rows`) + .toEqual(expected) + if (form === `collection`) + expect.soft(view.ready, `${phase}: renamed public readiness`).toBe(true) + } + if (row) + expect + .soft( + `children` in row || `row` in row, + `${phase}: input paths do not leak`, + ) + .toBe(false) + if ( + form !== `collection` || + phase === `initial` || + phase === `route-move` + ) { + if (projection === `functional` || consumer === `functional`) + expect + .soft(row?.total, `${phase}: derived total`) + .toBe( + [...expectedPrimary, ...expectedSibling].reduce( + (sum, child) => sum + child.value, + 0, + ), ) + for (const stage of [ + ...(projection === `functional` ? [`projection` as const] : []), + ...(consumer === `functional` ? [`consumer` as const] : []), + ]) { expect - .soft(sort(live.toArray.map(({ id, score }) => ({ id, score })))) - .toEqual(sort(expected)) - if (operator === `custom-key`) - expect - .soft([...live.keys()].sort()) - .toEqual(expected.map((row) => `result:${row.id}`).sort()) - for (const view of observed) { - expect.soft(view.valid, `operator callback input form`).toBe(true) - if (form === `collection`) - expect - .soft(view.ready, `operator callback input readiness`) - .toBe(true) - } - observed.length = 0 + .soft( + calls.filter((call) => call.phase === phase && call.stage === stage) + .length, + `${phase}: ${stage} callback reach`, + ) + .toBeGreaterThan(0) } - let primaryFailure: unknown = noPrimaryFailure - try { - await live.preload() - check() - if (readsInclude && form !== `collection`) expectedScores.set(1, 7) - children.write(`update`, { id: 10, parentGroup: 1, value: 7 }) - check() - expectedScores.set(1, 5) - parents.write(`update`, { id: 1, group: 2, base: 5 }) - check() - expectedScores.delete(1) - parents.write(`delete`, { id: 1, group: 2, base: 5 }) - check() - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => live.cleanup(), - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ]) + } + for (const call of calls.filter((item) => item.phase === phase)) { + for (const view of [ + call.primary, + ...(call.sibling ? [call.sibling] : []), + ]) { + expect.soft(view.valid, `${phase}: callback input form`).toBe(true) + if (form === `collection`) + expect + .soft(view.ready, `${phase}: callback input readiness`) + .toBe(true) } - }, - ) + } + } + let primaryFailure: unknown = noPrimaryFailure + try { + await live.preload() + check() + phase = `child-update` + const changed = { id: 10, parentGroup: 1, value: 7 } + truth.set(10, changed) + children.write(`update`, changed) + check() + if (withSibling) { + phase = `sibling-update` + const sibling = { id: 20, parentGroup: 2, value: 11 } + truth.set(20, sibling) + children.write(`update`, sibling) + check() + } + phase = `route-move` + group = 2 + parents.write(`update`, { id: 1, group, siblingGroup: 2 }) + check() + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => live.cleanup(), + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + ]) + } +} - 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 }, - ]) - const initial: Array = [ - { id: 10, parentGroup: 1, value: 3 }, - { id: 20, parentGroup: 2, value: 5 }, - ] - const children = createControlledCollection(`renamed-child`, initial) - const truth = new Map(initial.map((row) => [row.id, row])) - let group = 1 - let phase: Phase = `initial` - const calls: Array<{ - phase: Phase - stage: `projection` | `consumer` - primary: ChildView - sibling?: ChildView - }> = [] - const inspect = ( - stage: `projection` | `consumer`, - primary: unknown, - sibling: unknown, - ) => { - const view = readChildren(primary, form) - const second = withSibling ? readChildren(sibling, form) : undefined - calls.push({ - phase, - stage, - primary: { ...view, rows: selectedRows(view.rows) }, - sibling: second && { ...second, rows: selectedRows(second.rows) }, - }) - return ( - view.rows.reduce((sum, row) => sum + row.value, 0) + - (second?.rows.reduce((sum, row) => sum + row.value, 0) ?? 0) - ) - } - const buildQuery = () => - createLiveQueryCollection((q) => { - const source = q - .from({ parent: parents.collection }) - .select(({ parent }) => { - const primary = q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - const sibling = q - .from({ other: children.collection }) - .where(({ other }) => - eq(other.parentGroup, parent.siblingGroup), - ) - return { - id: parent.id, - children: - form === `collection` - ? include(primary) - : form === `array` - ? toArray(primary) - : materialize(primary), - ...(withSibling - ? { - sibling: - form === `collection` - ? include(sibling) - : form === `array` - ? toArray(sibling) - : materialize(sibling), - } - : {}), - } - }) - const input = q.from({ row: source }) - const projected = - projection === `expression` - ? input.select(({ row }) => ({ - id: row.id, - renamed: { primary: row.children, sibling: row.sibling }, - total: 0, - })) - : input.fn.select(({ row }) => ({ - id: row.id, - renamed: { primary: row.children, sibling: row.sibling }, - total: inspect(`projection`, row.children, row.sibling), - })) - const outer = q.from({ result: projected }) - return consumer === `expression` - ? outer.select(({ result }) => ({ value: result })) - : outer.fn.select(({ result }) => ({ - value: { - id: result.id, - renamed: result.renamed, - total: inspect( - `consumer`, - result.renamed.primary, - result.renamed.sibling, - ), - }, - })) - }) - if ( - rejectsCollectionInput( - form, - projection === `functional`, - consumer === `functional`, - ) - ) { - let primaryFailure: unknown = noPrimaryFailure - try { - expect(buildQuery).toThrow(collectionInputError) - expect(calls).toEqual([]) - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ]) - } - return - } - const live = buildQuery() - const check = () => { - const row = live.toArray[0]?.value - expect.soft(live.toArray, `${phase}: row count`).toHaveLength(1) - expect.soft(row?.id, `${phase}: public id`).toBe(1) - const expectedPrimary = expectedGroupChildren(truth, group) - const expectedSibling = withSibling - ? expectedGroupChildren(truth, 2) - : [] - for (const [value, expected] of [ - [row?.renamed.primary, expectedPrimary], - ...(withSibling - ? [[row?.renamed.sibling, expectedSibling] as const] - : []), - ] as const) { - const view = readChildren(value, form) - expect.soft(view.valid, `${phase}: renamed public form`).toBe(true) - expect - .soft(publicRows(view.rows), `${phase}: renamed public rows`) - .toEqual(expected) - if (form === `collection`) - expect - .soft(view.ready, `${phase}: renamed public readiness`) - .toBe(true) - } - if (row) - expect - .soft( - `children` in row || `row` in row, - `${phase}: input paths do not leak`, - ) - .toBe(false) - if ( - form !== `collection` || - phase === `initial` || - phase === `route-move` - ) { - if (projection === `functional` || consumer === `functional`) - expect - .soft(row?.total, `${phase}: derived total`) - .toBe( - [...expectedPrimary, ...expectedSibling].reduce( - (sum, child) => sum + child.value, - 0, - ), - ) - for (const stage of [ - ...(projection === `functional` ? [`projection` as const] : []), - ...(consumer === `functional` ? [`consumer` as const] : []), - ]) { - expect - .soft( - calls.filter( - (call) => call.phase === phase && call.stage === stage, - ).length, - `${phase}: ${stage} callback reach`, - ) - .toBeGreaterThan(0) - } - } - for (const call of calls.filter((item) => item.phase === phase)) { - for (const view of [ - call.primary, - ...(call.sibling ? [call.sibling] : []), - ]) { - expect.soft(view.valid, `${phase}: callback input form`).toBe(true) - if (form === `collection`) - expect - .soft(view.ready, `${phase}: callback input readiness`) - .toBe(true) +const boundaryLaw = `Query references, recursive references, and unions preserve each supported include form and its public row shape; functional callbacks receive complete values from the current graph step, and Collection inputs are rejected before user code runs.` + +async function judgeBoundaryCell({ + boundary, + form, + output, + initial, +}: (typeof cells | typeof orderedPairCells)[number]): Promise { + const parents = createControlledCollection(`projection-parents`, [ + { id: 1, group: 1 }, + ]) + const absent = createControlledCollection(`projection-absent`, [{ id: 2 }]) + const initialChildren: Array = [ + ...(initial !== `empty` ? [{ id: 10, parentGroup: 1, value: 3 }] : []), + { id: 20, parentGroup: 2, value: 5 }, + ...(initial === `ordered-pair` + ? [ + { id: 5, parentGroup: 1, value: 9 }, + { id: 15, parentGroup: 2, value: 11 }, + ] + : []), + ] + const children = createControlledCollection( + `projection-children`, + initialChildren, + ) + const truth = new Map(initialChildren.map((row) => [row.id, row])) + let group = 1 + let phase: Phase = `initial` + const calls: Array<{ + phase: Phase + kind: string + child: unknown + view: ChildView + }> = [] + const project = (row: Input) => { + const child = row.children + const view = readChildren(child, form) + // Capture readiness and contents NOW, not through a reference read after preload. + calls.push({ + phase, + kind: row.kind, + child, + view: { ...view, rows: selectedRows(view.rows) }, + }) + const total = view.rows.reduce((sum, item) => sum + item.value, 0) + return output === `opaque-root` + ? new Projection(row.id, row.kind, child, total) + : { id: row.id, kind: row.kind, children: child, total } + } + const buildQuery = () => + createLiveQueryCollection((q) => { + const included = q + .from({ parent: parents.collection }) + .select(({ parent }) => { + const childRows = q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + .orderBy(({ child }) => child.id) + .select(({ child }) => ({ + id: child.id, + parentGroup: child.parentGroup, + value: child.value, + })) + return { + id: parent.id, + kind: `included`, + total: 0, + children: + form === `collection` + ? include(childRows) + : form === `array` + ? toArray(childRows) + : materialize(childRows), } - } + }) + if (boundary === `union`) { + const withoutInclude = q + .from({ other: absent.collection }) + .select(({ other }) => ({ + id: other.id, + kind: `absent`, + total: 0, + })) + const union = q.unionAll(included, withoutInclude) + return output === `expression` ? union : union.fn.select(project) } - let primaryFailure: unknown = noPrimaryFailure - try { - await live.preload() - check() - phase = `child-update` - const changed = { id: 10, parentGroup: 1, value: 7 } - truth.set(10, changed) - children.write(`update`, changed) - check() - if (withSibling) { - phase = `sibling-update` - const sibling = { id: 20, parentGroup: 2, value: 11 } - truth.set(20, sibling) - children.write(`update`, sibling) - check() - } - phase = `route-move` - group = 2 - parents.write(`update`, { id: 1, group, siblingGroup: 2 }) - check() - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => live.cleanup(), - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ]) + if (boundary === `recursive-query-ref`) { + const intermediate = q + .from({ inner: included }) + .select(({ inner }) => inner) + const outer = q.from({ row: intermediate }) + return output === `expression` + ? outer.select(({ row }) => row) + : outer.fn.select(({ row }) => project(row)) } - }, - ) -}) - -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) - expect( - new Set( - [...cells, ...orderedPairCells].map((cell) => JSON.stringify(cell)), - ).size, - ).toBe(81) - }) - - it.each([...cells, ...orderedPairCells])( - `$boundary / $form / $output / $initial`, - async ({ boundary, form, output, initial }) => { - oracleRoute(`case`) - const parents = createControlledCollection(`projection-parents`, [ - { id: 1, group: 1 }, + const outer = q.from({ row: included }) + return output === `expression` + ? outer.select(({ row }) => row) + : outer.fn.select(({ row }) => project(row)) + }) + if (rejectsCollectionInput(form, output !== `expression`)) { + let primaryFailure: unknown = noPrimaryFailure + try { + expect(buildQuery).toThrow(collectionInputError) + expect(calls).toEqual([]) + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + () => absent.collection.cleanup(), ]) - const absent = createControlledCollection(`projection-absent`, [ - { id: 2 }, + } + return + } + const live = buildQuery() + let facade: unknown + const check = () => { + expect + .soft( + live.toArray + .map(({ id, kind }) => ({ id, kind })) + .sort((left, right) => left.id - right.id), + `${phase}: complete public roots`, + ) + .toEqual([ + { id: 1, kind: `included` }, + ...(boundary === `union` ? [{ id: 2, kind: `absent` }] : []), ]) - const initialChildren: Array = [ - ...(initial !== `empty` ? [{ id: 10, parentGroup: 1, value: 3 }] : []), - { id: 20, parentGroup: 2, value: 5 }, - ...(initial === `ordered-pair` - ? [ - { id: 5, parentGroup: 1, value: 9 }, - { id: 15, parentGroup: 2, value: 11 }, - ] - : []), - ] - const children = createControlledCollection( - `projection-children`, - initialChildren, + const row: (Input & { total: number }) | undefined = live.toArray.find( + (item) => item.kind === `included`, + ) + expect.soft(row, `${phase}: included public row`).toBeDefined() + if (!row) return + const expected = expectedGroupChildren(truth, group) + const view = readChildren(row.children, form) + expect.soft(view.valid, `${phase}: public include form`).toBe(true) + expect + .soft(selectedRows(view.rows), `${phase}: public children`) + .toEqual(expected) + if (form === `collection`) { + expect.soft(view.ready, `${phase}: public facade ready`).toBe(true) + if (phase === `initial`) facade = row.children + else if (phase === `child-update`) + expect.soft(row.children, `child-only facade identity`).toBe(facade) + else + expect.soft(row.children, `route move replaces facade`).not.toBe(facade) + } + // A Collection is a live handle, not a dependency-tracked scalar read. + // Assert derived scalars when the parent projection runs, not on child-only + // changes to a retained facade. Inline values do drive parent recomputation. + if ( + output !== `expression` && + (form !== `collection` || phase !== `child-update`) + ) { + expect + .soft(row.total, `${phase}: derived scalar`) + .toBe(expected.reduce((sum, item) => sum + item.value, 0)) + } + if (boundary === `union`) { + const other: (Input & { total?: number }) | undefined = live.toArray.find( + (item) => item.kind === `absent`, ) - const truth = new Map(initialChildren.map((row) => [row.id, row])) - let group = 1 - let phase: Phase = `initial` - const calls: Array<{ - phase: Phase - kind: string - child: unknown - view: ChildView - }> = [] - const project = (row: Input) => { - const child = row.children - const view = readChildren(child, form) - // Capture readiness and contents NOW, not through a reference read after preload. - calls.push({ - phase, - kind: row.kind, - child, - view: { ...view, rows: selectedRows(view.rows) }, - }) - const total = view.rows.reduce((sum, item) => sum + item.value, 0) - return output === `opaque-root` - ? new Projection(row.id, row.kind, child, total) - : { id: row.id, kind: row.kind, children: child, total } - } - const buildQuery = () => - createLiveQueryCollection((q) => { - const included = q - .from({ parent: parents.collection }) - .select(({ parent }) => { - const childRows = q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - .orderBy(({ child }) => child.id) - .select(({ child }) => ({ - id: child.id, - parentGroup: child.parentGroup, - value: child.value, - })) - return { - id: parent.id, - kind: `included`, - total: 0, - children: - form === `collection` - ? include(childRows) - : form === `array` - ? toArray(childRows) - : materialize(childRows), - } - }) - if (boundary === `union`) { - const withoutInclude = q - .from({ other: absent.collection }) - .select(({ other }) => ({ - id: other.id, - kind: `absent`, - total: 0, - })) - const union = q.unionAll(included, withoutInclude) - return output === `expression` ? union : union.fn.select(project) - } - if (boundary === `recursive-query-ref`) { - const intermediate = q - .from({ inner: included }) - .select(({ inner }) => inner) - const outer = q.from({ row: intermediate }) - return output === `expression` - ? outer.select(({ row }) => row) - : outer.fn.select(({ row }) => project(row)) - } - const outer = q.from({ row: included }) - return output === `expression` - ? outer.select(({ row }) => row) - : outer.fn.select(({ row }) => project(row)) - }) - if (rejectsCollectionInput(form, output !== `expression`)) { - let primaryFailure: unknown = noPrimaryFailure - try { - expect(buildQuery).toThrow(collectionInputError) - expect(calls).toEqual([]) - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - () => absent.collection.cleanup(), - ]) - } - return - } - const live = buildQuery() - let facade: unknown - const check = () => { - expect - .soft( - live.toArray - .map(({ id, kind }) => ({ id, kind })) - .sort((left, right) => left.id - right.id), - `${phase}: complete public roots`, - ) - .toEqual([ - { id: 1, kind: `included` }, - ...(boundary === `union` ? [{ id: 2, kind: `absent` }] : []), - ]) - const row: (Input & { total: number }) | undefined = live.toArray.find( - (item) => item.kind === `included`, - ) - expect.soft(row, `${phase}: included public row`).toBeDefined() - if (!row) return - const expected = expectedGroupChildren(truth, group) - const view = readChildren(row.children, form) - expect.soft(view.valid, `${phase}: public include form`).toBe(true) + expect.soft(other, `${phase}: absent branch survives`).toBeDefined() + expect + .soft(other?.children, `${phase}: absent branch value`) + .toBeUndefined() + expect.soft(other?.total, `${phase}: absent branch total`).toBe(0) + } + const current = calls.filter( + (call) => call.phase === phase && call.kind === `included`, + ) + if ( + output !== `expression` && + (form !== `collection` || phase !== `child-update`) + ) + expect.soft(current.length, `${phase}: callback reach`).toBeGreaterThan(0) + for (const call of current) { + expect.soft(call.view.valid, `${phase}: callback include form`).toBe(true) + if (form === `collection`) expect - .soft(selectedRows(view.rows), `${phase}: public children`) - .toEqual(expected) - if (form === `collection`) { - expect.soft(view.ready, `${phase}: public facade ready`).toBe(true) - if (phase === `initial`) facade = row.children - else if (phase === `child-update`) - expect.soft(row.children, `child-only facade identity`).toBe(facade) - else - expect - .soft(row.children, `route move replaces facade`) - .not.toBe(facade) - } - // A Collection is a live handle, not a dependency-tracked scalar read. - // Assert derived scalars when the parent projection runs, not on child-only - // changes to a retained facade. Inline values do drive parent recomputation. - if ( - output !== `expression` && - (form !== `collection` || phase !== `child-update`) - ) { - expect - .soft(row.total, `${phase}: derived scalar`) - .toBe(expected.reduce((sum, item) => sum + item.value, 0)) - } - if (boundary === `union`) { - const other: (Input & { total?: number }) | undefined = - live.toArray.find((item) => item.kind === `absent`) - expect.soft(other, `${phase}: absent branch survives`).toBeDefined() - expect - .soft(other?.children, `${phase}: absent branch value`) - .toBeUndefined() - expect.soft(other?.total, `${phase}: absent branch total`).toBe(0) - } - const current = calls.filter( - (call) => call.phase === phase && call.kind === `included`, - ) - if ( - output !== `expression` && - (form !== `collection` || phase !== `child-update`) - ) - expect - .soft(current.length, `${phase}: callback reach`) - .toBeGreaterThan(0) - for (const call of current) { - expect - .soft(call.view.valid, `${phase}: callback include form`) - .toBe(true) - if (form === `collection`) - expect - .soft(call.view.ready, `${phase}: callback facade ready`) - .toBe(true) - } - const absentCalls = calls.filter( - (item) => item.phase === phase && item.kind === `absent`, - ) - if ( - boundary === `union` && - output !== `expression` && - phase === `initial` - ) - expect - .soft(absentCalls.length, `initial: absent callback reach`) - .toBeGreaterThan(0) - for (const call of absentCalls) { - expect - .soft(call.child, `${phase}: valid callback absence`) - .toBeUndefined() - } - } - let primaryFailure: unknown = noPrimaryFailure - try { - await live.preload() - check() - phase = `child-update` - const changed = { id: 10, parentGroup: 1, value: 7 } - truth.set(10, changed) - children.write(initial === `empty` ? `insert` : `update`, changed) - check() - phase = `route-move` - group = 2 - parents.write(`update`, { id: 1, group }) - check() - } catch (error) { - primaryFailure = error - throw error - } finally { - await cleanupAfterOracle(primaryFailure, [ - () => live.cleanup(), - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - () => absent.collection.cleanup(), - ]) - } + .soft(call.view.ready, `${phase}: callback facade ready`) + .toBe(true) + } + const absentCalls = calls.filter( + (item) => item.phase === phase && item.kind === `absent`, + ) + if (boundary === `union` && output !== `expression` && phase === `initial`) + expect + .soft(absentCalls.length, `initial: absent callback reach`) + .toBeGreaterThan(0) + for (const call of absentCalls) { + expect + .soft(call.child, `${phase}: valid callback absence`) + .toBeUndefined() + } + } + let primaryFailure: unknown = noPrimaryFailure + try { + await live.preload() + check() + phase = `child-update` + const changed = { id: 10, parentGroup: 1, value: 7 } + truth.set(10, changed) + children.write(initial === `empty` ? `insert` : `update`, changed) + check() + phase = `route-move` + group = 2 + parents.write(`update`, { id: 1, group }) + check() + } catch (error) { + primaryFailure = error + throw error + } finally { + await cleanupAfterOracle(primaryFailure, [ + () => live.cleanup(), + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + () => absent.collection.cleanup(), + ]) + } +} + +describe(`functional projection output compatibility`, () => { + oracleProperty({ + id: `includes-functional-projection.operator`, + law: { + statement: operatorLaw, + authority: `The functional-projection contract in packages/db/src/query/live/ARCHITECTURE.md, stated above.`, + limits: `The 18 enumerated form, operator, and include-read cells over controlled source Collections; no arbitrary callback body, provider timing, or other recursive shape.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: operatorCells, + label: ({ form, operator, readsInclude }) => + `${form} / ${operator} / reads-include=${readsInclude} consumes the projected value`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(operatorLaw, plan, observe, () => + judgeOperatorCell(plan), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-functional-projection.renamed`, + law: { + statement: renamedLaw, + authority: `The functional-projection contract in packages/db/src/query/live/ARCHITECTURE.md, stated above.`, + limits: `The 24 enumerated form, projection, consumer, and sibling cells over controlled source Collections; no arbitrary callback body, provider timing, or other recursive shape.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: renamedCells, + label: ({ form, consumer, projection, withSibling }) => + `${form} / ${projection} / ${consumer} / sibling=${withSibling} materializes inputs before renaming them`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(renamedLaw, plan, observe, () => + judgeRenamedCell(plan), + ), + check: forwardAssertionJudgment, + }) +}) + +describe(`functional include projection boundary grammar`, () => { + oracleProperty({ + id: `includes-functional-projection.boundary`, + law: { + statement: boundaryLaw, + authority: `The functional-projection contract in packages/db/src/query/live/ARCHITECTURE.md, stated above.`, + limits: `The 81 enumerated boundary, form, output, and initial-state cells, including the ordered-pair cells, over controlled source Collections; no arbitrary callback body, provider timing, or other recursive shape.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [...cells, ...orderedPairCells], + label: ({ boundary, form, output, initial }) => + `${boundary} / ${form} / ${output} / ${initial}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(boundaryLaw, plan, observe, () => + judgeBoundaryCell(plan), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/query/includes-functional-projection.test.ts b/packages/db/tests/query/includes-functional-projection.test.ts index b82fdbb02f..a8ab9613e1 100644 --- a/packages/db/tests/query/includes-functional-projection.test.ts +++ b/packages/db/tests/query/includes-functional-projection.test.ts @@ -14,11 +14,15 @@ import { flushPromises } from '../utils.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { Projection, + cells, cleanupAfterOracle, collectionInputError, noPrimaryFailure, + operatorCells, + orderedPairCells, readChildren, rejectsCollectionInput, + renamedCells, valueCells, } from './includes-functional-projection-support.js' @@ -1034,3 +1038,34 @@ describe(`functional include projection boundary grammar`, () => { } }) }) + +// Grammar-size checks for the cell tables that +// includes-functional-projection-oracle.test.ts enumerates. They test test +// code, so they live here rather than in the oracle file. +describe(`functional projection cell grammar`, () => { + it(`covers the declared output and renamed-field products`, () => { + expect(valueCells).toHaveLength(48) + expect(renamedCells).toHaveLength(24) + expect(operatorCells).toHaveLength(18) + expect(new Set(valueCells.map((cell) => JSON.stringify(cell))).size).toBe( + 48, + ) + expect(new Set(renamedCells.map((cell) => JSON.stringify(cell))).size).toBe( + 24, + ) + expect( + new Set(operatorCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(18) + }) + + it(`covers every declared boundary product without duplicate cells`, () => { + expect(cells).toHaveLength(54) + expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(54) + expect(orderedPairCells).toHaveLength(27) + expect( + new Set( + [...cells, ...orderedPairCells].map((cell) => JSON.stringify(cell)), + ).size, + ).toBe(81) + }) +}) From 8d0122e45483f24721bc35d25805c4ddf7662ddd Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:44:19 -0700 Subject: [PATCH 020/116] test(db): register load-subset, join-key, and WHERE oracle tests through oracleProperty Convert the abort-phase and retired-demand cases to enumerated properties, replay the join-key fixed histories as fixed histories of join-result-key.pairs, and replay the WHERE named histories through a where-predicate.publication-fixed enumerated property. The Kleene model calibration runs no production, so it moves to an ordinary test beside an extracted, test-free model module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 51 +- .../join-result-key-oracle.property.test.ts | 46 +- ...source-readiness-refinement-oracle.test.ts | 554 +++++++++--------- ...bset-transaction-refinement-oracle.test.ts | 321 +++++----- ...here-predicate-publication-model-oracle.ts | 415 +++++++++++++ .../where-predicate-publication-model.test.ts | 61 ++ ...dicate-publication-oracle.property.test.ts | 530 ++--------------- 7 files changed, 1056 insertions(+), 922 deletions(-) create mode 100644 packages/db/tests/query/where-predicate-publication-model-oracle.ts create mode 100644 packages/db/tests/query/where-predicate-publication-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 115f47958d..d3ebde2c1a 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -5,6 +5,53 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +// pagination-oracle.property.test.ts +const paginationProperties: ReadonlyArray = [] +const paginationOwners: ReadonlyArray = [] -export const owners: ReadonlyArray = [] +// ordered-work-oracle.property.test.ts +const orderedWorkProperties: ReadonlyArray = [] +const orderedWorkOwners: ReadonlyArray = [] + +// ordered-lifecycle, bucket-facade-rollback, cold-join-reconciliation, and +// ordered-source-loader-state oracles +const lifecycleProperties: ReadonlyArray = [] +const lifecycleOwners: ReadonlyArray = [] + +// The remaining query oracles +const otherProperties: ReadonlyArray = [ + `load-subset.transaction-refinement`, + `load-subset.source-readiness-refinement`, + `where-predicate.publication-fixed`, +] +const otherOwners: ReadonlyArray = [ + [ + `db/tests/query/load-subset-transaction-refinement-oracle.test.ts`, + `load-subset`, + `transaction-refinement`, + ], + [ + `db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts`, + `load-subset`, + `source-readiness-refinement`, + ], + [ + `db/tests/query/where-predicate-publication-oracle.property.test.ts`, + `where-predicate`, + `publication-fixed`, + ], +] + +export const properties: ReadonlyArray = [ + ...paginationProperties, + ...orderedWorkProperties, + ...lifecycleProperties, + ...otherProperties, +] + +export const owners: ReadonlyArray = [ + ...paginationOwners, + ...orderedWorkOwners, + ...lifecycleOwners, + ...otherOwners, +] 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 3386b961f4..7da0310685 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 @@ -42,7 +42,7 @@ * compiler's keying of the rows it supplies, not any real adapter's behavior. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { Query, createLiveQueryCollection, @@ -52,18 +52,13 @@ import { toArray, } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' import type { QueryBuilder } from '../../src/query/builder/index.js' @@ -279,28 +274,16 @@ async function runHistory(history: History): Promise { ]) } -declareOracle({ - check: [runHistory], - model: [expectedPairs], -}) +// A fixed history's name lists its left keys, so each regression keeps the +// name of the test it replaced. +const fixedHistoryName = (history: History) => + `keeps every ${history.join} join pair for ${history.left + .map((row) => + typeof row.id === `number` ? String(row.id) : JSON.stringify(row.id), + ) + .join(` `)}` describe(`joined result key oracle`, () => { - // 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 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(` `)}`, () => { - oracleRoute(`case`) - return runHistory(history) - }) - } - } - oracleProperty({ id: property, law: { @@ -318,6 +301,10 @@ describe(`joined result key oracle`, () => { fixedSeeds: [44501962], states: [], constructed: [], + fixed: fixedHistories.map((history) => ({ + name: fixedHistoryName(history), + history, + })), }, resolve: (history) => ({ history }), model: recordedAssertionJudgment, @@ -804,11 +791,6 @@ async function runSelectionHistory( ]) } -declareOracle({ - model: [expectedSelections], - check: [checkSelections, runSelectionHistory], - grammar: [selectionForms, selectionHistoryArbitrary], -}) // The old aggregate ledger counted each form/receiver/cut reached by the finite // matrix. The native finite property preserves every form. Each history checks // its own receiver/cut product at the same production boundaries, so all 4,608 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 3de72418f2..54bfdf5abd 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 @@ -1,4 +1,4 @@ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { BTreeIndex } from '../../src/index.js' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' @@ -8,9 +8,13 @@ import { eq, toArray, } from '../../src/query/index.js' -import { declareOracle } from '../oracle-declaration.js' import { flushPromises } from '../utils.js' -import { oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { finishCleanup } from './load-subset-source-readiness-support.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -48,289 +52,311 @@ function expectedRows( ] } -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/indexes/btree-index.ts', - 'src/query/live-query-collection.ts#createLiveQueryCollection', - ], - model: [expectedRows], -}) - -it.each([ +// The four-cell history grammar: old resolve/reject crossed with +// old-first/fresh-first settlement. +const retiredDemandCells = [ { oldOutcome: `resolve`, settlementOrder: `old-first` }, { oldOutcome: `reject`, settlementOrder: `old-first` }, { oldOutcome: `resolve`, settlementOrder: `fresh-first` }, { oldOutcome: `reject`, settlementOrder: `fresh-first` }, -] 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 = { - options: LoadSubsetOptions - rows: ReturnType>> - } - const caseId = `${oldOutcome}-${settlementOrder}` - const parentId = `readiness-generation-parent-${caseId}` - const childId = `readiness-generation-child-${caseId}` - let parentBegin!: () => void - let parentWrite!: (message: { - type: `update` - value: Parent - previousValue: Parent - }) => void - let parentCommit!: () => true | Promise - const oldParent: Parent = { id: `parent`, group: `old` } - const freshParent: Parent = { ...oldParent, group: `fresh` } - const parent = createCollection({ - id: parentId, - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - parentBegin = begin - parentWrite = write - parentCommit = commit - begin() - write({ type: `insert`, value: oldParent }) - commit() - markReady() - }, +] as const +type RetiredDemandCell = (typeof retiredDemandCells)[number] + +// The production driver: real parent and on-demand child Collections with +// controlled adapter delivery, compared with `expectedRows` at each cut. +async function runRetiredDemandCell({ + oldOutcome, + settlementOrder, +}: RetiredDemandCell): Promise { + type Parent = { id: string; group: string } + type Child = { id: string; group: string } + type PendingRequest = { + options: LoadSubsetOptions + rows: ReturnType>> + } + const caseId = `${oldOutcome}-${settlementOrder}` + const parentId = `readiness-generation-parent-${caseId}` + const childId = `readiness-generation-child-${caseId}` + let parentBegin!: () => void + let parentWrite!: (message: { + type: `update` + value: Parent + previousValue: Parent + }) => void + let parentCommit!: () => true | Promise + const oldParent: Parent = { id: `parent`, group: `old` } + const freshParent: Parent = { ...oldParent, group: `fresh` } + const parent = createCollection({ + id: parentId, + getKey: (row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + parentBegin = begin + parentWrite = write + parentCommit = commit + begin() + write({ type: `insert`, value: oldParent }) + commit() + markReady() }, - }) - let childBegin!: () => void - let childWrite!: (message: { type: `insert`; value: Child }) => void - let childCommit!: () => true | Promise - const pending: Array = [] - const unloads: Array<{ - options: LoadSubsetOptions - abortedAtUnload: boolean | undefined - }> = [] - const child = createCollection({ - id: childId, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - childBegin = begin - childWrite = write - childCommit = commit - markReady() - return { - loadSubset: (options) => { - const rows = createDeferred>() - pending.push({ options, rows }) - return rows.promise.then(async (acquiredRows) => { - if (acquiredRows.length > 0) { - childBegin() - for (const row of acquiredRows) { - childWrite({ type: `insert`, value: row }) - } - const applied = childCommit() - if (applied !== true) await applied + }, + }) + let childBegin!: () => void + let childWrite!: (message: { type: `insert`; value: Child }) => void + let childCommit!: () => true | Promise + const pending: Array = [] + const unloads: Array<{ + options: LoadSubsetOptions + abortedAtUnload: boolean | undefined + }> = [] + const child = createCollection({ + id: childId, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + childBegin = begin + childWrite = write + childCommit = commit + markReady() + return { + loadSubset: (options) => { + const rows = createDeferred>() + pending.push({ options, rows }) + return rows.promise.then(async (acquiredRows) => { + if (acquiredRows.length > 0) { + childBegin() + for (const row of acquiredRows) { + childWrite({ type: `insert`, value: row }) } - return - }) - }, - unloadSubset: (options) => { - unloads.push({ - options, - abortedAtUnload: options.signal?.aborted, - }) - }, - } - }, - }, - }) - const live = createLiveQueryCollection({ - id: `readiness-generation-live-${caseId}`, - query: (q) => - q.from({ parent }).select(({ parent: parentRow }) => ({ - id: parentRow.id, - children: toArray( - q - .from({ child }) - .where(({ child: childRow }) => - eq(childRow.group, parentRow.group), - ), - ), - })), - startSync: true, - }) - const visibleRows = (): Array<{ id: string; children: Array }> => - live.toArray.map((row) => ({ - id: row.id, - children: row.children.map((childRow) => ({ - id: childRow.id, - group: childRow.group, - })), - })) - let preloadState: `pending` | `resolved` | `rejected` = `pending` - const preload = live.preload() - void preload.then( - () => { - preloadState = `resolved` - }, - () => { - preloadState = `rejected` - }, - ) - const requestedGroups = (options: LoadSubsetOptions): Array => - extractSimpleComparisons(options.where).flatMap((comparison) => { - if (comparison.field.join(`.`) !== `group`) return [] - if (comparison.operator === `eq`) { - return typeof comparison.value === `string` ? [comparison.value] : [] - } - if (comparison.operator !== `in` || !Array.isArray(comparison.value)) { - return [] + const applied = childCommit() + if (applied !== true) await applied + } + return + }) + }, + unloadSubset: (options) => { + unloads.push({ + options, + abortedAtUnload: options.signal?.aborted, + }) + }, } - return comparison.value.filter( - (value): value is string => typeof value === `string`, - ) - }) - const expectUnloads = ( - ...expectedOptions: ReadonlyArray - ): void => { - expect(unloads).toHaveLength(expectedOptions.length) - for (const [index, options] of expectedOptions.entries()) { - expect(unloads[index]!.options).toBe(options) - expect(unloads[index]!.abortedAtUnload).toBe(true) + }, + }, + }) + const live = createLiveQueryCollection({ + id: `readiness-generation-live-${caseId}`, + query: (q) => + q.from({ parent }).select(({ parent: parentRow }) => ({ + id: parentRow.id, + children: toArray( + q + .from({ child }) + .where(({ child: childRow }) => + eq(childRow.group, parentRow.group), + ), + ), + })), + startSync: true, + }) + const visibleRows = (): Array<{ id: string; children: Array }> => + live.toArray.map((row) => ({ + id: row.id, + children: row.children.map((childRow) => ({ + id: childRow.id, + group: childRow.group, + })), + })) + let preloadState: `pending` | `resolved` | `rejected` = `pending` + const preload = live.preload() + void preload.then( + () => { + preloadState = `resolved` + }, + () => { + preloadState = `rejected` + }, + ) + const requestedGroups = (options: LoadSubsetOptions): Array => + extractSimpleComparisons(options.where).flatMap((comparison) => { + if (comparison.field.join(`.`) !== `group`) return [] + if (comparison.operator === `eq`) { + return typeof comparison.value === `string` ? [comparison.value] : [] + } + if (comparison.operator !== `in` || !Array.isArray(comparison.value)) { + return [] } + return comparison.value.filter( + (value): value is string => typeof value === `string`, + ) + }) + const expectUnloads = ( + ...expectedOptions: ReadonlyArray + ): void => { + expect(unloads).toHaveLength(expectedOptions.length) + for (const [index, options] of expectedOptions.entries()) { + expect(unloads[index]!.options).toBe(options) + expect(unloads[index]!.abortedAtUnload).toBe(true) } - let liveCleaned = false - let primaryFailure: { error: unknown } | undefined + } + let liveCleaned = false + let primaryFailure: { error: unknown } | undefined - try { - await flushPromises() - expect(pending).toHaveLength(1) - expect(requestedGroups(pending[0]!.options)).toEqual([`old`]) - expect(live.status).toBe(`loading`) - expect(preloadState).toBe(`pending`) - expect(visibleRows()).toEqual(expectedRows(`old`, [])) + try { + await flushPromises() + expect(pending).toHaveLength(1) + expect(requestedGroups(pending[0]!.options)).toEqual([`old`]) + expect(live.status).toBe(`loading`) + expect(preloadState).toBe(`pending`) + expect(visibleRows()).toEqual(expectedRows(`old`, [])) - // Progressive rows applied before retirement remain in the source. - // Their route must still disappear when the parent changes group. - const oldChild: Child = { id: `old-child`, group: `old` } - childBegin() - childWrite({ type: `insert`, value: oldChild }) - const oldApplied = childCommit() - if (oldApplied !== true) await oldApplied - await flushPromises() - expect(visibleRows()).toEqual(expectedRows(`old`, [oldChild])) - expect(preloadState).toBe(`pending`) + // Progressive rows applied before retirement remain in the source. + // Their route must still disappear when the parent changes group. + const oldChild: Child = { id: `old-child`, group: `old` } + childBegin() + childWrite({ type: `insert`, value: oldChild }) + const oldApplied = childCommit() + if (oldApplied !== true) await oldApplied + await flushPromises() + expect(visibleRows()).toEqual(expectedRows(`old`, [oldChild])) + expect(preloadState).toBe(`pending`) - parentBegin() - parentWrite({ - type: `update`, - value: freshParent, - previousValue: oldParent, - }) - const parentApplied = parentCommit() - if (parentApplied !== true) await parentApplied - await flushPromises() + parentBegin() + parentWrite({ + type: `update`, + value: freshParent, + previousValue: oldParent, + }) + const parentApplied = parentCommit() + if (parentApplied !== true) await parentApplied + await flushPromises() - expect(pending).toHaveLength(2) - expect(requestedGroups(pending[0]!.options)).toEqual([`old`]) - expect(requestedGroups(pending[1]!.options)).toEqual([`fresh`]) - expect(pending[0]!.options.signal?.aborted).toBe(true) - expect(pending[1]!.options.signal?.aborted).toBe(false) - expectUnloads(pending[0]!.options) - expect(live.status).toBe(`loading`) - expect(preloadState).toBe(`pending`) - expect(child.get(oldChild.id)).toEqual(expect.objectContaining(oldChild)) - expect(visibleRows()).toEqual(expectedRows(`fresh`, [oldChild])) - // A route that retained the retired bucket would fail this comparison. - expect([{ id: `parent`, children: [oldChild] }]).not.toEqual( - expectedRows(`fresh`, [oldChild]), - ) + expect(pending).toHaveLength(2) + expect(requestedGroups(pending[0]!.options)).toEqual([`old`]) + expect(requestedGroups(pending[1]!.options)).toEqual([`fresh`]) + expect(pending[0]!.options.signal?.aborted).toBe(true) + expect(pending[1]!.options.signal?.aborted).toBe(false) + expectUnloads(pending[0]!.options) + expect(live.status).toBe(`loading`) + expect(preloadState).toBe(`pending`) + expect(child.get(oldChild.id)).toEqual(expect.objectContaining(oldChild)) + expect(visibleRows()).toEqual(expectedRows(`fresh`, [oldChild])) + // A route that retained the retired bucket would fail this comparison. + expect([{ id: `parent`, children: [oldChild] }]).not.toEqual( + expectedRows(`fresh`, [oldChild]), + ) - const freshChild: Child = { id: `fresh-child`, group: `fresh` } - const freshSettlement = { settled: false } - const settleOld = async () => { - if (oldOutcome === `resolve`) { - pending[0]!.rows.resolve([]) - } else { - pending[0]!.rows.reject(new Error(`retired source demand failed`)) - } - await flushPromises() - } - const settleFresh = async () => { - expect(child.get(freshChild.id)).toBeUndefined() - pending[1]!.rows.resolve([freshChild]) - await flushPromises() - freshSettlement.settled = true - } - const settlements = - settlementOrder === `old-first` - ? [settleOld, settleFresh] - : [settleFresh, settleOld] - for (const settle of settlements) { - await settle() - expect(live.status).toBe(freshSettlement.settled ? `ready` : `loading`) - expect(preloadState).toBe( - freshSettlement.settled ? `resolved` : `pending`, - ) - expect(visibleRows()).toEqual( - expectedRows( - `fresh`, - freshSettlement.settled ? [oldChild, freshChild] : [oldChild], - ), - ) - expect(live.utils.lastSubsetError).toBeUndefined() + const freshChild: Child = { id: `fresh-child`, group: `fresh` } + const freshSettlement = { settled: false } + const settleOld = async () => { + if (oldOutcome === `resolve`) { + pending[0]!.rows.resolve([]) + } else { + pending[0]!.rows.reject(new Error(`retired source demand failed`)) } - - await preload await flushPromises() - - expect(live.status).toBe(`ready`) - expect(preloadState).toBe(`resolved`) - expect(live.utils.lastSubsetError).toBeUndefined() - expect(child.get(freshChild.id)).toEqual( - expect.objectContaining(freshChild), + } + const settleFresh = async () => { + expect(child.get(freshChild.id)).toBeUndefined() + pending[1]!.rows.resolve([freshChild]) + await flushPromises() + freshSettlement.settled = true + } + const settlements = + settlementOrder === `old-first` + ? [settleOld, settleFresh] + : [settleFresh, settleOld] + for (const settle of settlements) { + await settle() + expect(live.status).toBe(freshSettlement.settled ? `ready` : `loading`) + expect(preloadState).toBe( + freshSettlement.settled ? `resolved` : `pending`, ) expect(visibleRows()).toEqual( - expectedRows(`fresh`, [oldChild, freshChild]), + expectedRows( + `fresh`, + freshSettlement.settled ? [oldChild, freshChild] : [oldChild], + ), ) - expect(live.toArray).toEqual([ - expect.objectContaining({ - id: `parent`, - children: [expect.objectContaining({ id: `fresh-child` })], - }), - ]) - expect(pending[1]!.options.signal?.aborted).toBe(false) - expectUnloads(pending[0]!.options) + expect(live.utils.lastSubsetError).toBeUndefined() + } - await live.cleanup() - liveCleaned = true - expect(pending[1]!.options.signal?.aborted).toBe(true) - expectUnloads(pending[0]!.options, pending[1]!.options) - } catch (error) { - primaryFailure = { error } - throw error - } finally { - for (const request of pending) { - request.rows.resolve([]) - } - await finishCleanup(primaryFailure, [ - [ - { name: `preload waiter`, run: () => preload.catch(() => undefined) }, - { - name: `live query`, - run: () => (liveCleaned ? Promise.resolve() : live.cleanup()), - }, - ], - [ - { name: `parent source`, run: () => parent.cleanup() }, - { name: `child source`, run: () => child.cleanup() }, - ], - ]) + await preload + await flushPromises() + + expect(live.status).toBe(`ready`) + expect(preloadState).toBe(`resolved`) + expect(live.utils.lastSubsetError).toBeUndefined() + expect(child.get(freshChild.id)).toEqual( + expect.objectContaining(freshChild), + ) + expect(visibleRows()).toEqual(expectedRows(`fresh`, [oldChild, freshChild])) + expect(live.toArray).toEqual([ + expect.objectContaining({ + id: `parent`, + children: [expect.objectContaining({ id: `fresh-child` })], + }), + ]) + expect(pending[1]!.options.signal?.aborted).toBe(false) + expectUnloads(pending[0]!.options) + + await live.cleanup() + liveCleaned = true + expect(pending[1]!.options.signal?.aborted).toBe(true) + expectUnloads(pending[0]!.options, pending[1]!.options) + } catch (error) { + primaryFailure = { error } + throw error + } finally { + for (const request of pending) { + request.rows.resolve([]) } + await finishCleanup(primaryFailure, [ + [ + { name: `preload waiter`, run: () => preload.catch(() => undefined) }, + { + name: `live query`, + run: () => (liveCleaned ? Promise.resolve() : live.cleanup()), + }, + ], + [ + { name: `parent source`, run: () => parent.cleanup() }, + { name: `child source`, run: () => child.cleanup() }, + ], + ]) + } +} + +oracleProperty({ + id: `load-subset.source-readiness-refinement`, + law: { + statement: `A retired correlated child demand may resolve or reject before or after the fresh one, but it never marks the query ready or releases the fresh acquisition lease, and visible children are the applied source rows that match the current parent group.`, + authority: `The stale-demand and initial-demand laws of packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The four bounded fixtures; no arbitrary provider cancellation or framework render timing.`, }, -) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: retiredDemandCells, + label: ({ oldOutcome, settlementOrder }) => + `fences a retired source-demand attempt across ${settlementOrder} ${oldOutcome} settlement`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `load-subset source readiness refinement`, + plan, + observe, + () => runRetiredDemandCell(plan.cell), + ), + check: forwardAssertionJudgment, +}) /** * Initial-query readiness for this two-source join needs both root demands. 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 fed4660dd1..838c40f9fc 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 @@ -1,10 +1,14 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' 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' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' /** * # When can an abort cancel a load? @@ -60,159 +64,174 @@ function expectedOutcome( } } -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/transactions.ts#createTransaction', - ], - model: [expectedOutcome], -}) +// The production driver: it creates one abort phase in production and +// compares the load, change event, and collection snapshot with +// `expectedOutcome`. +async function runAbortPhase(abortPhase: AbortPhase): Promise { + const sourceId = `transaction-refinement-${abortPhase}` + const remoteRow: Row = { id: `remote`, group: `requested` } + const expected = expectedOutcome(abortPhase, remoteRow.id) + const controller = new AbortController() + const persistence = createDeferred() + const publishedBatches: Array> = [] + const callbackReads: Array> = [] + let loadSubsetCalls = 0 + let abortObservedAtCommit = false + let receiptWasDeferred = false + let abortRaisedByPublication = false -describe(`loadSubset transaction refinement`, () => { - // These three phases cover the contract: - // - The caller aborts during commit. - // - The caller aborts after commit while publication waits. - // - The caller aborts when the first listener runs. - // This oracle does not vary the schedule within each phase. - it.each([ - `at-commit`, - `while-parked`, - `after-publication-starts`, - ])( - `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) - const controller = new AbortController() - const persistence = createDeferred() - const publishedBatches: Array> = [] - const callbackReads: Array> = [] - let loadSubsetCalls = 0 - let abortObservedAtCommit = false - let receiptWasDeferred = false - let abortRaisedByPublication = false - - // Keep the local mutation unresolved. This delays publication of the - // remote row and creates the second abort phase. - const source = createCollection({ - id: sourceId, - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: ({ signal }) => { - loadSubsetCalls++ - begin() - write({ type: `insert`, value: remoteRow }) - if (abortPhase === `at-commit`) controller.abort() - abortObservedAtCommit = signal?.aborted ?? false - const receipt = commit(signal) - receiptWasDeferred = receipt instanceof Promise - // Source contract: a caller that aborted sees `AbortError`, - // even though its accepted rows apply. - return Promise.resolve(receipt).then(() => { - if (signal?.aborted) - throw new LoadSubsetOperationAbortedError() - }) - }, - } + // Keep the local mutation unresolved. This delays publication of the + // remote row and creates the second abort phase. + const source = createCollection({ + id: sourceId, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: ({ signal }) => { + loadSubsetCalls++ + begin() + write({ type: `insert`, value: remoteRow }) + if (abortPhase === `at-commit`) controller.abort() + abortObservedAtCommit = signal?.aborted ?? false + const receipt = commit(signal) + receiptWasDeferred = receipt instanceof Promise + // Source contract: a caller that aborted sees `AbortError`, + // even though its accepted rows apply. + return Promise.resolve(receipt).then(() => { + if (signal?.aborted) throw new LoadSubsetOperationAbortedError() + }) }, - }, - }) - source.startSyncImmediate() - const blocker = createTransaction({ - mutationFn: () => persistence.promise, - }) - blocker.mutate(() => - source.insert({ id: `local`, group: `outside-request` }), - ) - - // Record the change and the collection snapshot at the same publication. - const subscription = source.subscribeChanges( - (changes) => { - const remoteKeys = changes - .filter((change) => change.key === remoteRow.id) - .map((change) => String(change.key)) - if (remoteKeys.length === 0) return - publishedBatches.push(remoteKeys) - callbackReads.push(source.has(remoteRow.id) ? [remoteRow.id] : []) - if (abortPhase === `after-publication-starts`) { - controller.abort() - abortRaisedByPublication = controller.signal.aborted - } - }, - { includeInitialState: false }, - ) - let primaryFailure: unknown - let failed = false - const cleanupFailures: Array = [] - try { - const load = source._sync.loadSubset({ signal: controller.signal }) - // Keep a rejection observed if an earlier reach assertion fails. - if (load instanceof Promise) void load.catch(() => undefined) - expect(load).toBeInstanceOf(Promise) - - // The adapter and applied-receipt boundary must be reached in every - // phase. The held mutation keeps the remote sync transaction parked. - expect(loadSubsetCalls).toBe(1) - expect(receiptWasDeferred).toBe(true) - expect(abortObservedAtCommit).toBe(abortPhase === `at-commit`) - expect(source.has(remoteRow.id)).toBe(false) - - if (abortPhase === `while-parked`) { - controller.abort() - } - - persistence.resolve() - await blocker.when(`settled`) - - if (expected.load === `rejects`) { - await expect(load).rejects.toMatchObject({ name: `AbortError` }) - } else { - await expect(load).resolves.toBeUndefined() } + }, + }, + }) + source.startSyncImmediate() + const blocker = createTransaction({ + mutationFn: () => persistence.promise, + }) + blocker.mutate(() => source.insert({ id: `local`, group: `outside-request` })) - // The load promise, change event, and collection snapshot must agree. - // A mismatch would expose a partial publication to callers. - expect(source.has(remoteRow.id)).toBe(expected.rowIsVisible) - expect(publishedBatches).toEqual(expected.publishedBatches) - expect(callbackReads).toEqual(expected.callbackReads) - expect(abortRaisedByPublication).toBe( - abortPhase === `after-publication-starts`, - ) - } catch (error) { - primaryFailure = error - failed = true - } finally { - persistence.resolve() - try { - await blocker.when(`settled`) - } catch (error) { - cleanupFailures.push(error) - } - try { - subscription.unsubscribe() - } catch (error) { - cleanupFailures.push(error) - } - try { - await source.cleanup() - } catch (error) { - cleanupFailures.push(error) - } + // Record the change and the collection snapshot at the same publication. + const subscription = source.subscribeChanges( + (changes) => { + const remoteKeys = changes + .filter((change) => change.key === remoteRow.id) + .map((change) => String(change.key)) + if (remoteKeys.length === 0) return + publishedBatches.push(remoteKeys) + callbackReads.push(source.has(remoteRow.id) ? [remoteRow.id] : []) + if (abortPhase === `after-publication-starts`) { + controller.abort() + abortRaisedByPublication = controller.signal.aborted } - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `loadSubset transaction refinement cleanup failed`, - { cause: failed ? primaryFailure : cleanupFailures[0] }, - ) - } - if (failed) throw primaryFailure }, + { includeInitialState: false }, ) + let primaryFailure: unknown + let failed = false + const cleanupFailures: Array = [] + try { + const load = source._sync.loadSubset({ signal: controller.signal }) + // Keep a rejection observed if an earlier reach assertion fails. + if (load instanceof Promise) void load.catch(() => undefined) + expect(load).toBeInstanceOf(Promise) + + // The adapter and applied-receipt boundary must be reached in every + // phase. The held mutation keeps the remote sync transaction parked. + expect(loadSubsetCalls).toBe(1) + expect(receiptWasDeferred).toBe(true) + expect(abortObservedAtCommit).toBe(abortPhase === `at-commit`) + expect(source.has(remoteRow.id)).toBe(false) + + if (abortPhase === `while-parked`) { + controller.abort() + } + + persistence.resolve() + await blocker.when(`settled`) + + if (expected.load === `rejects`) { + await expect(load).rejects.toMatchObject({ name: `AbortError` }) + } else { + await expect(load).resolves.toBeUndefined() + } + + // The load promise, change event, and collection snapshot must agree. + // A mismatch would expose a partial publication to callers. + expect(source.has(remoteRow.id)).toBe(expected.rowIsVisible) + expect(publishedBatches).toEqual(expected.publishedBatches) + expect(callbackReads).toEqual(expected.callbackReads) + expect(abortRaisedByPublication).toBe( + abortPhase === `after-publication-starts`, + ) + } catch (error) { + primaryFailure = error + failed = true + } finally { + persistence.resolve() + try { + await blocker.when(`settled`) + } catch (error) { + cleanupFailures.push(error) + } + try { + subscription.unsubscribe() + } catch (error) { + cleanupFailures.push(error) + } + try { + await source.cleanup() + } catch (error) { + cleanupFailures.push(error) + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + cleanupFailures, + `loadSubset transaction refinement cleanup failed`, + { cause: failed ? primaryFailure : cleanupFailures[0] }, + ) + } + if (failed) throw primaryFailure +} + +// These three phases cover the contract: +// - The caller aborts during commit. +// - The caller aborts after commit while publication waits. +// - The caller aborts when the first listener runs. +// This oracle does not vary the schedule within each phase. +const abortPhases: Array = [ + `at-commit`, + `while-parked`, + `after-publication-starts`, +] + +const transactionRefinementLaw = `loadSubset transaction refinement` + +describe(`loadSubset transaction refinement`, () => { + oracleProperty({ + id: `load-subset.transaction-refinement`, + law: { + statement: `Commit accepts an on-demand batch and an accepted batch always applies: an abort before acceptance discards the row, a later abort keeps it, and every aborted load rejects with AbortError.`, + authority: `The applied-receipt contract in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `One row, one parked optimistic transaction, and three abort phases at the direct source Collection boundary; no live-query graph and no other schedules.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/transactions.ts#createTransaction', + ], + histories: abortPhases, + label: (abortPhase) => + `matches the independent receipt and publication model when aborting ${abortPhase}`, + resolve: (abortPhase) => ({ abortPhase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(transactionRefinementLaw, plan, observe, () => + runAbortPhase(plan.abortPhase), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/query/where-predicate-publication-model-oracle.ts b/packages/db/tests/query/where-predicate-publication-model-oracle.ts new file mode 100644 index 0000000000..7242bb9fcb --- /dev/null +++ b/packages/db/tests/query/where-predicate-publication-model-oracle.ts @@ -0,0 +1,415 @@ +/** + * The independent model, history grammar, and named histories of + * `where-predicate-publication-oracle.property.test.ts`, moved unchanged so + * that its model calibration in `where-predicate-publication-model.test.ts` + * can read them. That oracle file states the law and its authority. This + * module registers no tests. + */ +import fc from 'fast-check' +import { MISSING, fieldEq } from './where-predicate-support.js' +import type { + FieldValue, + Operand, + Predicate, +} from './where-predicate-support.js' + +// A string whose content equals an internal normalized-key prefix must still +// compare as an ordinary string. +export const PREFIXED = `\u0000tanstack-db:string:a` +// A valid Date equals the number of its timestamp. +export const DATE_ONE = new Date(1) + +export type Truth = boolean | null + +export const fieldValues: ReadonlyArray = [ + `a`, + `b`, + PREFIXED, + true, + false, + 1, + 2, + Number.NaN, + DATE_ONE, + null, + MISSING, +] +export const literalValues: ReadonlyArray = [ + `a`, + PREFIXED, + true, + false, + 1, + Number.NaN, + null, +] + +export type SeedRow = { v: FieldValue; optimistic: boolean } +export type Operation = + | { type: `insert`; v: FieldValue; reuse?: number } + | { type: `update`; target: number; v: FieldValue } + | { type: `delete`; target: number } +export type History = + | { kind: `snapshot`; rows: Array; predicate: Predicate } + | { + kind: `changes` + rows: Array + predicate: Predicate + transactions: Array> + } + +// --------------------------------------------------------------------------- +// Model +// --------------------------------------------------------------------------- + +export type ModelRow = { + id: string + v: FieldValue + $hasPendingWrites: boolean + $origin: `local` | `remote` + $key: string +} + +export function operandValue(operand: Operand, row: ModelRow): unknown { + switch (operand.kind) { + case `field`: + return row.v === MISSING ? undefined : row.v + case `literal`: + return operand.value === MISSING ? undefined : operand.value + case `virtual`: + return row[operand.name] + } +} + +// SQL equality: nullish is UNKNOWN, NaN equals NaN, a Date compares as its +// timestamp, and other values compare by type and value. +export function expectedEquality(a: unknown, b: unknown): Truth { + if (a === null || a === undefined || b === null || b === undefined) { + return null + } + const left = a instanceof Date ? a.getTime() : a + const right = b instanceof Date ? b.getTime() : b + if (Number.isNaN(left) && Number.isNaN(right)) return true + return typeof left === typeof right && left === right +} + +// Kleene logic: FALSE dominates AND, TRUE dominates OR, NOT keeps UNKNOWN. +export function expectedTruth(predicate: Predicate, row: ModelRow): Truth { + switch (predicate.kind) { + case `eq`: + return expectedEquality( + operandValue(predicate.left, row), + operandValue(predicate.right, row), + ) + case `not`: { + const value = expectedTruth(predicate.arg, row) + return value === null ? null : !value + } + case `and`: { + const values = predicate.args.map((arg) => expectedTruth(arg, row)) + if (values.includes(false)) return false + return values.includes(null) ? null : true + } + case `or`: { + const values = predicate.args.map((arg) => expectedTruth(arg, row)) + if (values.includes(true)) return true + return values.includes(null) ? null : false + } + } +} + +// A subscriber without initial state sees a TRUE row only after a later +// insert or update touches it. +export function expectedVisible( + predicate: Predicate, + rows: ReadonlyMap, + touched?: ReadonlySet, +): Array { + return [...rows.values()] + .filter( + (row) => + expectedTruth(predicate, row) === true && + (touched === undefined || touched.has(row.id)), + ) + .map((row) => row.id) + .sort() +} + +// Updates between these values are no-ops outside this grammar. +export function equivalentFieldValues(a: FieldValue, b: FieldValue): boolean { + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime() + } + return Object.is(a, b) +} + +export function settledModelRow(id: string, v: FieldValue): ModelRow { + return { id, v, $hasPendingWrites: false, $origin: `remote`, $key: id } +} + +// --------------------------------------------------------------------------- +// History grammar +// --------------------------------------------------------------------------- + +export const fieldValueArbitrary = fc.constantFrom(...fieldValues) +export const operandArbitrary: fc.Arbitrary = fc.oneof( + { weight: 4, arbitrary: fc.constant({ kind: `field` as const }) }, + { + weight: 4, + arbitrary: fc + .constantFrom(...literalValues) + .map((value): Operand => ({ kind: `literal`, value })), + }, + { + weight: 1, + arbitrary: fc + .constantFrom( + `$hasPendingWrites` as const, + `$origin` as const, + `$key` as const, + ) + .map((name): Operand => ({ kind: `virtual`, name })), + }, +) +export const leafArbitrary: fc.Arbitrary = fc + .tuple(operandArbitrary, operandArbitrary) + .map(([left, right]) => ({ kind: `eq` as const, left, right })) + +export const predicateArbitrary: fc.Arbitrary = fc.letrec<{ + predicate: Predicate +}>((tie) => ({ + predicate: fc.oneof( + { depthSize: `small`, maxDepth: 3 }, + leafArbitrary, + tie(`predicate`).map((arg) => ({ kind: `not` as const, arg })), + fc + .tuple( + fc.constantFrom(`and` as const, `or` as const), + tie(`predicate`), + tie(`predicate`), + ) + .map(([kind, left, right]) => ({ + kind, + args: [left, right] as [Predicate, Predicate], + })), + ), +})).predicate + +// A top-level `eq(v, string | boolean)` conjunct lets a subscription skip +// batches that cannot match and lets an unindexed snapshot reject stored rows +// before copying them. Histories favor that shape so generated histories +// exercise both shortcuts, not only the full filter. +export const prefilterablePredicateArbitrary: fc.Arbitrary = fc + .tuple( + fc.constantFrom(`a`, PREFIXED, true), + fc.option(predicateArbitrary, { nil: undefined }), + ) + .map(([value, rest]): Predicate => { + const conjunct: Predicate = { + kind: `eq`, + left: { kind: `field` }, + right: { kind: `literal`, value }, + } + return rest === undefined + ? conjunct + : { kind: `and`, args: [conjunct, rest] } + }) + +// Histories favor one matching and one non-matching string so rows cross the +// predicate boundary often; the full domain stays reachable. +export const changeValueArbitrary = fc.oneof( + { weight: 2, arbitrary: fc.constantFrom(`a`, `b`) }, + fieldValueArbitrary, +) + +export const operationArbitrary: fc.Arbitrary = fc.oneof( + fc + .tuple( + changeValueArbitrary, + fc.option(fc.nat({ max: 7 }), { nil: undefined }), + ) + .map(([v, reuse]): Operation => + reuse === undefined + ? { type: `insert`, v } + : { type: `insert`, v, reuse }, + ), + { + weight: 2, + arbitrary: fc + .tuple(fc.nat({ max: 7 }), changeValueArbitrary) + .map(([target, v]): Operation => ({ type: `update`, target, v })), + }, + fc.nat({ max: 7 }).map((target): Operation => ({ type: `delete`, target })), +) + +export const historyArbitrary: fc.Arbitrary = fc.oneof( + fc.record({ + kind: fc.constant(`snapshot` as const), + rows: fc.array( + fc.record({ v: changeValueArbitrary, optimistic: fc.boolean() }), + { minLength: 1, maxLength: 6 }, + ), + predicate: fc.oneof(predicateArbitrary, prefilterablePredicateArbitrary), + }), + { + weight: 2, + arbitrary: fc.record({ + kind: fc.constant(`changes` as const), + rows: fc.array(changeValueArbitrary, { maxLength: 5 }), + predicate: fc.oneof(predicateArbitrary, { + weight: 2, + arbitrary: prefilterablePredicateArbitrary, + }), + transactions: fc.array( + fc.array(operationArbitrary, { minLength: 1, maxLength: 3 }), + { minLength: 1, maxLength: 6 }, + ), + }), + }, +) + +// 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. +export const fixedHistories: ReadonlyArray = [ + { + kind: `snapshot`, + rows: [ + { v: `a`, optimistic: false }, + { v: `b`, optimistic: false }, + { v: null, optimistic: false }, + { v: MISSING, optimistic: false }, + ], + predicate: { kind: `not`, arg: fieldEq(`a`) }, + }, + { + kind: `snapshot`, + rows: [ + { v: `a`, optimistic: true }, + { v: null, optimistic: false }, + { v: Number.NaN, optimistic: false }, + { v: PREFIXED, optimistic: true }, + ], + predicate: { + kind: `or`, + args: [ + { + kind: `not`, + arg: { + kind: `eq`, + left: { kind: `literal`, value: true }, + right: { kind: `field` }, + }, + }, + { + kind: `eq`, + left: { kind: `virtual`, name: `$hasPendingWrites` }, + right: { kind: `literal`, value: true }, + }, + ], + }, + }, + { + kind: `snapshot`, + rows: [ + { v: PREFIXED, optimistic: false }, + { v: `a`, optimistic: false }, + { v: Number.NaN, optimistic: false }, + { v: 1, optimistic: false }, + { v: MISSING, optimistic: false }, + ], + predicate: { + kind: `and`, + args: [ + { kind: `not`, arg: fieldEq(PREFIXED) }, + { kind: `not`, arg: fieldEq(Number.NaN) }, + ], + }, + }, +] + +// 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. +export const fixedPrefilteredSnapshot: History = { + kind: `snapshot`, + rows: [ + { v: `a`, optimistic: true }, + { v: `a`, optimistic: false }, + { v: `b`, optimistic: true }, + ], + predicate: fieldEq(`a`), +} + +// Each change history moves rows across a predicate whose equality operand a +// skip-if-no-match shortcut can misread. +export const fixedChangeHistories: ReadonlyArray = [ + { + // `or` operands are alternatives, not conjuncts. + kind: `changes`, + rows: [`b`, `a`], + predicate: { kind: `or`, args: [fieldEq(`a`), fieldEq(true)] }, + transactions: [ + [{ type: `update`, target: 0, v: true }], + [ + { type: `update`, target: 1, v: false }, + { type: `insert`, v: true }, + ], + ], + }, + { + // A valid Date equals its timestamp, so a number literal cannot prefilter + // by identity. + kind: `changes`, + rows: [2, 2], + predicate: fieldEq(1), + transactions: [ + [{ type: `update`, target: 0, v: DATE_ONE }], + [ + { type: `update`, target: 0, v: 2 }, + { type: `update`, target: 1, v: 1 }, + ], + ], + }, + { + // NaN equals NaN, although NaN is not identical to itself. + kind: `changes`, + rows: [2], + predicate: fieldEq(Number.NaN), + transactions: [[{ type: `update`, target: 0, v: Number.NaN }]], + }, + { + // A subscriber without initial state never saw the dropped `b` row. + // Deleting it and reinserting its key with a matching value must publish + // the new row, not treat it as a duplicate insert. + kind: `changes`, + rows: [], + predicate: fieldEq(`a`), + transactions: [ + [ + { type: `insert`, v: `a` }, + { type: `insert`, v: `b` }, + ], + [{ type: `delete`, target: 1 }], + [{ type: `insert`, v: `a`, reuse: 0 }], + ], + }, + { + // A row that moves out must be retracted, including inside one batch. + kind: `changes`, + rows: [`a`, `a`, `b`], + predicate: { + kind: `and`, + args: [fieldEq(`a`), { kind: `not`, arg: fieldEq(PREFIXED) }], + }, + transactions: [ + [ + { type: `update`, target: 0, v: `b` }, + { type: `update`, target: 2, v: `a` }, + ], + [ + { type: `delete`, target: 1 }, + { type: `insert`, v: `a` }, + { type: `update`, target: 0, v: `a` }, + ], + ], + }, +] diff --git a/packages/db/tests/query/where-predicate-publication-model.test.ts b/packages/db/tests/query/where-predicate-publication-model.test.ts new file mode 100644 index 0000000000..38e88c7c02 --- /dev/null +++ b/packages/db/tests/query/where-predicate-publication-model.test.ts @@ -0,0 +1,61 @@ +// Model calibration for where-predicate-publication-oracle.property.test.ts. +// It runs no production: it checks that each named snapshot history +// distinguishes the independent Kleene model from a two-valued fold, so that +// the history calibrates the oracle. The law, its authority, and the +// production comparison are in that oracle file; the model and named +// histories are in where-predicate-publication-model-oracle.ts. +import { describe, expect, it } from 'vitest' +import { oracleReplayRequested } from '../oracle-config.js' +import { + expectedEquality, + expectedVisible, + fixedHistories, + operandValue, + settledModelRow, +} from './where-predicate-publication-model-oracle.js' +import type { ModelRow } from './where-predicate-publication-model-oracle.js' +import type { Predicate } from './where-predicate-support.js' + +describe(`WHERE predicate publication oracle`, () => { + if (!oracleReplayRequested()) { + it(`fixed snapshot histories distinguish Kleene logic from two-valued logic`, () => { + // A checker that folds UNKNOWN into FALSE before NOT must disagree with + // the model on each fixed snapshot, or the history does not calibrate. + const twoValued = (predicate: Predicate, row: ModelRow): boolean => { + switch (predicate.kind) { + case `eq`: + return ( + expectedEquality( + operandValue(predicate.left, row), + operandValue(predicate.right, row), + ) === true + ) + case `not`: + return !twoValued(predicate.arg, row) + case `and`: + return predicate.args.every((arg) => twoValued(arg, row)) + case `or`: + return predicate.args.some((arg) => twoValued(arg, row)) + } + } + 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] => [ + `r${index}`, + { + ...settledModelRow(`r${index}`, seed.v), + $hasPendingWrites: seed.optimistic, + $origin: seed.optimistic ? `local` : `remote`, + }, + ]), + ) + const naive = [...rows.values()] + .filter((row) => twoValued(history.predicate, row)) + .map((row) => row.id) + .sort() + expect(naive).not.toEqual(expectedVisible(history.predicate, rows)) + } + }) + } +}) 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 da38c9b77b..2ff1d40bf2 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 @@ -82,8 +82,7 @@ * them. The mock sync source is a controlled provider; this oracle claims only * how the Collection filters and routes the sync transactions it supplies. */ -import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { and, eq, not, or } from '../../src/query/builder/functions.js' @@ -94,12 +93,7 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' import { MISSING, @@ -107,6 +101,16 @@ import { irPredicate, recordVisibleKeys, } from './where-predicate-support.js' +import { + PREFIXED, + equivalentFieldValues, + expectedVisible, + fixedChangeHistories, + fixedHistories, + fixedPrefilteredSnapshot, + historyArbitrary, + settledModelRow, +} from './where-predicate-publication-model-oracle.js' import type { FieldValue, Operand, @@ -114,410 +118,14 @@ import type { Row, } from './where-predicate-support.js' import type { BasicExpression } from '../../src/query/ir.js' +import type { + History, + ModelRow, + SeedRow, +} from './where-predicate-publication-model-oracle.js' const property = `where-predicate.publication` -// A string whose content equals an internal normalized-key prefix must still -// compare as an ordinary string. -const PREFIXED = `\u0000tanstack-db:string:a` -// A valid Date equals the number of its timestamp. -const DATE_ONE = new Date(1) - -type Truth = boolean | null - -const fieldValues: ReadonlyArray = [ - `a`, - `b`, - PREFIXED, - true, - false, - 1, - 2, - Number.NaN, - DATE_ONE, - null, - MISSING, -] -const literalValues: ReadonlyArray = [ - `a`, - PREFIXED, - true, - false, - 1, - Number.NaN, - null, -] - -type SeedRow = { v: FieldValue; optimistic: boolean } -type Operation = - | { type: `insert`; v: FieldValue; reuse?: number } - | { type: `update`; target: number; v: FieldValue } - | { type: `delete`; target: number } -type History = - | { kind: `snapshot`; rows: Array; predicate: Predicate } - | { - kind: `changes` - rows: Array - predicate: Predicate - transactions: Array> - } - -// --------------------------------------------------------------------------- -// Model -// --------------------------------------------------------------------------- - -type ModelRow = { - id: string - v: FieldValue - $hasPendingWrites: boolean - $origin: `local` | `remote` - $key: string -} - -function operandValue(operand: Operand, row: ModelRow): unknown { - switch (operand.kind) { - case `field`: - return row.v === MISSING ? undefined : row.v - case `literal`: - return operand.value === MISSING ? undefined : operand.value - case `virtual`: - return row[operand.name] - } -} - -// SQL equality: nullish is UNKNOWN, NaN equals NaN, a Date compares as its -// timestamp, and other values compare by type and value. -function expectedEquality(a: unknown, b: unknown): Truth { - if (a === null || a === undefined || b === null || b === undefined) { - return null - } - const left = a instanceof Date ? a.getTime() : a - const right = b instanceof Date ? b.getTime() : b - if (Number.isNaN(left) && Number.isNaN(right)) return true - return typeof left === typeof right && left === right -} - -// Kleene logic: FALSE dominates AND, TRUE dominates OR, NOT keeps UNKNOWN. -function expectedTruth(predicate: Predicate, row: ModelRow): Truth { - switch (predicate.kind) { - case `eq`: - return expectedEquality( - operandValue(predicate.left, row), - operandValue(predicate.right, row), - ) - case `not`: { - const value = expectedTruth(predicate.arg, row) - return value === null ? null : !value - } - case `and`: { - const values = predicate.args.map((arg) => expectedTruth(arg, row)) - if (values.includes(false)) return false - return values.includes(null) ? null : true - } - case `or`: { - const values = predicate.args.map((arg) => expectedTruth(arg, row)) - if (values.includes(true)) return true - return values.includes(null) ? null : false - } - } -} - -// A subscriber without initial state sees a TRUE row only after a later -// insert or update touches it. -function expectedVisible( - predicate: Predicate, - rows: ReadonlyMap, - touched?: ReadonlySet, -): Array { - return [...rows.values()] - .filter( - (row) => - expectedTruth(predicate, row) === true && - (touched === undefined || touched.has(row.id)), - ) - .map((row) => row.id) - .sort() -} - -// Updates between these values are no-ops outside this grammar. -function equivalentFieldValues(a: FieldValue, b: FieldValue): boolean { - if (a instanceof Date && b instanceof Date) { - return a.getTime() === b.getTime() - } - return Object.is(a, b) -} - -function settledModelRow(id: string, v: FieldValue): ModelRow { - return { id, v, $hasPendingWrites: false, $origin: `remote`, $key: id } -} - -// --------------------------------------------------------------------------- -// History grammar -// --------------------------------------------------------------------------- - -const fieldValueArbitrary = fc.constantFrom(...fieldValues) -const operandArbitrary: fc.Arbitrary = fc.oneof( - { weight: 4, arbitrary: fc.constant({ kind: `field` as const }) }, - { - weight: 4, - arbitrary: fc - .constantFrom(...literalValues) - .map((value): Operand => ({ kind: `literal`, value })), - }, - { - weight: 1, - arbitrary: fc - .constantFrom( - `$hasPendingWrites` as const, - `$origin` as const, - `$key` as const, - ) - .map((name): Operand => ({ kind: `virtual`, name })), - }, -) -const leafArbitrary: fc.Arbitrary = fc - .tuple(operandArbitrary, operandArbitrary) - .map(([left, right]) => ({ kind: `eq` as const, left, right })) - -const predicateArbitrary: fc.Arbitrary = fc.letrec<{ - predicate: Predicate -}>((tie) => ({ - predicate: fc.oneof( - { depthSize: `small`, maxDepth: 3 }, - leafArbitrary, - tie(`predicate`).map((arg) => ({ kind: `not` as const, arg })), - fc - .tuple( - fc.constantFrom(`and` as const, `or` as const), - tie(`predicate`), - tie(`predicate`), - ) - .map(([kind, left, right]) => ({ - kind, - args: [left, right] as [Predicate, Predicate], - })), - ), -})).predicate - -// A top-level `eq(v, string | boolean)` conjunct lets a subscription skip -// batches that cannot match and lets an unindexed snapshot reject stored rows -// before copying them. Histories favor that shape so generated histories -// exercise both shortcuts, not only the full filter. -const prefilterablePredicateArbitrary: fc.Arbitrary = fc - .tuple( - fc.constantFrom(`a`, PREFIXED, true), - fc.option(predicateArbitrary, { nil: undefined }), - ) - .map(([value, rest]): Predicate => { - const conjunct: Predicate = { - kind: `eq`, - left: { kind: `field` }, - right: { kind: `literal`, value }, - } - return rest === undefined - ? conjunct - : { kind: `and`, args: [conjunct, rest] } - }) - -// Histories favor one matching and one non-matching string so rows cross the -// predicate boundary often; the full domain stays reachable. -const changeValueArbitrary = fc.oneof( - { weight: 2, arbitrary: fc.constantFrom(`a`, `b`) }, - fieldValueArbitrary, -) - -const operationArbitrary: fc.Arbitrary = fc.oneof( - fc - .tuple( - changeValueArbitrary, - fc.option(fc.nat({ max: 7 }), { nil: undefined }), - ) - .map(([v, reuse]): Operation => - reuse === undefined - ? { type: `insert`, v } - : { type: `insert`, v, reuse }, - ), - { - weight: 2, - arbitrary: fc - .tuple(fc.nat({ max: 7 }), changeValueArbitrary) - .map(([target, v]): Operation => ({ type: `update`, target, v })), - }, - fc.nat({ max: 7 }).map((target): Operation => ({ type: `delete`, target })), -) - -const historyArbitrary: fc.Arbitrary = fc.oneof( - fc.record({ - kind: fc.constant(`snapshot` as const), - rows: fc.array( - fc.record({ v: changeValueArbitrary, optimistic: fc.boolean() }), - { minLength: 1, maxLength: 6 }, - ), - predicate: fc.oneof(predicateArbitrary, prefilterablePredicateArbitrary), - }), - { - weight: 2, - arbitrary: fc.record({ - kind: fc.constant(`changes` as const), - rows: fc.array(changeValueArbitrary, { maxLength: 5 }), - predicate: fc.oneof(predicateArbitrary, { - weight: 2, - arbitrary: prefilterablePredicateArbitrary, - }), - transactions: fc.array( - fc.array(operationArbitrary, { minLength: 1, maxLength: 3 }), - { minLength: 1, maxLength: 6 }, - ), - }), - }, -) - -// 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 fixedHistories: ReadonlyArray = [ - { - kind: `snapshot`, - rows: [ - { v: `a`, optimistic: false }, - { v: `b`, optimistic: false }, - { v: null, optimistic: false }, - { v: MISSING, optimistic: false }, - ], - predicate: { kind: `not`, arg: fieldEq(`a`) }, - }, - { - kind: `snapshot`, - rows: [ - { v: `a`, optimistic: true }, - { v: null, optimistic: false }, - { v: Number.NaN, optimistic: false }, - { v: PREFIXED, optimistic: true }, - ], - predicate: { - kind: `or`, - args: [ - { - kind: `not`, - arg: { - kind: `eq`, - left: { kind: `literal`, value: true }, - right: { kind: `field` }, - }, - }, - { - kind: `eq`, - left: { kind: `virtual`, name: `$hasPendingWrites` }, - right: { kind: `literal`, value: true }, - }, - ], - }, - }, - { - kind: `snapshot`, - rows: [ - { v: PREFIXED, optimistic: false }, - { v: `a`, optimistic: false }, - { v: Number.NaN, optimistic: false }, - { v: 1, optimistic: false }, - { v: MISSING, optimistic: false }, - ], - predicate: { - kind: `and`, - args: [ - { kind: `not`, arg: fieldEq(PREFIXED) }, - { kind: `not`, arg: fieldEq(Number.NaN) }, - ], - }, - }, -] - -// 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 fixedPrefilteredSnapshot: History = { - kind: `snapshot`, - rows: [ - { v: `a`, optimistic: true }, - { v: `a`, optimistic: false }, - { v: `b`, optimistic: true }, - ], - predicate: fieldEq(`a`), -} - -// Each change history moves rows across a predicate whose equality operand a -// skip-if-no-match shortcut can misread. -const fixedChangeHistories: ReadonlyArray = [ - { - // `or` operands are alternatives, not conjuncts. - kind: `changes`, - rows: [`b`, `a`], - predicate: { kind: `or`, args: [fieldEq(`a`), fieldEq(true)] }, - transactions: [ - [{ type: `update`, target: 0, v: true }], - [ - { type: `update`, target: 1, v: false }, - { type: `insert`, v: true }, - ], - ], - }, - { - // A valid Date equals its timestamp, so a number literal cannot prefilter - // by identity. - kind: `changes`, - rows: [2, 2], - predicate: fieldEq(1), - transactions: [ - [{ type: `update`, target: 0, v: DATE_ONE }], - [ - { type: `update`, target: 0, v: 2 }, - { type: `update`, target: 1, v: 1 }, - ], - ], - }, - { - // NaN equals NaN, although NaN is not identical to itself. - kind: `changes`, - rows: [2], - predicate: fieldEq(Number.NaN), - transactions: [[{ type: `update`, target: 0, v: Number.NaN }]], - }, - { - // A subscriber without initial state never saw the dropped `b` row. - // Deleting it and reinserting its key with a matching value must publish - // the new row, not treat it as a duplicate insert. - kind: `changes`, - rows: [], - predicate: fieldEq(`a`), - transactions: [ - [ - { type: `insert`, v: `a` }, - { type: `insert`, v: `b` }, - ], - [{ type: `delete`, target: 1 }], - [{ type: `insert`, v: `a`, reuse: 0 }], - ], - }, - { - // A row that moves out must be retracted, including inside one batch. - kind: `changes`, - rows: [`a`, `a`, `b`], - predicate: { - kind: `and`, - args: [fieldEq(`a`), { kind: `not`, arg: fieldEq(PREFIXED) }], - }, - transactions: [ - [ - { type: `update`, target: 0, v: `b` }, - { type: `update`, target: 2, v: `a` }, - ], - [ - { type: `delete`, target: 1 }, - { type: `insert`, v: `a` }, - { type: `update`, target: 0, v: `a` }, - ], - ], - }, -] - // --------------------------------------------------------------------------- // Production driver // --------------------------------------------------------------------------- @@ -799,13 +407,11 @@ 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 fixed calibration and the generated -// properties reach them; the witness tests in where-predicate-publication.test.ts -// do not. -declareOracle({ - check: [runHistory], - model: [expectedTruth, expectedVisible], -}) +// subscriber without initial state. The named and generated properties reach +// them; the witness tests in where-predicate-publication.test.ts do not. The +// model calibration, which runs no production, is in +// where-predicate-publication-model.test.ts. The model, grammar, and named +// histories live in where-predicate-publication-model-oracle.ts. function describeValue(value: FieldValue): string { if (value === MISSING) return `` @@ -836,64 +442,16 @@ function describePredicate(predicate: Predicate): string { // Refinement check and calibration // --------------------------------------------------------------------------- -describe(`WHERE predicate publication oracle`, () => { - // 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(`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 fixed snapshot, or the history does not calibrate. - const twoValued = (predicate: Predicate, row: ModelRow): boolean => { - switch (predicate.kind) { - case `eq`: - return ( - expectedEquality( - operandValue(predicate.left, row), - operandValue(predicate.right, row), - ) === true - ) - case `not`: - return !twoValued(predicate.arg, row) - case `and`: - return predicate.args.every((arg) => twoValued(arg, row)) - case `or`: - return predicate.args.some((arg) => twoValued(arg, row)) - } - } - 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] => [ - `r${index}`, - { - ...settledModelRow(`r${index}`, seed.v), - $hasPendingWrites: seed.optimistic, - $origin: seed.optimistic ? `local` : `remote`, - }, - ]), - ) - const naive = [...rows.values()] - .filter((row) => twoValued(history.predicate, row)) - .map((row) => row.id) - .sort() - expect(naive).not.toEqual(expectedVisible(history.predicate, rows)) - } - }) - - for (const history of [ - ...fixedHistories, - fixedPrefilteredSnapshot, - ...fixedChangeHistories, - ]) { - it(`publishes TRUE rows for ${history.kind} history ${describePredicate(history.predicate)}`, () => { - oracleRoute(`case`) - return runHistory(history) - }) - } - } +const namedFixedHistories = new Map( + [...fixedHistories, fixedPrefilteredSnapshot, ...fixedChangeHistories].map( + (history): [string, History] => [ + `publishes TRUE rows for ${history.kind} history ${describePredicate(history.predicate)}`, + history, + ], + ), +) +describe(`WHERE predicate publication oracle`, () => { // The complete existing helper keeps its independent Kleene model and all // consumer comparisons. The adapter records data and retains assertion // diagnostics separately; setup and cleanup errors remain driver errors. @@ -924,4 +482,30 @@ describe(`WHERE predicate publication oracle`, () => { ), check: forwardAssertionJudgment, }) + + // The named histories hold `MISSING` and Date values, which the generated + // grammar cannot take back and which are not enumerated history data. Each + // is named by its old test name and replays through the same `runHistory`. + oracleProperty({ + id: `where-predicate.publication-fixed`, + law: { + statement: `Every filtered consumer publishes exactly the TRUE rows at each original comparison checkpoint.`, + authority: `The SQL three-valued filtering and subscription contract stated at the start of this owner.`, + limits: `Named regressions that the generated predicate grammar cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [...namedFixedHistories.keys()], + label: (name) => name, + resolve: (name) => ({ history: namedFixedHistories.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan: { history: History }, observe) => + recordAssertionJudgment(property, plan, observe, () => + runHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) }) From 55b6ecf7aa38c11772fb9fd53d4e1b762cd4437f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:45:39 -0700 Subject: [PATCH 021/116] test(sqlite): register the Electron readiness and follower histories through oracleProperty P2: each it.each becomes an enumerated property, electron.ipc-readiness and electron.follower-settlement, with the same helpers, labels, and timeouts. Co-authored-by: Isaac --- .../db/tests/converted-properties/sqlite.ts | 11 +- .../electron-ipc-readiness-oracle.test.ts | 104 +++++++++++------- 2 files changed, 76 insertions(+), 39 deletions(-) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 6193b256bb..08929fdf11 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -5,7 +5,11 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [`opfs.open-outcome`] +export const properties: ReadonlyArray = [ + `opfs.open-outcome`, + `electron.ipc-readiness`, + `electron.follower-settlement`, +] export const owners: ReadonlyArray = [ [ @@ -13,4 +17,9 @@ export const owners: ReadonlyArray = [ `opfs`, `open-outcome`, ], + [ + `electron-db-sqlite-persistence/tests/electron-ipc-readiness-oracle.test.ts`, + `electron`, + `ipc-readiness follower-settlement`, + ], ] 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 7e5dcb1ccc..0a48f028cd 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 @@ -1,11 +1,15 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { describe, expect, 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 { oracleProperty } from '../../db/tests/oracle-property.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter.js' import { IR, createCollection, @@ -398,24 +402,34 @@ async function compareIpcReadinessHistory( ) } -declareOracle({ - owns: [ - `src/renderer.ts#createElectronSQLitePersistence`, - `src/electron-coordinator.ts`, - ], - check: [compareIpcReadinessHistory], - model: [expectedCheckpoints], -}) - +// Each enumerated history runs through compareIpcReadinessHistory, which +// compares the observed checkpoints with expectedCheckpoints. The adapter +// transports that judgment. +const ipcReadinessLaw = `Electron renderer initial readiness` 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) + oracleProperty({ + id: `electron.ipc-readiness`, + law: { + statement: `A renderer's live query over an on-demand persisted Collection shows, while the IPC loadSubset reply is held, after it, and after the upstream load settles, what the readiness model predicts from the persisted rows and each step's outcome.`, + authority: `ARCHITECTURE.md law 10 (a settled loadSubset is the readiness evidence) and law 7 (a successful load settles only after its rows are visible).`, + limits: `The eight renderer, hydration, and next-step histories, for a renderer without a coordinator; only the loadSubset reply of the IPC transport is controlled.`, }, - historyTimeout, - ) + owns: [ + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], + histories: ipcReadinessHistories, + label: ({ renderer, hydration, next }) => + `matches the model: ${renderer} renderer, hydration ${hydration}, then ${next}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ipcReadinessLaw, plan, observe, () => + compareIpcReadinessHistory(plan.history), + ), + check: forwardAssertionJudgment, + timeout: historyTimeout, + }) }) /** @@ -634,25 +648,39 @@ async function compareCoordinatedRendererHistory( ) } -declareOracle({ - check: [compareCoordinatedRendererHistory], - model: [expectedCoordinatedRenderer], -}) - +// Each enumerated history runs through compareCoordinatedRendererHistory, +// which compares the follower's checkpoints with expectedCoordinatedRenderer. +const coordinatedRendererLaw = `Electron follower renderer settlement` +const coordinatedRendererHistories: Array = ( + [`ok`, `fails`] as const +).flatMap((apply) => + ([`live-query`, `load`] as const).map((consumer) => ({ + apply, + consumer, + })), +) describe(`Electron follower renderer settles with its owner's rows`, () => { - it.each( - ([`ok`, `fails`] as const).flatMap((apply) => - ([`live-query`, `load`] as const).map((consumer) => ({ - apply, - consumer, - })), - ), - )( - `matches the model: apply $apply, consumer $consumer`, - async (history) => { - oracleRoute(`case`) - await compareCoordinatedRendererHistory(history) + oracleProperty({ + id: `electron.follower-settlement`, + law: { + statement: `A follower renderer's load, and its query's first ready, settle only once the owner's rows are visible in the follower, and they reject, crashing the follower source, when applying the owner's commit fails there.`, + authority: `ARCHITECTURE.md law 7, for a follower renderer that routes its demand to the owner.`, + limits: `The four apply-outcome and consumer histories, with two renderers over one main process and SQLite file; queued placement and the Query Collection source across real tabs belong to the browser per-collection oracle.`, }, - isElectronFullE2EEnabled() ? 180_000 : 20_000, - ) + owns: [ + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], + histories: coordinatedRendererHistories, + label: ({ apply, consumer }) => + `matches the model: apply ${apply}, consumer ${consumer}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatedRendererLaw, plan, observe, () => + compareCoordinatedRendererHistory(plan.history), + ), + check: forwardAssertionJudgment, + timeout: isElectronFullE2EEnabled() ? 180_000 : 20_000, + }) }) From c3fc3d20d1192584b7a1b0001a3b54577e9f6645 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:45:46 -0700 Subject: [PATCH 022/116] test(db): register proxy detachment cases through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 22 +- .../proxy-detachment-calibration.test.ts | 54 +++ .../proxy-detachment-contract-oracle.test.ts | 392 ++++++++++-------- 3 files changed, 283 insertions(+), 185 deletions(-) create mode 100644 packages/db/tests/proxy-detachment-calibration.test.ts diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 31dd2d3a39..d172537969 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -5,6 +5,24 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [ + // indexes and comparison + // transactions and mutations + // IndexedDB and live-query observers + // proxies and conformance + `proxy.own-property-histories`, + `proxy.cycle-backedges`, + `proxy.binary-detachment`, +] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + // indexes and comparison + // transactions and mutations + // IndexedDB and live-query observers + // proxies and conformance + [ + `db/tests/proxy-detachment-contract-oracle.test.ts`, + `proxy`, + `own-property-histories cycle-backedges binary-detachment`, + ], +] diff --git a/packages/db/tests/proxy-detachment-calibration.test.ts b/packages/db/tests/proxy-detachment-calibration.test.ts new file mode 100644 index 0000000000..609e27854b --- /dev/null +++ b/packages/db/tests/proxy-detachment-calibration.test.ts @@ -0,0 +1,54 @@ +// Checker calibrations for the detachment checks in proxy-detachment-fixture.ts. +// These tests run no production: each hands a native or deliberately wrong +// value to a shared check and requires the check to accept or reject it. The +// production histories that use the same checks are in +// proxy-detachment-contract-oracle.test.ts. +import { describe, expect, it } from 'vitest' +import { + cycleContainer, + cycleKinds, + cycleMember, + expectCycleSnapshot, + expectOwnDataProperty, +} from './proxy-detachment-fixture.js' +import type { CycleRow } from './proxy-detachment-fixture.js' + +describe(`Mutation result detachment`, () => { + it.each([`value`, `__proto__`, `constructor`, `toString`])( + `rejects missing or prototype-changing %s data properties`, + (key) => { + expectOwnDataProperty(Object.fromEntries([[key, 1]]), key, 1) + expect(() => expectOwnDataProperty({}, key, 1)).toThrow() + const wrong = Object.fromEntries([[key, 1]]) + Object.setPrototypeOf(wrong, { foreign: true }) + expect(() => expectOwnDataProperty(wrong, key, 1)).toThrow() + }, + ) + + it.each(cycleKinds)(`accepts a native %s cyclic snapshot`, (kind) => { + const row: CycleRow = { id: 1, count: 1 } + row.link = cycleContainer(kind, row) + expectCycleSnapshot(row, kind) + }) + + it.each( + cycleKinds.flatMap((kind) => + [`old-value`, `missing-member`, `broken-backedge`].map((fault) => ({ + kind, + fault, + })), + ), + )(`rejects $fault in a $kind cyclic snapshot`, ({ kind, fault }) => { + const row: CycleRow = { id: 1, count: 1 } + row.link = cycleContainer(kind, row) + if (fault === `old-value`) + cycleMember(row.link, kind).owner = { ...row, count: 0 } + else if (fault === `broken-backedge`) + cycleMember(row.link, kind).owner = { id: 1, count: 1 } + else if (row.link instanceof Map || row.link instanceof Set) + row.link.clear() + else if (Array.isArray(row.link)) row.link.length = 0 + else Reflect.deleteProperty(row.link, `member`) + expect(() => expectCycleSnapshot(row, kind)).toThrow() + }) +}) diff --git a/packages/db/tests/proxy-detachment-contract-oracle.test.ts b/packages/db/tests/proxy-detachment-contract-oracle.test.ts index 8871284370..3ff6560cff 100644 --- a/packages/db/tests/proxy-detachment-contract-oracle.test.ts +++ b/packages/db/tests/proxy-detachment-contract-oracle.test.ts @@ -16,14 +16,13 @@ * Native Set and Map iteration order is part of the detached snapshot: a * reorder with identical members or entries is still a draft change. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import fc from 'fast-check' import { withChangeTracking } from '../src/proxy.js' -import { oracleProperty, oracleRoute } from './oracle-config.js' +import { oracleProperty } from './oracle-config.js' import { cycleContainer, cycleKinds, - cycleMember, expectCycleSnapshot, expectDetachedBinaryClone, expectDetachedPayloadJson, @@ -37,7 +36,7 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter.js' -import type { CycleRow } from './proxy-detachment-fixture.js' +import type { CycleKind, CycleRow } from './proxy-detachment-fixture.js' function runOrdinaryFieldCopies(count: number): void { const row = Object.fromEntries( @@ -99,6 +98,134 @@ function runJsonDataKeyHistory([key, value, nested]: JsonDataKeyHistory): void { ) } +type OwnPropertyHistory = { key: string; identitySchema: boolean } +const ownPropertyHistories: Array = [ + `value`, + `__proto__`, + `constructor`, + `toString`, +].flatMap((key) => + [false, true].map((identitySchema) => ({ key, identitySchema })), +) + +async function runOwnPropertyHistory({ + key, + identitySchema, +}: OwnPropertyHistory): Promise { + // 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]) { + const make = () => + ({ + id: 1, + sibling: { value: 0 }, + ...Object.fromEntries([[key, { value: 1 }]]), + }) as Record & { id: number } + const run = (row: ReturnType) => { + row[key] = { value: 2 } + if (sibling) row.sibling = { value: 1 } + } + const changes = withChangeTracking(make(), run) + expectOwnDataProperty(changes, key, { value: 2 }) + const collection = storedDataRow(make(), identitySchema) + try { + const tx = collection.update(1, run) + await tx.when(`settled`) + expectOwnDataProperty(collection.get(1)!, key, { value: 2 }) + } finally { + await collection.cleanup() + } + } + const actions = [`null`, `undefined`, `delete`, `readd`] as const + for (const present of [false, true]) { + for (const action of actions) { + for (const sibling of [false, true]) { + const make = () => + ({ + id: 1, + read: ``, + sibling: { value: 0 }, + ...Object.fromEntries(present ? [[key, `before`]] : []), + }) as Record & { id: number } + const run = (row: ReturnType) => { + if (action === `delete` || action === `readd`) + Reflect.deleteProperty(row, key) + if (action !== `delete`) + Object.defineProperty(row, key, { + value: + action === `null` + ? null + : action === `readd` + ? `after` + : undefined, + configurable: true, + enumerable: true, + writable: true, + }) + row.read = Object.hasOwn(row, key) ? String(row[key]) : `absent` + if (sibling) row.sibling = { value: 1 } + } + const expected = make() + run(expected) + const original = make() + const changes = withChangeTracking(original, run) + const expectedOwnKey = present || action !== `delete` + expect(Object.hasOwn(changes, key)).toBe(expectedOwnKey) + expect(Object.getPrototypeOf(changes)).toBe(Object.prototype) + // Native deletion exposes inherited properties. The update API + // instead represents that operation as an own undefined field. + const patchValue = action === `delete` ? undefined : expected[key] + if (expectedOwnKey) expectOwnDataProperty(changes, key, patchValue) + expect(changes.read).toBe(expected.read) + expect(original).toStrictEqual(make()) + const collection = storedDataRow(make(), identitySchema) + try { + const tx = collection.update(1, run) + await tx.when(`settled`) + const saved = collection.get(1)! + expect(saved.read).toBe(expected.read) + // Collection patches represent deletion with an own undefined. + if (expectedOwnKey) expectOwnDataProperty(saved, key, patchValue) + expect(Object.getPrototypeOf(saved)).toBe(Object.prototype) + } finally { + await collection.cleanup() + } + } + } + } +} + +type CycleBackedgeHistory = { kind: CycleKind; edit: `before` | `after` } +const cycleBackedgeHistories: Array = cycleKinds.flatMap( + (kind) => ([`before`, `after`] as const).map((edit) => ({ kind, edit })), +) + +async function runCycleBackedge({ + kind, + edit, +}: CycleBackedgeHistory): Promise { + const original: CycleRow = { id: 1, count: 0 } + const collection = storedRow(original) + let settled: Promise | undefined + try { + const tx = collection.update(1, (draft) => { + if (edit === `before`) draft.count = 1 + draft.link = cycleContainer(kind, draft) + if (edit === `after`) draft.count = 1 + }) + settled = Promise.allSettled([tx.when(`settled`)]) + expectCycleSnapshot(collection.get(1)!, kind) + expect(original).toEqual({ id: 1, count: 0 }) + await tx.when(`settled`) + await settled + expectCycleSnapshot(collection.get(1)!, kind) + expect(original).toEqual({ id: 1, count: 0 }) + } finally { + await settled + await collection.cleanup() + } +} + 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. @@ -126,109 +253,28 @@ describe(`Mutation result detachment`, () => { check: forwardAssertionJudgment, }) - 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]]) - Object.setPrototypeOf(wrong, { foreign: true }) - expect(() => expectOwnDataProperty(wrong, key, 1)).toThrow() - }, - ) - - it.each( - [`value`, `__proto__`, `constructor`, `toString`].flatMap((key) => - [false, true].map((identitySchema) => ({ key, identitySchema })), - ), - )( - `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]) { - const make = () => - ({ - id: 1, - sibling: { value: 0 }, - ...Object.fromEntries([[key, { value: 1 }]]), - }) as Record & { id: number } - const run = (row: ReturnType) => { - row[key] = { value: 2 } - if (sibling) row.sibling = { value: 1 } - } - const changes = withChangeTracking(make(), run) - expectOwnDataProperty(changes, key, { value: 2 }) - const collection = storedDataRow(make(), identitySchema) - try { - const tx = collection.update(1, run) - await tx.when(`settled`) - expectOwnDataProperty(collection.get(1)!, key, { value: 2 }) - } finally { - await collection.cleanup() - } - } - const actions = [`null`, `undefined`, `delete`, `readd`] as const - for (const present of [false, true]) { - for (const action of actions) { - for (const sibling of [false, true]) { - const make = () => - ({ - id: 1, - read: ``, - sibling: { value: 0 }, - ...Object.fromEntries(present ? [[key, `before`]] : []), - }) as Record & { id: number } - const run = (row: ReturnType) => { - if (action === `delete` || action === `readd`) - Reflect.deleteProperty(row, key) - if (action !== `delete`) - Object.defineProperty(row, key, { - value: - action === `null` - ? null - : action === `readd` - ? `after` - : undefined, - configurable: true, - enumerable: true, - writable: true, - }) - row.read = Object.hasOwn(row, key) ? String(row[key]) : `absent` - if (sibling) row.sibling = { value: 1 } - } - const expected = make() - run(expected) - const original = make() - const changes = withChangeTracking(original, run) - const expectedOwnKey = present || action !== `delete` - expect(Object.hasOwn(changes, key)).toBe(expectedOwnKey) - expect(Object.getPrototypeOf(changes)).toBe(Object.prototype) - // Native deletion exposes inherited properties. The update API - // instead represents that operation as an own undefined field. - const patchValue = action === `delete` ? undefined : expected[key] - if (expectedOwnKey) expectOwnDataProperty(changes, key, patchValue) - expect(changes.read).toBe(expected.read) - expect(original).toStrictEqual(make()) - const collection = storedDataRow(make(), identitySchema) - try { - const tx = collection.update(1, run) - await tx.when(`settled`) - const saved = collection.get(1)! - expect(saved.read).toBe(expected.read) - // Collection patches represent deletion with an own undefined. - if (expectedOwnKey) expectOwnDataProperty(saved, key, patchValue) - expect(Object.getPrototypeOf(saved)).toBe(Object.prototype) - } finally { - await collection.cleanup() - } - } - } - } + oracleProperty({ + id: `proxy.own-property-histories`, + law: { + statement: `Assigning, nulling, deleting or re-adding a dangerous or ordinary data key keeps it an own data property in the change record and the stored row, with the native result and an unchanged original.`, + authority: `src/proxy.ts draft-to-stored-row value boundary contract stated above`, + limits: `Four keys by identity or non-identity schema; each history runs the fixed native assignment and presence/action/sibling matrix through withChangeTracking and Collection.update.`, }, - ) + owns: [`src/proxy.ts`], + histories: ownPropertyHistories, + label: ({ key, identitySchema }) => + `preserves native own-property histories for ${key} with schema=${identitySchema}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.own-property-histories`, + plan, + observe, + () => runOwnPropertyHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) // Contract: a native container's insertion order survives the draft patch // and the stored-row publication, even when its members are unchanged. @@ -269,64 +315,24 @@ describe(`Mutation result detachment`, () => { check: forwardAssertionJudgment, }) - it.each( - cycleKinds.flatMap((kind) => - [`before`, `after`].map((edit) => ({ kind, edit })), - ), - )( - `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 - try { - const tx = collection.update(1, (draft) => { - if (edit === `before`) draft.count = 1 - draft.link = cycleContainer(kind, draft) - if (edit === `after`) draft.count = 1 - }) - settled = Promise.allSettled([tx.when(`settled`)]) - expectCycleSnapshot(collection.get(1)!, kind) - expect(original).toEqual({ id: 1, count: 0 }) - await tx.when(`settled`) - await settled - expectCycleSnapshot(collection.get(1)!, kind) - expect(original).toEqual({ id: 1, count: 0 }) - } finally { - await settled - await collection.cleanup() - } + oracleProperty({ + id: `proxy.cycle-backedges`, + law: { + statement: `A draft backedge assigned inside Collection.update is stored as a detached cyclic snapshot before and after settlement, and the caller's original row is unchanged.`, + authority: `src/proxy.ts draft-to-stored-row value boundary contract stated above`, + limits: `Map, Set, array and object backedges with the scalar edit before or after the assignment; the shared cycle-shape check judges the stored row.`, }, - ) - - 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) - }) - - it.each( - cycleKinds.flatMap((kind) => - [`old-value`, `missing-member`, `broken-backedge`].map((fault) => ({ - kind, - fault, - })), - ), - )(`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`) - cycleMember(row.link, kind).owner = { ...row, count: 0 } - else if (fault === `broken-backedge`) - cycleMember(row.link, kind).owner = { id: 1, count: 1 } - else if (row.link instanceof Map || row.link instanceof Set) - row.link.clear() - else if (Array.isArray(row.link)) row.link.length = 0 - else Reflect.deleteProperty(row.link, `member`) - expect(() => expectCycleSnapshot(row, kind)).toThrow() + owns: [`src/proxy.ts`], + histories: cycleBackedgeHistories, + label: ({ kind, edit }) => + `detaches a ${kind} draft backedge with the scalar edit ${edit} assignment`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`proxy.cycle-backedges`, plan, observe, () => + runCycleBackedge(plan.history), + ), + check: forwardAssertionJudgment, }) // Current behavior, not a promise: a draft copies a row's own enumerable @@ -334,34 +340,54 @@ describe(`Mutation result detachment`, () => { // string keys are not copied. The change record keeps only the written key. }) +const binaryKinds = [`buffer`, `view`, `u8`, `i64`] as const +type BinaryKind = (typeof binaryKinds)[number] + // Native binary snapshots preserve the entire backing buffer and view range, // while detaching caller ownership. Native structuredClone is a second, // independent formulation for this bounded standard-native value domain. // Custom typed-array subclasses retain their separately tested construction // 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` - ? bytes.buffer - : kind === `view` - ? new DataView(bytes.buffer, 3, 7) - : kind === `u8` - ? new Uint8Array(bytes.buffer, 3, 7) - : new BigInt64Array(bytes.buffer, 8, 1) - const expected = structuredClone(value) - const changes = withChangeTracking({ value: null as unknown }, (draft) => { - draft.value = value - }) - bytes.fill(99) - const actual = changes.value as typeof value - expect(actual).not.toBe(value) - expectDetachedBinaryClone(actual, expected) +function runBinaryDetachment(kind: BinaryKind): void { + const bytes = Uint8Array.from({ length: 24 }, (_, index) => index) + const value = + kind === `buffer` + ? bytes.buffer + : kind === `view` + ? new DataView(bytes.buffer, 3, 7) + : kind === `u8` + ? new Uint8Array(bytes.buffer, 3, 7) + : new BigInt64Array(bytes.buffer, 8, 1) + const expected = structuredClone(value) + const changes = withChangeTracking({ value: null as unknown }, (draft) => { + draft.value = value }) + bytes.fill(99) + const actual = changes.value as typeof value + expect(actual).not.toBe(value) + expectDetachedBinaryClone(actual, expected) } +oracleProperty({ + id: `proxy.binary-detachment`, + law: { + statement: `An assigned ArrayBuffer, DataView or typed array is detached from caller ownership with its entire backing buffer and view range preserved.`, + authority: `src/proxy.ts draft-to-stored-row value boundary contract stated above; native structuredClone is the independent formulation`, + limits: `ArrayBuffer, DataView, Uint8Array and BigInt64Array over one 24-byte local buffer; subclasses and shared, resizable or detached buffers are outside this matrix.`, + }, + owns: [`src/proxy.ts`], + histories: binaryKinds, + label: (kind) => + `detaches assigned ${kind} without losing buffer bytes or view range`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`proxy.binary-detachment`, plan, observe, () => + runBinaryDetachment(plan.kind), + ), + check: forwardAssertionJudgment, +}) + // Construction compatibility has two independent axes: built-in versus // length-sensitive subclass, and local versus foreign/shared backing storage. // Each was accepted before native range preservation. A scalar edit must still From 7427425e4e28654513cd3cad9bdb7d5f58b551b3 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:46:39 -0700 Subject: [PATCH 023/116] test(db-ivm): register the hash identity witnesses through oracleProperty Converts the five plain-test sites in hash-identity-oracle.property.test.ts. The fixed witnesses, the second-copy seeds, and the shared-subtree seeds become ivm.hash-identity-fixed (P1b), one history per former seed pair, with the same drivePair, expectedPair, and checkPair. The digest collision case becomes ivm.hash-identity-digest-collision with a digest-only calibration (P3). The fixed-seed census runs no production, so it moves unchanged to hash-identity-grammar.test.ts (P6), and the grammar and model that it reads move unchanged to hash-identity-grammar-oracle.ts. expectPair has no use left and is deleted. Co-authored-by: Isaac --- .../tests/hash-identity-grammar-oracle.ts | 477 +++++++++++ .../tests/hash-identity-grammar.test.ts | 93 +++ .../hash-identity-oracle.property.test.ts | 773 ++++-------------- .../converted-properties/frameworks-ivm.ts | 25 + 4 files changed, 734 insertions(+), 634 deletions(-) create mode 100644 packages/db-ivm/tests/hash-identity-grammar-oracle.ts create mode 100644 packages/db-ivm/tests/hash-identity-grammar.test.ts diff --git a/packages/db-ivm/tests/hash-identity-grammar-oracle.ts b/packages/db-ivm/tests/hash-identity-grammar-oracle.ts new file mode 100644 index 0000000000..8e101a9399 --- /dev/null +++ b/packages/db-ivm/tests/hash-identity-grammar-oracle.ts @@ -0,0 +1,477 @@ +// The value-spec grammar and identity model of the D2 value-identity oracle. +// hash-identity-oracle.property.test.ts owns the law, the production driver, +// and the comparisons; hash-identity-grammar.test.ts checks this grammar's +// fixed-seed census. This module registers no tests and has no side effects. +import { fc } from '@fast-check/vitest' +import type { Temporal } from 'temporal-polyfill' + +// --------------------------------------------------------------------------- +// Value specs. A spec is plain data, so the model never reads production +// objects. Kinds name contract rules, not production's marker/header/body +// split. `ref` is production's reference leaf. Two kinds exist only in this +// model: `twice` is one child that the driver shares or copies, and `back` is +// a back edge to an ancestor container. + +export type Spec = + | { k: `num`; v: number } + | { k: `str`; v: string } + | { k: `bool`; v: boolean } + | { k: `null` } + | { k: `undef` } + | { k: `big`; v: bigint } + | { k: `sym`; id: number } + | { k: `ref`; kind: RefKind; id: number } + | { k: `date`; t: number } + | { k: `bin`; bytes: Array } + | { k: `temporal`; i: number } + | { k: `regex`; source: string; flags: string; lastIndex: number; p?: Spec } + | { k: `array`; items: Array; p?: Spec } + | { k: `twice`; child: Spec } + | { k: `map`; entries: Array<[Spec, Spec]> } + | { k: `set`; items: Array } + | { k: `obj`; entries: Array<[string, Spec]>; syms: Array<[number, Spec]> } + | { k: `back`; up: number } + +export type RefKind = `fn` | `handle` | `file` | `bigbin` + +export const TEMPORALS: ReadonlyArray<[keyof typeof Temporal, string]> = [ + [`PlainDate`, `2024-01-15`], + [`PlainDate`, `2024-01-16`], + [`Duration`, `P1D`], + [`Instant`, `2024-01-15T00:00:00Z`], +] +export const SYMBOLS = [Symbol(`s0`), Symbol(`s1`)] +// The last two are distinct keys that locale collation ranks as equal (a +// precomposed and a decomposed Å), so key order must not come from collation. +const OBJECT_KEYS = [`a`, `b`, `c`, `0`, `1`, `\u00C5`, `A\u030A`] +// No value in any pool equals this text, so a leaf that holds it makes a +// cyclic value unequal to its original. +export const SENTINEL = `\u0000sentinel` + +// --------------------------------------------------------------------------- +// Model: a canonical encoding of a spec's identity. + +export function identity(spec: Spec): string { + return JSON.stringify(canon(spec)) +} + +// Maps and Sets merge primitive keys and shared references that are equal +// under SameValueZero: the first position stays and the last value wins. +// Every other generated key is a fresh object and never merges. +const MERGING_KINDS = new Set([ + `num`, + `str`, + `bool`, + `null`, + `undef`, + `big`, + `sym`, + `ref`, +]) +const CONTAINER_KINDS = new Set([ + `array`, + `twice`, + `map`, + `set`, + `obj`, + `regex`, +]) +function mapEntries( + entries: Array<[Spec, Spec | undefined]>, +): Array<[unknown, unknown]> { + const out: Array<[unknown, unknown]> = [] + const positions = new Map() + for (const [key, value] of entries) { + const encoded = canon(key) + const valueCode = value === undefined ? null : canon(value) + if (MERGING_KINDS.has(key.k)) { + const id = JSON.stringify(encoded) + const at = positions.get(id) + if (at !== undefined) { + out[at]![1] = valueCode + continue + } + positions.set(id, out.length) + } + out.push([encoded, valueCode]) + } + return out +} + +export function canon(spec: Spec): unknown { + const inner = canon + const props = (p: Spec | undefined) => (p ? [[`p`, inner(p)]] : []) + switch (spec.k) { + case `num`: + // `String` writes -0 as `0` and keeps NaN and infinities distinct. + return [`num`, String(spec.v)] + case `str`: + case `bool`: + return [spec.k, spec.v] + case `null`: + case `undef`: + return [spec.k] + case `big`: + return [`big`, String(spec.v)] + case `sym`: + return [`sym`, spec.id] + case `ref`: + return [`ref`, spec.kind, spec.id] + case `date`: + return [`date`, String(spec.t)] + case `bin`: + return [`bin`, spec.bytes] + case `temporal`: + return [`temporal`, TEMPORALS[spec.i]] + case `regex`: + return [`regex`, spec.source, spec.flags, spec.lastIndex, props(spec.p)] + case `array`: + return [ + `array`, + spec.items.length, + spec.items.flatMap((item, index) => + item === null ? [] : [[index, inner(item)]], + ), + props(spec.p), + ] + case `twice`: + // A shared child and a copied child have the same identity. + return [`array`, 2, [0, 1].map((index) => [index, inner(spec.child)]), []] + case `map`: + return [ + `map`, + mapEntries(spec.entries.map(([key, value]) => [key, value])), + ] + case `set`: + return [ + `set`, + mapEntries(spec.items.map((item) => [item, undefined])).map( + ([item]) => item, + ), + ] + case `obj`: + return [ + `obj`, + [...spec.entries] + .sort(([left], [right]) => (left < right ? -1 : 1)) + .map(([key, value]) => [key, inner(value)]), + [...spec.syms] + .sort(([left], [right]) => left - right) + .map(([id, value]) => [id, inner(value)]), + ] + case `back`: + // Only cyclic histories hold back edges, and their verdicts come from + // construction, not from this encoding. + return [`back`, spec.up] + } +} + +// --------------------------------------------------------------------------- +// Grammar. Near-miss mutations change one node, so equal and unequal pairs +// both occur often. + +export const primitiveArb: fc.Arbitrary = fc.oneof( + fc + .constantFrom(0, 1, 2, 1.5, NaN, Infinity, -Infinity) + .map((v) => ({ k: `num` as const, v })), + fc.constantFrom(``, `a`, `1`, `true`).map((v) => ({ k: `str` as const, v })), + fc.boolean().map((v) => ({ k: `bool` as const, v })), + fc.constant({ k: `null` as const }), + fc.constant({ k: `undef` as const }), + fc.constantFrom(0n, 1n).map((v) => ({ k: `big` as const, v })), + fc.nat(1).map((id) => ({ k: `sym` as const, id })), +) +const leafArb: fc.Arbitrary = fc.oneof( + { weight: 4, arbitrary: primitiveArb }, + fc.record({ + k: fc.constant(`ref` as const), + kind: fc.constantFrom(`fn`, `handle`, `file`, `bigbin`), + id: fc.nat(1), + }), + fc.constantFrom(0, 1, NaN).map((t) => ({ k: `date` as const, t })), + fc + .array(fc.nat(255), { maxLength: 4 }) + .map((bytes) => ({ k: `bin` as const, bytes })), + fc.nat(TEMPORALS.length - 1).map((i) => ({ k: `temporal` as const, i })), + fc.record({ + k: fc.constant(`regex` as const), + source: fc.constantFrom(`a`, `b`), + flags: fc.constantFrom(``, `g`), + lastIndex: fc.nat(1), + }), +) + +const specArb = fc.letrec<{ spec: Spec }>((tie) => ({ + spec: fc.oneof( + { depthSize: `small`, withCrossShrink: true }, + { weight: 3, arbitrary: leafArb }, + fc.record({ + k: fc.constant(`array` as const), + items: fc.array(fc.option(tie(`spec`), { freq: 5, nil: null }), { + maxLength: 3, + }), + }), + fc.record({ k: fc.constant(`twice` as const), child: tie(`spec`) }), + fc.record({ + k: fc.constant(`map` as const), + // Keys are any generated value, unique by identity; mutations may still + // merge two keys, and the model merges them the same way. + entries: fc.uniqueArray( + fc.tuple( + fc.oneof({ weight: 2, arbitrary: primitiveArb }, tie(`spec`)), + tie(`spec`), + ), + { maxLength: 3, selector: ([key]) => identity(key) }, + ), + }), + fc.record({ + k: fc.constant(`set` as const), + // Unique identities, so a Set never merges two generated values. + items: fc.uniqueArray(tie(`spec`), { maxLength: 3, selector: identity }), + }), + fc.record({ + k: fc.constant(`obj` as const), + entries: fc.uniqueArray( + fc.tuple(fc.constantFrom(...OBJECT_KEYS), tie(`spec`)), + { maxLength: 3, selector: ([key]) => key }, + ), + syms: fc.uniqueArray(fc.tuple(fc.nat(1), tie(`spec`)), { + maxLength: 1, + selector: ([id]) => id, + }), + }), + ), +})).spec + +/** Every node of a spec in preorder, with a function that replaces it. */ +export function nodes( + spec: Spec, + replace: (next: Spec) => Spec = (next) => next, +): Array<[Spec, (next: Spec) => Spec]> { + const out: Array<[Spec, (next: Spec) => Spec]> = [[spec, replace]] + const child = (value: Spec, rebuild: (next: Spec) => Spec) => + out.push(...nodes(value, (next) => replace(rebuild(next)))) + if (spec.k === `array`) + spec.items.forEach((item, index) => { + if (item !== null) + child(item, (next) => ({ + ...spec, + items: spec.items.map((old, i) => (i === index ? next : old)), + })) + }) + else if (spec.k === `set`) + spec.items.forEach((item, index) => + child(item, (next) => ({ + ...spec, + items: spec.items.map((old, i) => (i === index ? next : old)), + })), + ) + else if (spec.k === `twice`) + child(spec.child, (next) => ({ ...spec, child: next })) + else if (spec.k === `map`) + spec.entries.forEach(([key, value], index) => { + child(key, (next) => ({ + ...spec, + entries: spec.entries.map((old, i) => + i === index ? ([next, value] as [Spec, Spec]) : old, + ), + })) + child(value, (next) => ({ + ...spec, + entries: spec.entries.map((old, i) => + i === index ? ([key, next] as [Spec, Spec]) : old, + ), + })) + }) + else if (spec.k === `obj`) + spec.entries.forEach(([key, value], index) => + child(value, (next) => ({ + ...spec, + entries: spec.entries.map((old, i) => + i === index ? ([key, next] as [string, Spec]) : old, + ), + })), + ) + if (spec.k === `obj`) + spec.syms.forEach(([id, value], index) => + child(value, (next) => ({ + ...spec, + syms: spec.syms.map((old, i) => + i === index ? ([id, next] as [number, Spec]) : old, + ), + })), + ) + return out +} + +export type Mutation = + | `none` + | `replace` + | `split` + | `header` + | `order` + | `hole` + | `retype` + | `property` + +/** A small change to one node. Some changes keep the identity; the model + * decides the verdict. */ +function nearMiss(spec: Spec, choice: number): [Mutation, Spec] { + switch (spec.k) { + case `num`: + return [`replace`, { k: `num`, v: [0, 1, NaN][choice % 3]! }] + case `str`: + return choice % 2 + ? [`retype`, { k: `num`, v: Number(spec.v) }] + : [`replace`, { k: `str`, v: `${spec.v}!` }] + case `big`: + return [`retype`, { k: `num`, v: Number(spec.v) }] + case `ref`: + return [`replace`, { ...spec, id: 1 - spec.id }] + case `date`: + return choice % 2 + ? [`retype`, { k: `num`, v: spec.t }] + : [`header`, { ...spec, t: Number.isNaN(spec.t) ? 0 : NaN }] + case `bin`: + return choice % 3 === 0 + ? [`header`, { k: `bin`, bytes: [...spec.bytes, 0] }] + : choice % 3 === 1 && spec.bytes.length > 0 + ? [ + `header`, + // Same length, one byte changed. + { + k: `bin`, + bytes: spec.bytes.map((byte, i) => (i === 0 ? byte ^ 1 : byte)), + }, + ] + : [`retype`, { k: `array`, items: spec.bytes.map(num) }] + case `temporal`: + return [`header`, { ...spec, i: (spec.i + 1) % TEMPORALS.length }] + case `regex`: + return choice % 3 === 0 + ? [`header`, { ...spec, lastIndex: spec.lastIndex + 1 }] + : choice % 3 === 1 + ? [`header`, { ...spec, flags: spec.flags ? `` : `g` }] + : [`property`, { ...spec, p: num(1) }] + case `array`: + return choice % 4 === 0 + ? [`hole`, { ...spec, items: [...spec.items, null] }] + : choice % 4 === 1 && spec.items.length > 0 + ? [ + `hole`, + { + ...spec, + items: spec.items.map((item, i): Spec | null => + i === 0 ? (item ? null : { k: `undef` }) : item, + ), + }, + ] + : choice % 4 === 2 + ? [`property`, { ...spec, p: num(1) }] + : [ + `retype`, + { + k: `obj`, + entries: spec.items.flatMap((item, i) => + item && i < 2 ? [[String(i), item] as [string, Spec]] : [], + ), + syms: [], + }, + ] + case `twice`: + // A shared child on one side against two different copies on the other + // tests that equality's revisit check keys on both objects. + return choice % 2 + ? [`replace`, { k: `array`, items: [spec.child] }] + : [ + `split`, + { + k: `array`, + items: [spec.child, nearMiss(spec.child, choice >> 1)[1]], + }, + ] + case `map`: { + const pairs = spec.entries.map(([key, value]): Spec => ({ + k: `array`, + items: [key, value], + })) + const [first, second] = spec.entries + // Repeating a primitive key makes the Map merge the two entries. + if (choice % 2 === 0 && first && second && MERGING_KINDS.has(first[0].k)) + return [ + `property`, + { + ...spec, + entries: spec.entries.map(([key, value], i) => + i === 1 ? ([first[0], value] as [Spec, Spec]) : [key, value], + ), + }, + ] + return choice % 4 === 0 && spec.entries.length > 1 + ? [`order`, { ...spec, entries: [...spec.entries].reverse() }] + : choice % 4 === 1 && spec.entries.length > 0 + ? [ + `property`, + { + ...spec, + entries: spec.entries.map(([key, value], i) => + i === 0 + ? ([{ k: `str`, v: `\u0000key` }, value] as [Spec, Spec]) + : [key, value], + ), + }, + ] + : choice % 4 === 2 + ? [`retype`, { k: `array`, items: pairs }] + : [`retype`, { k: `set`, items: pairs }] + } + case `set`: + return choice % 2 && spec.items.length > 1 + ? [`order`, { ...spec, items: [...spec.items].reverse() }] + : [`retype`, { k: `array`, items: spec.items }] + case `obj`: + return choice % 3 === 0 && spec.entries.length > 0 + ? [ + `property`, + { + ...spec, + entries: spec.entries.map(([key, value], i) => + i === 0 ? ([`${key}!`, value] as [string, Spec]) : [key, value], + ), + }, + ] + : choice % 3 === 1 + ? [ + `property`, + { + ...spec, + syms: spec.syms.length + ? spec.syms.map(([id, value]) => [1 - id, value]) + : [[0, num(1)]], + }, + ] + : [`order`, { ...spec, entries: [...spec.entries].reverse() }] + default: + return [`replace`, { k: `str`, v: `other` }] + } +} + +export function num(v: number): Spec { + return { k: `num`, v } +} + +export type Pair = { left: Spec; right: Spec; mutation: Mutation } + +export const pairArb: fc.Arbitrary = specArb.chain((left) => + fc.tuple(fc.nat(), fc.nat(), fc.nat(9)).map(([at, choice, mode]): Pair => { + if (mode < 3) return { left, right: left, mutation: `none` } + // Leaves outnumber containers, so half of the mutations target a + // container node when the spec has one. + const all = nodes(left) + const containers = all.filter(([node]) => CONTAINER_KINDS.has(node.k)) + const pool = mode % 2 && containers.length > 0 ? containers : all + const [node, replace] = pool[at % pool.length]! + const [mutation, next] = nearMiss(node, choice) + return { left, right: replace(next), mutation } + }), +) diff --git a/packages/db-ivm/tests/hash-identity-grammar.test.ts b/packages/db-ivm/tests/hash-identity-grammar.test.ts new file mode 100644 index 0000000000..3d4d02aa73 --- /dev/null +++ b/packages/db-ivm/tests/hash-identity-grammar.test.ts @@ -0,0 +1,93 @@ +// A census of the D2 value-identity oracle's grammar. It samples the fixed +// campaign of hash-identity-oracle.property.test.ts (the same seed and run +// count) and checks, against the model alone, that the sample reaches every +// value kind, every mutation, and both verdicts. It runs no production code, +// so it tests the grammar, not the hash. +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed } from '../../db/tests/oracle-config.js' +import { + canon, + identity, + nodes, + pairArb, +} from './hash-identity-grammar-oracle.js' + +const FIXED_SEED = 2026930 + +describe(`hash identity oracle`, () => { + // 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, + oracleFixedSeed(FIXED_SEED, { numRuns: 300 }), + ) + const kinds = new Set() + for (const { left } of sample) + for (const [node] of nodes(left)) + kinds.add(node.k === `ref` ? `ref:${node.kind}` : node.k) + expect([...kinds].sort()).toEqual( + [ + `array`, + `big`, + `bin`, + `bool`, + `date`, + `map`, + `null`, + `num`, + `obj`, + `ref:bigbin`, + `ref:file`, + `ref:fn`, + `ref:handle`, + `regex`, + `set`, + `str`, + `sym`, + `temporal`, + `twice`, + `undef`, + ].sort(), + ) + const verdicts = new Map>() + for (const pair of sample) { + const equal = identity(pair.left) === identity(pair.right) + const seen = verdicts.get(pair.mutation) ?? new Set() + verdicts.set(pair.mutation, seen.add(equal)) + } + expect([...verdicts.keys()].sort()).toEqual( + [ + `header`, + `hole`, + `none`, + `order`, + `property`, + `replace`, + `retype`, + `split`, + ].sort(), + ) + for (const mutation of [`header`, `hole`, `retype`, `replace`, `split`]) + expect(verdicts.get(mutation), mutation).toContain(false) + expect(verdicts.get(`none`)).toEqual(new Set([true])) + + // Map keys reach non-string primitives, containers, and merged keys. + const keyKinds = new Set() + let merged = 0 + for (const { left, right } of sample) + for (const spec of [left, right]) + for (const [node] of nodes(spec)) + if (node.k === `map`) { + for (const [key] of node.entries) keyKinds.add(key.k) + const encoded = canon(node) as [string, Array] + if (encoded[1].length < node.entries.length) merged++ + } + expect( + keyKinds.has(`num`) && keyKinds.has(`sym`) && keyKinds.has(`ref`), + ).toBe(true) + expect(keyKinds.has(`obj`) || keyKinds.has(`array`)).toBe(true) + expect(merged).toBeGreaterThan(0) + }) +}) 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 215fdd1180..f3f37fb450 100644 --- a/packages/db-ivm/tests/hash-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/hash-identity-oracle.property.test.ts @@ -1,22 +1,28 @@ import { Temporal } from 'temporal-polyfill' -import { describe, expect, it as vitestIt } from 'vitest' +import { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { equalHashValues, hash, registerOpaqueHash, } from '../src/hashing/hash.js' -import { - oracleFixedSeed, - oracleReplayTest, - oracleRoute, -} from '../../db/tests/oracle-config.js' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' import { colliding } from './hash-collision-support.js' +import { + SENTINEL, + SYMBOLS, + TEMPORALS, + identity, + nodes, + num, + pairArb, + primitiveArb, +} from './hash-identity-grammar-oracle.js' +import type { Pair, RefKind, Spec } from './hash-identity-grammar-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' /** @@ -77,47 +83,10 @@ import type { Observation, Verdict } from '../../db/tests/oracle-property.js' */ // --------------------------------------------------------------------------- -// Value specs. A spec is plain data, so the model never reads production -// objects. Kinds name contract rules, not production's marker/header/body -// split. `ref` is production's reference leaf. Two kinds exist only in this -// model: `twice` is one child that the driver shares or copies, and `back` is -// a back edge to an ancestor container. - -type Spec = - | { k: `num`; v: number } - | { k: `str`; v: string } - | { k: `bool`; v: boolean } - | { k: `null` } - | { k: `undef` } - | { k: `big`; v: bigint } - | { k: `sym`; id: number } - | { k: `ref`; kind: RefKind; id: number } - | { k: `date`; t: number } - | { k: `bin`; bytes: Array } - | { k: `temporal`; i: number } - | { k: `regex`; source: string; flags: string; lastIndex: number; p?: Spec } - | { k: `array`; items: Array; p?: Spec } - | { k: `twice`; child: Spec } - | { k: `map`; entries: Array<[Spec, Spec]> } - | { k: `set`; items: Array } - | { k: `obj`; entries: Array<[string, Spec]>; syms: Array<[number, Spec]> } - | { k: `back`; up: number } - -type RefKind = `fn` | `handle` | `file` | `bigbin` - -const TEMPORALS: ReadonlyArray<[keyof typeof Temporal, string]> = [ - [`PlainDate`, `2024-01-15`], - [`PlainDate`, `2024-01-16`], - [`Duration`, `P1D`], - [`Instant`, `2024-01-15T00:00:00Z`], -] -const SYMBOLS = [Symbol(`s0`), Symbol(`s1`)] -// The last two are distinct keys that locale collation ranks as equal (a -// precomposed and a decomposed Å), so key order must not come from collation. -const OBJECT_KEYS = [`a`, `b`, `c`, `0`, `1`, `\u00C5`, `A\u030A`] -// No value in any pool equals this text, so a leaf that holds it makes a -// cyclic value unequal to its original. -const SENTINEL = `\u0000sentinel` +// Value specs, the identity model (`identity`, `canon`), and the grammar +// (`pairArb`, with its near-miss mutations) live in +// hash-identity-grammar-oracle.ts. Their fixed-seed census is in +// hash-identity-grammar.test.ts. The driver and checks below stay here. // Reference leaves are shared by both sides of a pair: equal ids are the same // object, and different ids are different objects with the same contents. @@ -139,434 +108,6 @@ function refObject(kind: RefKind, id: number): object { return value } -// --------------------------------------------------------------------------- -// Model: a canonical encoding of a spec's identity. - -function identity(spec: Spec): string { - return JSON.stringify(canon(spec)) -} - -// Maps and Sets merge primitive keys and shared references that are equal -// under SameValueZero: the first position stays and the last value wins. -// Every other generated key is a fresh object and never merges. -const MERGING_KINDS = new Set([ - `num`, - `str`, - `bool`, - `null`, - `undef`, - `big`, - `sym`, - `ref`, -]) -const CONTAINER_KINDS = new Set([ - `array`, - `twice`, - `map`, - `set`, - `obj`, - `regex`, -]) -function mapEntries( - entries: Array<[Spec, Spec | undefined]>, -): Array<[unknown, unknown]> { - const out: Array<[unknown, unknown]> = [] - const positions = new Map() - for (const [key, value] of entries) { - const encoded = canon(key) - const valueCode = value === undefined ? null : canon(value) - if (MERGING_KINDS.has(key.k)) { - const id = JSON.stringify(encoded) - const at = positions.get(id) - if (at !== undefined) { - out[at]![1] = valueCode - continue - } - positions.set(id, out.length) - } - out.push([encoded, valueCode]) - } - return out -} - -function canon(spec: Spec): unknown { - const inner = canon - const props = (p: Spec | undefined) => (p ? [[`p`, inner(p)]] : []) - switch (spec.k) { - case `num`: - // `String` writes -0 as `0` and keeps NaN and infinities distinct. - return [`num`, String(spec.v)] - case `str`: - case `bool`: - return [spec.k, spec.v] - case `null`: - case `undef`: - return [spec.k] - case `big`: - return [`big`, String(spec.v)] - case `sym`: - return [`sym`, spec.id] - case `ref`: - return [`ref`, spec.kind, spec.id] - case `date`: - return [`date`, String(spec.t)] - case `bin`: - return [`bin`, spec.bytes] - case `temporal`: - return [`temporal`, TEMPORALS[spec.i]] - case `regex`: - return [`regex`, spec.source, spec.flags, spec.lastIndex, props(spec.p)] - case `array`: - return [ - `array`, - spec.items.length, - spec.items.flatMap((item, index) => - item === null ? [] : [[index, inner(item)]], - ), - props(spec.p), - ] - case `twice`: - // A shared child and a copied child have the same identity. - return [`array`, 2, [0, 1].map((index) => [index, inner(spec.child)]), []] - case `map`: - return [ - `map`, - mapEntries(spec.entries.map(([key, value]) => [key, value])), - ] - case `set`: - return [ - `set`, - mapEntries(spec.items.map((item) => [item, undefined])).map( - ([item]) => item, - ), - ] - case `obj`: - return [ - `obj`, - [...spec.entries] - .sort(([left], [right]) => (left < right ? -1 : 1)) - .map(([key, value]) => [key, inner(value)]), - [...spec.syms] - .sort(([left], [right]) => left - right) - .map(([id, value]) => [id, inner(value)]), - ] - case `back`: - // Only cyclic histories hold back edges, and their verdicts come from - // construction, not from this encoding. - return [`back`, spec.up] - } -} - -// --------------------------------------------------------------------------- -// Grammar. Near-miss mutations change one node, so equal and unequal pairs -// both occur often. - -const primitiveArb: fc.Arbitrary = fc.oneof( - fc - .constantFrom(0, 1, 2, 1.5, NaN, Infinity, -Infinity) - .map((v) => ({ k: `num` as const, v })), - fc.constantFrom(``, `a`, `1`, `true`).map((v) => ({ k: `str` as const, v })), - fc.boolean().map((v) => ({ k: `bool` as const, v })), - fc.constant({ k: `null` as const }), - fc.constant({ k: `undef` as const }), - fc.constantFrom(0n, 1n).map((v) => ({ k: `big` as const, v })), - fc.nat(1).map((id) => ({ k: `sym` as const, id })), -) -const leafArb: fc.Arbitrary = fc.oneof( - { weight: 4, arbitrary: primitiveArb }, - fc.record({ - k: fc.constant(`ref` as const), - kind: fc.constantFrom(`fn`, `handle`, `file`, `bigbin`), - id: fc.nat(1), - }), - fc.constantFrom(0, 1, NaN).map((t) => ({ k: `date` as const, t })), - fc - .array(fc.nat(255), { maxLength: 4 }) - .map((bytes) => ({ k: `bin` as const, bytes })), - fc.nat(TEMPORALS.length - 1).map((i) => ({ k: `temporal` as const, i })), - fc.record({ - k: fc.constant(`regex` as const), - source: fc.constantFrom(`a`, `b`), - flags: fc.constantFrom(``, `g`), - lastIndex: fc.nat(1), - }), -) - -const specArb = fc.letrec<{ spec: Spec }>((tie) => ({ - spec: fc.oneof( - { depthSize: `small`, withCrossShrink: true }, - { weight: 3, arbitrary: leafArb }, - fc.record({ - k: fc.constant(`array` as const), - items: fc.array(fc.option(tie(`spec`), { freq: 5, nil: null }), { - maxLength: 3, - }), - }), - fc.record({ k: fc.constant(`twice` as const), child: tie(`spec`) }), - fc.record({ - k: fc.constant(`map` as const), - // Keys are any generated value, unique by identity; mutations may still - // merge two keys, and the model merges them the same way. - entries: fc.uniqueArray( - fc.tuple( - fc.oneof({ weight: 2, arbitrary: primitiveArb }, tie(`spec`)), - tie(`spec`), - ), - { maxLength: 3, selector: ([key]) => identity(key) }, - ), - }), - fc.record({ - k: fc.constant(`set` as const), - // Unique identities, so a Set never merges two generated values. - items: fc.uniqueArray(tie(`spec`), { maxLength: 3, selector: identity }), - }), - fc.record({ - k: fc.constant(`obj` as const), - entries: fc.uniqueArray( - fc.tuple(fc.constantFrom(...OBJECT_KEYS), tie(`spec`)), - { maxLength: 3, selector: ([key]) => key }, - ), - syms: fc.uniqueArray(fc.tuple(fc.nat(1), tie(`spec`)), { - maxLength: 1, - selector: ([id]) => id, - }), - }), - ), -})).spec - -/** Every node of a spec in preorder, with a function that replaces it. */ -function nodes( - spec: Spec, - replace: (next: Spec) => Spec = (next) => next, -): Array<[Spec, (next: Spec) => Spec]> { - const out: Array<[Spec, (next: Spec) => Spec]> = [[spec, replace]] - const child = (value: Spec, rebuild: (next: Spec) => Spec) => - out.push(...nodes(value, (next) => replace(rebuild(next)))) - if (spec.k === `array`) - spec.items.forEach((item, index) => { - if (item !== null) - child(item, (next) => ({ - ...spec, - items: spec.items.map((old, i) => (i === index ? next : old)), - })) - }) - else if (spec.k === `set`) - spec.items.forEach((item, index) => - child(item, (next) => ({ - ...spec, - items: spec.items.map((old, i) => (i === index ? next : old)), - })), - ) - else if (spec.k === `twice`) - child(spec.child, (next) => ({ ...spec, child: next })) - else if (spec.k === `map`) - spec.entries.forEach(([key, value], index) => { - child(key, (next) => ({ - ...spec, - entries: spec.entries.map((old, i) => - i === index ? ([next, value] as [Spec, Spec]) : old, - ), - })) - child(value, (next) => ({ - ...spec, - entries: spec.entries.map((old, i) => - i === index ? ([key, next] as [Spec, Spec]) : old, - ), - })) - }) - else if (spec.k === `obj`) - spec.entries.forEach(([key, value], index) => - child(value, (next) => ({ - ...spec, - entries: spec.entries.map((old, i) => - i === index ? ([key, next] as [string, Spec]) : old, - ), - })), - ) - if (spec.k === `obj`) - spec.syms.forEach(([id, value], index) => - child(value, (next) => ({ - ...spec, - syms: spec.syms.map((old, i) => - i === index ? ([id, next] as [number, Spec]) : old, - ), - })), - ) - return out -} - -type Mutation = - | `none` - | `replace` - | `split` - | `header` - | `order` - | `hole` - | `retype` - | `property` - -/** A small change to one node. Some changes keep the identity; the model - * decides the verdict. */ -function nearMiss(spec: Spec, choice: number): [Mutation, Spec] { - switch (spec.k) { - case `num`: - return [`replace`, { k: `num`, v: [0, 1, NaN][choice % 3]! }] - case `str`: - return choice % 2 - ? [`retype`, { k: `num`, v: Number(spec.v) }] - : [`replace`, { k: `str`, v: `${spec.v}!` }] - case `big`: - return [`retype`, { k: `num`, v: Number(spec.v) }] - case `ref`: - return [`replace`, { ...spec, id: 1 - spec.id }] - case `date`: - return choice % 2 - ? [`retype`, { k: `num`, v: spec.t }] - : [`header`, { ...spec, t: Number.isNaN(spec.t) ? 0 : NaN }] - case `bin`: - return choice % 3 === 0 - ? [`header`, { k: `bin`, bytes: [...spec.bytes, 0] }] - : choice % 3 === 1 && spec.bytes.length > 0 - ? [ - `header`, - // Same length, one byte changed. - { - k: `bin`, - bytes: spec.bytes.map((byte, i) => (i === 0 ? byte ^ 1 : byte)), - }, - ] - : [`retype`, { k: `array`, items: spec.bytes.map(num) }] - case `temporal`: - return [`header`, { ...spec, i: (spec.i + 1) % TEMPORALS.length }] - case `regex`: - return choice % 3 === 0 - ? [`header`, { ...spec, lastIndex: spec.lastIndex + 1 }] - : choice % 3 === 1 - ? [`header`, { ...spec, flags: spec.flags ? `` : `g` }] - : [`property`, { ...spec, p: num(1) }] - case `array`: - return choice % 4 === 0 - ? [`hole`, { ...spec, items: [...spec.items, null] }] - : choice % 4 === 1 && spec.items.length > 0 - ? [ - `hole`, - { - ...spec, - items: spec.items.map((item, i): Spec | null => - i === 0 ? (item ? null : { k: `undef` }) : item, - ), - }, - ] - : choice % 4 === 2 - ? [`property`, { ...spec, p: num(1) }] - : [ - `retype`, - { - k: `obj`, - entries: spec.items.flatMap((item, i) => - item && i < 2 ? [[String(i), item] as [string, Spec]] : [], - ), - syms: [], - }, - ] - case `twice`: - // A shared child on one side against two different copies on the other - // tests that equality's revisit check keys on both objects. - return choice % 2 - ? [`replace`, { k: `array`, items: [spec.child] }] - : [ - `split`, - { - k: `array`, - items: [spec.child, nearMiss(spec.child, choice >> 1)[1]], - }, - ] - case `map`: { - const pairs = spec.entries.map(([key, value]): Spec => ({ - k: `array`, - items: [key, value], - })) - const [first, second] = spec.entries - // Repeating a primitive key makes the Map merge the two entries. - if (choice % 2 === 0 && first && second && MERGING_KINDS.has(first[0].k)) - return [ - `property`, - { - ...spec, - entries: spec.entries.map(([key, value], i) => - i === 1 ? ([first[0], value] as [Spec, Spec]) : [key, value], - ), - }, - ] - return choice % 4 === 0 && spec.entries.length > 1 - ? [`order`, { ...spec, entries: [...spec.entries].reverse() }] - : choice % 4 === 1 && spec.entries.length > 0 - ? [ - `property`, - { - ...spec, - entries: spec.entries.map(([key, value], i) => - i === 0 - ? ([{ k: `str`, v: `\u0000key` }, value] as [Spec, Spec]) - : [key, value], - ), - }, - ] - : choice % 4 === 2 - ? [`retype`, { k: `array`, items: pairs }] - : [`retype`, { k: `set`, items: pairs }] - } - case `set`: - return choice % 2 && spec.items.length > 1 - ? [`order`, { ...spec, items: [...spec.items].reverse() }] - : [`retype`, { k: `array`, items: spec.items }] - case `obj`: - return choice % 3 === 0 && spec.entries.length > 0 - ? [ - `property`, - { - ...spec, - entries: spec.entries.map(([key, value], i) => - i === 0 ? ([`${key}!`, value] as [string, Spec]) : [key, value], - ), - }, - ] - : choice % 3 === 1 - ? [ - `property`, - { - ...spec, - syms: spec.syms.length - ? spec.syms.map(([id, value]) => [1 - id, value]) - : [[0, num(1)]], - }, - ] - : [`order`, { ...spec, entries: [...spec.entries].reverse() }] - default: - return [`replace`, { k: `str`, v: `other` }] - } -} - -function num(v: number): Spec { - return { k: `num`, v } -} - -type Pair = { left: Spec; right: Spec; mutation: Mutation } - -const pairArb: fc.Arbitrary = specArb.chain((left) => - fc.tuple(fc.nat(), fc.nat(), fc.nat(9)).map(([at, choice, mode]): Pair => { - if (mode < 3) return { left, right: left, mutation: `none` } - // Leaves outnumber containers, so half of the mutations target a - // container node when the spec has one. - const all = nodes(left) - const containers = all.filter(([node]) => CONTAINER_KINDS.has(node.k)) - const pool = mode % 2 && containers.length > 0 ? containers : all - const [node, replace] = pool[at % pool.length]! - const [mutation, next] = nearMiss(node, choice) - return { left, right: replace(next), mutation } - }), -) - // --------------------------------------------------------------------------- // Realization. Each side is built as fresh objects. `seed` varies property // order, prototypes, hidden properties, carriers, and signed zero; none of @@ -796,25 +337,6 @@ function checkPair( return pass } -function expectPair( - pair: Pair, - seeds: [number, number], - modules: ReadonlyArray = MODULES, -): void { - const plan = { pair, seeds } - const trace: Array = [] - drivePair( - plan, - (checkpoint, value) => trace.push({ checkpoint, value }), - modules, - ) - const verdict = checkPair(plan, trace, expectedPair(plan)) - if (verdict.kind === `violation`) - expect(verdict.values?.actual, verdict.difference).toBe( - verdict.values?.expected, - ) -} - function driveCyclicPair( { spec, changed, seeds }: CyclicPair, observe: Observe, @@ -880,10 +402,6 @@ function checkCyclicPair( // runs its recorded history once and every other test stands aside. A seed // alone reseeds the random campaigns. -const FIXED_SEED = 2026930 - -const it = oracleReplayTest(vitestIt) - describe(`hash identity oracle`, () => { oracleProperty({ id: `ivm.hash-identity`, @@ -928,82 +446,6 @@ describe(`hash identity oracle`, () => { check: checkCyclicPair, }) - // 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`, () => { - 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)) - kinds.add(node.k === `ref` ? `ref:${node.kind}` : node.k) - expect([...kinds].sort()).toEqual( - [ - `array`, - `big`, - `bin`, - `bool`, - `date`, - `map`, - `null`, - `num`, - `obj`, - `ref:bigbin`, - `ref:file`, - `ref:fn`, - `ref:handle`, - `regex`, - `set`, - `str`, - `sym`, - `temporal`, - `twice`, - `undef`, - ].sort(), - ) - const verdicts = new Map>() - for (const pair of sample) { - const equal = identity(pair.left) === identity(pair.right) - const seen = verdicts.get(pair.mutation) ?? new Set() - verdicts.set(pair.mutation, seen.add(equal)) - } - expect([...verdicts.keys()].sort()).toEqual( - [ - `header`, - `hole`, - `none`, - `order`, - `property`, - `replace`, - `retype`, - `split`, - ].sort(), - ) - for (const mutation of [`header`, `hole`, `retype`, `replace`, `split`]) - expect(verdicts.get(mutation), mutation).toContain(false) - expect(verdicts.get(`none`)).toEqual(new Set([true])) - - // Map keys reach non-string primitives, containers, and merged keys. - const keyKinds = new Set() - let merged = 0 - for (const { left, right } of sample) - for (const spec of [left, right]) - for (const [node] of nodes(spec)) - if (node.k === `map`) { - for (const [key] of node.entries) keyKinds.add(key.k) - const encoded = canon(node) as [string, Array] - if (encoded[1].length < node.entries.length) merged++ - } - expect( - keyKinds.has(`num`) && keyKinds.has(`sym`) && keyKinds.has(`ref`), - ).toBe(true) - expect(keyKinds.has(`obj`) || keyKinds.has(`array`)).toBe(true) - expect(merged).toBeGreaterThan(0) - }) - // Fixed witnesses for rules that a wrong design would plausibly break. const fixedWitnesses: Array<[string, Spec, Spec]> = [ [ @@ -1129,70 +571,133 @@ describe(`hash identity oracle`, () => { { k: `obj`, entries: [], syms: [[1, num(1)]] }, ], ] - it.each(fixedWitnesses)(`%s`, (_name, left, right) => { - oracleRoute(`case`) - for (const seeds of [ - [1, 2], - [3, 4], - ] as Array<[number, number]>) - expectPair({ left, right, mutation: `replace` }, seeds) - }) - - 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: [] }, - } - const split: Spec = { - k: `array`, - items: [ - { k: `obj`, entries: [[`a`, num(1)]], syms: [] }, - { k: `obj`, entries: [[`a`, num(2)]], syms: [] }, - ], - } + // The fixed witnesses run through the same driver, model, and check as the + // generated campaign. pairArb is a chained arbitrary, so it cannot take + // these pairs back; they form one enumerated property. Each former test ran + // its pair under several realization seeds; each seed pair is a history. + const fixedPairHistories: Array = [ + ...fixedWitnesses.flatMap(([name, left, right]) => + ( + [ + [1, 2], + [3, 4], + ] as Array<[number, number]> + ).map((seeds) => ({ + name, + pair: { left, right, mutation: `replace` as const }, + seeds, + })), + ), // Some seeds share the child on the left side. - for (let seed = 1; seed < 20; seed++) - expectPair({ left: spec, right: split, mutation: `split` }, [seed, seed]) + ...Array.from({ length: 19 }, (_, index) => index + 1).map((seed) => ({ + name: `does not accept a second copy because the first copy matched`, + pair: { + left: { + k: `twice`, + child: { k: `obj`, entries: [[`a`, num(1)]], syms: [] }, + } satisfies Spec, + right: { + k: `array`, + items: [ + { k: `obj`, entries: [[`a`, num(1)]], syms: [] }, + { k: `obj`, entries: [[`a`, num(2)]], syms: [] }, + ], + } satisfies Spec, + mutation: `split` as const, + }, + seeds: [seed, seed] as [number, number], + })), + // Different seeds choose sharing, prototypes, carriers, and -0. + ...Array.from({ length: 39 }, (_, index) => index + 1).map((seed) => { + const spec: Spec = { + k: `twice`, + child: { + k: `obj`, + entries: [ + [`a`, num(0)], + [`b`, { k: `bin`, bytes: [1] }], + ], + syms: [], + }, + } + return { + name: `treats shared and copied subtrees, prototypes, and hidden properties as equal`, + pair: { left: spec, right: spec, mutation: `none` as const }, + seeds: [seed, seed + 101] as [number, number], + } + }), + ] + oracleProperty({ + id: `ivm.hash-identity-fixed`, + law: { + statement: `D2 equality is symmetric and agrees with independent value identity; equal identities have equal hashes under native and colliding-marker modules.`, + authority: `The existing nine value-identity rules documented above.`, + limits: `Named witnesses for rules that a wrong design would plausibly break, which the chained pair grammar cannot take back; each is replayed under its former realization seeds through the same driver, model, and check.`, + }, + owns: [ + `src/hashing/hash.ts#hash`, + `src/hashing/hash.ts#equalHashValues`, + `src/hashing/hash.ts#registerOpaqueHash`, + ], + histories: fixedPairHistories, + label: ({ name, seeds }) => `${name} (seeds ${seeds[0]}, ${seeds[1]})`, + model: expectedPair, + driver: drivePair, + check: checkPair, }) - it(`keeps distinct identities separate when their digests collide`, () => { - oracleRoute(`case`) - const pair: Pair = { - left: { k: `map`, entries: [] }, - right: { k: `set`, items: [] }, - mutation: `retype`, - } - const left = realize(pair.left, 1) - const right = realize(pair.right, 2) + // A map and a set whose colliding-marker digests are equal. The driver + // first confirms the collision, so the witness tests identity and not the + // digest. A digest-only equality must be rejected on it. + const digestOnly: Module = { + name: `digest-only mutant`, + hash: colliding.hash, + equalHashValues: (a, b) => colliding.hash(a) === colliding.hash(b), + } + function driveDigestCollision( + plan: PairPlan, + observe: Observe, + modules: ReadonlyArray = MODULES, + ): void { + const left = realize(plan.pair.left, plan.seeds[0]) + const right = realize(plan.pair.right, plan.seeds[1]) expect(colliding.hash(left)).toBe(colliding.hash(right)) - const digestOnly: Module = { - name: `digest-only mutant`, - hash: colliding.hash, - equalHashValues: (a, b) => colliding.hash(a) === colliding.hash(b), - } - expect(() => expectPair(pair, [1, 2], [digestOnly])).toThrow() - expectPair(pair, [1, 2]) - }) - - it(`treats shared and copied subtrees, prototypes, and hidden properties as equal`, () => { - oracleRoute(`case`) - const spec: Spec = { - k: `twice`, - child: { - k: `obj`, - entries: [ - [`a`, num(0)], - [`b`, { k: `bin`, bytes: [1] }], - ], - syms: [], + drivePair(plan, observe, modules) + } + oracleProperty({ + id: `ivm.hash-identity-digest-collision`, + law: { + statement: `D2 equality is symmetric and agrees with independent value identity; equal identities have equal hashes under native and colliding-marker modules.`, + authority: `The existing nine value-identity rules documented above.`, + limits: `One map and set pair whose colliding-marker digests are equal; a digest-only equality calibrates the comparison.`, + }, + owns: [ + `src/hashing/hash.ts#hash`, + `src/hashing/hash.ts#equalHashValues`, + `src/hashing/hash.ts#registerOpaqueHash`, + ], + histories: [ + { + pair: { + left: { k: `map`, entries: [] }, + right: { k: `set`, items: [] }, + mutation: `retype`, + }, + seeds: [1, 2], }, - } - // Different seeds choose sharing, prototypes, carriers, and -0. - for (let seed = 1; seed < 40; seed++) - expectPair({ left: spec, right: spec, mutation: `none` }, [ - seed, - seed + 101, - ]) + ] satisfies Array, + label: () => + `keeps distinct identities separate when their digests collide`, + model: expectedPair, + driver: driveDigestCollision, + check: checkPair, + calibrations: [ + { + name: `rejects a digest-only equality on colliding digests`, + law: `equality`, + driver: (plan, observe) => + driveDigestCollision(plan, observe, [digestOnly]), + }, + ], }) }) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 8da56a334f..5138710810 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -8,7 +8,12 @@ import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ // db-ivm `ivm.temporal-group-key`, + `ivm.hash-identity-fixed`, + `ivm.hash-identity-digest-collision`, // react-db + `react.convex-lazy-paging`, + `react.effect-callback`, + `react.paced-mutations-render-cuts`, // vue-db and scripts ] @@ -19,6 +24,26 @@ export const owners: ReadonlyArray = [ `ivm`, `temporal-group-key`, ], + [ + `db-ivm/tests/hash-identity-oracle.property.test.ts`, + `ivm`, + `hash-identity-fixed hash-identity-digest-collision`, + ], // react-db + [ + `react-db/tests/convex-lazy-paging-oracle.test.tsx`, + `react`, + `convex-lazy-paging`, + ], + [ + `react-db/tests/effect-callback-oracle.test.tsx`, + `react`, + `effect-callback`, + ], + [ + `react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx`, + `react`, + `paced-mutations-render-cuts`, + ], // vue-db and scripts ] From 651378ef9a1927e88f6ff74adff52c03ecc713cc Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:47:14 -0700 Subject: [PATCH 024/116] test(db): register proxy iteration cases through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 10 + .../proxy-iteration-contract-oracle.test.ts | 434 +++++++++++------- 2 files changed, 277 insertions(+), 167 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index d172537969..62c32763d7 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -13,6 +13,11 @@ export const properties: ReadonlyArray = [ `proxy.own-property-histories`, `proxy.cycle-backedges`, `proxy.binary-detachment`, + `proxy.iteration-array-edits`, + `proxy.iteration-insertion-values`, + `proxy.iteration-nested-membership`, + `proxy.iteration-set-clear-readd`, + `proxy.iteration-persisted-writes`, ] export const owners: ReadonlyArray = [ @@ -25,4 +30,9 @@ export const owners: ReadonlyArray = [ `proxy`, `own-property-histories cycle-backedges binary-detachment`, ], + [ + `db/tests/proxy-iteration-contract-oracle.test.ts`, + `proxy`, + `iteration-array-edits iteration-insertion-values iteration-nested-membership iteration-set-clear-readd iteration-persisted-writes`, + ], ] diff --git a/packages/db/tests/proxy-iteration-contract-oracle.test.ts b/packages/db/tests/proxy-iteration-contract-oracle.test.ts index 70f1822acf..225babd40d 100644 --- a/packages/db/tests/proxy-iteration-contract-oracle.test.ts +++ b/packages/db/tests/proxy-iteration-contract-oracle.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' 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 { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import type { Item, Protocol } from './proxy-iteration-fixture.js' /** @@ -50,13 +54,7 @@ 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], -}) +const iterationAuthority = `src/proxy.ts draft Map, Set and Array iteration contract stated above; the native Map, Set or Array run is the model` describe(`Array draft iteration`, () => { type Element = { x: number } | number @@ -102,189 +100,291 @@ describe(`Array draft iteration`, () => { items: [{ x: 1 }, 2, { x: 3 }], }) + const runArrayEdit = ( + iterate: (array: Array) => Iterable, + edit: (array: Array, first: unknown) => void, + ) => { + const native = make() + const expectedVisits = run(native.items, iterate, edit) + let visits: Array = [] + const changes = withChangeTracking(make(), (draft) => { + visits = run(draft.items, iterate, edit) + }) + expectNativeVisits(visits, expectedVisits) + expectNativeVisits(changes, { items: native.items }) + } + describe.each(iterations)(`%s`, (_name, iterate) => { - 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 = [] - const changes = withChangeTracking(make(), (draft) => { - visits = run(draft.items, iterate, edit) - }) - expectNativeVisits(visits, expectedVisits) - expectNativeVisits(changes, { items: native.items }) + // What this oracle owns: the gap report judges the draft's Map, Set, and + // Array iteration in proxy.ts by this file's tests. + oracleProperty({ + id: `proxy.iteration-array-edits`, + law: { + statement: `An edit made while a draft array iterator is open changes what it visits next as on a native array, and the detached changes equal the native result.`, + authority: iterationAuthority, + limits: `Three iteration forms over one three-element array, each with six edits made while the iterator holds its first element.`, }, - ) + owns: [`src/proxy.ts`], + histories: edits.map(([name]) => name), + label: (edit) => + `matches native visits and detached changes after ${edit}`, + resolve: (name) => ({ + edit: edits.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.iteration-array-edits`, + plan, + observe, + () => runArrayEdit(iterate, plan.edit), + ), + check: forwardAssertionJudgment, + }) }) }) describe.each([`Map`, `Set`] as const)( `%s caller-owned insertion values`, (kind) => { - 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) + const runInsertionValues = ( + operation: `mutate` | `delete-readd` | `second-entry`, + ) => { + const run = (values: Map | Set) => { + const item = { x: 1 } + if (values instanceof Map) values.set(`a`, item) + else values.add(item) + if (operation === `delete-readd`) { + if (values instanceof Map) values.delete(`a`) + else values.delete(item) + } + item.x = 2 + if (operation !== `mutate`) { + if (values instanceof Map) + values.set(operation === `second-entry` ? `b` : `a`, item) else values.add(item) - if (operation === `delete-readd`) { - if (values instanceof Map) values.delete(`a`) - else values.delete(item) - } - item.x = 2 - if (operation !== `mutate`) { - if (values instanceof Map) - values.set(operation === `second-entry` ? `b` : `a`, item) - else values.add(item) - } } - const make = () => - kind === `Map` ? new Map() : new Set() - const expected = make() - run(expected) - const changes = withChangeTracking({ values: make() }, (draft) => - run(draft.values), - ) - expectNativeVisits( - [...(changes.values as ReturnType).values()], - [...expected.values()], - ) + } + const make = () => + kind === `Map` ? new Map() : new Set() + const expected = make() + run(expected) + const changes = withChangeTracking({ values: make() }, (draft) => + run(draft.values), + ) + expectNativeVisits( + [...(changes.values as ReturnType).values()], + [...expected.values()], + ) + } + oracleProperty({ + id: `proxy.iteration-insertion-values`, + law: { + statement: `A caller-owned object inserted into a draft Map or Set stays shared while the callback runs, then detaches with the native final values.`, + authority: iterationAuthority, + limits: `Mutation after insertion, delete and re-add, and a second entry for one raw object.`, }, - ) + owns: [`src/proxy.ts`], + histories: [`mutate`, `delete-readd`, `second-entry`] as const, + label: (operation) => + `matches native raw-object mutation after insertion: ${operation}`, + resolve: (operation) => ({ operation }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.iteration-insertion-values`, + plan, + observe, + () => runInsertionValues(plan.operation), + ), + check: forwardAssertionJudgment, + }) }, ) 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) => { - if (seen.length > 5) throw new Error(`Iteration did not terminate`) - seen.push(value.x) - value.x += 10 - if (seen.length === 1) { - if (values instanceof Map) { - values.delete(2) - values.set(3, { x: 3 }) - } else { - values.delete([...values][1]!) - values.add({ x: 3 }) - } - } - }) - return { seen, values: [...values.values()] } - } - const make = () => - kind === `Map` - ? new Map([ - [1, { x: 1 }], - [2, { x: 2 }], - ]) - : new Set([{ x: 1 }, { x: 2 }]) - const expected = run(make()) - const changes = withChangeTracking({ values: make() }, (draft) => { - expectNativeVisits(run(draft.values), expected) - }) - expect([ - ...(changes.values as Map | Set).values(), - ]).toEqual(expected.values) - }, - ) -}) - -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 runNestedIteration = (protocol: Protocol) => { + const run = (values: Map | Set) => { const seen: Array = [] visit(values, protocol, (value) => { - if (seen.length > 2) throw new Error(`Iteration did not terminate`) + if (seen.length > 5) throw new Error(`Iteration did not terminate`) seen.push(value.x) + value.x += 10 if (seen.length === 1) { - value.x = 3 - values.clear() - values.add(value) + if (values instanceof Map) { + values.delete(2) + values.set(3, { x: 3 }) + } else { + values.delete([...values][1]!) + values.add({ x: 3 }) + } } }) - return seen + return { seen, values: [...values.values()] } } - const expected = run(new Set([{ x: 1 }, { x: 2 }])) - const changes = withChangeTracking( - { values: new Set([{ x: 1 }, { x: 2 }]) }, - (draft) => { - expectNativeVisits(run(draft.values), expected) - }, - ) - expect(changes.values).toEqual(new Set([{ x: 3 }])) + const make = () => + kind === `Map` + ? new Map([ + [1, { x: 1 }], + [2, { x: 2 }], + ]) + : new Set([{ x: 1 }, { x: 2 }]) + const expected = run(make()) + const changes = withChangeTracking({ values: make() }, (draft) => { + expectNativeVisits(run(draft.values), expected) + }) + expect([ + ...(changes.values as Map | Set).values(), + ]).toEqual(expected.values) + } + oracleProperty({ + id: `proxy.iteration-nested-membership`, + law: { + statement: `A draft Map or Set iteration protocol sees membership changes made before an entry is consumed, visits each entry once, and ends with the native values.`, + authority: iterationAuthority, + limits: `Two initial entries; the first visit deletes the second entry and adds a third, under each of the four iteration protocols.`, + }, + owns: [`src/proxy.ts`], + histories: protocols, + label: (protocol) => + `${protocol} matches native membership changes during iteration`, + resolve: (protocol) => ({ protocol }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.iteration-nested-membership`, + plan, + observe, + () => runNestedIteration(plan.protocol), + ), + check: forwardAssertionJudgment, + }) +}) + +function runSetClearReadd(protocol: Protocol) { + const run = (values: Set) => { + const seen: Array = [] + visit(values, protocol, (value) => { + if (seen.length > 2) throw new Error(`Iteration did not terminate`) + seen.push(value.x) + if (seen.length === 1) { + value.x = 3 + values.clear() + values.add(value) + } + }) + return seen + } + const expected = run(new Set([{ x: 1 }, { x: 2 }])) + const changes = withChangeTracking( + { values: new Set([{ x: 1 }, { x: 2 }]) }, + (draft) => { + expectNativeVisits(run(draft.values), expected) + }, + ) + expect(changes.values).toEqual(new Set([{ x: 3 }])) +} + +oracleProperty({ + id: `proxy.iteration-set-clear-readd`, + law: { + statement: `A draft Set iterator observes a clear and re-add made during its first visit just like a native iterator.`, + authority: iterationAuthority, + limits: `Two initial members; the first visit edits its member, clears the Set and re-adds that member, under each of the four iteration protocols.`, }, -) + owns: [`src/proxy.ts`], + histories: protocols, + label: (protocol) => + `Set ${protocol} observes clear and re-add after an edit just like a native iterator`, + resolve: (protocol) => ({ protocol }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.iteration-set-clear-readd`, + plan, + observe, + () => runSetClearReadd(plan.protocol), + ), + check: forwardAssertionJudgment, +}) -it.each( - ([`Map`, `Set`] as const).flatMap((kind) => - protocols.map((protocol) => ({ kind, protocol })), - ), -)( - `$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) - let confirm: (value: { - id: number - values: ReturnType - }) => void = () => {} - const collection = createCollection<{ - id: number - values: ReturnType - }>({ - getKey: (row) => row.id, - startSync: true, - sync: { - sync: ({ begin, write, commit, markReady }) => { +type PersistedWriteHistory = { kind: `Map` | `Set`; protocol: Protocol } + +async function runPersistedWrite({ kind, protocol }: PersistedWriteHistory) { + const make = () => + kind === `Map` ? new Map([[`a`, { x: 1 }]]) : new Set([{ x: 1 }]) + const expected = nativeWrittenValues(make(), protocol) + let confirm: (value: { + id: number + values: ReturnType + }) => void = () => {} + const collection = createCollection<{ + id: number + values: ReturnType + }>({ + getKey: (row) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ + type: `insert`, + value: { id: 1, values: make() }, + }) + commit() + markReady() + confirm = (value) => { begin() - write({ - type: `insert`, - value: { id: 1, values: make() }, - }) + write({ type: `update`, value }) commit() - markReady() - confirm = (value) => { - begin() - write({ type: `update`, value }) - commit() - } - }, - }, - // The handler confirms the update, so the stored row is its result. - onUpdate: ({ transaction }) => { - confirm(transaction.mutations[0].modified) - return Promise.resolve() + } }, - }) - try { - const tx = collection.update(1, (draft) => { - visit(draft.values, protocol, (value) => { - value.x = 2 - }) + }, + // The handler confirms the update, so the stored row is its result. + onUpdate: ({ transaction }) => { + confirm(transaction.mutations[0].modified) + return Promise.resolve() + }, + }) + try { + const tx = collection.update(1, (draft) => { + visit(draft.values, protocol, (value) => { + value.x = 2 }) - expectMembers( - tx.mutations[0]!.changes.values as ReturnType, - expected, - ) - expectMembers(collection.get(1)!.values, expected) - await tx.when(`settled`) - expectMembers(collection.get(1)!.values, expected) - } finally { - await collection.cleanup() - } + }) + expectMembers( + tx.mutations[0]!.changes.values as ReturnType, + expected, + ) + expectMembers(collection.get(1)!.values, expected) + await tx.when(`settled`) + expectMembers(collection.get(1)!.values, expected) + } finally { + await collection.cleanup() + } +} + +oracleProperty({ + id: `proxy.iteration-persisted-writes`, + law: { + statement: `A write through every member of a draft Map or Set under any iteration protocol matches the native written values in the change set, in the optimistic row, and after the handler persists it.`, + authority: iterationAuthority, + limits: `One-member Map and Set under each of the four iteration protocols; the handler confirms the modified row through sync.`, }, -) + owns: [`src/proxy.ts`], + histories: ([`Map`, `Set`] as const).flatMap((kind) => + protocols.map((protocol): PersistedWriteHistory => ({ kind, protocol })), + ), + label: ({ kind, protocol }) => + `${kind} ${protocol} writes match native values before and after persistence`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.iteration-persisted-writes`, + plan, + observe, + () => runPersistedWrite(plan.history), + ), + check: forwardAssertionJudgment, +}) From 127c9f632955741b10585d6c048f3e7e2d1e1566 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:47:21 -0700 Subject: [PATCH 025/116] test(db): register pooled live-query oracle tests through oracleProperty Replay the getter and Date regressions through a pooled-live-query.publication-fixed enumerated property, the gcTime matrix through pooled-live-query.release-timing, and the rejected-cleanup regression as a fixed history of pooled-live-query.cleanup-admission. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 12 ++ .../query/pooled-live-query-gc-oracle.test.ts | 63 ++++---- .../pooled-live-query-oracle.property.test.ts | 135 ++++++++++-------- 3 files changed, 125 insertions(+), 85 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index d3ebde2c1a..81ab135fd3 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -23,6 +23,8 @@ const otherProperties: ReadonlyArray = [ `load-subset.transaction-refinement`, `load-subset.source-readiness-refinement`, `where-predicate.publication-fixed`, + `pooled-live-query.publication-fixed`, + `pooled-live-query.release-timing`, ] const otherOwners: ReadonlyArray = [ [ @@ -40,6 +42,16 @@ const otherOwners: ReadonlyArray = [ `where-predicate`, `publication-fixed`, ], + [ + `db/tests/query/pooled-live-query-oracle.property.test.ts`, + `pooled-live-query`, + `publication-fixed`, + ], + [ + `db/tests/query/pooled-live-query-gc-oracle.test.ts`, + `pooled-live-query`, + `release-timing`, + ], ] export const properties: ReadonlyArray = [ 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 b3eee50814..c7c81fa41b 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 @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' @@ -7,14 +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 { oracleProperty, oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { declareOracle } from '../oracle-declaration.js' import { captureMicrotaskErrors, mockSyncCollectionOptions, @@ -89,15 +88,6 @@ async function expectPooledReleaseMatchesCollection( ) } -declareOracle({ - check: [ - expectPooledReleaseMatchesCollection, - checkCallbackHistory, - expectPooledCleanupMatchesOrdinary, - ], - model: [expectedCallbackHistory], -}) - /** * Callback failure does not transfer an unreturned subscription handle or * remove a healthy observer's right to terminal cleanup status. These are @@ -588,14 +578,36 @@ describe(`pooled live query gcTime`, () => { check: forwardAssertionJudgment, }) - 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) - }) - } - } + // Every release-timing cell: four gcTimes, with and without a subscriber. + oracleProperty({ + id: `pooled-live-query.release-timing`, + law: { + statement: `A pooled live query releases its source when the equivalent live-query Collection does, allowing the one tick the Collection's cleanup queue adds.`, + authority: `ARCHITECTURE.md, Pooled live queries, and the gcTime contract stated above.`, + limits: `Four gcTimes (1, 100, 0, and Infinity), each with and without a subscriber, on a synchronous source.`, + }, + 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', + ], + histories: [1, 100, 0, Number.POSITIVE_INFINITY].flatMap((gcTime) => + [true, false].map((subscribe) => ({ gcTime, subscribe })), + ), + label: ({ gcTime, subscribe }) => + `matches a live-query Collection for gcTime ${gcTime}${subscribe ? `` : ` without a subscriber`}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`pooled release timing`, plan, observe, () => + expectPooledReleaseMatchesCollection( + plan.cell.gcTime, + plan.cell.subscribe, + ), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -885,11 +897,6 @@ describe(`pooled live query cleanup admission`, () => { { kind: `cleanup`, view: 0, dependents: false }, ], } - it(`keeps a view whose cleanup rejected in its partition`, async () => { - oracleRoute(`fixed history`) - await expectPooledCleanupMatchesOrdinary(fixedHistory) - }) - oracleProperty({ id: property, law: { @@ -908,6 +915,12 @@ describe(`pooled live query cleanup admission`, () => { runs: 80, fixedSeeds: [2168], states: cleanupReachStates, + fixed: [ + { + name: `keeps a view whose cleanup rejected in its partition`, + history: fixedHistory, + }, + ], constructed: [ { name: `rejected view survives sibling cleanup and is retried`, 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 50bd9953f8..c49ba77f46 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 @@ -79,7 +79,7 @@ * and are outside this owner. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryObserver } from '../../src/live-query-observer.js' import { Query } from '../../src/query/builder/index.js' @@ -91,18 +91,13 @@ import { } from '../../src/query/index.js' import { createPooledLiveQuery } from '../../src/query/pooled-live-query.js' import { Func, PropRef, Value } from '../../src/query/ir.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { declareOracle } from '../oracle-declaration.js' import { flushPromises, mockSyncCollectionOptions, @@ -1108,61 +1103,45 @@ function expectSnapshotMatchesReference( ) } -declareOracle({ - check: [runHistory, expectSnapshotMatchesReference], - model: [expectedKeys, expectedRows], -}) - -describe(`pooled live query oracle`, () => { - // 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()) { - // 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++}`, - getKey: (row) => row.id, - initialData: [ - { id: `a`, profile: { code: 1 } }, - { - id: `b`, - profile: { - get code(): number { - throw new Error(`getter failed`) - }, - }, +// Generated fields are plain values; this covers a field whose getter +// throws, which both paths treat as a row that does not match. It compares +// the pooled snapshot with the live-query Collection through the shared +// `expectSnapshotMatchesReference` check. +async function runThrowingGetterHistory(): Promise { + const source = createCollection( + mockSyncCollectionOptions({ + id: `pooled-${serial++}`, + getKey: (row) => row.id, + initialData: [ + { id: `a`, profile: { code: 1 } }, + { + id: `b`, + profile: { + get code(): number { + throw new Error(`getter failed`) }, - ], - }), - ) - await source.stateWhenReady() - const query = (q: any) => - q.from({ r: source }).where(({ r }: any) => eq(r.profile.code, 1)) - const pooled = createLiveQueryObserver( - createPooledLiveQuery(query(new Query())), - { mode: `wholesale` }, - ) - const stop = pooled.subscribe(() => {}) - const reference = createLiveQueryCollection({ query, startSync: true }) - await reference.preload() - const snapshot = pooled.getSnapshot() - expectSnapshotMatchesReference(snapshot, reference) - stop() - await source.cleanup({ dependents: true }) - }) - - for (const { name, history } of fixedHistories) { - it(`matches the live-query Collection when ${name}`, () => { - oracleRoute(`case`) - return runHistory(history) - }) - } - } + }, + }, + ], + }), + ) + await source.stateWhenReady() + const query = (q: any) => + q.from({ r: source }).where(({ r }: any) => eq(r.profile.code, 1)) + const pooled = createLiveQueryObserver( + createPooledLiveQuery(query(new Query())), + { mode: `wholesale` }, + ) + const stop = pooled.subscribe(() => {}) + const reference = createLiveQueryCollection({ query, startSync: true }) + await reference.preload() + const snapshot = pooled.getSnapshot() + expectSnapshotMatchesReference(snapshot, reference) + stop() + await source.cleanup({ dependents: true }) +} +describe(`pooled live query oracle`, () => { oracleProperty({ id: property, law: { @@ -1215,4 +1194,40 @@ describe(`pooled live query oracle`, () => { ), check: forwardAssertionJudgment, }) + + // The named histories hold Date values and a row whose field getter + // throws, which the generated grammar of plain values cannot take back. Each + // replays through the shared driver under its old test name. + const namedHistories = new Map([ + [`an eq path getter throws`, undefined], + ...fixedHistories.map(({ name, history }): [string, History] => [ + name, + history, + ]), + ]) + oracleProperty({ + id: `pooled-live-query.publication-fixed`, + law: { + statement: `A pooled live-query observer publishes the same rows, order, values, status and granular changes as the equivalent live-query Collection across writes, mounts and cleanup.`, + authority: `ARCHITECTURE.md, Pooled live queries, and the equality, Collection cleanup and observer contracts stated above.`, + limits: `Named histories that the generated grammar of plain values cannot take back: a field whose eq path getter throws, and the fixed regressions.`, + }, + 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', + ], + histories: [...namedHistories.keys()], + label: (name) => `matches the live-query Collection when ${name}`, + resolve: (name) => ({ history: namedHistories.get(name) }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(property, plan, observe, () => + plan.history === undefined + ? runThrowingGetterHistory() + : runHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) }) From f916b3984a4326f1f38c6e3030fa5bff0928119e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:48:08 -0700 Subject: [PATCH 026/116] test(db): register proxy native-method fixed rows through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 8 ++ ...oxy-native-methods-oracle.property.test.ts | 100 +++++++++++++----- 2 files changed, 84 insertions(+), 24 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 62c32763d7..a46009ece3 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -18,6 +18,9 @@ export const properties: ReadonlyArray = [ `proxy.iteration-nested-membership`, `proxy.iteration-set-clear-readd`, `proxy.iteration-persisted-writes`, + `proxy.native-array-fixed-row`, + `proxy.native-typed-fixed-row`, + `proxy.native-typed-signed-zero`, ] export const owners: ReadonlyArray = [ @@ -35,4 +38,9 @@ export const owners: ReadonlyArray = [ `proxy`, `iteration-array-edits iteration-insertion-values iteration-nested-membership iteration-set-clear-readd iteration-persisted-writes`, ], + [ + `db/tests/proxy-native-methods-oracle.property.test.ts`, + `proxy`, + `native-array-fixed-row native-typed-fixed-row native-typed-signed-zero`, + ], ] 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 021eaf4b74..28929c13b7 100644 --- a/packages/db/tests/proxy-native-methods-oracle.property.test.ts +++ b/packages/db/tests/proxy-native-methods-oracle.property.test.ts @@ -1,14 +1,12 @@ -import { describe, expect, it as vitestIt } from 'vitest' +import { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { withChangeTracking } from '../src/proxy' +import { oracleProperty, pass, violation } from './oracle-config.js' import { - oracleProperty, - oracleReplayTest, - oracleRoute, - pass, - violation, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' /** * # Do built-in array and typed-array methods behave on a draft as natively? @@ -76,15 +74,11 @@ import { import type { ArrayRow, TypedRow } from './proxy-native-methods-fixture.js' import type { Verdict } from './oracle-config.js' -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. +// observeTyped record a call on either side. expectArrayCall and +// expectTypedCall are the shared native-differential checks of the fixed rows. // 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({ - check: [expectArrayCall, expectTypedCall], -}) // Detached native containers use the fixture's existing encodings. Only the // emptiness of the changes object is judged, so enumerable keys retain that @@ -115,10 +109,29 @@ function assertionVerdict(checkpoint: string, compare: () => void): Verdict { describe(`array methods behave like native arrays`, () => { // 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) + oracleProperty({ + id: `proxy.native-array-fixed-row`, + law: { + statement: `Each draft array method on the original fixed row returns the native result, preserves original-object identity relations, and detaches the native final field value.`, + authority: `src/proxy.ts draft contract and tests/proxy-oracle.test.ts native differential laws`, + limits: `Every Array.prototype method on one fixed row of objects, a number and a nested array, at indices 0, 1 and 3.`, + }, + owns: [`src/proxy.ts`], + histories: arrayMethods, + label: (m) => `${m} on a fixed row gives the native result`, + resolve: (m) => ({ m }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.native-array-fixed-row`, + plan, + observe, + () => { + for (const k of [0, 1, 3]) + expectArrayCall([{ x: 1 }, 2, [{ x: 3 }], { x: 0 }], plan.m, k) + }, + ), + check: forwardAssertionJudgment, }) oracleProperty({ @@ -190,16 +203,55 @@ describe(`array methods behave like native arrays`, () => { }) describe(`typed-array methods behave like native typed arrays`, () => { // 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) + oracleProperty({ + id: `proxy.native-typed-fixed-row`, + law: { + statement: `Each draft typed-array method on the original fixed row returns the native result and self relation, and detaches the native final field value with and without a write through the result.`, + authority: `src/proxy.ts draft contract and tests/proxy-oracle.test.ts native differential laws`, + limits: `Every TypedArray.prototype method on one fixed Float64Array row [3, 1, 2] at index 1.`, + }, + owns: [`src/proxy.ts`], + histories: typedMethods, + label: (m) => `${m} on a fixed row gives the native result`, + resolve: (m) => ({ m }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.native-typed-fixed-row`, + plan, + observe, + () => { + for (const write of [false, true]) + expectTypedCall([3, 1, 2], plan.m, 1, write) + }, + ), + check: forwardAssertionJudgment, }) // 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) + oracleProperty({ + id: `proxy.native-typed-signed-zero`, + law: { + statement: `Reversing a draft typed array of signed zeros preserves their native positions, including the detached change-set value.`, + authority: `src/proxy.ts draft contract and tests/proxy-oracle.test.ts native differential laws`, + limits: `One fixed Float64Array row [-0, 0] reversed without a write.`, + }, + owns: [`src/proxy.ts`], + histories: [`reversing signed zeros preserves their native positions`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.native-typed-signed-zero`, + plan, + observe, + () => { + expectTypedCall([-0, 0], `reverse`, 0, false) + }, + ), + check: forwardAssertionJudgment, }) oracleProperty({ From d5c605e975a1469ce6a8a80dad11d4430d8c6eea Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:48:38 -0700 Subject: [PATCH 027/116] test(sqlite): move the expression-index grammar calibrations out of the oracle file P6: the grammar calibrations sample the grammar and check the grammar check, with no production, so they move unchanged to expression-index-grammar.test.ts. The grammar and its check move unchanged to the test-free companion expression-index-grammar-oracle.ts, which both files import. Co-authored-by: Isaac --- .../tests/expression-index-grammar-oracle.ts | 334 ++++++++++++++ .../tests/expression-index-grammar.test.ts | 78 ++++ .../tests/expression-index-oracle.test.ts | 410 +----------------- 3 files changed, 429 insertions(+), 393 deletions(-) create mode 100644 packages/node-db-sqlite-persistence/tests/expression-index-grammar-oracle.ts create mode 100644 packages/node-db-sqlite-persistence/tests/expression-index-grammar.test.ts diff --git a/packages/node-db-sqlite-persistence/tests/expression-index-grammar-oracle.ts b/packages/node-db-sqlite-persistence/tests/expression-index-grammar-oracle.ts new file mode 100644 index 0000000000..ca1d368496 --- /dev/null +++ b/packages/node-db-sqlite-persistence/tests/expression-index-grammar-oracle.ts @@ -0,0 +1,334 @@ +/** + * The generated scenario-matrix grammar of the SQLite expression-index oracle + * in expression-index-oracle.test.ts: the seven query axes, their plausible + * wrong answers, the matrix arbitrary and its resolution into scenarios, and + * the grammar check that every matrix holds each axis exactly once. Owns + * nothing: the oracle test drives production with it, and + * expression-index-grammar.test.ts calibrates the grammar check. + */ +import fc from 'fast-check' +import { IR } from '@tanstack/db' +import { createNestedRow } from './expression-index-support' +import type { ExpressionIndexScenario } from './expression-index-support' + +export const DEFAULT_ORACLE_SEED = 1_659_005 + +const identifierStart = [`a`, `m`, `p`, `t`, `x`] as const +const identifierRest = [`a`, `b`, `e`, `i`, `n`, `r`, `s`, `0`, `1`] as const + +const identifierArbitrary = fc + .tuple( + fc.constantFrom(...identifierStart), + fc.array(fc.constantFrom(...identifierRest), { maxLength: 5 }), + ) + .map(([start, rest]) => `${start}${rest.join(``)}`) + +const legalPathArbitrary = fc + .tuple( + identifierArbitrary, + fc.array( + fc.oneof(identifierArbitrary, fc.integer({ min: 0, max: 3 }).map(String)), + { maxLength: 3 }, + ), + ) + .map(([root, tail]) => [root, ...tail]) + +export const GENERATED_SCENARIO_KINDS = [ + `eq`, + `in`, + `batched-in`, + `range`, + `and`, + `order-by`, + `wrapped-lower`, +] as const + +export type GeneratedScenarioKind = (typeof GENERATED_SCENARIO_KINDS)[number] + +export const GENERATED_SCENARIO_FAULTS = [ + { kind: `eq`, wrongAnswer: `inverted equality operator` }, + { kind: `in`, wrongAnswer: `shifted first IN binding` }, + { kind: `batched-in`, wrongAnswer: `replaced final batch member` }, + { kind: `range`, wrongAnswer: `exclusive lower boundary` }, + { kind: `and`, wrongAnswer: `wrong conjunct binding` }, + { kind: `order-by`, wrongAnswer: `reversed ordering direction` }, + { kind: `wrapped-lower`, wrongAnswer: `wrong expression wrapper` }, +] as const satisfies ReadonlyArray<{ + kind: GeneratedScenarioKind + wrongAnswer: string +}> + +export type GeneratedExpressionIndexScenario = ExpressionIndexScenario & { + kind: GeneratedScenarioKind + expectedKeys: Array + expectedPlan: `search` | `ordered-scan` +} + +const numericIndexCaseArbitrary = fc + .tuple(legalPathArbitrary, fc.integer({ min: -10_000, max: 10_000 })) + .map(([path, target]) => ({ path, target })) + +const stringIndexCaseArbitrary = fc + .tuple(legalPathArbitrary, fc.integer({ min: 0, max: 10_000 })) + .map(([path, suffix]) => ({ path, target: `target-'${suffix}` })) + +export const generatedScenarioMatrixArbitrary = fc.tuple( + stringIndexCaseArbitrary, + numericIndexCaseArbitrary, + numericIndexCaseArbitrary, + numericIndexCaseArbitrary, + stringIndexCaseArbitrary, + numericIndexCaseArbitrary, + stringIndexCaseArbitrary, +) + +type GeneratedMatrixHistory = + typeof generatedScenarioMatrixArbitrary extends fc.Arbitrary + ? H + : never + +export function resolveGeneratedScenarioMatrix([ + eqCase, + inCase, + batchedCase, + rangeCase, + andCase, + orderCase, + lowerCase, +]: GeneratedMatrixHistory) { + const eqRows = [ + { + key: `eq-match-a`, + value: createNestedRow(eqCase.path, eqCase.target), + }, + { + key: `eq-different`, + value: createNestedRow(eqCase.path, `${eqCase.target}-other`), + }, + { + key: `eq-match-b`, + value: createNestedRow(eqCase.path, eqCase.target), + }, + ] + const batchedValues = Array.from({ length: 901 }, (_unused, index) => + BigInt(batchedCase.target + index), + ) + const lowerTarget = lowerCase.target.toLowerCase() + + return [ + { + kind: `eq`, + label: `generated-eq`, + indexExpression: new IR.PropRef(eqCase.path), + where: new IR.Func(`eq`, [ + new IR.PropRef(eqCase.path), + new IR.Value(eqCase.target), + ]), + rows: eqRows, + expectedKeys: [`eq-match-a`, `eq-match-b`], + expectedPlan: `search`, + }, + { + kind: `in`, + label: `generated-in`, + indexExpression: new IR.PropRef(inCase.path), + where: new IR.Func(`in`, [ + new IR.PropRef(inCase.path), + new IR.Value([BigInt(inCase.target - 1), BigInt(inCase.target)]), + ]), + rows: [ + { + key: `in-lower`, + value: createNestedRow(inCase.path, BigInt(inCase.target - 1)), + }, + { + key: `in-match`, + value: createNestedRow(inCase.path, BigInt(inCase.target)), + }, + { + key: `in-higher`, + value: createNestedRow(inCase.path, BigInt(inCase.target + 1)), + }, + ], + expectedKeys: [`in-lower`, `in-match`], + expectedPlan: `search`, + }, + { + kind: `batched-in`, + label: `generated-batched-in`, + indexExpression: new IR.PropRef(batchedCase.path), + where: new IR.Func(`in`, [ + new IR.PropRef(batchedCase.path), + new IR.Value(batchedValues), + ]), + rows: [ + { + key: `batch-first`, + value: createNestedRow(batchedCase.path, BigInt(batchedCase.target)), + }, + { + key: `batch-last`, + value: createNestedRow( + batchedCase.path, + BigInt(batchedCase.target + 900), + ), + }, + { + key: `batch-outside`, + value: createNestedRow( + batchedCase.path, + BigInt(batchedCase.target - 1), + ), + }, + ], + expectedKeys: [`batch-first`, `batch-last`], + expectedPlan: `search`, + }, + { + kind: `range`, + label: `generated-range`, + indexExpression: new IR.PropRef(rangeCase.path), + where: new IR.Func(`gte`, [ + new IR.PropRef(rangeCase.path), + new IR.Value(rangeCase.target), + ]), + rows: [ + { + key: `range-lower`, + value: createNestedRow(rangeCase.path, rangeCase.target - 1), + }, + { + key: `range-match`, + value: createNestedRow(rangeCase.path, rangeCase.target), + }, + { + key: `range-higher`, + value: createNestedRow(rangeCase.path, rangeCase.target + 1), + }, + ], + expectedKeys: [`range-higher`, `range-match`], + expectedPlan: `search`, + }, + { + kind: `and`, + label: `generated-and`, + indexExpression: new IR.PropRef(andCase.path), + where: new IR.Func(`and`, [ + new IR.Func(`eq`, [ + new IR.PropRef(andCase.path), + new IR.Value(andCase.target), + ]), + new IR.Func(`eq`, [new IR.PropRef([`status`]), new IR.Value(`active`)]), + ]), + rows: [ + { + key: `and-match`, + value: { + ...createNestedRow(andCase.path, andCase.target), + status: `active`, + }, + }, + { + key: `and-inactive`, + value: { + ...createNestedRow(andCase.path, andCase.target), + status: `inactive`, + }, + }, + { + key: `and-different`, + value: { + ...createNestedRow(andCase.path, `${andCase.target}-other`), + status: `active`, + }, + }, + ], + expectedKeys: [`and-match`], + expectedPlan: `search`, + }, + { + kind: `order-by`, + label: `generated-order-by`, + indexExpression: new IR.PropRef(orderCase.path), + orderBy: [ + { + expression: new IR.PropRef(orderCase.path), + compareOptions: { + direction: `asc`, + nulls: `last`, + }, + }, + ], + preserveResultOrder: true, + rows: [ + { + key: `order-high`, + value: createNestedRow(orderCase.path, orderCase.target + 1), + }, + { + key: `order-low`, + value: createNestedRow(orderCase.path, orderCase.target - 1), + }, + { + key: `order-middle`, + value: createNestedRow(orderCase.path, orderCase.target), + }, + ], + expectedKeys: [`order-low`, `order-middle`, `order-high`], + expectedPlan: `ordered-scan`, + }, + { + kind: `wrapped-lower`, + label: `generated-wrapped-lower`, + indexExpression: new IR.Func(`lower`, [new IR.PropRef(lowerCase.path)]), + where: new IR.Func(`eq`, [ + new IR.Func(`lower`, [new IR.PropRef(lowerCase.path)]), + new IR.Value(lowerTarget), + ]), + rows: [ + { + key: `lower-upper`, + value: createNestedRow( + lowerCase.path, + lowerCase.target.toUpperCase(), + ), + }, + { + key: `lower-lower`, + value: createNestedRow(lowerCase.path, lowerTarget), + }, + { + key: `lower-other`, + value: createNestedRow(lowerCase.path, `${lowerTarget}-other`), + }, + ], + expectedKeys: [`lower-lower`, `lower-upper`], + expectedPlan: `search`, + }, + ] satisfies Array +} + +export function assertGeneratedScenarioGrammar( + scenarios: ReadonlyArray, +): void { + const actualKinds = scenarios.map(({ kind }) => kind) + const missingKinds = GENERATED_SCENARIO_KINDS.filter( + (kind) => !actualKinds.includes(kind), + ) + const unexpectedKinds = actualKinds.filter( + (kind) => !GENERATED_SCENARIO_KINDS.includes(kind), + ) + const duplicateKinds = actualKinds.filter( + (kind, index) => actualKinds.indexOf(kind) !== index, + ) + + if ( + missingKinds.length > 0 || + unexpectedKinds.length > 0 || + duplicateKinds.length > 0 + ) { + throw new Error( + `generated scenario grammar mismatch: missing=${missingKinds.join(`,`) || `none`}; unexpected=${unexpectedKinds.join(`,`) || `none`}; duplicate=${duplicateKinds.join(`,`) || `none`}`, + ) + } +} diff --git a/packages/node-db-sqlite-persistence/tests/expression-index-grammar.test.ts b/packages/node-db-sqlite-persistence/tests/expression-index-grammar.test.ts new file mode 100644 index 0000000000..6815011044 --- /dev/null +++ b/packages/node-db-sqlite-persistence/tests/expression-index-grammar.test.ts @@ -0,0 +1,78 @@ +/** + * Calibrations of the generated scenario-matrix grammar check in + * expression-index-grammar-oracle.ts. They sample the grammar at the oracle's + * fixed seed and check the grammar check itself; they run no production, so + * they are ordinary tests. The oracle that drives production with this grammar + * is expression-index-oracle.test.ts. + */ +import fc from 'fast-check' +import { describe, expect, it as vitestIt } from 'vitest' +import { + oracleFixedSeed, + oracleReplayTest, +} from '../../db/tests/oracle-config.js' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { + DEFAULT_ORACLE_SEED, + GENERATED_SCENARIO_FAULTS, + GENERATED_SCENARIO_KINDS, + assertGeneratedScenarioGrammar, + generatedScenarioMatrixArbitrary, + resolveGeneratedScenarioMatrix, +} from './expression-index-grammar-oracle' + +describe(`SQLite expression-index oracle`, () => { + const generatedControlTest = oracleReplayTest(vitestIt) + + generatedControlTest( + `reconstructs the retained generated grammar and rejects a nearby duplicate axis`, + () => { + const [matrix] = fc.sample( + generatedScenarioMatrixArbitrary.map(resolveGeneratedScenarioMatrix), + oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1 }), + ) + if (!matrix) throw new Error(`fixed grammar witness was not generated`) + assertGeneratedScenarioGrammar(matrix) + oracleAssertion( + `expect(GENERATED_SCENARIO_FAULTS.map(({ kind }) => kind)).toEqual( GENERATED_SCENARIO_KINDS, )`, + () => + expect(GENERATED_SCENARIO_FAULTS.map(({ kind }) => kind)).toEqual( + GENERATED_SCENARIO_KINDS, + ), + ) + const equalityScenario = matrix[0] + if (!equalityScenario) { + throw new Error(`fixed grammar witness omitted equality`) + } + oracleAssertion( + `expect(() => assertGeneratedScenarioGrammar([...matrix, equalityScenario]), ).toThrow('duplicate=eq'`, + () => + expect(() => + assertGeneratedScenarioGrammar([...matrix, equalityScenario]), + ).toThrow(`duplicate=eq`), + ) + }, + ) + + // Grammar calibration: ablating any one axis from the fixed matrix fails + // the grammar check that every native history runs, naming that axis. + generatedControlTest.each(GENERATED_SCENARIO_KINDS)( + `rejects the generated grammar when the %s axis is ablated`, + (kind) => { + const [matrix] = fc.sample( + generatedScenarioMatrixArbitrary.map(resolveGeneratedScenarioMatrix), + oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1 }), + ) + if (!matrix) throw new Error(`fixed grammar witness was not generated`) + oracleAssertion( + `expect(() => assertGeneratedScenarioGrammar(ablated)).toThrow('missing=${kind}')`, + () => + expect(() => + assertGeneratedScenarioGrammar( + matrix.filter((scenario) => scenario.kind !== kind), + ), + ).toThrow(`missing=${kind}`), + ) + }, + ) +}) 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 863906aede..1d649d212a 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 @@ -57,7 +57,9 @@ * random campaigns. A matrix that lacks an axis fails every history's grammar * check (assertGeneratedScenarioGrammar, run by the driver); a per-axis * grammar calibration ablates each axis from the fixed matrix and requires - * that check to name it. Each axis's plausible wrong SQL answer is a native + * that check to name it. The grammar and its check live in + * expression-index-grammar-oracle.ts, and their calibrations, which run no + * production, in expression-index-grammar.test.ts. Each axis's plausible wrong SQL answer is a native * calibration that the unchanged comparison must reject at that axis: the * check names the compared axis in the law (`sqlite-persistence.expression-index: * axis`). Seed, run and path configuration, and the shrinking @@ -79,26 +81,20 @@ * ablation stays as a grammar calibration without fast-check campaigns. */ import fc from 'fast-check' -import { describe, expect, vi, it as vitestIt } from 'vitest' +import { describe, expect, vi } from 'vitest' import { IR } from '@tanstack/db' import { createPersistedTableName, createSQLiteCorePersistenceAdapter, decodePersistedStorageKey, } from '@tanstack/db-sqlite-persistence-core' -import { - oracleFixedSeed, - oracleProperty, - oracleReplayTest, - oracleRoute, -} from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { Temporal, temporalFamilies, @@ -121,19 +117,29 @@ import { sqliteScalarParameter, withFailurePreservingCleanup, } from './expression-index-support' +import { + DEFAULT_ORACLE_SEED, + GENERATED_SCENARIO_FAULTS, + GENERATED_SCENARIO_KINDS, + assertGeneratedScenarioGrammar, + generatedScenarioMatrixArbitrary, + resolveGeneratedScenarioMatrix, +} from './expression-index-grammar-oracle' +import type { + GeneratedExpressionIndexScenario, + GeneratedScenarioKind, +} from './expression-index-grammar-oracle' import type { Observation, Verdict } from '../../db/tests/oracle-config.js' import type { TemporalFamily } from '../../db-sqlite-persistence-core/tests/temporal-value-oracle' import type { CapturedQuery, ExpressionIndexObservation, - ExpressionIndexScenario, OracleScalar, QueryPlanRow, QueryTransform, ScanRow, } from './expression-index-support' -const DEFAULT_ORACLE_SEED = 1_659_005 const DEFAULT_ORACLE_RUNS = 8 type OracleCase = { @@ -142,26 +148,6 @@ type OracleCase = { distractors: [OracleScalar, OracleScalar] } -const identifierStart = [`a`, `m`, `p`, `t`, `x`] as const -const identifierRest = [`a`, `b`, `e`, `i`, `n`, `r`, `s`, `0`, `1`] as const - -const identifierArbitrary = fc - .tuple( - fc.constantFrom(...identifierStart), - fc.array(fc.constantFrom(...identifierRest), { maxLength: 5 }), - ) - .map(([start, rest]) => `${start}${rest.join(``)}`) - -const legalPathArbitrary = fc - .tuple( - identifierArbitrary, - fc.array( - fc.oneof(identifierArbitrary, fc.integer({ min: 0, max: 3 }).map(String)), - { maxLength: 3 }, - ), - ) - .map(([root, tail]) => [root, ...tail]) - function expectedKeysFromScan( rows: ReadonlyArray, path: ReadonlyArray, @@ -173,306 +159,6 @@ function expectedKeysFromScan( .sort() } -const GENERATED_SCENARIO_KINDS = [ - `eq`, - `in`, - `batched-in`, - `range`, - `and`, - `order-by`, - `wrapped-lower`, -] as const - -type GeneratedScenarioKind = (typeof GENERATED_SCENARIO_KINDS)[number] - -const GENERATED_SCENARIO_FAULTS = [ - { kind: `eq`, wrongAnswer: `inverted equality operator` }, - { kind: `in`, wrongAnswer: `shifted first IN binding` }, - { kind: `batched-in`, wrongAnswer: `replaced final batch member` }, - { kind: `range`, wrongAnswer: `exclusive lower boundary` }, - { kind: `and`, wrongAnswer: `wrong conjunct binding` }, - { kind: `order-by`, wrongAnswer: `reversed ordering direction` }, - { kind: `wrapped-lower`, wrongAnswer: `wrong expression wrapper` }, -] as const satisfies ReadonlyArray<{ - kind: GeneratedScenarioKind - wrongAnswer: string -}> - -type GeneratedExpressionIndexScenario = ExpressionIndexScenario & { - kind: GeneratedScenarioKind - expectedKeys: Array - expectedPlan: `search` | `ordered-scan` -} - -const numericIndexCaseArbitrary = fc - .tuple(legalPathArbitrary, fc.integer({ min: -10_000, max: 10_000 })) - .map(([path, target]) => ({ path, target })) - -const stringIndexCaseArbitrary = fc - .tuple(legalPathArbitrary, fc.integer({ min: 0, max: 10_000 })) - .map(([path, suffix]) => ({ path, target: `target-'${suffix}` })) - -const generatedScenarioMatrixArbitrary = fc.tuple( - stringIndexCaseArbitrary, - numericIndexCaseArbitrary, - numericIndexCaseArbitrary, - numericIndexCaseArbitrary, - stringIndexCaseArbitrary, - numericIndexCaseArbitrary, - stringIndexCaseArbitrary, -) - -type GeneratedMatrixHistory = - typeof generatedScenarioMatrixArbitrary extends fc.Arbitrary - ? H - : never - -function resolveGeneratedScenarioMatrix([ - eqCase, - inCase, - batchedCase, - rangeCase, - andCase, - orderCase, - lowerCase, -]: GeneratedMatrixHistory) { - const eqRows = [ - { - key: `eq-match-a`, - value: createNestedRow(eqCase.path, eqCase.target), - }, - { - key: `eq-different`, - value: createNestedRow(eqCase.path, `${eqCase.target}-other`), - }, - { - key: `eq-match-b`, - value: createNestedRow(eqCase.path, eqCase.target), - }, - ] - const batchedValues = Array.from({ length: 901 }, (_unused, index) => - BigInt(batchedCase.target + index), - ) - const lowerTarget = lowerCase.target.toLowerCase() - - return [ - { - kind: `eq`, - label: `generated-eq`, - indexExpression: new IR.PropRef(eqCase.path), - where: new IR.Func(`eq`, [ - new IR.PropRef(eqCase.path), - new IR.Value(eqCase.target), - ]), - rows: eqRows, - expectedKeys: [`eq-match-a`, `eq-match-b`], - expectedPlan: `search`, - }, - { - kind: `in`, - label: `generated-in`, - indexExpression: new IR.PropRef(inCase.path), - where: new IR.Func(`in`, [ - new IR.PropRef(inCase.path), - new IR.Value([BigInt(inCase.target - 1), BigInt(inCase.target)]), - ]), - rows: [ - { - key: `in-lower`, - value: createNestedRow(inCase.path, BigInt(inCase.target - 1)), - }, - { - key: `in-match`, - value: createNestedRow(inCase.path, BigInt(inCase.target)), - }, - { - key: `in-higher`, - value: createNestedRow(inCase.path, BigInt(inCase.target + 1)), - }, - ], - expectedKeys: [`in-lower`, `in-match`], - expectedPlan: `search`, - }, - { - kind: `batched-in`, - label: `generated-batched-in`, - indexExpression: new IR.PropRef(batchedCase.path), - where: new IR.Func(`in`, [ - new IR.PropRef(batchedCase.path), - new IR.Value(batchedValues), - ]), - rows: [ - { - key: `batch-first`, - value: createNestedRow(batchedCase.path, BigInt(batchedCase.target)), - }, - { - key: `batch-last`, - value: createNestedRow( - batchedCase.path, - BigInt(batchedCase.target + 900), - ), - }, - { - key: `batch-outside`, - value: createNestedRow( - batchedCase.path, - BigInt(batchedCase.target - 1), - ), - }, - ], - expectedKeys: [`batch-first`, `batch-last`], - expectedPlan: `search`, - }, - { - kind: `range`, - label: `generated-range`, - indexExpression: new IR.PropRef(rangeCase.path), - where: new IR.Func(`gte`, [ - new IR.PropRef(rangeCase.path), - new IR.Value(rangeCase.target), - ]), - rows: [ - { - key: `range-lower`, - value: createNestedRow(rangeCase.path, rangeCase.target - 1), - }, - { - key: `range-match`, - value: createNestedRow(rangeCase.path, rangeCase.target), - }, - { - key: `range-higher`, - value: createNestedRow(rangeCase.path, rangeCase.target + 1), - }, - ], - expectedKeys: [`range-higher`, `range-match`], - expectedPlan: `search`, - }, - { - kind: `and`, - label: `generated-and`, - indexExpression: new IR.PropRef(andCase.path), - where: new IR.Func(`and`, [ - new IR.Func(`eq`, [ - new IR.PropRef(andCase.path), - new IR.Value(andCase.target), - ]), - new IR.Func(`eq`, [new IR.PropRef([`status`]), new IR.Value(`active`)]), - ]), - rows: [ - { - key: `and-match`, - value: { - ...createNestedRow(andCase.path, andCase.target), - status: `active`, - }, - }, - { - key: `and-inactive`, - value: { - ...createNestedRow(andCase.path, andCase.target), - status: `inactive`, - }, - }, - { - key: `and-different`, - value: { - ...createNestedRow(andCase.path, `${andCase.target}-other`), - status: `active`, - }, - }, - ], - expectedKeys: [`and-match`], - expectedPlan: `search`, - }, - { - kind: `order-by`, - label: `generated-order-by`, - indexExpression: new IR.PropRef(orderCase.path), - orderBy: [ - { - expression: new IR.PropRef(orderCase.path), - compareOptions: { - direction: `asc`, - nulls: `last`, - }, - }, - ], - preserveResultOrder: true, - rows: [ - { - key: `order-high`, - value: createNestedRow(orderCase.path, orderCase.target + 1), - }, - { - key: `order-low`, - value: createNestedRow(orderCase.path, orderCase.target - 1), - }, - { - key: `order-middle`, - value: createNestedRow(orderCase.path, orderCase.target), - }, - ], - expectedKeys: [`order-low`, `order-middle`, `order-high`], - expectedPlan: `ordered-scan`, - }, - { - kind: `wrapped-lower`, - label: `generated-wrapped-lower`, - indexExpression: new IR.Func(`lower`, [new IR.PropRef(lowerCase.path)]), - where: new IR.Func(`eq`, [ - new IR.Func(`lower`, [new IR.PropRef(lowerCase.path)]), - new IR.Value(lowerTarget), - ]), - rows: [ - { - key: `lower-upper`, - value: createNestedRow( - lowerCase.path, - lowerCase.target.toUpperCase(), - ), - }, - { - key: `lower-lower`, - value: createNestedRow(lowerCase.path, lowerTarget), - }, - { - key: `lower-other`, - value: createNestedRow(lowerCase.path, `${lowerTarget}-other`), - }, - ], - expectedKeys: [`lower-lower`, `lower-upper`], - expectedPlan: `search`, - }, - ] satisfies Array -} - -function assertGeneratedScenarioGrammar( - scenarios: ReadonlyArray, -): void { - const actualKinds = scenarios.map(({ kind }) => kind) - const missingKinds = GENERATED_SCENARIO_KINDS.filter( - (kind) => !actualKinds.includes(kind), - ) - const unexpectedKinds = actualKinds.filter( - (kind) => !GENERATED_SCENARIO_KINDS.includes(kind), - ) - const duplicateKinds = actualKinds.filter( - (kind, index) => actualKinds.indexOf(kind) !== index, - ) - - if ( - missingKinds.length > 0 || - unexpectedKinds.length > 0 || - duplicateKinds.length > 0 - ) { - throw new Error( - `generated scenario grammar mismatch: missing=${missingKinds.join(`,`) || `none`}; unexpected=${unexpectedKinds.join(`,`) || `none`}; duplicate=${duplicateKinds.join(`,`) || `none`}`, - ) - } -} - function mutateGeneratedScenarioQuery( kind: GeneratedScenarioKind, ): QueryTransform { @@ -901,12 +587,6 @@ function expectedNativePolarityKeys( .sort() } -declareOracle({ - check: [assertExpressionIndexHistory], - model: [expectedKeysFromScan, expectedRangeKeys, expectedNativePolarityKeys], - grammar: [generatedScenarioMatrixArbitrary], -}) - // A generated-matrix comparison names its scenario's axis in its checkpoint // (the diagnostic JSON's `kind`). The check carries that axis into the law, // so a violation, and each wrong-answer calibration, names the faulted axis. @@ -1250,62 +930,6 @@ describe(`SQLite expression-index oracle`, () => { ), })), }) - - const generatedControlTest = oracleReplayTest(vitestIt) - - generatedControlTest( - `reconstructs the retained generated grammar and rejects a nearby duplicate axis`, - () => { - oracleRoute(`calibration`) - const [matrix] = fc.sample( - generatedScenarioMatrixArbitrary.map(resolveGeneratedScenarioMatrix), - oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1 }), - ) - if (!matrix) throw new Error(`fixed grammar witness was not generated`) - assertGeneratedScenarioGrammar(matrix) - oracleAssertion( - `expect(GENERATED_SCENARIO_FAULTS.map(({ kind }) => kind)).toEqual( GENERATED_SCENARIO_KINDS, )`, - () => - expect(GENERATED_SCENARIO_FAULTS.map(({ kind }) => kind)).toEqual( - GENERATED_SCENARIO_KINDS, - ), - ) - const equalityScenario = matrix[0] - if (!equalityScenario) { - throw new Error(`fixed grammar witness omitted equality`) - } - oracleAssertion( - `expect(() => assertGeneratedScenarioGrammar([...matrix, equalityScenario]), ).toThrow('duplicate=eq'`, - () => - expect(() => - assertGeneratedScenarioGrammar([...matrix, equalityScenario]), - ).toThrow(`duplicate=eq`), - ) - }, - ) - - // Grammar calibration: ablating any one axis from the fixed matrix fails - // the grammar check that every native history runs, naming that axis. - generatedControlTest.each(GENERATED_SCENARIO_KINDS)( - `rejects the generated grammar when the %s axis is ablated`, - (kind) => { - oracleRoute(`calibration`) - const [matrix] = fc.sample( - generatedScenarioMatrixArbitrary.map(resolveGeneratedScenarioMatrix), - oracleFixedSeed(DEFAULT_ORACLE_SEED, { numRuns: 1 }), - ) - if (!matrix) throw new Error(`fixed grammar witness was not generated`) - oracleAssertion( - `expect(() => assertGeneratedScenarioGrammar(ablated)).toThrow('missing=${kind}')`, - () => - expect(() => - assertGeneratedScenarioGrammar( - matrix.filter((scenario) => scenario.kind !== kind), - ), - ).toThrow(`missing=${kind}`), - ) - }, - ) }) // Native values add a bounded rank/text dimension to this SQL/plan owner. From ba0e582ba78f60c0fcbd58a2b4aea16d2050e6ee Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:49:25 -0700 Subject: [PATCH 028/116] test(db): register proxy native-row cases through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 9 + packages/db/tests/proxy-oracle.test.ts | 195 ++++++++++++------ 2 files changed, 137 insertions(+), 67 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index a46009ece3..f8813f244c 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -21,6 +21,10 @@ export const properties: ReadonlyArray = [ `proxy.native-array-fixed-row`, `proxy.native-typed-fixed-row`, `proxy.native-typed-signed-zero`, + `proxy.access-path-writes`, + `proxy.stored-function-probes`, + `proxy.define-property`, + `proxy.frozen-nested-writes`, ] export const owners: ReadonlyArray = [ @@ -43,4 +47,9 @@ export const owners: ReadonlyArray = [ `proxy`, `native-array-fixed-row native-typed-fixed-row native-typed-signed-zero`, ], + [ + `db/tests/proxy-oracle.test.ts`, + `proxy`, + `access-path-writes stored-function-probes define-property frozen-nested-writes`, + ], ] diff --git a/packages/db/tests/proxy-oracle.test.ts b/packages/db/tests/proxy-oracle.test.ts index 0613ec0340..28ab6d2129 100644 --- a/packages/db/tests/proxy-oracle.test.ts +++ b/packages/db/tests/proxy-oracle.test.ts @@ -1,16 +1,19 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../src/collection/index' import { createChangeProxy, withChangeTracking } from '../src/proxy' import { makeFrozenRow, makeStoredRow } from './proxy-native-rows' -import { oracleRoute } from './oracle-config' import { asProductionError, errorViolation, isErrorObservation, } from './oracle-error-observation' -import { declareOracle } from './oracle-declaration' import { differs, oracleProperty, pass, violation } from './oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter' import type { Observation, Verdict } from './oracle-property' import type { FrozenRow, StoredRow } from './proxy-native-rows' /** @@ -263,11 +266,6 @@ function writeThroughAccessPath( return row } -declareOracle({ - check: [judgeCallbacks], - model: [runArrayCallback, writeThroughAccessPath, modelArrayCallbacks], -}) - // -0 joins the values and deltas, so `-0 += 0` writes 0: a change of a // zero's sign alone, which a draft must record like any other write. The // campaign and its random histories are oracleProperty below. @@ -424,55 +422,70 @@ oracleProperty>({ ], }) +async function runAccessPathWrites(path: (typeof accessPaths)[number]) { + for (const delta of [0, 3, -2]) { + const make = () => ({ id: 1, items: [{ value: 1 }, { value: 2 }] }) + const run = (row: ReturnType) => + writeThroughAccessPath(row, path, delta) + const expected = writeThroughAccessPath(make(), path, delta) + const original = make() + const changes = withChangeTracking(original, run) + expect({ ...original, ...changes }).toStrictEqual(expected) + expect(original).toStrictEqual(make()) + let confirm: (value: ReturnType) => void = () => {} + const collection = createCollection({ + getKey: (row: ReturnType) => row.id, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: make() }) + commit() + markReady() + confirm = (value) => { + begin() + write({ type: `update`, value }) + commit() + } + }, + }, + // The handler confirms the update, so the stored row is its result. + onUpdate: ({ transaction }) => { + confirm(transaction.mutations[0].modified) + return Promise.resolve() + }, + }) + try { + const tx = collection.update(1, run) + await tx.when(`settled`) + const saved = collection.get(1)! + // This oracle covers row data, not collection-owned virtual fields. + expect({ id: saved.id, items: saved.items }).toStrictEqual(expected) + } finally { + await collection.cleanup() + } + } +} + 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) => - writeThroughAccessPath(row, path, delta) - const expected = writeThroughAccessPath(make(), path, delta) - const original = make() - const changes = withChangeTracking(original, run) - expect({ ...original, ...changes }).toStrictEqual(expected) - expect(original).toStrictEqual(make()) - let confirm: (value: ReturnType) => void = () => {} - const collection = createCollection({ - getKey: (row: ReturnType) => row.id, - startSync: true, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ type: `insert`, value: make() }) - commit() - markReady() - confirm = (value) => { - begin() - write({ type: `update`, value }) - commit() - } - }, - }, - // The handler confirms the update, so the stored row is its result. - onUpdate: ({ transaction }) => { - confirm(transaction.mutations[0].modified) - return Promise.resolve() - }, - }) - try { - const tx = collection.update(1, run) - await tx.when(`settled`) - const saved = collection.get(1)! - // This oracle covers row data, not collection-owned virtual fields. - expect({ id: saved.id, items: saved.items }).toStrictEqual(expected) - } finally { - await collection.cleanup() - } - } + oracleProperty({ + id: `proxy.access-path-writes`, + law: { + statement: `A write through each callback access path of a draft array gives the native row in the change set, leaves the original unchanged, and is the stored row after the handler confirms it.`, + authority: `src/proxy.ts draft contract stated above; the same write on a plain native row is the model`, + limits: `Five access paths over a two-item array with deltas 0, 3 and -2; row data only, not collection-owned virtual fields.`, }, - ) + owns: [`src/proxy.ts`], + histories: accessPaths, + label: (path) => `preserves native writes through the ${path} access path`, + resolve: (path) => ({ path }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`proxy.access-path-writes`, plan, observe, () => + runAccessPathWrites(plan.path), + ), + check: forwardAssertionJudgment, + }) }) // Commented-out draft checks kept from the Proxy Library suite. @@ -625,11 +638,29 @@ describe(`stored functions behave like native values`, () => { const { proxy } = createChangeProxy(make(f, f2)) expect(probe(proxy, f)).toEqual(native) } - declareOracle({ check: [expectNativeProbeResult] }) - it.each(probes)(`%s gives the native result`, (_name, probe) => { - oracleRoute(`case`) - expectNativeProbeResult(probe) + oracleProperty({ + id: `proxy.stored-function-probes`, + law: { + statement: `Reading or calling a function stored as row data on a draft gives the observation that the same probe gives on a native row.`, + authority: `src/proxy.ts draft contract stated above; the probe on a plain native row is the model`, + limits: `The fifteen named read, call, assignment and inherited-constructor probes over one stored-function row.`, + }, + owns: [`src/proxy.ts`], + histories: probes.map(([name]) => name), + label: (name) => `${name} gives the native result`, + resolve: (name) => ({ + probe: probes.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `proxy.stored-function-probes`, + plan, + observe, + () => expectNativeProbeResult(plan.probe), + ), + check: forwardAssertionJudgment, }) }) @@ -706,11 +737,26 @@ describe(`defineProperty behaves like on a native row`, () => { const enumerable = expected.rest.enumerable === true expect(getChanges()).toEqual(enumerable ? { [key]: native[key] } : {}) } - declareOracle({ check: [expectDefinitionMatchesNative] }) - it.each(definitions)(`%s`, (_name, define) => { - oracleRoute(`case`) - expectDefinitionMatchesNative(define) + oracleProperty({ + id: `proxy.define-property`, + law: { + statement: `Object.defineProperty on a draft gives the native result, value and descriptor, and a defined enumerable value is a change.`, + authority: `src/proxy.ts draft contract stated above; the same definition on a plain native row is the model`, + limits: `The nine named value, read-only, nested and getter definitions over one two-key row.`, + }, + owns: [`src/proxy.ts`], + histories: definitions.map(([name]) => name), + label: (name) => name, + resolve: (name) => ({ + define: definitions.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment(`proxy.define-property`, plan, record, () => + expectDefinitionMatchesNative(plan.define), + ), + check: forwardAssertionJudgment, }) }) @@ -760,11 +806,26 @@ describe(`frozen and sealed drafts keep nested writes`, () => { run(proxy) expect({ ...make(), ...getChanges() }).toEqual({ ...native }) } - declareOracle({ check: [expectNativeRowAfter] }) - it.each(histories)(`%s gives the native row`, (_name, run) => { - oracleRoute(`case`) - expectNativeRowAfter(run) + oracleProperty({ + id: `proxy.frozen-nested-writes`, + law: { + statement: `Freezing, sealing or fixing a key of a draft does not lose a later nested write: applying the changes to the original gives the native row.`, + authority: `src/proxy.ts draft contract stated above; the same history on a plain native row is the model`, + limits: `The four named freeze, seal and fixed-key histories over one nested row.`, + }, + owns: [`src/proxy.ts`], + histories: histories.map(([name]) => name), + label: (name) => `${name} gives the native row`, + resolve: (name) => ({ + run: histories.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`proxy.frozen-nested-writes`, plan, observe, () => + expectNativeRowAfter(plan.run), + ), + check: forwardAssertionJudgment, }) // The boundary is a read-only and non-configurable key. A sealed key is From 1225f87ce164f2b1d20262fd770a6bbc3144d934 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:49:32 -0700 Subject: [PATCH 029/116] test(db): register optimistic includes witnesses through oracleProperty Forward owns from registerIncludesProperty callers to oracleProperty. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 10 + ...ncludes-optimistic-oracle.property.test.ts | 1372 +++++++++-------- .../query/includes-registration-oracle.ts | 3 + 3 files changed, 774 insertions(+), 611 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 42cca82b63..e20987eb73 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -12,6 +12,11 @@ export const properties: ReadonlyArray = [ `includes-functional-projection.operator`, `includes-functional-projection.renamed`, `includes-functional-projection.boundary`, + `includes-optimistic.held-repair`, + `includes-optimistic.sibling-delivery`, + `includes-optimistic.failed-cleanup`, + `includes-optimistic.apply-checkpoint`, + `includes-optimistic.immediate-route`, ] export const owners: ReadonlyArray = [ @@ -35,4 +40,9 @@ export const owners: ReadonlyArray = [ `includes-functional-projection`, `operator renamed boundary`, ], + [ + `db/tests/query/includes-optimistic-oracle.property.test.ts`, + `includes-optimistic`, + `held-repair sibling-delivery failed-cleanup apply-checkpoint immediate-route`, + ], ] 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 01f4dd9241..01e0b6aa9a 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -1,6 +1,12 @@ -import { fc, test as fcTest } from '@fast-check/vitest' +import { fc } from '@fast-check/vitest' import { describe, expect } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' import { BasicIndex } from '../../src/indexes/basic-index.js' @@ -15,8 +21,7 @@ import { import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { runTrace } from '../trace-runner.js' -import { oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { registerIncludesProperty } from './includes-registration-oracle.js' import { assertCanStartOptimisticChange, @@ -632,17 +637,6 @@ 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], -}) - const routeValuesArbitrary: fc.Arbitrary = fc.record({ // Routes are equality keys. Disjoint ranges preserve distinctness while // letting FastCheck shrink each semantic role independently. @@ -661,638 +655,794 @@ function optimisticProperty( ): void { registerIncludesProperty( [routeValuesArbitrary], - { property, runs: 12, fixedSeed: 1_658_013 }, + { + property, + runs: 12, + fixedSeed: 1_658_013, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, name, run, ) } -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 = { - id: number - value: string - children: Array<{ id: number }> - } - type Event = { - type: string - key: unknown - value: PublishedRoot - previousValue?: PublishedRoot - } +const heldRepairLaw = `A same-root optimistic snapshot stays private while an authoritative ordered repair is held, and the repair then publishes the reference rows with the snapshot applied.` +const siblingDeliveryLaw = `A queued sibling delivery reaches the nested result at one checkpoint, whether or not an optimistic change is pending.` +const failedCleanupLaw = `A failed optimistic action leaves no pending model or runtime work and cleans up every Collection, retaining the action failure and suppressing a later cleanup failure.` +const applyCheckpointLaw = `The trace driver checks the optimistic overlay before apply yields.` +const immediateRouteLaw = `A reparent moves the row to its optimistic parent route at the immediate optimistic checkpoint.` + +// The former fixed-history test body, unchanged: one held ordered repair over +// a same-root optimistic snapshot, compared with expectedHeldRepairRows. +async function judgeHeldRepairSnapshot(): Promise { + type OrderedRoot = { id: number; rank: number; value: string } + type Child = { id: number; parentId: number } + type PublishedRoot = { + id: number + value: string + children: Array<{ id: number }> + } + type Event = { + type: string + key: unknown + value: PublishedRoot + previousValue?: PublishedRoot + } - const sourceRows = new Map([ - [1, { id: 1, rank: 1, value: `A` }], - [2, { id: 2, rank: 2, value: `X` }], - ]) - let sync!: Parameters[`sync`]>[0] - let holdRepair = false - let requests = 0 - const held: Array<() => void> = [] - const heldRequests: Array = [] - const mutation = createDeferred() - const roots = createCollection({ - id: `held-optimistic-repair-roots`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - onUpdate: () => mutation.promise, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: (options) => { - requests++ - if (requests === 1) { - sync.begin() - for (const row of sourceRows.values()) - sync.write({ type: `insert`, value: { ...row } }) - expect(sync.commit()).toBe(true) - } - if (holdRepair) { - heldRequests.push(options) - return new Promise((resolve) => held.push(resolve)) - } - return true - }, - unloadSubset: () => {}, + const sourceRows = new Map([ + [1, { id: 1, rank: 1, value: `A` }], + [2, { id: 2, rank: 2, value: `X` }], + ]) + let sync!: Parameters[`sync`]>[0] + let holdRepair = false + let requests = 0 + const held: Array<() => void> = [] + const heldRequests: Array = [] + const mutation = createDeferred() + const roots = createCollection({ + id: `held-optimistic-repair-roots`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + onUpdate: () => mutation.promise, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: (options) => { + requests++ + if (requests === 1) { + sync.begin() + for (const row of sourceRows.values()) + sync.write({ type: `insert`, value: { ...row } }) + expect(sync.commit()).toBe(true) } + if (holdRepair) { + heldRequests.push(options) + return new Promise((resolve) => held.push(resolve)) + } + return true }, + unloadSubset: () => {}, + } + }, + }, + }) + const children = createControlledCollection( + `held-optimistic-repair-children`, + [{ id: 10, parentId: 1 }], + ) + children.collection.createIndex((child) => child.parentId, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: (q) => + q + .from({ root: roots }) + .orderBy(({ root }) => root.rank) + .limit(2) + .select(({ root }) => ({ + id: root.id, + value: root.value, + children: materializeChildRows( + q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentId, root.id)) + .select(({ child }) => ({ id: child.id })), + ), + })), + getKey: (row) => row.id, + }) + const published = (row: unknown) => + stripVirtualProperties(row) as PublishedRoot + const snapshot = (): Array => + live.toArray.map((row) => published(row)) + const callbacks: Array<{ + rows: Array + changes: Array + }> = [] + let unsubscribe: (() => void) | undefined + let persistence: Promise | undefined + + await withHistoryCleanup( + async () => { + await live.preload() + const before = expectedHeldRepairRows(sourceRows) + expect(snapshot()).toEqual(before) + const subscription = live.subscribeChanges( + (changes) => { + callbacks.push({ + rows: snapshot(), + changes: changes.map((change) => ({ + type: change.type, + key: change.key, + value: published(change.value), + ...(change.type === `update` + ? { previousValue: published(change.previousValue) } + : {}), + })), + }) }, - }) - const children = createControlledCollection( - `held-optimistic-repair-children`, - [{ id: 10, parentId: 1 }], + { includeInitialState: false }, ) - children.collection.createIndex((child) => child.parentId, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: (q) => - q - .from({ root: roots }) - .orderBy(({ root }) => root.rank) - .limit(2) - .select(({ root }) => ({ - id: root.id, - value: root.value, - children: materializeChildRows( - q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentId, root.id)) - .select(({ child }) => ({ id: child.id })), - ), - })), - getKey: (row) => row.id, + unsubscribe = () => subscription.unsubscribe() + + // The source-order change invalidates finite coverage and starts a + // bounded repair. Its new rank is private to the live query. + holdRepair = true + const moved = { ...sourceRows.get(1)!, rank: 2.5 } + sourceRows.set(1, moved) + sync.begin() + sync.write({ type: `update`, value: { ...moved } }) + expect(sync.commit()).toBe(true) + expect(requests).toBeGreaterThan(1) + expect(held.length).toBeGreaterThan(0) + expect(heldRequests).toHaveLength(1) + expect(heldRequests[0]).toMatchObject({ limit: 2, refetch: true }) + expect( + heldRequests[0]!.orderBy?.map(({ expression, compareOptions }) => ({ + path: expression instanceof PropRef ? expression.path : undefined, + direction: compareOptions.direction, + })), + ).toEqual([{ path: [`rank`], direction: `asc` }]) + expect(heldRequests[0]!.cursor).toBeUndefined() + expect(heldRequests[0]!.offset).toBeUndefined() + expect(snapshot()).toEqual(before) + expect(callbacks).toEqual([]) + + // This changes the same root row while its order repair is held. + // A leaked optimistic row or early retraction would be public here. + const transaction = roots.update(1, (draft) => { + draft.value = `B` }) - const published = (row: unknown) => - stripVirtualProperties(row) as PublishedRoot - const snapshot = (): Array => - live.toArray.map((row) => published(row)) - const callbacks: Array<{ - rows: Array - changes: Array - }> = [] - let unsubscribe: (() => void) | undefined - let persistence: Promise | undefined - - await withHistoryCleanup( - async () => { - await live.preload() - const before = expectedHeldRepairRows(sourceRows) - expect(snapshot()).toEqual(before) - const subscription = live.subscribeChanges( - (changes) => { - callbacks.push({ - rows: snapshot(), - changes: changes.map((change) => ({ - type: change.type, - key: change.key, - value: published(change.value), - ...(change.type === `update` - ? { previousValue: published(change.previousValue) } - : {}), - })), - }) - }, - { includeInitialState: false }, - ) - unsubscribe = () => subscription.unsubscribe() - - // The source-order change invalidates finite coverage and starts a - // bounded repair. Its new rank is private to the live query. - holdRepair = true - const moved = { ...sourceRows.get(1)!, rank: 2.5 } - sourceRows.set(1, moved) - sync.begin() - sync.write({ type: `update`, value: { ...moved } }) - expect(sync.commit()).toBe(true) - expect(requests).toBeGreaterThan(1) - expect(held.length).toBeGreaterThan(0) - expect(heldRequests).toHaveLength(1) - expect(heldRequests[0]).toMatchObject({ limit: 2, refetch: true }) - expect( - heldRequests[0]!.orderBy?.map(({ expression, compareOptions }) => ({ - path: expression instanceof PropRef ? expression.path : undefined, - direction: compareOptions.direction, - })), - ).toEqual([{ path: [`rank`], direction: `asc` }]) - expect(heldRequests[0]!.cursor).toBeUndefined() - expect(heldRequests[0]!.offset).toBeUndefined() - expect(snapshot()).toEqual(before) - expect(callbacks).toEqual([]) - - // This changes the same root row while its order repair is held. - // A leaked optimistic row or early retraction would be public here. - const transaction = roots.update(1, (draft) => { - draft.value = `B` - }) - persistence = transaction.when(`settled`).then( - () => undefined, - () => undefined, - ) - await flushPromises() - expect(roots.get(1)?.value).toBe(`B`) - const checkHeldCut = ( - rows: Array, - events: typeof callbacks, - ) => { - expect(rows).toEqual(before) - expect(events).toEqual([]) - } - // Wrong-result controls reach this cut: early optimistic visibility - // and a premature root retraction both fail the same refinement. - expect(() => - checkHeldCut(expectedHeldRepairRows(sourceRows, `B`), []), - ).toThrow() - expect(() => checkHeldCut(before.slice(1), [])).toThrow() - checkHeldCut(snapshot(), callbacks) - - holdRepair = false - for (const release of held.splice(0)) release() - await flushPromises() - const after = expectedHeldRepairRows(sourceRows, `B`) - const withoutOverlay = expectedHeldRepairRows(sourceRows) - expect(after).not.toEqual(before) - expect(after).not.toEqual(withoutOverlay) - const expectedPublication: typeof callbacks = [ + persistence = transaction.when(`settled`).then( + () => undefined, + () => undefined, + ) + await flushPromises() + expect(roots.get(1)?.value).toBe(`B`) + const checkHeldCut = ( + rows: Array, + events: typeof callbacks, + ) => { + expect(rows).toEqual(before) + expect(events).toEqual([]) + } + // Wrong-result controls reach this cut: early optimistic visibility + // and a premature root retraction both fail the same refinement. + expect(() => + checkHeldCut(expectedHeldRepairRows(sourceRows, `B`), []), + ).toThrow() + expect(() => checkHeldCut(before.slice(1), [])).toThrow() + checkHeldCut(snapshot(), callbacks) + + holdRepair = false + for (const release of held.splice(0)) release() + await flushPromises() + const after = expectedHeldRepairRows(sourceRows, `B`) + const withoutOverlay = expectedHeldRepairRows(sourceRows) + expect(after).not.toEqual(before) + expect(after).not.toEqual(withoutOverlay) + const expectedPublication: typeof callbacks = [ + { + rows: after, + changes: [ { - rows: after, - changes: [ - { - type: `update`, - key: 1, - value: after[1]!, - previousValue: before[0]!, - }, - ], + type: `update`, + key: 1, + value: after[1]!, + previousValue: before[0]!, }, - ] - const checkPublishedCut = ( - rows: Array, - events: typeof callbacks, - ) => { - expect(rows).toEqual(after) - expect(events).toEqual(expectedPublication) - } - // A repair that drops the optimistic snapshot or retracts the root - // cannot pass the publication comparison. - expect(() => checkPublishedCut(withoutOverlay, [])).toThrow() - expect(() => checkPublishedCut(after.slice(0, 1), [])).toThrow() - checkPublishedCut(snapshot(), callbacks) - - // Rejection removes only the optimistic snapshot. The repaired - // authoritative rank and child route remain publicly coherent. - mutation.reject(new Error(`discard optimistic value`)) - await persistence - await flushPromises() - expect(snapshot()).toEqual(withoutOverlay) - expect(callbacks).toEqual([ + ], + }, + ] + const checkPublishedCut = ( + rows: Array, + events: typeof callbacks, + ) => { + expect(rows).toEqual(after) + expect(events).toEqual(expectedPublication) + } + // A repair that drops the optimistic snapshot or retracts the root + // cannot pass the publication comparison. + expect(() => checkPublishedCut(withoutOverlay, [])).toThrow() + expect(() => checkPublishedCut(after.slice(0, 1), [])).toThrow() + checkPublishedCut(snapshot(), callbacks) + + // Rejection removes only the optimistic snapshot. The repaired + // authoritative rank and child route remain publicly coherent. + mutation.reject(new Error(`discard optimistic value`)) + await persistence + await flushPromises() + expect(snapshot()).toEqual(withoutOverlay) + expect(callbacks).toEqual([ + { + rows: after, + changes: [ { - rows: after, - changes: [ - { - type: `update`, - key: 1, - value: after[1], - previousValue: before[0], - }, - ], + type: `update`, + key: 1, + value: after[1], + previousValue: before[0], }, + ], + }, + { + rows: withoutOverlay, + changes: [ { - rows: withoutOverlay, - changes: [ - { - type: `update`, - key: 1, - value: withoutOverlay[1], - previousValue: after[1], - }, - ], + type: `update`, + key: 1, + value: withoutOverlay[1], + previousValue: after[1], }, - ]) + ], }, - () => [ - () => { - holdRepair = false - for (const release of held.splice(0)) release() - mutation.reject(new Error(`cleanup optimistic value`)) - }, - () => persistence, - () => unsubscribe?.(), - () => live.cleanup(), - () => roots.cleanup(), - () => children.collection.cleanup(), - ], - ) + ]) }, + () => [ + () => { + holdRepair = false + for (const release of held.splice(0)) release() + mutation.reject(new Error(`cleanup optimistic value`)) + }, + () => persistence, + () => unsubscribe?.(), + () => live.cleanup(), + () => roots.cleanup(), + () => children.collection.cleanup(), + ], ) +} - for (const pending of [false, true]) { - fcTest( - `observes queued sibling delivery with pending=${pending}`, - async () => { - oracleRoute(`case`) - const routes: RouteValues = { - rootA: 10, - rootB: 100, - rootC: 200, - original: 300, - optimistic: 400, - authoritative: 500, - } - const { roots, levels } = fixture(routes) - const sibling: ChildRow = { - id: 12, - parentGroup: 10, - group: 300, - value: 120, - position: 1, - } - const changes: Array> = [ - { type: `insert`, value: sibling }, - ] - const driver = createDriver(roots, levels) - const events: Array<{ type: string; row: ChildRow }> = [] - let unsubscribe: (() => void) | undefined - let siblingCuts = 0 - let runtime: PendingRuntimeMutation | undefined - const descendants = [ - { - id: 21, - group: 1300, - value: 210, - position: 0, - children: [{ id: 31, group: 2300, value: 310, position: 0 }], - }, - ] - const published = (optimistic: boolean) => [ - { - id: 1, - group: 10, - value: 10, - position: 0, - children: optimistic - ? [{ id: 11, group: 400, value: 110, position: 0, children: [] }] - : [ - { - id: 11, - group: 300, - value: 110, - position: 0, - children: descendants, - }, - { - id: 12, - group: 300, - value: 120, - position: 1, - children: descendants, - }, - ], - }, - { id: 2, group: 100, value: 20, position: 1, children: [] }, - { id: 3, group: 200, value: 30, position: 2, children: [] }, - ] - await runTrace({ - steps: [ - pending - ? { - type: `optimisticRollback`, - level: 1, - id: 11, - patch: { group: 400 }, - beforeRollback: { level: 1, changes }, - } - : { type: `sync`, level: 1, changes }, +// The former case body, unchanged, for one pending flag. +async function judgeQueuedSiblingDelivery(pending: boolean): Promise { + const routes: RouteValues = { + rootA: 10, + rootB: 100, + rootC: 200, + original: 300, + optimistic: 400, + authoritative: 500, + } + const { roots, levels } = fixture(routes) + const sibling: ChildRow = { + id: 12, + parentGroup: 10, + group: 300, + value: 120, + position: 1, + } + const changes: Array> = [ + { type: `insert`, value: sibling }, + ] + const driver = createDriver(roots, levels) + const events: Array<{ type: string; row: ChildRow }> = [] + let unsubscribe: (() => void) | undefined + let siblingCuts = 0 + let runtime: PendingRuntimeMutation | undefined + const descendants = [ + { + id: 21, + group: 1300, + value: 210, + position: 0, + children: [{ id: 31, group: 2300, value: 310, position: 0 }], + }, + ] + const published = (optimistic: boolean) => [ + { + id: 1, + group: 10, + value: 10, + position: 0, + children: optimistic + ? [{ id: 11, group: 400, value: 110, position: 0, children: [] }] + : [ { - type: `sync`, - level: 2, - changes: [ - { type: `update`, value: { ...levels[1][0]!, value: 211 } }, - ], - }, - ] satisfies Array, - driver: { - ...driver, - setup: async () => { - const context = await driver.setup() - const source = context.sources.levels[0] - const subscription = source.collection.subscribeChanges( - (batch) => { - events.push( - ...batch.map(({ type, value }) => ({ - type, - row: { ...value }, - })), - ) - }, - { includeInitialState: false }, - ) - unsubscribe = () => subscription.unsubscribe() - const writeBatch = source.writeBatch - source.writeBatch = (batch) => { - const eventStart = events.length - runtime = [...context.runtimePending][0] - writeBatch(batch) - siblingCuts += 1 - const actual = { - present: source.collection.has(12), - row: source.collection.get(12), - events: events.slice(eventStart), - rows: stripVirtualProperties(context.live.toArray), - } - expect(actual.present).toBe(!pending) - if (pending) expect(actual.row).toBeUndefined() - else expect(actual.row).toMatchObject(sibling) - expect(actual.rows).toEqual(published(pending)) - if (pending) { - expect(actual.events).toEqual([]) - expect(runtime?.transaction.state).toBe(`persisting`) - expect(runtime?.settled).toBe(false) - } else { - expect(runtime).toBeUndefined() - expect(actual.events).toContainEqual({ - type: `insert`, - row: expect.objectContaining(sibling), - }) - } - } - return context - }, - apply: async (step, context, checkpoint) => { - await driver.apply(step, context, checkpoint) - if (step.level === 1) { - if (pending) { - await runtime!.receipt - expect(runtime!.settled).toBe(true) - expect(runtime!.transaction.state).toBe(`failed`) - } - expect( - context.sources.levels[0].collection.get(12), - ).toMatchObject(sibling) - expect(events).toContainEqual({ - type: `insert`, - row: expect.objectContaining(sibling), - }) - expect(stripVirtualProperties(context.live.toArray)).toEqual( - published(false), - ) - } + id: 11, + group: 300, + value: 110, + position: 0, + children: descendants, }, - cleanup: async (context) => { - const results = await Promise.allSettled([ - Promise.resolve().then(() => unsubscribe?.()), - Promise.resolve().then(() => driver.cleanup(context)), - ]) - const errors = results.flatMap((result): Array => - result.status === `rejected` ? [result.reason as unknown] : [], - ) - if (errors.length === 1) throw errors[0] - if (errors.length > 1) - throw new AggregateError( - errors, - `Sibling observation cleanup failed`, - ) + { + id: 12, + group: 300, + value: 120, + position: 1, + children: descendants, }, + ], + }, + { id: 2, group: 100, value: 20, position: 1, children: [] }, + { id: 3, group: 200, value: 30, position: 2, children: [] }, + ] + await runTrace({ + steps: [ + pending + ? { + type: `optimisticRollback`, + level: 1, + id: 11, + patch: { group: 400 }, + beforeRollback: { level: 1, changes }, + } + : { type: `sync`, level: 1, changes }, + { + type: `sync`, + level: 2, + changes: [{ type: `update`, value: { ...levels[1][0]!, value: 211 } }], + }, + ] satisfies Array, + driver: { + ...driver, + setup: async () => { + const context = await driver.setup() + const source = context.sources.levels[0] + const subscription = source.collection.subscribeChanges( + (batch) => { + events.push( + ...batch.map(({ type, value }) => ({ + type, + row: { ...value }, + })), + ) }, - projection, - }) - expect(siblingCuts).toBe(1) + { includeInitialState: false }, + ) + unsubscribe = () => subscription.unsubscribe() + const writeBatch = source.writeBatch + source.writeBatch = (batch) => { + const eventStart = events.length + runtime = [...context.runtimePending][0] + writeBatch(batch) + siblingCuts += 1 + const actual = { + present: source.collection.has(12), + row: source.collection.get(12), + events: events.slice(eventStart), + rows: stripVirtualProperties(context.live.toArray), + } + expect(actual.present).toBe(!pending) + if (pending) expect(actual.row).toBeUndefined() + else expect(actual.row).toMatchObject(sibling) + expect(actual.rows).toEqual(published(pending)) + if (pending) { + expect(actual.events).toEqual([]) + expect(runtime?.transaction.state).toBe(`persisting`) + expect(runtime?.settled).toBe(false) + } else { + expect(runtime).toBeUndefined() + expect(actual.events).toContainEqual({ + type: `insert`, + row: expect.objectContaining(sibling), + }) + } + } + return context }, - ) - } + apply: async (step, context, checkpoint) => { + await driver.apply(step, context, checkpoint) + if (step.level === 1) { + if (pending) { + await runtime!.receipt + expect(runtime!.settled).toBe(true) + expect(runtime!.transaction.state).toBe(`failed`) + } + expect(context.sources.levels[0].collection.get(12)).toMatchObject( + sibling, + ) + expect(events).toContainEqual({ + type: `insert`, + row: expect.objectContaining(sibling), + }) + expect(stripVirtualProperties(context.live.toArray)).toEqual( + published(false), + ) + } + }, + cleanup: async (context) => { + const results = await Promise.allSettled([ + Promise.resolve().then(() => unsubscribe?.()), + Promise.resolve().then(() => driver.cleanup(context)), + ]) + const errors = results.flatMap((result): Array => + result.status === `rejected` ? [result.reason as unknown] : [], + ) + if (errors.length === 1) throw errors[0] + if (errors.length > 1) + throw new AggregateError(errors, `Sibling observation cleanup failed`) + }, + }, + projection, + }) + expect(siblingCuts).toBe(1) +} - for (const compound of [false, true]) { - for (const cleanupFails of [false, true]) { - fcTest( - `cleans failed optimistic work (compound=${compound}, cleanup failure=${cleanupFails})`, - async () => { - oracleRoute(`case`) - const routes: RouteValues = { - rootA: 10, - rootB: 100, - rootC: 200, - original: 300, - optimistic: 400, - authoritative: 500, +// The former case body, unchanged, for one compound and cleanup-failure cell. +async function judgeFailedOptimisticCleanup( + compound: boolean, + cleanupFails: boolean, +): Promise { + const routes: RouteValues = { + rootA: 10, + rootB: 100, + rootC: 200, + original: 300, + optimistic: 400, + authoritative: 500, + } + const { roots, levels } = fixture(routes) + const driver = createDriver(roots, levels) + const failure = new Error(`optimistic action failed`) + const cleanupFailure = new Error(`live cleanup failed after disposal`) + let context: OptimisticContext | undefined + let failedTransaction: SettlingTransaction | undefined + const fail = (current: OptimisticContext): never => { + const runtime = [...current.runtimePending][0] + expect(runtime).toBeDefined() + failedTransaction = runtime!.transaction + throw failure + } + const step: OptimisticRelationshipStep = compound + ? { + type: `optimisticRollback`, + level: 1, + id: 11, + patch: { group: routes.optimistic }, + beforeRollback: { + level: 1, + changes: [ + { + type: `insert`, + value: { + id: 12, + parentGroup: routes.rootA, + group: routes.original, + value: 120, + position: 1, + }, + }, + ], + }, + } + : { + type: `optimistic`, + handle: `failed`, + level: 1, + id: 11, + patch: { parentGroup: routes.rootB }, + } + await expect( + runTrace({ + steps: [step], + driver: { + ...driver, + setup: async () => { + const current = await driver.setup() + context = current + if (compound) { + const source = current.sources.levels[0] + const writeBatch = source.writeBatch + source.writeBatch = (changes) => { + writeBatch(changes) + fail(current) + } } - const { roots, levels } = fixture(routes) - const driver = createDriver(roots, levels) - const failure = new Error(`optimistic action failed`) - const cleanupFailure = new Error(`live cleanup failed after disposal`) - let context: OptimisticContext | undefined - let failedTransaction: SettlingTransaction | undefined - const fail = (current: OptimisticContext): never => { - const runtime = [...current.runtimePending][0] - expect(runtime).toBeDefined() - failedTransaction = runtime!.transaction - throw failure + if (cleanupFails) { + const cleanup = current.live.cleanup.bind(current.live) + current.live.cleanup = async () => { + await cleanup() + throw cleanupFailure + } } - const step: OptimisticRelationshipStep = compound - ? { - type: `optimisticRollback`, - level: 1, - id: 11, - patch: { group: routes.optimistic }, - beforeRollback: { - level: 1, - changes: [ - { - type: `insert`, - value: { - id: 12, - parentGroup: routes.rootA, - group: routes.original, - value: 120, - position: 1, - }, - }, - ], - }, - } - : { - type: `optimistic`, - handle: `failed`, - level: 1, - id: 11, - patch: { parentGroup: routes.rootB }, - } - await expect( - runTrace({ - steps: [step], - driver: { - ...driver, - setup: async () => { - const current = await driver.setup() - context = current - if (compound) { - const source = current.sources.levels[0] - const writeBatch = source.writeBatch - source.writeBatch = (changes) => { - writeBatch(changes) - fail(current) - } - } - if (cleanupFails) { - const cleanup = current.live.cleanup.bind(current.live) - current.live.cleanup = async () => { - await cleanup() - throw cleanupFailure - } - } - return current - }, - }, - projection: { - ...projection, - assertEqual: (actual, expected) => { - projection.assertEqual(actual, expected) - if (!compound && context!.pending.size > 0) fail(context!) - return undefined - }, - }, - }), - ).rejects.toBe(failure) - expect( - (failure as Error & { suppressed?: Array }).suppressed, - ).toEqual(cleanupFails ? [cleanupFailure] : undefined) - if (!context) throw new Error(`Expected initialized trace context`) - expect(failedTransaction?.state).toBe(`failed`) - expect(context.pending.size).toBe(0) - expect(context.runtimePending.size).toBe(0) - for (const collection of [ - context.live, - context.sources.roots.collection, - ...context.sources.levels.map((source) => source.collection), - ]) - expect(collection.status).toBe(`cleaned-up`) + return current }, - ) - } + }, + projection: { + ...projection, + assertEqual: (actual, expected) => { + projection.assertEqual(actual, expected) + if (!compound && context!.pending.size > 0) fail(context!) + return undefined + }, + }, + }), + ).rejects.toBe(failure) + expect( + (failure as Error & { suppressed?: Array }).suppressed, + ).toEqual(cleanupFails ? [cleanupFailure] : undefined) + if (!context) throw new Error(`Expected initialized trace context`) + expect(failedTransaction?.state).toBe(`failed`) + expect(context.pending.size).toBe(0) + expect(context.runtimePending.size).toBe(0) + for (const collection of [ + context.live, + context.sources.roots.collection, + ...context.sources.levels.map((source) => source.collection), + ]) + expect(collection.status).toBe(`cleaned-up`) +} + +// The former fixed-history body, unchanged: the shared trace driver and +// projection, with apply counting its synchronous checkpoints. +async function judgeCheckpointBeforeYield(): Promise { + const routes: RouteValues = { + rootA: 10, + rootB: 100, + rootC: 200, + original: 300, + optimistic: 400, + authoritative: 500, } + const { roots, levels } = fixture(routes) + const driver = createDriver(roots, levels) + await runTrace({ + steps: [ + { + type: `optimistic` as const, + handle: `immediate`, + level: 1 as const, + id: 11, + patch: { parentGroup: routes.rootB }, + }, + ], + driver: { + ...driver, + apply: (step, context, checkpoint) => { + let checkpoints = 0 + const applied = driver.apply(step, context, () => { + checkpoints += 1 + return checkpoint() + }) + const checkpointsBeforeYield = checkpoints + return Promise.resolve(applied).then(() => { + expect(checkpointsBeforeYield).toBe(1) + }) + }, + }, + projection, + }) +} - fcTest( - `checks the optimistic overlay before yielding from apply`, - async () => { - oracleRoute(`fixed history`) - const routes: RouteValues = { - rootA: 10, - rootB: 100, - rootC: 200, - original: 300, - optimistic: 400, - authoritative: 500, - } - const { roots, levels } = fixture(routes) - const driver = createDriver(roots, levels) - await runTrace({ - steps: [ - { - type: `optimistic` as const, - handle: `immediate`, - level: 1 as const, - id: 11, - patch: { parentGroup: routes.rootB }, - }, - ], - driver: { - ...driver, - apply: (step, context, checkpoint) => { - let checkpoints = 0 - const applied = driver.apply(step, context, () => { - checkpoints += 1 - return checkpoint() - }) - const checkpointsBeforeYield = checkpoints - return Promise.resolve(applied).then(() => { - expect(checkpointsBeforeYield).toBe(1) - }) - }, - }, - projection, - }) +// The immediate reparent history that the stale-route calibration corrupts. +// Its clean replay is the shared trace check, expectHistoryMatches. +const immediateReparentHistories = [ + { + routes: { + rootA: 10, + rootB: 100, + rootC: 200, + original: 300, + optimistic: 400, + authoritative: 500, }, - ) + steps: [ + { + type: `optimistic` as const, + handle: `reparent`, + level: 1 as const, + id: 11, + patch: { parentGroup: 100 }, + }, + ], + }, +] - fcTest( - `rejects a stale route at the immediate optimistic checkpoint`, - async () => { - oracleRoute(`calibration`) - const routes: RouteValues = { - rootA: 10, - rootB: 100, - rootC: 200, - original: 300, - optimistic: 400, - authoritative: 500, - } - const { roots, levels } = fixture(routes) - let initial: unknown - let optimistic: unknown - let observations = 0 - - await expect( - runTrace({ - steps: [ - { - type: `optimistic`, - handle: `reparent`, - level: 1, - id: 11, - patch: { parentGroup: routes.rootB }, - }, - ], - driver: createDriver(roots, levels), - projection: { - ...projection, - observe: (context) => { - const actual = projection.observe(context) - observations += 1 - if (observations === 1) initial = actual - if (observations === 2) { - optimistic = actual - return initial +describe(`optimistic relationship-transition oracle`, () => { + oracleProperty({ + id: `includes-optimistic.held-repair`, + law: { + statement: heldRepairLaw, + authority: `The optimistic relationship-transition laws stated at the start of this owner, and the optimistic and atomic window publication rules in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The one held ordered-repair witness over controlled source Collections; no real-provider event timing or Collection-valued facade claim.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [ + `keeps a same-root optimistic snapshot private until ordered repair publishes`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(heldRepairLaw, plan, observe, () => + judgeHeldRepairSnapshot(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-optimistic.sibling-delivery`, + law: { + statement: siblingDeliveryLaw, + authority: `The optimistic relationship-transition laws stated at the start of this owner, and the optimistic and atomic window publication rules in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The two enumerated pending flags over fixed route values.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [false, true], + label: (pending) => + `observes queued sibling delivery with pending=${pending}`, + resolve: (pending) => ({ pending }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(siblingDeliveryLaw, plan, observe, () => + judgeQueuedSiblingDelivery(plan.pending), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-optimistic.failed-cleanup`, + law: { + statement: failedCleanupLaw, + authority: `The optimistic relationship-transition laws stated at the start of this owner, and the optimistic and atomic window publication rules in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The four enumerated compound and cleanup-failure cells over fixed route values.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [false, true].flatMap((compound) => + [false, true].map((cleanupFails) => ({ compound, cleanupFails })), + ), + label: ({ compound, cleanupFails }) => + `cleans failed optimistic work (compound=${compound}, cleanup failure=${cleanupFails})`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(failedCleanupLaw, plan, observe, () => + judgeFailedOptimisticCleanup(plan.compound, plan.cleanupFails), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-optimistic.apply-checkpoint`, + law: { + statement: applyCheckpointLaw, + authority: `The optimistic relationship-transition laws stated at the start of this owner, and the optimistic and atomic window publication rules in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The one immediate optimistic step over fixed route values.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`checks the optimistic overlay before yielding from apply`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(applyCheckpointLaw, plan, observe, () => + judgeCheckpointBeforeYield(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-optimistic.immediate-route`, + law: { + statement: immediateRouteLaw, + authority: `The optimistic relationship-transition laws stated at the start of this owner, and the optimistic and atomic window publication rules in packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The one immediate level-one reparent over fixed route values, replayed through the shared trace check, and its stale-route calibration.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: immediateReparentHistories, + label: () => + `moves a reparented row at the immediate optimistic checkpoint`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(immediateRouteLaw, plan, observe, () => + expectHistoryMatches(plan.history.routes, plan.history.steps), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a stale route at the immediate optimistic checkpoint`, + law: immediateRouteLaw, + // The former calibration body: the projection returns the initial rows + // at the optimistic checkpoint. Exactly the old matcher: only a + // TraceAssertionError at checkpoint 1, after two observations whose + // optimistic rows differ from the initial rows, counts as the check + // rejecting it. + driver: (plan, observe) => + recordAssertionJudgment( + immediateRouteLaw, + plan, + observe, + async () => { + const { roots, levels } = fixture(plan.history.routes) + let initial: unknown + let optimistic: unknown + let observations = 0 + try { + await runTrace({ + steps: plan.history.steps, + driver: createDriver(roots, levels), + projection: { + ...projection, + observe: (context) => { + const actual = projection.observe(context) + observations += 1 + if (observations === 1) initial = actual + if (observations === 2) { + optimistic = actual + return initial + } + return actual + }, + }, + }) + } catch (error) { + if ( + error instanceof Error && + error.name === `TraceAssertionError` && + (error as Error & { checkpoint?: unknown }).checkpoint === 1 + ) { + expect(observations).toBe(2) + expect(optimistic).not.toEqual(initial) + rejectAt(`stale route calibration`, error) + } + // The shared trace check marks its own comparison failures. + // A failure outside the old matcher is rethrown without that + // marking, so it fails this calibration as it failed the old + // test. + throw new Error( + `the stale-route corruption failed outside the expected rejection: ${String(error)}`, + ) } - return actual }, - }, - }), - ).rejects.toMatchObject({ - name: `TraceAssertionError`, - checkpoint: 1, - }) - expect(observations).toBe(2) - expect(optimistic).not.toEqual(initial) - }, - ) + ), + }, + ], + }) optimisticProperty( `includes-optimistic.rekey-detach`, diff --git a/packages/db/tests/query/includes-registration-oracle.ts b/packages/db/tests/query/includes-registration-oracle.ts index 48f2b26780..261494db39 100644 --- a/packages/db/tests/query/includes-registration-oracle.ts +++ b/packages/db/tests/query/includes-registration-oracle.ts @@ -13,6 +13,8 @@ export type IncludesCampaign> = { runs: number fixedSeed: number examples?: ReadonlyArray + /** The calling owner's production code, forwarded unchanged to oracleProperty. */ + owns?: ReadonlyArray beforeCampaign?: () => void afterCampaign?: () => void } @@ -59,6 +61,7 @@ export function registerIncludesProperty>( authority: `The existing independent includes judgments and the live-query materialization architecture cited by the calling owner.`, limits: `The calling owner's unchanged histories, production paths, comparison checkpoints, and stated limits. This registration migration adds no semantic coverage.`, }, + owns: campaign.owns, histories, label: (history) => `retained history ${campaign.examples!.indexOf(history) + 1}`, From cd6ff457e13a572d686b0fec9e409a4b8da38d95 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:50:13 -0700 Subject: [PATCH 030/116] test(db): register loader, mixed-index, and derived-delete oracle tests through oracleProperty Convert the staged-window cells, the mixed-index public-order matrix, the retained on-demand window, and the queued-batch matrix to enumerated properties. Turn the driver-row mutation probes into calibrations of derived-publication.membership-work-fixed. The staged-window grammar check runs no production, so it moves to an ordinary test beside an extracted, test-free grammar module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 19 ++ ...rived-delete-reconciliation-oracle.test.ts | 261 +++++++++--------- .../query/mixed-index-order-oracle.test.ts | 88 ++++-- .../ordered-source-loader-model-oracle.ts | 26 ++ .../query/ordered-source-loader-model.test.ts | 16 ++ .../ordered-source-loader-oracle.test.ts | 254 ++++++++--------- 6 files changed, 386 insertions(+), 278 deletions(-) create mode 100644 packages/db/tests/query/ordered-source-loader-model-oracle.ts create mode 100644 packages/db/tests/query/ordered-source-loader-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 81ab135fd3..55ccdb8255 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -25,6 +25,10 @@ const otherProperties: ReadonlyArray = [ `where-predicate.publication-fixed`, `pooled-live-query.publication-fixed`, `pooled-live-query.release-timing`, + `ordered-source-loader.staged-window`, + `mixed-index-order.public-order`, + `mixed-index-order.on-demand-window`, + `derived-publication.queued-batches`, ] const otherOwners: ReadonlyArray = [ [ @@ -52,6 +56,21 @@ const otherOwners: ReadonlyArray = [ `pooled-live-query`, `release-timing`, ], + [ + `db/tests/query/ordered-source-loader-oracle.test.ts`, + `ordered-source-loader`, + `staged-window`, + ], + [ + `db/tests/query/mixed-index-order-oracle.test.ts`, + `mixed-index-order`, + `public-order on-demand-window`, + ], + [ + `db/tests/query/derived-delete-reconciliation-oracle.test.ts`, + `derived-publication`, + `queued-batches`, + ], ] export const properties: ReadonlyArray = [ 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 d8ae45a46b..02fd2eaede 100644 --- a/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/derived-delete-reconciliation-oracle.test.ts @@ -1,18 +1,16 @@ import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' 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 { oracleProperty, pass, violation } from '../oracle-config.js' import { - oracleProperty, - oracleReplayTest, - oracleRoute, - pass, - violation, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import type { Observation, Verdict } from '../oracle-config.js' import type { SyncConfig } from '../../src/types.js' @@ -22,7 +20,7 @@ import type { SyncConfig } from '../../src/types.js' // downstream hold the source's committed rows, whatever the batches' // membership deltas and whether the delete was acknowledged or rolled back. // Membership lookups run only for batches that carry an update. The check -// (runFlushHistory) runs generated queue histories and compares the output +// (checkFlushHistory) judges generated queue histories and compares the output // with the source rows it wrote. The model predicts the final rows of the // parameter grid. Unit tests for the same subsystem are in // derived-delete-reconciliation.test.ts. @@ -136,26 +134,6 @@ function expectedRowsAfterQueuedBatches(batches: number): Array { ] } -declareOracle({ - check: [checkFlushHistory], - model: [expectedRowsAfterQueuedBatches, modelFlushHistory], -}) - -oracleReplayTest(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) => { - if (at === phase) row.value += 100 - return row - }), - ).rejects.toMatchObject({ name: `AssertionError` }) - await runFlushHistory(steps) - }, -) - // The independent reference owns the source rows implied by the history. // Each held flush commits, queues once, and stays invisible; only an update // needs a membership lookup. Settlement exposes all committed source rows. @@ -226,21 +204,6 @@ function checkFlushHistory( } return pass } -async function runFlushHistory( - steps: FlushHistory, - prepareDriverRow?: (row: Row, phase: WritePhase) => Row, -) { - const trace: Array = [] - await driveFlushHistory( - steps, - (checkpoint, value) => trace.push({ checkpoint, value }), - prepareDriverRow, - ) - const verdict = checkFlushHistory(steps, trace, modelFlushHistory(steps)) - if (verdict.kind === `violation`) - throw verdict.cause ?? new Error(verdict.difference) -} - // The old fixed and fresh campaigns had different run budgets (40 and 60). // Separate registrations retain both. The fixed-budget registration also gets // the framework's standard fresh campaign; neither old budget is reduced. @@ -267,8 +230,24 @@ oracleProperty({ fixedSeeds: [41703], states: [], constructed: [], + // The histories that the driver-row calibrations below mutate. + fixed: [false, true].map((update) => ({ + name: `two inserts${update ? ` and a row-one update` : ``}`, + history: [{ inserts: 2, update }], + })), }, check: checkFlushHistory, + // A driver that changes the row it writes at one boundary must fail the + // settled-row comparison. + calibrations: ([`initial`, `update`, `insert`] as const).map((phase) => ({ + name: `detects driver-only row mutation at the ${phase} boundary`, + law: `membership work`, + driver: (steps: FlushHistory, observe) => + driveFlushHistory(steps, observe, (row, at) => { + if (at === phase) row.value += 100 + return row + }), + })), }) oracleProperty({ ...flushOptions, @@ -288,91 +267,117 @@ oracleProperty({ check: checkFlushHistory, }) -describe(`derived updates beneath optimistic deletes`, () => { - oracleReplayTest(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, - sync: { - sync: (params) => { - sync = params - params.begin() - params.write({ type: `insert`, value: { id: 1, value: 10 } }) - params.commit() - params.markReady() - }, - }, - }) - const middle = createLiveQueryCollection({ - query: (q) => q.from({ row: source }), - }) - const derived = createLiveQueryCollection({ - query: (q) => { - const query = q.from({ row: layered ? middle : source }) - if (shape === `order`) return query.orderBy(({ row }) => row.value) - if (shape === `select`) - return query.fn.select(({ row }) => ({ ...row })) - return query - }, - }) - const downstream = createLiveQueryCollection({ - query: (q) => q.from({ row: derived }), - }) - const done = createDeferred() - const tx = createTransaction({ mutationFn: () => done.promise }) - const settled = tx.when(`settled`).catch((error: unknown) => error) - const read = (collection: { values: () => Iterable }) => - [...collection.values()] - .map((row) => ({ id: row.id, value: row.value })) - .sort((a, b) => a.id - b.id) - try { - await downstream.preload() - tx.mutate(() => derived.delete(1)) - const reconstructed = new Map() - const subscription = derived.subscribeChanges((changes) => { - for (const change of changes) { - if (change.type === `delete`) reconstructed.delete(change.key) - else reconstructed.set(change.key, stripVirtualProps(change.value)) - } - expect( - [...reconstructed.values()].sort((a, b) => a.id - b.id), - ).toEqual(read(derived)) +// The production driver for one cell of the shape/outcome matrix: queued +// batches stay invisible, then publish `expectedRowsAfterQueuedBatches`. +async function runQueuedBatchCase({ + shape, + layered, + outcome, + batches, +}: (typeof cases)[number]): Promise { + let sync!: Parameters[`sync`]>[0] + const source = createCollection({ + getKey: (row) => row.id, + sync: { + sync: (params) => { + sync = params + params.begin() + params.write({ type: `insert`, value: { id: 1, value: 10 } }) + params.commit() + params.markReady() + }, + }, + }) + const middle = createLiveQueryCollection({ + query: (q) => q.from({ row: source }), + }) + const derived = createLiveQueryCollection({ + query: (q) => { + const query = q.from({ row: layered ? middle : source }) + if (shape === `order`) return query.orderBy(({ row }) => row.value) + if (shape === `select`) return query.fn.select(({ row }) => ({ ...row })) + return query + }, + }) + const downstream = createLiveQueryCollection({ + query: (q) => q.from({ row: derived }), + }) + const done = createDeferred() + const tx = createTransaction({ mutationFn: () => done.promise }) + const settled = tx.when(`settled`).catch((error: unknown) => error) + const read = (collection: { values: () => Iterable }) => + [...collection.values()] + .map((row) => ({ id: row.id, value: row.value })) + .sort((a, b) => a.id - b.id) + try { + await downstream.preload() + tx.mutate(() => derived.delete(1)) + const reconstructed = new Map() + const subscription = derived.subscribeChanges((changes) => { + for (const change of changes) { + if (change.type === `delete`) reconstructed.delete(change.key) + else reconstructed.set(change.key, stripVirtualProps(change.value)) + } + expect([...reconstructed.values()].sort((a, b) => a.id - b.id)).toEqual( + read(derived), + ) + }) + try { + for (let index = 0; index < batches; index++) { + sync.begin() + sync.write({ type: `update`, value: { id: 1, value: 20 + index } }) + if (index > 0) + sync.write({ type: `update`, value: { id: 2, value: 40 } }) + sync.write({ + type: `insert`, + value: { id: 2 + index, value: 30 + index }, }) - try { - for (let index = 0; index < batches; index++) { - sync.begin() - sync.write({ type: `update`, value: { id: 1, value: 20 + index } }) - if (index > 0) - sync.write({ type: `update`, value: { id: 2, value: 40 } }) - sync.write({ - type: `insert`, - value: { id: 2 + index, value: 30 + index }, - }) - expect(sync.commit()).toBe(true) - // The whole graph-output batch remains queued, including the insert. - expect(read(derived)).toEqual([]) - expect(read(downstream)).toEqual([]) - } - if (outcome === `rollback`) done.reject(new Error(`Rejected delete`)) - else done.resolve() - await settled - const expected = expectedRowsAfterQueuedBatches(batches) - expect(read(derived)).toEqual(expected) - expect(read(downstream)).toEqual(expected) - } finally { - subscription.unsubscribe() - } - } finally { - done.resolve() - await settled - await downstream.cleanup() - await derived.cleanup() - await middle.cleanup() - await source.cleanup() + expect(sync.commit()).toBe(true) + // The whole graph-output batch remains queued, including the insert. + expect(read(derived)).toEqual([]) + expect(read(downstream)).toEqual([]) } + if (outcome === `rollback`) done.reject(new Error(`Rejected delete`)) + else done.resolve() + await settled + const expected = expectedRowsAfterQueuedBatches(batches) + expect(read(derived)).toEqual(expected) + expect(read(downstream)).toEqual(expected) + } finally { + subscription.unsubscribe() + } + } finally { + done.resolve() + await settled + await downstream.cleanup() + await derived.cleanup() + await middle.cleanup() + await source.cleanup() + } +} + +describe(`derived updates beneath optimistic deletes`, () => { + oracleProperty({ + id: `derived-publication.queued-batches`, + law: { + statement: `While an optimistic delete hides a derived row, synced source batches are queued and publish nothing until the transaction settles; after settlement the derived collection and its downstream hold the source's committed rows.`, + authority: `The existing derived-publication law stated at the start of this owner.`, + limits: `The finite shape (pass-through, order, select), layering, outcome (acknowledge, rollback), and one-or-two-batch matrix.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/transactions.ts#createTransaction', + ], + histories: cases, + label: ({ shape, layered, outcome, batches }) => + `${shape} / layered=${layered} / ${outcome} / ${batches} batches`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`queued derived batches`, plan, observe, () => + runQueuedBatchCase(plan.cell), + ), + check: forwardAssertionJudgment, + }) }) 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 fd50daffa8..e09fcfe25f 100644 --- a/packages/db/tests/query/mixed-index-order-oracle.test.ts +++ b/packages/db/tests/query/mixed-index-order-oracle.test.ts @@ -24,16 +24,20 @@ * public-prefix comparison; a same-type step checks that the repair still * permits indexed traversal when its order agrees with the query. */ -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import type { Collection } from '../../src/collection/index.js' import type { LoadSubsetOptions, @@ -253,27 +257,48 @@ async function expectIndexedPublicOrder( ]) } -declareOracle({ +const indexTypes = { BasicIndex, BTreeIndex } +const scenarios = new Map( + histories.map((scenario) => [scenario.name, scenario]), +) + +oracleProperty({ + id: `mixed-index-order.public-order`, + law: { + statement: `An index is a physical route for an orderBy query: it returns the same exact ordered public IDs as a full-source scan of the same query, on the first read and after a source value changes kind.`, + authority: `The orderBy/limit contract in docs/guides/live-queries.md.`, + limits: `The finite mixed-kind histories above, on BasicIndex and BTreeIndex, ascending and descending, plus one on-demand provider history; no order is prescribed for every mixed-kind combination.`, + }, 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], + histories: histories.flatMap((scenario) => + ([`BasicIndex`, `BTreeIndex`] as const).flatMap((index) => + ([`asc`, `desc`] as const).map((direction) => ({ + scenario: scenario.name, + index, + direction, + })), + ), + ), + label: ({ scenario, index, direction }) => + `${index} preserves ${scenario} ${direction} public order across kind changes`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`mixed index public order`, plan, observe, () => + expectIndexedPublicOrder( + scenarios.get(plan.cell.scenario)!, + indexTypes[plan.cell.index], + plan.cell.direction, + ), + ), + check: forwardAssertionJudgment, }) -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`, () => { - oracleRoute(`case`) - return expectIndexedPublicOrder(scenario, IndexType, direction) - }) - } - } -} - /** * The eager histories above do not cross provider demand. This receiving * history begins with a homogeneous numeric index and a finite on-demand @@ -330,8 +355,9 @@ function evaluateNumericRequest( return evaluateReferenceExpression(expression, row) } -it(`preserves a retained on-demand window after its index domain becomes mixed`, async () => { - oracleRoute(`fixed history`) +// The production driver for the on-demand receiving history: the provider +// and the model that sorts its source rows are the independent reference. +async function runRetainedMixedWindowHistory(): Promise { const initial: Array = [ { id: `three`, value: 3 }, { id: `five`, value: 5 }, @@ -464,4 +490,30 @@ it(`preserves a retained on-demand window after its index domain becomes mixed`, requests.slice(requestsBeforePage).some((request) => request.cursor), ).toBe(true) }, [() => take?.mockRestore(), () => live?.cleanup(), () => source.cleanup()]) +} + +oracleProperty({ + id: `mixed-index-order.on-demand-window`, + law: { + statement: `An index is a physical route for an orderBy query: it returns the same exact ordered public IDs as a full-source scan of the same query, on the first read and after a source value changes kind.`, + authority: `The orderBy/limit contract in docs/guides/live-queries.md.`, + limits: `The finite mixed-kind histories above, on BasicIndex and BTreeIndex, ascending and descending, plus one on-demand provider history; no order is prescribed for every mixed-kind combination.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/basic-index.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `preserves a retained on-demand window after its index domain becomes mixed`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`mixed index public order`, plan, observe, () => + runRetainedMixedWindowHistory(), + ), + check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/query/ordered-source-loader-model-oracle.ts b/packages/db/tests/query/ordered-source-loader-model-oracle.ts new file mode 100644 index 0000000000..3b4f4b1afe --- /dev/null +++ b/packages/db/tests/query/ordered-source-loader-model-oracle.ts @@ -0,0 +1,26 @@ +// The boundary-by-demand grammar and the independent immediate-work model of +// ordered-source-loader-oracle.test.ts, moved unchanged so that the grammar +// check in ordered-source-loader-model.test.ts can read them. That oracle file +// states the law. This module registers no tests. +export const stagedWindowAxes = { + boundary: [`absent`, `present`] as const, + demand: [`satisfied`, `widened`] as const, +} +export const stagedWindowCases = stagedWindowAxes.boundary.flatMap((boundary) => + stagedWindowAxes.demand.map((demand) => ({ boundary, demand })), +) + +/** + * Independent immediate-work model for an explicit window that consumes a + * staged boundary continuation. A present boundary requires its tie + * acquisition. An absent boundary starts no work, so only remaining window + * demand can require a prefix acquisition. + */ +export const expectedStagedWindowTrace = ( + boundary: (typeof stagedWindowAxes.boundary)[number], + demand: (typeof stagedWindowAxes.demand)[number], +): Array<`page` | `boundary`> => [ + `page`, + ...(boundary === `present` ? ([`boundary`] as const) : []), + ...(demand === `widened` ? ([`page`] as const) : []), +] diff --git a/packages/db/tests/query/ordered-source-loader-model.test.ts b/packages/db/tests/query/ordered-source-loader-model.test.ts new file mode 100644 index 0000000000..6ece64264d --- /dev/null +++ b/packages/db/tests/query/ordered-source-loader-model.test.ts @@ -0,0 +1,16 @@ +// Grammar check for ordered-source-loader-oracle.test.ts. It runs no +// production: it checks that the boundary-by-demand grammar names each cell +// once. The grammar is in ordered-source-loader-model-oracle.ts. +import { describe, expect, it } from 'vitest' +import { stagedWindowCases } from './ordered-source-loader-model-oracle.js' + +describe(`OrderedSourceLoader`, () => { + it(`enumerates every staged-boundary explicit-window cell exactly once`, () => { + const keys = stagedWindowCases.map( + ({ boundary, demand }) => `${boundary}:${demand}`, + ) + + expect(stagedWindowCases).toHaveLength(4) + expect(new Set(keys).size).toBe(4) + }) +}) 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 61712a5e9c..8fcf2b6814 100644 --- a/packages/db/tests/query/ordered-source-loader-oracle.test.ts +++ b/packages/db/tests/query/ordered-source-loader-oracle.test.ts @@ -1,152 +1,142 @@ // Law: an explicit window that consumes a staged boundary continuation issues // only the immediate work that its boundary and remaining demand require. The // model `expectedStagedWindowTrace` states that trace for each cell of the -// boundary-by-demand grammar `stagedWindowCases`. The remaining scenarios in -// this area are fixed histories, owned by ordered-source-loader.test.ts. -import { describe, expect, it, vi } from 'vitest' +// boundary-by-demand grammar `stagedWindowCases`; both live in +// ordered-source-loader-model-oracle.ts. The remaining scenarios in this area +// are fixed histories, owned by ordered-source-loader.test.ts. +import { describe, expect, 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createDeferred, createOrderByInfo, } from './ordered-source-loader-support.js' +import { + expectedStagedWindowTrace, + stagedWindowCases, +} from './ordered-source-loader-model-oracle.js' import type { RequestOptions } from './ordered-source-loader-support.js' import type { CollectionSubscription } from '../../src/collection/subscription.js' -const stagedWindowAxes = { - boundary: [`absent`, `present`] as const, - demand: [`satisfied`, `widened`] as const, -} -const stagedWindowCases = stagedWindowAxes.boundary.flatMap((boundary) => - stagedWindowAxes.demand.map((demand) => ({ boundary, demand })), -) +// The production driver: one staged-boundary explicit-window cell through a +// real OrderedSourceLoader, compared with `expectedStagedWindowTrace`. +async function runStagedWindowCell({ + boundary, + demand, +}: (typeof stagedWindowCases)[number]): Promise { + type ObservedRequest = { + kind: `page` | `boundary` + options: RequestOptions + settlement?: ReturnType + } + const requests: Array = [] + let graphInputRevision = 0 + let dataNeeded = 1 + const request = (kind: ObservedRequest[`kind`], options: RequestOptions) => { + if (requests.length === 0) { + requests.push({ kind, options }) + graphInputRevision++ + options.onLoadSubsetResult?.(true, options, () => {}) + return + } + const settlement = createDeferred() + requests.push({ kind, options, settlement }) + options.onLoadSubsetResult?.(settlement.promise, options, () => {}) + } + const subscription = { + setOrderByIndex: () => {}, + readOrderedSnapshot: () => + boundary === `present` ? [{ value: { rank: 1 } }] : [], + requestLimitedSnapshot: (options: RequestOptions) => + request(`page`, options), + requestSnapshot: (options: RequestOptions) => request(`boundary`, options), + } + const info = createOrderByInfo({ dataNeeded: () => dataNeeded }) + const loader = new OrderedSourceLoader( + info, + subscription as unknown as CollectionSubscription, + `row`, + undefined, + undefined, + () => graphInputRevision, + ) -/** - * Independent immediate-work model for an explicit window that consumes a - * staged boundary continuation. A present boundary requires its tie - * acquisition. An absent boundary starts no work, so only remaining window - * demand can require a prefix acquisition. - */ -const expectedStagedWindowTrace = ( - boundary: (typeof stagedWindowAxes.boundary)[number], - demand: (typeof stagedWindowAxes.demand)[number], -): Array<`page` | `boundary`> => [ - `page`, - ...(boundary === `present` ? ([`boundary`] as const) : []), - ...(demand === `widened` ? ([`page`] as const) : []), -] + try { + loader.start() + expect( + (loader as unknown as { stagedContinuation?: unknown }) + .stagedContinuation, + ).toBeDefined() -declareOracle({ - owns: ['src/query/live/ordered-source-loader.ts'], - model: [expectedStagedWindowTrace], - grammar: [stagedWindowCases], -}) + if (demand === `widened`) { + info.limit = 2 + dataNeeded = 2 + } else { + dataNeeded = 0 + } + const window = loader.loadMore(1) -describe(`OrderedSourceLoader`, () => { - it(`enumerates every staged-boundary explicit-window cell exactly once`, () => { - oracleRoute(`case`) - const keys = stagedWindowCases.map( - ({ boundary, demand }) => `${boundary}:${demand}`, - ) + if (requests.length === 1) { + expect(window).toBeUndefined() + } else { + expect(window).toBeInstanceOf(Promise) + } - expect(stagedWindowCases).toHaveLength(4) - expect(new Set(keys).size).toBe(4) - }) + if (boundary === `present` && demand === `widened`) { + requests[1]!.settlement!.resolve() + await vi.waitFor(() => expect(requests).toHaveLength(3)) + } + for (const observed of requests.slice(1)) { + observed.settlement?.resolve() + } + await window - it.each(stagedWindowCases)( - `refines staged boundary $boundary with $demand explicit-window demand`, - async ({ boundary, demand }) => { - oracleRoute(`case`) - type ObservedRequest = { - kind: `page` | `boundary` - options: RequestOptions - settlement?: ReturnType - } - const requests: Array = [] - let graphInputRevision = 0 - let dataNeeded = 1 - const request = ( - kind: ObservedRequest[`kind`], - options: RequestOptions, - ) => { - if (requests.length === 0) { - requests.push({ kind, options }) - graphInputRevision++ - options.onLoadSubsetResult?.(true, options, () => {}) - return - } - const settlement = createDeferred() - requests.push({ kind, options, settlement }) - options.onLoadSubsetResult?.(settlement.promise, options, () => {}) - } - const subscription = { - setOrderByIndex: () => {}, - readOrderedSnapshot: () => - boundary === `present` ? [{ value: { rank: 1 } }] : [], - requestLimitedSnapshot: (options: RequestOptions) => - request(`page`, options), - requestSnapshot: (options: RequestOptions) => - request(`boundary`, options), + expect(requests.map(({ kind }) => kind)).toEqual( + expectedStagedWindowTrace(boundary, demand), + ) + if (demand === `widened`) { + const page = requests.at(-1)! + expect(page.kind).toBe(`page`) + expect(page.options.limit).toBe(2) + if (boundary === `absent`) { + expect(page.options.offset).toBe(0) + expect(page.options.minValues).toBeUndefined() + } else { + expect(page.options.minValues).toEqual([1]) } - const info = createOrderByInfo({ dataNeeded: () => dataNeeded }) - const loader = new OrderedSourceLoader( - info, - subscription as unknown as CollectionSubscription, - `row`, - undefined, - undefined, - () => graphInputRevision, - ) - - try { - loader.start() - expect( - (loader as unknown as { stagedContinuation?: unknown }) - .stagedContinuation, - ).toBeDefined() - - if (demand === `widened`) { - info.limit = 2 - dataNeeded = 2 - } else { - dataNeeded = 0 - } - const window = loader.loadMore(1) - - if (requests.length === 1) { - expect(window).toBeUndefined() - } else { - expect(window).toBeInstanceOf(Promise) - } - - if (boundary === `present` && demand === `widened`) { - requests[1]!.settlement!.resolve() - await vi.waitFor(() => expect(requests).toHaveLength(3)) - } - for (const observed of requests.slice(1)) { - observed.settlement?.resolve() - } - await window + } + } finally { + for (const observed of requests) observed.settlement?.resolve() + loader.dispose() + } +} - expect(requests.map(({ kind }) => kind)).toEqual( - expectedStagedWindowTrace(boundary, demand), - ) - if (demand === `widened`) { - const page = requests.at(-1)! - expect(page.kind).toBe(`page`) - expect(page.options.limit).toBe(2) - if (boundary === `absent`) { - expect(page.options.offset).toBe(0) - expect(page.options.minValues).toBeUndefined() - } else { - expect(page.options.minValues).toEqual([1]) - } - } - } finally { - for (const observed of requests) observed.settlement?.resolve() - loader.dispose() - } +describe(`OrderedSourceLoader`, () => { + oracleProperty({ + id: `ordered-source-loader.staged-window`, + law: { + statement: `An explicit window that consumes a staged boundary continuation issues only the immediate work that its boundary and remaining demand require.`, + authority: `The ordered loading laws of packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The four boundary-by-demand cells of stagedWindowCases; the remaining scenarios are fixed histories in ordered-source-loader.test.ts.`, }, - ) + owns: ['src/query/live/ordered-source-loader.ts'], + histories: stagedWindowCases, + label: ({ boundary, demand }) => + `refines staged boundary ${boundary} with ${demand} explicit-window demand`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `staged boundary window work`, + plan, + observe, + () => runStagedWindowCell(plan.cell), + ), + check: forwardAssertionJudgment, + }) }) From d521c3e76988d92e7ca0535404568086f91e5688 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:50:28 -0700 Subject: [PATCH 031/116] test(db-ivm): register the incrementalization named cells through oracleProperty Converts the four plain-test sites in incrementalization-law-oracle.property.test.ts. The named cells become the enumerated property ivm.incrementalization-law-cells (P2); the former body runs unchanged through recordAssertionJudgment. The promotion census samples only the grammar, and the two fault-control calibrations run the shared checker against planted graphs and illegal inputs, so all three move unchanged to incrementalization-law-calibration.test.ts (P6). The operator helpers, value domains, grammar, and direct evaluators that they share with the oracle move unchanged to incrementalization-law-domain-oracle.ts. Co-authored-by: Isaac --- ...incrementalization-law-calibration.test.ts | 283 +++++ .../incrementalization-law-domain-oracle.ts | 429 +++++++ ...ementalization-law-oracle.property.test.ts | 1125 ++++------------- .../converted-properties/frameworks-ivm.ts | 6 + 4 files changed, 978 insertions(+), 865 deletions(-) create mode 100644 packages/db-ivm/tests/incrementalization-law-calibration.test.ts create mode 100644 packages/db-ivm/tests/incrementalization-law-domain-oracle.ts diff --git a/packages/db-ivm/tests/incrementalization-law-calibration.test.ts b/packages/db-ivm/tests/incrementalization-law-calibration.test.ts new file mode 100644 index 0000000000..39c072a798 --- /dev/null +++ b/packages/db-ivm/tests/incrementalization-law-calibration.test.ts @@ -0,0 +1,283 @@ +// Calibrations of the DBSP incrementalization oracle's grammar and checker. +// They run the grammar and the shared law checker, not a production campaign: +// the census samples the fixed ordered campaign of +// incrementalization-law-oracle.property.test.ts (the same seed and run +// count), and each fault control plants a wrong operator graph or an illegal +// input state that the checker must reject. +import { fc } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { + consolidate, + filter, + groupBy, + groupByOperators, + map, + orderByWithFractionalIndex, + reduce, +} from '../src/operators/index.js' +import { oracleFixedSeed, oracleRuns } from '../../db/tests/oracle-config.js' +import { assertUnaryIncrementalization } from './incrementalization-law-oracle.js' +import { + firstThree, + firstTwoPerParity, + groupedAggregates, + groupedPolicy, + keyedIdentity, + keyedPolicy, + orderedHistory, + uniqueRowSplitDomain, + withoutIndex, + withoutPromotion, +} from './incrementalization-law-domain-oracle.js' +import type { Weighted } from './incrementalization-law-oracle.js' +import type { Keyed } from './incrementalization-law-domain-oracle.js' + +const FIXED_SEED = 1741 +const RUNS = oracleRuns(100) + +describe(`DBSP incrementalization laws`, () => { + // Reconstruction: the fixed ordered campaign reaches promotion. Sampling + // its arbitrary with its seed and run count yields exactly the histories it + // executes. A promotion is a transition after which a window holds a row + // that the previous world held, unchanged, outside the window: a removal + // or displacement promoted it. Only a window that already holds its limit + // 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`, () => { + const promotions = { global: 0, perParity: 0 } + const promoted = ( + window: (rows: Weighted) => Weighted, + previous: Weighted, + next: Weighted, + ) => { + const before = new Set(window(previous).map(([row]) => String(row))) + const held = new Set(previous.map(([row]) => String(row))) + return window(next).some( + ([row]) => held.has(String(row)) && !before.has(String(row)), + ) + } + 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++ + if (promoted(firstTwoPerParity, previous, next)) promotions.perParity++ + } + } + // At 100 runs: 25 global and 21 per-parity promotions; independent + // worlds alone gave 2 and 2. + expect(promotions.global).toBeGreaterThanOrEqual(10) + expect(promotions.perParity).toBeGreaterThanOrEqual(10) + }) + + it(`rejects invalid negative and duplicate-key input states`, () => { + // A checker calibration: a failure here is the framework's, not a kill. + expect(() => + assertUnaryIncrementalization({ + name: `negative input state excluded`, + initial: [[[0, 1], -1]], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => input.pipe(consolidate()), + evaluate: keyedIdentity, + }), + ).toThrow(/illegal negative input state/) + + expect(() => + assertUnaryIncrementalization({ + name: `duplicate unique row key excluded`, + initial: [ + [[0, 1], 1], + [[0, 2], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => input.pipe(consolidate()), + evaluate: keyedIdentity, + }), + ).toThrow(/no unit ordering with legal split-delivery prefixes/) + }) + + it(`rejects omitted, sign-flipped, wrong-member, wrong-window, wrong-limit, missing-promotion, and truthiness-filtered output`, () => { + expect(() => + assertUnaryIncrementalization({ + name: `omitted output fault`, + initial: [[[0, 1], 1]], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => input.pipe(filter(() => false)), + evaluate: keyedIdentity, + }), + ).toThrow(/output delta diverged/) + expect(() => + assertUnaryIncrementalization({ + name: `sign-flipped weight fault`, + initial: [[[0, 1], 1]], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + // A reduce that negates every multiplicity flips the sign of each row. + build: (input) => + input.pipe( + reduce((values) => + values.map(([value, weight]) => [value, -weight] as const), + ), + ), + evaluate: keyedIdentity, + }), + ).toThrow(/output delta diverged/) + expect(() => + assertUnaryIncrementalization({ + name: `wrong member fault`, + initial: [[[0, 1], 1]], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe(map(([key, value]) => [key, value + 1] as Keyed)), + evaluate: keyedIdentity, + }), + ).toThrow(/output delta diverged/) + expect(() => + assertUnaryIncrementalization({ + name: `wrong window fault`, + initial: [ + [[0, 0], 1], + [[1, 1], 1], + [[2, 2], 1], + [[3, 3], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { + comparator: (left, right) => right - left, + limit: 3, + }), + withoutIndex(), + ), + evaluate: firstThree, + }), + ).toThrow(/output delta diverged/) + expect(() => + assertUnaryIncrementalization({ + name: `wrong window limit fault`, + initial: [ + [[0, 1] as Keyed, 1], + [[1, 2] as Keyed, 1], + [[2, 3] as Keyed, 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 3 }), + withoutIndex(), + ), + evaluate: (input) => firstThree(input).slice(0, 2), + }), + ).toThrow(/output delta diverged at checkpoint 0/) + // On the promotion history, the same wrong limit agrees with the model + // until the third value arrives past the boundary. + expect(() => + assertUnaryIncrementalization({ + name: `wrong promotion limit fault`, + initial: [ + [[0, 1] as Keyed, 1], + [[1, 2] as Keyed, 1], + ], + batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 3 }), + withoutIndex(), + ), + evaluate: (input) => firstThree(input).slice(0, 2), + }), + ).toThrow(/output delta diverged at checkpoint 1/) + // A window that removes the first value without promoting the third + // agrees with the model until the removal. + expect(() => + assertUnaryIncrementalization({ + name: `missing promotion fault`, + initial: [ + [[0, 1] as Keyed, 1], + [[1, 2] as Keyed, 1], + ], + batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 2 }), + withoutPromotion(), + withoutIndex(), + ), + evaluate: (input) => firstThree(input).slice(0, 2), + }), + ).toThrow(/output delta diverged at checkpoint 2/) + + const truthinessMinimum = { + preMap: ([, value]: Keyed): number | undefined => value, + reduce: (values: Array<[number | undefined, number]>) => { + let minimum: number | undefined + for (const [value] of values) { + if (!minimum || (value !== undefined && value && value < minimum)) { + minimum = value + } + } + return minimum + }, + postMap: (result: number | undefined) => result, + } + const truthinessMaximum = { + preMap: ([, value]: Keyed): number | undefined => value, + reduce: (values: Array<[number | undefined, number]>) => { + let maximum: number | undefined + for (const [value] of values) { + if (!maximum || (value !== undefined && value && value > maximum)) { + maximum = value + } + } + return maximum + }, + postMap: (result: number | undefined) => result, + } + expect(() => + assertUnaryIncrementalization({ + name: `truthiness-filtered extrema fault`, + initial: [ + [[0, 5], 1], + [[2, 0], 1], + [[1, -2], 1], + [[3, 0], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: groupedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + groupBy(([key]) => ({ bucket: key % 2 }), { + total: groupByOperators.sum(([, value]) => value), + minimum: truthinessMinimum, + maximum: truthinessMaximum, + mean: groupByOperators.avg(([, value]) => value), + }), + ), + evaluate: groupedAggregates, + }), + ).toThrow(/output delta diverged/) + }) +}) diff --git a/packages/db-ivm/tests/incrementalization-law-domain-oracle.ts b/packages/db-ivm/tests/incrementalization-law-domain-oracle.ts new file mode 100644 index 0000000000..933a85dbe1 --- /dev/null +++ b/packages/db-ivm/tests/incrementalization-law-domain-oracle.ts @@ -0,0 +1,429 @@ +// The operator helpers, value domains, history grammar, and direct +// evaluators of the DBSP incrementalization oracle. The law, its production +// drivers, and the comparisons are in +// incrementalization-law-oracle.property.test.ts; the grammar census and the +// checker calibrations are in incrementalization-law-calibration.test.ts. +// This module registers no tests and has no side effects. +import { fc } from '@fast-check/vitest' +import { join, map } from '../src/operators/index.js' +import { StreamBuilder } from '../src/d2.js' +import { DifferenceStreamWriter, LinearUnaryOperator } from '../src/graph.js' +import { MultiSet } from '../src/multiset.js' +import { + jsonLawValuePolicy, + weightedStateArbitrary, + weightedTransitions, +} from './incrementalization-law-oracle.js' +import type { Weighted } from './incrementalization-law-oracle.js' +import type { IStreamBuilder, KeyValue, PipedOperator } from '../src/types.js' + +// An inner join matches only present rows, so neither side of its output is +// null. +export function innerJoined( + right: IStreamBuilder>, +): PipedOperator, KeyValue> { + return join(right, `inner`) as unknown as PipedOperator< + KeyValue, + KeyValue + > +} + +// The window operators annotate each row with a fractional index. These laws +// compare rows only, so the index is dropped from the output. +export function withoutIndex() { + return map(([key, [value]]: [K, [V, string]]) => [key, value] as [K, V]) +} + +// A fault for the promotion law: a window that forwards a removal but drops +// the rows entering in the same message, as a top-K that forgets to promote +// the next row would. +export class WithoutPromotionOperator extends LinearUnaryOperator { + inner(collection: MultiSet): MultiSet { + const changes = collection.getInner() + return changes.some(([, weight]) => weight < 0) + ? new MultiSet(changes.filter(([, weight]) => weight < 0)) + : collection + } +} + +export function withoutPromotion(): PipedOperator { + return (stream: IStreamBuilder): IStreamBuilder => { + const output = new StreamBuilder( + stream.graph, + new DifferenceStreamWriter(), + ) + stream.graph.addOperator( + new WithoutPromotionOperator( + stream.graph.getNextOperatorId(), + stream.connectReader(), + output.writer, + ), + ) + return output + } +} + +export type Keyed = [number, number] +export type JoinOutput = [number, [number, number]] +export type OuterJoinOutput = [number, [number | null, number | null]] +export type GroupedMeanOutput = [string, { bucket: number; mean: number }] + +export type GroupedTextOutput = [ + string, + { + bucket: number + lowFirst: string | undefined + highFirst: string | undefined + lowSecond: string | undefined + highSecond: string | undefined + }, +] + +/** + * Each value names a pair of strings. groupBy merges contributions with equal + * pre-aggregate values without hashing them, so its identity for a string + * must not depend on where one value ends and the next begins. A string's + * identity is `"` and its text, joined to the next part with `|`, so the + * pairs `["x|\"y", ""]` and `["x", "y|\""]`, and `["|\"", ""]` and + * `["", "|\""]`, read the same without each string's length. + */ +export const TEXT_PAIRS: ReadonlyArray = [ + [`x|"y`, ``], + [`x`, `y|"`], + [`|"`, ``], + [``, `|"`], + [`a`, `"`], + [`a|"`, ``], + [``, ``], +] +// Named witnesses reach past the generated -3..3 range, so the table wraps. +export const textPair = (value: number) => + TEXT_PAIRS[ + ((value % TEXT_PAIRS.length) + TEXT_PAIRS.length) % TEXT_PAIRS.length + ]! + +export type GroupedOutput = [ + string, + { + bucket: number + total: number + minimum: number | undefined + maximum: number | undefined + mean: number + }, +] + +export const CANCELLATION_ROW: Keyed = [99, 99] +export const keyedPolicy = jsonLawValuePolicy() +export const joinPolicy = jsonLawValuePolicy() +export const outerJoinPolicy = jsonLawValuePolicy() +export const groupedPolicy = jsonLawValuePolicy() +export const groupedTextPolicy = jsonLawValuePolicy() +export const groupedMeanPolicy = jsonLawValuePolicy() +export const uniqueRowSplitDomain = { + uniqueKey: ([key]: Keyed) => String(key), +} + +export const weightedWorld = weightedStateArbitrary( + fc.tuple(fc.integer({ min: 0, max: 3 }), fc.integer({ min: -3, max: 3 })), + { maxLength: 8, maxWeight: 2 }, +) + +export function state(values: Weighted): Map { + const result = new Map() + for (const [value, delta] of values) { + const key = JSON.stringify(value) + const weight = (result.get(key)?.[1] ?? 0) + delta + if (weight === 0) result.delete(key) + else result.set(key, [value, weight]) + } + return result +} + +export function transitions( + worlds: Array>, +): Array> { + return weightedTransitions(worlds, keyedPolicy, CANCELLATION_ROW) +} + +export function keyedIdentity(input: Weighted): Weighted { + return [...state(input).values()].map(([value, weight]) => [value, weight]) +} + +export function summed(input: Weighted): Weighted { + const groups = new Map() + for (const [[key, value], weight] of keyedIdentity(input)) { + groups.set(key, (groups.get(key) ?? 0) + value * weight) + } + return [...groups].map(([key, value]) => [[key, value], 1]) +} + +export function joined( + left: Weighted, + right: Weighted, +): Weighted { + const result: Weighted = [] + for (const [[leftKey, leftValue], leftWeight] of keyedIdentity(left)) { + for (const [[rightKey, rightValue], rightWeight] of keyedIdentity(right)) { + if (leftKey === rightKey) { + result.push([ + [leftKey, [leftValue, rightValue]], + leftWeight * rightWeight, + ]) + } + } + } + return result +} + +export function fullJoined( + left: Weighted, + right: Weighted, +): Weighted { + const leftRows = keyedIdentity(left) + const rightRows = keyedIdentity(right) + const result: Weighted = [] + for (const [[leftKey, leftValue], leftWeight] of leftRows) { + const matches = rightRows.filter(([[rightKey]]) => rightKey === leftKey) + if (matches.length === 0) { + result.push([[leftKey, [leftValue, null]], leftWeight]) + } else { + for (const [[, rightValue], rightWeight] of matches) { + result.push([ + [leftKey, [leftValue, rightValue]], + leftWeight * rightWeight, + ]) + } + } + } + for (const [[rightKey, rightValue], rightWeight] of rightRows) { + if (leftRows.every(([[leftKey]]) => leftKey !== rightKey)) { + result.push([[rightKey, [null, rightValue]], rightWeight]) + } + } + return result +} + +export function leftJoined( + left: Weighted, + right: Weighted, +): Weighted { + return fullJoined(left, right).filter( + ([[, [leftValue]]]) => leftValue !== null, + ) +} + +export function rightJoined( + left: Weighted, + right: Weighted, +): Weighted { + return fullJoined(left, right).filter( + ([[, [, rightValue]]]) => rightValue !== null, + ) +} + +export function joinedSums( + left: Weighted, + right: Weighted, +): Weighted { + const sums = new Map() + for (const [[key, [leftValue, rightValue]], weight] of joined(left, right)) { + sums.set(key, (sums.get(key) ?? 0) + leftValue * rightValue * weight) + } + return [...sums].map(([key, value]) => [[key, value], 1]) +} + +/** + * Group extrema come from every retained defined value. Zero is a value, not + * absence. The model recomputes from plain weighted rows and shares no + * aggregate reducer with production. + */ +export function groupedAggregates( + input: Weighted, +): Weighted { + const groups = new Map< + number, + { total: number; minimum: number; maximum: number; count: number } + >() + for (const [[key, value], weight] of keyedIdentity(input)) { + const bucket = key % 2 + const current = groups.get(bucket) + groups.set( + bucket, + current === undefined + ? { + total: value * weight, + minimum: value, + maximum: value, + count: weight, + } + : { + total: current.total + value * weight, + minimum: Math.min(current.minimum, value), + maximum: Math.max(current.maximum, value), + count: current.count + weight, + }, + ) + } + return [...groups].map(([bucket, { count, ...aggregates }]) => [ + [ + JSON.stringify({ bucket }), + // The mean is the weighted total over the weighted row count. + { bucket, ...aggregates, mean: aggregates.total / count }, + ], + 1, + ]) +} + +/** + * The per-bucket mean alone. With no other aggregate, every value's identity + * comes from avg, so an avg identity that merges different sums shows here. + */ +export function groupedMean( + input: Weighted, +): Weighted { + const groups = new Map() + for (const [[key, value], weight] of keyedIdentity(input)) { + const current = groups.get(key % 2) ?? { total: 0, count: 0 } + groups.set(key % 2, { + total: current.total + value * weight, + count: current.count + weight, + }) + } + return [...groups].map(([bucket, { total, count }]) => [ + [JSON.stringify({ bucket }), { bucket, mean: total / count }], + 1, + ]) +} + +/** Per-bucket string extrema of both parts, recomputed from plain rows. */ +export function groupedTextExtrema( + input: Weighted, +): Weighted { + const groups = new Map>() + for (const [[key, value]] of keyedIdentity(input)) { + const pairs = groups.get(key % 2) ?? [] + pairs.push(textPair(value)) + groups.set(key % 2, pairs) + } + const low = (values: Array) => + values.reduce((left, right) => (right < left ? right : left)) + const high = (values: Array) => + values.reduce((left, right) => (right > left ? right : left)) + return [...groups].map(([bucket, pairs]) => { + const first = pairs.map(([text]) => text) + const second = pairs.map(([, text]) => text) + return [ + [ + JSON.stringify({ bucket }), + { + bucket, + lowFirst: low(first), + highFirst: high(first), + lowSecond: low(second), + highSecond: high(second), + }, + ], + 1, + ] + }) +} + +export const orderedWorld = fc + .uniqueArray( + fc.tuple(fc.integer({ min: 0, max: 7 }), fc.integer({ min: -20, max: 20 })), + { maxLength: 8, selector: ([key]) => key }, + ) + .filter((rows) => new Set(rows.map(([, rank]) => rank)).size === rows.length) + .map((rows): Weighted => rows.map((row) => [row, 1])) + +// Independent worlds rarely share rows, so a window row seldom leaves while +// the next row stays to be promoted. Most ordered steps therefore edit the +// previous world: they remove one row or insert one with an unused key and +// rank. A fresh world still replaces the relation now and then. +export type OrderedStep = + | { type: `fresh`; world: Weighted } + | { type: `remove`; pick: number } + | { type: `insert`; row: Keyed } +export const orderedHistory: fc.Arbitrary>> = fc + .tuple( + orderedWorld, + fc.array( + fc.oneof( + { + weight: 1, + arbitrary: orderedWorld.map((world) => ({ + type: `fresh` as const, + world, + })), + }, + { + weight: 2, + arbitrary: fc.nat(7).map((pick) => ({ + type: `remove` as const, + pick, + })), + }, + { + weight: 2, + arbitrary: fc + .tuple( + fc.integer({ min: 0, max: 7 }), + fc.integer({ min: -20, max: 20 }), + ) + .map((row) => ({ type: `insert` as const, row })), + }, + ), + { minLength: 1, maxLength: 6 }, + ), + ) + .map(([initial, steps]) => { + const worlds = [initial] + for (const step of steps as Array) { + const previous = worlds.at(-1)! + if (step.type === `fresh`) { + worlds.push(step.world) + } else if (step.type === `remove`) { + worlds.push( + previous.filter( + (_, index) => index !== step.pick % Math.max(previous.length, 1), + ), + ) + } else { + const [key, rank] = step.row + const unused = previous.every( + ([[otherKey, otherRank]]) => otherKey !== key && otherRank !== rank, + ) + worlds.push(unused ? [...previous, [step.row, 1]] : previous) + } + } + return worlds + }) + +// The grammar of legal ordered histories. Its calibration test in +// incrementalization-law-calibration.test.ts checks that the generated steps +// reach row promotions, which the ordered laws need. + +export function firstThree(input: Weighted): Weighted { + return keyedIdentity(input) + .sort(([[leftKey, leftRank]], [[rightKey, rightRank]]) => + leftRank === rightRank ? leftKey - rightKey : leftRank - rightRank, + ) + .slice(0, 3) +} + +export function firstTwoPerParity(input: Weighted): Weighted { + const groups = new Map>() + for (const [value, weight] of keyedIdentity(input)) { + const values = groups.get(value[0] % 2) ?? [] + values.push([value, weight]) + groups.set(value[0] % 2, values) + } + return [...groups.values()].flatMap((values) => + values + .sort(([[leftKey, leftRank]], [[rightKey, rightRank]]) => + leftRank === rightRank ? leftKey - rightKey : leftRank - rightRank, + ) + .slice(0, 2), + ) +} 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 82a80c2b75..3f7dc06d9d 100644 --- a/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law-oracle.property.test.ts @@ -1,9 +1,8 @@ import { setImmediate } from 'node:timers/promises' import { fc } from '@fast-check/vitest' -import { afterEach, beforeEach, describe, expect, it as vitestIt } from 'vitest' +import { afterEach, beforeEach, describe, expect } from 'vitest' import { consolidate, - filter, groupBy, groupByOperators, groupedOrderByWithFractionalIndex, @@ -12,82 +11,56 @@ import { orderByWithFractionalIndex, reduce, } from '../src/operators/index.js' -import { StreamBuilder } from '../src/d2.js' -import { DifferenceStreamWriter, LinearUnaryOperator } from '../src/graph.js' -import { MultiSet } from '../src/multiset.js' -import { - oracleFixedSeed, - oracleReplayTest, - oracleRoute, - oracleRuns, -} from '../../db/tests/oracle-config.js' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter.js' import { assertBinaryIncrementalization, assertUnaryIncrementalization, - jsonLawValuePolicy, weightedDifference, - weightedStateArbitrary, - weightedTransitions, } from './incrementalization-law-oracle.js' +import { + CANCELLATION_ROW, + firstThree, + firstTwoPerParity, + fullJoined, + groupedAggregates, + groupedMean, + groupedMeanPolicy, + groupedPolicy, + groupedTextExtrema, + groupedTextPolicy, + innerJoined, + joinPolicy, + joined, + joinedSums, + keyedIdentity, + keyedPolicy, + leftJoined, + orderedHistory, + outerJoinPolicy, + rightJoined, + summed, + textPair, + transitions, + uniqueRowSplitDomain, + weightedWorld, + withoutIndex, +} from './incrementalization-law-domain-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' import type { Arbitrary } from 'fast-check' import type { IncrementalizationComparisonRecorder, IncrementalizationReach, - Weighted, } from './incrementalization-law-oracle.js' -import type { IStreamBuilder, KeyValue, PipedOperator } from '../src/types.js' - -// An inner join matches only present rows, so neither side of its output is -// null. -function innerJoined( - right: IStreamBuilder>, -): PipedOperator, KeyValue> { - return join(right, `inner`) as unknown as PipedOperator< - KeyValue, - KeyValue - > -} - -// The window operators annotate each row with a fractional index. These laws -// compare rows only, so the index is dropped from the output. -function withoutIndex() { - return map(([key, [value]]: [K, [V, string]]) => [key, value] as [K, V]) -} - -// A fault for the promotion law: a window that forwards a removal but drops -// the rows entering in the same message, as a top-K that forgets to promote -// the next row would. -class WithoutPromotionOperator extends LinearUnaryOperator { - inner(collection: MultiSet): MultiSet { - const changes = collection.getInner() - return changes.some(([, weight]) => weight < 0) - ? new MultiSet(changes.filter(([, weight]) => weight < 0)) - : collection - } -} - -function withoutPromotion(): PipedOperator { - return (stream: IStreamBuilder): IStreamBuilder => { - const output = new StreamBuilder( - stream.graph, - new DifferenceStreamWriter(), - ) - stream.graph.addOperator( - new WithoutPromotionOperator( - stream.graph.getNextOperatorId(), - stream.connectReader(), - output.writer, - ), - ) - return output - } -} +import type { Keyed } from './incrementalization-law-domain-oracle.js' /** * # Does incremental execution equal full recomputation? @@ -141,59 +114,6 @@ function withoutPromotion(): PipedOperator { * owns the generated incremental-versus-recompute law. */ -type Keyed = [number, number] -type JoinOutput = [number, [number, number]] -type OuterJoinOutput = [number, [number | null, number | null]] -type GroupedMeanOutput = [string, { bucket: number; mean: number }] - -type GroupedTextOutput = [ - string, - { - bucket: number - lowFirst: string | undefined - highFirst: string | undefined - lowSecond: string | undefined - highSecond: string | undefined - }, -] - -/** - * Each value names a pair of strings. groupBy merges contributions with equal - * pre-aggregate values without hashing them, so its identity for a string - * must not depend on where one value ends and the next begins. A string's - * identity is `"` and its text, joined to the next part with `|`, so the - * pairs `["x|\"y", ""]` and `["x", "y|\""]`, and `["|\"", ""]` and - * `["", "|\""]`, read the same without each string's length. - */ -const TEXT_PAIRS: ReadonlyArray = [ - [`x|"y`, ``], - [`x`, `y|"`], - [`|"`, ``], - [``, `|"`], - [`a`, `"`], - [`a|"`, ``], - [``, ``], -] -// Named witnesses reach past the generated -3..3 range, so the table wraps. -const textPair = (value: number) => - TEXT_PAIRS[ - ((value % TEXT_PAIRS.length) + TEXT_PAIRS.length) % TEXT_PAIRS.length - ]! - -type GroupedOutput = [ - string, - { - bucket: number - total: number - minimum: number | undefined - maximum: number | undefined - mean: number - }, -] - -const FIXED_SEED = 1741 -const RUNS = oracleRuns(100) -const it = oracleReplayTest(vitestIt) /** * Reach of one generated campaign. The checker delivers every logical batch * twice: as one atomic batch, and as legal one-row steps. The split law says @@ -257,17 +177,6 @@ class CampaignReach { } } -const CANCELLATION_ROW: Keyed = [99, 99] -const keyedPolicy = jsonLawValuePolicy() -const joinPolicy = jsonLawValuePolicy() -const outerJoinPolicy = jsonLawValuePolicy() -const groupedPolicy = jsonLawValuePolicy() -const groupedTextPolicy = jsonLawValuePolicy() -const groupedMeanPolicy = jsonLawValuePolicy() -const uniqueRowSplitDomain = { - uniqueKey: ([key]: Keyed) => String(key), -} - /** * The model observes relation membership and multiplicity. The ordered * window operators also annotate each selected member with a fractional @@ -278,305 +187,6 @@ const uniqueRowSplitDomain = { * property selects only its recorded seed and shrink path. */ -const weightedWorld = weightedStateArbitrary( - fc.tuple(fc.integer({ min: 0, max: 3 }), fc.integer({ min: -3, max: 3 })), - { maxLength: 8, maxWeight: 2 }, -) - -function state(values: Weighted): Map { - const result = new Map() - for (const [value, delta] of values) { - const key = JSON.stringify(value) - const weight = (result.get(key)?.[1] ?? 0) + delta - if (weight === 0) result.delete(key) - else result.set(key, [value, weight]) - } - return result -} - -function transitions(worlds: Array>): Array> { - return weightedTransitions(worlds, keyedPolicy, CANCELLATION_ROW) -} - -function keyedIdentity(input: Weighted): Weighted { - return [...state(input).values()].map(([value, weight]) => [value, weight]) -} - -function summed(input: Weighted): Weighted { - const groups = new Map() - for (const [[key, value], weight] of keyedIdentity(input)) { - groups.set(key, (groups.get(key) ?? 0) + value * weight) - } - return [...groups].map(([key, value]) => [[key, value], 1]) -} - -function joined( - left: Weighted, - right: Weighted, -): Weighted { - const result: Weighted = [] - for (const [[leftKey, leftValue], leftWeight] of keyedIdentity(left)) { - for (const [[rightKey, rightValue], rightWeight] of keyedIdentity(right)) { - if (leftKey === rightKey) { - result.push([ - [leftKey, [leftValue, rightValue]], - leftWeight * rightWeight, - ]) - } - } - } - return result -} - -function fullJoined( - left: Weighted, - right: Weighted, -): Weighted { - const leftRows = keyedIdentity(left) - const rightRows = keyedIdentity(right) - const result: Weighted = [] - for (const [[leftKey, leftValue], leftWeight] of leftRows) { - const matches = rightRows.filter(([[rightKey]]) => rightKey === leftKey) - if (matches.length === 0) { - result.push([[leftKey, [leftValue, null]], leftWeight]) - } else { - for (const [[, rightValue], rightWeight] of matches) { - result.push([ - [leftKey, [leftValue, rightValue]], - leftWeight * rightWeight, - ]) - } - } - } - for (const [[rightKey, rightValue], rightWeight] of rightRows) { - if (leftRows.every(([[leftKey]]) => leftKey !== rightKey)) { - result.push([[rightKey, [null, rightValue]], rightWeight]) - } - } - return result -} - -function leftJoined( - left: Weighted, - right: Weighted, -): Weighted { - return fullJoined(left, right).filter( - ([[, [leftValue]]]) => leftValue !== null, - ) -} - -function rightJoined( - left: Weighted, - right: Weighted, -): Weighted { - return fullJoined(left, right).filter( - ([[, [, rightValue]]]) => rightValue !== null, - ) -} - -function joinedSums( - left: Weighted, - right: Weighted, -): Weighted { - const sums = new Map() - for (const [[key, [leftValue, rightValue]], weight] of joined(left, right)) { - sums.set(key, (sums.get(key) ?? 0) + leftValue * rightValue * weight) - } - return [...sums].map(([key, value]) => [[key, value], 1]) -} - -/** - * Group extrema come from every retained defined value. Zero is a value, not - * absence. The model recomputes from plain weighted rows and shares no - * aggregate reducer with production. - */ -function groupedAggregates(input: Weighted): Weighted { - const groups = new Map< - number, - { total: number; minimum: number; maximum: number; count: number } - >() - for (const [[key, value], weight] of keyedIdentity(input)) { - const bucket = key % 2 - const current = groups.get(bucket) - groups.set( - bucket, - current === undefined - ? { - total: value * weight, - minimum: value, - maximum: value, - count: weight, - } - : { - total: current.total + value * weight, - minimum: Math.min(current.minimum, value), - maximum: Math.max(current.maximum, value), - count: current.count + weight, - }, - ) - } - return [...groups].map(([bucket, { count, ...aggregates }]) => [ - [ - JSON.stringify({ bucket }), - // The mean is the weighted total over the weighted row count. - { bucket, ...aggregates, mean: aggregates.total / count }, - ], - 1, - ]) -} - -/** - * The per-bucket mean alone. With no other aggregate, every value's identity - * comes from avg, so an avg identity that merges different sums shows here. - */ -function groupedMean(input: Weighted): Weighted { - const groups = new Map() - for (const [[key, value], weight] of keyedIdentity(input)) { - const current = groups.get(key % 2) ?? { total: 0, count: 0 } - groups.set(key % 2, { - total: current.total + value * weight, - count: current.count + weight, - }) - } - return [...groups].map(([bucket, { total, count }]) => [ - [JSON.stringify({ bucket }), { bucket, mean: total / count }], - 1, - ]) -} - -/** Per-bucket string extrema of both parts, recomputed from plain rows. */ -function groupedTextExtrema( - input: Weighted, -): Weighted { - const groups = new Map>() - for (const [[key, value]] of keyedIdentity(input)) { - const pairs = groups.get(key % 2) ?? [] - pairs.push(textPair(value)) - groups.set(key % 2, pairs) - } - const low = (values: Array) => - values.reduce((left, right) => (right < left ? right : left)) - const high = (values: Array) => - values.reduce((left, right) => (right > left ? right : left)) - return [...groups].map(([bucket, pairs]) => { - const first = pairs.map(([text]) => text) - const second = pairs.map(([, text]) => text) - return [ - [ - JSON.stringify({ bucket }), - { - bucket, - lowFirst: low(first), - highFirst: high(first), - lowSecond: low(second), - highSecond: high(second), - }, - ], - 1, - ] - }) -} - -const orderedWorld = fc - .uniqueArray( - fc.tuple(fc.integer({ min: 0, max: 7 }), fc.integer({ min: -20, max: 20 })), - { maxLength: 8, selector: ([key]) => key }, - ) - .filter((rows) => new Set(rows.map(([, rank]) => rank)).size === rows.length) - .map((rows): Weighted => rows.map((row) => [row, 1])) - -// Independent worlds rarely share rows, so a window row seldom leaves while -// the next row stays to be promoted. Most ordered steps therefore edit the -// previous world: they remove one row or insert one with an unused key and -// rank. A fresh world still replaces the relation now and then. -type OrderedStep = - | { type: `fresh`; world: Weighted } - | { type: `remove`; pick: number } - | { type: `insert`; row: Keyed } -const orderedHistory: fc.Arbitrary>> = fc - .tuple( - orderedWorld, - fc.array( - fc.oneof( - { - weight: 1, - arbitrary: orderedWorld.map((world) => ({ - type: `fresh` as const, - world, - })), - }, - { - weight: 2, - arbitrary: fc.nat(7).map((pick) => ({ - type: `remove` as const, - pick, - })), - }, - { - weight: 2, - arbitrary: fc - .tuple( - fc.integer({ min: 0, max: 7 }), - fc.integer({ min: -20, max: 20 }), - ) - .map((row) => ({ type: `insert` as const, row })), - }, - ), - { minLength: 1, maxLength: 6 }, - ), - ) - .map(([initial, steps]) => { - const worlds = [initial] - for (const step of steps as Array) { - const previous = worlds.at(-1)! - if (step.type === `fresh`) { - worlds.push(step.world) - } else if (step.type === `remove`) { - worlds.push( - previous.filter( - (_, index) => index !== step.pick % Math.max(previous.length, 1), - ), - ) - } else { - const [key, rank] = step.row - const unused = previous.every( - ([[otherKey, otherRank]]) => otherKey !== key && otherRank !== rank, - ) - worlds.push(unused ? [...previous, [step.row, 1]] : previous) - } - } - return worlds - }) - -// The grammar of legal ordered histories. Its calibration test checks that the -// generated steps reach row promotions, which the ordered laws need. -// What this oracle owns: the gap report judges these entries, and their -// reference trees within the package, by this file's tests. - -function firstThree(input: Weighted): Weighted { - return keyedIdentity(input) - .sort(([[leftKey, leftRank]], [[rightKey, rightRank]]) => - leftRank === rightRank ? leftKey - rightKey : leftRank - rightRank, - ) - .slice(0, 3) -} - -function firstTwoPerParity(input: Weighted): Weighted { - const groups = new Map>() - for (const [value, weight] of keyedIdentity(input)) { - const values = groups.get(value[0] % 2) ?? [] - values.push([value, weight]) - groups.set(value[0] % 2, values) - } - return [...groups.values()].flatMap((values) => - values - .sort(([[leftKey, leftRank]], [[rightKey, rightRank]]) => - leftRank === rightRank ? leftKey - rightKey : leftRank - rightRank, - ) - .slice(0, 2), - ) -} - // The independent recomputations remain in the shared law checker. Native // registration records its actual/reference pairs and preserves its original // fail-fast error, including errors that are unrelated to a comparison. @@ -664,6 +274,206 @@ function registerIncrementalization( }) } +// The named cells force empty batches, duplicate weights, replacements, +// cancellation, presence flips, falsey group extrema, boundary ties, a +// zero-width window, and a promotion even if a generated campaign misses +// them. Each runs through the shared law checker, and the checker's delivery +// counts are compared with the counts that each cell's batches imply. +function replayNamedCells(): void { + const unaryReach = assertUnaryIncrementalization({ + name: `named unary cells`, + initial: [], + batches: [ + [ + [CANCELLATION_ROW, -1], + [CANCELLATION_ROW, 1], + ], + [[[0, 1], 2]], + [ + [[0, 1], -2], + [[0, 2], 1], + ], + [[[0, 2], -1]], + ], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => input.pipe(consolidate()), + evaluate: keyedIdentity, + }) + expect(unaryReach).toEqual({ + atomicCheckpoints: 5, + atomicDeliveries: 5, + splitCheckpoints: 5, + splitDeliveries: 6, + }) + + const binaryReach = assertBinaryIncrementalization({ + name: `named outer-join presence cells`, + initialLeft: [[[0, 1], 1]], + initialRight: [], + batches: [ + { left: [], right: [[[0, 2], 1]] }, + { + left: [[[0, 1], -1]], + right: [[[0, 2], -1]], + }, + ], + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: outerJoinPolicy, + build: (left, right) => left.pipe(join(right, `full`)), + evaluate: fullJoined, + }) + expect(binaryReach).toEqual({ + atomicCheckpoints: 3, + atomicDeliveries: 3, + splitCheckpoints: 3, + splitDeliveries: 4, + }) + + const extremaReach = assertUnaryIncrementalization({ + name: `grouped falsey extrema`, + initial: [ + [[0, 5], 1], + [[2, 0], 1], + [[1, -2], 1], + [[3, 0], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: groupedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + groupBy(([key]) => ({ bucket: key % 2 }), { + total: groupByOperators.sum(([, value]) => value), + minimum: groupByOperators.min(([, value]) => value), + maximum: groupByOperators.max(([, value]) => value), + mean: groupByOperators.avg(([, value]) => value), + }), + ), + evaluate: groupedAggregates, + }) + expect(extremaReach).toEqual({ + atomicCheckpoints: 1, + atomicDeliveries: 1, + splitCheckpoints: 1, + splitDeliveries: 4, + }) + + assertUnaryIncrementalization({ + name: `grouped-order boundary tie`, + initial: [ + [[4, 1], 1], + [[2, 1], 1], + [[0, 1], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + groupedOrderByWithFractionalIndex((rank) => rank, { + groupKeyFn: (key) => key % 2, + limit: 2, + }), + map(([key, [rank]]) => [key, rank] as Keyed), + ), + evaluate: firstTwoPerParity, + }) + + assertUnaryIncrementalization({ + name: `grouped-order replacement uses legal split prefixes`, + initial: [[[6, 0], 1]], + // Deliberately put the insertion first. The split scheduler must retract + // the occupied unique row key before it can deliver the replacement. + batches: [ + [ + [[6, -1], 1], + [[6, 0], -1], + ], + ], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + groupedOrderByWithFractionalIndex((rank) => rank, { + groupKeyFn: (key) => key % 2, + limit: 2, + }), + map(([key, [rank]]) => [key, rank] as Keyed), + ), + evaluate: firstTwoPerParity, + }) + + assertUnaryIncrementalization({ + name: `zero-width order window`, + initial: [ + [[0, 0], 1], + [[1, 1], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 0 }), + withoutIndex(), + ), + evaluate: () => [], + }) + + // The adjacent width-one case admits exactly the first ranked value. + assertUnaryIncrementalization({ + name: `one-width order window`, + initial: [ + [[0, 0], 1], + [[1, 1], 1], + ], + batches: [], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 1 }), + withoutIndex(), + ), + evaluate: (input) => firstThree(input).slice(0, 1), + }) + + // At two values, limit 2 retains both. The third value lands past the + // boundary; a wrong limit of 3 would leak it at checkpoint 1. Removing the + // first value then requires the third to enter the window. + const promotion = assertUnaryIncrementalization({ + name: `ordered window boundary and promotion`, + initial: [ + [[0, 1] as Keyed, 1], + [[1, 2] as Keyed, 1], + ], + batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + orderByWithFractionalIndex((rank) => rank, { limit: 2 }), + withoutIndex(), + ), + evaluate: (input) => firstThree(input).slice(0, 2), + }) + expect(promotion).toEqual({ + atomicCheckpoints: 3, + atomicDeliveries: 3, + splitCheckpoints: 3, + splitDeliveries: 4, + }) +} + +const NAMED_CELLS_LAW = `Every named cell's emitted delta and retained output equal full recomputation under atomic and split delivery.` + describe(`DBSP incrementalization laws`, () => { registerIncrementalization( `checks consolidate, reduce, and groupBy against full recomputation`, @@ -901,445 +711,30 @@ describe(`DBSP incrementalization laws`, () => { }, ) - // Reconstruction: the fixed ordered campaign reaches promotion. Sampling - // its arbitrary with its seed and run count yields exactly the histories it - // executes. A promotion is a transition after which a window holds a row - // that the previous world held, unchanged, outside the window: a removal - // or displacement promoted it. Only a window that already holds its limit - // 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, - previous: Weighted, - next: Weighted, - ) => { - const before = new Set(window(previous).map(([row]) => String(row))) - const held = new Set(previous.map(([row]) => String(row))) - return window(next).some( - ([row]) => held.has(String(row)) && !before.has(String(row)), - ) - } - 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++ - if (promoted(firstTwoPerParity, previous, next)) promotions.perParity++ - } - } - // At 100 runs: 25 global and 21 per-parity promotions; independent - // worlds alone gave 2 and 2. - expect(promotions.global).toBeGreaterThanOrEqual(10) - expect(promotions.perParity).toBeGreaterThanOrEqual(10) - }) - - it(`replays empty, duplicate, replacement, cancellation, and presence-flip cells`, () => { - oracleRoute(`case`) - const unaryReach = assertUnaryIncrementalization({ - name: `named unary cells`, - initial: [], - batches: [ - [ - [CANCELLATION_ROW, -1], - [CANCELLATION_ROW, 1], - ], - [[[0, 1], 2]], - [ - [[0, 1], -2], - [[0, 2], 1], - ], - [[[0, 2], -1]], - ], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => input.pipe(consolidate()), - evaluate: keyedIdentity, - }) - expect(unaryReach).toEqual({ - atomicCheckpoints: 5, - atomicDeliveries: 5, - splitCheckpoints: 5, - splitDeliveries: 6, - }) - - const binaryReach = assertBinaryIncrementalization({ - name: `named outer-join presence cells`, - initialLeft: [[[0, 1], 1]], - initialRight: [], - batches: [ - { left: [], right: [[[0, 2], 1]] }, - { - left: [[[0, 1], -1]], - right: [[[0, 2], -1]], - }, - ], - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: outerJoinPolicy, - build: (left, right) => left.pipe(join(right, `full`)), - evaluate: fullJoined, - }) - expect(binaryReach).toEqual({ - atomicCheckpoints: 3, - atomicDeliveries: 3, - splitCheckpoints: 3, - splitDeliveries: 4, - }) - - const extremaReach = assertUnaryIncrementalization({ - name: `grouped falsey extrema`, - initial: [ - [[0, 5], 1], - [[2, 0], 1], - [[1, -2], 1], - [[3, 0], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: groupedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - groupBy(([key]) => ({ bucket: key % 2 }), { - total: groupByOperators.sum(([, value]) => value), - minimum: groupByOperators.min(([, value]) => value), - maximum: groupByOperators.max(([, value]) => value), - mean: groupByOperators.avg(([, value]) => value), - }), - ), - evaluate: groupedAggregates, - }) - expect(extremaReach).toEqual({ - atomicCheckpoints: 1, - atomicDeliveries: 1, - splitCheckpoints: 1, - splitDeliveries: 4, - }) - - assertUnaryIncrementalization({ - name: `grouped-order boundary tie`, - initial: [ - [[4, 1], 1], - [[2, 1], 1], - [[0, 1], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - groupedOrderByWithFractionalIndex((rank) => rank, { - groupKeyFn: (key) => key % 2, - limit: 2, - }), - map(([key, [rank]]) => [key, rank] as Keyed), - ), - evaluate: firstTwoPerParity, - }) - - assertUnaryIncrementalization({ - name: `grouped-order replacement uses legal split prefixes`, - initial: [[[6, 0], 1]], - // Deliberately put the insertion first. The split scheduler must retract - // the occupied unique row key before it can deliver the replacement. - batches: [ - [ - [[6, -1], 1], - [[6, 0], -1], - ], - ], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - groupedOrderByWithFractionalIndex((rank) => rank, { - groupKeyFn: (key) => key % 2, - limit: 2, - }), - map(([key, [rank]]) => [key, rank] as Keyed), - ), - evaluate: firstTwoPerParity, - }) - - assertUnaryIncrementalization({ - name: `zero-width order window`, - initial: [ - [[0, 0], 1], - [[1, 1], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 0 }), - withoutIndex(), - ), - evaluate: () => [], - }) - - // The adjacent width-one case admits exactly the first ranked value. - assertUnaryIncrementalization({ - name: `one-width order window`, - initial: [ - [[0, 0], 1], - [[1, 1], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 1 }), - withoutIndex(), - ), - evaluate: (input) => firstThree(input).slice(0, 1), - }) - - // At two values, limit 2 retains both. The third value lands past the - // boundary; a wrong limit of 3 would leak it at checkpoint 1. Removing the - // first value then requires the third to enter the window. - const promotion = assertUnaryIncrementalization({ - name: `ordered window boundary and promotion`, - initial: [ - [[0, 1] as Keyed, 1], - [[1, 2] as Keyed, 1], - ], - batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 2 }), - withoutIndex(), - ), - evaluate: (input) => firstThree(input).slice(0, 2), - }) - expect(promotion).toEqual({ - atomicCheckpoints: 3, - atomicDeliveries: 3, - splitCheckpoints: 3, - splitDeliveries: 4, - }) - }) - - 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`, - initial: [[[0, 1], -1]], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => input.pipe(consolidate()), - evaluate: keyedIdentity, - }), - ).toThrow(/illegal negative input state/) - - expect(() => - assertUnaryIncrementalization({ - name: `duplicate unique row key excluded`, - initial: [ - [[0, 1], 1], - [[0, 2], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => input.pipe(consolidate()), - evaluate: keyedIdentity, - }), - ).toThrow(/no unit ordering with legal split-delivery prefixes/) - }) - - 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`, - initial: [[[0, 1], 1]], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => input.pipe(filter(() => false)), - evaluate: keyedIdentity, - }), - ).toThrow(/output delta diverged/) - expect(() => - assertUnaryIncrementalization({ - name: `sign-flipped weight fault`, - initial: [[[0, 1], 1]], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - // A reduce that negates every multiplicity flips the sign of each row. - build: (input) => - input.pipe( - reduce((values) => - values.map(([value, weight]) => [value, -weight] as const), - ), - ), - evaluate: keyedIdentity, - }), - ).toThrow(/output delta diverged/) - expect(() => - assertUnaryIncrementalization({ - name: `wrong member fault`, - initial: [[[0, 1], 1]], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe(map(([key, value]) => [key, value + 1] as Keyed)), - evaluate: keyedIdentity, - }), - ).toThrow(/output delta diverged/) - expect(() => - assertUnaryIncrementalization({ - name: `wrong window fault`, - initial: [ - [[0, 0], 1], - [[1, 1], 1], - [[2, 2], 1], - [[3, 3], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { - comparator: (left, right) => right - left, - limit: 3, - }), - withoutIndex(), - ), - evaluate: firstThree, - }), - ).toThrow(/output delta diverged/) - expect(() => - assertUnaryIncrementalization({ - name: `wrong window limit fault`, - initial: [ - [[0, 1] as Keyed, 1], - [[1, 2] as Keyed, 1], - [[2, 3] as Keyed, 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 3 }), - withoutIndex(), - ), - evaluate: (input) => firstThree(input).slice(0, 2), - }), - ).toThrow(/output delta diverged at checkpoint 0/) - // On the promotion history, the same wrong limit agrees with the model - // until the third value arrives past the boundary. - expect(() => - assertUnaryIncrementalization({ - name: `wrong promotion limit fault`, - initial: [ - [[0, 1] as Keyed, 1], - [[1, 2] as Keyed, 1], - ], - batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 3 }), - withoutIndex(), - ), - evaluate: (input) => firstThree(input).slice(0, 2), - }), - ).toThrow(/output delta diverged at checkpoint 1/) - // A window that removes the first value without promoting the third - // agrees with the model until the removal. - expect(() => - assertUnaryIncrementalization({ - name: `missing promotion fault`, - initial: [ - [[0, 1] as Keyed, 1], - [[1, 2] as Keyed, 1], - ], - batches: [[[[2, 3] as Keyed, 1]], [[[0, 1] as Keyed, -1]]], - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - orderByWithFractionalIndex((rank) => rank, { limit: 2 }), - withoutPromotion(), - withoutIndex(), - ), - evaluate: (input) => firstThree(input).slice(0, 2), - }), - ).toThrow(/output delta diverged at checkpoint 2/) - - const truthinessMinimum = { - preMap: ([, value]: Keyed): number | undefined => value, - reduce: (values: Array<[number | undefined, number]>) => { - let minimum: number | undefined - for (const [value] of values) { - if (!minimum || (value !== undefined && value && value < minimum)) { - minimum = value - } - } - return minimum - }, - postMap: (result: number | undefined) => result, - } - const truthinessMaximum = { - preMap: ([, value]: Keyed): number | undefined => value, - reduce: (values: Array<[number | undefined, number]>) => { - let maximum: number | undefined - for (const [value] of values) { - if (!maximum || (value !== undefined && value && value > maximum)) { - maximum = value - } - } - return maximum - }, - postMap: (result: number | undefined) => result, - } - expect(() => - assertUnaryIncrementalization({ - name: `truthiness-filtered extrema fault`, - initial: [ - [[0, 5], 1], - [[2, 0], 1], - [[1, -2], 1], - [[3, 0], 1], - ], - batches: [], - inputPolicy: keyedPolicy, - outputPolicy: groupedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - groupBy(([key]) => ({ bucket: key % 2 }), { - total: groupByOperators.sum(([, value]) => value), - minimum: truthinessMinimum, - maximum: truthinessMaximum, - mean: groupByOperators.avg(([, value]) => value), - }), - ), - evaluate: groupedAggregates, - }), - ).toThrow(/output delta diverged/) + oracleProperty({ + id: `ivm.incrementalization-law-cells`, + law: { + statement: NAMED_CELLS_LAW, + authority: `The existing DBSP accumulation/difference law and direct evaluators documented above.`, + limits: `The named empty, duplicate, replacement, cancellation, presence-flip, falsey-extrema, boundary-tie, zero-width, and promotion cells only.`, + }, + 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`, + ], + histories: [{ cells: `named` }], + label: () => + `replays empty, duplicate, replacement, cancellation, and presence-flip cells`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(NAMED_CELLS_LAW, plan, observe, replayNamedCells), + check: forwardAssertionJudgment, }) }) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 5138710810..904d42b59d 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -10,6 +10,7 @@ export const properties: ReadonlyArray = [ `ivm.temporal-group-key`, `ivm.hash-identity-fixed`, `ivm.hash-identity-digest-collision`, + `ivm.incrementalization-law-cells`, // react-db `react.convex-lazy-paging`, `react.effect-callback`, @@ -29,6 +30,11 @@ export const owners: ReadonlyArray = [ `ivm`, `hash-identity-fixed hash-identity-digest-collision`, ], + [ + `db-ivm/tests/incrementalization-law-oracle.property.test.ts`, + `ivm`, + `incrementalization-law-cells`, + ], // react-db [ `react-db/tests/convex-lazy-paging-oracle.test.tsx`, From c3b86272674fe49ac8beb1e80f5952a3c02867b7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:51:22 -0700 Subject: [PATCH 032/116] test(sqlite): make the dropped-cause diagnostic witness a calibration of its fixed property P3: the witness becomes a corrupt calibration on opfs-worker.diagnostics-fixed that replaces the retained example's error with its primary message. The file now registers only through oracleProperty, so the typecheck collector skips it. Co-authored-by: Isaac --- .../opfs-worker-diagnostics-oracle.test.ts | 57 ++++++++++--------- .../vite.config.ts | 1 + 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts index 46bb18b7dc..48ae8a72b0 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,11 +1,6 @@ import { fc } from '@fast-check/vitest' -import { afterEach, describe, expect, it, vi } from 'vitest' -import { - oracleReplayRequested, - oracleRoute, -} from '../../db/tests/oracle-config' +import { afterEach, vi } from 'vitest' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' -import { declareOracle } from '../../db/tests/oracle-declaration' import type { Observation, Verdict } from '../../db/tests/oracle-property' import type { BrowserOPFSWorkerRequest, @@ -321,27 +316,6 @@ 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: [checkNative], - model: [referenceMessage, referenceResponse], -}) - -describe(`OPFS worker diagnostics oracle`, () => { - it.skipIf(oracleReplayRequested())( - `rejects a diagnostic that drops an exposed VFS cause`, - async () => { - oracleRoute(`calibration`) - const input = diagnosticExamples[6]![0] as OpenFailure - const { response, opens } = await initialize(input) - const expected = referenceResponse(input).response - - expect(response).toEqual(expected) - expect(opens).toBe(1) - expect({ ...response, error: input.primaryMessage }).not.toEqual(expected) - }, - ) -}) - // The formatter and admission relation keep the same independent judgment; // native registration supplies fixed/random scheduling and direct replay. function checkNative( @@ -398,5 +372,34 @@ oracleProperty({ observe(`worker init response`, await initialize(input)) }, check: checkNative, + // A present-cause example rejects a response that drops the cause: the + // check must reject the retained example's real response with its error + // replaced by the primary message alone. The framework runs it only where + // the clean response and open reach match the model. + calibrations: [ + { + name: `rejects a diagnostic that drops an exposed VFS cause`, + law: `diagnostic response and open reach`, + corrupt: (trace, input) => { + if (input !== diagnosticExamples[6]![0]) return undefined + return trace.map((entry) => { + if (entry.checkpoint !== `worker init response`) return entry + const { response, opens } = entry.value as Awaited< + ReturnType + > + return { + ...entry, + value: { + response: { + ...response, + error: (input as OpenFailure).primaryMessage, + }, + opens, + }, + } + }) + }, + }, + ], timeout: 15_000, }) diff --git a/packages/browser-db-sqlite-persistence/vite.config.ts b/packages/browser-db-sqlite-persistence/vite.config.ts index d2fd228966..05362384d5 100644 --- a/packages/browser-db-sqlite-persistence/vite.config.ts +++ b/packages/browser-db-sqlite-persistence/vite.config.ts @@ -115,6 +115,7 @@ const config = defineConfig({ `tests/reload-class-oracle.test.ts`, `tests/opfs-page-lifecycle-oracle.test.ts`, `tests/opfs-request-order-oracle.test.ts`, + `tests/opfs-worker-diagnostics-oracle.test.ts`, ], }, }, From 0f888055106c74c528cdd1d1b3ab99ef8e8ecf3a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:53:03 -0700 Subject: [PATCH 033/116] test(db-collection-e2e): remove declareOracle from the shared e2e suites The move suite runs only in electric.e2e.test.ts on Node. It now registers one enumerated oracleProperty per sync mode (collection-e2e.moves.eager, .on-demand, .progressive). Each history is the command script that its test passed to withMoveHistory before, unchanged. The judgment moves through the oracle-assertion adapter. The predicates, pagination, joins, and live-updates suites are renamed to *-witness.suite.ts (maintainer decision 2026-11-11: witnesses for runtimes that are not Vitest on Node). The persisted conformance contract bundles them into the Capacitor and Tauri WebView apps, and the framework's main entry is Node-only. They keep their plain tests, and the test names and the 113-name manifest do not change. ESLint applies every oracle rule to them except no-unit-test. They import oracleRoute from @tanstack/oracles/declaration. The declareOracle call is removed from all six files, including the notification-laws-oracle.ts companion. No part became unused. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- eslint.config.js | 18 + packages/db-collection-e2e/README.md | 6 +- packages/db-collection-e2e/src/index.ts | 8 +- ...oracle.suite.ts => joins-witness.suite.ts} | 9 +- ...suite.ts => live-updates-witness.suite.ts} | 11 +- .../src/suites/live-updates.suite.ts | 2 +- .../src/suites/moves-oracle.suite.ts | 1215 +++++++++-------- ...e.suite.ts => pagination-witness.suite.ts} | 9 +- ...e.suite.ts => predicates-witness.suite.ts} | 9 +- .../src/utils/notification-laws-oracle.ts | 11 - ...rsisted-collection-conformance-contract.ts | 8 +- .../converted-properties/db-collection-e2e.ts | 17 +- 13 files changed, 694 insertions(+), 631 deletions(-) rename packages/db-collection-e2e/src/suites/{joins-oracle.suite.ts => joins-witness.suite.ts} (99%) rename packages/db-collection-e2e/src/suites/{live-updates-oracle.suite.ts => live-updates-witness.suite.ts} (95%) rename packages/db-collection-e2e/src/suites/{pagination-oracle.suite.ts => pagination-witness.suite.ts} (99%) rename packages/db-collection-e2e/src/suites/{predicates-oracle.suite.ts => predicates-witness.suite.ts} (99%) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 402b788455..a91b135ae6 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2127,7 +2127,7 @@ generic visitor behavior outside the named collectors. | 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. 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. | +| 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. Witness files (maintainer decision, 2026-11-11: witnesses for runtimes that are not Vitest on Node): `predicates-witness.suite.ts`, `pagination-witness.suite.ts`, `joins-witness.suite.ts`, and `live-updates-witness.suite.ts` keep their 84 plain tests and their existing checks and models (`withPredicateRows`, `createWindowOracle`, `createJoinOracle`, `expectNotificationHistory`) without `oracleProperty`, because `persisted-collection-conformance-contract.ts` also bundles them into the Capacitor and Tauri WebView apps, and the framework's main entry, which `oracleProperty` needs, is Node-only (`node:async_hooks`, `node:fs`, `node:util`). Their native runs check the exact 113 test names in `persisted-conformance-manifest.ts`. `eslint.config.js` applies every oracle rule to them except `oracle/no-unit-test`. Owner: this row. The move suite, which only `electric.e2e.test.ts` runs on Node, registers `collection-e2e.moves.{eager,on-demand,progressive}` through `oracleProperty`. 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 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. | diff --git a/eslint.config.js b/eslint.config.js index 1145ef5a91..35de1c992e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,4 +58,22 @@ export default [ { settings: oracleLintSettings }, oraclePlugin.configs.recommended, oraclePlugin.configs.production, + // Witnesses for runtimes that are not Vitest on Node (maintainer decision, + // 2026-11-11). These shared suites also run inside the Capacitor and Tauri + // WebView apps, which bundle them and cannot load the framework's Node-only + // main entry, so they keep their plain tests and are not oracle files. Every + // other oracle rule still applies to them. + { + name: `db/collection-e2e-witnesses`, + files: [`packages/db-collection-e2e/src/suites/*-witness.suite.ts`], + plugins: { oracle: oraclePlugin }, + linterOptions: { noInlineConfig: true }, + rules: { + 'oracle/no-disabled': `error`, + 'oracle/no-oracle-disable': `error`, + 'oracle/oracle-property-only': `error`, + 'oracle/pure-model': `error`, + 'oracle/replayable-seed': `error`, + }, + }, ] diff --git a/packages/db-collection-e2e/README.md b/packages/db-collection-e2e/README.md index 1bd5a1044b..a08e790670 100644 --- a/packages/db-collection-e2e/README.md +++ b/packages/db-collection-e2e/README.md @@ -267,7 +267,7 @@ The e2e tests will run alongside your regular tests. All test suites are implemented in `src/suites/*.suite.ts` files and exported as factory functions. -### Predicates Suite (`predicates-oracle.suite.ts`) +### Predicates Suite (`predicates-witness.suite.ts`) Tests basic where clause functionality with ~20 test scenarios: @@ -293,7 +293,7 @@ it('should filter with eq() on number field', async () => { - Complex boolean logic with `and()`, `or()`, `not()` - Predicate pushdown verification -### Pagination Suite (`pagination-oracle.suite.ts`) +### Pagination Suite (`pagination-witness.suite.ts`) Tests ordering and pagination with ~15 test scenarios: @@ -319,7 +319,7 @@ it('should sort ascending by single field', async () => { - Edge cases (limit=0, offset beyond dataset) - Performance verification (only requested page loaded) -### Joins Suite (`joins-oracle.suite.ts`) +### Joins Suite (`joins-witness.suite.ts`) Tests multi-collection joins with ~12 test scenarios: diff --git a/packages/db-collection-e2e/src/index.ts b/packages/db-collection-e2e/src/index.ts index 52b6e2b13d..b297b235fd 100644 --- a/packages/db-collection-e2e/src/index.ts +++ b/packages/db-collection-e2e/src/index.ts @@ -18,13 +18,13 @@ export { } from './utils/helpers' // Export test suite creators -export { createPredicatesTestSuite } from './suites/predicates-oracle.suite' -export { createPaginationTestSuite } from './suites/pagination-oracle.suite' -export { createJoinsTestSuite } from './suites/joins-oracle.suite' +export { createPredicatesTestSuite } from './suites/predicates-witness.suite' +export { createPaginationTestSuite } from './suites/pagination-witness.suite' +export { createJoinsTestSuite } from './suites/joins-witness.suite' export { createDeduplicationTestSuite } from './suites/deduplication.suite' export { createCollationTestSuite } from './suites/collation.suite' export { createMutationsTestSuite } from './suites/mutations.suite' export { createLiveUpdatesUnitTestSuite } from './suites/live-updates.suite' -export { createLiveUpdatesTestSuite } from './suites/live-updates-oracle.suite' +export { createLiveUpdatesTestSuite } from './suites/live-updates-witness.suite' export { createProgressiveTestSuite } from './suites/progressive.suite' export { createMovesTestSuite } from './suites/moves-oracle.suite' diff --git a/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts b/packages/db-collection-e2e/src/suites/joins-witness.suite.ts similarity index 99% rename from packages/db-collection-e2e/src/suites/joins-oracle.suite.ts rename to packages/db-collection-e2e/src/suites/joins-witness.suite.ts index bacc949e4c..5b832f0fb7 100644 --- a/packages/db-collection-e2e/src/suites/joins-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/joins-witness.suite.ts @@ -7,10 +7,10 @@ import { randomUUID } from 'node:crypto' import { createLiveQueryCollection, eq, gt, isNull } from '@tanstack/db' import { describe, expect, it, vi } from 'vitest' -import { - declareOracle, - oracleRoute, -} from '../../../db/tests/oracle-declaration.js' +// The browser-safe route entry: these suites also run inside the Capacitor +// and Tauri WebView apps, which cannot load the framework's Node-only main +// entry, so this witness keeps its routes without oracleProperty. +import { oracleRoute } from '@tanstack/oracles/declaration' import { waitFor, waitForQueryData } from '../utils/helpers' import type { Comment, @@ -973,4 +973,3 @@ export function createJoinsTestSuite(getConfig: () => Promise) { // 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-witness.suite.ts similarity index 95% rename from packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts rename to packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts index 6ae3b90b3d..177b47a4e0 100644 --- a/packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts @@ -1,15 +1,15 @@ /** Reactive backend updates with complete owned rows and unchanged-peer frames. */ import { describe, expect, it, vi } from 'vitest' import { createLiveQueryCollection } from '@tanstack/db' +// The browser-safe route entry: these suites also run inside the Capacitor +// and Tauri WebView apps, which cannot load the framework's Node-only main +// entry, so this witness keeps its routes without oracleProperty. +import { oracleRoute } from '@tanstack/oracles/declaration' import { waitForQueryData } from '../utils/helpers' import { expectNotificationHistory, expectNotificationsStopped, } from '../utils/notification-laws-oracle' -import { - declareOracle, - oracleRoute, -} from '../../../db/tests/oracle-declaration.js' import { captureUserRows, userFixture, @@ -138,6 +138,3 @@ export function createLiveUpdatesTestSuite( // 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/live-updates.suite.ts b/packages/db-collection-e2e/src/suites/live-updates.suite.ts index ecae173eb4..faf7d3bd8f 100644 --- a/packages/db-collection-e2e/src/suites/live-updates.suite.ts +++ b/packages/db-collection-e2e/src/suites/live-updates.suite.ts @@ -1,7 +1,7 @@ /** * Reactive update and update-in-place cases whose expectations are written * out row by row. The notification history and unsubscribe laws that the - * oracle suite declares are in live-updates-oracle.suite.ts, which runs this + * witness suite checks are in live-updates-witness.suite.ts, which runs this * suite too. */ import { describe, expect, it } from 'vitest' 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 5ebd880d7a..e9699de2d3 100644 --- a/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/moves-oracle.suite.ts @@ -6,13 +6,15 @@ */ import { randomUUID } from 'node:crypto' -import { beforeAll, describe, expect, it, vi } from 'vitest' +import { beforeAll, describe, expect, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { electricCollectionOptions } from '@tanstack/electric-db-collection' +import { oracleProperty } from '../../../db/tests/oracle-property' import { - declareOracle, - oracleRoute, -} from '../../../db/tests/oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../../db/tests/oracle-assertion-adapter' import { waitFor } from '../utils/helpers' import { expectNotificationHistory } from '../utils/notification-laws-oracle' import type { E2ETestConfig, Post, User } from '../types' @@ -65,6 +67,30 @@ function assertOwnedPosts(actual: Array, expected: Array) { ) } +// The law that every sync mode's move histories check. The finite world that +// withMoveHistory builds from the authored row and activity commands is its +// independent model; it does not read the provider's subquery parser or +// output. +// +// 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". +const movesLaw = { + statement: `A Collection over a shape whose WHERE clause has a subquery holds exactly the history's owned posts whose owning user is active in its authored row and activity commands, each under its own key, and its change callbacks reconstruct its rows.`, + authority: `Electric shape subqueries: a row moves into or out of \`"userId" IN (SELECT id FROM users WHERE "isActive" = true)\` when its parent's activity changes; the change-notification law of notification-laws-oracle.ts.`, + limits: `Eight authored histories per sync mode, against real Postgres and Electric. Only the rows that a history owns are compared; Electric's tags are not observed.`, +} + +// The replay manifest reads each property id from this module, so the ids +// are written out, one per sync mode. +const movesPropertyIds = { + eager: `collection-e2e.moves.eager`, + 'on-demand': `collection-e2e.moves.on-demand`, + progressive: `collection-e2e.moves.progressive`, +} satisfies Record + interface MoveHistory { own: (collection: T) => T ready: (collection: MoveCollection, syncMode: SyncMode) => Promise @@ -374,640 +400,670 @@ export function createMovesTestSuite(getConfig: () => Promise) { throw new AggregateError(errors, 'Move history and cleanup failed') } - // Helper function to run all tests for a given sync mode + // Registers this sync mode's authored move histories. Each history is the + // command script that its test used to pass to withMoveHistory, unchanged: + // withMoveHistory still owns the finite world, the comparison, and the + // cleanup, and the property only names the script and runs it. 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`) - } - - // Insert 2 active users and 1 inactive user - const userId1 = randomUUID() - const userId2 = randomUUID() - const userId3 = randomUUID() - - await history.insertUser({ - id: userId1, - name: `Active User 1`, - email: `user1@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + const moveHistories: Array<{ + name: string + commands: (history: MoveHistory) => Promise + }> = [ + { + name: `Initial snapshot contains only posts from active users`, + commands: async (history) => { + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - await history.insertUser({ - id: userId2, - name: `Active User 2`, - email: `user2@test.com`, - age: 30, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Insert 2 active users and 1 inactive user + const userId1 = randomUUID() + const userId2 = randomUUID() + const userId3 = randomUUID() + + await history.insertUser({ + id: userId1, + name: `Active User 1`, + email: `user1@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - await history.insertUser({ - id: userId3, - name: `Inactive User`, - email: `user3@test.com`, - age: 42, - isActive: false, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + await history.insertUser({ + id: userId2, + name: `Active User 2`, + email: `user2@test.com`, + age: 30, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // Wait for all 3 users to be synced to Electric before inserting posts - // This ensures the subquery in the WHERE clause can properly evaluate - await waitForUsersSynced([userId1, userId2, userId3]) - - // Insert posts for these users - const postId1 = randomUUID() - const postId2 = randomUUID() - const postId3 = randomUUID() - - await history.insertPost({ - id: postId1, - userId: userId1, - title: `Post 1`, - content: `Content 1`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + await history.insertUser({ + id: userId3, + name: `Inactive User`, + email: `user3@test.com`, + age: 42, + isActive: false, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - await history.insertPost({ - id: postId2, - userId: userId2, - title: `Post 2`, - content: `Content 2`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Wait for all 3 users to be synced to Electric before inserting posts + // This ensures the subquery in the WHERE clause can properly evaluate + await waitForUsersSynced([userId1, userId2, userId3]) + + // Insert posts for these users + const postId1 = randomUUID() + const postId2 = randomUUID() + const postId3 = randomUUID() + + await history.insertPost({ + id: postId1, + userId: userId1, + title: `Post 1`, + content: `Content 1`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - await history.insertPost({ - id: postId3, - userId: userId3, - title: `Post 3`, - content: `Content 3`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + await history.insertPost({ + id: postId2, + userId: userId2, + title: `Post 2`, + content: `Content 2`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - // Create collection on posts with WHERE clause: userId IN (SELECT id FROM users WHERE isActive = true) - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) + await history.insertPost({ + id: postId3, + userId: userId3, + title: `Post 3`, + content: `Content 3`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - // Wait for collection to sync - await history.ready(collection, syncMode) - - // Wait for both posts to appear (users are active, so posts match the subquery) - await waitForItem(collection, postId1) - await waitForItem(collection, postId2) - - // Verify only posts 1 and 2 are in the collection - expect(collection.has(postId1)).toBe(true) - await history.check(collection) - expect(collection.has(postId2)).toBe(true) - await history.check(collection) - expect(collection.has(postId3)).toBe(false) - await history.check(collection) - - // Wait a bit to make sure post 3 is not coming in later - await new Promise((resolve) => setTimeout(resolve, 50)) - expect(collection.has(postId3)).toBe(false) - await history.check(collection) - - // Note: Tags are internal to Electric and may not be directly accessible - // The test verifies that posts with matching conditions appear in snapshot - }) - }) + // Create collection on posts with WHERE clause: userId IN (SELECT id FROM users WHERE isActive = true) + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) - it(`Move-in: row becomes eligible for subquery`, async () => { - oracleRoute(`fixed history`) - await withMoveHistory(async (history) => { - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection, syncMode) + // Wait for collection to sync + await history.ready(collection, syncMode) - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + // Wait for both posts to appear (users are active, so posts match the subquery) + await waitForItem(collection, postId1) + await waitForItem(collection, postId2) - // Insert user with isActive = false - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Inactive User`, - email: `inactive@test.com`, - age: 25, - isActive: false, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Verify only posts 1 and 2 are in the collection + expect(collection.has(postId1)).toBe(true) + await history.check(collection) + expect(collection.has(postId2)).toBe(true) + await history.check(collection) + expect(collection.has(postId3)).toBe(false) + await history.check(collection) - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `Inactive User Post`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Wait a bit to make sure post 3 is not coming in later + await new Promise((resolve) => setTimeout(resolve, 50)) + expect(collection.has(postId3)).toBe(false) + await history.check(collection) - // Wait a bit to ensure post doesn't appear (user is inactive, so post doesn't match subquery) - await new Promise((resolve) => setTimeout(resolve, 500)) - expect(collection.has(postId)).toBe(false) - await history.check(collection) - - // Update user to isActive = true (move-in for the post) - await config.mutations.updateUser(userId, { isActive: true }) - history.setActive(userId, true) - - // Wait for post to appear (move-in) - await waitForItem(collection, postId, 1000) - expect(collection.has(postId)).toBe(true) - await history.check(collection) - expect(collection.get(postId)?.title).toBe(`Inactive User Post`) - }) - }) + // Note: Tags are internal to Electric and may not be directly accessible + // The test verifies that posts with matching conditions appear in snapshot + }, + }, - it(`Move-out: row becomes ineligible for subquery`, async () => { - oracleRoute(`fixed history`) - await withMoveHistory(async (history) => { - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection, syncMode) + { + name: `Move-in: row becomes eligible for subquery`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection, syncMode) - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - // Insert user with isActive = true - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Active User`, - email: `active@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Insert user with isActive = false + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Inactive User`, + email: `inactive@test.com`, + age: 25, + isActive: false, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `Active User Post`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `Inactive User Post`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - // Wait for post to appear (user is active, so post matches subquery) - await waitForItem(collection, postId) - expect(collection.has(postId)).toBe(true) - await history.check(collection) + // Wait a bit to ensure post doesn't appear (user is inactive, so post doesn't match subquery) + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(collection.has(postId)).toBe(false) + await history.check(collection) - // Update user to isActive = false (move-out for the post) - await config.mutations.updateUser(userId, { isActive: false }) - history.setActive(userId, false) + // Update user to isActive = true (move-in for the post) + await config.mutations.updateUser(userId, { isActive: true }) + history.setActive(userId, true) - // Wait for post to be removed (move-out) - await waitForItemRemoved(collection, postId) - expect(collection.has(postId)).toBe(false) - await history.check(collection) - }) - }) + // Wait for post to appear (move-in) + await waitForItem(collection, postId, 1000) + expect(collection.has(postId)).toBe(true) + await history.check(collection) + expect(collection.get(postId)?.title).toBe(`Inactive User Post`) + }, + }, - it(`Move-out → move-in cycle`, async () => { - oracleRoute(`fixed history`) - await withMoveHistory(async (history) => { - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection, syncMode) + { + name: `Move-out: row becomes ineligible for subquery`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection, syncMode) - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - // Insert user with isActive = true - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Flapping User`, - email: `flapping@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Insert user with isActive = true + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Active User`, + email: `active@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `Flapping Post`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `Active User Post`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - await waitForItem(collection, postId) - expect(collection.has(postId)).toBe(true) - await history.check(collection) - - // Move-out: isActive = false - await config.mutations.updateUser(userId, { isActive: false }) - history.setActive(userId, false) - await waitForItemRemoved(collection, postId, 15000) - expect(collection.has(postId)).toBe(false) - await history.check(collection) - - // Move-in: isActive = true - await config.mutations.updateUser(userId, { isActive: true }) - history.setActive(userId, true) - await waitForItem(collection, postId, 15000) - expect(collection.has(postId)).toBe(true) - await history.check(collection) - }) - }) + // Wait for post to appear (user is active, so post matches subquery) + await waitForItem(collection, postId) + expect(collection.has(postId)).toBe(true) + await history.check(collection) - it(`Title update while row stays within subquery`, async () => { - oracleRoute(`fixed history`) - await withMoveHistory(async (history) => { - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection, syncMode) + // Update user to isActive = false (move-out for the post) + await config.mutations.updateUser(userId, { isActive: false }) + history.setActive(userId, false) - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + // Wait for post to be removed (move-out) + await waitForItemRemoved(collection, postId) + expect(collection.has(postId)).toBe(false) + await history.check(collection) + }, + }, - // Insert user with isActive = true - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Active User`, - email: `active@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + { + name: `Move-out → move-in cycle`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection, syncMode) - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `Tagged Post`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - await waitForItem(collection, postId) - expect(collection.has(postId)).toBe(true) - await history.check(collection) + // Insert user with isActive = true + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Flapping User`, + email: `flapping@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // This changes a visible row value; it is not a pure tag-only event. - // The post stays eligible because its user remains active. - await dbClient.query( - `UPDATE ${postsTable} SET title = $1 WHERE id = $2`, - [`Updated Tagged Post`, postId], - ) - history.setTitle(postId, `Updated Tagged Post`) - - // Wait a bit and verify post still exists - await new Promise((resolve) => setTimeout(resolve, 500)) - expect(collection.has(postId)).toBe(true) - await history.check(collection) - expect(collection.get(postId)?.title).toBe(`Updated Tagged Post`) - }) - }) + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `Flapping Post`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - it(`Database DELETE leads to row being removed from collection`, async () => { - oracleRoute(`fixed history`) - await withMoveHistory(async (history) => { - const collection = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection, syncMode) + await waitForItem(collection, postId) + expect(collection.has(postId)).toBe(true) + await history.check(collection) + + // Move-out: isActive = false + await config.mutations.updateUser(userId, { isActive: false }) + history.setActive(userId, false) + await waitForItemRemoved(collection, postId, 15000) + expect(collection.has(postId)).toBe(false) + await history.check(collection) + + // Move-in: isActive = true + await config.mutations.updateUser(userId, { isActive: true }) + history.setActive(userId, true) + await waitForItem(collection, postId, 15000) + expect(collection.has(postId)).toBe(true) + await history.check(collection) + }, + }, - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + { + name: `Title update while row stays within subquery`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection, syncMode) - // Insert user with isActive = true - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Active User`, - email: `active@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `To Be Deleted`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Insert user with isActive = true + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Active User`, + email: `active@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - await waitForItem(collection, postId) - expect(collection.has(postId)).toBe(true) - await history.check(collection) - - // Delete post in Postgres - await dbClient.query(`DELETE FROM ${postsTable} WHERE id = $1`, [ - postId, - ]) - history.removePost(postId) - - // Wait for post to be removed - await waitForItemRemoved(collection, postId) - expect(collection.has(postId)).toBe(false) - await history.check(collection) - }) - }) + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `Tagged Post`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - 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`) - } + await waitForItem(collection, postId) + expect(collection.has(postId)).toBe(true) + await history.check(collection) - // Create first collection - const collection1 = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection1, syncMode) - - // Insert user with isActive = true - const userId = randomUUID() - await history.insertUser({ - id: userId, - name: `Snapshot Test User`, - email: `snapshot@test.com`, - age: 25, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // This changes a visible row value; it is not a pure tag-only event. + // The post stays eligible because its user remains active. + await dbClient.query( + `UPDATE ${postsTable} SET title = $1 WHERE id = $2`, + [`Updated Tagged Post`, postId], + ) + history.setTitle(postId, `Updated Tagged Post`) - // Wait for user to be synced to Electric before inserting post - await waitForUsersSynced([userId]) - - // Insert post for this user - const postId = randomUUID() - await history.insertPost({ - id: postId, - userId, - title: `Snapshot Test Post`, - content: `Content`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Wait a bit and verify post still exists + await new Promise((resolve) => setTimeout(resolve, 500)) + expect(collection.has(postId)).toBe(true) + await history.check(collection) + expect(collection.get(postId)?.title).toBe(`Updated Tagged Post`) + }, + }, - await waitForItem(collection1, postId) - expect(collection1.has(postId)).toBe(true) - await history.check(collection1) + { + name: `Database DELETE leads to row being removed from collection`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection, syncMode) - // Update user → post moves out - await config.mutations.updateUser(userId, { isActive: false }) - history.setActive(userId, false) + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - await waitForItemRemoved(collection1, postId) - expect(collection1.has(postId)).toBe(false) - await history.check(collection1) + // Insert user with isActive = true + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Active User`, + email: `active@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // Clean up first collection - await collection1.cleanup() + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `To Be Deleted`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - // Create fresh collection (new subscription) - const collection2 = history.own( - createPostsByActiveUsersCollection(syncMode), - ) - await history.ready(collection2, syncMode) + await waitForItem(collection, postId) + expect(collection.has(postId)).toBe(true) + await history.check(collection) - // Wait a bit to ensure snapshot is complete - await new Promise((resolve) => setTimeout(resolve, 1000)) + // Delete post in Postgres + await dbClient.query(`DELETE FROM ${postsTable} WHERE id = $1`, [ + postId, + ]) + history.removePost(postId) - // Snapshot should NOT include the removed post (user is inactive) - expect(collection2.has(postId)).toBe(false) - await history.check(collection2) - }) - }) + // Wait for post to be removed + await waitForItemRemoved(collection, postId) + expect(collection.has(postId)).toBe(false) + await history.check(collection) + }, + }, - 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), - ) - await history.ready(collection, syncMode) - const archiveCallbacks = history.observe(collection) + { + name: `Snapshot after move-out should not re-include removed rows`, + commands: async (history) => { + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } - if (!config.mutations) { - throw new Error(`Mutations not configured`) - } + // Create first collection + const collection1 = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection1, syncMode) + + // Insert user with isActive = true + const userId = randomUUID() + await history.insertUser({ + id: userId, + name: `Snapshot Test User`, + email: `snapshot@test.com`, + age: 25, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) - // Insert 3 users all with isActive = true - const userId1 = randomUUID() - const userId2 = randomUUID() - const userId3 = randomUUID() - - await history.insertUser({ - id: userId1, - name: `User 1`, - email: `user1@test.com`, - age: 25, - isActive: false, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Wait for user to be synced to Electric before inserting post + await waitForUsersSynced([userId]) + + // Insert post for this user + const postId = randomUUID() + await history.insertPost({ + id: postId, + userId, + title: `Snapshot Test Post`, + content: `Content`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) - await history.insertUser({ - id: userId2, - name: `User 2`, - email: `user2@test.com`, - age: 30, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + await waitForItem(collection1, postId) + expect(collection1.has(postId)).toBe(true) + await history.check(collection1) - await history.insertUser({ - id: userId3, - name: `User 3`, - email: `user3@test.com`, - age: 35, - isActive: true, - createdAt: new Date(), - metadata: null, - deletedAt: null, - }) + // Update user → post moves out + await config.mutations.updateUser(userId, { isActive: false }) + history.setActive(userId, false) - // Wait for all 3 users to be synced to Electric before inserting posts - // This ensures the subquery in the WHERE clause can properly evaluate - await waitForUsersSynced([userId1, userId2, userId3]) - - // Insert posts for these users - const postId1 = randomUUID() - const postId2 = randomUUID() - const postId3 = randomUUID() - - await history.insertPost({ - id: postId1, - userId: userId1, - title: `Post 1`, - content: `Content 1`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + await waitForItemRemoved(collection1, postId) + expect(collection1.has(postId)).toBe(false) + await history.check(collection1) - await history.insertPost({ - id: postId2, - userId: userId2, - title: `Post 2`, - content: `Content 2`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Clean up first collection + await collection1.cleanup() - await history.insertPost({ - id: postId3, - userId: userId3, - title: `Post 3`, - content: `Content 3`, - viewCount: 0, - largeViewCount: BigInt(0), - publishedAt: null, - deletedAt: null, - }) + // Create fresh collection (new subscription) + const collection2 = history.own( + createPostsByActiveUsersCollection(syncMode), + ) + await history.ready(collection2, syncMode) - // Wait for posts 2 and 3 to appear - await waitForItem(collection, postId2) - await waitForItem(collection, postId3) + // Wait a bit to ensure snapshot is complete + await new Promise((resolve) => setTimeout(resolve, 1000)) - expect(collection.has(postId1)).toBe(false) - await history.check(collection) + // Snapshot should NOT include the removed post (user is inactive) + expect(collection2.has(postId)).toBe(false) + await history.check(collection2) + }, + }, - // In one SQL transaction: - // user1: isActive → true (post1 moves in) - // post2: title change (stays in since user2 is still active) - // user3: isActive → false (post3 moves out) - await dbClient.query(`BEGIN`) - try { - await dbClient.query( - `UPDATE ${usersTable} SET "isActive" = $1 WHERE id = $2`, - [true, userId1], + { + name: `Multi-row transaction: some rows move in, some move out`, + commands: async (history) => { + const collection = history.own( + createPostsByActiveUsersCollection(syncMode), ) - await dbClient.query( - `UPDATE ${postsTable} SET title = $1 WHERE id = $2`, - [`Updated Post 2`, postId2], - ) - await dbClient.query( - `UPDATE ${usersTable} SET "isActive" = $1 WHERE id = $2`, - [false, userId3], - ) - await dbClient.query(`COMMIT`) - } catch (error) { + await history.ready(collection, syncMode) + const archiveCallbacks = history.observe(collection) + + if (!config.mutations) { + throw new Error(`Mutations not configured`) + } + + // Insert 3 users all with isActive = true + const userId1 = randomUUID() + const userId2 = randomUUID() + const userId3 = randomUUID() + + await history.insertUser({ + id: userId1, + name: `User 1`, + email: `user1@test.com`, + age: 25, + isActive: false, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) + + await history.insertUser({ + id: userId2, + name: `User 2`, + email: `user2@test.com`, + age: 30, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) + + await history.insertUser({ + id: userId3, + name: `User 3`, + email: `user3@test.com`, + age: 35, + isActive: true, + createdAt: new Date(), + metadata: null, + deletedAt: null, + }) + + // Wait for all 3 users to be synced to Electric before inserting posts + // This ensures the subquery in the WHERE clause can properly evaluate + await waitForUsersSynced([userId1, userId2, userId3]) + + // Insert posts for these users + const postId1 = randomUUID() + const postId2 = randomUUID() + const postId3 = randomUUID() + + await history.insertPost({ + id: postId1, + userId: userId1, + title: `Post 1`, + content: `Content 1`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) + + await history.insertPost({ + id: postId2, + userId: userId2, + title: `Post 2`, + content: `Content 2`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) + + await history.insertPost({ + id: postId3, + userId: userId3, + title: `Post 3`, + content: `Content 3`, + viewCount: 0, + largeViewCount: BigInt(0), + publishedAt: null, + deletedAt: null, + }) + + // Wait for posts 2 and 3 to appear + await waitForItem(collection, postId2) + await waitForItem(collection, postId3) + + expect(collection.has(postId1)).toBe(false) + await history.check(collection) + + // In one SQL transaction: + // user1: isActive → true (post1 moves in) + // post2: title change (stays in since user2 is still active) + // user3: isActive → false (post3 moves out) + await dbClient.query(`BEGIN`) try { - await dbClient.query(`ROLLBACK`) - } catch (rollbackError) { - throw new AggregateError( - [error, rollbackError], - 'SQL transaction and rollback failed', + await dbClient.query( + `UPDATE ${usersTable} SET "isActive" = $1 WHERE id = $2`, + [true, userId1], + ) + await dbClient.query( + `UPDATE ${postsTable} SET title = $1 WHERE id = $2`, + [`Updated Post 2`, postId2], ) + await dbClient.query( + `UPDATE ${usersTable} SET "isActive" = $1 WHERE id = $2`, + [false, userId3], + ) + await dbClient.query(`COMMIT`) + } catch (error) { + try { + await dbClient.query(`ROLLBACK`) + } catch (rollbackError) { + throw new AggregateError( + [error, rollbackError], + 'SQL transaction and rollback failed', + ) + } + throw error } - throw error - } - history.setActive(userId1, true) - history.setTitle(postId2, `Updated Post 2`) - history.setActive(userId3, false) - - // Wait for changes to propagate - await waitForItemRemoved(collection, postId3) - await history.check(collection) - expect(collection.has(postId1)).toBe(true) // post1: moved in (user1 active) - await history.check(collection) - expect(collection.has(postId2)).toBe(true) // post2: still in (user2 active) - await history.check(collection) - expect(collection.get(postId2)?.title).toBe(`Updated Post 2`) - expect(collection.has(postId3)).toBe(false) // post3: moved out (user3 inactive) - await history.check(collection) - - archiveCallbacks() - }) + history.setActive(userId1, true) + history.setTitle(postId2, `Updated Post 2`) + history.setActive(userId3, false) + + // Wait for changes to propagate + await waitForItemRemoved(collection, postId3) + await history.check(collection) + expect(collection.has(postId1)).toBe(true) // post1: moved in (user1 active) + await history.check(collection) + expect(collection.has(postId2)).toBe(true) // post2: still in (user2 active) + await history.check(collection) + expect(collection.get(postId2)?.title).toBe(`Updated Post 2`) + expect(collection.has(postId3)).toBe(false) // post3: moved out (user3 inactive) + await history.check(collection) + + archiveCallbacks() + }, + }, + ] + // A history is the script's name, which is data; the plan resolves it + // to the script. + const commandsByName = new Map( + moveHistories.map(({ name, commands }) => [name, commands]), + ) + oracleProperty({ + id: movesPropertyIds[syncMode], + law: movesLaw, + histories: moveHistories.map(({ name }) => name), + label: (name) => name, + resolve: (name) => ({ commands: commandsByName.get(name)! }), + model: recordedAssertionJudgment, + driver: async (plan, observe) => { + await recordAssertionJudgment( + movesLaw.statement, + plan, + observe, + () => withMoveHistory(plan.commands), + ) + }, + check: forwardAssertionJudgment, }) }) } @@ -1018,10 +1074,3 @@ export function createMovesTestSuite(getConfig: () => Promise) { runTestsForSyncMode('progressive') }) } - -// 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-witness.suite.ts similarity index 99% rename from packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts rename to packages/db-collection-e2e/src/suites/pagination-witness.suite.ts index 3272f08fb7..5956b7df7f 100644 --- a/packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/pagination-witness.suite.ts @@ -6,10 +6,10 @@ import { describe, expect, it, vi } from 'vitest' import { createLiveQueryCollection, eq } from '@tanstack/db' -import { - declareOracle, - oracleRoute, -} from '../../../db/tests/oracle-declaration.js' +// The browser-safe route entry: these suites also run inside the Capacitor +// and Tauri WebView apps, which cannot load the framework's Node-only main +// entry, so this witness keeps its routes without oracleProperty. +import { oracleRoute } from '@tanstack/oracles/declaration' import { assertAllItemsMatch, assertCollectionSize, @@ -1556,4 +1556,3 @@ export function createPaginationTestSuite( // 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-witness.suite.ts similarity index 99% rename from packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts rename to packages/db-collection-e2e/src/suites/predicates-witness.suite.ts index 07564a3a3d..a04b5f0fe6 100644 --- a/packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts +++ b/packages/db-collection-e2e/src/suites/predicates-witness.suite.ts @@ -22,10 +22,10 @@ import { not, or, } from '@tanstack/db' -import { - declareOracle, - oracleRoute, -} from '../../../db/tests/oracle-declaration.js' +// The browser-safe route entry: these suites also run inside the Capacitor +// and Tauri WebView apps, which cannot load the framework's Node-only main +// entry, so this witness keeps its routes without oracleProperty. +import { oracleRoute } from '@tanstack/oracles/declaration' import { assertAllItemsMatch, assertCollectionSize } from '../utils/assertions' import { waitForQueryData } from '../utils/helpers' import type { E2ETestConfig, Post, User } from '../types' @@ -1265,4 +1265,3 @@ export function createPredicatesTestSuite( // 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 17ee462462..b2b637090d 100644 --- a/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts +++ b/packages/db-collection-e2e/src/utils/notification-laws-oracle.ts @@ -1,5 +1,4 @@ import { expect } from 'vitest' -import { declareOracle } from '../../../db/tests/oracle-declaration.js' export type Notification = { events: Array<{ @@ -53,13 +52,3 @@ export function expectNotificationsStopped( ): void { 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-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts index f79b33face..1d5b5fd25e 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts @@ -1,12 +1,12 @@ import { describe } from 'vitest' import { createCollationTestSuite } from '../../../db-collection-e2e/src/suites/collation.suite' import { createDeduplicationTestSuite } from '../../../db-collection-e2e/src/suites/deduplication.suite' -import { createJoinsTestSuite } from '../../../db-collection-e2e/src/suites/joins-oracle.suite' +import { createJoinsTestSuite } from '../../../db-collection-e2e/src/suites/joins-witness.suite' import { createLiveUpdatesUnitTestSuite } from '../../../db-collection-e2e/src/suites/live-updates.suite' -import { createLiveUpdatesTestSuite } from '../../../db-collection-e2e/src/suites/live-updates-oracle.suite' +import { createLiveUpdatesTestSuite } from '../../../db-collection-e2e/src/suites/live-updates-witness.suite' import { createMutationsTestSuite } from '../../../db-collection-e2e/src/suites/mutations.suite' -import { createPaginationTestSuite } from '../../../db-collection-e2e/src/suites/pagination-oracle.suite' -import { createPredicatesTestSuite } from '../../../db-collection-e2e/src/suites/predicates-oracle.suite' +import { createPaginationTestSuite } from '../../../db-collection-e2e/src/suites/pagination-witness.suite' +import { createPredicatesTestSuite } from '../../../db-collection-e2e/src/suites/predicates-witness.suite' import type { E2ETestConfig } from '../../../db-collection-e2e/src/types' /** diff --git a/packages/db/tests/converted-properties/db-collection-e2e.ts b/packages/db/tests/converted-properties/db-collection-e2e.ts index def7ed8e1c..1a7e4e9ac5 100644 --- a/packages/db/tests/converted-properties/db-collection-e2e.ts +++ b/packages/db/tests/converted-properties/db-collection-e2e.ts @@ -5,6 +5,19 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +// moves-oracle.suite.ts registers one enumerated property per sync mode. +export const properties: ReadonlyArray = [ + `collection-e2e.moves.eager`, + `collection-e2e.moves.on-demand`, + `collection-e2e.moves.progressive`, +] -export const owners: ReadonlyArray = [] +// Only electric.e2e.test.ts runs the move suite, under vitest.e2e.config.ts. +export const owners: ReadonlyArray = [ + [ + `electric-db-collection/e2e/electric.e2e.test.ts`, + `collection-e2e.moves`, + `eager on-demand progressive`, + `db-collection-e2e/src/suites/moves-oracle.suite.ts`, + ], +] From 48e6d277d8aab67e172ddc9b10214f07c42f25f8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:53:34 -0700 Subject: [PATCH 034/116] test(db-ivm): register the structural identity witnesses through oracleProperty Converts the three plain-test sites in structural-identity-oracle.property.test.ts. The named counterexamples that the independent-values arbitrary can take back become its fixed histories (P1a); the two it cannot become structural-identity.independent-values-fixed (P1b), with the same resolve, driver, model, and check. The two representation witnesses become structural-identity.representation-fixed (P1b), whose driver keeps their representation assertions before the shared agreement read. expectAgreement has no use left and is deleted. Co-authored-by: Isaac --- ...tructural-identity-oracle.property.test.ts | 304 +++++++++++------- .../converted-properties/frameworks-ivm.ts | 49 +++ 2 files changed, 239 insertions(+), 114 deletions(-) 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 1932cf7613..4f6d7ec5e7 100644 --- a/packages/db-ivm/tests/structural-identity-oracle.property.test.ts +++ b/packages/db-ivm/tests/structural-identity-oracle.property.test.ts @@ -1,7 +1,6 @@ -import { describe, expect, it as vitestIt } from 'vitest' +import { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { equalHashValues, structuralIdentity } from '../src/hashing/hash.js' -import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config.js' import { differs, oracleProperty, @@ -65,7 +64,6 @@ type Spec = const SYMBOL = Symbol(`s`) const ENUMERABLE_SYMBOL = Symbol(`enumerable`) -const it = oracleReplayTest(vitestIt) const LEAVES: ReadonlyArray = [ 0, -0, @@ -413,108 +411,20 @@ function checkAgreement( return pass } -// Retained fixed histories use the generated campaign's shared judgment. -function expectAgreement( - leftSpec: Spec, - rightSpec: Spec, - left: unknown, - right: unknown, -): void { - const plan = { leftSpec, rightSpec, left, right } - const trace: Array = [] - driveAgreement(plan, (checkpoint, value) => trace.push({ checkpoint, value })) - const verdict = checkAgreement(plan, trace, expectedAgreement(plan)) - if (verdict.kind === `violation`) - expect(verdict.values?.actual, verdict.difference).toBe( - verdict.values?.expected, - ) -} - function decisions(...values: Array): () => boolean { let index = 0 return () => values[index++] ?? false } -describe(`structuralIdentity`, () => { - it(`ignores array subclass and hidden symbol representations`, () => { - const spec: Spec = { k: `array`, items: [{ k: `leaf`, i: 3 }] } - const plain = build(spec, () => false) - const variant = build(spec, decisions(true, true)) as Sub - expect(variant).toBeInstanceOf(Sub) - const [hidden] = Object.getOwnPropertySymbols(variant) - expect(hidden).toBeDefined() - expect(Object.getOwnPropertyDescriptor(variant, hidden!)?.enumerable).toBe( - false, - ) - expectAgreement(spec, spec, plain, variant) - }) - - it(`ignores object prototype, key order, and hidden symbol representations`, () => { - const spec: Spec = { - k: `object`, - entries: [ - [`a`, { k: `leaf`, i: 0 }], - [`b`, { k: `leaf`, i: 3 }], - ], - } - const plain = build(spec, () => false) - const classVariant = build(spec, decisions(true, true, true, true)) - const nullPrototype = build(spec, decisions(true, false, false, false)) - expect(classVariant).toBeInstanceOf(Instance) - expect(Object.keys(classVariant as object)).toEqual([`b`, `a`]) - const [hidden] = Object.getOwnPropertySymbols(classVariant as object) - expect(hidden).toBeDefined() - expect( - Object.getOwnPropertyDescriptor(classVariant, hidden!)?.enumerable, - ).toBe(false) - expect(Object.getPrototypeOf(nullPrototype)).toBeNull() - expectAgreement(spec, spec, plain, classVariant) - expectAgreement(spec, spec, plain, nullPrototype) - }) - - oracleProperty({ - id: `structural-identity.equal-representations`, - law: { - statement: `D2 equality agrees with the independent normal-form model, and structural identity preserves equality while every shared defined identity denotes equal values.`, - authority: `The existing structural-identity law documented above.`, - limits: `The original equal-spec grammar and sixty-four representation decisions; no additional value domain or mutation cut is introduced.`, - }, - // What this oracle owns: the gap report judges these entries, and their - // reference trees within the package, by this file's tests. - owns: [ - `src/hashing/hash.ts#structuralIdentity`, - `src/hashing/hash.ts#equalHashValues`, - ], - histories: { - arbitrary: fc.tuple( - equalSpecPairArbitrary, - fc.array(fc.boolean(), { minLength: 64, maxLength: 64 }), - ), - runs: 2000, - fixedSeeds: [20261014], - }, - resolve: ([[leftSpec, rightSpec], coins]) => { - let index = 0 - const vary = () => coins[index++ % coins.length]! - return { - leftSpec, - rightSpec, - left: build(leftSpec, () => false), - right: build(rightSpec, vary), - } - }, - model: expectedAgreement, - driver: (plan, observe) => { - driveAgreement(plan, observe) - return Promise.resolve() - }, - check: checkAgreement, - }) - - // 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([ +// 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. +// The independent-values arbitrary takes some of them back, and they replay +// as its fixed histories. The rest replay through the same resolve, driver, +// model, and check as one enumerated property. +const independentPairArbitrary = fc.tuple(specArbitrary, specArbitrary) +const independentFixedHistories = ( + [ [ `nested deep nodes`, { k: `deep`, depth: 6, v: { k: `leaf`, i: 3 } }, @@ -531,12 +441,18 @@ describe(`structuralIdentity`, () => { ], [ `a deep node and an explicit array`, - { k: `array`, items: [{ k: `deep`, depth: 5, v: { k: `leaf`, i: 3 } }] }, + { + k: `array`, + items: [{ k: `deep`, depth: 5, v: { k: `leaf`, i: 3 } }], + }, { k: `deep`, depth: 6, v: { k: `leaf`, i: 3 } }, ], [ `adjacent nesting depths`, - { k: `array`, items: [{ k: `deep`, depth: 5, v: { k: `leaf`, i: 3 } }] }, + { + k: `array`, + items: [{ k: `deep`, depth: 5, v: { k: `leaf`, i: 3 } }], + }, { k: `deep`, depth: 5, v: { k: `leaf`, i: 3 } }, ], [ @@ -631,18 +547,117 @@ describe(`structuralIdentity`, () => { extra: { k: `leaf`, i: 3 }, }, ], - ] as Array<[string, Spec, Spec]>)( - `agrees with D2 value identity for %s`, - (_name, left, right) => { - oracleRoute(`case`) - expectAgreement( - left, - right, - build(left, () => false), - build(right, () => false), - ) + ] as Array<[string, Spec, Spec]> +).map(([name, left, right]) => ({ + name: `agrees with D2 value identity for ${name}`, + history: [left, right] as [Spec, Spec], +})) + +// Two named representation witnesses. Each builds a plain value and a +// variant from the same spec; the variant takes representations that D2 +// identity ignores. The driver first confirms that the variant has the named +// representation, so the witness tests identity, then reads the same +// agreement as the generated campaigns. +type RepresentationHistory = { + name: string + representation: `array subclass` | `class instance` | `null prototype` + spec: Spec + decisions: Array +} +const representationHistories: Array = [ + { + name: `ignores array subclass and hidden symbol representations`, + representation: `array subclass`, + spec: { k: `array`, items: [{ k: `leaf`, i: 3 }] }, + decisions: [true, true], + }, + ...( + [ + [`class instance`, [true, true, true, true]], + [`null prototype`, [true, false, false, false]], + ] as const + ).map(([representation, choices]) => ({ + name: `ignores object prototype, key order, and hidden symbol representations`, + representation, + spec: { + k: `object`, + entries: [ + [`a`, { k: `leaf`, i: 0 }], + [`b`, { k: `leaf`, i: 3 }], + ], + } satisfies Spec, + decisions: [...choices], + })), +] + +function expectRepresentation( + representation: RepresentationHistory[`representation`], + variant: unknown, +): void { + switch (representation) { + case `array subclass`: { + expect(variant).toBeInstanceOf(Sub) + const [hidden] = Object.getOwnPropertySymbols(variant) + expect(hidden).toBeDefined() + expect( + Object.getOwnPropertyDescriptor(variant, hidden!)?.enumerable, + ).toBe(false) + return + } + case `class instance`: { + expect(variant).toBeInstanceOf(Instance) + expect(Object.keys(variant as object)).toEqual([`b`, `a`]) + const [hidden] = Object.getOwnPropertySymbols(variant as object) + expect(hidden).toBeDefined() + expect( + Object.getOwnPropertyDescriptor(variant, hidden!)?.enumerable, + ).toBe(false) + return + } + case `null prototype`: + expect(Object.getPrototypeOf(variant)).toBeNull() + } +} + +describe(`structuralIdentity`, () => { + oracleProperty({ + id: `structural-identity.equal-representations`, + law: { + statement: `D2 equality agrees with the independent normal-form model, and structural identity preserves equality while every shared defined identity denotes equal values.`, + authority: `The existing structural-identity law documented above.`, + limits: `The original equal-spec grammar and sixty-four representation decisions; no additional value domain or mutation cut is introduced.`, }, - ) + // What this oracle owns: the gap report judges these entries, and their + // reference trees within the package, by this file's tests. + owns: [ + `src/hashing/hash.ts#structuralIdentity`, + `src/hashing/hash.ts#equalHashValues`, + ], + histories: { + arbitrary: fc.tuple( + equalSpecPairArbitrary, + fc.array(fc.boolean(), { minLength: 64, maxLength: 64 }), + ), + runs: 2000, + fixedSeeds: [20261014], + }, + resolve: ([[leftSpec, rightSpec], coins]) => { + let index = 0 + const vary = () => coins[index++ % coins.length]! + return { + leftSpec, + rightSpec, + left: build(leftSpec, () => false), + right: build(rightSpec, vary), + } + }, + model: expectedAgreement, + driver: (plan, observe) => { + driveAgreement(plan, observe) + return Promise.resolve() + }, + check: checkAgreement, + }) oracleProperty({ id: `structural-identity.independent-values`, @@ -656,9 +671,12 @@ describe(`structuralIdentity`, () => { `src/hashing/hash.ts#equalHashValues`, ], histories: { - arbitrary: fc.tuple(specArbitrary, specArbitrary), + arbitrary: independentPairArbitrary, runs: 2000, fixedSeeds: [1158727847], + fixed: independentFixedHistories.filter(({ history }) => + independentPairArbitrary.canShrinkWithoutContext(history), + ), }, resolve: ([leftSpec, rightSpec]) => ({ leftSpec, @@ -673,4 +691,62 @@ describe(`structuralIdentity`, () => { }, check: checkAgreement, }) + oracleProperty({ + id: `structural-identity.representation-fixed`, + law: { + statement: `D2 equality agrees with the independent normal-form model, and structural identity preserves equality while every shared defined identity denotes equal values.`, + authority: `The existing structural-identity law documented above.`, + limits: `Named array-subclass, class-instance, and null-prototype representations of two specs; no additional value domain or mutation cut is introduced.`, + }, + owns: [ + `src/hashing/hash.ts#structuralIdentity`, + `src/hashing/hash.ts#equalHashValues`, + ], + histories: representationHistories, + label: ({ name, representation }) => `${name}: ${representation}`, + resolve: ({ representation, spec, decisions: choices }) => ({ + representation, + leftSpec: spec, + rightSpec: spec, + left: build(spec, () => false), + right: build(spec, decisions(...choices)), + }), + model: expectedAgreement, + driver: (plan, observe) => { + expectRepresentation(plan.representation, plan.right) + driveAgreement(plan, observe) + return Promise.resolve() + }, + check: checkAgreement, + }) + + oracleProperty({ + id: `structural-identity.independent-values-fixed`, + law: { + statement: `D2 equality agrees with the independent normal-form model, and structural identity preserves equality while every shared defined identity denotes equal values.`, + authority: `The existing structural-identity law documented above.`, + limits: `Named counterexamples with plain representations that the independent-spec arbitrary cannot take back; each replays through the same resolve, driver, model, and check.`, + }, + owns: [ + `src/hashing/hash.ts#structuralIdentity`, + `src/hashing/hash.ts#equalHashValues`, + ], + histories: independentFixedHistories.filter( + ({ history }) => + !independentPairArbitrary.canShrinkWithoutContext(history), + ), + label: ({ name }) => name, + resolve: ({ history: [leftSpec, rightSpec] }) => ({ + leftSpec, + rightSpec, + left: build(leftSpec, () => false), + right: build(rightSpec, () => false), + }), + model: expectedAgreement, + driver: (plan, observe) => { + driveAgreement(plan, observe) + return Promise.resolve() + }, + check: checkAgreement, + }) }) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 904d42b59d..94729f8cbb 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -11,10 +11,19 @@ export const properties: ReadonlyArray = [ `ivm.hash-identity-fixed`, `ivm.hash-identity-digest-collision`, `ivm.incrementalization-law-cells`, + `structural-identity.representation-fixed`, + `structural-identity.independent-values-fixed`, // react-db `react.convex-lazy-paging`, `react.effect-callback`, `react.paced-mutations-render-cuts`, + `react.infinite-query-render-cuts.on-demand`, + `react.infinite-query-render-cuts.sync-agreement`, + `react.descriptor-abandoned-render-reader`, + `react.descriptor-prebuilt-query-binding`, + `react.ssr-ordinary-examples`, + `react.ssr-infinite-examples`, + `react.ssr-stream-cut-fixed`, // vue-db and scripts ] @@ -35,6 +44,11 @@ export const owners: ReadonlyArray = [ `ivm`, `incrementalization-law-cells`, ], + [ + `db-ivm/tests/structural-identity-oracle.property.test.ts`, + `structural-identity`, + `representation-fixed independent-values-fixed`, + ], // react-db [ `react-db/tests/convex-lazy-paging-oracle.test.tsx`, @@ -51,5 +65,40 @@ export const owners: ReadonlyArray = [ `react`, `paced-mutations-render-cuts`, ], + [ + `react-db/tests/infinite-query-render-cuts-oracle.test.tsx`, + `react`, + `infinite-query-render-cuts.on-demand`, + ], + [ + `react-db/tests/infinite-query-render-cuts-oracle.test.tsx`, + `react`, + `infinite-query-render-cuts.sync-agreement`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `descriptor-abandoned-render-reader`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `descriptor-prebuilt-query-binding`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `ssr-ordinary-examples`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `ssr-infinite-examples`, + ], + [ + `react-db/tests/descriptor-query-binding-oracle.test.tsx`, + `react`, + `ssr-stream-cut-fixed`, + ], // vue-db and scripts ] From eb15c0fa1d88cd12721ab597991da7410d4ae755 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:53:46 -0700 Subject: [PATCH 035/116] test(sqlite): register the real Electron reload checkpoint through oracleProperty P1b: the one fixed real-app run becomes the enumerated property sqlite-reload.class.electron-app, with the same driver, comparison, owns, and timeout. Co-authored-by: Isaac --- .../db/tests/converted-properties/sqlite.ts | 6 ++ .../reload-class-oracle.electron.e2e.test.ts | 56 ++++++++++++++----- 2 files changed, 47 insertions(+), 15 deletions(-) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 08929fdf11..2ff3107fbc 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -9,6 +9,7 @@ export const properties: ReadonlyArray = [ `opfs.open-outcome`, `electron.ipc-readiness`, `electron.follower-settlement`, + `sqlite-reload.class.electron-app`, ] export const owners: ReadonlyArray = [ @@ -22,4 +23,9 @@ export const owners: ReadonlyArray = [ `electron`, `ipc-readiness follower-settlement`, ], + [ + `electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts`, + `sqlite-reload`, + `class.electron-app`, + ], ] 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 0c94f11bb4..525f3f871e 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 @@ -13,9 +13,13 @@ import { dirname, join, resolve } from 'node:path' 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' +import { expect, onTestFinished } from 'vitest' +import { oracleProperty } from '../../db/tests/oracle-property.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter.js' const fixture = join( dirname(fileURLToPath(import.meta.url)), @@ -141,17 +145,9 @@ function expectReloadClassLawsHold(result: Result): void { ).toEqual([]) } -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`) +// The real-Electron checkpoint: bundle the app, run every fixed reload history +// in one app, and apply the app's comparison to the published result. +async function runRealElectronReload(): Promise { const out = mkdtempSync(join(tmpdir(), `reload-class-app-`)) const databases = mkdtempSync(join(tmpdir(), `reload-class-db-`)) onTestFinished(() => { @@ -162,4 +158,34 @@ it(`real Electron reload is free of avoidable work ahead of its reads`, async () const result = await runApp(out, databases) if (process.env.RELOAD_ORACLE_DEBUG) console.log(JSON.stringify(result)) expectReloadClassLawsHold(result) -}, 600_000) +} + +// One fixed history: the app's run of every fixed reload history. The adapter +// transports the judgment of expectReloadClassLawsHold. +const realElectronReloadLaw = `real Electron reload class` +oracleProperty({ + id: `sqlite-reload.class.electron-app`, + law: { + statement: `A real Electron app reload does no avoidable work ahead of its reads: every fixed reload history reports, and none fails before its checkpoint or reports a violation of the reload class oracle.`, + authority: `The reload class law in packages/db-sqlite-persistence-core/tests/reload-class-oracle.ts.`, + limits: `The fixed reload histories, run once in one real Electron app; the generated campaign belongs to reload-class-oracle.test.ts.`, + }, + owns: [ + `src/main.ts#exposeElectronSQLitePersistence`, + `src/renderer.ts#createElectronSQLitePersistence`, + `src/electron-coordinator.ts`, + ], + histories: [ + { + name: `real Electron reload is free of avoidable work ahead of its reads`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(realElectronReloadLaw, plan, observe, () => + runRealElectronReload(), + ), + check: forwardAssertionJudgment, + timeout: 600_000, +}) From e360ee9be2884eec842b7d81a274276e42ff8e6d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:53:51 -0700 Subject: [PATCH 036/116] test(db): register cross-formulation fixed histories and cases through oracleProperty Move the model and TLP checker calibrations to the ordinary cross-formulation tests, with the model, TLP check, and named scenarios in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 8 + ...includes-cross-formulation-model-oracle.ts | 135 ++++ ...-cross-formulation-oracle.property.test.ts | 733 ++++++++---------- .../query/includes-cross-formulation.test.ts | 121 +++ 4 files changed, 572 insertions(+), 425 deletions(-) create mode 100644 packages/db/tests/query/includes-cross-formulation-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index e20987eb73..8621d7429c 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -17,6 +17,9 @@ export const properties: ReadonlyArray = [ `includes-optimistic.failed-cleanup`, `includes-optimistic.apply-checkpoint`, `includes-optimistic.immediate-route`, + `includes-cross-formulation.grouped-equality`, + `includes-cross-formulation.equivalence-fixed`, + `includes-cross-formulation.ordered-window-cases`, ] export const owners: ReadonlyArray = [ @@ -45,4 +48,9 @@ export const owners: ReadonlyArray = [ `includes-optimistic`, `held-repair sibling-delivery failed-cleanup apply-checkpoint immediate-route`, ], + [ + `db/tests/query/includes-cross-formulation-oracle.property.test.ts`, + `includes-cross-formulation`, + `grouped-equality equivalence-fixed ordered-window-cases`, + ], ] diff --git a/packages/db/tests/query/includes-cross-formulation-model-oracle.ts b/packages/db/tests/query/includes-cross-formulation-model-oracle.ts new file mode 100644 index 0000000000..9a6b6773e6 --- /dev/null +++ b/packages/db/tests/query/includes-cross-formulation-model-oracle.ts @@ -0,0 +1,135 @@ +import { expect } from 'vitest' +import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + compareChildren, + normalizeNested, +} from './includes-cross-formulation-support.js' +import type { + ChildRow, + NormalizedParent, + ParentRow, +} from './includes-cross-formulation-support.js' + +// The independent relationship model, the TLP partition check, and the two +// named scenarios of the cross-formulation oracle. They moved here unchanged +// from includes-cross-formulation-oracle.property.test.ts so that its ordinary +// model and checker calibrations in includes-cross-formulation.test.ts can +// import them without registering the oracle's properties again. This module +// registers no tests. + +export type CrossFormulationAction = + // `put` is model shorthand for a Collection insert when the id is absent + // and a Collection update when it is present. Deletes of absent ids are + // model no-ops; the driver does not send a source change for them. + | { type: `putParent`; row: ParentRow } + | { type: `deleteParent`; id: number } + | { type: `putChild`; row: ChildRow } + | { type: `deleteChild`; id: number } + +export type CrossFormulationScenario = { + parents: Array + children: Array + pivot: number + actions: Array +} + +// This is the independent relationship model. It uses only current source +// Maps, strict group equality, and the declared total orders. +export function recomputeNestedModel( + parents: Map, + children: Map, +): Array { + return normalizeNested( + [...parents.values()].map((parent) => ({ + ...parent, + children: [...children.values()].filter( + (child) => child.parentGroup === parent.group, + ), + })), + ) +} + +export function recomputeWindowedModel( + parents: ReadonlyMap, + children: ReadonlyMap, + offset: number, + limit: number, +): Array { + return normalizeNested( + [...parents.values()].map((parent) => ({ + ...parent, + children: [...children.values()] + .filter((child) => child.parentGroup === parent.group) + .sort(compareChildren) + .slice(offset, offset + limit), + })), + ) +} + +export function assertTlpPartitions( + results: ReadonlyArray>, + sourceRows: ReadonlyArray, + pivot: number, + context: string, +): void { + const partitions = [`predicate`, `complement`, `unknown`] as const + oracleAssertion( + 'includes cross-formulation-oracle.property comparison 318', + () => + expect(results, `${context} partition count`).toHaveLength( + partitions.length, + ), + ) + for (const [index, partition] of partitions.entries()) { + // This is a direct finite relation over source values, independent of the + // query evaluator. A union could be complete with rows in wrong branches. + const expected = sourceRows + .filter((child) => { + if (child.score === null) return partition === `unknown` + return partition === (child.score < pivot ? `predicate` : `complement`) + }) + .sort(compareChildren) + oracleAssertion( + 'includes cross-formulation-oracle.property comparison 328', + () => + expect(results[index], `${context} ${partition} partition`).toEqual( + expected, + ), + ) + } +} + +export const sharedRouteDeletionScenario: CrossFormulationScenario = { + parents: [ + { id: 0, group: 0, position: 0 }, + { id: 1, group: 0, position: 0 }, + ], + children: [ + { id: 10, parentGroup: 0, score: null, position: 0 }, + { id: 11, parentGroup: 0, score: null, position: 0 }, + { id: 12, parentGroup: 0, score: null, position: 0 }, + ], + pivot: 0, + actions: [{ type: `deleteChild`, id: 10 }], +} + +export const partitionAndRouteScenario: CrossFormulationScenario = { + parents: [ + { id: 0, group: 0, position: 1 }, + { id: 1, group: 0, position: 0 }, + ], + children: [ + { id: 10, parentGroup: 0, score: null, position: 2 }, + { id: 11, parentGroup: 0, score: -1, position: 1 }, + { id: 12, parentGroup: 1, score: 1, position: 0 }, + ], + pivot: 0, + actions: [ + { type: `putParent`, row: { id: 0, group: 1, position: 1 } }, + { + type: `putChild`, + row: { id: 12, parentGroup: 0, score: 1, position: 0 }, + }, + { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, + ], +} 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 ab4cb1cfd2..b9bcc78905 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 @@ -1,7 +1,12 @@ -import { fc, test as fcTest } from '@fast-check/vitest' +import { fc } from '@fast-check/vitest' import { describe, expect, test } from 'vitest' import { Temporal } from 'temporal-polyfill' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createCollection } from '../../src/collection/index.js' import { createFilterFunctionFromExpression } from '../../src/collection/change-events.js' import { @@ -18,16 +23,23 @@ import { import { oracleCalibrationTest, oracleFixedSeed, + oracleProperty, oraclePropertyOptions, oracleReplayRequested, oracleRoute, oracleRuns, readOracleRunConfig, } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' import { flushPromises } from '../utils.js' import { runIncludesCalibration } from './includes-calibration.js' import { registerIncludesProperty } from './includes-registration-oracle.js' +import { + assertTlpPartitions, + partitionAndRouteScenario, + recomputeNestedModel, + recomputeWindowedModel, + sharedRouteDeletionScenario, +} from './includes-cross-formulation-model-oracle.js' import { assertArrayKeys, assertGroupedRouteCounts, @@ -35,7 +47,6 @@ import { captureGroupedRows, captureOrderedNested, captureReferenceRows, - compareChildren, compareParents, createControlledCollection, normalizeChild, @@ -43,6 +54,10 @@ import { normalizeNested, withCleanup, } from './includes-cross-formulation-support.js' +import type { + CrossFormulationAction, + CrossFormulationScenario, +} from './includes-cross-formulation-model-oracle.js' import type { Collection } from '../../src/collection/index.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { ControlledCollection } from './includes-oracle-helpers.js' @@ -85,22 +100,6 @@ import type { * demand, publication, or facade lifecycle contracts. */ -type CrossFormulationAction = - // `put` is model shorthand for a Collection insert when the id is absent - // and a Collection update when it is present. Deletes of absent ids are - // model no-ops; the driver does not send a source change for them. - | { type: `putParent`; row: ParentRow } - | { type: `deleteParent`; id: number } - | { type: `putChild`; row: ChildRow } - | { type: `deleteChild`; id: number } - -type CrossFormulationScenario = { - parents: Array - children: Array - pivot: number - actions: Array -} - type ReferenceContextParent = { id: number group: number @@ -114,39 +113,6 @@ type ReferenceContextChild = { token: ReferenceKey } -// This is the independent relationship model. It uses only current source -// Maps, strict group equality, and the declared total orders. -function recomputeNestedModel( - parents: Map, - children: Map, -): Array { - return normalizeNested( - [...parents.values()].map((parent) => ({ - ...parent, - children: [...children.values()].filter( - (child) => child.parentGroup === parent.group, - ), - })), - ) -} - -function recomputeWindowedModel( - parents: ReadonlyMap, - children: ReadonlyMap, - offset: number, - limit: number, -): Array { - return normalizeNested( - [...parents.values()].map((parent) => ({ - ...parent, - children: [...children.values()] - .filter((child) => child.parentGroup === parent.group) - .sort(compareChildren) - .slice(offset, offset + limit), - })), - ) -} - function assertFormulationResults( expected: ReadonlyArray, observed: { @@ -321,39 +287,6 @@ async function queryChildren( ) } -function assertTlpPartitions( - results: ReadonlyArray>, - sourceRows: ReadonlyArray, - pivot: number, - context: string, -): void { - const partitions = [`predicate`, `complement`, `unknown`] as const - oracleAssertion( - 'includes cross-formulation-oracle.property comparison 318', - () => - expect(results, `${context} partition count`).toHaveLength( - partitions.length, - ), - ) - for (const [index, partition] of partitions.entries()) { - // This is a direct finite relation over source values, independent of the - // query evaluator. A union could be complete with rows in wrong branches. - const expected = sourceRows - .filter((child) => { - if (child.score === null) return partition === `unknown` - return partition === (child.score < pivot ? `predicate` : `complement`) - }) - .sort(compareChildren) - oracleAssertion( - 'includes cross-formulation-oracle.property comparison 328', - () => - expect(results[index], `${context} ${partition} partition`).toEqual( - expected, - ), - ) - } -} - async function queryPerParent( parents: ReadonlyArray, children: Collection, @@ -654,41 +587,6 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( // maximum sampled offset are marginal cases. Longer histories, deeper include // trees, arbitrary row shapes, asynchronous failures, and provider races are // outside this grammar and belong to the named adjacent owners. -const sharedRouteDeletionScenario: CrossFormulationScenario = { - parents: [ - { id: 0, group: 0, position: 0 }, - { id: 1, group: 0, position: 0 }, - ], - children: [ - { id: 10, parentGroup: 0, score: null, position: 0 }, - { id: 11, parentGroup: 0, score: null, position: 0 }, - { id: 12, parentGroup: 0, score: null, position: 0 }, - ], - pivot: 0, - actions: [{ type: `deleteChild`, id: 10 }], -} - -const partitionAndRouteScenario: CrossFormulationScenario = { - parents: [ - { id: 0, group: 0, position: 1 }, - { id: 1, group: 0, position: 0 }, - ], - children: [ - { id: 10, parentGroup: 0, score: null, position: 2 }, - { id: 11, parentGroup: 0, score: -1, position: 1 }, - { id: 12, parentGroup: 1, score: 1, position: 0 }, - ], - pivot: 0, - actions: [ - { type: `putParent`, row: { id: 0, group: 1, position: 1 } }, - { - type: `putChild`, - row: { id: 12, parentGroup: 0, score: 1, position: 0 }, - }, - { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, - ], -} - const overwriteAndNoopScenario: CrossFormulationScenario = { parents: [ { id: 0, group: -1, position: 0 }, @@ -742,150 +640,207 @@ const windowedExamples: Array< [{ scenario: partitionAndRouteScenario, offset: 2, limit: 3 }], ] -// The parts this file declares. The equivalence check compares the nested, -// flat, per-parent, and TLP formulations with the recomputed model, and the -// window check does the same for ordered child windows. The TLP check compares -// 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', +// The query-equality route values. Each row builds fresh values, so the +// enumerated history is the row's name and resolution finds its builder. +const groupedEqualityRoutes = [ + [`Date and number`, () => [new Date(0), 0, new Date(1)] as const], + [ + `Buffer and Uint8Array`, + () => + [ + Buffer.from([1, 2, 3]), + new Uint8Array([1, 2, 3]), + new Uint8Array([1, 2, 4]), + ] as const, ], - check: [ - expectFormulationsEquivalent, - expectWindowedIncludeMatches, - assertTlpPartitions, - expectNestedCountsMatchStandalone, + [ + `equivalent Temporal values`, + () => + [ + Temporal.PlainDate.from(`2024-04-05`), + Temporal.PlainDate.from(`2024-04-05`), + Temporal.PlainDate.from(`2024-04-06`), + ] as const, ], - model: [recomputeNestedModel, recomputeWindowedModel], -}) - -describe(`includes cross-formulation oracle`, () => { - const replay = readOracleRunConfig() +] as const +const groupedEqualityRouteValues = new Map( + groupedEqualityRoutes.map(([name, createValues]) => [name, createValues]), +) - // The reference-sensitive and symbol-route checks compare a lazy or nested - // formulation with a fully loaded or standalone one. Each generated seed runs - // its own parent and child history. - declareOracle({ - check: [ - checkReferenceContextRouting, - checkReferenceKeyRouting, - checkSymbolGroupRouting, - ], +// The former case body, unchanged: a nested grouped include and the +// standalone grouped query must agree through deletes and reinserts. +async function judgeGroupedEqualityRoute( + createValues: (typeof groupedEqualityRoutes)[number][1], +): Promise { + const [parentGroup, equivalentChildGroup, unequalChildGroup] = createValues() + const parents = createControlledCollection(`equality-route-parents`, [ + { id: 1, group: parentGroup as unknown }, + ]) + const childRows = [ + { id: 10, parentGroup: parentGroup as unknown }, + { id: 11, parentGroup: equivalentChildGroup as unknown }, + { id: 12, parentGroup: unequalChildGroup as unknown }, + ] + const children = createControlledCollection( + `equality-route-children`, + childRows, + ) + const nested = createLiveQueryCollection({ + query: (q) => + q.from({ parent: parents.collection }).select(({ parent }) => ({ + id: parent.id, + summaries: toArray( + q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + .groupBy(({ child }) => child.parentGroup) + .select(({ child }) => ({ count: count(child.id) })), + ), + })), + }) + const standalone = createLiveQueryCollection({ + query: (q) => + q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, parentGroup)) + .groupBy(({ child }) => child.parentGroup) + .select(({ child }) => ({ count: count(child.id) })), }) - test(`bounded grammar axes distinguish routes, order, partitions, and windows`, () => { - oracleRoute(`fixed history`) - const parents = new Map( - partitionAndRouteScenario.parents.map((row) => [row.id, row]), + const assertCounts = (expectedCount: number) => { + assertGroupedRouteCounts(nested.toArray, [expectedCount]) + const nestedCounts = expectNestedCountsMatchStandalone( + nested.toArray, + standalone.toArray, ) - const children = new Map( - partitionAndRouteScenario.children.map((row) => [row.id, row]), + expect(nestedCounts).toEqual( + expectedCount === 0 ? [] : [{ count: expectedCount }], ) - const ids = (rows: ReadonlyArray) => - rows.map((row) => ({ - id: row.id, - children: row.children.map((c) => c.id), - })) + } - expect(ids(recomputeNestedModel(parents, children))).toEqual([ - { id: 1, children: [11, 10] }, - { id: 0, children: [11, 10] }, - ]) - parents.set(0, { id: 0, group: 1, position: 1 }) - expect(ids(recomputeNestedModel(parents, children))).toEqual([ - { id: 1, children: [11, 10] }, - { id: 0, children: [12] }, - ]) - children.set(12, { ...children.get(12)!, parentGroup: 0 }) - expect(ids(recomputeNestedModel(parents, children))).toEqual([ - { id: 1, children: [12, 11, 10] }, - { id: 0, children: [] }, - ]) - parents.set(0, { id: 0, group: 0, position: -1 }) - expect(ids(recomputeNestedModel(parents, children))).toEqual([ - { id: 0, children: [12, 11, 10] }, - { id: 1, children: [12, 11, 10] }, - ]) - expect( - recomputeWindowedModel(parents, children, 0, 0).map( - (row) => row.children.length, - ), - ).toEqual([0, 0]) - expect(ids(recomputeWindowedModel(parents, children, 1, 2))).toEqual([ - { id: 0, children: [11, 10] }, - { id: 1, children: [11, 10] }, - ]) - expect(ids(recomputeWindowedModel(parents, children, 2, 3))).toEqual([ - { id: 0, children: [10] }, - { id: 1, children: [10] }, - ]) + await withCleanup(async () => { + await Promise.all([nested.preload(), standalone.preload()]) + assertCounts(2) + for (const [type, rowIndex, expectedCount] of [ + [`delete`, 0, 1], + [`delete`, 1, 0], + [`insert`, 0, 1], + [`insert`, 1, 2], + ] as const) { + children.write(type, childRows[rowIndex]!) + await flushPromises() + assertCounts(expectedCount) + } + }, [ + () => nested.cleanup(), + () => standalone.cleanup(), + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + ]) +} - // The three child scores cover true, false, and unknown at pivot 0. - // Moving the pivot to -2 moves -1 into the complement; replacing null - // with a number removes the unknown partition. These are independent of - // the query evaluator used by the TLP production formulation. - const partition = (pivot: number) => - [...children.values()].map((child) => ({ - id: child.id, - branch: - child.score === null - ? `unknown` - : child.score < pivot - ? `predicate` - : `complement`, - })) - expect(partition(0)).toEqual([ - { id: 10, branch: `unknown` }, - { id: 11, branch: `predicate` }, - { id: 12, branch: `complement` }, - ]) - expect(partition(-2)).toEqual([ - { id: 10, branch: `unknown` }, - { id: 11, branch: `complement` }, - { id: 12, branch: `complement` }, - ]) - children.set(10, { ...children.get(10)!, score: 0 }) - expect(partition(0).find((row) => row.id === 10)?.branch).toBe(`complement`) +const groupedEqualityLaw = `A grouped include routes children by query equality, so its counts match the standalone grouped query for equal and unequal Date, binary, and Temporal route values.` + +// Named regressions that the bounded scenario grammar cannot produce, or that +// the file keeps by name. Each replays the equivalence check unchanged. +const equivalenceFixedHistories: Array<{ + name: string + scenario: CrossFormulationScenario +}> = [ + { + name: `shared-route child deletion agrees across formulations`, + scenario: sharedRouteDeletionScenario, + }, + { + name: `split, merge and reactivated routes preserve all predicate partitions`, + scenario: { + parents: [ + { id: 0, group: 0, position: 1 }, + { id: 1, group: 0, position: 0 }, + ], + children: [ + { id: 10, parentGroup: 0, score: null, position: 2 }, + { id: 11, parentGroup: 0, score: -1, position: 1 }, + { id: 12, parentGroup: 0, score: 1, position: 0 }, + { id: 13, parentGroup: 1, score: 0, position: 0 }, + ], + pivot: 0, + actions: [ + { type: `putParent`, row: { id: 0, group: 1, position: 1 } }, + { type: `putParent`, row: { id: 1, group: 1, position: 0 } }, + { + type: `putChild`, + row: { id: 11, parentGroup: 0, score: -1, position: -1 }, + }, + { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, + { type: `deleteParent`, id: 1 }, + { type: `putParent`, row: { id: 1, group: 0, position: 0 } }, + { + type: `putChild`, + row: { id: 12, parentGroup: 1, score: 1, position: 0 }, + }, + ], + }, + }, +] - const sharedChildren = new Map( - sharedRouteDeletionScenario.children.map((row) => [row.id, row]), - ) - const sharedParents = new Map( - sharedRouteDeletionScenario.parents.map((row) => [row.id, row]), - ) - expect(ids(recomputeNestedModel(sharedParents, sharedChildren))).toEqual([ - { id: 0, children: [10, 11, 12] }, - { id: 1, children: [10, 11, 12] }, - ]) - sharedChildren.delete(10) - expect(ids(recomputeNestedModel(sharedParents, sharedChildren))).toEqual([ - { id: 0, children: [11, 12] }, - { id: 1, children: [11, 12] }, - ]) - }) +const equivalenceLaw = `The nested, flat, per-parent, and TLP formulations publish the recomputed relationship model after every source change.` + +// The bounded ordered-window cases, including the offset-3 empty tail that the +// sampled 0..2-offset grammar cannot produce. +const orderedWindowCases = [ + { name: `zero limit`, offset: 0, limit: 0 }, + { name: `first tied page`, offset: 0, limit: 2 }, + { name: `offset through tie`, offset: 1, limit: 2 }, + // One child remains at this boundary. Treating the final occupied offset + // as an empty tail would violate the ordered-window contract. + { name: `last occupied offset`, offset: 2, limit: 2 }, + { name: `empty tail`, offset: 3, limit: 2 }, +] - 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 }, - { id: 12, parentGroup: 0, score: 1, position: 2 }, - ] - const correct = [[rows[1]!], [rows[2]!], [rows[0]!]] - assertTlpPartitions(correct, rows, 0, `TLP calibration`) - const wrong = [[rows[0]!], [rows[2]!], [rows[1]!]] - expect(wrong.flat()).toHaveLength(rows.length) - expect(new Set(wrong.flat().map((row) => row.id))).toEqual( - new Set(rows.map((row) => row.id)), - ) - expect(() => - assertTlpPartitions(wrong, rows, 0, `TLP calibration`), - ).toThrow(`TLP calibration predicate partition`) - }) +// The former case body, unchanged. +function judgeOrderedWindowCase({ + offset, + limit, +}: (typeof orderedWindowCases)[number]): Promise { + return expectWindowedIncludeMatches( + { + parents: [ + { id: 0, group: 0, position: 1 }, + { id: 1, group: 1, position: 0 }, + ], + children: [ + { id: 10, parentGroup: 0, score: null, position: 1 }, + { id: 11, parentGroup: 0, score: -1, position: 0 }, + { id: 12, parentGroup: 0, score: 1, position: 0 }, + { id: 13, parentGroup: 1, score: 0, position: 0 }, + ], + pivot: 0, + actions: [ + { + type: `putChild`, + row: { id: 10, parentGroup: 0, score: null, position: -1 }, + }, + { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, + { + type: `putChild`, + row: { id: 11, parentGroup: 0, score: -1, position: 2 }, + }, + { + type: `putChild`, + row: { id: 12, parentGroup: 1, score: 1, position: 0 }, + }, + ], + }, + offset, + limit, + ) +} + +const orderedWindowLaw = `A nested include's ordered child window matches the recomputed windowed model through root and child moves.` + +describe(`includes cross-formulation oracle`, () => { + const replay = readOracleRunConfig() // The next three properties have fixed route-transition histories. Their // generated integer is only a user token label: values span FastCheck's @@ -903,6 +858,12 @@ describe(`includes cross-formulation oracle`, () => { property: `includes-cross-formulation.reference-context`, runs: 4, fixedSeed: 1658001, + 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`, + ], }, `parent-context routing preserves reference-sensitive predicate values across transitions for a fixed seed`, async (code) => { @@ -1091,98 +1052,31 @@ describe(`includes cross-formulation oracle`, () => { ]) } - test.each([ - [`Date and number`, () => [new Date(0), 0, new Date(1)] as const], - [ - `Buffer and Uint8Array`, - () => - [ - Buffer.from([1, 2, 3]), - new Uint8Array([1, 2, 3]), - new Uint8Array([1, 2, 4]), - ] as const, - ], - [ - `equivalent Temporal values`, - () => - [ - Temporal.PlainDate.from(`2024-04-05`), - Temporal.PlainDate.from(`2024-04-05`), - Temporal.PlainDate.from(`2024-04-06`), - ] as const, - ], - ])( - `grouped includes use query equality for %s routes`, - async (_name, createValues) => { - oracleRoute(`case`) - const [parentGroup, equivalentChildGroup, unequalChildGroup] = - createValues() - const parents = createControlledCollection(`equality-route-parents`, [ - { id: 1, group: parentGroup as unknown }, - ]) - const childRows = [ - { id: 10, parentGroup: parentGroup as unknown }, - { id: 11, parentGroup: equivalentChildGroup as unknown }, - { id: 12, parentGroup: unequalChildGroup as unknown }, - ] - const children = createControlledCollection( - `equality-route-children`, - childRows, - ) - const nested = createLiveQueryCollection({ - query: (q) => - q.from({ parent: parents.collection }).select(({ parent }) => ({ - id: parent.id, - summaries: toArray( - q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - .groupBy(({ child }) => child.parentGroup) - .select(({ child }) => ({ count: count(child.id) })), - ), - })), - }) - const standalone = createLiveQueryCollection({ - query: (q) => - q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, parentGroup)) - .groupBy(({ child }) => child.parentGroup) - .select(({ child }) => ({ count: count(child.id) })), - }) - - const assertCounts = (expectedCount: number) => { - assertGroupedRouteCounts(nested.toArray, [expectedCount]) - const nestedCounts = expectNestedCountsMatchStandalone( - nested.toArray, - standalone.toArray, - ) - expect(nestedCounts).toEqual( - expectedCount === 0 ? [] : [{ count: expectedCount }], - ) - } - - await withCleanup(async () => { - await Promise.all([nested.preload(), standalone.preload()]) - assertCounts(2) - for (const [type, rowIndex, expectedCount] of [ - [`delete`, 0, 1], - [`delete`, 1, 0], - [`insert`, 0, 1], - [`insert`, 1, 2], - ] as const) { - children.write(type, childRows[rowIndex]!) - await flushPromises() - assertCounts(expectedCount) - } - }, [ - () => nested.cleanup(), - () => standalone.cleanup(), - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ]) + oracleProperty({ + id: `includes-cross-formulation.grouped-equality`, + law: { + statement: groupedEqualityLaw, + authority: `The cross-formulation equivalence contract stated at the start of this owner and packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The three enumerated route-value families over controlled source Collections.`, }, - ) + 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`, + ], + histories: groupedEqualityRoutes.map(([name]) => name), + label: (name) => `grouped includes use query equality for ${name} routes`, + resolve: (name) => ({ + createValues: groupedEqualityRouteValues.get(name)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(groupedEqualityLaw, plan, observe, () => + judgeGroupedEqualityRoute(plan.createValues), + ), + check: forwardAssertionJudgment, + }) registerIncludesProperty( [fc.integer()], @@ -1190,6 +1084,12 @@ describe(`includes cross-formulation oracle`, () => { property: `includes-cross-formulation.reference-key`, runs: 4, fixedSeed: 1658002, + 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`, + ], }, `lazy materialization matches fully loaded materialization for reference-sensitive correlation keys for a fixed seed`, (...args: Parameters) => { @@ -1400,6 +1300,12 @@ describe(`includes cross-formulation oracle`, () => { property: `includes-cross-formulation.symbol-group-route`, runs: 4, fixedSeed: 1658003, + 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`, + ], }, `grouped includes agree with standalone groups for symbol routes for a fixed seed`, (...args: Parameters) => { @@ -1495,89 +1401,54 @@ describe(`includes cross-formulation oracle`, () => { } } - fcTest(`shared-route child deletion agrees across formulations`, () => { - oracleRoute(`fixed history`) - return expectFormulationsEquivalent(sharedRouteDeletionScenario) - }) - - 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 }, - ], - children: [ - { id: 10, parentGroup: 0, score: null, position: 2 }, - { id: 11, parentGroup: 0, score: -1, position: 1 }, - { id: 12, parentGroup: 0, score: 1, position: 0 }, - { id: 13, parentGroup: 1, score: 0, position: 0 }, - ], - pivot: 0, - actions: [ - { type: `putParent`, row: { id: 0, group: 1, position: 1 } }, - { type: `putParent`, row: { id: 1, group: 1, position: 0 } }, - { - type: `putChild`, - row: { id: 11, parentGroup: 0, score: -1, position: -1 }, - }, - { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, - { type: `deleteParent`, id: 1 }, - { type: `putParent`, row: { id: 1, group: 0, position: 0 } }, - { - type: `putChild`, - row: { id: 12, parentGroup: 1, score: 1, position: 0 }, - }, - ], - }) + oracleProperty({ + id: `includes-cross-formulation.equivalence-fixed`, + law: { + statement: equivalenceLaw, + authority: `The cross-formulation equivalence contract stated at the start of this owner and packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `Two named fixed scenarios, replayed through the same equivalence check as the generated campaign.`, + }, + 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`, + ], + histories: equivalenceFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(equivalenceLaw, plan, observe, () => + expectFormulationsEquivalent(plan.history.scenario), + ), + check: forwardAssertionJudgment, }) - test.each([ - { name: `zero limit`, offset: 0, limit: 0 }, - { name: `first tied page`, offset: 0, limit: 2 }, - { name: `offset through tie`, offset: 1, limit: 2 }, - // One child remains at this boundary. Treating the final occupied offset - // as an empty tail would violate the ordered-window contract. - { name: `last occupied offset`, offset: 2, limit: 2 }, - { name: `empty tail`, offset: 3, limit: 2 }, - ])( - `preserves ordered window $name through root and child moves`, - ({ offset, limit }) => { - oracleRoute(`case`) - return expectWindowedIncludeMatches( - { - parents: [ - { id: 0, group: 0, position: 1 }, - { id: 1, group: 1, position: 0 }, - ], - children: [ - { id: 10, parentGroup: 0, score: null, position: 1 }, - { id: 11, parentGroup: 0, score: -1, position: 0 }, - { id: 12, parentGroup: 0, score: 1, position: 0 }, - { id: 13, parentGroup: 1, score: 0, position: 0 }, - ], - pivot: 0, - actions: [ - { - type: `putChild`, - row: { id: 10, parentGroup: 0, score: null, position: -1 }, - }, - { type: `putParent`, row: { id: 0, group: 0, position: -1 } }, - { - type: `putChild`, - row: { id: 11, parentGroup: 0, score: -1, position: 2 }, - }, - { - type: `putChild`, - row: { id: 12, parentGroup: 1, score: 1, position: 0 }, - }, - ], - }, - offset, - limit, - ) + oracleProperty({ + id: `includes-cross-formulation.ordered-window-cases`, + law: { + statement: orderedWindowLaw, + authority: `The cross-formulation equivalence contract stated at the start of this owner and packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The five enumerated offset and limit cases over one fixed scenario.`, }, - ) + 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`, + ], + histories: orderedWindowCases, + label: ({ name }) => + `preserves ordered window ${name} through root and child moves`, + resolve: (windowCase) => ({ ...windowCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(orderedWindowLaw, plan, observe, () => + judgeOrderedWindowCase(plan), + ), + check: forwardAssertionJudgment, + }) // Checked direct replay witness: // TANSTACK_DB_ORACLE_SEED=1658004 @@ -1732,6 +1603,12 @@ describe(`includes cross-formulation oracle`, () => { runs: 8, fixedSeed: 1658004, examples: scenarioExamples, + 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`, + ], }, `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a fixed seed`, (...args: Parameters) => { @@ -1774,6 +1651,12 @@ describe(`includes cross-formulation oracle`, () => { runs: 12, fixedSeed: 1658005, examples: windowedExamples, + 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`, + ], }, `matches recomputation for ordered offset and limit child windows for a fixed seed`, (...args: Parameters) => { diff --git a/packages/db/tests/query/includes-cross-formulation.test.ts b/packages/db/tests/query/includes-cross-formulation.test.ts index d606a5913d..a92f4d9f36 100644 --- a/packages/db/tests/query/includes-cross-formulation.test.ts +++ b/packages/db/tests/query/includes-cross-formulation.test.ts @@ -28,9 +28,17 @@ import { referenceRequestPredicate, withCleanup, } from './includes-cross-formulation-support.js' +import { + assertTlpPartitions, + partitionAndRouteScenario, + recomputeNestedModel, + recomputeWindowedModel, + sharedRouteDeletionScenario, +} from './includes-cross-formulation-model-oracle.js' import type { BasicExpression } from '../../src/query/ir.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { + ChildRow, FlatRow, NormalizedParent, ReferenceChild, @@ -508,3 +516,116 @@ describe(`includes cross-formulation oracle`, () => { }, ) }) + +// Model and checker calibrations for the cross-formulation oracle. They run no +// production: the first steps the relationship and window models through the +// named scenarios, and the second feeds the TLP check a wrong partition. +describe(`includes cross-formulation model and checker`, () => { + test(`bounded grammar axes distinguish routes, order, partitions, and windows`, () => { + const parents = new Map( + partitionAndRouteScenario.parents.map((row) => [row.id, row]), + ) + const children = new Map( + partitionAndRouteScenario.children.map((row) => [row.id, row]), + ) + const ids = (rows: ReadonlyArray) => + rows.map((row) => ({ + id: row.id, + children: row.children.map((c) => c.id), + })) + + expect(ids(recomputeNestedModel(parents, children))).toEqual([ + { id: 1, children: [11, 10] }, + { id: 0, children: [11, 10] }, + ]) + parents.set(0, { id: 0, group: 1, position: 1 }) + expect(ids(recomputeNestedModel(parents, children))).toEqual([ + { id: 1, children: [11, 10] }, + { id: 0, children: [12] }, + ]) + children.set(12, { ...children.get(12)!, parentGroup: 0 }) + expect(ids(recomputeNestedModel(parents, children))).toEqual([ + { id: 1, children: [12, 11, 10] }, + { id: 0, children: [] }, + ]) + parents.set(0, { id: 0, group: 0, position: -1 }) + expect(ids(recomputeNestedModel(parents, children))).toEqual([ + { id: 0, children: [12, 11, 10] }, + { id: 1, children: [12, 11, 10] }, + ]) + expect( + recomputeWindowedModel(parents, children, 0, 0).map( + (row) => row.children.length, + ), + ).toEqual([0, 0]) + expect(ids(recomputeWindowedModel(parents, children, 1, 2))).toEqual([ + { id: 0, children: [11, 10] }, + { id: 1, children: [11, 10] }, + ]) + expect(ids(recomputeWindowedModel(parents, children, 2, 3))).toEqual([ + { id: 0, children: [10] }, + { id: 1, children: [10] }, + ]) + + // The three child scores cover true, false, and unknown at pivot 0. + // Moving the pivot to -2 moves -1 into the complement; replacing null + // with a number removes the unknown partition. These are independent of + // the query evaluator used by the TLP production formulation. + const partition = (pivot: number) => + [...children.values()].map((child) => ({ + id: child.id, + branch: + child.score === null + ? `unknown` + : child.score < pivot + ? `predicate` + : `complement`, + })) + expect(partition(0)).toEqual([ + { id: 10, branch: `unknown` }, + { id: 11, branch: `predicate` }, + { id: 12, branch: `complement` }, + ]) + expect(partition(-2)).toEqual([ + { id: 10, branch: `unknown` }, + { id: 11, branch: `complement` }, + { id: 12, branch: `complement` }, + ]) + children.set(10, { ...children.get(10)!, score: 0 }) + expect(partition(0).find((row) => row.id === 10)?.branch).toBe(`complement`) + + const sharedChildren = new Map( + sharedRouteDeletionScenario.children.map((row) => [row.id, row]), + ) + const sharedParents = new Map( + sharedRouteDeletionScenario.parents.map((row) => [row.id, row]), + ) + expect(ids(recomputeNestedModel(sharedParents, sharedChildren))).toEqual([ + { id: 0, children: [10, 11, 12] }, + { id: 1, children: [10, 11, 12] }, + ]) + sharedChildren.delete(10) + expect(ids(recomputeNestedModel(sharedParents, sharedChildren))).toEqual([ + { id: 0, children: [11, 12] }, + { id: 1, children: [11, 12] }, + ]) + }) + + test(`TLP checker rejects a wrong branch even when the union is complete`, () => { + const rows: Array = [ + { id: 10, parentGroup: 0, score: null, position: 0 }, + { id: 11, parentGroup: 0, score: -1, position: 1 }, + { id: 12, parentGroup: 0, score: 1, position: 2 }, + ] + const correct = [[rows[1]!], [rows[2]!], [rows[0]!]] + assertTlpPartitions(correct, rows, 0, `TLP calibration`) + const wrong = [[rows[0]!], [rows[2]!], [rows[1]!]] + expect(wrong.flat()).toHaveLength(rows.length) + expect(new Set(wrong.flat().map((row) => row.id))).toEqual( + new Set(rows.map((row) => row.id)), + ) + expect(() => + assertTlpPartitions(wrong, rows, 0, `TLP calibration`), + ).toThrow(`TLP calibration predicate partition`) + }) +}) From d32864e45bb0d3e047eb43ce6458ab4f8b4204df Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:54:03 -0700 Subject: [PATCH 037/116] test(db): register proxy revert fixed histories through oracleProperty The fixed histories replay through the campaigns' own model, driver and check as enumerated properties; the unwritten-field calibration becomes trace corruptions of its two histories. The grammar censuses run no production, so they move to proxy-revert-census.test.ts, with the grammar and model in the test-free proxy-revert-model-oracle.ts. Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 7 + packages/db/tests/proxy-revert-census.test.ts | 162 ++ .../db/tests/proxy-revert-model-oracle.ts | 629 +++++++ .../proxy-revert-oracle.property.test.ts | 1542 +++++------------ 4 files changed, 1228 insertions(+), 1112 deletions(-) create mode 100644 packages/db/tests/proxy-revert-census.test.ts create mode 100644 packages/db/tests/proxy-revert-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index f8813f244c..e5ceb7a2a9 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -25,6 +25,8 @@ export const properties: ReadonlyArray = [ `proxy.stored-function-probes`, `proxy.define-property`, `proxy.frozen-nested-writes`, + `proxy.revert-fixed-histories`, + `proxy.revert-native-fixed-histories`, ] export const owners: ReadonlyArray = [ @@ -52,4 +54,9 @@ export const owners: ReadonlyArray = [ `proxy`, `access-path-writes stored-function-probes define-property frozen-nested-writes`, ], + [ + `db/tests/proxy-revert-oracle.property.test.ts`, + `proxy`, + `revert-fixed-histories revert-native-fixed-histories`, + ], ] diff --git a/packages/db/tests/proxy-revert-census.test.ts b/packages/db/tests/proxy-revert-census.test.ts new file mode 100644 index 0000000000..4deb12f975 --- /dev/null +++ b/packages/db/tests/proxy-revert-census.test.ts @@ -0,0 +1,162 @@ +// Fixed-seed censuses of the draft revert grammars. These tests run no +// production: each samples a grammar of proxy-revert-model-oracle.ts with the +// oracle's fixed seed and counts, through the model, the states that the +// generated campaigns in proxy-revert-oracle.property.test.ts must exercise. +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed } from './oracle-config' +import { + FIELDS, + FIXED_SEED, + applicable, + classWriteArb, + encode, + expectedChanges, + historyArb, + nativeHistoryArb, + resolve, + step, +} from './proxy-revert-model-oracle' +import type { Root } from './proxy-revert-model-oracle' + +describe(`draft revert oracle`, () => { + // 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, + oracleFixedSeed(FIXED_SEED, { numRuns: 400 }), + ) + const reached = new Set() + let partial = 0 + let full = 0 + let symbolOnly = 0 + // Reverts that write the row's own object back, a class instance included. + let sameObjectReverts = 0 + let samePointReverts = 0 + for (const { original, ops } of sample) { + let state: Root = { ...original } + // A revert counts only when the field differs from its original, so + // the set trap's revert branch runs. Other reverts write an equal value. + let effectiveReverts = 0 + for (const generated of ops) { + const op = resolve(state, original, generated) + if (op === undefined || !applicable(state, original, op)) continue + reached.add(op.op) + const restored = original[op.field] + if ( + op.op === `revert` && + op.same && + restored !== undefined && + !(restored.k === `prim` || restored.k === `date`) + ) + sameObjectReverts += + restored.k === `point` ? (samePointReverts++, 1) : 1 + if ( + op.op === `revert` && + encode(state[op.field]) !== encode(original[op.field]) + ) + effectiveReverts++ + state = step(state, original, op) + } + const changed = expectedChanges(original, state).size + if (effectiveReverts > 0 && changed > 0) partial++ + if (effectiveReverts > 0 && changed === 0) full++ + for (const field of FIELDS) { + const a = original[field] + const b = state[field] + if ( + a?.k === `obj` && + b?.k === `obj` && + a.sym !== b.sym && + encode({ ...a, sym: undefined }) === + encode({ ...b, sym: undefined }) && + !Object.is(a.sym, b.sym) && + encode(a) !== encode(b) + ) + symbolOnly++ + } + } + expect([...reached].sort()).toEqual([ + `delete`, + `forOf`, + `index`, + `nested`, + `nestedDelete`, + `revert`, + `set`, + ]) + expect(partial).toBeGreaterThanOrEqual(5) + expect(full).toBeGreaterThanOrEqual(30) + expect(symbolOnly).toBeGreaterThan(0) + expect(sameObjectReverts).toBeGreaterThanOrEqual(20) + expect(samePointReverts).toBeGreaterThan(0) + }) + + // 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`, () => { + let urlWrites = 0 + let keylessWrites = 0 + 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) { + const op = resolve(state, original, generated) + if (op === undefined || !applicable(state, original, op)) continue + const current = state[op.field] + if (op.op === `set` && op.value.k === `url` && current?.k === `url`) + if (op.value.path !== current.path) urlWrites++ + if (op.op === `set` && op.value.k === `secret`) + if ( + (current?.k === `secret` && current.v !== op.value.v) || + (current?.k === `obj` && encode(current) === encode({ k: `obj` })) + ) + keylessWrites++ + state = step(state, original, op) + } + } + expect(urlWrites).toBeGreaterThanOrEqual(10) + expect(keylessWrites).toBeGreaterThanOrEqual(10) + }) +}) + +describe(`draft revert oracle: native mutators mixed with assignments`, () => { + // 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, + oracleFixedSeed(FIXED_SEED, { numRuns: 400 }), + ) + const replaced = sample.filter((h) => { + const first = h.ops.findIndex((o) => + [`push`, `pop`, `reverse`].includes(o.op), + ) + return ( + first >= 0 && + h.ops + .slice(first) + .some((o) => o.op === `assignArr` || o.op === `restoreArr`) + ) + }) + const detached = sample.filter((h) => { + const retain = h.ops.findIndex((o) => o.op === `retain`) + if (retain < 0) return false + const after = h.ops.slice(retain + 1) + const replace = after.findIndex( + (o) => o.op === `assignArr` || o.op === `restoreArr`, + ) + return ( + replace >= 0 && + after.slice(replace + 1).some((o) => o.op === `pushHandle`) + ) + }) + expect(replaced.length).toBeGreaterThan(20) + expect(detached.length).toBeGreaterThan(3) + }) +}) diff --git a/packages/db/tests/proxy-revert-model-oracle.ts b/packages/db/tests/proxy-revert-model-oracle.ts new file mode 100644 index 0000000000..1aee97c2cc --- /dev/null +++ b/packages/db/tests/proxy-revert-model-oracle.ts @@ -0,0 +1,629 @@ +// The plain-data value specs, history grammars, and transition model of the +// draft revert oracle. The law, its authority, and its limits are stated in +// proxy-revert-oracle.property.test.ts, which drives production against this +// model; proxy-revert-census.test.ts samples the same grammars. This module +// registers no tests and has no side effects. +import { fc } from '@fast-check/vitest' + +// --------------------------------------------------------------------------- +// Value specs. The model works on plain-data specs and never reads a draft. + +export const S = Symbol(`s`) +export const PRIMITIVES = [0, -0, 1, NaN, `a`, `b`, null, undefined] as const +export type Primitive = (typeof PRIMITIVES)[number] + +export type Spec = + | { k: `prim`; v: Primitive } + | { k: `date`; t: number } + | { k: `regex`; flags: string; lastIndex: number } + | { k: `map`; entries: Array<[string, MapValue]> } + | { k: `set`; values: Array } + | { k: `array`; items: Array } + | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } + | { k: `rows`; rows: Array<{ a: Primitive }> } + | { k: `typed`; ctor: TypedKind; values: Array } + | { k: `url`; path: `a` | `b` } + | { k: `secret`; v: number } + | { k: `point`; a: Primitive } + +// A Map value is a primitive or a nested Set, so draft rules must also hold +// inside Map values. +export type MapValue = Primitive | { set: Array } +export const isSetValue = (v: MapValue): v is { set: Array } => + typeof v === `object` && v !== null +export const mapValue = (v: MapValue): unknown => + isSetValue(v) ? [`set`, v.set] : prim(v) + +// Typed arrays, including a subclass whose constructor ignores its argument, +// so a clone must not rely on constructor arguments to copy elements. +export type TypedKind = `f64` | `u8` | `vec3` +export class Vec3 extends Float64Array { + constructor() { + super(3) + } +} +export const typedKind = (value: unknown): TypedKind | undefined => + value instanceof Vec3 + ? `vec3` + : value instanceof Float64Array + ? `f64` + : value instanceof Uint8Array + ? `u8` + : undefined + +// A class whose only state is a private field, so it has no enumerable keys. +export class Secret { + #v: number + constructor(v: number) { + this.#v = v + } + read(): number { + return this.#v + } +} + +// A class instance with one enumerable key. +export class Point { + constructor(public a: unknown) {} +} + +export const HOLE = Symbol(`hole`) +export const FIELDS = [`f`, `g`, `h`] as const +export type Field = (typeof FIELDS)[number] +export type Root = Partial> + +// Draft equality as an encoding. Equal encodings mean equal values. +export function encode(spec: Spec | undefined): string { + return JSON.stringify(canon(spec)) +} +// SameValue preserves the sign that String(number) and JSON numbers erase. +// This token is independent of the production draft comparator. +export function numberToken(value: number): string { + return Object.is(value, -0) ? `-0` : String(value) +} +export function prim(v: Primitive | undefined): unknown { + return typeof v === `number` ? [`num`, numberToken(v)] : [typeof v, v ?? null] +} +export function canon(spec: Spec | undefined): unknown { + if (spec === undefined) return [`absent`] + switch (spec.k) { + case `prim`: + return prim(spec.v) + case `date`: + return [`date`, String(spec.t)] + case `regex`: + return [`regex`, spec.flags, spec.lastIndex] + case `map`: + return [`map`, spec.entries.map(([key, v]) => [key, mapValue(v)])] + case `set`: + return [`set`, spec.values] + case `array`: + return [ + `array`, + spec.items.length, + spec.items.flatMap((v, i) => (v === HOLE ? [] : [[i, prim(v)]])), + ] + case `obj`: + return [ + `obj`, + `a` in spec ? prim(spec.a) : `-`, + `b` in spec ? prim(spec.b) : `-`, + `sym` in spec ? prim(spec.sym) : `-`, + ] + case `rows`: + return [`rows`, spec.rows.map((row) => prim(row.a))] + case `typed`: + // Elements follow rule 1: -0 differs from 0 and NaN equals NaN. The class is + // part of the value. + return [`typed`, spec.ctor, spec.values.map(numberToken)] + case `url`: + return [`url`, spec.path] + case `point`: + // Rule 9: a Point compares by keys with the plain object a draft + // snapshot holds, so it encodes as that object. + return canon({ k: `obj`, a: spec.a }) + case `secret`: + // Rule 9. Secrets appear only as written values, and the original is + // never a Secret, so the value is enough to compare written states. + return [`secret`, spec.v] + } +} + +export function realize(spec: Spec): unknown { + switch (spec.k) { + case `prim`: + return spec.v + case `date`: + return new Date(spec.t) + case `regex`: { + const value = new RegExp(`x`, spec.flags) + value.lastIndex = spec.lastIndex + return value + } + case `map`: + return new Map( + spec.entries.map(([key, v]) => [ + key, + isSetValue(v) ? new Set(v.set) : v, + ]), + ) + case `set`: + return new Set(spec.values) + case `array`: { + const value: Array = [] + value.length = spec.items.length + spec.items.forEach((v, i) => { + if (v !== HOLE) value[i] = v + }) + return value + } + case `obj`: { + const value: Record = {} + if (`a` in spec) value.a = spec.a + if (`b` in spec) value.b = spec.b + if (`sym` in spec) value[S] = spec.sym + return value + } + case `rows`: + return spec.rows.map((row) => ({ a: row.a })) + case `typed`: { + if (spec.ctor === `u8`) return Uint8Array.from(spec.values) + const value = + spec.ctor === `vec3` ? new Vec3() : new Float64Array(spec.values.length) + spec.values.forEach((v, i) => (value[i] = v)) + return value + } + case `url`: + return new URL(`https://example.com/${spec.path}`) + case `secret`: + return new Secret(spec.v) + case `point`: + return new Point(spec.a) + } +} + +export function realizeRoot(root: Root): Record { + const value: Record = {} + for (const field of FIELDS) { + const spec = root[field] + if (spec) value[field] = realize(spec) + } + return value +} + +// --------------------------------------------------------------------------- +// Grammar. + +export const primArb = fc.constantFrom(...PRIMITIVES) +export const specArb: fc.Arbitrary = fc.oneof( + { weight: 2, arbitrary: primArb.map((v) => ({ k: `prim` as const, v })) }, + fc.constantFrom(0, 1, NaN).map((t) => ({ k: `date` as const, t })), + fc.record({ + k: fc.constant(`regex` as const), + flags: fc.constantFrom(``, `g`), + lastIndex: fc.nat(1), + }), + fc + .uniqueArray( + fc.tuple( + fc.constantFrom(`x`, `y`), + fc.oneof( + primArb, + fc + .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) + .map((set): MapValue => ({ set })), + ), + ), + { maxLength: 2, selector: ([key]) => key }, + ) + .map((entries) => ({ k: `map` as const, entries })), + fc + .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) + .map((values) => ({ k: `set` as const, values })), + fc + .array(fc.oneof(primArb, fc.constant(HOLE)), { maxLength: 3 }) + .map((items) => ({ k: `array` as const, items })), + fc.record( + { k: fc.constant(`obj` as const), a: primArb, b: primArb, sym: primArb }, + { requiredKeys: [`k`] }, + ), + fc + .array(fc.record({ a: primArb }), { minLength: 1, maxLength: 3 }) + .map((rows) => ({ k: `rows` as const, rows })), + fc.oneof( + fc + .array(fc.constantFrom(0, -0, 1, NaN, 1.5), { maxLength: 3 }) + .map((values): Spec => ({ k: `typed`, ctor: `f64`, values })), + fc + .array(fc.constantFrom(0, 1, 255), { maxLength: 3 }) + .map((values): Spec => ({ k: `typed`, ctor: `u8`, values })), + fc + .tuple(...[0, 1, 2].map(() => fc.constantFrom(0, -0, 1, NaN))) + .map((values): Spec => ({ k: `typed`, ctor: `vec3`, values })), + ), + fc + .constantFrom(`a` as const, `b` as const) + .map((path): Spec => ({ k: `url`, path })), + primArb.map((a): Spec => ({ k: `point`, a })), +) +// Written values may also be class instances with only private state. +export const writtenArb: fc.Arbitrary = fc.oneof( + { weight: 9, arbitrary: specArb }, + fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), +) + +export type Op = + | { op: `set`; field: Field; value: Spec } + // `same` writes the original row's own value back instead of an equal + // fresh value, so identity-based paths are reached too. + | { op: `revert`; field: Field; same?: boolean } + | { op: `delete`; field: Field } + | { op: `nested`; field: Field; key: `a` | `b` | `sym`; value: Primitive } + | { op: `nestedDelete`; field: Field; key: `a` | `b` | `sym` } + | { op: `index`; field: Field; index: number; value: Primitive } + | { op: `forOf`; field: Field; index: number; value: Primitive } + +export const opArb: fc.Arbitrary = fc.oneof( + fc.record({ + op: fc.constant(`set` as const), + field: fc.constantFrom(...FIELDS), + value: writtenArb, + }), + { + weight: 3, + arbitrary: fc.record({ + op: fc.constant(`revert` as const), + field: fc.constantFrom(...FIELDS), + same: fc.boolean(), + }), + }, + fc.record({ + op: fc.constant(`delete` as const), + field: fc.constantFrom(...FIELDS), + }), + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`nested` as const), + field: fc.constantFrom(...FIELDS), + key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), + value: primArb, + }), + }, + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`nestedDelete` as const), + field: fc.constantFrom(...FIELDS), + key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), + }), + }, + fc.record({ + op: fc.constant(`index` as const), + field: fc.constantFrom(...FIELDS), + index: fc.nat(2), + value: primArb, + }), + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`forOf` as const), + field: fc.constantFrom(...FIELDS), + index: fc.nat(2), + value: primArb, + }), + }, +) + +// Generated ops also include a revert of a field that is currently changed, +// so most reverts run the set trap's revert branch. `resolve` turns it into a +// concrete revert of one changed field, or nothing. +export type GeneratedOp = Op | { op: `revertChanged`; pick: number } +export type History = { original: Root; ops: Array } + +export function resolve( + state: Root, + original: Root, + op: GeneratedOp, +): Op | undefined { + if (op.op !== `revertChanged`) return op + const changed = FIELDS.filter( + (field) => encode(state[field]) !== encode(original[field]), + ) + return changed.length > 0 + ? { op: `revert`, field: changed[op.pick % changed.length]! } + : undefined +} +export const historyArb: fc.Arbitrary = fc.record({ + original: fc.record( + { f: specArb, g: specArb, h: specArb }, + { requiredKeys: [] }, + ), + ops: fc.array( + fc.oneof( + { weight: 3, arbitrary: opArb as fc.Arbitrary }, + { + weight: 2, + arbitrary: fc.record({ + op: fc.constant(`revertChanged` as const), + pick: fc.nat(2), + }), + }, + ), + { minLength: 1, maxLength: 8 }, + ), +}) + +// Partial reverts by construction: change two or three fields in some order, +// then revert every changed field but one. The model decides whether a change +// survives (a new value can equal the original). +export const partialRevertArb: fc.Arbitrary = fc + .record({ + original: fc.record({ f: specArb, g: specArb, h: specArb }), + values: fc.tuple(specArb, specArb, specArb), + order: fc.shuffledSubarray([...FIELDS], { minLength: 2 }), + keep: fc.nat(2), + }) + .map(({ original, values, order, keep }) => { + const kept = order[keep % order.length]! + const changes: Array = order.map((field) => ({ + op: `set`, + field, + value: values[FIELDS.indexOf(field)]!, + })) + const reverts: Array = order + .filter((field) => field !== kept) + .map((field) => ({ op: `revert`, field })) + return { original, ops: [...changes, ...reverts] } + }) + +// Two writes of URLs or keyless Secrets to one field, with up to two other +// ops in between and an optional revert. The original field is a URL, an +// object without keys, or any value. +export const urlOrSecretArb: fc.Arbitrary = fc.oneof( + fc + .constantFrom(`a` as const, `b` as const) + .map((path): Spec => ({ k: `url`, path })), + fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), +) +export const classWriteArb: fc.Arbitrary = fc + .record({ + f: fc.oneof( + urlOrSecretArb.filter((spec) => spec.k === `url`), + fc.constant({ k: `obj` }), + specArb, + ), + g: specArb, + first: urlOrSecretArb, + second: urlOrSecretArb, + between: fc.array(opArb, { maxLength: 2 }), + revert: fc.boolean(), + }) + .map(({ f, g, first, second, between, revert }): History => ({ + original: { f, g }, + ops: [ + { op: `set`, field: `f`, value: first }, + ...between, + { op: `set`, field: `f`, value: second }, + ...(revert ? [{ op: `revert` as const, field: `f` as const }] : []), + ], + })) + +// Nested round trips. Either add a key the original object lacks and later +// delete it, or change an existing key and later write its original value +// back, with up to two other ops in between. Other ops may leave other fields +// changed, so the nested object must stop counting as a change on its own. +export const nestedRoundTripArb: fc.Arbitrary = fc + .record({ + a: primArb, + sym: fc.option(primArb, { nil: undefined }), + g: specArb, + mode: fc.constantFrom( + `delete` as const, + `restore` as const, + `replace` as const, + ), + value: primArb, + between: fc.array(opArb, { maxLength: 2 }), + }) + .map(({ a, sym, g, mode, value, between }): History => { + const f: Spec = sym === undefined ? { k: `obj`, a } : { k: `obj`, a, sym } + // `replace` assigns a new object without `a`; the nested write that + // restores `a` makes the field equal its original again. + const replacement: Spec = + sym === undefined ? { k: `obj` } : { k: `obj`, sym } + const first: GeneratedOp = + mode === `delete` + ? { op: `nested`, field: `f`, key: `b`, value } + : mode === `replace` + ? { op: `set`, field: `f`, value: replacement } + : { op: `nested`, field: `f`, key: `a`, value } + const last: GeneratedOp = + mode === `delete` + ? { op: `nestedDelete`, field: `f`, key: `b` } + : { op: `nested`, field: `f`, key: `a`, value: a } + return { original: { f, g }, ops: [first, ...between, last] } + }) + +// A sign-only write must survive each assignment path. Random fields, nested +// keys and typed-array positions vary the access path without another value +// change masking a lost zero sign. Both signs are legal; Uint8Array is excluded +// because native integer conversion cannot retain -0. Removing a path loses +// its draft boundary; removing the sign distinction admits the old comparator. +export const signedZeroPaths = [`root`, `nested`, `float64`] as const +export const signedZeroArb = fc.record({ + path: fc.constantFrom(...signedZeroPaths), + field: fc.constantFrom(...FIELDS), + negative: fc.boolean(), + key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), + width: fc.integer({ min: 1, max: 3 }), + index: fc.nat(2), +}) +export type SignedZeroInput = + typeof signedZeroArb extends fc.Arbitrary ? T : never +export function signedZeroHistory(input: SignedZeroInput): History { + const before = input.negative ? -0 : 0 + const after = input.negative ? 0 : -0 + let original: Spec + let op: Op + if (input.path === `root`) { + original = { k: `prim`, v: before } + op = { op: `set`, field: input.field, value: { k: `prim`, v: after } } + } else if (input.path === `nested`) { + original = { k: `obj`, [input.key]: before } + op = { op: `nested`, field: input.field, key: input.key, value: after } + } else { + const index = input.index % input.width + original = { + k: `typed`, + ctor: `f64`, + values: Array.from({ length: input.width }, (_, at) => + at === index ? before : 1, + ), + } + op = { op: `index`, field: input.field, index, value: after } + } + return { original: { [input.field]: original }, ops: [op] } +} + +// --------------------------------------------------------------------------- +// Model: apply each op to the spec state. An op whose target has the wrong +// shape does nothing, and the driver skips it the same way. + +export function applicable(state: Root, original: Root, op: Op): boolean { + const current = state[op.field] + switch (op.op) { + case `set`: + return true + case `revert`: + // Restoring a deleted field is a revert too. Skip only when the field + // is absent both originally and now. + return original[op.field] !== undefined || current !== undefined + case `delete`: + return current !== undefined + case `nested`: + return current?.k === `obj` + case `nestedDelete`: + return current?.k === `obj` && op.key in current + case `index`: + // Float typed arrays store any number exactly; Uint8Array would coerce. + if (current?.k === `typed`) + return ( + current.ctor !== `u8` && + typeof op.value === `number` && + op.index < current.values.length + ) + return current?.k === `array` && op.index < current.items.length + case `forOf`: + return current?.k === `rows` && op.index < current.rows.length + } +} + +export function step(state: Root, original: Root, op: Op): Root { + const next: Root = { ...state } + const current = state[op.field] + switch (op.op) { + case `set`: + next[op.field] = op.value + break + case `revert`: + if (original[op.field] === undefined) delete next[op.field] + else next[op.field] = original[op.field] + break + case `delete`: + delete next[op.field] + break + case `nested`: + next[op.field] = { + ...(current as Extract), + [op.key]: op.value, + } + break + case `nestedDelete`: { + const obj = { ...(current as Extract) } + delete obj[op.key] + next[op.field] = obj + break + } + case `index`: { + if (current?.k === `typed`) { + const values = [...current.values] + values[op.index] = op.value as number + next[op.field] = { ...current, values } + break + } + const items = [...(current as Extract).items] + items[op.index] = op.value + next[op.field] = { k: `array`, items } + break + } + case `forOf`: { + const rows = (current as Extract).rows.map( + (row) => ({ ...row }), + ) + rows[op.index] = { a: op.value } + next[op.field] = { k: `rows`, rows } + break + } + } + return next +} + +export function expectedChanges( + original: Root, + final: Root, +): Map { + const changes = new Map() + for (const field of FIELDS) { + if (encode(final[field]) === encode(original[field])) continue + changes.set(field, final[field]) + } + return changes +} + +export const FIXED_SEED = 2026101 + +// Native mutators mixed with assignments: the grammar. + +export type NativeRow = { + x: number + f: { arr: Array; g: { a?: number } } +} +export type NativeOp = + | { op: `push`; v: number } + | { op: `pop` } + | { op: `reverse` } + | { op: `assignArr`; items: Array } + | { op: `restoreArr` } + | { op: `replaceG` } + | { op: `setGa`; v: number } + | { op: `retain` } + | { op: `pushHandle`; v: number } + | { op: `setX`; v: number } + +export const smallInt = fc.integer({ min: 0, max: 2 }) +export const nativeOpArb: fc.Arbitrary = fc.oneof( + smallInt.map((v): NativeOp => ({ op: `push`, v })), + fc.constant({ op: `pop` }), + fc.constant({ op: `reverse` }), + fc + .array(smallInt, { maxLength: 3 }) + .map((items): NativeOp => ({ op: `assignArr`, items })), + { weight: 2, arbitrary: fc.constant({ op: `restoreArr` }) }, + fc.constant({ op: `replaceG` }), + { weight: 2, arbitrary: smallInt.map((v): NativeOp => ({ op: `setGa`, v })) }, + fc.constant({ op: `retain` }), + smallInt.map((v): NativeOp => ({ op: `pushHandle`, v })), + smallInt.map((v): NativeOp => ({ op: `setX`, v })), +) +export type NativeHistory = { original: NativeRow; ops: Array } +export const nativeHistoryArb: fc.Arbitrary = fc.record({ + original: fc.record({ + x: smallInt, + f: fc.record({ + arr: fc.array(smallInt, { maxLength: 3 }), + g: fc.record({ a: smallInt }), + }), + }), + ops: fc.array(nativeOpArb, { minLength: 1, maxLength: 8 }), +}) diff --git a/packages/db/tests/proxy-revert-oracle.property.test.ts b/packages/db/tests/proxy-revert-oracle.property.test.ts index 7666ee64d6..4cdbc1a713 100644 --- a/packages/db/tests/proxy-revert-oracle.property.test.ts +++ b/packages/db/tests/proxy-revert-oracle.property.test.ts @@ -1,10 +1,44 @@ -import { describe, expect, it as vitestIt } from 'vitest' -import { fc } from '@fast-check/vitest' +import { describe, expect } from 'vitest' import { createChangeProxy } from '../src/proxy' -import { oracleFixedSeed, oracleReplayTest, oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' import { oracleProperty, pass } from './oracle-property' +import { + FIELDS, + FIXED_SEED, + HOLE, + S, + Secret, + applicable, + canon, + classWriteArb, + encode, + expectedChanges, + historyArb, + nativeHistoryArb, + nestedRoundTripArb, + partialRevertArb, + prim, + realize, + realizeRoot, + resolve, + signedZeroArb, + signedZeroHistory, + signedZeroPaths, + step, + typedKind, +} from './proxy-revert-model-oracle' import type { Observation, Verdict } from './oracle-property' +import type { + Field, + History, + MapValue, + NativeHistory, + NativeRow, + Op, + Primitive, + Root, + SignedZeroInput, + Spec, +} from './proxy-revert-model-oracle' /** * # Which draft writes count as changes, and which count as reverts? @@ -79,578 +113,6 @@ import type { Observation, Verdict } from './oracle-property' * detachment contract owns that boundary. */ -// --------------------------------------------------------------------------- -// Value specs. The model works on plain-data specs and never reads a draft. - -const S = Symbol(`s`) -const PRIMITIVES = [0, -0, 1, NaN, `a`, `b`, null, undefined] as const -type Primitive = (typeof PRIMITIVES)[number] - -type Spec = - | { k: `prim`; v: Primitive } - | { k: `date`; t: number } - | { k: `regex`; flags: string; lastIndex: number } - | { k: `map`; entries: Array<[string, MapValue]> } - | { k: `set`; values: Array } - | { k: `array`; items: Array } - | { k: `obj`; a?: Primitive; b?: Primitive; sym?: Primitive } - | { k: `rows`; rows: Array<{ a: Primitive }> } - | { k: `typed`; ctor: TypedKind; values: Array } - | { k: `url`; path: `a` | `b` } - | { k: `secret`; v: number } - | { k: `point`; a: Primitive } - -// A Map value is a primitive or a nested Set, so draft rules must also hold -// inside Map values. -type MapValue = Primitive | { set: Array } -const isSetValue = (v: MapValue): v is { set: Array } => - typeof v === `object` && v !== null -const mapValue = (v: MapValue): unknown => - isSetValue(v) ? [`set`, v.set] : prim(v) - -// Typed arrays, including a subclass whose constructor ignores its argument, -// so a clone must not rely on constructor arguments to copy elements. -type TypedKind = `f64` | `u8` | `vec3` -class Vec3 extends Float64Array { - constructor() { - super(3) - } -} -const typedKind = (value: unknown): TypedKind | undefined => - value instanceof Vec3 - ? `vec3` - : value instanceof Float64Array - ? `f64` - : value instanceof Uint8Array - ? `u8` - : undefined - -// A class whose only state is a private field, so it has no enumerable keys. -class Secret { - #v: number - constructor(v: number) { - this.#v = v - } - read(): number { - return this.#v - } -} - -// A class instance with one enumerable key. -class Point { - constructor(public a: unknown) {} -} - -const HOLE = Symbol(`hole`) -const FIELDS = [`f`, `g`, `h`] as const -type Field = (typeof FIELDS)[number] -type Root = Partial> - -// Draft equality as an encoding. Equal encodings mean equal values. -function encode(spec: Spec | undefined): string { - return JSON.stringify(canon(spec)) -} -// SameValue preserves the sign that String(number) and JSON numbers erase. -// This token is independent of the production draft comparator. -function numberToken(value: number): string { - return Object.is(value, -0) ? `-0` : String(value) -} -function prim(v: Primitive | undefined): unknown { - return typeof v === `number` ? [`num`, numberToken(v)] : [typeof v, v ?? null] -} -function canon(spec: Spec | undefined): unknown { - if (spec === undefined) return [`absent`] - switch (spec.k) { - case `prim`: - return prim(spec.v) - case `date`: - return [`date`, String(spec.t)] - case `regex`: - return [`regex`, spec.flags, spec.lastIndex] - case `map`: - return [`map`, spec.entries.map(([key, v]) => [key, mapValue(v)])] - case `set`: - return [`set`, spec.values] - case `array`: - return [ - `array`, - spec.items.length, - spec.items.flatMap((v, i) => (v === HOLE ? [] : [[i, prim(v)]])), - ] - case `obj`: - return [ - `obj`, - `a` in spec ? prim(spec.a) : `-`, - `b` in spec ? prim(spec.b) : `-`, - `sym` in spec ? prim(spec.sym) : `-`, - ] - case `rows`: - return [`rows`, spec.rows.map((row) => prim(row.a))] - case `typed`: - // Elements follow rule 1: -0 differs from 0 and NaN equals NaN. The class is - // part of the value. - return [`typed`, spec.ctor, spec.values.map(numberToken)] - case `url`: - return [`url`, spec.path] - case `point`: - // Rule 9: a Point compares by keys with the plain object a draft - // snapshot holds, so it encodes as that object. - return canon({ k: `obj`, a: spec.a }) - case `secret`: - // Rule 9. Secrets appear only as written values, and the original is - // never a Secret, so the value is enough to compare written states. - return [`secret`, spec.v] - } -} - -function realize(spec: Spec): unknown { - switch (spec.k) { - case `prim`: - return spec.v - case `date`: - return new Date(spec.t) - case `regex`: { - const value = new RegExp(`x`, spec.flags) - value.lastIndex = spec.lastIndex - return value - } - case `map`: - return new Map( - spec.entries.map(([key, v]) => [ - key, - isSetValue(v) ? new Set(v.set) : v, - ]), - ) - case `set`: - return new Set(spec.values) - case `array`: { - const value: Array = [] - value.length = spec.items.length - spec.items.forEach((v, i) => { - if (v !== HOLE) value[i] = v - }) - return value - } - case `obj`: { - const value: Record = {} - if (`a` in spec) value.a = spec.a - if (`b` in spec) value.b = spec.b - if (`sym` in spec) value[S] = spec.sym - return value - } - case `rows`: - return spec.rows.map((row) => ({ a: row.a })) - case `typed`: { - if (spec.ctor === `u8`) return Uint8Array.from(spec.values) - const value = - spec.ctor === `vec3` ? new Vec3() : new Float64Array(spec.values.length) - spec.values.forEach((v, i) => (value[i] = v)) - return value - } - case `url`: - return new URL(`https://example.com/${spec.path}`) - case `secret`: - return new Secret(spec.v) - case `point`: - return new Point(spec.a) - } -} - -function realizeRoot(root: Root): Record { - const value: Record = {} - for (const field of FIELDS) { - const spec = root[field] - if (spec) value[field] = realize(spec) - } - return value -} - -// --------------------------------------------------------------------------- -// Grammar. - -const primArb = fc.constantFrom(...PRIMITIVES) -const specArb: fc.Arbitrary = fc.oneof( - { weight: 2, arbitrary: primArb.map((v) => ({ k: `prim` as const, v })) }, - fc.constantFrom(0, 1, NaN).map((t) => ({ k: `date` as const, t })), - fc.record({ - k: fc.constant(`regex` as const), - flags: fc.constantFrom(``, `g`), - lastIndex: fc.nat(1), - }), - fc - .uniqueArray( - fc.tuple( - fc.constantFrom(`x`, `y`), - fc.oneof( - primArb, - fc - .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) - .map((set): MapValue => ({ set })), - ), - ), - { maxLength: 2, selector: ([key]) => key }, - ) - .map((entries) => ({ k: `map` as const, entries })), - fc - .uniqueArray(fc.constantFrom(`x`, `y`), { maxLength: 2 }) - .map((values) => ({ k: `set` as const, values })), - fc - .array(fc.oneof(primArb, fc.constant(HOLE)), { maxLength: 3 }) - .map((items) => ({ k: `array` as const, items })), - fc.record( - { k: fc.constant(`obj` as const), a: primArb, b: primArb, sym: primArb }, - { requiredKeys: [`k`] }, - ), - fc - .array(fc.record({ a: primArb }), { minLength: 1, maxLength: 3 }) - .map((rows) => ({ k: `rows` as const, rows })), - fc.oneof( - fc - .array(fc.constantFrom(0, -0, 1, NaN, 1.5), { maxLength: 3 }) - .map((values): Spec => ({ k: `typed`, ctor: `f64`, values })), - fc - .array(fc.constantFrom(0, 1, 255), { maxLength: 3 }) - .map((values): Spec => ({ k: `typed`, ctor: `u8`, values })), - fc - .tuple(...[0, 1, 2].map(() => fc.constantFrom(0, -0, 1, NaN))) - .map((values): Spec => ({ k: `typed`, ctor: `vec3`, values })), - ), - fc - .constantFrom(`a` as const, `b` as const) - .map((path): Spec => ({ k: `url`, path })), - primArb.map((a): Spec => ({ k: `point`, a })), -) -// Written values may also be class instances with only private state. -const writtenArb: fc.Arbitrary = fc.oneof( - { weight: 9, arbitrary: specArb }, - fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), -) - -type Op = - | { op: `set`; field: Field; value: Spec } - // `same` writes the original row's own value back instead of an equal - // fresh value, so identity-based paths are reached too. - | { op: `revert`; field: Field; same?: boolean } - | { op: `delete`; field: Field } - | { op: `nested`; field: Field; key: `a` | `b` | `sym`; value: Primitive } - | { op: `nestedDelete`; field: Field; key: `a` | `b` | `sym` } - | { op: `index`; field: Field; index: number; value: Primitive } - | { op: `forOf`; field: Field; index: number; value: Primitive } - -const opArb: fc.Arbitrary = fc.oneof( - fc.record({ - op: fc.constant(`set` as const), - field: fc.constantFrom(...FIELDS), - value: writtenArb, - }), - { - weight: 3, - arbitrary: fc.record({ - op: fc.constant(`revert` as const), - field: fc.constantFrom(...FIELDS), - same: fc.boolean(), - }), - }, - fc.record({ - op: fc.constant(`delete` as const), - field: fc.constantFrom(...FIELDS), - }), - { - weight: 2, - arbitrary: fc.record({ - op: fc.constant(`nested` as const), - field: fc.constantFrom(...FIELDS), - key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), - value: primArb, - }), - }, - { - weight: 2, - arbitrary: fc.record({ - op: fc.constant(`nestedDelete` as const), - field: fc.constantFrom(...FIELDS), - key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), - }), - }, - fc.record({ - op: fc.constant(`index` as const), - field: fc.constantFrom(...FIELDS), - index: fc.nat(2), - value: primArb, - }), - { - weight: 2, - arbitrary: fc.record({ - op: fc.constant(`forOf` as const), - field: fc.constantFrom(...FIELDS), - index: fc.nat(2), - value: primArb, - }), - }, -) - -// Generated ops also include a revert of a field that is currently changed, -// so most reverts run the set trap's revert branch. `resolve` turns it into a -// concrete revert of one changed field, or nothing. -type GeneratedOp = Op | { op: `revertChanged`; pick: number } -type History = { original: Root; ops: Array } - -function resolve(state: Root, original: Root, op: GeneratedOp): Op | undefined { - if (op.op !== `revertChanged`) return op - const changed = FIELDS.filter( - (field) => encode(state[field]) !== encode(original[field]), - ) - return changed.length > 0 - ? { op: `revert`, field: changed[op.pick % changed.length]! } - : undefined -} -const historyArb: fc.Arbitrary = fc.record({ - original: fc.record( - { f: specArb, g: specArb, h: specArb }, - { requiredKeys: [] }, - ), - ops: fc.array( - fc.oneof( - { weight: 3, arbitrary: opArb as fc.Arbitrary }, - { - weight: 2, - arbitrary: fc.record({ - op: fc.constant(`revertChanged` as const), - pick: fc.nat(2), - }), - }, - ), - { minLength: 1, maxLength: 8 }, - ), -}) - -// Partial reverts by construction: change two or three fields in some order, -// then revert every changed field but one. The model decides whether a change -// survives (a new value can equal the original). -const partialRevertArb: fc.Arbitrary = fc - .record({ - original: fc.record({ f: specArb, g: specArb, h: specArb }), - values: fc.tuple(specArb, specArb, specArb), - order: fc.shuffledSubarray([...FIELDS], { minLength: 2 }), - keep: fc.nat(2), - }) - .map(({ original, values, order, keep }) => { - const kept = order[keep % order.length]! - const changes: Array = order.map((field) => ({ - op: `set`, - field, - value: values[FIELDS.indexOf(field)]!, - })) - const reverts: Array = order - .filter((field) => field !== kept) - .map((field) => ({ op: `revert`, field })) - return { original, ops: [...changes, ...reverts] } - }) - -// Two writes of URLs or keyless Secrets to one field, with up to two other -// ops in between and an optional revert. The original field is a URL, an -// object without keys, or any value. -const urlOrSecretArb: fc.Arbitrary = fc.oneof( - fc - .constantFrom(`a` as const, `b` as const) - .map((path): Spec => ({ k: `url`, path })), - fc.constantFrom(1, 2).map((v): Spec => ({ k: `secret`, v })), -) -const classWriteArb: fc.Arbitrary = fc - .record({ - f: fc.oneof( - urlOrSecretArb.filter((spec) => spec.k === `url`), - fc.constant({ k: `obj` }), - specArb, - ), - g: specArb, - first: urlOrSecretArb, - second: urlOrSecretArb, - between: fc.array(opArb, { maxLength: 2 }), - revert: fc.boolean(), - }) - .map(({ f, g, first, second, between, revert }): History => ({ - original: { f, g }, - ops: [ - { op: `set`, field: `f`, value: first }, - ...between, - { op: `set`, field: `f`, value: second }, - ...(revert ? [{ op: `revert` as const, field: `f` as const }] : []), - ], - })) - -// Nested round trips. Either add a key the original object lacks and later -// delete it, or change an existing key and later write its original value -// back, with up to two other ops in between. Other ops may leave other fields -// changed, so the nested object must stop counting as a change on its own. -const nestedRoundTripArb: fc.Arbitrary = fc - .record({ - a: primArb, - sym: fc.option(primArb, { nil: undefined }), - g: specArb, - mode: fc.constantFrom( - `delete` as const, - `restore` as const, - `replace` as const, - ), - value: primArb, - between: fc.array(opArb, { maxLength: 2 }), - }) - .map(({ a, sym, g, mode, value, between }): History => { - const f: Spec = sym === undefined ? { k: `obj`, a } : { k: `obj`, a, sym } - // `replace` assigns a new object without `a`; the nested write that - // restores `a` makes the field equal its original again. - const replacement: Spec = - sym === undefined ? { k: `obj` } : { k: `obj`, sym } - const first: GeneratedOp = - mode === `delete` - ? { op: `nested`, field: `f`, key: `b`, value } - : mode === `replace` - ? { op: `set`, field: `f`, value: replacement } - : { op: `nested`, field: `f`, key: `a`, value } - const last: GeneratedOp = - mode === `delete` - ? { op: `nestedDelete`, field: `f`, key: `b` } - : { op: `nested`, field: `f`, key: `a`, value: a } - return { original: { f, g }, ops: [first, ...between, last] } - }) - -// A sign-only write must survive each assignment path. Random fields, nested -// keys and typed-array positions vary the access path without another value -// change masking a lost zero sign. Both signs are legal; Uint8Array is excluded -// because native integer conversion cannot retain -0. Removing a path loses -// its draft boundary; removing the sign distinction admits the old comparator. -const signedZeroPaths = [`root`, `nested`, `float64`] as const -const signedZeroArb = fc.record({ - path: fc.constantFrom(...signedZeroPaths), - field: fc.constantFrom(...FIELDS), - negative: fc.boolean(), - key: fc.constantFrom(`a` as const, `b` as const, `sym` as const), - width: fc.integer({ min: 1, max: 3 }), - index: fc.nat(2), -}) -type SignedZeroInput = - typeof signedZeroArb extends fc.Arbitrary ? T : never -function signedZeroHistory(input: SignedZeroInput): History { - const before = input.negative ? -0 : 0 - const after = input.negative ? 0 : -0 - let original: Spec - let op: Op - if (input.path === `root`) { - original = { k: `prim`, v: before } - op = { op: `set`, field: input.field, value: { k: `prim`, v: after } } - } else if (input.path === `nested`) { - original = { k: `obj`, [input.key]: before } - op = { op: `nested`, field: input.field, key: input.key, value: after } - } else { - const index = input.index % input.width - original = { - k: `typed`, - ctor: `f64`, - values: Array.from({ length: input.width }, (_, at) => - at === index ? before : 1, - ), - } - op = { op: `index`, field: input.field, index, value: after } - } - return { original: { [input.field]: original }, ops: [op] } -} - -// --------------------------------------------------------------------------- -// Model: apply each op to the spec state. An op whose target has the wrong -// shape does nothing, and the driver skips it the same way. - -function applicable(state: Root, original: Root, op: Op): boolean { - const current = state[op.field] - switch (op.op) { - case `set`: - return true - case `revert`: - // Restoring a deleted field is a revert too. Skip only when the field - // is absent both originally and now. - return original[op.field] !== undefined || current !== undefined - case `delete`: - return current !== undefined - case `nested`: - return current?.k === `obj` - case `nestedDelete`: - return current?.k === `obj` && op.key in current - case `index`: - // Float typed arrays store any number exactly; Uint8Array would coerce. - if (current?.k === `typed`) - return ( - current.ctor !== `u8` && - typeof op.value === `number` && - op.index < current.values.length - ) - return current?.k === `array` && op.index < current.items.length - case `forOf`: - return current?.k === `rows` && op.index < current.rows.length - } -} - -function step(state: Root, original: Root, op: Op): Root { - const next: Root = { ...state } - const current = state[op.field] - switch (op.op) { - case `set`: - next[op.field] = op.value - break - case `revert`: - if (original[op.field] === undefined) delete next[op.field] - else next[op.field] = original[op.field] - break - case `delete`: - delete next[op.field] - break - case `nested`: - next[op.field] = { - ...(current as Extract), - [op.key]: op.value, - } - break - case `nestedDelete`: { - const obj = { ...(current as Extract) } - delete obj[op.key] - next[op.field] = obj - break - } - case `index`: { - if (current?.k === `typed`) { - const values = [...current.values] - values[op.index] = op.value as number - next[op.field] = { ...current, values } - break - } - const items = [...(current as Extract).items] - items[op.index] = op.value - next[op.field] = { k: `array`, items } - break - } - case `forOf`: { - const rows = (current as Extract).rows.map( - (row) => ({ ...row }), - ) - rows[op.index] = { a: op.value } - next[op.field] = { k: `rows`, rows } - break - } - } - return next -} - -function expectedChanges( - original: Root, - final: Root, -): Map { - const changes = new Map() - for (const field of FIELDS) { - if (encode(final[field]) === encode(original[field])) continue - changes.set(field, final[field]) - } - return changes -} - // --------------------------------------------------------------------------- // Driver. @@ -882,10 +344,6 @@ function expectHistoryObservation( ).toBe(observed.before[field]) } -function expectHistory(history: History): void { - expectHistoryObservation(history, driveHistory(history)) -} - function checkHistory( history: History, trace: ReadonlyArray, @@ -932,7 +390,6 @@ const revertLaw = { // runs its recorded history once and every other test stands aside. A seed // alone reseeds the random campaigns. -const FIXED_SEED = 2026101 const WRITE_HISTORY = `proxy.revert-history` const NESTED_ROUND_TRIPS = `proxy.revert-nested-round-trips` const CLASS_WRITES = `proxy.revert-class-writes` @@ -940,8 +397,6 @@ const PARTIAL_REVERTS = `proxy.revert-partial-reverts` const NATIVE_HISTORIES = `proxy.revert-native-histories` const SIGNED_ZERO = `proxy.revert-signed-zero` -const it = oracleReplayTest(vitestIt) - 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 @@ -1040,373 +495,282 @@ describe(`draft revert oracle`, () => { check: checkHistory, }) - // 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`, () => { - oracleRoute(`fixed seed`) - const sample = fc.sample( - historyArb, - oracleFixedSeed(FIXED_SEED, { numRuns: 400 }), - ) - const reached = new Set() - let partial = 0 - let full = 0 - let symbolOnly = 0 - // Reverts that write the row's own object back, a class instance included. - let sameObjectReverts = 0 - let samePointReverts = 0 - for (const { original, ops } of sample) { - let state: Root = { ...original } - // A revert counts only when the field differs from its original, so - // the set trap's revert branch runs. Other reverts write an equal value. - let effectiveReverts = 0 - for (const generated of ops) { - const op = resolve(state, original, generated) - if (op === undefined || !applicable(state, original, op)) continue - reached.add(op.op) - const restored = original[op.field] - if ( - op.op === `revert` && - op.same && - restored !== undefined && - !(restored.k === `prim` || restored.k === `date`) - ) - sameObjectReverts += - restored.k === `point` ? (samePointReverts++, 1) : 1 - if ( - op.op === `revert` && - encode(state[op.field]) !== encode(original[op.field]) - ) - effectiveReverts++ - state = step(state, original, op) - } - const changed = expectedChanges(original, state).size - if (effectiveReverts > 0 && changed > 0) partial++ - if (effectiveReverts > 0 && changed === 0) full++ - for (const field of FIELDS) { - const a = original[field] - const b = state[field] - if ( - a?.k === `obj` && - b?.k === `obj` && - a.sym !== b.sym && - encode({ ...a, sym: undefined }) === - encode({ ...b, sym: undefined }) && - !Object.is(a.sym, b.sym) && - encode(a) !== encode(b) - ) - symbolOnly++ - } - } - expect([...reached].sort()).toEqual([ - `delete`, - `forOf`, - `index`, - `nested`, - `nestedDelete`, - `revert`, - `set`, - ]) - expect(partial).toBeGreaterThanOrEqual(5) - expect(full).toBeGreaterThanOrEqual(30) - expect(symbolOnly).toBeGreaterThan(0) - expect(sameObjectReverts).toBeGreaterThanOrEqual(20) - expect(samePointReverts).toBeGreaterThan(0) - }) - - // 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, - oracleFixedSeed(FIXED_SEED, { numRuns: 200 }), - ) - for (const { original, ops } of sample) { - let state: Root = { ...original } - for (const generated of ops) { - const op = resolve(state, original, generated) - if (op === undefined || !applicable(state, original, op)) continue - const current = state[op.field] - if (op.op === `set` && op.value.k === `url` && current?.k === `url`) - if (op.value.path !== current.path) urlWrites++ - if (op.op === `set` && op.value.k === `secret`) - if ( - (current?.k === `secret` && current.v !== op.value.v) || - (current?.k === `obj` && encode(current) === encode({ k: `obj` })) - ) - keylessWrites++ - state = step(state, original, op) - } - } - expect(urlWrites).toBeGreaterThanOrEqual(10) - expect(keylessWrites).toBeGreaterThanOrEqual(10) - }) - - // Fixed histories. - it.each<[string, History]>([ - [ - `reverting one of two changed fields keeps the other change`, - { - original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, - ops: [ - { op: `set`, field: `f`, value: { k: `prim`, v: `b` } }, - { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, - { op: `revert`, field: `f` }, - ], - }, - ], + // Fixed histories. The generated grammars cannot take these back, so one + // enumerated property replays them through the campaign's model, draft + // driver and final getChanges/draft/isolation check. + const fixedHistories: Array<{ name: string; history: History }> = ( [ - `adding a nested key and deleting it again is not a change`, - { - original: { f: { k: `obj`, a: 1 } }, - ops: [ - { op: `nested`, field: `f`, key: `b`, value: 2 }, - { op: `nestedDelete`, field: `f`, key: `b` }, - ], - }, - ], - [ - `restoring a nested value is not a change while a sibling stays changed`, - { - original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, - ops: [ - { op: `nested`, field: `f`, key: `a`, value: 2 }, - { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, - { op: `nested`, field: `f`, key: `a`, value: 1 }, - ], - }, - ], - [ - `deleting an added nested key is not a change while a sibling stays changed`, - { - original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, - ops: [ - { op: `nested`, field: `f`, key: `b`, value: 2 }, - { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, - { op: `nestedDelete`, field: `f`, key: `b` }, - ], - }, - ], - [ - `a replaced object that returns to its new value is still a change`, - { - original: { f: { k: `obj`, a: 1 } }, - ops: [ - { op: `set`, field: `f`, value: { k: `obj`, a: 2 } }, - { op: `nested`, field: `f`, key: `a`, value: 3 }, - { op: `nested`, field: `f`, key: `a`, value: 2 }, - ], - }, - ], - [ - `a replaced object restored to its original by a nested write reports at most its original value`, - { - original: { f: { k: `obj`, a: 0 } }, - ops: [ - { op: `set`, field: `f`, value: { k: `obj` } }, - { op: `nested`, field: `f`, key: `a`, value: 0 }, - ], - }, - ], - [ - `a replaced object restored with its symbol key kept reports at most its original value`, - { - original: { - f: { k: `obj`, a: 0, sym: 1 }, - g: { k: `map`, entries: [] }, + [ + `reverting one of two changed fields keeps the other change`, + { + original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `set`, field: `f`, value: { k: `prim`, v: `b` } }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `revert`, field: `f` }, + ], }, - ops: [ - { op: `nested`, field: `f`, key: `a`, value: 0 }, - { op: `set`, field: `f`, value: { k: `obj`, sym: 1 } }, - { op: `nested`, field: `f`, key: `a`, value: 0 }, - ], - }, - ], - [ - `an array replaced by an empty Set is a change`, - { - original: { g: { k: `array`, items: [] } }, - ops: [{ op: `set`, field: `g`, value: { k: `set`, values: [] } }], - }, - ], - [ - `a key added with the value undefined stays a change after a sibling revert`, - { - original: { f: { k: `obj`, a: 0 } }, - ops: [ - { op: `nested`, field: `f`, key: `b`, value: 0 }, - { op: `set`, field: `h`, value: { k: `prim`, v: undefined } }, - { op: `nestedDelete`, field: `f`, key: `b` }, - ], - }, - ], - [ - `a typed-array subclass keeps its elements in the draft`, - { - original: { f: { k: `typed`, ctor: `vec3`, values: [1, 2, 3] } }, - ops: [{ op: `index`, field: `f`, index: 0, value: 9 }], - }, - ], - [ - `rewriting NaN into a Float64Array is not a change`, - { - original: { f: { k: `typed`, ctor: `f64`, values: [NaN, 1] } }, - ops: [ - { - op: `set`, - field: `f`, - value: { k: `typed`, ctor: `f64`, values: [NaN, 1] }, - }, - { op: `index`, field: `f`, index: 0, value: NaN }, - ], - }, - ], - [ - `a typed array of another class is a change`, - { - original: { f: { k: `typed`, ctor: `f64`, values: [1, 0] } }, - ops: [ - { - op: `set`, - field: `f`, - value: { k: `typed`, ctor: `u8`, values: [1, 0] }, - }, - ], - }, - ], - [ - `deleting a field and writing its original back is a revert`, - { - original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, - ops: [ - { op: `delete`, field: `f` }, - { op: `revert`, field: `f` }, - ], - }, - ], - [ - `a nested write through for...of records the row change`, - { - original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, - ops: [{ op: `forOf`, field: `f`, index: 1, value: `a` }], - }, - ], - [ - `a nested write through for...of that restores the value is a revert`, - { - original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, - ops: [ - { op: `forOf`, field: `f`, index: 1, value: `a` }, - { op: `forOf`, field: `f`, index: 1, value: 1 }, - ], - }, - ], - [ - `a change made only under a nested symbol key is recorded`, - { - original: { f: { k: `obj`, a: 1, sym: `a` } }, - ops: [{ op: `nested`, field: `f`, key: `sym`, value: `b` }], - }, - ], - [ - `replacing a nested object with one that differs only under a symbol key is recorded`, - { - original: { f: { k: `obj`, a: 1, sym: `a` } }, - ops: [{ op: `set`, field: `f`, value: { k: `obj`, a: 1, sym: `b` } }], - }, - ], - [ - `Map entry order is a change`, - { - original: { - f: { - k: `map`, - entries: [ - [`x`, 1], - [`y`, 0], - ], + ], + [ + `adding a nested key and deleting it again is not a change`, + { + original: { f: { k: `obj`, a: 1 } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 2 }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `restoring a nested value is not a change while a sibling stays changed`, + { + original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `nested`, field: `f`, key: `a`, value: 2 }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `nested`, field: `f`, key: `a`, value: 1 }, + ], + }, + ], + [ + `deleting an added nested key is not a change while a sibling stays changed`, + { + original: { f: { k: `obj`, a: 1 }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 2 }, + { op: `set`, field: `g`, value: { k: `prim`, v: `b` } }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `a replaced object that returns to its new value is still a change`, + { + original: { f: { k: `obj`, a: 1 } }, + ops: [ + { op: `set`, field: `f`, value: { k: `obj`, a: 2 } }, + { op: `nested`, field: `f`, key: `a`, value: 3 }, + { op: `nested`, field: `f`, key: `a`, value: 2 }, + ], + }, + ], + [ + `a replaced object restored to its original by a nested write reports at most its original value`, + { + original: { f: { k: `obj`, a: 0 } }, + ops: [ + { op: `set`, field: `f`, value: { k: `obj` } }, + { op: `nested`, field: `f`, key: `a`, value: 0 }, + ], + }, + ], + [ + `a replaced object restored with its symbol key kept reports at most its original value`, + { + original: { + f: { k: `obj`, a: 0, sym: 1 }, + g: { k: `map`, entries: [] }, }, + ops: [ + { op: `nested`, field: `f`, key: `a`, value: 0 }, + { op: `set`, field: `f`, value: { k: `obj`, sym: 1 } }, + { op: `nested`, field: `f`, key: `a`, value: 0 }, + ], }, - ops: [ - { - op: `set`, - field: `f`, - value: { + ], + [ + `an array replaced by an empty Set is a change`, + { + original: { g: { k: `array`, items: [] } }, + ops: [{ op: `set`, field: `g`, value: { k: `set`, values: [] } }], + }, + ], + [ + `a key added with the value undefined stays a change after a sibling revert`, + { + original: { f: { k: `obj`, a: 0 } }, + ops: [ + { op: `nested`, field: `f`, key: `b`, value: 0 }, + { op: `set`, field: `h`, value: { k: `prim`, v: undefined } }, + { op: `nestedDelete`, field: `f`, key: `b` }, + ], + }, + ], + [ + `a typed-array subclass keeps its elements in the draft`, + { + original: { f: { k: `typed`, ctor: `vec3`, values: [1, 2, 3] } }, + ops: [{ op: `index`, field: `f`, index: 0, value: 9 }], + }, + ], + [ + `rewriting NaN into a Float64Array is not a change`, + { + original: { f: { k: `typed`, ctor: `f64`, values: [NaN, 1] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `typed`, ctor: `f64`, values: [NaN, 1] }, + }, + { op: `index`, field: `f`, index: 0, value: NaN }, + ], + }, + ], + [ + `a typed array of another class is a change`, + { + original: { f: { k: `typed`, ctor: `f64`, values: [1, 0] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `typed`, ctor: `u8`, values: [1, 0] }, + }, + ], + }, + ], + [ + `deleting a field and writing its original back is a revert`, + { + original: { f: { k: `prim`, v: `a` }, g: { k: `prim`, v: `a` } }, + ops: [ + { op: `delete`, field: `f` }, + { op: `revert`, field: `f` }, + ], + }, + ], + [ + `a nested write through for...of records the row change`, + { + original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, + ops: [{ op: `forOf`, field: `f`, index: 1, value: `a` }], + }, + ], + [ + `a nested write through for...of that restores the value is a revert`, + { + original: { f: { k: `rows`, rows: [{ a: 0 }, { a: 1 }] } }, + ops: [ + { op: `forOf`, field: `f`, index: 1, value: `a` }, + { op: `forOf`, field: `f`, index: 1, value: 1 }, + ], + }, + ], + [ + `a change made only under a nested symbol key is recorded`, + { + original: { f: { k: `obj`, a: 1, sym: `a` } }, + ops: [{ op: `nested`, field: `f`, key: `sym`, value: `b` }], + }, + ], + [ + `replacing a nested object with one that differs only under a symbol key is recorded`, + { + original: { f: { k: `obj`, a: 1, sym: `a` } }, + ops: [{ op: `set`, field: `f`, value: { k: `obj`, a: 1, sym: `b` } }], + }, + ], + [ + `Map entry order is a change`, + { + original: { + f: { k: `map`, entries: [ - [`y`, 0], [`x`, 1], + [`y`, 0], ], }, }, - ], - }, - ], - [ - `a reordered Set inside a Map value is a change`, - { - original: { f: { k: `map`, entries: [[`x`, { set: [`x`, `y`] }]] } }, - ops: [ - { - op: `set`, - field: `f`, - value: { k: `map`, entries: [[`x`, { set: [`y`, `x`] }]] }, - }, - ], - }, - ], - [ - `Set value order is a change`, - { - original: { f: { k: `set`, values: [`x`, `y`] } }, - ops: [ - { op: `set`, field: `f`, value: { k: `set`, values: [`y`, `x`] } }, - ], - }, - ], - [ - `RegExp lastIndex is a change`, - { - original: { f: { k: `regex`, flags: `g`, lastIndex: 0 } }, - ops: [ - { - op: `set`, - field: `f`, - value: { k: `regex`, flags: `g`, lastIndex: 1 }, - }, - ], - }, - ], - [ - `a hole replaced by undefined is a change`, - { - original: { f: { k: `array`, items: [HOLE, 1] } }, - ops: [ - { - op: `set`, - field: `f`, - value: { k: `array`, items: [undefined, 1] }, - }, - ], - }, - ], - [ - `a zero-sign rewrite changes while NaN remains equal`, - { - original: { f: { k: `prim`, v: 0 }, g: { k: `prim`, v: NaN } }, - ops: [ - { op: `set`, field: `f`, value: { k: `prim`, v: -0 } }, - { op: `set`, field: `g`, value: { k: `prim`, v: NaN } }, - ], - }, - ], - ])(`%s`, (_name, history) => { - oracleRoute(`fixed history`) - return expectHistory(history) + ops: [ + { + op: `set`, + field: `f`, + value: { + k: `map`, + entries: [ + [`y`, 0], + [`x`, 1], + ], + }, + }, + ], + }, + ], + [ + `a reordered Set inside a Map value is a change`, + { + original: { f: { k: `map`, entries: [[`x`, { set: [`x`, `y`] }]] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `map`, entries: [[`x`, { set: [`y`, `x`] }]] }, + }, + ], + }, + ], + [ + `Set value order is a change`, + { + original: { f: { k: `set`, values: [`x`, `y`] } }, + ops: [ + { op: `set`, field: `f`, value: { k: `set`, values: [`y`, `x`] } }, + ], + }, + ], + [ + `RegExp lastIndex is a change`, + { + original: { f: { k: `regex`, flags: `g`, lastIndex: 0 } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `regex`, flags: `g`, lastIndex: 1 }, + }, + ], + }, + ], + [ + `a hole replaced by undefined is a change`, + { + original: { f: { k: `array`, items: [HOLE, 1] } }, + ops: [ + { + op: `set`, + field: `f`, + value: { k: `array`, items: [undefined, 1] }, + }, + ], + }, + ], + [ + `a zero-sign rewrite changes while NaN remains equal`, + { + original: { f: { k: `prim`, v: 0 }, g: { k: `prim`, v: NaN } }, + ops: [ + { op: `set`, field: `f`, value: { k: `prim`, v: -0 } }, + { op: `set`, field: `g`, value: { k: `prim`, v: NaN } }, + ], + }, + ], + ] satisfies Array<[string, History]> + ).map(([name, history]) => ({ name, history })) + oracleProperty({ + id: `proxy.revert-fixed-histories`, + law: revertLaw, + owns: [`src/proxy.ts`], + // Some fixed histories hold symbol keys or values, which are not history + // data, so each history is its name and resolve looks up its value. + histories: fixedHistories.map(({ name }) => name), + label: (name) => name, + resolve: (name) => + fixedHistories.find((entry) => entry.name === name)!.history, + model: historyExpectation, + driver: historyDriver, + check: checkHistory, }) }) @@ -1434,46 +798,6 @@ describe(`draft revert oracle`, () => { // the live `f` value, and then only with its final value. // - Reading the draft gives the model's final row. -type NativeRow = { x: number; f: { arr: Array; g: { a?: number } } } -type NativeOp = - | { op: `push`; v: number } - | { op: `pop` } - | { op: `reverse` } - | { op: `assignArr`; items: Array } - | { op: `restoreArr` } - | { op: `replaceG` } - | { op: `setGa`; v: number } - | { op: `retain` } - | { op: `pushHandle`; v: number } - | { op: `setX`; v: number } - -const smallInt = fc.integer({ min: 0, max: 2 }) -const nativeOpArb: fc.Arbitrary = fc.oneof( - smallInt.map((v): NativeOp => ({ op: `push`, v })), - fc.constant({ op: `pop` }), - fc.constant({ op: `reverse` }), - fc - .array(smallInt, { maxLength: 3 }) - .map((items): NativeOp => ({ op: `assignArr`, items })), - { weight: 2, arbitrary: fc.constant({ op: `restoreArr` }) }, - fc.constant({ op: `replaceG` }), - { weight: 2, arbitrary: smallInt.map((v): NativeOp => ({ op: `setGa`, v })) }, - fc.constant({ op: `retain` }), - smallInt.map((v): NativeOp => ({ op: `pushHandle`, v })), - smallInt.map((v): NativeOp => ({ op: `setX`, v })), -) -type NativeHistory = { original: NativeRow; ops: Array } -const nativeHistoryArb: fc.Arbitrary = fc.record({ - original: fc.record({ - x: smallInt, - f: fc.record({ - arr: fc.array(smallInt, { maxLength: 3 }), - g: fc.record({ a: smallInt }), - }), - }), - ops: fc.array(nativeOpArb, { minLength: 1, maxLength: 8 }), -}) - const cloneRow = (row: NativeRow): NativeRow => ({ x: row.x, f: { arr: [...row.f.arr], g: { ...row.f.g } }, @@ -1610,53 +934,63 @@ function expectNativeResult( ) } -function expectNativeHistory(history: NativeHistory): void { - expectNativeResult(history, driveNative(history)) +function checkNativeHistory( + history: NativeHistory, + trace: ReadonlyArray, + expected: ReturnType, +): Verdict { + try { + const final = trace.find((entry) => entry.checkpoint === `final`) + expect(final, `final native draft observation`).toBeDefined() + expectNativeResult( + history, + final!.value as ReturnType, + expected, + ) + return pass + } catch (error) { + if (!(error instanceof Error) || error.name !== `AssertionError`) + throw error + return { + kind: `violation`, + law: `native draft revert`, + checkpoint: `final`, + difference: error.message, + cause: error, + } + } } -declareOracle({ - check: [ - expectHistory, - expectHistoryObservation, - checkHistory, - expectNativeResult, - ], - model: [numberToken, step, expectedChanges, historyExpectation, nativeModel], - grammar: [signedZeroArb, signedZeroHistory, nativeHistoryArb], -}) +const nativeRevertLaw = { + ...revertLaw, + statement: `Array mutators, assignments, and detached handles preserve the existing final row and permitted change-report law.`, + limits: `The existing bounded numeric native-history grammar; Map and Set mutators are not mixed with assignments.`, +} -describe(`draft revert oracle: native mutators mixed with assignments`, () => { - it(`rejects an unwritten field in the change report`, () => { - oracleRoute(`calibration`) - const histories: Array = [ - { +// The two histories of the unwritten-field calibration: an f that the history +// never wrote must stay out of the change report. +const unwrittenFieldHistories: Array<{ name: string; history: NativeHistory }> = + [ + { + name: `setX 1`, + history: { original: { x: 0, f: { arr: [1], g: { a: 0 } } }, ops: [{ op: `setX`, v: 1 }], }, - { + }, + { + name: `setGa 0`, + history: { original: { x: 0, f: { arr: [1], g: { a: 0 } } }, ops: [{ op: `setGa`, v: 0 }], }, - ] - for (const history of histories) { - const observed = driveNative(history) - expect(() => - expectNativeResult(history, { - ...observed, - changes: { ...observed.changes, f: history.original.f }, - }), - ).toThrow() - expectNativeResult(history, observed) - } - }) + }, + ] +describe(`draft revert oracle: native mutators mixed with assignments`, () => { oracleProperty({ id: NATIVE_HISTORIES, - law: { - ...revertLaw, - statement: `Array mutators, assignments, and detached handles preserve the existing final row and permitted change-report law.`, - limits: `The existing bounded numeric native-history grammar; Map and Set mutators are not mixed with assignments.`, - }, + law: nativeRevertLaw, owns: [`src/proxy.ts`], histories: { arbitrary: nativeHistoryArb, @@ -1665,105 +999,89 @@ describe(`draft revert oracle: native mutators mixed with assignments`, () => { }, model: nativeModel, driver: (history, observe) => observe(`final`, driveNative(history)), - check: (history, trace, expected) => { - try { - const final = trace.find((entry) => entry.checkpoint === `final`) - expect(final, `final native draft observation`).toBeDefined() - expectNativeResult( - history, - final!.value as ReturnType, - expected, - ) - return pass - } catch (error) { - if (!(error instanceof Error) || error.name !== `AssertionError`) - throw error - return { - kind: `violation`, - law: `native draft revert`, - checkpoint: `final`, - difference: error.message, - cause: error, - } - } - }, + check: checkNativeHistory, }) - // 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`, () => { - 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), - ) - return ( - first >= 0 && - h.ops - .slice(first) - .some((o) => o.op === `assignArr` || o.op === `restoreArr`) - ) - }) - const detached = sample.filter((h) => { - const retain = h.ops.findIndex((o) => o.op === `retain`) - if (retain < 0) return false - const after = h.ops.slice(retain + 1) - const replace = after.findIndex( - (o) => o.op === `assignArr` || o.op === `restoreArr`, - ) - return ( - replace >= 0 && - after.slice(replace + 1).some((o) => o.op === `pushHandle`) - ) - }) - expect(replaced.length).toBeGreaterThan(20) - expect(detached.length).toBeGreaterThan(3) - }) - - it.each<[string, NativeHistory]>([ - [ - `a native write that an assignment then restores reports at most its original value`, - { - original: { x: 0, f: { arr: [1], g: { a: 0 } } }, - ops: [ - { op: `setX`, v: 1 }, - { op: `push`, v: 2 }, - { op: `restoreArr` }, - { op: `replaceG` }, - { op: `setGa`, v: 0 }, - ], - }, - ], - [ - `a mutator through a detached handle changes nothing in the row`, - { - original: { x: 0, f: { arr: [1], g: { a: 0 } } }, - ops: [ - { op: `retain` }, - { op: `assignArr`, items: [1, 2] }, - { op: `restoreArr` }, - { op: `pushHandle`, v: 0 }, - ], - }, - ], + // Fixed native histories. The generated grammar cannot take them back, so + // one enumerated property replays them, and the unwritten-field calibration + // histories, through the campaign's model, draft driver and check. + const nativeFixedHistories: Array<{ name: string; history: NativeHistory }> = [ - `restoring the last native write reports at most the original value`, - { - original: { x: 0, f: { arr: [0], g: { a: 0 } } }, - ops: [ - { op: `pop` }, - { op: `replaceG` }, - { op: `restoreArr` }, - { op: `setGa`, v: 0 }, - ], - }, - ], - ])(`%s`, (_name, history) => { - oracleRoute(`fixed history`) - return expectNativeHistory(history) + ...( + [ + [ + `a native write that an assignment then restores reports at most its original value`, + { + original: { x: 0, f: { arr: [1], g: { a: 0 } } }, + ops: [ + { op: `setX`, v: 1 }, + { op: `push`, v: 2 }, + { op: `restoreArr` }, + { op: `replaceG` }, + { op: `setGa`, v: 0 }, + ], + }, + ], + [ + `a mutator through a detached handle changes nothing in the row`, + { + original: { x: 0, f: { arr: [1], g: { a: 0 } } }, + ops: [ + { op: `retain` }, + { op: `assignArr`, items: [1, 2] }, + { op: `restoreArr` }, + { op: `pushHandle`, v: 0 }, + ], + }, + ], + [ + `restoring the last native write reports at most the original value`, + { + original: { x: 0, f: { arr: [0], g: { a: 0 } } }, + ops: [ + { op: `pop` }, + { op: `replaceG` }, + { op: `restoreArr` }, + { op: `setGa`, v: 0 }, + ], + }, + ], + ] satisfies Array<[string, NativeHistory]> + ).map(([name, history]) => ({ name, history })), + ...unwrittenFieldHistories.map(({ name, history }) => ({ + name: `unwritten-field calibration history: ${name}`, + history, + })), + ] + oracleProperty({ + id: `proxy.revert-native-fixed-histories`, + law: nativeRevertLaw, + owns: [`src/proxy.ts`], + histories: nativeFixedHistories, + label: ({ name }) => name, + resolve: ({ history }) => history, + model: nativeModel, + driver: (history, observe) => observe(`final`, driveNative(history)), + check: checkNativeHistory, + // The check must reject a report of the unwritten f, at its original + // value, in each calibration history's recorded final observation. + calibrations: unwrittenFieldHistories.map(({ name, history: target }) => ({ + name: `rejects an unwritten field in the change report: ${name}`, + law: `native draft revert`, + corrupt: (trace: ReadonlyArray, history: NativeHistory) => + history !== target + ? undefined + : trace.map((entry) => { + if (entry.checkpoint !== `final`) return entry + const observed = entry.value as ReturnType + return { + ...entry, + value: { + ...observed, + changes: { ...observed.changes, f: history.original.f }, + }, + } + }), + })), }) }) From dd25652b62a9eb9d1a813e9931f0e3435abe2a40 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:54:29 -0700 Subject: [PATCH 038/116] test(db): register ordered replay regressions through oracleProperty The named replay regressions and the two-failed-replay marker history cannot be taken back by the generated command grammar, so they replay through replay.ordered-window-regressions and replay.ordered-window-fixed with their unchanged bodies. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 7 + ...ad-subset-replay-refinement-oracle.test.ts | 157 +++++++++++------- 2 files changed, 108 insertions(+), 56 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 55ccdb8255..556207386f 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -29,6 +29,8 @@ const otherProperties: ReadonlyArray = [ `mixed-index-order.public-order`, `mixed-index-order.on-demand-window`, `derived-publication.queued-batches`, + `replay.ordered-window-regressions`, + `replay.ordered-window-fixed`, ] const otherOwners: ReadonlyArray = [ [ @@ -71,6 +73,11 @@ const otherOwners: ReadonlyArray = [ `derived-publication`, `queued-batches`, ], + [ + `db/tests/query/load-subset-replay-refinement-oracle.test.ts`, + `replay`, + `ordered-window-regressions ordered-window-fixed`, + ], ] export const properties: ReadonlyArray = [ 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 3dcba0ca31..10877c90c2 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -1,5 +1,5 @@ import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection, @@ -12,7 +12,7 @@ import { BTreeIndex } from '../../src/indexes/btree-index.js' import { CollectionSubscription } from '../../src/collection/subscription.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { flushPromises } from '../utils.js' -import { oracleProperty, oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -20,7 +20,6 @@ import { recordedAssertionJudgment, rejectAt, } from '../oracle-assertion-adapter.js' -import { declareOracle } from '../oracle-declaration.js' import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' /** @@ -2084,9 +2083,6 @@ 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], -}) type GeneratedValue = T extends fc.Arbitrary ? Value : never const orderedReplayLaw = { @@ -2702,6 +2698,39 @@ function preloadConstructions() { const orderedReplayProperty = `replay.ordered-window-faults` const orderedFailureProperty = `replay.keyed-request-failures` +type OrderedReplayRegression = { + name: string + history: Array + scenario?: OrderedScenario + reaches?: keyof OrderedReach + // 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 + failureState?: ReplayDemandState +} + +// One named replay regression: `checkOrderedReplay` judges it, and the +// regression's own reach and recovery premises must hold. +async function runOrderedReplayRegression({ + history, + scenario, + ...replay +}: OrderedReplayRegression): Promise { + const entered = new Set() + const reach = await checkOrderedReplay(history, scenario, (state) => + entered.add(state), + ) + checkReplayDemandFailure(reach, entered) + if (`failureState` in replay) expect(entered).toContain(replay.failureState) + const reaches: keyof OrderedReach | undefined = + `reaches` in replay ? replay.reaches : undefined + if (reaches) expect(reach[reaches], reaches).toBeGreaterThan(0) + if (`recovers` in replay) { + expect(reach.terminalErrors, `recovery failure ends the query`).toBe(0) + } +} + describe(`Ordered windows across truncate replays`, () => { // Traces the campaigns found, kept as replays. Each failed before its fix: // - an unload that throws while a successful repair retires an older @@ -2734,7 +2763,7 @@ describe(`Ordered windows across truncate replays`, () => { // showed neither window); // - two window moves that a pending source recovery defers (both resumed // after recovery, the older one last, so it replaced the newer window). - it.each([ + const orderedReplayRegressions: Array = [ { name: `a release that throws after a successful repair`, history: [ @@ -3280,35 +3309,30 @@ describe(`Ordered windows across truncate replays`, () => { }, reaches: `failedLoads`, }, - ] satisfies Array<{ - name: string - history: Array - scenario?: OrderedScenario - reaches?: keyof OrderedReach - // 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 - failureState?: ReplayDemandState - }>)( - `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), - ) - checkReplayDemandFailure(reach, entered) - if (`failureState` in replay) - expect(entered).toContain(replay.failureState) - const reaches: keyof OrderedReach | undefined = - `reaches` in replay ? replay.reaches : undefined - if (reaches) expect(reach[reaches], reaches).toBeGreaterThan(0) - if (`recovers` in replay) { - expect(reach.terminalErrors, `recovery failure ends the query`).toBe(0) - } + ] + oracleProperty({ + id: `replay.ordered-window-regressions`, + law: { + ...orderedReplayLaw, + limits: `The named replay regressions above, which the generated command grammar cannot take back; each replays through checkOrderedReplay with the same reach and recovery checks.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/collection/subscription.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: orderedReplayRegressions, + label: ({ name }) => + `keeps the ordered window for ${name} (generated replay)`, + resolve: (regression) => ({ regression }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`ordered replay refinement`, plan, observe, () => + runOrderedReplayRegression(plan.regression), + ), + check: forwardAssertionJudgment, + }) // Found by the failed-replay random campaign at five times its run count. // A join and an include share the marker source. One subscription's replay @@ -3317,29 +3341,50 @@ describe(`Ordered windows across truncate replays`, () => { // marker change then retires the first replay's failed demand, which // 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` }, - { type: `truncateMarkers` }, - { type: `settle`, ok: true, keepHolding: true, newest: false }, - { type: `settle`, ok: false, newest: true, keepHolding: true }, - { type: `move`, id: 1, rank: 1 }, - { type: `settle`, ok: false, newest: false }, - { type: `hold` }, - { type: `mark`, id: 4, on: false }, - ], + oracleProperty({ + id: `replay.ordered-window-fixed`, + law: { + ...orderedReplayLaw, + limits: `One named regression that the generated command grammar cannot take back; it replays through checkOrderedReplay.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/collection/subscription.ts', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ { - ...ordinaryOrderedScenario, - direction: `desc`, - offset: 1, - limit: 1, - joined: `some`, - markerMode: `on-demand`, - includes: true, + name: `keeps the last publication when a marker change follows two failed replay loads`, + history: [ + { type: `hold` }, + { type: `truncateMarkers` }, + { type: `settle`, ok: true, keepHolding: true, newest: false }, + { type: `settle`, ok: false, newest: true, keepHolding: true }, + { type: `move`, id: 1, rank: 1 }, + { type: `settle`, ok: false, newest: false }, + { type: `hold` }, + { type: `mark`, id: 4, on: false }, + ] satisfies Array, + scenario: { + ...ordinaryOrderedScenario, + direction: `desc`, + offset: 1, + limit: 1, + joined: `some`, + markerMode: `on-demand`, + includes: true, + } satisfies OrderedScenario, }, - ) + ], + label: ({ name }) => name, + resolve: (regression) => ({ regression }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`ordered replay refinement`, plan, observe, () => + checkOrderedReplay(plan.regression.history, plan.regression.scenario), + ), + check: forwardAssertionJudgment, }) // A window with no initial include demand can activate its first include From 401c9c08be6d63ed375072e2272df33267874047 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:55:08 -0700 Subject: [PATCH 039/116] test(db-ivm): register the distinct replay through oracleProperty Converts the three plain-test sites in the two distinct oracle files. The equal-selection replay becomes ivm.distinct-representatives-fixed (P1b), which runs the same drive and checkDistinctCampaign. The two checker calibrations hand wrong inputs straight to the shared checkers and run no production, so they move unchanged to distinct-calibration.test.ts (P6). Co-authored-by: Isaac --- .../db-ivm/tests/distinct-calibration.test.ts | 41 ++++++++++++++++ ...incrementalization-oracle.property.test.ts | 17 ------- ...nct-representative-oracle.property.test.ts | 47 ++++++++----------- .../converted-properties/frameworks-ivm.ts | 6 +++ 4 files changed, 66 insertions(+), 45 deletions(-) create mode 100644 packages/db-ivm/tests/distinct-calibration.test.ts diff --git a/packages/db-ivm/tests/distinct-calibration.test.ts b/packages/db-ivm/tests/distinct-calibration.test.ts new file mode 100644 index 0000000000..c9a20cc72c --- /dev/null +++ b/packages/db-ivm/tests/distinct-calibration.test.ts @@ -0,0 +1,41 @@ +// Calibrations of the distinct oracles' shared checkers. Each test hands a +// wrong snapshot or an invented representative straight to the checker and +// requires it to reject the input; no production operator runs. The +// production campaigns are in distinct-incrementalization-oracle.property.test.ts +// and distinct-representative-oracle.property.test.ts. +import { expect, it } from 'vitest' +import { hash } from '../src/hashing/hash.js' +import { + checkDistinctRepresentatives, + checkDistinctSnapshot, + distinctCollisionValues, +} from './distinct-incrementalization-oracle.js' +import type { DistinctValue } from './distinct-incrementalization-oracle.js' +import type { Weighted } from './incrementalization-law-oracle.js' + +it(`rejects omitted, duplicate and wrong-member snapshots`, () => { + const values = distinctCollisionValues(hash) + const input: Weighted = values.map((value) => [value, 1]) + for (const bad of [ + values.slice(1), + [...values, values[0]!], + [values[0]!, values[0]!, values[2]!], + ]) + expect(() => checkDistinctSnapshot(bad, input, 0)).toThrow( + /snapshot diverged/, + ) +}) + +// Checker calibration supplies a wrong observation to the same refinement +// check. Correct selected membership alone cannot authorize invented payloads. +it(`rejects a representative absent from source inputs`, () => { + const source = [{ member: 0, variant: 0 }] + expect(() => + checkDistinctRepresentatives( + [[[0, { member: 0, variant: 999 }], 1]], + source, + source, + `fabricated representative`, + ), + ).toThrow(/distinct representative contribution diverged/) +}) 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 bf974d32fe..1dffbcc6e3 100644 --- a/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-incrementalization-oracle.property.test.ts @@ -6,11 +6,9 @@ * Limits: immutable two-member collision fixtures plus an anchor; no public * Collection key policy, ordering, or intermediate transaction publications. */ -import { expect, it } from 'vitest' import { hash } from '../src/hashing/hash.js' import { distinct } from '../src/operators/distinct.js' import { map } from '../src/operators/map.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' import { oracleProperty } from '../../db/tests/oracle-property.js' import { assertUnaryIncrementalization, @@ -19,7 +17,6 @@ import { } from './incrementalization-law-oracle.js' import { DISTINCT_REACH_STATES, - checkDistinctSnapshot, distinctCollisionValues, distinctReachStates, distinctRecipe, @@ -162,17 +159,3 @@ oracleProperty({ owns: [`src/operators/distinct.ts#distinct`], histories: { ...cell1.histories, runs: 100, fixedSeeds: [2306] }, }) - -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 [ - values.slice(1), - [...values, values[0]!], - [values[0]!, values[0]!, values[2]!], - ]) - expect(() => checkDistinctSnapshot(bad, input, 0)).toThrow( - /snapshot diverged/, - ) -}) 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 4bc5f4f68a..05ab37eff7 100644 --- a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts @@ -9,11 +9,9 @@ * Limits: immutable fixture selections, four colliding members plus an anchor, * eight slots and twelve random tail worlds; no external source scheduling. */ -import { expect, it } from 'vitest' import { D2 } from '../src/d2.js' import { hash } from '../src/hashing/hash.js' import { distinct } from '../src/operators/distinct.js' -import { oracleReplayTest } from '../../db/tests/oracle-config.js' import { oracleProperty } from '../../db/tests/oracle-property.js' import { DISTINCT_BUCKET_REACH_STATES, @@ -190,11 +188,16 @@ oracleProperty({ // Small replay of differing unselected fields through the same driver and // comparison as the generated campaign; no hand-authored expected answer. -oracleReplayTest(it)( - `retracts the contribution it emitted for an equal selection`, - () => { - drive( - [ +// The recipe grammar cannot take a resolved history back, so it is one +// enumerated property. +oracleProperty({ + id: `ivm.distinct-representatives-fixed`, + law: cell0.law, + owns: [`src/operators/distinct.ts#distinct`], + histories: [ + { + name: `retracts the contribution it emitted for an equal selection`, + worlds: [ [], [ { member: 0, variant: 0 }, @@ -202,25 +205,13 @@ oracleReplayTest(it)( ], [{ member: 0, variant: 1 }], [], - ], - false, - ) - }, -) - -// Checker calibration supplies a wrong observation to the same refinement -// check. Correct selected membership alone cannot authorize invented payloads. -oracleReplayTest(it)( - `rejects a representative absent from source inputs`, - () => { - const source = [{ member: 0, variant: 0 }] - expect(() => - checkDistinctRepresentatives( - [[[0, { member: 0, variant: 999 }], 1]], - source, - source, - `fabricated representative`, - ), - ).toThrow(/distinct representative contribution diverged/) + ] satisfies DistinctBucketHistory, + }, + ], + label: ({ name }) => name, + model: (): ReadonlyArray => [], + driver: ({ worlds }, observe) => { + driveDistinctChecked(() => drive(worlds, false), observe) }, -) + check: checkDistinctCampaign, +}) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 94729f8cbb..9380cafea1 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -13,6 +13,7 @@ export const properties: ReadonlyArray = [ `ivm.incrementalization-law-cells`, `structural-identity.representation-fixed`, `structural-identity.independent-values-fixed`, + `ivm.distinct-representatives-fixed`, // react-db `react.convex-lazy-paging`, `react.effect-callback`, @@ -49,6 +50,11 @@ export const owners: ReadonlyArray = [ `structural-identity`, `representation-fixed independent-values-fixed`, ], + [ + `db-ivm/tests/distinct-representative-oracle.property.test.ts`, + `ivm`, + `distinct-representatives-fixed`, + ], // react-db [ `react-db/tests/convex-lazy-paging-oracle.test.tsx`, From aa5059605352d906128113378c23ae98918910e8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:55:28 -0700 Subject: [PATCH 040/116] test(db): register flat change tracking pinned histories through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 6 +++ ...at-change-tracking-oracle.property.test.ts | 51 +++++++++++-------- 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index e5ceb7a2a9..29a2e427ef 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -27,6 +27,7 @@ export const properties: ReadonlyArray = [ `proxy.frozen-nested-writes`, `proxy.revert-fixed-histories`, `proxy.revert-native-fixed-histories`, + `flat-change-tracking.pinned`, ] export const owners: ReadonlyArray = [ @@ -59,4 +60,9 @@ export const owners: ReadonlyArray = [ `proxy`, `revert-fixed-histories revert-native-fixed-histories`, ], + [ + `db/tests/flat-change-tracking-oracle.property.test.ts`, + `flat-change-tracking`, + `pinned`, + ], ] 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 dd9df4cc84..e3765e944e 100644 --- a/packages/db/tests/flat-change-tracking-oracle.property.test.ts +++ b/packages/db/tests/flat-change-tracking-oracle.property.test.ts @@ -73,18 +73,13 @@ * contracts own them, and this file checks only that they fall back. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { withArrayChangeTracking, withChangeTracking, withFlatChangeTracking, } from '../src/proxy.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -600,20 +595,36 @@ function runHistory(history: History): void { ) } -declareOracle({ - check: [runHistory], - model: [expectedChanges], -}) - describe(`flat change tracking oracle`, () => { - if (!oracleReplayRequested()) { - for (const { name, history } of pinnedHistories) { - it(`matches the draft proxy when ${name}`, () => { - oracleRoute(`fixed history`) - return runHistory(history) - }) - } - } + // The pinned histories are outside what the generated grammar can take + // back, so one enumerated property replays them through the same helper. + oracleProperty({ + id: `flat-change-tracking.pinned`, + law: { + statement: `Flat and proxy trackers agree with the independent authored field-difference model, including signed zero, deletion, reversion, descriptor effects, stored drafts and callback rejection.`, + authority: `src/proxy.ts draft/change-set contract and the 2026-10-29 signed-zero maintainer decision`, + limits: `Named regressions that the generated flat-row grammar cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [ + `src/proxy.ts#withFlatChangeTracking`, + `src/proxy.ts#withChangeTracking`, + `src/proxy.ts#withArrayChangeTracking`, + ], + histories: pinnedHistories.map(({ name }) => name), + label: (name) => `matches the draft proxy when ${name}`, + resolve: (name) => ({ + history: pinnedHistories.find((entry) => entry.name === name)!.history, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `flat change tracking equivalence`, + plan, + observe, + () => runHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: property, From ba1a08b677cccca2bba8f4fd581a114e266ee0ad Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:55:46 -0700 Subject: [PATCH 041/116] test(db): register layered publication cases through oracleProperty Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 11 + .../query/includes-publication-oracle.test.ts | 1102 ++++++++++------- 2 files changed, 679 insertions(+), 434 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 8621d7429c..5bca5bcdb2 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -20,6 +20,12 @@ export const properties: ReadonlyArray = [ `includes-cross-formulation.grouped-equality`, `includes-cross-formulation.equivalence-fixed`, `includes-cross-formulation.ordered-window-cases`, + `includes-publication.plain-write-checkpoints`, + `includes-publication.transient-tear`, + `includes-publication.callback-only-tear`, + `includes-publication.confirmation-sides`, + `includes-publication.missing-callbacks`, + `includes-publication.held-repair`, ] export const owners: ReadonlyArray = [ @@ -53,4 +59,9 @@ export const owners: ReadonlyArray = [ `includes-cross-formulation`, `grouped-equality equivalence-fixed ordered-window-cases`, ], + [ + `db/tests/query/includes-publication-oracle.test.ts`, + `includes-publication`, + `plain-write-checkpoints transient-tear callback-only-tear confirmation-sides missing-callbacks held-repair`, + ], ] diff --git a/packages/db/tests/query/includes-publication-oracle.test.ts b/packages/db/tests/query/includes-publication-oracle.test.ts index 7b236b0469..bfdf508138 100644 --- a/packages/db/tests/query/includes-publication-oracle.test.ts +++ b/packages/db/tests/query/includes-publication-oracle.test.ts @@ -1,7 +1,12 @@ import { setImmediate as yieldToRunner } from 'node:timers/promises' import { fc } from '@fast-check/vitest' -import { afterEach, describe, expect, it } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { afterEach, describe, expect } from 'vitest' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createLiveQueryCollection, eq, @@ -12,8 +17,7 @@ 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 { oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { registerIncludesProperty } from './includes-registration-oracle.js' @@ -593,18 +597,6 @@ 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], -}) - const q2Shapes = [`passThrough`, `where`, `orderBy`, `select`] as const const q1Shapes = [`direct`, `joined`] as const @@ -620,6 +612,444 @@ function publicationCampaigns(runs: number, property: string) { ] } +const publicationShapeCells = q1Shapes.flatMap((q1Shape) => + q2Shapes.map((q2Shape) => ({ q1Shape, q2Shape })), +) + +const plainWriteLaw = `Every plain source write reaches a callback from both query layers and its synchronous checkpoints before the write yields, and each checkpoint matches the recomputed rows.` +const transientTearLaw = `A layered tear that exists only inside the write is rejected at the in-action checkpoint, though a settled-only observation misses it.` +const callbackOnlyTearLaw = `The callback-time comparison rejects a corrupted callback capture and a missing change batch while installed reads still agree.` +const confirmationSidesLaw = `The optimistic and confirmed checkpoints of a confirmation are both compared, so corrupting either one is rejected.` +const missingCallbacksLaw = `The callback-reach check rejects a checkpoint whose optimistic or rollback callbacks are missing, even when the final reads agree.` +const heldPublicationLaw = `A held ordered repair publishes the final row once against the last public snapshot: a layout notification without a value change when the final value equals it, and one update otherwise.` + +// The former per-shape case body, unchanged. +async function judgePlainWriteCheckpoints( + q1Shape: (typeof q1Shapes)[number], + q2Shape: (typeof q2Shapes)[number], +): Promise { + const actions: Array = [ + { type: `parentScalar`, value: 7 }, + { type: `childScalar`, value: 8 }, + { type: `parentRoute`, group: 20 }, + { type: `atomicReplace`, group: 30, value: 9 }, + { type: `parentThenChild`, parentValue: 10, childValue: 11 }, + ] + const driver = createPublicationDriver(q1Shape, q2Shape) + await runTrace({ + steps: actions, + driver: { + ...driver, + apply: (action, context, checkpoint) => { + const beforeCallbacks = context.callbacks.length + let reached = 0 + const result = driver.apply(action, context, () => { + reached++ + return checkpoint() + }) + // Capture before awaiting the async action's returned Promise. + const synchronousCount = reached + return Promise.resolve(result).then(() => { + for (const layer of [`q1`, `q2`] as const) { + expect( + context.callbacks + .slice(beforeCallbacks) + .some((callback) => callback.layer === layer), + ).toBe(true) + } + expect(synchronousCount).toBe( + action.type === `parentThenChild` ? 2 : 1, + ) + }) + }, + }, + projection: publicationProjection, + }) +} + +// The former per-shape case body, unchanged. +async function judgeTransientTear( + q1Shape: (typeof q1Shapes)[number], + q2Shape: (typeof q2Shapes)[number], +): Promise { + const run = (observeInsideAction: boolean) => { + let transient = false + const driver = createPublicationDriver(q1Shape, q2Shape) + return runTrace({ + steps: [{ type: `parentScalar` as const, value: 7 }], + driver: { + ...driver, + apply: (action, context, checkpoint) => { + transient = true + queueMicrotask(() => { + transient = false + }) + return driver.apply( + action, + context, + observeInsideAction ? checkpoint : () => undefined, + ) + }, + }, + projection: { + ...publicationProjection, + observe: (context) => { + const observed = publicationProjection.observe(context) + if (transient) observed.q2[0]!.item.value = -999 + return observed + }, + }, + }) + } + // The old settled-only observation misses this test-owned fault. + await expect(run(false)).resolves.toBeUndefined() + await expect(run(true)).rejects.toMatchObject({ + name: `TraceAssertionError`, + checkpoint: 1, + }) +} + +// The former per-shape case body, unchanged. +async function judgeCallbackOnlyTear( + q1Shape: (typeof q1Shapes)[number], + q2Shape: (typeof q2Shapes)[number], +): Promise { + const driver = createPublicationDriver(q1Shape, q2Shape) + let reached = 0 + const context = await driver.setup() + try { + await driver.start?.(context) + await driver.apply( + { type: `parentScalar`, value: 7 }, + context, + () => undefined, + ) + const callback = context.callbacks.find((entry) => entry.layer === `q2`) + expect(callback).toBeDefined() + callback!.rows[0]!.item.value = -999 + reached++ + // The installed final reads still agree. This fault corrupts only the + // captured callback and repairs it before the write returns. + const observed = publicationProjection.observe(context) + const expected = publicationProjection.recompute(context) + expect(observed.q1).toEqual(expected.q1) + expect(observed.q2).toEqual(expected.q2) + expect(() => + publicationProjection.assertEqual(observed, expected), + ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + // Repair the row capture, then remove the whole change batch. The + // same callback-time comparison must reject a missing public event. + callback!.rows = structuredClone(callback!.expected) + callback!.changes = [] + expect(() => + publicationProjection.assertEqual( + publicationProjection.observe(context), + expected, + ), + ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + expect(reached).toBe(1) + } finally { + await driver.cleanup(context) + } +} + +// The former per-shape case body, unchanged. +async function judgeConfirmationSides( + q1Shape: (typeof q1Shapes)[number], + q2Shape: (typeof q2Shapes)[number], +): Promise { + for (const fault of [`none`, `optimistic`, `confirmed`] as const) { + const driver = createPublicationDriver(q1Shape, q2Shape) + let phase: `initial` | `optimistic` | `confirmed` = `initial` + const seen: Array = [] + let hits = 0 + const run = runTrace({ + steps: [{ type: `optimisticConfirm` as const, value: 7 }], + driver: { + ...driver, + apply: async (action, context, checkpoint) => { + phase = `optimistic` + await driver.apply(action, context, checkpoint) + phase = `confirmed` + }, + }, + projection: { + ...publicationProjection, + observe: (context) => { + seen.push(phase) + const observed = publicationProjection.observe(context) + if (phase === fault) { + observed.q2[0]!.item.value = -999 + hits++ + } + return observed + }, + }, + }) + if (fault === `none`) { + await run + expect(seen).toEqual([`initial`, `optimistic`, `confirmed`]) + expect(hits).toBe(0) + } else { + await expect(run).rejects.toMatchObject({ + name: `TraceAssertionError`, + }) + expect(hits).toBe(1) + } + } +} + +const missingCallbackCases = [ + { phase: `optimistic`, cut: 0, checkpoint: 1 }, + { phase: `rollback`, cut: 1, checkpoint: 2 }, +] + +// The former case body, unchanged. +async function judgeMissingCallbacks({ + cut, + checkpoint: expectedCheckpoint, +}: (typeof missingCallbackCases)[number]): Promise { + const driver = createPublicationDriver(`direct`, `passThrough`) + let reachedCuts = 0 + let previousCallbackCount = 0 + const run = runTrace({ + steps: [{ type: `optimisticRollback` as const, value: 7 }], + driver: { + ...driver, + apply: (action, context, checkpoint) => + driver.apply(action, context, () => { + // Remove one phase's recorded public callbacks while preserving + // the final reads. The callback-reach check must reject it. + if (reachedCuts === cut) { + context.callbacks.splice(previousCallbackCount) + } + reachedCuts++ + previousCallbackCount = context.callbacks.length + return checkpoint() + }), + }, + projection: publicationProjection, + }) + await expect(run).rejects.toMatchObject({ + name: `TraceAssertionError`, + checkpoint: expectedCheckpoint, + }) + expect(reachedCuts).toBe(2) +} + +const heldPublicationCases = [ + { intermediate: `A`, final: `A` }, + { intermediate: `B`, final: `A` }, + { intermediate: `B`, final: `C` }, +] + +// The former case body, unchanged. +async function judgeHeldPublication({ + intermediate, + final, +}: (typeof heldPublicationCases)[number]): Promise { + type Row = { id: number; rank: number; value: string } + const sourceRows = new Map([ + [1, { id: 1, rank: 1, value: `A` }], + [2, { id: 2, rank: 2, value: `X` }], + ]) + let sync!: Parameters[`sync`]>[0] + let holdRepair = false + const held: Array<() => void> = [] + let requests = 0 + const source = createCollection({ + id: `held-publication-source-${nextCollectionId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: () => { + requests++ + if (requests === 1) { + sync.begin() + for (const row of sourceRows.values()) { + sync.write({ type: `insert`, value: { ...row } }) + } + expect(sync.commit()).toBe(true) + } + return holdRepair + ? new Promise((resolve) => held.push(resolve)) + : true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const children = createControlledCollection(`held-publication-child`, [ + { id: 10, parentId: 1 }, + ]) + children.collection.createIndex((child) => child.parentId, { + indexType: BasicIndex, + }) + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ parent: source }) + .orderBy(({ parent }) => parent.rank) + .limit(2) + .select(({ parent }) => ({ + id: parent.id, + value: parent.value, + children: materialize( + q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentId, parent.id)) + .select(({ child }) => ({ id: child.id })), + ), + })), + getKey: (row) => row.id, + }) + const snapshot = () => + query.toArray.map(({ id, value, children: childRows }) => ({ + id, + value, + children: childRows.map(({ id: childId }) => childId), + })) + const callbacks: Array<{ + rows: ReturnType + changes: Array<{ + type: string + key: string | number + value?: string + previousValue?: string + }> + }> = [] + const effectBatches: Array< + Array<{ + type: string + key: string | number + value: string + previousValue: string | undefined + }> + > = [] + let effect: ReturnType | undefined + let unsubscribe: (() => void) | undefined + + await withHistoryCleanup( + async () => { + await query.preload() + effect = createEffect<{ id: number; value: string }, number>({ + query: (q) => + q + .from({ parent: source }) + .orderBy(({ parent }) => parent.rank) + .limit(2) + .select(({ parent }) => ({ + id: parent.id, + value: parent.value, + })), + skipInitial: true, + onBatch: (events) => { + effectBatches.push( + events.map((event) => ({ + type: event.type, + key: event.key, + value: event.value.value, + previousValue: + event.type === `update` ? event.previousValue.value : undefined, + })), + ) + }, + }) + await flushPromises() + const before = snapshot() + expect(before).toEqual(expectedHeldPublicationRows(sourceRows)) + const subscription = query.subscribeChanges( + (changes) => { + callbacks.push({ + rows: snapshot(), + changes: changes.map((change) => + change.type === `update` + ? { + type: change.type, + key: change.key, + value: change.value.value, + previousValue: change.previousValue?.value, + } + : { type: change.type, key: change.key }, + ), + }) + }, + { includeInitialState: false }, + ) + unsubscribe = () => subscription.unsubscribe() + + holdRepair = true + const write = (row: Row) => { + sourceRows.set(row.id, { ...row }) + sync.begin() + sync.write({ type: `update`, value: { ...row } }) + expect(sync.commit()).toBe(true) + } + write({ id: 1, rank: 2.5, value: `A` }) + write({ id: 1, rank: 2.5, value: intermediate }) + write({ id: 1, rank: 2.5, value: final }) + expect(held.length).toBeGreaterThan(0) + expect(snapshot()).toEqual(before) + expect(callbacks).toEqual([]) + expect(effectBatches).toEqual([]) + + holdRepair = false + for (const release of held.splice(0)) release() + await flushPromises() + const after = expectedHeldPublicationRows(sourceRows) + expect(snapshot()).toEqual(after) + expect(callbacks).toEqual([ + { + rows: after, + changes: + final === `A` + ? [] + : [ + { + type: `update`, + key: 1, + value: final, + previousValue: `A`, + }, + ], + }, + ]) + expect(effectBatches).toEqual( + final === `A` + ? [] + : [ + [ + { + type: `update`, + key: 1, + value: final, + previousValue: `A`, + }, + ], + ], + ) + }, + () => [ + () => { + holdRepair = false + for (const release of held) release() + }, + () => unsubscribe?.(), + () => effect?.dispose(), + () => query.cleanup(), + () => source.cleanup(), + () => children.collection.cleanup(), + ], + ) +} + describe(`layered-query publication oracle`, () => { // Completed-promise histories can starve worker RPC replies at high run counts. // Yield between whole tests, never inside a publication trace or checkpoint. @@ -636,179 +1066,22 @@ 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 }, - { type: `parentRoute`, group: 20 }, - { type: `atomicReplace`, group: 30, value: 9 }, - { type: `parentThenChild`, parentValue: 10, childValue: 11 }, - ] - const driver = createPublicationDriver(q1Shape, q2Shape) - await runTrace({ - steps: actions, - driver: { - ...driver, - apply: (action, context, checkpoint) => { - const beforeCallbacks = context.callbacks.length - let reached = 0 - const result = driver.apply(action, context, () => { - reached++ - return checkpoint() - }) - // Capture before awaiting the async action's returned Promise. - const synchronousCount = reached - return Promise.resolve(result).then(() => { - for (const layer of [`q1`, `q2`] as const) { - expect( - context.callbacks - .slice(beforeCallbacks) - .some((callback) => callback.layer === layer), - ).toBe(true) - } - expect(synchronousCount).toBe( - action.type === `parentThenChild` ? 2 : 1, - ) - }) - }, - }, - projection: publicationProjection, - }) - }) - - it(`rejects a transient layered tear through ${q1Shape}/${q2Shape}`, async () => { - oracleRoute(`case`) - const run = (observeInsideAction: boolean) => { - let transient = false - const driver = createPublicationDriver(q1Shape, q2Shape) - return runTrace({ - steps: [{ type: `parentScalar` as const, value: 7 }], - driver: { - ...driver, - apply: (action, context, checkpoint) => { - transient = true - queueMicrotask(() => { - transient = false - }) - return driver.apply( - action, - context, - observeInsideAction ? checkpoint : () => undefined, - ) - }, - }, - projection: { - ...publicationProjection, - observe: (context) => { - const observed = publicationProjection.observe(context) - if (transient) observed.q2[0]!.item.value = -999 - return observed - }, - }, - }) - } - // The old settled-only observation misses this test-owned fault. - await expect(run(false)).resolves.toBeUndefined() - await expect(run(true)).rejects.toMatchObject({ - name: `TraceAssertionError`, - checkpoint: 1, - }) - }) - - 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() - try { - await driver.start?.(context) - await driver.apply( - { type: `parentScalar`, value: 7 }, - context, - () => undefined, - ) - const callback = context.callbacks.find( - (entry) => entry.layer === `q2`, - ) - expect(callback).toBeDefined() - callback!.rows[0]!.item.value = -999 - reached++ - // The installed final reads still agree. This fault corrupts only the - // captured callback and repairs it before the write returns. - const observed = publicationProjection.observe(context) - const expected = publicationProjection.recompute(context) - expect(observed.q1).toEqual(expected.q1) - expect(observed.q2).toEqual(expected.q2) - expect(() => - publicationProjection.assertEqual(observed, expected), - ).toThrowError(expect.objectContaining({ name: `AssertionError` })) - // Repair the row capture, then remove the whole change batch. The - // same callback-time comparison must reject a missing public event. - callback!.rows = structuredClone(callback!.expected) - callback!.changes = [] - expect(() => - publicationProjection.assertEqual( - publicationProjection.observe(context), - expected, - ), - ).toThrowError(expect.objectContaining({ name: `AssertionError` })) - expect(reached).toBe(1) - } finally { - await driver.cleanup(context) - } - }) - - 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` - const seen: Array = [] - let hits = 0 - const run = runTrace({ - steps: [{ type: `optimisticConfirm` as const, value: 7 }], - driver: { - ...driver, - apply: async (action, context, checkpoint) => { - phase = `optimistic` - await driver.apply(action, context, checkpoint) - phase = `confirmed` - }, - }, - projection: { - ...publicationProjection, - observe: (context) => { - seen.push(phase) - const observed = publicationProjection.observe(context) - if (phase === fault) { - observed.q2[0]!.item.value = -999 - hits++ - } - return observed - }, - }, - }) - if (fault === `none`) { - await run - expect(seen).toEqual([`initial`, `optimistic`, `confirmed`]) - expect(hits).toBe(0) - } else { - await expect(run).rejects.toMatchObject({ - name: `TraceAssertionError`, - }) - expect(hits).toBe(1) - } - } - }) - for (const campaign of publicationCampaigns( 12, `includes-publication.parent-scalar.${q1Shape}.${q2Shape}`, )) { registerIncludesProperty( [changedValueArbitrary], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `publishes parent scalar updates through a ${q1Shape} Q1 and ${q2Shape} Q2 (${campaign.name})`, async (value) => { await expectPublicationMatches( @@ -826,7 +1099,16 @@ describe(`layered-query publication oracle`, () => { )) { registerIncludesProperty( [changedValueArbitrary, changedChildValueArbitrary], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `recovers a ${q1Shape} Q1 and ${q2Shape} Q2 after a child update (${campaign.name})`, async (parentValue, childValue) => { await expectPublicationMatches( @@ -844,7 +1126,16 @@ describe(`layered-query publication oracle`, () => { )) { registerIncludesProperty( [changedValueArbitrary], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `publishes optimistic state before and after confirmation through a ${q1Shape} Q1 and ${q2Shape} Q2 (${campaign.name})`, async (value) => { await expectPublicationMatches( @@ -858,41 +1149,135 @@ describe(`layered-query publication oracle`, () => { } } - it.each([ - { phase: `optimistic`, cut: 0, checkpoint: 1 }, - { phase: `rollback`, cut: 1, checkpoint: 2 }, - ])( - `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 - const run = runTrace({ - steps: [{ type: `optimisticRollback` as const, value: 7 }], - driver: { - ...driver, - apply: (action, context, checkpoint) => - driver.apply(action, context, () => { - // Remove one phase's recorded public callbacks while preserving - // the final reads. The callback-reach check must reject it. - if (reachedCuts === cut) { - context.callbacks.splice(previousCallbackCount) - } - reachedCuts++ - previousCallbackCount = context.callbacks.length - return checkpoint() - }), - }, - projection: publicationProjection, - }) - await expect(run).rejects.toMatchObject({ - name: `TraceAssertionError`, - checkpoint: expectedCheckpoint, - }) - expect(reachedCuts).toBe(2) + oracleProperty({ + id: `includes-publication.plain-write-checkpoints`, + law: { + statement: plainWriteLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The eight enumerated Q1 and Q2 shape cells over controlled source Collections.`, }, - ) + 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`, + ], + histories: publicationShapeCells, + label: ({ q1Shape, q2Shape }) => + `observes every plain write before yielding through ${q1Shape}/${q2Shape}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(plainWriteLaw, plan, observe, () => + judgePlainWriteCheckpoints(plan.q1Shape, plan.q2Shape), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-publication.transient-tear`, + law: { + statement: transientTearLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The eight enumerated Q1 and Q2 shape cells over controlled source Collections.`, + }, + 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`, + ], + histories: publicationShapeCells, + label: ({ q1Shape, q2Shape }) => + `rejects a transient layered tear through ${q1Shape}/${q2Shape}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(transientTearLaw, plan, observe, () => + judgeTransientTear(plan.q1Shape, plan.q2Shape), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-publication.callback-only-tear`, + law: { + statement: callbackOnlyTearLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The eight enumerated Q1 and Q2 shape cells over controlled source Collections.`, + }, + 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`, + ], + histories: publicationShapeCells, + label: ({ q1Shape, q2Shape }) => + `rejects a callback-only tear before reads recover through ${q1Shape}/${q2Shape}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(callbackOnlyTearLaw, plan, observe, () => + judgeCallbackOnlyTear(plan.q1Shape, plan.q2Shape), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-publication.confirmation-sides`, + law: { + statement: confirmationSidesLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The eight enumerated Q1 and Q2 shape cells over controlled source Collections.`, + }, + 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`, + ], + histories: publicationShapeCells, + label: ({ q1Shape, q2Shape }) => + `checks both sides of confirmation through ${q1Shape}/${q2Shape}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(confirmationSidesLaw, plan, observe, () => + judgeConfirmationSides(plan.q1Shape, plan.q2Shape), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-publication.missing-callbacks`, + law: { + statement: missingCallbacksLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The two enumerated optimistic and rollback cuts in the direct pass-through shape.`, + }, + 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`, + ], + histories: missingCallbackCases, + label: ({ phase }) => + `rejects missing ${phase} callbacks at the public checkpoint`, + resolve: (missingCase) => ({ ...missingCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(missingCallbacksLaw, plan, observe, () => + judgeMissingCallbacks(plan), + ), + check: forwardAssertionJudgment, + }) for (const campaign of publicationCampaigns( 100, @@ -900,7 +1285,16 @@ describe(`layered-query publication oracle`, () => { )) { registerIncludesProperty( [changedChildValueArbitrary], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `publishes child-only scalar updates through both layers (${campaign.name})`, async (value) => { await expectPublicationMatches({ type: `childScalar`, value }) @@ -914,7 +1308,16 @@ describe(`layered-query publication oracle`, () => { )) { registerIncludesProperty( [fc.constantFrom(20, 30)], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `compares route transitions at both query layers (${campaign.name})`, async (group) => { await expectPublicationMatches({ type: `parentRoute`, group }) @@ -933,7 +1336,16 @@ describe(`layered-query publication oracle`, () => { value: changedValueArbitrary, }), ], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `compares atomic parent replacements at both query layers (${campaign.name})`, async (row) => { await expectPublicationMatches({ type: `atomicReplace`, ...row }) @@ -947,7 +1359,16 @@ describe(`layered-query publication oracle`, () => { )) { registerIncludesProperty( [changedValueArbitrary], - campaign.options, + { + ...campaign.options, + 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`, + ], + }, `publishes restored state after optimistic rollback (${campaign.name})`, async (value) => { await expectPublicationMatches({ @@ -969,216 +1390,29 @@ describe(`layered-query publication oracle`, () => { * checks a value update. */ describe(`held include publication`, () => { - it.each([ - { intermediate: `A`, final: `A` }, - { intermediate: `B`, final: `A` }, - { intermediate: `B`, final: `C` }, - ])( - `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` }], - [2, { id: 2, rank: 2, value: `X` }], - ]) - let sync!: Parameters[`sync`]>[0] - let holdRepair = false - const held: Array<() => void> = [] - let requests = 0 - const source = createCollection({ - id: `held-publication-source-${nextCollectionId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: () => { - requests++ - if (requests === 1) { - sync.begin() - for (const row of sourceRows.values()) { - sync.write({ type: `insert`, value: { ...row } }) - } - expect(sync.commit()).toBe(true) - } - return holdRepair - ? new Promise((resolve) => held.push(resolve)) - : true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const children = createControlledCollection(`held-publication-child`, [ - { id: 10, parentId: 1 }, - ]) - children.collection.createIndex((child) => child.parentId, { - indexType: BasicIndex, - }) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ parent: source }) - .orderBy(({ parent }) => parent.rank) - .limit(2) - .select(({ parent }) => ({ - id: parent.id, - value: parent.value, - children: materialize( - q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentId, parent.id)) - .select(({ child }) => ({ id: child.id })), - ), - })), - getKey: (row) => row.id, - }) - const snapshot = () => - query.toArray.map(({ id, value, children: childRows }) => ({ - id, - value, - children: childRows.map(({ id: childId }) => childId), - })) - const callbacks: Array<{ - rows: ReturnType - changes: Array<{ - type: string - key: string | number - value?: string - previousValue?: string - }> - }> = [] - const effectBatches: Array< - Array<{ - type: string - key: string | number - value: string - previousValue: string | undefined - }> - > = [] - let effect: ReturnType | undefined - let unsubscribe: (() => void) | undefined - - await withHistoryCleanup( - async () => { - await query.preload() - effect = createEffect<{ id: number; value: string }, number>({ - query: (q) => - q - .from({ parent: source }) - .orderBy(({ parent }) => parent.rank) - .limit(2) - .select(({ parent }) => ({ - id: parent.id, - value: parent.value, - })), - skipInitial: true, - onBatch: (events) => { - effectBatches.push( - events.map((event) => ({ - type: event.type, - key: event.key, - value: event.value.value, - previousValue: - event.type === `update` - ? event.previousValue.value - : undefined, - })), - ) - }, - }) - await flushPromises() - const before = snapshot() - expect(before).toEqual(expectedHeldPublicationRows(sourceRows)) - const subscription = query.subscribeChanges( - (changes) => { - callbacks.push({ - rows: snapshot(), - changes: changes.map((change) => - change.type === `update` - ? { - type: change.type, - key: change.key, - value: change.value.value, - previousValue: change.previousValue?.value, - } - : { type: change.type, key: change.key }, - ), - }) - }, - { includeInitialState: false }, - ) - unsubscribe = () => subscription.unsubscribe() - - holdRepair = true - const write = (row: Row) => { - sourceRows.set(row.id, { ...row }) - sync.begin() - sync.write({ type: `update`, value: { ...row } }) - expect(sync.commit()).toBe(true) - } - write({ id: 1, rank: 2.5, value: `A` }) - write({ id: 1, rank: 2.5, value: intermediate }) - write({ id: 1, rank: 2.5, value: final }) - expect(held.length).toBeGreaterThan(0) - expect(snapshot()).toEqual(before) - expect(callbacks).toEqual([]) - expect(effectBatches).toEqual([]) - - holdRepair = false - for (const release of held.splice(0)) release() - await flushPromises() - const after = expectedHeldPublicationRows(sourceRows) - expect(snapshot()).toEqual(after) - expect(callbacks).toEqual([ - { - rows: after, - changes: - final === `A` - ? [] - : [ - { - type: `update`, - key: 1, - value: final, - previousValue: `A`, - }, - ], - }, - ]) - expect(effectBatches).toEqual( - final === `A` - ? [] - : [ - [ - { - type: `update`, - key: 1, - value: final, - previousValue: `A`, - }, - ], - ], - ) - }, - () => [ - () => { - holdRepair = false - for (const release of held) release() - }, - () => unsubscribe?.(), - () => effect?.dispose(), - () => query.cleanup(), - () => source.cleanup(), - () => children.collection.cleanup(), - ], - ) + oracleProperty({ + id: `includes-publication.held-repair`, + law: { + statement: heldPublicationLaw, + authority: `ARCHITECTURE.md, "Coherent publication", "Atomic window publication", and normative laws 8–9, stated at the start of this owner.`, + limits: `The three enumerated intermediate and final values of one two-row root reorder; no Collection-valued facade, cleanup, or concurrent optimistic change.`, }, - ) + 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`, + ], + histories: heldPublicationCases, + label: ({ intermediate, final }) => + `compares the final ${intermediate} to ${final} row with the last public snapshot`, + resolve: (heldCase) => ({ ...heldCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(heldPublicationLaw, plan, observe, () => + judgeHeldPublication(plan), + ), + check: forwardAssertionJudgment, + }) }) From d706e9b357eb6f605f39f961857281938a6f71d4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:56:04 -0700 Subject: [PATCH 042/116] test(sqlite): register the SQLite typed-value histories through oracleProperty P2: the 16 reopen cells and the native Temporal families become enumerated properties whose drivers are the unchanged test bodies. P6: the comparison calibrations run no production, so they move unchanged to sqlite-temporal-value-calibration.test.ts, with the model and grammar in the test-free companion sqlite-temporal-value-model-oracle.ts. Co-authored-by: Isaac --- .../sqlite-temporal-value-calibration.test.ts | 58 ++ .../sqlite-temporal-value-model-oracle.ts | 69 ++ .../sqlite-temporal-value-oracle.test.ts | 642 ++++++++---------- .../db/tests/converted-properties/sqlite.ts | 7 + 4 files changed, 432 insertions(+), 344 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-calibration.test.ts create mode 100644 packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-model-oracle.ts diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-calibration.test.ts new file mode 100644 index 0000000000..c845a1100a --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-calibration.test.ts @@ -0,0 +1,58 @@ +// @vitest-environment node +/** + * Calibrations of the SQLite typed-value comparison in + * sqlite-temporal-value-support.ts against the model in + * sqlite-temporal-value-model-oracle.ts: the comparison rejects empty objects, + * wrong same-type values, ISO-string erasure, and missing, duplicate, and + * reversed query keys, and the grammar holds all 16 distinct cells. They run + * no production, so they are ordinary tests. The oracle that drives production + * is sqlite-temporal-value-oracle.test.ts. + */ +import { Temporal } from 'temporal-polyfill' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { check } from './sqlite-temporal-value-support' +import { + cases, + expectedValue, + inputValue, + kinds, + textFor, +} from './sqlite-temporal-value-model-oracle' + +beforeEach(() => vi.stubGlobal(`Temporal`, Temporal)) +afterEach(() => vi.unstubAllGlobals()) + +describe(`typed-value oracle calibration`, () => { + it.each(kinds)(`distinguishes type and value for $0`, (kind) => { + check(inputValue(kind, 0), expectedValue(kind, 0), `valid value`) + expect(() => check({}, expectedValue(kind, 0), `empty object`)).toThrow() + expect(() => + check(inputValue(kind, 1), expectedValue(kind, 0), `wrong value`), + ).toThrow() + if (kind !== `ISO string`) { + expect(() => + check(textFor(kind, 0), expectedValue(kind, 0), `type erased`), + ).toThrow() + } + }) + + it(`rejects missing, duplicate, and reversed query results`, () => { + const expected = [`z-early`, `a-late`] + check(expected, expected, `valid keys`) + for (const actual of [ + [], + [`z-early`, `z-early`], + [...expected].reverse(), + ]) { + expect(() => check(actual, expected, `query keys`)).toThrow() + } + expect(cases).toHaveLength(16) + expect( + new Set( + cases.map( + ({ kind, history, indexed }) => `${kind}/${history}/${indexed}`, + ), + ).size, + ).toBe(16) + }) +}) diff --git a/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-model-oracle.ts b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-model-oracle.ts new file mode 100644 index 0000000000..af92977d06 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-model-oracle.ts @@ -0,0 +1,69 @@ +/** + * The model and bounded grammar of the SQLite typed-value oracle in + * sqlite-temporal-value-oracle.test.ts: the four value kinds, two fixture + * ordinals, the literal type/text records that the model expects, the complete + * public rows after each history, and the 16 kind, history, and index cells. + * Owns nothing: the oracle test drives production with them, and + * sqlite-temporal-value-calibration.test.ts calibrates the comparison. + */ +import { Temporal } from 'temporal-polyfill' + +export type Kind = `Date` | `ISO string` | `Instant` | `PlainDate` +export type Ordinal = 0 | 1 +export type History = `insert` | `update` +export const kinds: Array = [`Date`, `ISO string`, `Instant`, `PlainDate`] +export const histories: Array = [`insert`, `update`] +export const keys = [`z-early`, `a-late`] as const + +export function textFor(kind: Kind, ordinal: Ordinal): string { + const day = ordinal === 0 ? `2026-01-02` : `2026-01-03` + return kind === `PlainDate` + ? day + : `${day}T00:00:00${kind === `Instant` ? `` : `.000`}Z` +} + +export function inputValue(kind: Kind, ordinal: Ordinal): unknown { + const text = textFor(kind, ordinal) + switch (kind) { + case `Date`: + return new Date(text) + case `ISO string`: + return text + case `Instant`: + return Temporal.Instant.from(text) + case `PlainDate`: + return Temporal.PlainDate.from(text) + } +} + +export function expectedValue(kind: Kind, ordinal: Ordinal): unknown { + const text = textFor(kind, ordinal) + return kind === `ISO string` ? text : { kind, text } +} + +export function payload(value: unknown): Record { + return { stamp: value, nested: { dates: [value] } } +} + +// Recompute the complete public result from fixture ordinals. This model does +// not read the driver's transaction or persisted bytes to derive expectations. +export function expectedRows(kind: Kind, history: History) { + return [ + { + key: `metadata-only`, + value: { id: `metadata-only` }, + metadata: payload(expectedValue(kind, 1)), + }, + ...keys.map((key, index) => { + const ordinal = (history === `insert` ? index : 1 - index) as Ordinal + const value = payload(expectedValue(kind, ordinal)) + return { key, value: { id: key, ...value }, metadata: value } + }), + ].sort((a, b) => a.key.localeCompare(b.key)) +} + +export const cases = kinds.flatMap((kind) => + histories.flatMap((history) => + [false, true].map((indexed) => ({ kind, history, indexed })), + ), +) 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 68f14249d0..c01cefe404 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 @@ -38,64 +38,47 @@ * * Calibration: the same comparison rejects empty objects, ISO-string erasure, * wrong same-type values, missing/duplicate rows, and reversed query output. - * afterEach owns handles/files; Vitest retains assertion and teardown failures + * Those calibrations run no production, so they live in + * sqlite-temporal-value-calibration.test.ts; the model and grammar live in + * sqlite-temporal-value-model-oracle.ts. afterEach owns handles/files; Vitest retains assertion and teardown failures * separately. The review record documents ORC outcomes and exact run receipts: * docs/contributing/oracle-reviews/issue-2034-temporal-persistence.md. */ 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 { afterEach, beforeEach, describe, expect, vi } from 'vitest' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { temporalFamilies, temporalQueryCases, temporalValue, } from './temporal-value-oracle' import { check, collectionId, scope } from './sqlite-temporal-value-support' +import { + cases, + expectedRows, + expectedValue, + inputValue, + keys, + payload, +} from './sqlite-temporal-value-model-oracle' +import type { TemporalFamily } from './temporal-value-oracle' +import type { + History, + Kind, + Ordinal, +} from './sqlite-temporal-value-model-oracle' import type { PersistedTx } from '../src' -type Kind = `Date` | `ISO string` | `Instant` | `PlainDate` -type Ordinal = 0 | 1 -type History = `insert` | `update` -const kinds: Array = [`Date`, `ISO string`, `Instant`, `PlainDate`] -const histories: Array = [`insert`, `update`] -const keys = [`z-early`, `a-late`] as const - -function textFor(kind: Kind, ordinal: Ordinal): string { - const day = ordinal === 0 ? `2026-01-02` : `2026-01-03` - return kind === `PlainDate` - ? day - : `${day}T00:00:00${kind === `Instant` ? `` : `.000`}Z` -} - -function inputValue(kind: Kind, ordinal: Ordinal): unknown { - const text = textFor(kind, ordinal) - switch (kind) { - case `Date`: - return new Date(text) - case `ISO string`: - return text - case `Instant`: - return Temporal.Instant.from(text) - case `PlainDate`: - return Temporal.PlainDate.from(text) - } -} - -function expectedValue(kind: Kind, ordinal: Ordinal): unknown { - const text = textFor(kind, ordinal) - return kind === `ISO string` ? text : { kind, text } -} - beforeEach(() => vi.stubGlobal(`Temporal`, Temporal)) afterEach(() => vi.unstubAllGlobals()) afterEach(scope.cleanup) -function payload(value: unknown): Record { - return { stamp: value, nested: { dates: [value] } } -} - function transaction(kind: Kind, history: History): PersistedTx { const seq = history === `insert` ? 2 : 3 const ordinals: Array = history === `insert` ? [0, 1] : [1, 0] @@ -129,343 +112,314 @@ function transaction(kind: Kind, history: History): PersistedTx { } } -// Recompute the complete public result from fixture ordinals. This model does -// not read the driver's transaction or persisted bytes to derive expectations. -function expectedRows(kind: Kind, history: History) { - return [ +// One cell: commit its writes, reopen, and compare every public read with the +// model. Soft comparisons keep reaching later checkpoints after a mismatch. +async function runTypedValueCase({ + kind, + history, + indexed, +}: (typeof cases)[number]): Promise { + const harness = scope.create() + const { adapter } = harness.open() + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, { - key: `metadata-only`, - value: { id: `metadata-only` }, - metadata: payload(expectedValue(kind, 1)), - }, - ...keys.map((key, index) => { - const ordinal = (history === `insert` ? index : 1 - index) as Ordinal - const value = payload(expectedValue(kind, ordinal)) - return { key, value: { id: key, ...value }, metadata: value } - }), - ].sort((a, b) => a.key.localeCompare(b.key)) -} - -const cases = kinds.flatMap((kind) => - histories.flatMap((history) => - [false, true].map((indexed) => ({ kind, history, indexed })), - ), -) - -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( - { kind: `eager`, collectionId: collectionId }, + txId: `baseline`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ { - txId: `baseline`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [ - { - type: `insert`, - key: `metadata-only`, - value: { id: `metadata-only` }, - }, - ], + type: `insert`, + key: `metadata-only`, + value: { id: `metadata-only` }, }, - ) - await adapter.applyCommittedTx( - { kind: `eager`, collectionId: collectionId }, - transaction(kind, `insert`), - ) - if (history === `update`) { - await adapter.applyCommittedTx( - { kind: `eager`, collectionId: collectionId }, - transaction(kind, `update`), - ) - } - if (indexed) { - await adapter.ensureIndex( - { kind: `eager`, collectionId: collectionId }, - `stamp`, - { - expressionSql: [JSON.stringify({ type: `ref`, path: [`stamp`] })], - }, - ) - } + ], + }, + ) + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + transaction(kind, `insert`), + ) + if (history === `update`) { + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + transaction(kind, `update`), + ) + } + if (indexed) { + await adapter.ensureIndex( + { kind: `eager`, collectionId: collectionId }, + `stamp`, + { + expressionSql: [JSON.stringify({ type: `ref`, path: [`stamp`] })], + }, + ) + } - const { adapter: reopened, database } = harness.open() - const indexes = database - .prepare( - `SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (SELECT index_name FROM persisted_index_registry WHERE collection_id = ? AND signature = ?)`, - ) - .all(collectionId, `stamp`) - expect(indexes).toHaveLength(indexed ? 1 : 0) - const expected = expectedRows(kind, history) - const rows = await reopened.loadSubset( - { kind: `eager`, collectionId: collectionId }, - {}, - ) - const scanned = await reopened.scanRows( - { kind: `eager`, collectionId: collectionId }, - {}, - ) - // Soft checks continue to the replay and query checkpoints after a mismatch. - for (const [checkpoint, actual] of [ - [`hydration`, rows], - [`scan`, scanned], - ] as const) { - check( - [...actual].sort((a, b) => - String(a.key).localeCompare(String(b.key)), - ), - expected, - checkpoint, - expect.soft, - ) - } - const metadata = await reopened.loadCollectionMetadata({ - kind: `eager`, - collectionId: collectionId, - }) - check( - metadata, - [{ key: `checkpoint`, value: payload(expectedValue(kind, 0)) }], - `collection metadata`, - expect.soft, - ) + const { adapter: reopened, database } = harness.open() + const indexes = database + .prepare( + `SELECT name FROM sqlite_master WHERE type = 'index' AND name IN (SELECT index_name FROM persisted_index_registry WHERE collection_id = ? AND signature = ?)`, + ) + .all(collectionId, `stamp`) + expect(indexes).toHaveLength(indexed ? 1 : 0) + const expected = expectedRows(kind, history) + const rows = await reopened.loadSubset( + { kind: `eager`, collectionId: collectionId }, + {}, + ) + const scanned = await reopened.scanRows( + { kind: `eager`, collectionId: collectionId }, + {}, + ) + // Soft checks continue to the replay and query checkpoints after a mismatch. + for (const [checkpoint, actual] of [ + [`hydration`, rows], + [`scan`, scanned], + ] as const) { + check( + [...actual].sort((a, b) => String(a.key).localeCompare(String(b.key))), + expected, + checkpoint, + expect.soft, + ) + } + const metadata = await reopened.loadCollectionMetadata({ + kind: `eager`, + collectionId: collectionId, + }) + check( + metadata, + [{ key: `checkpoint`, value: payload(expectedValue(kind, 0)) }], + `collection metadata`, + expect.soft, + ) - const seq = history === `insert` ? 2 : 3 - const replay = await reopened.pullSince( - { kind: `eager`, collectionId: collectionId }, - seq - 1, - ) - expect(replay.requiresFullReload, `replay payload reached`).toBe(false) - if (replay.requiresFullReload) - throw new Error(`Expected a replay payload`) - const expectedChangedRows = keys.map((key) => { - const row = expected.find((entry) => entry.key === key)! - return { key, value: row.value } - }) - check( - replay.deltas, - [ + const seq = history === `insert` ? 2 : 3 + const replay = await reopened.pullSince( + { kind: `eager`, collectionId: collectionId }, + seq - 1, + ) + expect(replay.requiresFullReload, `replay payload reached`).toBe(false) + if (replay.requiresFullReload) throw new Error(`Expected a replay payload`) + const expectedChangedRows = keys.map((key) => { + const row = expected.find((entry) => entry.key === key)! + return { key, value: row.value } + }) + check( + replay.deltas, + [ + { + txId: `values-${seq}`, + latestRowVersion: seq, + changedRows: expectedChangedRows, + deletedKeys: [], + rowMetadataMutations: [ { - txId: `values-${seq}`, - latestRowVersion: seq, - changedRows: expectedChangedRows, - deletedKeys: [], - rowMetadataMutations: [ - { - type: `set`, - key: `metadata-only`, - value: payload(expectedValue(kind, 1)), - }, - ], - collectionMetadataMutations: [ - { - type: `set`, - key: `checkpoint`, - value: payload(expectedValue(kind, 0)), - }, - ], + type: `set`, + key: `metadata-only`, + value: payload(expectedValue(kind, 1)), }, ], - `transaction replay`, - expect.soft, - ) - - const earlier = history === `insert` ? keys[0] : keys[1] - const later = history === `insert` ? keys[1] : keys[0] - for (const [operator, expectedKey] of [ - [`eq`, earlier], - [`gt`, later], - ] as const) { - const matches = await reopened.loadSubset( - { kind: `eager`, collectionId: collectionId }, - { - where: new IR.Func(operator, [ - new IR.PropRef([`stamp`]), - new IR.Value(inputValue(kind, 0)), - ]), - }, - ) - check( - matches.map((row) => row.key), - [expectedKey], - `${operator} query`, - expect.soft, - ) - } - for (const [offset, expectedKey] of [ - [0, earlier], - [1, later], - ] as const) { - const page = await reopened.loadSubset( - { kind: `eager`, collectionId: collectionId }, + collectionMetadataMutations: [ { - orderBy: [ - { - expression: new IR.PropRef([`stamp`]), - compareOptions: { direction: `asc`, nulls: `last` }, - }, - ], - limit: 1, - offset, + type: `set`, + key: `checkpoint`, + value: payload(expectedValue(kind, 0)), }, - ) - check( - page.map((row) => row.key), - [expectedKey], - `ordered page ${offset}`, - expect.soft, - ) - } - }, + ], + }, + ], + `transaction replay`, + expect.soft, ) -}) -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(() => - check(inputValue(kind, 1), expectedValue(kind, 0), `wrong value`), - ).toThrow() - if (kind !== `ISO string`) { - expect(() => - check(textFor(kind, 0), expectedValue(kind, 0), `type erased`), - ).toThrow() - } - }) + const earlier = history === `insert` ? keys[0] : keys[1] + const later = history === `insert` ? keys[1] : keys[0] + for (const [operator, expectedKey] of [ + [`eq`, earlier], + [`gt`, later], + ] as const) { + const matches = await reopened.loadSubset( + { kind: `eager`, collectionId: collectionId }, + { + where: new IR.Func(operator, [ + new IR.PropRef([`stamp`]), + new IR.Value(inputValue(kind, 0)), + ]), + }, + ) + check( + matches.map((row) => row.key), + [expectedKey], + `${operator} query`, + expect.soft, + ) + } + for (const [offset, expectedKey] of [ + [0, earlier], + [1, later], + ] as const) { + const page = await reopened.loadSubset( + { kind: `eager`, collectionId: collectionId }, + { + orderBy: [ + { + expression: new IR.PropRef([`stamp`]), + compareOptions: { direction: `asc`, nulls: `last` }, + }, + ], + limit: 1, + offset, + }, + ) + check( + page.map((row) => row.key), + [expectedKey], + `ordered page ${offset}`, + expect.soft, + ) + } +} - 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 [ - [], - [`z-early`, `z-early`], - [...expected].reverse(), - ]) { - expect(() => check(actual, expected, `query keys`)).toThrow() - } - expect(cases).toHaveLength(16) - expect( - new Set( - cases.map( - ({ kind, history, indexed }) => `${kind}/${history}/${indexed}`, - ), - ).size, - ).toBe(16) +const typedValueLaw = `SQLite typed-value preservation` +describe(`SQLite typed-value preservation after reopen`, () => { + oracleProperty({ + id: `sqlite-persistence.typed-value-reopen`, + law: { + statement: `Accepted Date, ISO string, Temporal.Instant, and Temporal.PlainDate values survive a SQLite close and reopen with their type and value in rows, row and collection metadata, transaction replay, equality and range queries, and an ordered one-row window.`, + authority: `The core adapter contract that preserves Date and BigInt, and issue #2034's approved v2 design for native Temporal reconstruction.`, + limits: `The 16 kind, history, and index cells, with one polyfill copy and an in-process reopen; the installed index has a reach witness, not a planner claim.`, + }, + owns: [`src/sqlite-value.ts`, `src/sqlite-core-adapter.ts`], + histories: cases, + label: ({ kind, history, indexed }) => + `${kind} / ${history} / indexed=${indexed}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(typedValueLaw, plan, observe, () => + runTypedValueCase(plan.cell), + ), + check: forwardAssertionJudgment, }) }) // V2 extends the same physical-reopen owner with bounded value families. Rank // 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( +const nativeTemporalLaw = `SQLite native Temporal query refinement` +// One family: write its values, reopen, and compare each query, cursor, and +// ordered page with the companion rank/text model. +async function runNativeTemporalFamily(family: TemporalFamily): Promise { + const harness = scope.create() + const { adapter } = harness.open() + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + { + txId: `seed`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: family.texts.map((_, i) => ({ + type: `insert`, + key: `row-${i}`, + value: { + stamp: temporalValue(family, i), + target: temporalValue(family, 1), + }, + })), + }, + ) + const { adapter: reopened } = harness.open() + for (const query of temporalQueryCases(family)) { + const rows = await reopened.loadSubset( { kind: `eager`, collectionId: collectionId }, { - txId: `seed`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: family.texts.map((_, i) => ({ - type: `insert`, - key: `row-${i}`, - value: { - stamp: temporalValue(family, i), - target: temporalValue(family, 1), - }, - })), + where: query.where, }, ) - const { adapter: reopened } = harness.open() - for (const query of temporalQueryCases(family)) { - const rows = await reopened.loadSubset( - { kind: `eager`, collectionId: collectionId }, + expect + .soft(rows.map((row) => row.key).sort(), query.name) + .toEqual(query.expectedKeys) + } + const target = new IR.Value(temporalValue(family, 1)) + const stamp = new IR.PropRef([`stamp`]) + const cursorRows = await reopened.loadSubset( + { kind: `eager`, collectionId: collectionId }, + { + cursor: { + whereCurrent: new IR.Func(`and`, [ + new IR.Func(`gte`, [stamp, target]), + new IR.Func(`lte`, [stamp, target]), + ]), + whereFrom: new IR.Func(`gt`, [stamp, target]), + }, + orderBy: [ { - where: query.where, + expression: stamp, + compareOptions: { direction: `asc`, nulls: `last` }, }, - ) - expect - .soft(rows.map((row) => row.key).sort(), query.name) - .toEqual(query.expectedKeys) - } - const target = new IR.Value(temporalValue(family, 1)) - const stamp = new IR.PropRef([`stamp`]) - const cursorRows = await reopened.loadSubset( + ], + limit: 1, + }, + ) + const currentKeys = family.texts.flatMap((_, i) => + family.ranks[i] === family.ranks[1] ? [`row-${i}`] : [], + ) + const followingKeys = family.texts + .flatMap((_, i) => (family.ranks[i]! > family.ranks[1] ? [`row-${i}`] : [])) + .slice(0, 1) + expect + .soft( + cursorRows.map((row) => row.key), + `cursor tie group and continuation`, + ) + .toEqual([...currentKeys, ...followingKeys]) + const ordered = family.texts + .map((_, i) => i) + .sort((a, b) => family.ranks[a]! - family.ranks[b]! || a - b) + for (const [offset, index] of ordered.entries()) { + const rows = await reopened.loadSubset( { kind: `eager`, collectionId: collectionId }, { - cursor: { - whereCurrent: new IR.Func(`and`, [ - new IR.Func(`gte`, [stamp, target]), - new IR.Func(`lte`, [stamp, target]), - ]), - whereFrom: new IR.Func(`gt`, [stamp, target]), - }, orderBy: [ { - expression: stamp, + expression: new IR.PropRef([`stamp`]), compareOptions: { direction: `asc`, nulls: `last` }, }, ], + offset, limit: 1, }, ) - const currentKeys = family.texts.flatMap((_, i) => - family.ranks[i] === family.ranks[1] ? [`row-${i}`] : [], + expect.soft(rows.map((row) => row.key)).toEqual([`row-${index}`]) + check( + rows[0]?.value.stamp, + { kind: family.kind, text: family.texts[index] }, + `native result`, + expect.soft, ) - const followingKeys = family.texts - .flatMap((_, i) => - family.ranks[i]! > family.ranks[1] ? [`row-${i}`] : [], - ) - .slice(0, 1) - expect - .soft( - cursorRows.map((row) => row.key), - `cursor tie group and continuation`, - ) - .toEqual([...currentKeys, ...followingKeys]) - const ordered = family.texts - .map((_, i) => i) - .sort((a, b) => family.ranks[a]! - family.ranks[b]! || a - b) - for (const [offset, index] of ordered.entries()) { - const rows = await reopened.loadSubset( - { kind: `eager`, collectionId: collectionId }, - { - orderBy: [ - { - expression: new IR.PropRef([`stamp`]), - compareOptions: { direction: `asc`, nulls: `last` }, - }, - ], - offset, - limit: 1, - }, - ) - expect.soft(rows.map((row) => row.key)).toEqual([`row-${index}`]) - check( - rows[0]?.value.stamp, - { kind: family.kind, text: family.texts[index] }, - `native result`, - expect.soft, - ) - } + } +} + +describe(`native Temporal query refinement`, () => { + oracleProperty({ + id: `sqlite-persistence.native-temporal-query`, + law: { + statement: `Native Temporal values written to SQLite answer equality, range, cursor, and ordered-page queries after a reopen as the companion rank/text model orders them, and reconstruct as the same kind and text.`, + authority: `Issue #2034's approved v2 design for native Temporal reconstruction, with the companion model in temporal-value-oracle.ts.`, + limits: `The bounded Instant and PlainDate families of temporal-value-oracle.ts, with one polyfill copy and an in-process reopen.`, + }, + owns: [`src/sqlite-value.ts`, `src/sqlite-core-adapter.ts`], + histories: temporalFamilies, + label: ({ kind, name }) => `${kind} / ${name}`, + resolve: (family) => ({ family }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(nativeTemporalLaw, plan, observe, () => + runNativeTemporalFamily(plan.family), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 2ff3107fbc..cefd3dc93d 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -10,6 +10,8 @@ export const properties: ReadonlyArray = [ `electron.ipc-readiness`, `electron.follower-settlement`, `sqlite-reload.class.electron-app`, + `sqlite-persistence.typed-value-reopen`, + `sqlite-persistence.native-temporal-query`, ] export const owners: ReadonlyArray = [ @@ -28,4 +30,9 @@ export const owners: ReadonlyArray = [ `sqlite-reload`, `class.electron-app`, ], + [ + `db-sqlite-persistence-core/tests/sqlite-temporal-value-oracle.test.ts`, + `sqlite-persistence`, + `typed-value-reopen native-temporal-query`, + ], ] From 60aa401fcd070b40dd0cfdaaa42991ea5ce73f14 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:56:48 -0700 Subject: [PATCH 043/116] test(db-ivm): register the Index refinement regressions through oracleProperty Converts the two plain-test sites in index-refinement-oracle.property.test.ts. The named NaN-prefix regressions become ivm.index-refinement-fixed (P1b), with the same driveIndex, indexExpectations, and checkIndex in both modes. The fixed-seed census samples only the grammar, so it moves unchanged to index-refinement-grammar.test.ts (P6), and the grammar moves unchanged to index-refinement-grammar-oracle.ts. expectRefinement has no use left and is deleted. Co-authored-by: Isaac --- .../tests/index-refinement-grammar-oracle.ts | 64 ++++++++ .../tests/index-refinement-grammar.test.ts | 31 ++++ .../index-refinement-oracle.property.test.ts | 141 ++++-------------- .../converted-properties/frameworks-ivm.ts | 18 +++ 4 files changed, 146 insertions(+), 108 deletions(-) create mode 100644 packages/db-ivm/tests/index-refinement-grammar-oracle.ts create mode 100644 packages/db-ivm/tests/index-refinement-grammar.test.ts diff --git a/packages/db-ivm/tests/index-refinement-grammar-oracle.ts b/packages/db-ivm/tests/index-refinement-grammar-oracle.ts new file mode 100644 index 0000000000..5d5c14ecea --- /dev/null +++ b/packages/db-ivm/tests/index-refinement-grammar-oracle.ts @@ -0,0 +1,64 @@ +// The history grammar of the Index refinement oracle. The law, model, +// production driver, and check are in index-refinement-oracle.property.test.ts; +// index-refinement-grammar.test.ts checks this grammar's fixed-seed census. +// This module registers no tests and has no side effects. +import { fc } from '@fast-check/vitest' + +export type Prefix = number | string | bigint +export type Value = [Prefix, number | string] | number | string +export type Addition = { key: `k1` | `k2`; value: Value; multiplicity: number } + +// --------------------------------------------------------------------------- +// History grammar +// --------------------------------------------------------------------------- + +const prefixes: ReadonlyArray = [ + Number.NaN, + 0, + -0, + 1, + `1`, + BigInt(1), + `a`, +] + +const valueArbitrary: fc.Arbitrary = fc.oneof( + { + weight: 3, + arbitrary: fc + .tuple(fc.constantFrom(...prefixes), fc.constantFrom(0, 1, `x`)) + .map(([prefix, payload]): Value => [prefix, payload]), + }, + fc.constantFrom(5, 6, `u`), +) + +// Reusing earlier values lets histories cancel what they added. +export const historyArbitrary: fc.Arbitrary> = fc + .array( + fc.record({ + key: fc.constantFrom(`k1` as const, `k2` as const), + value: valueArbitrary, + multiplicity: fc.constantFrom(-2, -1, 1, 2), + repeat: fc.option(fc.nat({ max: 11 }), { nil: undefined }), + }), + { maxLength: 12 }, + ) + .map((steps) => { + const additions: Array = [] + for (const { key, value, multiplicity, repeat } of steps) { + const earlier = + repeat === undefined + ? undefined + : additions[repeat % (additions.length || 1)] + additions.push( + earlier === undefined + ? { key, value, multiplicity } + : { + key: earlier.key, + value: earlier.value, + multiplicity: -earlier.multiplicity, + }, + ) + } + return additions + }) diff --git a/packages/db-ivm/tests/index-refinement-grammar.test.ts b/packages/db-ivm/tests/index-refinement-grammar.test.ts new file mode 100644 index 0000000000..c61b18ba08 --- /dev/null +++ b/packages/db-ivm/tests/index-refinement-grammar.test.ts @@ -0,0 +1,31 @@ +// A census of the Index refinement oracle's grammar. It samples the fixed +// campaign of index-refinement-oracle.property.test.ts (the same seed and run +// count) and counts histories that reach a cancelled NaN-prefixed value +// beside other values. It runs no production code, so it tests the grammar. +import { fc } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { oracleFixedSeed } from '../../db/tests/oracle-config.js' +import { historyArbitrary } from './index-refinement-grammar-oracle.js' + +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, + oracleFixedSeed(20260930, { numRuns: 300 }), + ) + const reached = sample.filter((additions) => + additions.some( + (addition, step) => + Array.isArray(addition.value) && + Number.isNaN(addition.value[0]) && + addition.multiplicity < 0 && + additions + .slice(0, step) + .some((earlier) => earlier.value !== addition.value), + ), + ) + expect(reached.length).toBeGreaterThan(10) + }) +}) 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 31a41341d0..47608578c5 100644 --- a/packages/db-ivm/tests/index-refinement-oracle.property.test.ts +++ b/packages/db-ivm/tests/index-refinement-oracle.property.test.ts @@ -51,24 +51,20 @@ * payloads other than numbers and strings are outside this owner; the join * operator tests and the incrementalization law own the operators. */ -import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe } from 'vitest' import { Index } from '../src/indexes.js' -import { - oracleFixedSeed, - oracleReplayTest, - oracleRoute, -} from '../../db/tests/oracle-config.js' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' +import { historyArbitrary } from './index-refinement-grammar-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' - -type Prefix = number | string | bigint -type Value = [Prefix, number | string] | number | string -type Addition = { key: `k1` | `k2`; value: Value; multiplicity: number } +import type { + Addition, + Prefix, + Value, +} from './index-refinement-grammar-oracle.js' // --------------------------------------------------------------------------- // Model @@ -109,60 +105,10 @@ function expectedIndex( } // --------------------------------------------------------------------------- -// History grammar +// History grammar: `historyArbitrary`, in index-refinement-grammar-oracle.ts. +// Its fixed-seed census is in index-refinement-grammar.test.ts. // --------------------------------------------------------------------------- -const prefixes: ReadonlyArray = [ - Number.NaN, - 0, - -0, - 1, - `1`, - BigInt(1), - `a`, -] - -const valueArbitrary: fc.Arbitrary = fc.oneof( - { - weight: 3, - arbitrary: fc - .tuple(fc.constantFrom(...prefixes), fc.constantFrom(0, 1, `x`)) - .map(([prefix, payload]): Value => [prefix, payload]), - }, - fc.constantFrom(5, 6, `u`), -) - -// Reusing earlier values lets histories cancel what they added. -const historyArbitrary: fc.Arbitrary> = fc - .array( - fc.record({ - key: fc.constantFrom(`k1` as const, `k2` as const), - value: valueArbitrary, - multiplicity: fc.constantFrom(-2, -1, 1, 2), - repeat: fc.option(fc.nat({ max: 11 }), { nil: undefined }), - }), - { maxLength: 12 }, - ) - .map((steps) => { - const additions: Array = [] - for (const { key, value, multiplicity, repeat } of steps) { - const earlier = - repeat === undefined - ? undefined - : additions[repeat % (additions.length || 1)] - additions.push( - earlier === undefined - ? { key, value, multiplicity } - : { - key: earlier.key, - value: earlier.value, - multiplicity: -earlier.multiplicity, - }, - ) - } - return additions - }) - const add = (value: Value, multiplicity: number): Addition => ({ key: `k1`, value, @@ -285,23 +231,6 @@ function checkIndex( return pass } -// Fixed regressions use the same production driver, model and comparison as -// the generated native campaign, with no hand-written expected answer. -function expectRefinement( - additions: ReadonlyArray, - mode: Mode = `structural`, -): void { - const trace: Array = [] - driveIndex(additions, [mode], (checkpoint, value) => - trace.push({ checkpoint, value }), - ) - const verdict = checkIndex(trace, indexExpectations(additions, [mode])) - if (verdict.kind === `violation`) - expect(verdict.values?.actual, verdict.difference).toEqual( - verdict.values?.expected, - ) -} - // 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 @@ -314,14 +243,6 @@ function expectRefinement( // test in this file stands aside. describe(`Index refinement oracle`, () => { - for (const { name, additions } of fixedHistories) { - oracleReplayTest(it)(`matches the multiset model when ${name}`, () => { - oracleRoute(`fixed history`) - expectRefinement(additions) - expectRefinement(additions, `prefixIdentity`) - }) - } - // Preserve both storage modes and every get/has/iterator cut. The model // recomputes each prefix without reading the production Index. oracleProperty({ @@ -349,25 +270,29 @@ describe(`Index refinement oracle`, () => { check: (_additions, trace, expected) => checkIndex(trace, expected), }) - // 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`, () => { - oracleRoute(`fixed seed`) - const sample = fc.sample( - historyArbitrary, - oracleFixedSeed(20260930, { numRuns: 300 }), - ) - const reached = sample.filter((additions) => - additions.some( - (addition, step) => - Array.isArray(addition.value) && - Number.isNaN(addition.value[0]) && - addition.multiplicity < 0 && - additions - .slice(0, step) - .some((earlier) => earlier.value !== addition.value), - ), - ) - expect(reached.length).toBeGreaterThan(10) + // The fixed regressions run through the same production driver, model, and + // comparison as the generated campaign, with no hand-written expected + // answer. The mapped grammar cannot take them back, so they form one + // enumerated property. + oracleProperty({ + id: `ivm.index-refinement-fixed`, + law: { + statement: `After each addition the Index get/has observations equal independently summed multiplicities, and get agrees with getIterator in order, in both structural and prefixIdentity modes.`, + authority: `Existing Index multiset and prefixIdentity contract documented above.`, + limits: `The named NaN-prefix cancellation and merge regressions only.`, + }, + owns: [ + `src/indexes.ts#Index.addValue`, + `src/indexes.ts#Index.get`, + `src/indexes.ts#Index.has`, + `src/indexes.ts#Index.getIterator`, + ], + histories: fixedHistories, + label: ({ name }) => `matches the multiset model when ${name}`, + model: ({ additions }) => + indexExpectations(additions, [`structural`, `prefixIdentity`]), + driver: ({ additions }, observe) => + driveIndex(additions, [`structural`, `prefixIdentity`], observe), + check: (_history, trace, expected) => checkIndex(trace, expected), }) }) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 9380cafea1..1445615247 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -14,6 +14,7 @@ export const properties: ReadonlyArray = [ `structural-identity.representation-fixed`, `structural-identity.independent-values-fixed`, `ivm.distinct-representatives-fixed`, + `ivm.index-refinement-fixed`, // react-db `react.convex-lazy-paging`, `react.effect-callback`, @@ -25,6 +26,8 @@ export const properties: ReadonlyArray = [ `react.ssr-ordinary-examples`, `react.ssr-infinite-examples`, `react.ssr-stream-cut-fixed`, + `react.source-id-reuse-fixed`, + `react.source-id-reuse-infinite-fixed`, // vue-db and scripts ] @@ -55,6 +58,11 @@ export const owners: ReadonlyArray = [ `ivm`, `distinct-representatives-fixed`, ], + [ + `db-ivm/tests/index-refinement-oracle.property.test.ts`, + `ivm`, + `index-refinement-fixed`, + ], // react-db [ `react-db/tests/convex-lazy-paging-oracle.test.tsx`, @@ -106,5 +114,15 @@ export const owners: ReadonlyArray = [ `react`, `ssr-stream-cut-fixed`, ], + [ + `react-db/tests/source-id-reuse-oracle.test.tsx`, + `react`, + `source-id-reuse-fixed`, + ], + [ + `react-db/tests/source-id-reuse-oracle.test.tsx`, + `react`, + `source-id-reuse-infinite-fixed`, + ], // vue-db and scripts ] From 722e847a0825cc18158793344991cf0a279e9537 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:57:20 -0700 Subject: [PATCH 044/116] test(db): register index-path and custom-index-order oracle tests through oracleProperty Each production path becomes an enumerated property through a named driver. The output-indexing checker calibration runs no production, so it moves to an ordinary test beside an extracted, test-free model module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 42 +++ ...stom-index-order-selection-model-oracle.ts | 89 ++++++ ...custom-index-order-selection-model.test.ts | 43 +++ ...ustom-index-order-selection-oracle.test.ts | 302 ++++++++---------- .../query/index-path-collision-oracle.test.ts | 246 +++++++++----- 5 files changed, 477 insertions(+), 245 deletions(-) create mode 100644 packages/db/tests/query/custom-index-order-selection-model-oracle.ts create mode 100644 packages/db/tests/query/custom-index-order-selection-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 556207386f..e99be348e7 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -31,6 +31,13 @@ const otherProperties: ReadonlyArray = [ `derived-publication.queued-batches`, `replay.ordered-window-regressions`, `replay.ordered-window-fixed`, + `index-path-collision.predicate`, + `index-path-collision.subscription`, + `index-path-collision.selected-order`, + `index-path-collision.live-query`, + `custom-index-order.rank-window`, + `custom-index-order.later-eligible`, + `custom-index-order.eager-reader`, ] const otherOwners: ReadonlyArray = [ [ @@ -78,6 +85,41 @@ const otherOwners: ReadonlyArray = [ `replay`, `ordered-window-regressions ordered-window-fixed`, ], + [ + `db/tests/query/index-path-collision-oracle.test.ts`, + `index-path-collision`, + `predicate`, + ], + [ + `db/tests/query/index-path-collision-oracle.test.ts`, + `index-path-collision`, + `subscription`, + ], + [ + `db/tests/query/index-path-collision-oracle.test.ts`, + `index-path-collision`, + `selected-order`, + ], + [ + `db/tests/query/index-path-collision-oracle.test.ts`, + `index-path-collision`, + `live-query`, + ], + [ + `db/tests/query/custom-index-order-selection-oracle.test.ts`, + `custom-index-order`, + `rank-window`, + ], + [ + `db/tests/query/custom-index-order-selection-oracle.test.ts`, + `custom-index-order`, + `later-eligible`, + ], + [ + `db/tests/query/custom-index-order-selection-oracle.test.ts`, + `custom-index-order`, + `eager-reader`, + ], ] export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/query/custom-index-order-selection-model-oracle.ts b/packages/db/tests/query/custom-index-order-selection-model-oracle.ts new file mode 100644 index 0000000000..9c6a68c262 --- /dev/null +++ b/packages/db/tests/query/custom-index-order-selection-model-oracle.ts @@ -0,0 +1,89 @@ +/** + * The output-indexing grammar, model, and comparisons of + * `custom-index-order-selection-oracle.test.ts`, moved unchanged so that the + * checker calibration in `custom-index-order-selection-model.test.ts` can read + * them. That oracle file states the law and its authority. This module + * registers no tests. + */ +import { expect } from 'vitest' +import { BTreeIndex } from '../../src/indexes/btree-index.js' +import { BasicIndex } from '../../src/indexes/basic-index.js' +import { + expectedIndexing, + indexConfigurations, +} from '../conformance/indexing-config-oracle.js' +import { oracleAssertion } from '../oracle-assertion-adapter.js' +import type { IndexSettings } from '../conformance/indexing-config-oracle.js' + +export type MaterializedRow = { + id: number + rank: number | null + score: number | null +} +export const configurations = indexConfigurations({ + indexTypes: { basic: BasicIndex, btree: BTreeIndex }, +}).flatMap((settings) => + ([`direct`, `options`] as const).map((path) => ({ ...settings, path })), +) +export const orderedReads = [ + [`rank`, `first`], + [`rank`, `last`], + [`score`, `first`], + [`score`, `last`], + [`rank`, `first`], +] as const + +// One automatic index serves each distinct ordered specification. Repeating a +// read or updating source rows must not allocate another index. Off admits no +// optimized reader until a caller explicitly creates an index. +export function expectedMaterializedIndexes(settings: IndexSettings) { + const indexing = expectedIndexing(settings) + return { + optimized: indexing.mode === `eager`, + count: + indexing.automaticIndexes * + new Set(orderedReads.map(([field, nulls]) => `${field}/${nulls}`)).size, + } +} +export function expectMaterializedIndexes( + count: number, + settings: IndexSettings, +) { + oracleAssertion(`expectMaterializedIndexes comparison 2`, () => + expect(count).toBe(expectedMaterializedIndexes(settings).count), + ) +} + +// Full recomputation uses only the projected values and the declared null +// placement. Distinct non-null values avoid an unrelated tie-order promise. +export function expectedMaterializedWindow( + values: ReadonlyArray, + field: `rank` | `score`, + nulls: `first` | `last`, +) { + return [...values] + .sort((a, b) => { + const left = a[field] + const right = b[field] + if (left === right) return 0 + if (left === null) return nulls === `first` ? -1 : 1 + if (right === null) return nulls === `first` ? 1 : -1 + return left - right + }) + .slice(0, 2) + .map(({ id }) => id) +} + +export function expectMaterializedWindow( + actual: unknown, + values: ReadonlyArray, + field: `rank` | `score`, + nulls: `first` | `last`, + optimized = true, +) { + oracleAssertion(`expectMaterializedWindow comparison 3`, () => + expect(actual).toEqual( + optimized ? expectedMaterializedWindow(values, field, nulls) : undefined, + ), + ) +} diff --git a/packages/db/tests/query/custom-index-order-selection-model.test.ts b/packages/db/tests/query/custom-index-order-selection-model.test.ts new file mode 100644 index 0000000000..377d4698e3 --- /dev/null +++ b/packages/db/tests/query/custom-index-order-selection-model.test.ts @@ -0,0 +1,43 @@ +// Checker calibration for custom-index-order-selection-oracle.test.ts. It +// runs no production: an extra index and the opposite optimized-read +// availability must be rejected by the output-indexing comparisons. The law +// and the production comparison are in that oracle file; the grammar, model, +// and comparisons are in custom-index-order-selection-model-oracle.ts. +import { expect, it } from 'vitest' +import { expectedIndexing } from '../conformance/indexing-config-oracle.js' +import { + configurations, + expectMaterializedIndexes, + expectMaterializedWindow, + expectedMaterializedIndexes, + expectedMaterializedWindow, +} from './custom-index-order-selection-model-oracle.js' +import type { MaterializedRow } from './custom-index-order-selection-model-oracle.js' + +// Calibrate the policy comparisons independently of a production path: an +// extra index and the opposite optimized-read availability must be rejected. +it.each( + configurations.filter((settings) => !expectedIndexing(settings).rejects), +)(`rejects wrong output-indexing observations: %j`, (settings) => { + const expected = expectedMaterializedIndexes(settings) + expect(() => + expectMaterializedIndexes(expected.count + 1, settings), + ).toThrow() + const values: Array = [ + { id: 1, rank: 3, score: 1 }, + { id: 2, rank: null, score: 3 }, + { id: 3, rank: 1, score: null }, + ] + const wrong = expected.optimized + ? undefined + : expectedMaterializedWindow(values, `rank`, `first`) + expect(() => + expectMaterializedWindow( + wrong, + values, + `rank`, + `first`, + expected.optimized, + ), + ).toThrow() +}) 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 21bdf17a61..290ad8a00f 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 @@ -26,7 +26,7 @@ * all three are already installed. This witness does not cover provider paging * from an incomplete source or later source changes. */ -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' @@ -35,26 +35,29 @@ import { liveQueryCollectionOptions, } from '../../src/query/live-query-collection.js' import { PropRef } from '../../src/query/ir.js' -import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions, withOracleCleanup } from '../utils.js' import { expectIndexingAdmission, expectIndexingConfiguration, expectedIndexing, - indexConfigurations, } from '../conformance/indexing-config-oracle.js' -import { oracleProperty, oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' +import { + configurations, + expectMaterializedIndexes, + expectMaterializedWindow, + expectedMaterializedIndexes, + orderedReads, +} from './custom-index-order-selection-model-oracle.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { InitialQueryBuilder } from '../../src/query/builder/index.js' -import type { IndexSettings } from '../conformance/indexing-config-oracle.js' - type Row = { id: number; rank: number } const rows: ReadonlyArray = [ @@ -79,23 +82,6 @@ function expectRankWindow(actualIds: unknown): void { ) } -declareOracle({ - check: [ - expectRankWindow, - expectMaterializedWindow, - expectMaterializedIndexes, - expectIndexingAdmission, - expectIndexingConfiguration, - ], - model: [ - expectedRankWindowIds, - expectedMaterializedWindow, - expectedMaterializedIndexes, - expectedIndexing, - ], - grammar: [indexConfigurations], -}) - function makeSource( mode: `eager` | `on-demand`, path: `scan` | `reverse manual index`, @@ -136,51 +122,52 @@ function makeSource( }) } -it.each( - ([`eager`, `on-demand`] as const).flatMap((mode) => - ([`scan`, `reverse manual index`] as const).map((path) => ({ mode, path })), - ), -)( - `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 () => { - if (mode === `eager`) await source.stateWhenReady() - else expect(source.isReady()).toBe(true) - if (path === `reverse manual index`) { - source.createIndex((row) => row.rank, { - indexType: BTreeIndex, - options: { compareFn: (a: number, b: number) => b - a }, - }) - } +const rankWindowCells = ([`eager`, `on-demand`] as const).flatMap((mode) => + ([`scan`, `reverse manual index`] as const).map((path) => ({ mode, path })), +) - const direct = source.currentStateAsChanges({ - orderBy: [ - { - expression: new PropRef([`rank`]), - compareOptions: { direction: `asc`, nulls: `first` }, - }, - ], - limit: 2, - })! - expectRankWindow(direct.map((change) => change.value.id)) +// The production driver of one rank-window cell: a direct ordered snapshot +// and a live query, each compared with `expectedRankWindowIds`. +async function runRankWindowCell({ + mode, + path, +}: (typeof rankWindowCells)[number]): Promise { + const source = makeSource(mode, path) + let live: ReturnType | undefined + await withOracleCleanup(async () => { + if (mode === `eager`) await source.stateWhenReady() + else expect(source.isReady()).toBe(true) + if (path === `reverse manual index`) { + source.createIndex((row) => row.rank, { + indexType: BTreeIndex, + options: { compareFn: (a: number, b: number) => b - a }, + }) + } - live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(2), - ) - await live.preload() - expectRankWindow([...live.values()].map((row) => row.id)) - }, [() => live?.cleanup(), () => source.cleanup()]) - }, -) + const direct = source.currentStateAsChanges({ + orderBy: [ + { + expression: new PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + limit: 2, + })! + expectRankWindow(direct.map((change) => change.value.id)) -it(`uses a later eligible index after an unusable matching index`, async () => { - oracleRoute(`fixed history`) + live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(2), + ) + await live.preload() + expectRankWindow([...live.values()].map((row) => row.id)) + }, [() => live?.cleanup(), () => source.cleanup()]) +} + +// A named history: an unusable matching index registered before a usable one. +async function runLaterEligibleIndexHistory(): Promise { const requests: Array = [] const source = makeSource(`on-demand`, `scan`, (request) => requests.push(request), @@ -245,10 +232,10 @@ it(`uses a later eligible index after an unusable matching index`, async () => { () => live?.cleanup(), () => source.cleanup(), ]) -}) +} -it(`eager indexing creates an ordered reader when only a custom reader exists`, async () => { - oracleRoute(`fixed history`) +// A named history: eager indexing beside a custom-order reader. +async function runEagerOrderedReaderHistory(): Promise { const source = createCollection( mockSyncCollectionOptions({ id: `custom-index-order-selection-eager-auto`, @@ -291,6 +278,85 @@ it(`eager indexing creates an ordered reader when only a custom reader exists`, }) expectRankWindow(direct?.map((change) => change.value.id)) }, [() => live?.cleanup(), () => source.cleanup()]) +} + +// Reader selection: a bounded public window follows the query's declared +// rank order, whatever index traversal a source offers. +const rankWindowLaw = { + statement: `A bounded ordered read returns the query's declared rank order, whatever custom-order or later-registered indexes the source holds.`, + authority: `The orderBy/limit contract in docs/guides/live-queries.md; an index is a physical route, not a result contract.`, +} +oracleProperty({ + id: `custom-index-order.rank-window`, + law: { + ...rankWindowLaw, + limits: `Eager and on-demand sources, each scanned or read through a reverse manual index.`, + }, + 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', + ], + histories: rankWindowCells, + label: ({ mode, path }) => + `keeps bounded public rank order with ${mode} ${path}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`custom index order selection`, plan, observe, () => + runRankWindowCell(plan.cell), + ), + check: forwardAssertionJudgment, +}) +oracleProperty({ + id: `custom-index-order.later-eligible`, + law: { + ...rankWindowLaw, + limits: `One on-demand history with an unusable matching index registered before a usable one.`, + }, + 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', + ], + histories: [`uses a later eligible index after an unusable matching index`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`custom index order selection`, plan, observe, () => + runLaterEligibleIndexHistory(), + ), + check: forwardAssertionJudgment, +}) +oracleProperty({ + id: `custom-index-order.eager-reader`, + law: { + ...rankWindowLaw, + limits: `One eager-indexing history whose only rank index has a custom order.`, + }, + 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', + ], + histories: [ + `eager indexing creates an ordered reader when only a custom reader exists`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`custom index order selection`, plan, observe, () => + runEagerOrderedReaderHistory(), + ), + check: forwardAssertionJudgment, }) /** @@ -309,72 +375,6 @@ it(`eager indexing creates an ordered reader when only a custom reader exists`, * This does not measure elapsed time, incomplete providers, or arbitrary * comparators; the existing selection witnesses above cover reader admission. */ -type MaterializedRow = { id: number; rank: number | null; score: number | null } -const configurations = indexConfigurations({ - indexTypes: { basic: BasicIndex, btree: BTreeIndex }, -}).flatMap((settings) => - ([`direct`, `options`] as const).map((path) => ({ ...settings, path })), -) -const orderedReads = [ - [`rank`, `first`], - [`rank`, `last`], - [`score`, `first`], - [`score`, `last`], - [`rank`, `first`], -] as const - -// One automatic index serves each distinct ordered specification. Repeating a -// read or updating source rows must not allocate another index. Off admits no -// optimized reader until a caller explicitly creates an index. -function expectedMaterializedIndexes(settings: IndexSettings) { - const indexing = expectedIndexing(settings) - return { - optimized: indexing.mode === `eager`, - count: - indexing.automaticIndexes * - new Set(orderedReads.map(([field, nulls]) => `${field}/${nulls}`)).size, - } -} -function expectMaterializedIndexes(count: number, settings: IndexSettings) { - oracleAssertion(`expectMaterializedIndexes comparison 2`, () => - expect(count).toBe(expectedMaterializedIndexes(settings).count), - ) -} - -// Full recomputation uses only the projected values and the declared null -// placement. Distinct non-null values avoid an unrelated tie-order promise. -function expectedMaterializedWindow( - values: ReadonlyArray, - field: `rank` | `score`, - nulls: `first` | `last`, -) { - return [...values] - .sort((a, b) => { - const left = a[field] - const right = b[field] - if (left === right) return 0 - if (left === null) return nulls === `first` ? -1 : 1 - if (right === null) return nulls === `first` ? 1 : -1 - return left - right - }) - .slice(0, 2) - .map(({ id }) => id) -} - -function expectMaterializedWindow( - actual: unknown, - values: ReadonlyArray, - field: `rank` | `score`, - nulls: `first` | `last`, - optimized = true, -) { - oracleAssertion(`expectMaterializedWindow comparison 3`, () => - expect(actual).toEqual( - optimized ? expectedMaterializedWindow(values, field, nulls) : undefined, - ), - ) -} - async function runMaterializedIndexHistory( settings: (typeof configurations)[number], reached: (state: string) => void, @@ -428,7 +428,7 @@ async function runMaterializedIndexHistory( } else { await cached!.preload() oracleAssertion(`output indexing configuration`, () => - expectIndexingConfiguration(cached!, settings), + expectIndexingConfiguration(cached, settings), ) for (const updated of [false, true]) { if (updated) { @@ -509,6 +509,9 @@ async function runMaterializedIndexHistory( // Constructor identities stay in the resolved production plan. Enumerated // histories name the exact position in the existing mode/constructor/path // grammar, preserving its order and all eighteen combinations as plain data. +// The grammar, model, and comparisons live in +// custom-index-order-selection-model-oracle.ts; their checker calibration is +// in custom-index-order-selection-model.test.ts. oracleProperty({ id: `live-query.output-indexing`, law: { @@ -554,32 +557,3 @@ oracleProperty({ ), check: forwardAssertionJudgment, }) - -// Calibrate the policy comparisons independently of a production path: an -// extra index and the opposite optimized-read availability must be rejected. -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), - ).toThrow() - const values: Array = [ - { id: 1, rank: 3, score: 1 }, - { id: 2, rank: null, score: 3 }, - { id: 3, rank: 1, score: null }, - ] - const wrong = expected.optimized - ? undefined - : expectedMaterializedWindow(values, `rank`, `first`) - expect(() => - expectMaterializedWindow( - wrong, - values, - `rank`, - `first`, - expected.optimized, - ), - ).toThrow() -}) 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 623fc90bc2..1306b5e8db 100644 --- a/packages/db/tests/query/index-path-collision-oracle.test.ts +++ b/packages/db/tests/query/index-path-collision-oracle.test.ts @@ -35,15 +35,19 @@ * nullish values, custom collation, or incremental publication histories. * Lazy demand target deduplication has a separate compiler-boundary witness. */ -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { add, and, gt, lt, lte } from '../../src/query/builder/functions.js' 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.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,15 +109,6 @@ 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], -}) - const cases: Array<{ name: string where: BasicExpression @@ -141,54 +136,49 @@ 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) - const queryKeys = () => - collection - .currentStateAsChanges({ where })! - .map((change) => change.key) - .sort() - - expect(queryKeys(), `scan result`).toEqual(expected) - const index = collection.createIndex((row) => row.a.b) - const lookup = vi.spyOn(index, `lookup`) - expect(queryKeys(), `indexed result`).toEqual(expected) - expect(lookup, `indexed path reached`).toHaveBeenCalled() - }) - }, -) +async function runPredicateCase({ + name, + where, + accepts, +}: (typeof cases)[number]): Promise { + await checkWithCleanup(async (register) => { + const collection = await readyCollection(name, register) + const expected = modelKeys(accepts) + const queryKeys = () => + collection + .currentStateAsChanges({ where })! + .map((change) => change.key) + .sort() -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}`, - register, - ) - if (indexed) collection.createIndex((row) => row.a.b) - const changes: Array = [] - const subscription = collection.subscribeChanges( - (batch) => changes.push(...batch.map((change) => change.key)), - { - includeInitialState: true, - where: (row) => and(gt(row[`a.b`], 5), lt(row.a.b, 20)), - }, - ) - register(() => subscription.unsubscribe()) - expect(changes.sort()).toEqual(expectedKeys) - }) - }, -) + expect(queryKeys(), `scan result`).toEqual(expected) + const index = collection.createIndex((row) => row.a.b) + const lookup = vi.spyOn(index, `lookup`) + expect(queryKeys(), `indexed result`).toEqual(expected) + expect(lookup, `indexed path reached`).toHaveBeenCalled() + }) +} -it(`keeps selected dotted and nested paths distinct in a public order`, async () => { - oracleRoute(`fixed history`) +async function runSubscriptionCase(indexed: boolean): Promise { + await checkWithCleanup(async (register) => { + const collection = await readyCollection( + `subscription-${indexed}`, + register, + ) + if (indexed) collection.createIndex((row) => row.a.b) + const changes: Array = [] + const subscription = collection.subscribeChanges( + (batch) => changes.push(...batch.map((change) => change.key)), + { + includeInitialState: true, + where: (row) => and(gt(row[`a.b`], 5), lt(row.a.b, 20)), + }, + ) + register(() => subscription.unsubscribe()) + expect(changes.sort()).toEqual(expectedKeys) + }) +} + +async function runSelectedOrderHistory(): Promise { await checkWithCleanup(async (register) => { const collection = await readyCollection(`selected-order`, register) const result = createLiveQueryCollection({ @@ -210,29 +200,123 @@ it(`keeps selected dotted and nested paths distinct in a public order`, async () const expected = modelSelectedOrder() expect(result.toArray.map((row) => row.id)).toEqual(expected) }) -}) +} -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}`, - register, - ) - if (indexed) collection.createIndex((row) => row.a.b) - const result = createLiveQueryCollection({ - query: (q) => - q - .from({ item: collection }) - .where(({ item }) => and(gt(item[`a.b`], 5), lt(item.a.b, 20))) - .select(({ item }) => ({ id: item.id })), - startSync: true, - }) - register(() => result.cleanup()) - await result.stateWhenReady() - expect(result.toArray.map((row) => row.id).sort()).toEqual(expectedKeys) +async function runLiveQueryCase(indexed: boolean): Promise { + await checkWithCleanup(async (register) => { + const collection = await readyCollection(`live-query-${indexed}`, register) + if (indexed) collection.createIndex((row) => row.a.b) + const result = createLiveQueryCollection({ + query: (q) => + q + .from({ item: collection }) + .where(({ item }) => and(gt(item[`a.b`], 5), lt(item.a.b, 20))) + .select(({ item }) => ({ id: item.id })), + startSync: true, }) + register(() => result.cleanup()) + await result.stateWhenReady() + expect(result.toArray.map((row) => row.id).sort()).toEqual(expectedKeys) + }) +} + +// Each production path above runs as its own property. The rows of `cases` +// hold functions, so a predicate history is the case's name. +const pathLaw = { + statement: `A source row matches an AND predicate according to each complete property path, so a scalar property named a.b and a nested a.b property are distinct fields; adding an index may change work, but not public rows.`, + authority: `docs/guides/live-queries.md (AND filters, selected fields, computed orderBy values) and the live-query architecture's treatment of an index as a physical query detail.`, +} +const casesByName = new Map(cases.map((row) => [row.name, row])) + +oracleProperty({ + id: `index-path-collision.predicate`, + law: { + ...pathLaw, + limits: `The four bounded predicate shapes, scanned and then indexed, through currentStateAsChanges.`, }, -) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: cases.map(({ name }) => name), + label: (name) => + `keeps dotted scalar and nested predicates distinct: ${name}`, + resolve: (name) => ({ row: casesByName.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`distinct field paths`, plan, observe, () => + runPredicateCase(plan.row), + ), + check: forwardAssertionJudgment, +}) + +oracleProperty({ + id: `index-path-collision.subscription`, + law: { + ...pathLaw, + limits: `A Collection subscription callback with and without the nested-field index.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [false, true], + label: (indexed) => + `preserves distinct fields in a Collection subscription callback (indexed=${indexed})`, + resolve: (indexed) => ({ indexed }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`distinct field paths`, plan, observe, () => + runSubscriptionCase(plan.indexed), + ), + check: forwardAssertionJudgment, +}) + +oracleProperty({ + id: `index-path-collision.selected-order`, + law: { + ...pathLaw, + limits: `One selected-field ordering through the public $selected callback proxy.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `keeps selected dotted and nested paths distinct in a public order`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`distinct field paths`, plan, observe, () => + runSelectedOrderHistory(), + ), + check: forwardAssertionJudgment, +}) + +oracleProperty({ + id: `index-path-collision.live-query`, + law: { + ...pathLaw, + limits: `A live-query callback with and without the nested-field index.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [false, true], + label: (indexed) => + `preserves distinct fields in a live-query callback (indexed=${indexed})`, + resolve: (indexed) => ({ indexed }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`distinct field paths`, plan, observe, () => + runLiveQueryCase(plan.indexed), + ), + check: forwardAssertionJudgment, +}) From 03213dd977eeb708ca3f85994464706a33e00d63 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:57:58 -0700 Subject: [PATCH 045/116] test(offline-transactions): register the plain oracle tests through oracleProperty Convert the offline-transactions plain tests under D2. The serializer's marker-shaped regression and the time-zero authored history become fixed histories of offline.serializer.mutation-wire, and its ten fault calibrations run there with their old matchers. The cleanup-failure calibration runs on its own enumerated history (offline.serializer.mutation-wire-cleanup). The restored-snapshot and started-peer cases become enumerated properties (offline.settlement.restored, offline.settlement.started-peer), and the visible-tab replay becomes offline.connectivity-replay with its owns. The restored-observation checker calibration and the replay-ledger model rule move to ordinary test files, with their model in test-free oracle-named modules. The serializer module's owns now sits on the registering file's oracleProperty calls. Co-authored-by: Isaac --- .../db/tests/converted-properties/adapters.ts | 35 +- .../tests/connectivity-replay-oracle.test.ts | 245 +-- .../leadership-replay-ledger-model-oracle.ts | 33 + .../leadership-replay-ledger-model.test.ts | 21 + .../leadership-replay-oracle.property.test.ts | 50 +- ...saction-serializer-oracle.property.test.ts | 316 +++- ...saction-settlement-oracle.property.test.ts | 1380 +++++++---------- ...action-settlement-restored-model-oracle.ts | 227 +++ ...nsaction-settlement-restored-model.test.ts | 58 + 9 files changed, 1327 insertions(+), 1038 deletions(-) create mode 100644 packages/offline-transactions/tests/leadership-replay-ledger-model-oracle.ts create mode 100644 packages/offline-transactions/tests/leadership-replay-ledger-model.test.ts create mode 100644 packages/offline-transactions/tests/transaction-settlement-restored-model-oracle.ts create mode 100644 packages/offline-transactions/tests/transaction-settlement-restored-model.test.ts diff --git a/packages/db/tests/converted-properties/adapters.ts b/packages/db/tests/converted-properties/adapters.ts index e9a7db9cf8..03358f69b9 100644 --- a/packages/db/tests/converted-properties/adapters.ts +++ b/packages/db/tests/converted-properties/adapters.ts @@ -5,6 +5,37 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [ + // query-db-collection (refetch) + // query-db-collection (rest) + // electric-db-collection + // convex-db-collection and db-adapter-conformance + // offline-transactions + `offline.serializer.mutation-wire-cleanup`, + `offline.settlement.restored`, + `offline.settlement.started-peer`, + `offline.connectivity-replay`, +] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + // query-db-collection (refetch) + // query-db-collection (rest) + // electric-db-collection + // convex-db-collection and db-adapter-conformance + // offline-transactions + [ + `offline-transactions/tests/transaction-serializer-oracle.property.test.ts`, + `offline`, + `serializer.mutation-wire-cleanup`, + ], + [ + `offline-transactions/tests/transaction-settlement-oracle.property.test.ts`, + `offline`, + `settlement.restored settlement.started-peer`, + ], + [ + `offline-transactions/tests/connectivity-replay-oracle.test.ts`, + `offline`, + `connectivity-replay`, + ], +] diff --git a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts index ac87e11846..3742e747b7 100644 --- a/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts +++ b/packages/offline-transactions/tests/connectivity-replay-oracle.test.ts @@ -1,6 +1,11 @@ -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, vi } from 'vitest' import { NonRetriableError } from '../src/types' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { atOracleCheckpoint, @@ -55,116 +60,142 @@ 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`, { - visibilityState: `hidden`, - addEventListener: (name: string, listener: () => void) => { - if (name === `visibilitychange`) visibilityListeners.add(listener) - }, - removeEventListener: (name: string, listener: () => void) => { - if (name === `visibilitychange`) visibilityListeners.delete(listener) - }, - }) +const signals = [`visibilitychange`, `explicit retry`] as const +type Signal = (typeof signals)[number] - let persisted!: () => void - const persistedPromise = new Promise((resolve) => { - persisted = resolve - }) - class Storage extends FakeStorageAdapter { - override async set(key: string, value: string): Promise { - await super.set(key, value) - persisted() - } - } - const env = createTestOfflineEnvironment({ storage: new Storage() }) - const settlement: { state: `pending` | `fulfilled` | `rejected` } = { - state: `pending`, +// One history: a committed write under a false online hint in a hidden tab, +// then visibility by `signal`. The driver compares the provider calls, outbox +// and caller settlement with expectedAt at the hidden and visible cuts. +async function runVisibleReplay(signal: Signal): Promise { + const visibilityListeners = new Set<() => void>() + vi.stubGlobal(`navigator`, { ...navigator, onLine: false }) + vi.stubGlobal(`document`, { + visibilityState: `hidden`, + addEventListener: (name: string, listener: () => void) => { + if (name === `visibilitychange`) visibilityListeners.add(listener) + }, + removeEventListener: (name: string, listener: () => void) => { + if (name === `visibilitychange`) visibilityListeners.delete(listener) + }, + }) + + let persisted!: () => void + const persistedPromise = new Promise((resolve) => { + persisted = resolve + }) + class Storage extends FakeStorageAdapter { + override async set(key: string, value: string): Promise { + await super.set(key, value) + persisted() } - let commitObserved: Promise | undefined - let transactionId = `` - let hasPrimaryFailure = false - try { - await env.waitForLeader() - const transaction = env.executor.createOfflineTransaction({ - mutationFnName: env.mutationFnName, - autoCommit: false, - }) - transactionId = transaction.id - transaction.mutate(() => { - env.collection.insert({ - id: `saved`, - value: `local`, - completed: false, - updatedAt: new Date(0), - }) + } + const env = createTestOfflineEnvironment({ storage: new Storage() }) + const settlement: { state: `pending` | `fulfilled` | `rejected` } = { + state: `pending`, + } + let commitObserved: Promise | undefined + let transactionId = `` + let hasPrimaryFailure = false + try { + await env.waitForLeader() + const transaction = env.executor.createOfflineTransaction({ + mutationFnName: env.mutationFnName, + autoCommit: false, + }) + transactionId = transaction.id + transaction.mutate(() => { + env.collection.insert({ + id: `saved`, + value: `local`, + completed: false, + updatedAt: new Date(0), }) - commitObserved = transaction.commit().then( - () => { - settlement.state = `fulfilled` - }, - () => { - settlement.state = `rejected` - }, - ) + }) + commitObserved = transaction.commit().then( + () => { + settlement.state = `fulfilled` + }, + () => { + settlement.state = `rejected` + }, + ) - await atOracleCheckpoint(persistedPromise, `outbox write persisted`) - const outboxBefore = await env.executor.peekOutbox() - expect(outboxBefore.map(({ id }) => id)).toEqual([transactionId]) - // Reach the queued-work boundary before judging hidden eligibility. - expect(env.executor.getPendingCount()).toBe(1) - expect({ - providerCalls: env.mutationCalls.length, - outboxEntries: outboxBefore.length, - callerState: settlement.state, - }).toEqual(expectedAt(`hidden`)) + await atOracleCheckpoint(persistedPromise, `outbox write persisted`) + const outboxBefore = await env.executor.peekOutbox() + expect(outboxBefore.map(({ id }) => id)).toEqual([transactionId]) + // Reach the queued-work boundary before judging hidden eligibility. + expect(env.executor.getPendingCount()).toBe(1) + expect({ + providerCalls: env.mutationCalls.length, + outboxEntries: outboxBefore.length, + callerState: settlement.state, + }).toEqual(expectedAt(`hidden`)) - Object.defineProperty(document, `visibilityState`, { value: `visible` }) - if (signal === `visibilitychange`) { - expect(visibilityListeners.size).toBe(1) - for (const listener of visibilityListeners) listener() - } else { - env.executor.getOnlineDetector().notifyOnline() - } + Object.defineProperty(document, `visibilityState`, { value: `visible` }) + if (signal === `visibilitychange`) { + expect(visibilityListeners.size).toBe(1) + for (const listener of visibilityListeners) listener() + } else { + env.executor.getOnlineDetector().notifyOnline() + } - // One microtask boundary lets the event-driven executor reach its - // provider, without waiting for a retry timer or network timeout. - await Promise.resolve() - expect(env.mutationCalls).toHaveLength( - expectedAt(`visible`).providerCalls, - ) - expect( - env.mutationCalls.map(({ idempotencyKey }) => idempotencyKey), - ).toEqual([outboxBefore[0]!.idempotencyKey]) - await atOracleCheckpoint(commitObserved, `visible replay committed`) - const outboxAfter = await env.executor.peekOutbox() - expect({ - providerCalls: env.mutationCalls.length, - outboxEntries: outboxAfter.length, - callerState: settlement.state, - }).toEqual(expectedAt(`visible`)) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - if (settlement.state === `pending` && transactionId) - env.executor.rejectTransaction( - transactionId, - new NonRetriableError(`oracle cleanup`), - ) - await cleanupOfflineOracle( - [ - () => commitObserved, - () => env.executor.dispose(), - () => env.collection.cleanup(), - () => vi.unstubAllGlobals(), - ], - hasPrimaryFailure, + // One microtask boundary lets the event-driven executor reach its + // provider, without waiting for a retry timer or network timeout. + await Promise.resolve() + expect(env.mutationCalls).toHaveLength(expectedAt(`visible`).providerCalls) + expect( + env.mutationCalls.map(({ idempotencyKey }) => idempotencyKey), + ).toEqual([outboxBefore[0]!.idempotencyKey]) + await atOracleCheckpoint(commitObserved, `visible replay committed`) + const outboxAfter = await env.executor.peekOutbox() + expect({ + providerCalls: env.mutationCalls.length, + outboxEntries: outboxAfter.length, + callerState: settlement.state, + }).toEqual(expectedAt(`visible`)) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + if (settlement.state === `pending` && transactionId) + env.executor.rejectTransaction( + transactionId, + new NonRetriableError(`oracle cleanup`), ) - } + await cleanupOfflineOracle( + [ + () => commitObserved, + () => env.executor.dispose(), + () => env.collection.cleanup(), + () => vi.unstubAllGlobals(), + ], + hasPrimaryFailure, + ) + } +} + +oracleProperty({ + id: `offline.connectivity-replay`, + law: { + statement: `An elected web executor attempts persisted work once its tab is visible, even under a false browser online hint; a hidden tab with the same hint does not attempt it, and provider success removes the durable row and settles the caller.`, + authority: `The contract stated at the top of this file, recorded for web connectivity replay in docs/contributing/oracle-coverage.md and oracle-reviews/pr-1879-visible-replay.md.`, + limits: `One elected executor, one write, controlled browser globals and fake storage; no actual browser network detection, offline retry timing, multi-tab leadership transfer or React Native behavior. Exactly-once network execution is not promised.`, }, -) + // What this oracle owns: the gap report judges these entries, and their + // reference trees within the package, by this file's tests. + owns: [ + `src/OfflineExecutor.ts#OfflineExecutor.getOnlineDetector`, + `src/OfflineExecutor.ts#OfflineExecutor.createOfflineTransaction`, + `src/OfflineExecutor.ts#OfflineExecutor.peekOutbox`, + ], + histories: signals, + label: (signal) => + `replays a committed outbox write on ${signal} in a visible tab with a false online hint`, + resolve: (signal) => ({ signal }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`visible replay`, plan, observe, () => + runVisibleReplay(plan.signal), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/offline-transactions/tests/leadership-replay-ledger-model-oracle.ts b/packages/offline-transactions/tests/leadership-replay-ledger-model-oracle.ts new file mode 100644 index 0000000000..c75a86df65 --- /dev/null +++ b/packages/offline-transactions/tests/leadership-replay-ledger-model-oracle.ts @@ -0,0 +1,33 @@ +// The durable replay ledger model: a fold of durable facts that the +// leadership replay oracle compares production with, and that +// leadership-replay-ledger-model.test.ts checks against its authority. This +// module registers no tests. +export type ReplayLedgerEvent = + | { type: `stored`; id: string } + | { type: `read-captured`; ids: Array } + | { type: `durably-removed`; id: string } + | { type: `read-delivered` } + +// The model records durable facts instead of copying OutboxManager's version +// fences. A stale captured value cannot undo a later durable removal. These +// fixtures use FakeStorageAdapter and therefore establish executor/outbox +// behavior, not IndexedDB, LocalStorage, or native transaction completion. +export function foldReplayLedger(events: ReadonlyArray): { + unfinished: Array + staleCapture: Array +} { + const stored = new Set() + const removed = new Set() + let staleCapture: Array = [] + + for (const event of events) { + if (event.type === `stored`) stored.add(event.id) + else if (event.type === `durably-removed`) removed.add(event.id) + else if (event.type === `read-captured`) staleCapture = [...event.ids] + } + + return { + unfinished: Array.from(stored).filter((id) => !removed.has(id)), + staleCapture, + } +} diff --git a/packages/offline-transactions/tests/leadership-replay-ledger-model.test.ts b/packages/offline-transactions/tests/leadership-replay-ledger-model.test.ts new file mode 100644 index 0000000000..e884363cd5 --- /dev/null +++ b/packages/offline-transactions/tests/leadership-replay-ledger-model.test.ts @@ -0,0 +1,21 @@ +// A model rule of the durable replay ledger, checked against its authority +// without production. The leadership replay oracle in +// leadership-replay-oracle.property.test.ts compares production with the +// same fold. +import { expect, it } from 'vitest' +import { foldReplayLedger } from './leadership-replay-ledger-model-oracle' +import type { ReplayLedgerEvent } from './leadership-replay-ledger-model-oracle' + +it(`append-only replay judgment rejects stale admission after durable removal`, () => { + const ledger: Array = [ + { type: `stored`, id: `removed` }, + { type: `stored`, id: `peer` }, + { type: `read-captured`, ids: [`removed`, `peer`] }, + { type: `durably-removed`, id: `removed` }, + { type: `read-delivered` }, + ] + const expected = foldReplayLedger(ledger) + + expect(expected.unfinished).toEqual([`peer`]) + expect(expected.staleCapture).not.toEqual(expected.unfinished) +}) 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 3f1d1da40b..3db6fdb66c 100644 --- a/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay-oracle.property.test.ts @@ -1,11 +1,10 @@ import { isDeepStrictEqual } from 'node:util' import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { OutboxManager } from '../src/outbox/OutboxManager' import { OfflineExecutor } from '../src/OfflineExecutor' import { TransactionExecutor } from '../src/executor/TransactionExecutor' -import { oracleRoute } from '../../db/tests/oracle-config' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { FakeStorageAdapter, @@ -13,6 +12,7 @@ import { gate, turn, } from './harness' +import { foldReplayLedger } from './leadership-replay-ledger-model-oracle' import { storedTransaction } from './leadership-replay-support' import { atOracleCheckpoint, @@ -21,6 +21,7 @@ import { } from './oracle-lifecycle' import type { Observation, Verdict } from '../../db/tests/oracle-property' import type { OfflineTransaction } from '../src/types' +import type { ReplayLedgerEvent } from './leadership-replay-ledger-model-oracle' /** * # May leadership replay an offline transaction more than once, or report @@ -90,36 +91,6 @@ afterEach(() => { releaseClock?.() }) -type ReplayLedgerEvent = - | { type: `stored`; id: string } - | { type: `read-captured`; ids: Array } - | { type: `durably-removed`; id: string } - | { type: `read-delivered` } - -// The model records durable facts instead of copying OutboxManager's version -// fences. A stale captured value cannot undo a later durable removal. These -// fixtures use FakeStorageAdapter and therefore establish executor/outbox -// behavior, not IndexedDB, LocalStorage, or native transaction completion. -function foldReplayLedger(events: ReadonlyArray): { - unfinished: Array - staleCapture: Array -} { - const stored = new Set() - const removed = new Set() - let staleCapture: Array = [] - - for (const event of events) { - if (event.type === `stored`) stored.add(event.id) - else if (event.type === `durably-removed`) removed.add(event.id) - else if (event.type === `read-captured`) staleCapture = [...event.ids] - } - - return { - unfinished: Array.from(stored).filter((id) => !removed.has(id)), - staleCapture, - } -} - // Each campaign below is its own replay property, and a path belongs to one of // them. To replay a shrink path, set TANSTACK_DB_ORACLE_SEED, // TANSTACK_DB_ORACLE_PATH, and TANSTACK_DB_ORACLE_PROPERTY to that campaign's @@ -948,21 +919,6 @@ oracleProperty({ }, }) -it(`append-only replay judgment rejects stale admission after durable removal`, () => { - oracleRoute(`case`) - const ledger: Array = [ - { type: `stored`, id: `removed` }, - { type: `stored`, id: `peer` }, - { type: `read-captured`, ids: [`removed`, `peer`] }, - { type: `durably-removed`, id: `removed` }, - { type: `read-delivered` }, - ] - const expected = foldReplayLedger(ledger) - - expect(expected.unfinished).toEqual([`peer`]) - expect(expected.staleCapture).not.toEqual(expected.unfinished) -}) - const delayedReadArbitrary = fc.record({ peers: fc.integer({ min: 1, max: 4 }), scans: fc.integer({ min: 1, max: 3 }), 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 85bb82b6c9..8ec182dc16 100644 --- a/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer-oracle.property.test.ts @@ -1,16 +1,16 @@ import { types as nativeTypes } from 'node:util' import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { OutboxManager } from '../src/outbox/OutboxManager' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' -import { oracleRoute } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from '../../db/tests/oracle-assertion-adapter' import { cleanupOfflineOracle } from './oracle-lifecycle' import { FakeStorageAdapter } from './harness' @@ -86,25 +86,13 @@ import type { * Retained-storage checks use FakeStorageAdapter, not browser storage. */ -it(`preserves marker-shaped user data through current wire encoding`, async () => { - oracleRoute(`case`) - const runtime = { - __type: `Temporal`, - type: `Temporal.PlainDate`, - value: `2026-09-16`, - } - await checkRoundtrip( - [ - { - kind: `insert`, - slot: 0, - before: twin, - after: { runtime, wire: objectWire(runtime) }, - }, - ], - 0, - ) -}) +// A marker-shaped user object is data, not a Temporal marker: the current +// wire escapes it once, and the restored operand is the same object. +const markerShapedRuntime = { + __type: `Temporal`, + type: `Temporal.PlainDate`, + value: `2026-09-16`, +} // Roundtrips generate valid envelopes. Corrupted wire must be rejected before // it can replace any mutation field with an invented empty object. @@ -114,6 +102,13 @@ describe.each([`modified`, `original`, `changes`] as const)( (field) => { oracleProperty({ id: `offline.serializer.malformed-object`, + 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`, + ], law: { ...serializerLaw, statement: `Malformed escaped Object markers reject before replacing any mutation field.`, @@ -193,8 +188,52 @@ describe.each([`modified`, `original`, `changes`] as const)( }) }, ) +// Each encoder and decoder fault, crossed with the boundary it is injected at. +const mutationWireFaults = ( + [ + `date-as-string`, + `string-as-date`, + `wrong-registry`, + `omit-changes`, + `unknown-encoding`, + ] as const +).flatMap((fault) => + ([`encoder`, `decoder`] as const).map((boundary) => ({ fault, boundary })), +) +/** A mutation wire history's edits and the clock time it runs at. */ +type MutationWirePlan = { edits: Array; time: number } +/** Whether a calibration's old Vitest matchers accept a rejection. */ +function matches(assertions: () => void): boolean { + try { + assertions() + return true + } catch { + return false + } +} +const asError = (failure: unknown): Error => + failure instanceof Error ? failure : new Error(String(failure)) +/** A rejection that the old matchers did not accept stays a driver error: a + * new, unmarked error, so no comparison checkpoint inside it counts. */ +const unaccepted = (mutant: string, failure: unknown): Error => + new Error( + `${mutant} failed in a way that its calibration does not accept: ${String(failure)}`, + ) + +// The check runs one history through production and compares every checkpoint +// with the semantic model in transaction-serializer-oracle.ts. Generated +// properties, fixed histories, and the fault calibrations all reach it. What +// this oracle owns: the gap report judges these entries, and their reference +// trees within the package, by this file's tests. oracleProperty({ id: MUTATION_WIRE_PROPERTY, + 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`, + ], law: { ...serializerLaw, statement: `Mutation wire and restored operands preserve their authored meaning across application recreation.`, @@ -208,10 +247,34 @@ oracleProperty({ runs: 100, fixedSeeds: [20260914], fixed: [ + // The fault calibrations below run on the first named history whose + // clean trace passes, so this one comes first: it is the authored + // history at time zero that each fault calibration corrupts. + { + name: `all edit kinds at time zero`, + history: encodeHistory([fixedHistory, 0]), + }, { name: `all edit kinds and hostile marker values`, history: encodeHistory([fixedHistory, 1704067200000]), }, + { + name: `preserves marker-shaped user data through current wire encoding`, + history: encodeHistory([ + [ + { + kind: `insert`, + slot: 0, + before: twin, + after: { + runtime: markerShapedRuntime, + wire: objectWire(markerShapedRuntime), + }, + }, + ], + 0, + ]), + }, ], }, resolve: (history) => { @@ -224,31 +287,141 @@ oracleProperty({ checkRoundtrip(plan.edits, plan.time), ), check: forwardAssertionJudgment, + calibrations: mutationWireFaults.map(({ fault, boundary }) => ({ + name: `rejects the ${fault} ${boundary} mutant at its own boundary`, + law: `mutation wire meaning`, + driver: (plan: MutationWirePlan, observe) => + recordAssertionJudgment( + `mutation wire meaning`, + plan, + observe, + async () => { + const decode = vi.spyOn( + TransactionSerializer.prototype, + `deserialize`, + ) + try { + const expectedFailure = + boundary === `encoder` || + (fault !== `wrong-registry` && fault !== `unknown-encoding`) + ? { name: `AssertionError` } + : { + message: + fault === `wrong-registry` + ? `Collection with id writer:0 not found` + : `Unsupported transaction value encoding: 5`, + } + const failure = await checkRoundtrip( + plan.edits, + plan.time, + fault, + boundary, + ).then( + () => undefined, + (error: unknown) => error ?? new Error(String(error)), + ) + if (failure === undefined) return + // Exactly the old matchers: only this rejection, at this boundary, + // counts as the check rejecting the mutant. + if ( + matches(() => { + expect(failure).toMatchObject(expectedFailure) + expect(decode).toHaveBeenCalledTimes( + boundary === `encoder` ? 0 : 1, + ) + }) + ) + rejectAt(`${fault} ${boundary} mutant`, asError(failure)) + throw unaccepted(`the ${fault} ${boundary} mutant`, failure) + } finally { + decode.mockRestore() + } + }, + ), + })), }) -it(`retains the wire mismatch when cleanup also fails`, async () => { - oracleRoute(`calibration`) - let failure: unknown - try { - await checkRoundtrip( - [fixedHistory[0]!], - 0, - `omit-changes`, - `encoder`, - false, - false, - true, - ) - } catch (error) { - failure = error - } - expect(failure).toBeInstanceOf(AggregateError) - const aggregate = failure as AggregateError - expect(aggregate.cause).toMatchObject({ name: `AssertionError` }) - expect(String(aggregate.cause)).toContain(`mutation wire checkpoint`) - expect(aggregate.errors).toEqual([ - new Error(`injected serializer cleanup failure`), - ]) +// The cleanup-failure calibration corrupts exactly the first authored edit at +// time zero, so that history runs as its own enumerated case through the same +// driver and comparison as the generated campaign above. +const cleanupCalibrationHistories = [ + { + name: `the first authored edit at time zero`, + history: encodeHistory([[fixedHistory[0]!], 0]), + }, +] +oracleProperty({ + id: `offline.serializer.mutation-wire-cleanup`, + law: { + ...serializerLaw, + statement: `Mutation wire and restored operands preserve their authored meaning across application recreation, and a comparison failure survives a cleanup failure.`, + limits: `The first authored edit at time zero, which the cleanup-failure calibration corrupts; it runs through the same driver and comparison as offline.serializer.mutation-wire.`, + }, + 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`, + ], + histories: cleanupCalibrationHistories, + label: ({ name }) => name, + resolve: ({ history }) => { + const [edits, time] = decodeHistory<[Array, number]>(history) + return { edits, time } + }, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`mutation wire meaning`, plan, observe, () => + checkRoundtrip(plan.edits, plan.time), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `retains the wire mismatch when cleanup also fails`, + law: `mutation wire meaning`, + driver: (plan: MutationWirePlan, observe) => + recordAssertionJudgment( + `mutation wire meaning`, + plan, + observe, + async () => { + const failure = await checkRoundtrip( + plan.edits, + plan.time, + `omit-changes`, + `encoder`, + false, + false, + true, + ).then( + () => undefined, + (error: unknown) => error ?? new Error(String(error)), + ) + if (failure === undefined) return + // Exactly the old matchers: the aggregate keeps the wire mismatch + // as its cause and the cleanup failure as its only error. + if ( + matches(() => { + expect(failure).toBeInstanceOf(AggregateError) + const aggregate = failure as AggregateError + expect(aggregate.cause).toMatchObject({ + name: `AssertionError`, + }) + expect(String(aggregate.cause)).toContain( + `mutation wire checkpoint`, + ) + expect(aggregate.errors).toEqual([ + new Error(`injected serializer cleanup failure`), + ]) + }) + ) + rejectAt(`mutation wire cleanup failure`, asError(failure)) + throw unaccepted(`the cleanup-failure mutant`, failure) + }, + ), + }, + ], }) const legacyEdits = fc.array( @@ -290,6 +463,13 @@ describe.each([ ])(`compatible wire $id`, ({ id, seed, legacy, arbitrary, fixed }) => { oracleProperty({ id, + 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`, + ], law: { ...serializerLaw, statement: `Compatible unversioned and version-two wire preserves authored mutation meaning.`, @@ -331,47 +511,6 @@ describe.each([ }) }) -it.each( - ( - [ - `date-as-string`, - `string-as-date`, - `wrong-registry`, - `omit-changes`, - `unknown-encoding`, - ] as const - ).flatMap((fault) => - ([`encoder`, `decoder`] as const).map((boundary) => ({ - fault, - boundary, - })), - ), -)( - `rejects the $fault $boundary mutant at its own boundary`, - async ({ fault, boundary }) => { - oracleRoute(`calibration`) - const decode = vi.spyOn(TransactionSerializer.prototype, `deserialize`) - try { - const expectedFailure = - boundary === `encoder` || - (fault !== `wrong-registry` && fault !== `unknown-encoding`) - ? { name: `AssertionError` } - : { - message: - fault === `wrong-registry` - ? `Collection with id writer:0 not found` - : `Unsupported transaction value encoding: 5`, - } - await expect( - checkRoundtrip(fixedHistory, 0, fault, boundary), - ).rejects.toMatchObject(expectedFailure) - expect(decode).toHaveBeenCalledTimes(boundary === `encoder` ? 0 : 1) - } finally { - decode.mockRestore() - } - }, -) - /** * Opt-in mutation codecs own their mutation-value domain independently of the * default JSON/Date/Temporal encoding above. The wire records the codec ID and @@ -754,6 +893,13 @@ const codecConstructions = [ ] oracleProperty({ id: `offline.serializer.mutation-codec`, + 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`, + ], law: { ...serializerLaw, statement: `Opt-in codecs preserve complete supplied operands and unreadable records leave durable work unchanged.`, 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 9157bc66ae..88058475f1 100644 --- a/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts @@ -1,17 +1,8 @@ import fc from 'fast-check' import { createCollection, createTransaction } from '@tanstack/db' -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect, vi } from 'vitest' import { NonRetriableError } from '../src/types' import { OutboxManager } from '../src/outbox/OutboxManager' -import { oracleRoute } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -31,7 +22,19 @@ import { cleanupOfflineOracle, useControlledClock, } from './oracle-lifecycle' +import { + checkRestoredState, + expectLocalRows, + expectedRestoredState, + localRows, + restoredRows, +} from './transaction-settlement-restored-model-oracle' import type { TestItem } from './harness' +import type { + RestoredHistory, + RestoredObservation, + RestoredSnapshot, +} from './transaction-settlement-restored-model-oracle' import type { Transaction } from '@tanstack/db' import type { OfflineConfig } from '../src/types' @@ -194,224 +197,6 @@ const settlementLaw = { } type ArbitraryValue = T extends fc.Arbitrary ? TInput : never -const localRows = (rows: Iterable) => - [...rows] - .map(({ id, value, completed, updatedAt }) => ({ - id, - value, - completed, - updatedAt, - })) - .sort((a, b) => a.id.localeCompare(b.id)) - -function expectLocalRows( - actual: Iterable, - expected: Iterable, -) { - oracleAssertion(`independent settlement checkpoint`, () => - expect(localRows(actual)).toEqual(localRows(expected)), - ) -} - -/** Restored snapshots have a different lifetime from durable outbox work. - * A core rollback cancels conflicting pending snapshots, but their independently - * admitted outbox entries still execute and settle from their own providers. - * A cancelled snapshot stays failed even if its outbox entry later succeeds. - * Authority: core rollback/admission and the README's per-ID durable settlement. - * This stateless prefix fold uses authored keys, whole rows and provider results; - * it does not consult executor queues, restoration maps or actual Collection rows. - */ -type RestoredHistory = { - layout: `shared` | `disjoint` | `chain` - width: number - succeeds: ReadonlyArray -} -function restoredRows(history: RestoredHistory): Array> { - // Alternating field edits preserve the old insert/value-edit/flag-edit - // history. In particular, the flag-only snapshot carries the prior value. - // Its later success must still send that whole snapshot after sibling failure. - const authored = new Map() - return history.succeeds.map((_success, index) => - Array.from({ length: history.width }, (_unused, slot) => { - const id = - history.layout === `shared` - ? `key-${slot}` - : history.layout === `chain` - ? `key-${index + slot}` - : `key-${index}-${slot}` - const before = authored.get(id) - const row = { - id, - value: before - ? index % 2 === 1 - ? `edited-${index}-${id}` - : before.value - : `initial-${id}`, - completed: before ? (index % 2 === 0 ? true : before.completed) : false, - updatedAt: new Date(1700000000000), - } - authored.set(id, row) - return row - }), - ) -} - -type RestoredSnapshot = { rows: Array; pending: Array } -function restoredSnapshot( - rows: ReadonlyArray>, - server: ReadonlyMap, - cancelled: ReadonlySet, - completed: number, -): RestoredSnapshot { - const local = new Map(server) - const pending = new Set() - for (let index = completed + 1; index < rows.length; index++) - if (!cancelled.has(index)) - for (const row of rows[index]!) { - local.set(row.id, row) - pending.add(row.id) - } - return { rows: [...local.values()], pending: [...pending].sort() } -} -type RestoredObservation = RestoredSnapshot & { - waiters: Array - server: Map - outcomes: Array - states: Array - calls: Array<{ id: string; rows: Array }> - outbox: Array - errors: Array> - warnings: Array> - unhandled: Array -} -function expectedRestoredState( - history: RestoredHistory, - failures: ReadonlyArray, - completed: number, -): RestoredObservation { - const rows = restoredRows(history) - const server = new Map() - const cancelled = new Set() - const waiters: RestoredObservation[`waiters`] = rows.map(() => undefined) - for (let index = 0; index <= completed; index++) { - if (history.succeeds[index]) { - for (const row of rows[index]!) server.set(row.id, row) - } else if (!cancelled.has(index)) { - cancelled.add(index) - const keys = new Set(rows[index]!.map((row) => row.id)) - for (let peer = index + 1; peer < rows.length; peer++) - if (rows[peer]!.some((row) => keys.has(row.id))) cancelled.add(peer) - } - waiters[index] = restoredSnapshot(rows, server, cancelled, index) - } - return { - ...restoredSnapshot(rows, server, cancelled, completed), - waiters, - server, - outcomes: history.succeeds.map((success, index) => - index > completed ? `pending` : success ? `fulfilled` : failures[index], - ), - states: history.succeeds.map((_success, index) => - cancelled.has(index) - ? `failed` - : index <= completed - ? `completed` - : `pending`, - ), - calls: rows - .slice(0, Math.min(completed + 2, rows.length)) - .map((batch, index) => ({ id: `restored-${index}`, rows: batch })), - outbox: rows - .slice(completed + 1) - .map((_row, offset) => `restored-${completed + 1 + offset}`), - errors: [], - warnings: [], - unhandled: [], - } -} -function checkRestoredState( - actual: RestoredObservation, - expected: RestoredObservation, -): void { - expectLocalRows(actual.rows, expected.rows) - oracleAssertion(`independent settlement checkpoint`, () => - expect( - actual.pending, - `restored snapshot pending-write contribution`, - ).toEqual(expected.pending), - ) - for (const [index, snapshot] of expected.waiters.entries()) { - oracleAssertion(`independent settlement checkpoint`, () => - expect( - Boolean(actual.waiters[index]), - `waiter snapshot presence ${index}`, - ).toBe(Boolean(snapshot)), - ) - if (snapshot) { - expectLocalRows(actual.waiters[index]!.rows, snapshot.rows) - oracleAssertion(`independent settlement checkpoint`, () => - expect( - actual.waiters[index]!.pending, - `pending writes at waiter ${index}`, - ).toEqual(snapshot.pending), - ) - } - } - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.server).toEqual(expected.server), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.outcomes).toEqual(expected.outcomes), - ) - for (const [index, outcome] of expected.outcomes.entries()) - if (outcome instanceof Error) - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.outcomes[index]).toBe(outcome), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.states).toEqual(expected.states), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.calls).toEqual(expected.calls), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.outbox).toEqual(expected.outbox), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect( - actual.errors, - `restoration must not log an executor/cleanup failure`, - ).toEqual(expected.errors), - ) - // An intentional permanent provider rejection may be reported as a warning. - // Its exact authored Error is allowed; a new cleanup/executor exception is - // not. Check reasons, without prescribing log text or duplicate-report policy. - const allowed = expected.outcomes.filter( - (outcome) => outcome instanceof Error, - ) - for (const warning of actual.warnings) { - const reasons = warning.filter((value) => value instanceof Error) - oracleAssertion(`independent settlement checkpoint`, () => - expect( - reasons.length, - `warning must identify a known provider failure`, - ).toBeGreaterThan(0), - ) - for (const reason of reasons) - oracleAssertion(`independent settlement checkpoint`, () => - expect( - allowed.some((failure) => failure === reason), - `unexpected restoration warning: ${String(reason)}`, - ).toBe(true), - ) - } - oracleAssertion(`independent settlement checkpoint`, () => - expect(actual.unhandled, `restoration unhandled rejections`).toEqual( - expected.unhandled, - ), - ) -} - /** * How a generated transaction commits: by `commit()`, by auto-commit with the * option set or omitted, or as an `OfflineAction`, which always auto-commits. @@ -1110,6 +895,192 @@ const restoredHistories: Array = [2, 3].flatMap((count) => ) const restoredHistoryId = (history: RestoredHistory) => `${history.layout}/${history.width}/${history.succeeds.map(Number).join(``)}` +// One enumerated restored history through production, compared with the +// prefix fold at every provider completion. Each reached checkpoint is added +// to `restoredReached`, which the describe block below checks after all runs. +async function runRestoredHistory( + history: RestoredHistory, + restoredReached: Set, +): Promise { + const rows = restoredRows(history) + const storage = new FakeStorageAdapter() + const seed = createCollection({ + id: `test-items`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (ops) => { + ops.markReady() + }, + }, + }) + const outbox = new OutboxManager(storage, { [seed.id]: seed }) + const rollbackSeeds: Array<() => void> = [] + let seedFailed = false + try { + for (const [index, batch] of rows.entries()) { + const tx = createTransaction({ + autoCommit: false, + mutationFn: () => Promise.resolve(), + }) + rollbackSeeds.push(() => { + tx.rollback({ isSecondaryRollback: true }) + }) + void tx.when(`settled`).catch(() => {}) + tx.mutate(() => { + for (const row of batch) + if (seed.has(row.id)) + seed.update(row.id, (draft) => { + Object.assign(draft, row) + }) + else seed.insert(row) + }) + await outbox.add({ + id: `restored-${index}`, + mutationFnName: `syncData`, + mutations: tx.mutations, + keys: tx.mutations.map(({ globalKey }) => globalKey), + idempotencyKey: `restore-${index}`, + createdAt: new Date(1700000000000 + index), + retryCount: 0, + nextAttemptAt: 0, + version: 1, + }) + } + } catch (error) { + seedFailed = true + throw error + } finally { + await cleanupOfflineOracle( + [...rollbackSeeds, () => seed.cleanup()], + seedFailed, + ) + } + const entered = rows.map(() => gate()) + const released = rows.map(() => gate()) + const failures = rows.map( + (_row, index) => + new NonRetriableError(`restored permanent failure ${index}`), + ) + const outcomes: Array = rows.map(() => `pending`) + const waits: Array> = [] + const calls: RestoredObservation[`calls`] = [] + const logged = vi.spyOn(console, `error`).mockImplementation(() => {}) + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const { rejections: unhandled, cleanup: cleanupUnhandled } = + captureUnhandledRejections() + const env = createTestOfflineEnvironment({ + storage, + mutationFn: async (params) => { + const index = calls.length + const mutations = params.transaction.mutations + calls.push({ + id: params.transaction.id, + rows: mutations.map((mutation) => structuredClone(mutation.modified)), + }) + if (!entered[index]) throw new NonRetriableError(`unexpected replay`) + entered[index].resolve() + await released[index]!.promise + if (!history.succeeds[index]) throw failures[index] + env.applyMutations(mutations) + }, + }) + const registrations = vi.spyOn(env.executor, `registerRestorationTransaction`) + const waiterSnapshots: RestoredObservation[`waiters`] = rows.map( + () => undefined, + ) + const readSnapshot = (): RestoredSnapshot => { + const current = env.collection.toArray + return { + rows: localRows(current), + pending: current + .filter((row) => row.$hasPendingWrites) + .map((row) => row.id) + .sort(), + } + } + let hasPrimaryFailure = false + try { + await env.waitForLeader() + oracleAssertion(`independent settlement checkpoint`, () => + expect(registrations.mock.calls.map(([id]) => id)).toEqual( + rows.map((_row, index) => `restored-${index}`), + ), + ) + const restored = new Map(registrations.mock.calls) + oracleAssertion(`independent settlement checkpoint`, () => + expect([...restored.keys()]).toEqual( + rows.map((_row, index) => `restored-${index}`), + ), + ) + restoredReached.add(`${restoredHistoryId(history)}/registered`) + for (const index of rows.keys()) + waits.push( + env.executor.waitForTransactionCompletion(`restored-${index}`).then( + () => { + outcomes[index] = `fulfilled` + waiterSnapshots[index] = readSnapshot() + }, + (error: unknown) => { + outcomes[index] = error + waiterSnapshots[index] = readSnapshot() + }, + ), + ) + const check = async (completed: number) => + checkRestoredState( + { + ...readSnapshot(), + waiters: [...waiterSnapshots], + server: env.serverState, + outcomes: [...outcomes], + states: rows.map( + (_row, index) => restored.get(`restored-${index}`)!.state, + ), + calls: structuredClone(calls), + outbox: (await env.executor.peekOutbox()).map(({ id }) => id), + errors: [...logged.mock.calls], + warnings: [...warning.mock.calls], + unhandled: [...unhandled], + }, + expectedRestoredState(history, failures, completed), + ) + await atOracleCheckpoint(entered[0]!.promise, `restored provider entered`) + await check(-1) + for (const index of rows.keys()) { + released[index]!.resolve() + await atOracleCheckpoint(waits[index]!, `restored ${index} settled`) + if (index < rows.length - 1) + await atOracleCheckpoint( + entered[index + 1]!.promise, + `restored sibling still held`, + ) + await turn() + restoredReached.add(`${restoredHistoryId(history)}/settled-${index}`) + await check(index) + } + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + released.forEach((item) => item.resolve()) + registrations.mockRestore() + logged.mockRestore() + warning.mockRestore() + try { + await cleanupOfflineOracle( + [ + () => Promise.all(waits), + () => env.executor.dispose(), + () => env.collection.cleanup(), + ], + hasPrimaryFailure, + ) + } finally { + cleanupUnhandled() + } + } +} describe(`restored snapshots and independent outbox settlement`, () => { const restoredRequired = restoredHistories.flatMap((history) => [ `${restoredHistoryId(history)}/registered`, @@ -1123,246 +1094,36 @@ describe(`restored snapshots and independent outbox settlement`, () => { expect([...restoredReached].sort()).toEqual([...restoredRequired].sort()), ) }) - it.each(restoredHistories)( - `restores whole snapshots through independent outcomes: %j`, - async (history) => { - // An enumerated history: a reproducible route for the gap report. - oracleRoute(`case`) - const rows = restoredRows(history) - const storage = new FakeStorageAdapter() - const seed = createCollection({ - id: `test-items`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: (ops) => { - ops.markReady() - }, - }, - }) - const outbox = new OutboxManager(storage, { [seed.id]: seed }) - const rollbackSeeds: Array<() => void> = [] - let seedFailed = false - try { - for (const [index, batch] of rows.entries()) { - const tx = createTransaction({ - autoCommit: false, - mutationFn: () => Promise.resolve(), - }) - rollbackSeeds.push(() => { - tx.rollback({ isSecondaryRollback: true }) - }) - void tx.when(`settled`).catch(() => {}) - tx.mutate(() => { - for (const row of batch) - if (seed.has(row.id)) - seed.update(row.id, (draft) => { - Object.assign(draft, row) - }) - else seed.insert(row) - }) - await outbox.add({ - id: `restored-${index}`, - mutationFnName: `syncData`, - mutations: tx.mutations, - keys: tx.mutations.map(({ globalKey }) => globalKey), - idempotencyKey: `restore-${index}`, - createdAt: new Date(1700000000000 + index), - retryCount: 0, - nextAttemptAt: 0, - version: 1, - }) - } - } catch (error) { - seedFailed = true - throw error - } finally { - await cleanupOfflineOracle( - [...rollbackSeeds, () => seed.cleanup()], - seedFailed, - ) - } - const entered = rows.map(() => gate()) - const released = rows.map(() => gate()) - const failures = rows.map( - (_row, index) => - new NonRetriableError(`restored permanent failure ${index}`), - ) - const outcomes: Array = rows.map(() => `pending`) - const waits: Array> = [] - const calls: RestoredObservation[`calls`] = [] - const logged = vi.spyOn(console, `error`).mockImplementation(() => {}) - const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const { rejections: unhandled, cleanup: cleanupUnhandled } = - captureUnhandledRejections() - const env = createTestOfflineEnvironment({ - storage, - mutationFn: async (params) => { - const index = calls.length - const mutations = params.transaction.mutations - calls.push({ - id: params.transaction.id, - rows: mutations.map((mutation) => - structuredClone(mutation.modified), - ), - }) - if (!entered[index]) throw new NonRetriableError(`unexpected replay`) - entered[index].resolve() - await released[index]!.promise - if (!history.succeeds[index]) throw failures[index] - env.applyMutations(mutations) - }, - }) - const registrations = vi.spyOn( - env.executor, - `registerRestorationTransaction`, - ) - const waiterSnapshots: RestoredObservation[`waiters`] = rows.map( - () => undefined, - ) - const readSnapshot = (): RestoredSnapshot => { - const current = env.collection.toArray - return { - rows: localRows(current), - pending: current - .filter((row) => row.$hasPendingWrites) - .map((row) => row.id) - .sort(), - } - } - let hasPrimaryFailure = false - try { - await env.waitForLeader() - oracleAssertion(`independent settlement checkpoint`, () => - expect(registrations.mock.calls.map(([id]) => id)).toEqual( - rows.map((_row, index) => `restored-${index}`), - ), - ) - const restored = new Map(registrations.mock.calls) - oracleAssertion(`independent settlement checkpoint`, () => - expect([...restored.keys()]).toEqual( - rows.map((_row, index) => `restored-${index}`), - ), - ) - restoredReached.add(`${restoredHistoryId(history)}/registered`) - for (const index of rows.keys()) - waits.push( - env.executor.waitForTransactionCompletion(`restored-${index}`).then( - () => { - outcomes[index] = `fulfilled` - waiterSnapshots[index] = readSnapshot() - }, - (error: unknown) => { - outcomes[index] = error - waiterSnapshots[index] = readSnapshot() - }, - ), - ) - const check = async (completed: number) => - checkRestoredState( - { - ...readSnapshot(), - waiters: [...waiterSnapshots], - server: env.serverState, - outcomes: [...outcomes], - states: rows.map( - (_row, index) => restored.get(`restored-${index}`)!.state, - ), - calls: structuredClone(calls), - outbox: (await env.executor.peekOutbox()).map(({ id }) => id), - errors: [...logged.mock.calls], - warnings: [...warning.mock.calls], - unhandled: [...unhandled], - }, - expectedRestoredState(history, failures, completed), - ) - await atOracleCheckpoint( - entered[0]!.promise, - `restored provider entered`, - ) - await check(-1) - for (const index of rows.keys()) { - released[index]!.resolve() - await atOracleCheckpoint(waits[index]!, `restored ${index} settled`) - if (index < rows.length - 1) - await atOracleCheckpoint( - entered[index + 1]!.promise, - `restored sibling still held`, - ) - await turn() - restoredReached.add(`${restoredHistoryId(history)}/settled-${index}`) - await check(index) - } - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - released.forEach((item) => item.resolve()) - registrations.mockRestore() - logged.mockRestore() - warning.mockRestore() - try { - await cleanupOfflineOracle( - [ - () => Promise.all(waits), - () => env.executor.dispose(), - () => env.collection.cleanup(), - ], - hasPrimaryFailure, - ) - } finally { - cleanupUnhandled() - } - } + oracleProperty({ + id: `offline.settlement.restored`, + law: { + ...settlementLaw, + statement: `Restored snapshots settle through their own independent outbox outcomes: a cancelled snapshot stays failed, and every waiter sees the completed prefix.`, + limits: `The finite restored histories above, through the same production driver and prefix-fold comparison; controlled storage and provider behavior does not establish native durability.`, }, - ) -}) - -// These are checker inputs, not alternate production histories. All expected -// answers come from the same prefix fold as the real restoration receiver. -it.each([ - `extra-error`, - `extra-warning`, - `leaked-pending`, - `stale-waiter`, - `resurrect-failed`, - `early-peer`, - `missing-row`, -] as const)(`rejects a wrong restored observation: %s`, (fault) => { - oracleRoute(`calibration`) - const history: RestoredHistory = { - layout: `shared`, - width: 2, - succeeds: [false, true, true], - } - const failures = history.succeeds.map(() => new Error(`provider failure`)) - const expected = expectedRestoredState(history, failures, 1) - const actual = { - ...expected, - rows: [...expected.rows], - states: [...expected.states], - outcomes: [...expected.outcomes], - } - checkRestoredState(actual, expected) - if (fault === `extra-error`) actual.errors = [[new Error(`cleanup failed`)]] - if (fault === `extra-warning`) - actual.warnings = [ - [`executor failed`, failures[0], new Error(`cleanup failed`)], - ] - if (fault === `leaked-pending`) - actual.pending = actual.rows.map((row) => row.id) - if (fault === `stale-waiter`) - actual.waiters = actual.waiters.map((snapshot) => - snapshot - ? { ...snapshot, pending: snapshot.rows.map((row) => row.id) } - : snapshot, - ) - if (fault === `resurrect-failed`) actual.states[1] = `completed` - if (fault === `early-peer`) actual.outcomes[2] = `fulfilled` - if (fault === `missing-row`) actual.rows.pop() - oracleAssertion(`independent settlement checkpoint`, () => - expect(() => checkRestoredState(actual, expected)).toThrow(), - ) + 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`, + ], + histories: restoredHistories, + label: (history) => + `restores whole snapshots through independent outcomes: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `independent settlement checkpoint`, + plan, + observe, + () => runRestoredHistory(plan.history, restoredReached), + ), + check: forwardAssertionJudgment, + }) }) const retryErrorKinds = [ @@ -1830,7 +1591,7 @@ function expectedStartedPeer( // The driver compares both public promises, Collection rows, exact outbox IDs, // and provider calls before and after the two acknowledgements. A successful // peer must finish in this executor; this is not a fresh-executor replay law. -it.each([ +const startedPeerCases = [ [`throw`, `durable-before`], [`throw`, `late-during-marker`], [`throw`, `visible-before-ack`], @@ -1843,392 +1604,417 @@ it.each([ [`invalid`, `visible-across-cleanup`], [`invalid`, `late-after-cleanup`], [`invalid`, `failure-before-store`], -] 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`) - const providerEntered = gate() - const releaseProvider = gate() - const peerWriteEntered = gate() - const peerRowStored = gate() - const releasePeerWrite = gate() - const peerWriteSettled = gate() - const hookEntered = gate() - const terminalMarkerEntered = gate() - const releaseTerminalMarker = gate() - const peerProviderEntered = gate() - const releasePeerProvider = gate() - const providerCalls: Array = [] - let headId = `` - let peerId = `` - let holdPeerWrite = peerWriteCase !== `durable-before` +] as const +type StartedPeerCase = (typeof startedPeerCases)[number] +async function runStartedPeerCase([ + failureKind, + peerWriteCase, +]: StartedPeerCase): Promise { + const providerError = new Error(`HTTP 401 Unauthorized`) + const hookError = new Error(`retry decision failed`) + const storageError = new Error(`peer outbox write failed`) + const providerEntered = gate() + const releaseProvider = gate() + const peerWriteEntered = gate() + const peerRowStored = gate() + const releasePeerWrite = gate() + const peerWriteSettled = gate() + const hookEntered = gate() + const terminalMarkerEntered = gate() + const releaseTerminalMarker = gate() + const peerProviderEntered = gate() + const releasePeerProvider = gate() + const providerCalls: Array = [] + let headId = `` + let peerId = `` + let holdPeerWrite = peerWriteCase !== `durable-before` - class ControlledStorage extends FakeStorageAdapter { - override async set(key: string, value: string): Promise { + class ControlledStorage extends FakeStorageAdapter { + override async set(key: string, value: string): Promise { + if ( + key === `tx:${headId}` && + (JSON.parse(value) as { outboxPhase?: string }).outboxPhase === + `rejection-pending` + ) { + terminalMarkerEntered.resolve() + await releaseTerminalMarker.promise + } + if (key === `tx:${peerId}`) { + peerWriteEntered.resolve() if ( - key === `tx:${headId}` && - (JSON.parse(value) as { outboxPhase?: string }).outboxPhase === - `rejection-pending` + (peerWriteCase === `visible-before-ack` || + peerWriteCase === `visible-across-cleanup`) && + holdPeerWrite ) { - terminalMarkerEntered.resolve() - await releaseTerminalMarker.promise + await super.set(key, value) + peerRowStored.resolve() + await releasePeerWrite.promise + holdPeerWrite = false + peerWriteSettled.resolve() + return } - if (key === `tx:${peerId}`) { - peerWriteEntered.resolve() - if ( - (peerWriteCase === `visible-before-ack` || - peerWriteCase === `visible-across-cleanup`) && - holdPeerWrite - ) { - await super.set(key, value) - peerRowStored.resolve() - await releasePeerWrite.promise - holdPeerWrite = false + if (holdPeerWrite) { + await releasePeerWrite.promise + holdPeerWrite = false + if (peerWriteCase === `failure-before-store`) { peerWriteSettled.resolve() - return - } - if (holdPeerWrite) { - await releasePeerWrite.promise - holdPeerWrite = false - if (peerWriteCase === `failure-before-store`) { - peerWriteSettled.resolve() - throw storageError - } + throw storageError } } - await super.set(key, value) - if (key === `tx:${peerId}`) { - peerRowStored.resolve() - peerWriteSettled.resolve() - } + } + await super.set(key, value) + if (key === `tx:${peerId}`) { + peerRowStored.resolve() + peerWriteSettled.resolve() } } + } - const storage = new ControlledStorage() - const env = createTestOfflineEnvironment({ - storage, - config: { - shouldRetry: (): boolean | undefined => { - hookEntered.resolve() - if (failureKind === `throw`) throw hookError - return null as unknown as boolean | undefined - }, - }, - mutationFn: async ({ transaction }) => { - providerCalls.push(transaction.id) - if (transaction.id === headId) { - providerEntered.resolve() - await releaseProvider.promise - throw providerError - } - peerProviderEntered.resolve() - await releasePeerProvider.promise - env.applyMutations(transaction.mutations) + const storage = new ControlledStorage() + const env = createTestOfflineEnvironment({ + storage, + config: { + shouldRetry: (): boolean | undefined => { + hookEntered.resolve() + if (failureKind === `throw`) throw hookError + return null as unknown as boolean | undefined }, - }) - const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const errorLog = vi.spyOn(console, `error`).mockImplementation(() => {}) - const observed: Array> = [] - const outcomes = new Map() - let hasPrimaryFailure = false - - function createObservedTransaction(rowId: string, updatedAt: number) { - const tx = env.executor.createOfflineTransaction({ - mutationFnName: env.mutationFnName, - autoCommit: false, - }) - const transaction = tx.mutate(() => - env.collection.insert({ - id: rowId, - value: rowId, - completed: false, - updatedAt: new Date(updatedAt), - }), - ) - const outcome = { - commit: `pending` as unknown, - persisted: `pending` as unknown, + }, + mutationFn: async ({ transaction }) => { + providerCalls.push(transaction.id) + if (transaction.id === headId) { + providerEntered.resolve() + await releaseProvider.promise + throw providerError } - outcomes.set(tx.id, outcome) - const commit = tx.commit().then( - () => { - outcome.commit = `fulfilled` - }, - (error: unknown) => { - outcome.commit = error - }, - ) - const persisted = transaction.when(`settled`).then( - () => { - outcome.persisted = `fulfilled` - }, - (error: unknown) => { - outcome.persisted = error - }, - ) - observed.push(commit, persisted) - return { id: tx.id, commit, persisted, outcome } + peerProviderEntered.resolve() + await releasePeerProvider.promise + env.applyMutations(transaction.mutations) + }, + }) + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const errorLog = vi.spyOn(console, `error`).mockImplementation(() => {}) + const observed: Array> = [] + const outcomes = new Map() + let hasPrimaryFailure = false + + function createObservedTransaction(rowId: string, updatedAt: number) { + const tx = env.executor.createOfflineTransaction({ + mutationFnName: env.mutationFnName, + autoCommit: false, + }) + const transaction = tx.mutate(() => + env.collection.insert({ + id: rowId, + value: rowId, + completed: false, + updatedAt: new Date(updatedAt), + }), + ) + const outcome = { + commit: `pending` as unknown, + persisted: `pending` as unknown, } + outcomes.set(tx.id, outcome) + const commit = tx.commit().then( + () => { + outcome.commit = `fulfilled` + }, + (error: unknown) => { + outcome.commit = error + }, + ) + const persisted = transaction.when(`settled`).then( + () => { + outcome.persisted = `fulfilled` + }, + (error: unknown) => { + outcome.persisted = error + }, + ) + observed.push(commit, persisted) + return { id: tx.id, commit, persisted, outcome } + } - try { - await env.waitForLeader() - const head = createObservedTransaction(`head`, 0) - headId = head.id - await atOracleCheckpoint(providerEntered.promise, `head provider entered`) + try { + await env.waitForLeader() + const head = createObservedTransaction(`head`, 0) + headId = head.id + await atOracleCheckpoint(providerEntered.promise, `head provider entered`) - const peer = createObservedTransaction(`peer`, 1) - peerId = peer.id - await atOracleCheckpoint(peerWriteEntered.promise, `peer write started`) - if (peerWriteCase === `durable-before`) - await atOracleCheckpoint( - peerWriteSettled.promise, - `peer durable before fault`, - ) - if ( - peerWriteCase === `visible-before-ack` || - peerWriteCase === `visible-across-cleanup` + const peer = createObservedTransaction(`peer`, 1) + peerId = peer.id + await atOracleCheckpoint(peerWriteEntered.promise, `peer write started`) + if (peerWriteCase === `durable-before`) + await atOracleCheckpoint( + peerWriteSettled.promise, + `peer durable before fault`, ) - await atOracleCheckpoint( - peerRowStored.promise, - `peer visible before ack`, - ) + if ( + peerWriteCase === `visible-before-ack` || + peerWriteCase === `visible-across-cleanup` + ) + await atOracleCheckpoint(peerRowStored.promise, `peer visible before ack`) - releaseProvider.resolve() - await atOracleCheckpoint(hookEntered.promise, `retry hook failed`) - await atOracleCheckpoint( - terminalMarkerEntered.promise, - `terminal marker held`, + releaseProvider.resolve() + await atOracleCheckpoint(hookEntered.promise, `retry hook failed`) + await atOracleCheckpoint( + terminalMarkerEntered.promise, + `terminal marker held`, + ) + const atFault = expectedStartedPeer( + peerWriteCase === `durable-before` + ? { stored: true, acknowledgement: `fulfilled` } + : peerWriteCase === `visible-before-ack` || + peerWriteCase === `visible-across-cleanup` + ? { stored: true, acknowledgement: `held` } + : { stored: false, acknowledgement: `held` }, + false, + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(head.outcome.commit).toBe(`pending`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.commit).toBe(atFault.caller), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.persisted).toBe(atFault.caller), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(providerCalls).toEqual([head.id]), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(atFault.providerEligible).toBe(false), + ) + { + const oracleAwaitedValue6 = await env.executor.peekOutbox() + oracleAssertion(`independent settlement checkpoint`, () => + expect(oracleAwaitedValue6.map(({ id }) => id)).toEqual( + atFault.visible ? [head.id, peer.id] : [head.id], + ), ) - const atFault = expectedStartedPeer( - peerWriteCase === `durable-before` - ? { stored: true, acknowledgement: `fulfilled` } - : peerWriteCase === `visible-before-ack` || - peerWriteCase === `visible-across-cleanup` + } + oracleAssertion(`independent settlement checkpoint`, () => + expect(env.collection.toArray.map(({ id }) => id)).toEqual([ + `head`, + `peer`, + ]), + ) + + const writeAfterCleanup = + peerWriteCase === `late-after-cleanup` || + peerWriteCase === `visible-across-cleanup` + if (!writeAfterCleanup) { + releasePeerWrite.resolve() + await atOracleCheckpoint(peerWriteSettled.promise, `peer write settled`) + } + const afterWrite = expectedStartedPeer( + peerWriteCase === `failure-before-store` + ? { stored: false, acknowledgement: `rejected` } + : writeAfterCleanup + ? peerWriteCase === `visible-across-cleanup` ? { stored: true, acknowledgement: `held` } - : { stored: false, acknowledgement: `held` }, - false, + : { stored: false, acknowledgement: `held` } + : { stored: true, acknowledgement: `fulfilled` }, + false, + ) + if (peerWriteCase === `failure-before-store`) { + await atOracleCheckpoint(peer.commit, `failed peer commit settled`) + await atOracleCheckpoint( + peer.persisted, + `failed peer persistence settled`, ) oracleAssertion(`independent settlement checkpoint`, () => - expect(head.outcome.commit).toBe(`pending`), + expect(peer.outcome.commit).toBe(storageError), ) oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(atFault.caller), + expect(peer.outcome.persisted).toBe(storageError), ) + } else { + await turn() oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(atFault.caller), + expect(peer.outcome.commit).toBe(afterWrite.caller), ) oracleAssertion(`independent settlement checkpoint`, () => - expect(providerCalls).toEqual([head.id]), + expect(peer.outcome.persisted).toBe(afterWrite.caller), ) + } + oracleAssertion(`independent settlement checkpoint`, () => + expect(afterWrite.providerEligible).toBe(false), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(providerCalls).toEqual([head.id]), + ) + { + const oracleAwaitedValue7 = await env.executor.peekOutbox() oracleAssertion(`independent settlement checkpoint`, () => - expect(atFault.providerEligible).toBe(false), + expect(oracleAwaitedValue7.map(({ id }) => id)).toEqual( + afterWrite.visible ? [head.id, peer.id] : [head.id], + ), ) - { - const oracleAwaitedValue6 = await env.executor.peekOutbox() - oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue6.map(({ id }) => id)).toEqual( - atFault.visible ? [head.id, peer.id] : [head.id], - ), - ) - } + } + + releaseTerminalMarker.resolve() + await atOracleCheckpoint(head.commit, `failed head settled`) + await atOracleCheckpoint(head.persisted, `failed head persistence settled`) + if (failureKind === `throw`) { oracleAssertion(`independent settlement checkpoint`, () => - expect(env.collection.toArray.map(({ id }) => id)).toEqual([ - `head`, - `peer`, - ]), + expect(head.outcome.commit).toBe(hookError), ) - - const writeAfterCleanup = - peerWriteCase === `late-after-cleanup` || - peerWriteCase === `visible-across-cleanup` - if (!writeAfterCleanup) { - releasePeerWrite.resolve() - await atOracleCheckpoint(peerWriteSettled.promise, `peer write settled`) - } - const afterWrite = expectedStartedPeer( - peerWriteCase === `failure-before-store` - ? { stored: false, acknowledgement: `rejected` } - : writeAfterCleanup - ? peerWriteCase === `visible-across-cleanup` - ? { stored: true, acknowledgement: `held` } - : { stored: false, acknowledgement: `held` } - : { stored: true, acknowledgement: `fulfilled` }, - false, + oracleAssertion(`independent settlement checkpoint`, () => + expect(head.outcome.persisted).toBe(hookError), ) - if (peerWriteCase === `failure-before-store`) { - await atOracleCheckpoint(peer.commit, `failed peer commit settled`) - await atOracleCheckpoint( - peer.persisted, - `failed peer persistence settled`, - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(storageError), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(storageError), - ) - } else { - await turn() - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(afterWrite.caller), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(afterWrite.caller), - ) - } + } else { oracleAssertion(`independent settlement checkpoint`, () => - expect(afterWrite.providerEligible).toBe(false), + expect(head.outcome.commit).toBeInstanceOf(TypeError), ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(head.outcome.persisted).toBeInstanceOf(TypeError), + ) + } + + if (writeAfterCleanup) { + // A visible row without write acknowledgement cannot run even after + // the failed head is removed. oracleAssertion(`independent settlement checkpoint`, () => expect(providerCalls).toEqual([head.id]), ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.commit).toBe(`pending`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.persisted).toBe(`pending`), + ) { - const oracleAwaitedValue7 = await env.executor.peekOutbox() + const oracleAwaitedValue8 = await env.executor.peekOutbox() oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue7.map(({ id }) => id)).toEqual( - afterWrite.visible ? [head.id, peer.id] : [head.id], + expect(oracleAwaitedValue8.map(({ id }) => id)).toEqual( + peerWriteCase === `visible-across-cleanup` ? [peer.id] : [], ), ) } - - releaseTerminalMarker.resolve() - await atOracleCheckpoint(head.commit, `failed head settled`) + releasePeerWrite.resolve() await atOracleCheckpoint( - head.persisted, - `failed head persistence settled`, + peerWriteSettled.promise, + `late peer write settled`, ) - if (failureKind === `throw`) { - oracleAssertion(`independent settlement checkpoint`, () => - expect(head.outcome.commit).toBe(hookError), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(head.outcome.persisted).toBe(hookError), - ) - } else { - oracleAssertion(`independent settlement checkpoint`, () => - expect(head.outcome.commit).toBeInstanceOf(TypeError), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(head.outcome.persisted).toBeInstanceOf(TypeError), - ) - } + } - if (writeAfterCleanup) { - // A visible row without write acknowledgement cannot run even after - // the failed head is removed. - oracleAssertion(`independent settlement checkpoint`, () => - expect(providerCalls).toEqual([head.id]), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(`pending`), - ) + if (peerWriteCase === `failure-before-store`) { + oracleAssertion(`independent settlement checkpoint`, () => + expect(providerCalls).toEqual([head.id]), + ) + { + const oracleAwaitedValue9 = await env.executor.peekOutbox() oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(`pending`), - ) - { - const oracleAwaitedValue8 = await env.executor.peekOutbox() - oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue8.map(({ id }) => id)).toEqual( - peerWriteCase === `visible-across-cleanup` ? [peer.id] : [], - ), - ) - } - releasePeerWrite.resolve() - await atOracleCheckpoint( - peerWriteSettled.promise, - `late peer write settled`, + expect(oracleAwaitedValue9).toEqual([]), ) } - - if (peerWriteCase === `failure-before-store`) { - oracleAssertion(`independent settlement checkpoint`, () => - expect(providerCalls).toEqual([head.id]), - ) - { - const oracleAwaitedValue9 = await env.executor.peekOutbox() - oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue9).toEqual([]), - ) - } - oracleAssertion(`independent settlement checkpoint`, () => - expect(env.collection.toArray).toEqual([]), - ) - } else { - const firstPeerEvent = await atOracleCheckpoint( - Promise.race([ - peerProviderEntered.promise.then(() => `provider` as const), - peer.commit.then(() => `settled` as const), - ]), - `peer provider or early settlement`, - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(firstPeerEvent).toBe(`provider`), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(`pending`), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(`pending`), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(providerCalls).toEqual([head.id, peer.id]), - ) - { - const oracleAwaitedValue10 = await env.executor.peekOutbox() - oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue10.map(({ id }) => id)).toEqual([peer.id]), - ) - } - releasePeerProvider.resolve() - await atOracleCheckpoint(peer.commit, `peer commit settled`) - await atOracleCheckpoint(peer.persisted, `peer persistence settled`) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.commit).toBe(`fulfilled`), - ) - oracleAssertion(`independent settlement checkpoint`, () => - expect(peer.outcome.persisted).toBe(`fulfilled`), - ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(env.collection.toArray).toEqual([]), + ) + } else { + const firstPeerEvent = await atOracleCheckpoint( + Promise.race([ + peerProviderEntered.promise.then(() => `provider` as const), + peer.commit.then(() => `settled` as const), + ]), + `peer provider or early settlement`, + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(firstPeerEvent).toBe(`provider`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.commit).toBe(`pending`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.persisted).toBe(`pending`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(providerCalls).toEqual([head.id, peer.id]), + ) + { + const oracleAwaitedValue10 = await env.executor.peekOutbox() oracleAssertion(`independent settlement checkpoint`, () => - expect(providerCalls).toEqual([head.id, peer.id]), + expect(oracleAwaitedValue10.map(({ id }) => id)).toEqual([peer.id]), ) - { - const oracleAwaitedValue11 = await env.executor.peekOutbox() - oracleAssertion(`independent settlement checkpoint`, () => - expect(oracleAwaitedValue11).toEqual([]), - ) - } + } + releasePeerProvider.resolve() + await atOracleCheckpoint(peer.commit, `peer commit settled`) + await atOracleCheckpoint(peer.persisted, `peer persistence settled`) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.commit).toBe(`fulfilled`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(peer.outcome.persisted).toBe(`fulfilled`), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(providerCalls).toEqual([head.id, peer.id]), + ) + { + const oracleAwaitedValue11 = await env.executor.peekOutbox() oracleAssertion(`independent settlement checkpoint`, () => - expect(env.collection.toArray.map(({ id }) => id)).toEqual([`peer`]), + expect(oracleAwaitedValue11).toEqual([]), ) } - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - releaseProvider.resolve() - releasePeerWrite.resolve() - releaseTerminalMarker.resolve() - releasePeerProvider.resolve() - const cleanupError = new Error(`started-peer oracle cleanup`) - for (const [id, outcome] of outcomes) - if (outcome.commit === `pending` || outcome.persisted === `pending`) - env.executor.rejectTransaction(id, cleanupError) - await cleanupOfflineOracle( - [ - () => Promise.all(observed), - () => env.executor.dispose(), - () => env.collection.cleanup(), - () => warning.mockRestore(), - () => errorLog.mockRestore(), - ], - hasPrimaryFailure, + oracleAssertion(`independent settlement checkpoint`, () => + expect(env.collection.toArray.map(({ id }) => id)).toEqual([`peer`]), ) } + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + releaseProvider.resolve() + releasePeerWrite.resolve() + releaseTerminalMarker.resolve() + releasePeerProvider.resolve() + const cleanupError = new Error(`started-peer oracle cleanup`) + for (const [id, outcome] of outcomes) + if (outcome.commit === `pending` || outcome.persisted === `pending`) + env.executor.rejectTransaction(id, cleanupError) + await cleanupOfflineOracle( + [ + () => Promise.all(observed), + () => env.executor.dispose(), + () => env.collection.cleanup(), + () => warning.mockRestore(), + () => errorLog.mockRestore(), + ], + hasPrimaryFailure, + ) + } +} +oracleProperty({ + id: `offline.settlement.started-peer`, + law: { + ...settlementLaw, + statement: `A started peer's commit, persistence promise, provider eligibility and outbox row follow its own write acknowledgement and the failed head's deletion, across a throwing or invalid retry hook.`, + limits: `The twelve hook-failure and peer-write cases above, through the same production driver and the expectedStartedPeer model; this is not a fresh-executor replay law.`, }, -) + 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`, + ], + histories: startedPeerCases, + label: ([failureKind, peerWriteCase]) => + `settles a started peer across ${failureKind} hook failure and ${peerWriteCase} write`, + resolve: (startedPeer) => ({ startedPeer }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `independent settlement checkpoint`, + plan, + observe, + () => runStartedPeerCase(plan.startedPeer), + ), + check: forwardAssertionJudgment, +}) // A first-provider retry-record write fails after 2–5 transactions have been // admitted. Two is the marginal held-head/peer history; five checks that every diff --git a/packages/offline-transactions/tests/transaction-settlement-restored-model-oracle.ts b/packages/offline-transactions/tests/transaction-settlement-restored-model-oracle.ts new file mode 100644 index 0000000000..6b678c2daf --- /dev/null +++ b/packages/offline-transactions/tests/transaction-settlement-restored-model-oracle.ts @@ -0,0 +1,227 @@ +// The restored-snapshot model and its comparison: a stateless prefix fold over +// authored keys, whole rows and provider results. The oracle in +// transaction-settlement-oracle.property.test.ts judges production with it, +// and transaction-settlement-restored-model.test.ts calibrates the comparison. +// This module registers no tests. +import { expect } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import type { Transaction } from '@tanstack/db' +import type { TestItem } from './harness' + +export const localRows = (rows: Iterable) => + [...rows] + .map(({ id, value, completed, updatedAt }) => ({ + id, + value, + completed, + updatedAt, + })) + .sort((a, b) => a.id.localeCompare(b.id)) + +export function expectLocalRows( + actual: Iterable, + expected: Iterable, +) { + oracleAssertion(`independent settlement checkpoint`, () => + expect(localRows(actual)).toEqual(localRows(expected)), + ) +} + +/** Restored snapshots have a different lifetime from durable outbox work. + * A core rollback cancels conflicting pending snapshots, but their independently + * admitted outbox entries still execute and settle from their own providers. + * A cancelled snapshot stays failed even if its outbox entry later succeeds. + * Authority: core rollback/admission and the README's per-ID durable settlement. + * This stateless prefix fold uses authored keys, whole rows and provider results; + * it does not consult executor queues, restoration maps or actual Collection rows. + */ +export type RestoredHistory = { + layout: `shared` | `disjoint` | `chain` + width: number + succeeds: ReadonlyArray +} +export function restoredRows(history: RestoredHistory): Array> { + // Alternating field edits preserve the old insert/value-edit/flag-edit + // history. In particular, the flag-only snapshot carries the prior value. + // Its later success must still send that whole snapshot after sibling failure. + const authored = new Map() + return history.succeeds.map((_success, index) => + Array.from({ length: history.width }, (_unused, slot) => { + const id = + history.layout === `shared` + ? `key-${slot}` + : history.layout === `chain` + ? `key-${index + slot}` + : `key-${index}-${slot}` + const before = authored.get(id) + const row = { + id, + value: before + ? index % 2 === 1 + ? `edited-${index}-${id}` + : before.value + : `initial-${id}`, + completed: before ? (index % 2 === 0 ? true : before.completed) : false, + updatedAt: new Date(1700000000000), + } + authored.set(id, row) + return row + }), + ) +} + +export type RestoredSnapshot = { rows: Array; pending: Array } +function restoredSnapshot( + rows: ReadonlyArray>, + server: ReadonlyMap, + cancelled: ReadonlySet, + completed: number, +): RestoredSnapshot { + const local = new Map(server) + const pending = new Set() + for (let index = completed + 1; index < rows.length; index++) + if (!cancelled.has(index)) + for (const row of rows[index]!) { + local.set(row.id, row) + pending.add(row.id) + } + return { rows: [...local.values()], pending: [...pending].sort() } +} +export type RestoredObservation = RestoredSnapshot & { + waiters: Array + server: Map + outcomes: Array + states: Array + calls: Array<{ id: string; rows: Array }> + outbox: Array + errors: Array> + warnings: Array> + unhandled: Array +} +export function expectedRestoredState( + history: RestoredHistory, + failures: ReadonlyArray, + completed: number, +): RestoredObservation { + const rows = restoredRows(history) + const server = new Map() + const cancelled = new Set() + const waiters: RestoredObservation[`waiters`] = rows.map(() => undefined) + for (let index = 0; index <= completed; index++) { + if (history.succeeds[index]) { + for (const row of rows[index]!) server.set(row.id, row) + } else if (!cancelled.has(index)) { + cancelled.add(index) + const keys = new Set(rows[index]!.map((row) => row.id)) + for (let peer = index + 1; peer < rows.length; peer++) + if (rows[peer]!.some((row) => keys.has(row.id))) cancelled.add(peer) + } + waiters[index] = restoredSnapshot(rows, server, cancelled, index) + } + return { + ...restoredSnapshot(rows, server, cancelled, completed), + waiters, + server, + outcomes: history.succeeds.map((success, index) => + index > completed ? `pending` : success ? `fulfilled` : failures[index], + ), + states: history.succeeds.map((_success, index) => + cancelled.has(index) + ? `failed` + : index <= completed + ? `completed` + : `pending`, + ), + calls: rows + .slice(0, Math.min(completed + 2, rows.length)) + .map((batch, index) => ({ id: `restored-${index}`, rows: batch })), + outbox: rows + .slice(completed + 1) + .map((_row, offset) => `restored-${completed + 1 + offset}`), + errors: [], + warnings: [], + unhandled: [], + } +} +export function checkRestoredState( + actual: RestoredObservation, + expected: RestoredObservation, +): void { + expectLocalRows(actual.rows, expected.rows) + oracleAssertion(`independent settlement checkpoint`, () => + expect( + actual.pending, + `restored snapshot pending-write contribution`, + ).toEqual(expected.pending), + ) + for (const [index, snapshot] of expected.waiters.entries()) { + oracleAssertion(`independent settlement checkpoint`, () => + expect( + Boolean(actual.waiters[index]), + `waiter snapshot presence ${index}`, + ).toBe(Boolean(snapshot)), + ) + if (snapshot) { + expectLocalRows(actual.waiters[index]!.rows, snapshot.rows) + oracleAssertion(`independent settlement checkpoint`, () => + expect( + actual.waiters[index]!.pending, + `pending writes at waiter ${index}`, + ).toEqual(snapshot.pending), + ) + } + } + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.server).toEqual(expected.server), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.outcomes).toEqual(expected.outcomes), + ) + for (const [index, outcome] of expected.outcomes.entries()) + if (outcome instanceof Error) + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.outcomes[index]).toBe(outcome), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.states).toEqual(expected.states), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.calls).toEqual(expected.calls), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.outbox).toEqual(expected.outbox), + ) + oracleAssertion(`independent settlement checkpoint`, () => + expect( + actual.errors, + `restoration must not log an executor/cleanup failure`, + ).toEqual(expected.errors), + ) + // An intentional permanent provider rejection may be reported as a warning. + // Its exact authored Error is allowed; a new cleanup/executor exception is + // not. Check reasons, without prescribing log text or duplicate-report policy. + const allowed = expected.outcomes.filter( + (outcome) => outcome instanceof Error, + ) + for (const warning of actual.warnings) { + const reasons = warning.filter((value) => value instanceof Error) + oracleAssertion(`independent settlement checkpoint`, () => + expect( + reasons.length, + `warning must identify a known provider failure`, + ).toBeGreaterThan(0), + ) + for (const reason of reasons) + oracleAssertion(`independent settlement checkpoint`, () => + expect( + allowed.some((failure) => failure === reason), + `unexpected restoration warning: ${String(reason)}`, + ).toBe(true), + ) + } + oracleAssertion(`independent settlement checkpoint`, () => + expect(actual.unhandled, `restoration unhandled rejections`).toEqual( + expected.unhandled, + ), + ) +} diff --git a/packages/offline-transactions/tests/transaction-settlement-restored-model.test.ts b/packages/offline-transactions/tests/transaction-settlement-restored-model.test.ts new file mode 100644 index 0000000000..6f5d20c4e9 --- /dev/null +++ b/packages/offline-transactions/tests/transaction-settlement-restored-model.test.ts @@ -0,0 +1,58 @@ +// Calibrates the restored-snapshot comparison against hand-built wrong +// observations. These are checker inputs, not production histories, so they +// live outside the oracle; the oracle in +// transaction-settlement-oracle.property.test.ts judges production with the +// same model and comparison. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { + checkRestoredState, + expectedRestoredState, +} from './transaction-settlement-restored-model-oracle' +import type { RestoredHistory } from './transaction-settlement-restored-model-oracle' + +// These are checker inputs, not alternate production histories. All expected +// answers come from the same prefix fold as the real restoration receiver. +it.each([ + `extra-error`, + `extra-warning`, + `leaked-pending`, + `stale-waiter`, + `resurrect-failed`, + `early-peer`, + `missing-row`, +] as const)(`rejects a wrong restored observation: %s`, (fault) => { + const history: RestoredHistory = { + layout: `shared`, + width: 2, + succeeds: [false, true, true], + } + const failures = history.succeeds.map(() => new Error(`provider failure`)) + const expected = expectedRestoredState(history, failures, 1) + const actual = { + ...expected, + rows: [...expected.rows], + states: [...expected.states], + outcomes: [...expected.outcomes], + } + checkRestoredState(actual, expected) + if (fault === `extra-error`) actual.errors = [[new Error(`cleanup failed`)]] + if (fault === `extra-warning`) + actual.warnings = [ + [`executor failed`, failures[0], new Error(`cleanup failed`)], + ] + if (fault === `leaked-pending`) + actual.pending = actual.rows.map((row) => row.id) + if (fault === `stale-waiter`) + actual.waiters = actual.waiters.map((snapshot) => + snapshot + ? { ...snapshot, pending: snapshot.rows.map((row) => row.id) } + : snapshot, + ) + if (fault === `resurrect-failed`) actual.states[1] = `completed` + if (fault === `early-peer`) actual.outcomes[2] = `fulfilled` + if (fault === `missing-row`) actual.rows.pop() + oracleAssertion(`independent settlement checkpoint`, () => + expect(() => checkRestoredState(actual, expected)).toThrow(), + ) +}) From 1d12edb94ad151446b28b4fe2fe43736c78e1311 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:58:53 -0700 Subject: [PATCH 046/116] test(db-ivm): move the top-K window checker calibrations to an ordinary file Both plain-test sites in topk-support-window-oracle.test.ts feed planted wrong relations straight to TopKRelation and run no production, so they move unchanged to topk-support-window-calibration.test.ts (P6). The key domain and selectedWindowKeys, which both files read, move unchanged to topk-window-model-oracle.ts. Co-authored-by: Isaac --- .../topk-support-window-calibration.test.ts | 65 ++++++++++++++ .../topk-support-window-oracle.test.ts | 84 ++----------------- .../operators/topk-window-model-oracle.ts | 23 +++++ 3 files changed, 97 insertions(+), 75 deletions(-) create mode 100644 packages/db-ivm/tests/operators/topk-support-window-calibration.test.ts create mode 100644 packages/db-ivm/tests/operators/topk-window-model-oracle.ts diff --git a/packages/db-ivm/tests/operators/topk-support-window-calibration.test.ts b/packages/db-ivm/tests/operators/topk-support-window-calibration.test.ts new file mode 100644 index 0000000000..acd781e439 --- /dev/null +++ b/packages/db-ivm/tests/operators/topk-support-window-calibration.test.ts @@ -0,0 +1,65 @@ +// Calibrations of the top-K support window oracle's signed-output checker. +// Each test feeds TopKRelation a planted wrong relation and requires it to +// reject the answer; no production operator runs. The production campaigns +// are in topk-support-window-oracle.test.ts. +import { expect, it } from 'vitest' +import { TopKRelation } from './topk-relation-oracle.js' +import { + keys, + orderedKeys, + selectedWindowKeys, +} from './topk-window-model-oracle.js' +import type { Key } from './topk-window-model-oracle.js' + +// Rival interpretations must disagree with the rank predicate. Feed their +// 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`, () => { + const present = orderedKeys.slice(0, 3) + for (const window of [ + { offset: -2, limit: 1 }, + { offset: -1, limit: 2 }, + ]) { + const expected = selectedWindowKeys(present, window).map( + (key): [Key, number, string] => [key, 10 - keys.indexOf(key), `tie`], + ) + const wrong = + window.limit === 1 + ? present.slice(window.offset, window.offset + window.limit) + : present.slice(0, window.limit) + const relation = new TopKRelation() + relation.add( + wrong.map((key, index) => [ + [key, [{ id: 10 - keys.indexOf(key), value: `tie` }, `a${index}`]], + 1, + ]), + ) + expect(() => relation.expectRows(expected)).toThrow() + } +}) + +it.each([-2, 2])( + `rejects excess signed weight %s across a moved window`, + (weight) => { + const check = (corrupt: boolean) => { + const relation = new TopKRelation() + relation.add([[[1, [{ id: 9, value: `tie` }, `a0`]], 1]]) + relation.expectRows([[1, 9, `tie`]]) + relation.add([ + [ + [1, [{ id: 9, value: `tie` }, `a0`]], + corrupt && weight < 0 ? weight : -1, + ], + [ + [`1`, [{ id: 8, value: `tie` }, `a1`]], + corrupt && weight > 0 ? weight : 1, + ], + ]) + relation.expectRows([[`1`, 8, `tie`]]) + } + check(false) + expect(() => check(true)).toThrowError( + expect.objectContaining({ name: `AssertionError` }), + ) + }, +) 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 67c635b9fa..5e053cd81c 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 @@ -1,5 +1,5 @@ import { writeSync } from 'node:fs' -import { beforeEach, describe, expect, it as vitestIt } from 'vitest' +import { beforeEach, describe, expect } from 'vitest' import fc from 'fast-check' import { D2 } from '../../src/d2.js' import { MultiSet } from '../../src/multiset.js' @@ -7,10 +7,6 @@ import { output } from '../../src/operators/index.js' import { topKWithFractionalIndex } from '../../src/operators/topKWithFractionalIndex.js' import { groupedTopKWithFractionalIndex } from '../../src/operators/groupedTopKWithFractionalIndex.js' import { orderByWithFractionalIndex } from '../../src/operators/orderBy.js' -import { - oracleReplayTest, - oracleRoute, -} from '../../../db/tests/oracle-config.js' import { oracleProperty } from '../../../db/tests/oracle-property.js' import { atOracleCheckpoint } from '../../../db/tests/oracle-failure-fidelity.js' import { @@ -19,7 +15,13 @@ import { topKReferences, } from './topk-property-oracle.js' import { TopKRelation } from './topk-relation-oracle.js' +import { + keys, + orderedKeys, + selectedWindowKeys, +} from './topk-window-model-oracle.js' import type { TopKComparisonRecorder } from './topk-relation-oracle.js' +import type { Key, Window } from './topk-window-model-oracle.js' import type { TopKWithFractionalIndexOptions } from '../../src/operators/topKWithFractionalIndex.js' /** @@ -44,22 +46,10 @@ import type { TopKWithFractionalIndexOptions } from '../../src/operators/topKWit * that lets a downstream graph respond to changed requested demand. */ -const keys = [1, `1`, 2, `2`, `a`] as const -const orderedKeys = [`1`, `2`, `a`, 1, 2] as const -type Key = (typeof keys)[number] +// The key domain and the window selection model are in +// topk-window-model-oracle.ts. type Row = { id: number; value: string } type Step = { slot: number; support: number; offset: number; limit: number } -type Window = { offset: number; limit: number } - -// Selection is a predicate on each installed row's rank. It never enumerates -// absent ranks or adds offset and width, so large bounds cannot corrupt the -// reference through floating-point addition. Authored offset and width remain -// independent: a later partial move must retain the other exact bound. -function selectedWindowKeys(present: ReadonlyArray, window: Window) { - return present.filter( - (_, rank) => rank >= window.offset && rank - window.offset < window.limit, - ) -} // A requested window is a set of ranks, independently of installed rows. // Distinct nonempty windows differ even when both currently select no row. @@ -74,7 +64,6 @@ function requestedWindowChanged(before: Window, after: Window): boolean { // The fixed and random campaigns share the same property, recorder, checks, // and run budget. A path replay of this property selects only its random // campaign; a seed without a path reseeds it. -const it = oracleReplayTest(vitestIt) // Slot chooses typed identity and rank; support chooses the presence crossing; // offset chooses the first selected rank; limit chooses window width. Removing // any of these axes loses its corresponding distinction. The grammar rejects @@ -724,34 +713,6 @@ function checkNegativeWindowHistory( }) } -// Rival interpretations must disagree with the rank predicate. Feed their -// 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 }, - { offset: -1, limit: 2 }, - ]) { - const expected = selectedWindowKeys(present, window).map( - (key): [Key, number, string] => [key, 10 - keys.indexOf(key), `tie`], - ) - const wrong = - window.limit === 1 - ? present.slice(window.offset, window.offset + window.limit) - : present.slice(0, window.limit) - const relation = new TopKRelation() - relation.add( - wrong.map((key, index) => [ - [key, [{ id: 10 - keys.indexOf(key), value: `tie` }, `a${index}`]], - 1, - ]), - ) - expect(() => relation.expectRows(expected)).toThrow() - } -}) - describe.each([ { name: `array`, @@ -814,30 +775,3 @@ 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]]) - relation.expectRows([[1, 9, `tie`]]) - relation.add([ - [ - [1, [{ id: 9, value: `tie` }, `a0`]], - corrupt && weight < 0 ? weight : -1, - ], - [ - [`1`, [{ id: 8, value: `tie` }, `a1`]], - corrupt && weight > 0 ? weight : 1, - ], - ]) - relation.expectRows([[`1`, 8, `tie`]]) - } - check(false) - expect(() => check(true)).toThrowError( - expect.objectContaining({ name: `AssertionError` }), - ) - }, -) diff --git a/packages/db-ivm/tests/operators/topk-window-model-oracle.ts b/packages/db-ivm/tests/operators/topk-window-model-oracle.ts new file mode 100644 index 0000000000..02b5b022b8 --- /dev/null +++ b/packages/db-ivm/tests/operators/topk-window-model-oracle.ts @@ -0,0 +1,23 @@ +// The typed key domain and the window selection model of the top-K support +// window oracle. The law, grammars, production drivers, and checks are in +// topk-support-window-oracle.test.ts; topk-support-window-calibration.test.ts +// feeds the model's answers to the signed-output checker. This module +// registers no tests and has no side effects. + +export const keys = [1, `1`, 2, `2`, `a`] as const +export const orderedKeys = [`1`, `2`, `a`, 1, 2] as const +export type Key = (typeof keys)[number] +export type Window = { offset: number; limit: number } + +// Selection is a predicate on each installed row's rank. It never enumerates +// absent ranks or adds offset and width, so large bounds cannot corrupt the +// reference through floating-point addition. Authored offset and width remain +// independent: a later partial move must retain the other exact bound. +export function selectedWindowKeys( + present: ReadonlyArray, + window: Window, +) { + return present.filter( + (_, rank) => rank >= window.offset && rank - window.offset < window.limit, + ) +} From 774c2582511d1795653a3eabf4841a8fb79eed1c Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:59:15 -0700 Subject: [PATCH 047/116] test(react-db): register the plain-test sites through oracleProperty Converts the 30 plain-test sites in nine react-db oracle files: - source-id-reuse: the ten fixed histories become react.source-id-reuse-fixed and react.source-id-reuse-infinite-fixed (P1b) through checkRerender; the release-observation checker calibration moves to source-id-reuse-release-calibration.test.tsx (P6), with its model and check in source-id-reuse-release-oracle.ts. - descriptor-query-binding: react.descriptor-abandoned-render-reader, react.descriptor-prebuilt-query-binding (P2), react.ssr-ordinary-examples, react.ssr-infinite-examples, and react.ssr-stream-cut-fixed (P1b). - infinite-query-render-cuts: react.infinite-query-render-cuts.on-demand and react.infinite-query-render-cuts.sync-agreement, with owns (M2). - convex-lazy-paging, effect-callback, usePacedMutations-render-cuts: enumerated properties with owns (P2, M2). - ssr-effects, descriptor-start-release, bound-schema-options: checker calibrations move to ordinary *-calibration files (P6); the ssr-effects check moves to ssr-effects-check-oracle.ts. Every former body runs unchanged, and labels keep the old test names. Co-authored-by: Isaac --- .../bound-schema-options-calibration.test.tsx | 195 ++++ .../bound-schema-options-oracle.test.tsx | 193 +--- .../tests/convex-lazy-paging-oracle.test.tsx | 227 ++--- .../descriptor-query-binding-oracle.test.tsx | 830 ++++++++++-------- ...criptor-start-release-calibration.test.tsx | 26 + .../descriptor-start-release-oracle.test.tsx | 19 - .../tests/effect-callback-oracle.test.tsx | 158 ++-- ...infinite-query-render-cuts-oracle.test.tsx | 187 ++-- .../tests/source-id-reuse-oracle.test.tsx | 203 +++-- ...urce-id-reuse-release-calibration.test.tsx | 41 + .../tests/source-id-reuse-release-oracle.ts | 32 + .../tests/ssr-effects-calibration.test.ts | 41 + .../tests/ssr-effects-check-oracle.ts | 80 ++ .../react-db/tests/ssr-effects-oracle.test.ts | 113 +-- ...PacedMutations-render-cuts-oracle.test.tsx | 67 +- 15 files changed, 1394 insertions(+), 1018 deletions(-) create mode 100644 packages/react-db/tests/bound-schema-options-calibration.test.tsx create mode 100644 packages/react-db/tests/descriptor-start-release-calibration.test.tsx create mode 100644 packages/react-db/tests/source-id-reuse-release-calibration.test.tsx create mode 100644 packages/react-db/tests/source-id-reuse-release-oracle.ts create mode 100644 packages/react-db/tests/ssr-effects-calibration.test.ts create mode 100644 packages/react-db/tests/ssr-effects-check-oracle.ts diff --git a/packages/react-db/tests/bound-schema-options-calibration.test.tsx b/packages/react-db/tests/bound-schema-options-calibration.test.tsx new file mode 100644 index 0000000000..95d50c0580 --- /dev/null +++ b/packages/react-db/tests/bound-schema-options-calibration.test.tsx @@ -0,0 +1,195 @@ +// Checker calibration for the React bound-schema receiver oracle. Each +// history sends hand-built wrong observations, with no production render, to +// the shared bound-schema judgment. Moved unchanged from +// bound-schema-options-oracle.test.tsx, which registers only oracleProperty +// tests. +import { expect, test } from 'vitest' +import { + boundSchemaHistories, + boundSchemaModes, + checkBoundSchemaMutation, + checkBoundSchemaRows, + checkBoundSchemaValues, + modelBoundSchemaBinding, + modelBoundSchemaLabel, + modelBoundSchemaMutation, + modelBoundSchemaRows, +} from '../../db/tests/live-query-bound-options-oracle' +import type { BoundSchemaObservation } from '../../db/tests/live-query-bound-options-oracle' + +// These controls send deliberately wrong public observations to the same +// judgment used by every native receiver. An earlier wrong immediate result +// cannot be hidden by correct later host data. Unordered rows may be reordered, +// but occurrences, ordinary fields, keys and handler payloads remain exact. +function observationFromModel( + expected: ReturnType, +): BoundSchemaObservation { + const rows = () => + expected.rows.map(({ key, value }) => ({ key, value: { ...value } })) + return { + immediate: rows(), + collection: rows(), + state: rows(), + data: expected.rows.map(({ value }) => ({ ...value })), + mutations: expected.mutations.map((mutation) => ({ + ...mutation, + original: { ...mutation.original }, + modified: { ...mutation.modified }, + changes: { ...mutation.changes }, + })), + } +} +test.each(boundSchemaHistories)( + `schema judgment rejects wrong binding and mutation observations %j`, + (history) => { + const base = [ + { id: `one`, label: ` untouched first `, rank: 1 }, + { id: `peer`, label: ` untouched peer `, rank: 2 }, + ] + for (const phase of [`initial`, `retained`, `rebound`] as const) { + const expected = modelBoundSchemaMutation(history, phase, base) + const binding = modelBoundSchemaBinding(history, phase) + const faults: Array<{ + label: string + observation: BoundSchemaObservation + }> = [] + for (const schema of boundSchemaModes) { + if (schema === binding.schema) continue + const wrong = modelBoundSchemaMutation( + { ...history, firstSchema: schema, nextSchema: schema }, + phase, + base, + ) + faults.push({ + label: `wrong schema ${schema}`, + observation: observationFromModel(wrong), + }) + } + const immediate = observationFromModel(expected) + immediate.immediate = immediate.immediate.map((row, index) => + index === 0 + ? { ...row, value: { ...row.value, label: `wrong immediate` } } + : row, + ) + faults.push({ + label: `earlier mismatch with correct later state`, + observation: immediate, + }) + for (const field of [`immediate`, `collection`, `state`] as const) { + const undefinedField = observationFromModel(expected) + undefinedField[field] = undefinedField[field].map((row) => ({ + ...row, + value: { ...row.value, unexpected: undefined }, + })) + faults.push({ + label: `extra undefined field in ${field}`, + observation: undefinedField, + }) + } + const undefinedData = observationFromModel(expected) + undefinedData.data = undefinedData.data.map((row) => ({ + ...row, + unexpected: undefined, + })) + faults.push({ + label: `extra undefined field in host data`, + observation: undefinedData, + }) + const undefinedMutation = observationFromModel(expected) + undefinedMutation.mutations = undefinedMutation.mutations.map( + (mutation) => ({ + ...mutation, + changes: { ...mutation.changes, unexpected: undefined }, + }), + ) + faults.push({ + label: `extra undefined field in handler`, + observation: undefinedMutation, + }) + const collection = observationFromModel(expected) + collection.collection = collection.collection.slice(0, 1) + faults.push({ label: `missing Collection peer`, observation: collection }) + const state = observationFromModel(expected) + state.state = [...state.state, state.state[1]!] + faults.push({ label: `duplicate state row`, observation: state }) + const data = observationFromModel(expected) + data.data = data.data.map((row, index) => + index === 1 ? { ...row, extra: `unexpected` } : row, + ) + faults.push({ label: `extra host data field`, observation: data }) + const key = observationFromModel(expected) + key.state = key.state.map((row) => ({ + ...row, + key: `wrong-${String(row.key)}`, + })) + faults.push({ label: `wrong public keys`, observation: key }) + const missing = observationFromModel(expected) + missing.mutations = [] + faults.push({ label: `missing handler mutation`, observation: missing }) + const duplicate = observationFromModel(expected) + duplicate.mutations = [...duplicate.mutations, duplicate.mutations[0]!] + faults.push({ + label: `duplicate handler mutation`, + observation: duplicate, + }) + const original = observationFromModel(expected) + original.mutations = original.mutations.map((mutation) => ({ + ...mutation, + original: { ...mutation.original, label: mutation.modified.label }, + })) + faults.push({ + label: `original replaced with modified`, + observation: original, + }) + if (binding.schema !== `absent`) { + const raw = observationFromModel(expected) + const label = modelBoundSchemaLabel(`absent`, history.input) + raw.mutations = raw.mutations.map((mutation) => ({ + ...mutation, + modified: { ...mutation.modified, label }, + changes: { ...mutation.changes, label }, + })) + faults.push({ + label: `raw handler payload with transformed public rows`, + observation: raw, + }) + } + for (const fault of faults) + expect( + () => + checkBoundSchemaMutation(fault.observation, expected, fault.label), + fault.label, + ).toThrow(fault.label) + const reordered = observationFromModel(expected) + for (const field of [`immediate`, `collection`, `state`] as const) + reordered[field] = [...reordered[field]].reverse() + reordered.data = [...reordered.data].reverse() + checkBoundSchemaMutation(reordered, expected, `allowed unordered rows`) + + const source = modelBoundSchemaRows(base, binding.key) + const transformed = source.map((row) => ({ + ...row, + value: { + ...row.value, + label: modelBoundSchemaLabel(`trim`, history.input), + }, + })) + expect(() => + checkBoundSchemaRows(transformed, source, `sync rows stay raw`), + ).toThrow(`sync rows stay raw`) + expect(() => + checkBoundSchemaValues( + transformed.map(({ value }) => value), + source.map(({ value }) => value), + `sync host data stays raw`, + ), + ).toThrow(`sync host data stays raw`) + checkBoundSchemaRows([...source].reverse(), source, `raw unordered rows`) + checkBoundSchemaValues( + source.map(({ value }) => value).reverse(), + source.map(({ value }) => value), + `raw unordered host data`, + ) + } + }, +) 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 eebf6328ee..e79120c9ba 100644 --- a/packages/react-db/tests/bound-schema-options-oracle.test.tsx +++ b/packages/react-db/tests/bound-schema-options-oracle.test.tsx @@ -4,24 +4,16 @@ * also captures Collection entries before update returns. Each finite case * preserves all twelve raw-source/held/settled/followup cuts and reference * relations. Controlled Collections supply no provider, SSR or concurrent-render - * claim; the existing72 strict checker-calibration histories remain below. + * claim; the existing72 strict checker-calibration histories live in + * bound-schema-options-calibration.test.tsx. */ import { act, renderHook } from '@testing-library/react' -import { expect, test } from 'vitest' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' -import { oracleProperty, oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { boundSchemaGrammar, boundSchemaHistories, - boundSchemaModes, - checkBoundSchemaMutation, - checkBoundSchemaRows, - checkBoundSchemaValues, - modelBoundSchemaBinding, - modelBoundSchemaLabel, - modelBoundSchemaMutation, - modelBoundSchemaRows, } from '../../db/tests/live-query-bound-options-oracle' import { checkBoundSchemaReceiver, @@ -30,7 +22,6 @@ import { import { recordBoundSchemaHistory } from '../../db/tests/live-query-bound-options-support' import type { ExtractContext } from '@tanstack/db' import type { BoundSchemaNativeHost } from '../../db/tests/live-query-bound-options-support' -import type { BoundSchemaObservation } from '../../db/tests/live-query-bound-options-oracle' import type { BoundSchemaReceiverHistory } from '../../db/tests/bound-schema-receiver-oracle' // Source values were fixed inside the old driver. Naming them as history inputs @@ -162,181 +153,3 @@ for (const hook of [`live`, `suspense`] as const) { ], }) } - -// These controls send deliberately wrong public observations to the same -// judgment used by every native receiver. An earlier wrong immediate result -// cannot be hidden by correct later host data. Unordered rows may be reordered, -// but occurrences, ordinary fields, keys and handler payloads remain exact. -function observationFromModel( - expected: ReturnType, -): BoundSchemaObservation { - const rows = () => - expected.rows.map(({ key, value }) => ({ key, value: { ...value } })) - return { - immediate: rows(), - collection: rows(), - state: rows(), - data: expected.rows.map(({ value }) => ({ ...value })), - mutations: expected.mutations.map((mutation) => ({ - ...mutation, - original: { ...mutation.original }, - modified: { ...mutation.modified }, - changes: { ...mutation.changes }, - })), - } -} -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 }, - ] - for (const phase of [`initial`, `retained`, `rebound`] as const) { - const expected = modelBoundSchemaMutation(history, phase, base) - const binding = modelBoundSchemaBinding(history, phase) - const faults: Array<{ - label: string - observation: BoundSchemaObservation - }> = [] - for (const schema of boundSchemaModes) { - if (schema === binding.schema) continue - const wrong = modelBoundSchemaMutation( - { ...history, firstSchema: schema, nextSchema: schema }, - phase, - base, - ) - faults.push({ - label: `wrong schema ${schema}`, - observation: observationFromModel(wrong), - }) - } - const immediate = observationFromModel(expected) - immediate.immediate = immediate.immediate.map((row, index) => - index === 0 - ? { ...row, value: { ...row.value, label: `wrong immediate` } } - : row, - ) - faults.push({ - label: `earlier mismatch with correct later state`, - observation: immediate, - }) - for (const field of [`immediate`, `collection`, `state`] as const) { - const undefinedField = observationFromModel(expected) - undefinedField[field] = undefinedField[field].map((row) => ({ - ...row, - value: { ...row.value, unexpected: undefined }, - })) - faults.push({ - label: `extra undefined field in ${field}`, - observation: undefinedField, - }) - } - const undefinedData = observationFromModel(expected) - undefinedData.data = undefinedData.data.map((row) => ({ - ...row, - unexpected: undefined, - })) - faults.push({ - label: `extra undefined field in host data`, - observation: undefinedData, - }) - const undefinedMutation = observationFromModel(expected) - undefinedMutation.mutations = undefinedMutation.mutations.map( - (mutation) => ({ - ...mutation, - changes: { ...mutation.changes, unexpected: undefined }, - }), - ) - faults.push({ - label: `extra undefined field in handler`, - observation: undefinedMutation, - }) - const collection = observationFromModel(expected) - collection.collection = collection.collection.slice(0, 1) - faults.push({ label: `missing Collection peer`, observation: collection }) - const state = observationFromModel(expected) - state.state = [...state.state, state.state[1]!] - faults.push({ label: `duplicate state row`, observation: state }) - const data = observationFromModel(expected) - data.data = data.data.map((row, index) => - index === 1 ? { ...row, extra: `unexpected` } : row, - ) - faults.push({ label: `extra host data field`, observation: data }) - const key = observationFromModel(expected) - key.state = key.state.map((row) => ({ - ...row, - key: `wrong-${String(row.key)}`, - })) - faults.push({ label: `wrong public keys`, observation: key }) - const missing = observationFromModel(expected) - missing.mutations = [] - faults.push({ label: `missing handler mutation`, observation: missing }) - const duplicate = observationFromModel(expected) - duplicate.mutations = [...duplicate.mutations, duplicate.mutations[0]!] - faults.push({ - label: `duplicate handler mutation`, - observation: duplicate, - }) - const original = observationFromModel(expected) - original.mutations = original.mutations.map((mutation) => ({ - ...mutation, - original: { ...mutation.original, label: mutation.modified.label }, - })) - faults.push({ - label: `original replaced with modified`, - observation: original, - }) - if (binding.schema !== `absent`) { - const raw = observationFromModel(expected) - const label = modelBoundSchemaLabel(`absent`, history.input) - raw.mutations = raw.mutations.map((mutation) => ({ - ...mutation, - modified: { ...mutation.modified, label }, - changes: { ...mutation.changes, label }, - })) - faults.push({ - label: `raw handler payload with transformed public rows`, - observation: raw, - }) - } - for (const fault of faults) - expect( - () => - checkBoundSchemaMutation(fault.observation, expected, fault.label), - fault.label, - ).toThrow(fault.label) - const reordered = observationFromModel(expected) - for (const field of [`immediate`, `collection`, `state`] as const) - reordered[field] = [...reordered[field]].reverse() - reordered.data = [...reordered.data].reverse() - checkBoundSchemaMutation(reordered, expected, `allowed unordered rows`) - - const source = modelBoundSchemaRows(base, binding.key) - const transformed = source.map((row) => ({ - ...row, - value: { - ...row.value, - label: modelBoundSchemaLabel(`trim`, history.input), - }, - })) - expect(() => - checkBoundSchemaRows(transformed, source, `sync rows stay raw`), - ).toThrow(`sync rows stay raw`) - expect(() => - checkBoundSchemaValues( - transformed.map(({ value }) => value), - source.map(({ value }) => value), - `sync host data stays raw`, - ), - ).toThrow(`sync host data stays raw`) - checkBoundSchemaRows([...source].reverse(), source, `raw unordered rows`) - checkBoundSchemaValues( - source.map(({ value }) => value).reverse(), - source.map(({ value }) => value), - `raw unordered host data`, - ) - } - }, -) 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 49c07de0e1..658f855ac7 100644 --- a/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx +++ b/packages/react-db/tests/convex-lazy-paging-oracle.test.tsx @@ -4,7 +4,7 @@ * pagination algorithm. Other framework bindings remain separate owners. */ import { act, renderHook } from '@testing-library/react' import { useLayoutEffect } from 'react' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { lazyFixture } from '../../convex-db-collection/tests/lazy-paging-driver' import { @@ -12,108 +12,131 @@ import { expectedLazyRows, } from '../../convex-db-collection/tests/lazy-paging-model-oracle' import { withTestCleanup } from '../../convex-db-collection/tests/helpers' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' -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), - text: `row ${i}`, - })) - const f = lazyFixture(rows, pageSize) - const permission = {} - f.remote.scopes.set(permission, { limit: 3 }) - const trace: Array = [] - const expectedLedger = (pages: number) => { - const data = expectedLazyRows(rows, { limit: pages * 2 }) - return JSON.stringify({ - data, - pages: Array.from({ length: pages }, (_, i) => - data.slice(i * 2, i * 2 + 2), - ), - pageParams: Array.from({ length: pages }, (_, i) => i), - hasNextPage: rows.length > pages * 2, - }) - } - const hook = renderHook(() => { - const result = useLiveInfiniteQuery( - (q) => - q - .from({ row: f.source }) - .orderBy(({ row }) => row.rank) - .orderBy(({ row }) => row.id) - .select(({ row }) => row), - { pageSize: 2 }, - ) - // Capture each committed ready tuple, including intermediate fetch commits. - // Discarded concurrent renders are not public publications. - useLayoutEffect(() => { - if (result.isReady) - trace.push( - JSON.stringify({ - data: copyLazyRows(result.data), - pages: result.pages.map(copyLazyRows), - pageParams: result.pageParams, - hasNextPage: result.hasNextPage, - }), - ) - }) - return result +async function runLazyPagingCase(pageSize: number): Promise { + const rows = Array.from({ length: 7 }, (_, i) => ({ + id: `r${i}`, + rank: Math.floor(i / 2), + text: `row ${i}`, + })) + const f = lazyFixture(rows, pageSize) + const permission = {} + f.remote.scopes.set(permission, { limit: 3 }) + const trace: Array = [] + const expectedLedger = (pages: number) => { + const data = expectedLazyRows(rows, { limit: pages * 2 }) + return JSON.stringify({ + data, + pages: Array.from({ length: pages }, (_, i) => + data.slice(i * 2, i * 2 + 2), + ), + pageParams: Array.from({ length: pages }, (_, i) => i), + hasNextPage: rows.length > pages * 2, }) - await withTestCleanup( - async () => { - await act(async () => { - await f.remote.pump() - }) - for (let pages = 1; pages <= 4; pages++) { - if (pages > 1) { - f.remote.scopes.set(permission, { limit: pages * 2 + 1 }) - await act(async () => { - const fetching = hook.result.current.fetchNextPage() - await f.remote.pump() - await fetching - }) - } - const publications = trace.splice(0) - expect( - publications, - `React lazy publication: no ready target tuple`, - ).toContain(expectedLedger(pages)) - expect(hook.result.current.isReady).toBe(true) - let advanced = false - for (const snapshot of publications) { - if (snapshot === expectedLedger(pages)) advanced = true - else - expect( - snapshot, - `React lazy publication: torn or stale page ledger`, - ).toBe(expectedLedger(advanced ? pages : Math.max(1, pages - 1))) - } - const expected = expectedLazyRows(rows, { limit: pages * 2 }) - expect(copyLazyRows(hook.result.current.data)).toEqual(expected) - expect(hook.result.current.pages.map(copyLazyRows)).toEqual( - Array.from({ length: pages }, (_, i) => - expected.slice(i * 2, i * 2 + 2), - ), - ) - expect(hook.result.current.pageParams).toEqual( - Array.from({ length: pages }, (_, i) => i), - ) - expect(hook.result.current.hasNextPage).toBe(rows.length > pages * 2) - expect(hook.result.current.isFetchingNextPage).toBe(false) - } - }, - () => hook.unmount(), - () => { - const collection = hook.result.current.collection - if (collection === undefined) - throw new Error(`React lazy paging Collection missing at cleanup`) - return collection.cleanup() - }, - () => f.source.cleanup(), + } + const hook = renderHook(() => { + const result = useLiveInfiniteQuery( + (q) => + q + .from({ row: f.source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.id) + .select(({ row }) => row), + { pageSize: 2 }, ) + // Capture each committed ready tuple, including intermediate fetch commits. + // Discarded concurrent renders are not public publications. + useLayoutEffect(() => { + if (result.isReady) + trace.push( + JSON.stringify({ + data: copyLazyRows(result.data), + pages: result.pages.map(copyLazyRows), + pageParams: result.pageParams, + hasNextPage: result.hasNextPage, + }), + ) + }) + return result + }) + await withTestCleanup( + async () => { + await act(async () => { + await f.remote.pump() + }) + for (let pages = 1; pages <= 4; pages++) { + if (pages > 1) { + f.remote.scopes.set(permission, { limit: pages * 2 + 1 }) + await act(async () => { + const fetching = hook.result.current.fetchNextPage() + await f.remote.pump() + await fetching + }) + } + const publications = trace.splice(0) + expect( + publications, + `React lazy publication: no ready target tuple`, + ).toContain(expectedLedger(pages)) + expect(hook.result.current.isReady).toBe(true) + let advanced = false + for (const snapshot of publications) { + if (snapshot === expectedLedger(pages)) advanced = true + else + expect( + snapshot, + `React lazy publication: torn or stale page ledger`, + ).toBe(expectedLedger(advanced ? pages : Math.max(1, pages - 1))) + } + const expected = expectedLazyRows(rows, { limit: pages * 2 }) + expect(copyLazyRows(hook.result.current.data)).toEqual(expected) + expect(hook.result.current.pages.map(copyLazyRows)).toEqual( + Array.from({ length: pages }, (_, i) => + expected.slice(i * 2, i * 2 + 2), + ), + ) + expect(hook.result.current.pageParams).toEqual( + Array.from({ length: pages }, (_, i) => i), + ) + expect(hook.result.current.hasNextPage).toBe(rows.length > pages * 2) + expect(hook.result.current.isFetchingNextPage).toBe(false) + } + }, + () => hook.unmount(), + () => { + const collection = hook.result.current.collection + if (collection === undefined) + throw new Error(`React lazy paging Collection missing at cleanup`) + return collection.cleanup() + }, + () => f.source.cleanup(), + ) +} + +const LAZY_PAGING_LAW = `React lazy infinite-query publication` + +oracleProperty({ + id: `react.convex-lazy-paging`, + law: { + statement: `Each committed ready infinite-query tuple equals the shared Convex lazy window model's page ledger, without torn or stale pages.`, + authority: `The shared Convex lazy window law and expectedLazyRows in lazy-paging-model-oracle.ts.`, + limits: `Seven rows, React page size two, provider page sizes one to four, four pages; React scheduling only.`, }, -) + owns: [`src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`], + histories: [1, 2, 3, 4].map((pageSize) => ({ pageSize })), + label: ({ pageSize }) => + `receives exact infinite-query pages with provider page size ${pageSize}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(LAZY_PAGING_LAW, plan, observe, () => + runLazyPagingCase(plan.history.pageSize), + ), + check: forwardAssertionJudgment, +}) 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 d041e53826..495abfaccb 100644 --- a/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-query-binding-oracle.test.tsx @@ -100,7 +100,7 @@ import { getLiveQueryHash, getStableQueryBuilderHash, } from '@tanstack/db' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { Component, Suspense } from 'react' import { renderToPipeableStream, renderToString } from 'react-dom/server' @@ -112,7 +112,7 @@ import { ssrSnapshotHistoryArbitrary, } from '../../db/tests/ssr-snapshot-admission-oracle' import { ssrStreamHydrationMismatches } from '../../db/tests/ssr-stream-view-oracle' -import { oracleProperty, oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -187,327 +187,376 @@ describe(`standalone descriptor query binding`, () => { // while a deferring subscription preserves the zero-start cut. Ordinary // admission also crosses startup cleanup: cleanup retires this start, and // only fresh later demand can restart it. Both startSync settings must work. - it.each( - ( - [ - { reader: `preload`, startSync: true }, - { reader: `preload`, startSync: false }, - { reader: `subscriber`, startSync: true }, - { reader: `subscriber`, startSync: false }, - { reader: `resumed subscriber`, startSync: true }, - { reader: `resumed subscriber`, startSync: false }, - { reader: `deferring subscriber`, startSync: true }, - { reader: `deferring subscriber`, startSync: false }, - ] as const - ).flatMap((history) => - ([`eager`, `on-demand`] as const).flatMap((syncMode) => - (history.reader === `subscriber` || - history.reader === `resumed subscriber` - ? subscriberStartCleanupHistories - : [subscriberStartCleanupHistories[0]] - ).map((cleanupHistory) => ({ - ...history, - syncMode, - ...cleanupHistory, - })), - ), - ), - )( - `$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() - const client = new DbClient() - let starts = 0 - let cleanups = 0 - const descriptor = collectionOptions(`suspended-direct-reader`, () => ({ - id: `suspended-direct-reader`, - getKey: (row: Row) => row.id, - startSync, + const abandonedRenderReaderHistories = ( + [ + { reader: `preload`, startSync: true }, + { reader: `preload`, startSync: false }, + { reader: `subscriber`, startSync: true }, + { reader: `subscriber`, startSync: false }, + { reader: `resumed subscriber`, startSync: true }, + { reader: `resumed subscriber`, startSync: false }, + { reader: `deferring subscriber`, startSync: true }, + { reader: `deferring subscriber`, startSync: false }, + ] as const + ).flatMap((history) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + (history.reader === `subscriber` || + history.reader === `resumed subscriber` + ? subscriberStartCleanupHistories + : [subscriberStartCleanupHistories[0]] + ).map((cleanupHistory) => ({ + ...history, syncMode, - sync: { - sync: ({ begin, write, commit, markReady }) => { - starts++ - const publish = () => { - begin() - write({ type: `insert`, value: { id: `one`, value: `live` } }) - commit() - return true as const - } - if (syncMode === `eager`) publish() - markReady() - return { - loadSubset: publish, - cleanup: () => { - cleanups++ - return cleanupPending ? pendingCleanup.promise : undefined - }, - } - }, + ...cleanupHistory, + })), + ), + ) + type AbandonedRenderReaderHistory = + (typeof abandonedRenderReaderHistories)[number] + async function runAbandonedRenderReader( + history: AbandonedRenderReaderHistory, + ): Promise { + const { reader, startSync, syncMode, cleanupAt, cleanupPending } = history + const expected = modelSubscriberStartCleanup(history) + const pendingCleanup = createDeferred() + const client = new DbClient() + let starts = 0 + let cleanups = 0 + const descriptor = collectionOptions(`suspended-direct-reader`, () => ({ + id: `suspended-direct-reader`, + getKey: (row: Row) => row.id, + startSync, + syncMode, + sync: { + sync: ({ begin, write, commit, markReady }) => { + starts++ + const publish = () => { + begin() + write({ type: `insert`, value: { id: `one`, value: `live` } }) + commit() + return true as const + } + if (syncMode === `eager`) publish() + markReady() + return { + loadSubset: publish, + cleanup: () => { + cleanups++ + return cleanupPending ? pendingCleanup.promise : undefined + }, + } }, - })) - const query = new Query().from({ item: descriptor }) - const never = new Promise(() => {}) - function Suspended(): ReactNode { - useLiveQuery({ query }) - throw never + }, + })) + const query = new Query().from({ item: descriptor }) + const never = new Promise(() => {}) + function Suspended(): ReactNode { + useLiveQuery({ query }) + throw never + } + const mounted = render( + + Waiting}> + + + , + ) + const source = client.collection(descriptor) + const statuses: Array = [] + let cleanup: Promise | undefined + let cleanupRequested = false + const removeStatusListener = source.on(`status:change`, ({ status }) => { + statuses.push(status) + if (!cleanupRequested && status === cleanupAt) { + cleanupRequested = true + cleanup = source.cleanup() } - const mounted = render( - - Waiting}> - - - , + }) + let directRead: Promise | undefined + let subscription: ReturnType | undefined + let peer: ReturnType | undefined + try { + oracleAssertion( + 'expect(mounted.getByText(`Waiting`)).toBeDefined()', + () => expect(mounted.getByText(`Waiting`)).toBeDefined(), + ) + oracleAssertion( + 'expect(starts, `before commit or direct demand`).toBe(0)', + () => expect(starts, `before commit or direct demand`).toBe(0), ) - const source = client.collection(descriptor) - const statuses: Array = [] - let cleanup: Promise | undefined - let cleanupRequested = false - const removeStatusListener = source.on(`status:change`, ({ status }) => { - statuses.push(status) - if (!cleanupRequested && status === cleanupAt) { - cleanupRequested = true - cleanup = source.cleanup() + if (reader === `preload`) { + directRead = source.preload() + void directRead.catch(() => {}) + } else { + subscription = source.subscribeChanges(() => {}, { + deferAcquisition: + reader === `deferring subscriber` || + reader === `resumed subscriber`, + includeInitialState: true, + }) + if (reader === `resumed subscriber`) { + oracleAssertion('expect(', () => + expect( + starts, + `deferred demand preserves the abandoned-render cut`, + ).toBe(0), + ) + subscription.resumeDeferredAcquisition() } - }) - let directRead: Promise | undefined - let subscription: ReturnType | undefined - let peer: ReturnType | undefined - try { - oracleAssertion( - 'expect(mounted.getByText(`Waiting`)).toBeDefined()', - () => expect(mounted.getByText(`Waiting`)).toBeDefined(), - ) + } + oracleAssertion( + 'expect(starts, `direct demand starts the source`).toBe(', + () => + expect(starts, `direct demand starts the source`).toBe( + reader === `deferring subscriber` ? 0 : expected.starts, + ), + ) + oracleAssertion('expect(cleanups).toBe(expected.cleanups)', () => + expect(cleanups).toBe(expected.cleanups), + ) + oracleAssertion('expect(source.status).toBe(', () => + expect(source.status).toBe( + reader === `deferring subscriber` ? `idle` : expected.statusOnReturn, + ), + ) + oracleAssertion('expect(statuses).toEqual(', () => + expect(statuses).toEqual( + reader === `deferring subscriber` ? [] : expected.statusesOnReturn, + ), + ) + pendingCleanup.resolve() + await cleanup + await directRead + oracleAssertion('expect(source.status).toBe(', () => + expect(source.status).toBe( + reader === `deferring subscriber` + ? `idle` + : expected.statusAfterCleanup, + ), + ) + oracleAssertion('expect(statuses).toEqual(', () => + expect(statuses).toEqual( + reader === `deferring subscriber` + ? [] + : expected.statusesAfterCleanup, + ), + ) + if (cleanupAt === `none`) { + const expectsRow = + reader !== `deferring subscriber` && + (syncMode === `eager` || + reader === `subscriber` || + reader === `resumed subscriber`) oracleAssertion( - 'expect(starts, `before commit or direct demand`).toBe(0)', - () => expect(starts, `before commit or direct demand`).toBe(0), + 'expect(source.get(`one`)?.value).toBe(expectsRow ? `live` : undefined)', + () => + expect(source.get(`one`)?.value).toBe( + expectsRow ? `live` : undefined, + ), ) - if (reader === `preload`) { - directRead = source.preload() - void directRead.catch(() => {}) - } else { - subscription = source.subscribeChanges(() => {}, { - deferAcquisition: - reader === `deferring subscriber` || - reader === `resumed subscriber`, - includeInitialState: true, - }) - if (reader === `resumed subscriber`) { - oracleAssertion('expect(', () => - expect( - starts, - `deferred demand preserves the abandoned-render cut`, - ).toBe(0), - ) - subscription.resumeDeferredAcquisition() - } - } + } + removeStatusListener() + if (reader !== `deferring subscriber`) { + peer = source.subscribeChanges(() => {}) oracleAssertion( - 'expect(starts, `direct demand starts the source`).toBe(', + 'expect(starts, `later demand reuses or restarts the source`).toBe(', () => - expect(starts, `direct demand starts the source`).toBe( - reader === `deferring subscriber` ? 0 : expected.starts, + expect(starts, `later demand reuses or restarts the source`).toBe( + expected.starts + (cleanupAt === `none` ? 0 : 1), ), ) - oracleAssertion('expect(cleanups).toBe(expected.cleanups)', () => - expect(cleanups).toBe(expected.cleanups), - ) - oracleAssertion('expect(source.status).toBe(', () => - expect(source.status).toBe( - reader === `deferring subscriber` - ? `idle` - : expected.statusOnReturn, - ), - ) - oracleAssertion('expect(statuses).toEqual(', () => - expect(statuses).toEqual( - reader === `deferring subscriber` ? [] : expected.statusesOnReturn, - ), - ) - pendingCleanup.resolve() - await cleanup - await directRead - oracleAssertion('expect(source.status).toBe(', () => - expect(source.status).toBe( - reader === `deferring subscriber` - ? `idle` - : expected.statusAfterCleanup, - ), - ) - oracleAssertion('expect(statuses).toEqual(', () => - expect(statuses).toEqual( - reader === `deferring subscriber` - ? [] - : expected.statusesAfterCleanup, - ), + oracleAssertion('expect(source.status).toBe(`ready`)', () => + expect(source.status).toBe(`ready`), ) - if (cleanupAt === `none`) { - const expectsRow = - reader !== `deferring subscriber` && - (syncMode === `eager` || - reader === `subscriber` || - reader === `resumed subscriber`) - oracleAssertion( - 'expect(source.get(`one`)?.value).toBe(expectsRow ? `live` : undefined)', - () => - expect(source.get(`one`)?.value).toBe( - expectsRow ? `live` : undefined, - ), - ) - } - removeStatusListener() - if (reader !== `deferring subscriber`) { - peer = source.subscribeChanges(() => {}) - oracleAssertion( - 'expect(starts, `later demand reuses or restarts the source`).toBe(', - () => - expect(starts, `later demand reuses or restarts the source`).toBe( - expected.starts + (cleanupAt === `none` ? 0 : 1), - ), - ) - oracleAssertion('expect(source.status).toBe(`ready`)', () => - expect(source.status).toBe(`ready`), - ) - } - } finally { - removeStatusListener() - pendingCleanup.resolve() - await cleanup - peer?.unsubscribe() - subscription?.unsubscribe() - mounted.unmount() - await client.cleanup() - await directRead?.catch(() => {}) } + } finally { + removeStatusListener() + pendingCleanup.resolve() + await cleanup + peer?.unsubscribe() + subscription?.unsubscribe() + mounted.unmount() + await client.cleanup() + await directRead?.catch(() => {}) + } + } + oracleProperty({ + id: `react.descriptor-abandoned-render-reader`, + law: { + statement: `A suspended render owns no provider demand; a later independent preload or subscriber starts exactly one sync run, a deferring subscriber preserves the zero-start cut, and startup cleanup retires the start until fresh demand restarts it.`, + authority: `The standalone Query contract and SSR factory-descriptor promise documented above; modelSubscriberStartCleanup.`, + limits: `The unchanged reader, startSync, sync mode and subscriber cleanup-point histories through one abandoned DbProvider render.`, }, - ) - 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) => - mockSyncCollectionOptions({ - id: `standalone-descriptor-rows-${name}`, - getKey: (row) => row.id, - initialData: client.requireDependency>(`rows`), - }), - ) - // Definition happens outside React and before either DbClient exists. - const query = new Query() - .from({ item: descriptor }) + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories: abandonedRenderReaderHistories, + label: (history) => + `${history.reader} after an abandoned render (startSync=${history.startSync}, ${history.syncMode}, cleanup=${history.cleanupAt}, pending=${history.cleanupPending})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `abandoned render descriptor demand`, + plan, + observe, + () => runAbandonedRenderReader(plan.history), + ), + check: forwardAssertionJudgment, + }) + async function runPrebuiltQueryBinding({ + name, + initial, + writes, + }: (typeof histories)[number]): Promise { + const descriptor = collectionOptions( + `standalone-descriptor-rows-${name}`, + (client) => + mockSyncCollectionOptions({ + id: `standalone-descriptor-rows-${name}`, + getKey: (row) => row.id, + initialData: client.requireDependency>(`rows`), + }), + ) + // Definition happens outside React and before either DbClient exists. + const query = new Query() + .from({ item: descriptor }) + .select(({ item }) => ({ id: item.id, value: item.value })) + const firstModel = new Map([[initial.first.id, initial.first]]) + const secondModel = new Map([ + [initial.second.id, initial.second], + ]) + const seedRows = (model: ReadonlyMap) => + expectedRows(model).map((row) => ({ ...row })) + const firstClient = new DbClient({ rows: seedRows(firstModel) }) + const secondClient = new DbClient({ rows: seedRows(secondModel) }) + const planIdentity = getStableQueryBuilderHash(query) + const boundShape = (client: DbClient) => + new Query() + .from({ item: client.collection(descriptor) }) .select(({ item }) => ({ id: item.id, value: item.value })) - const firstModel = new Map([ - [initial.first.id, initial.first], - ]) - const secondModel = new Map([ - [initial.second.id, initial.second], - ]) - const seedRows = (model: ReadonlyMap) => - expectedRows(model).map((row) => ({ ...row })) - const firstClient = new DbClient({ rows: seedRows(firstModel) }) - const secondClient = new DbClient({ rows: seedRows(secondModel) }) - const planIdentity = getStableQueryBuilderHash(query) - const boundShape = (client: DbClient) => - new Query() - .from({ item: client.collection(descriptor) }) - .select(({ item }) => ({ id: item.id, value: item.value })) - oracleAssertion('expect(planIdentity).toBe(', () => - expect(planIdentity).toBe( - getStableQueryBuilderHash(boundShape(firstClient)), - ), - ) - oracleAssertion('expect(planIdentity).toBe(', () => - expect(planIdentity).toBe( - getStableQueryBuilderHash(boundShape(secondClient)), - ), - ) - let firstReceivingClient = firstClient - const first = renderHook(() => useLiveQuery({ query }), { - wrapper: ({ children }: { children: ReactNode }) => ( - {children} - ), + oracleAssertion('expect(planIdentity).toBe(', () => + expect(planIdentity).toBe( + getStableQueryBuilderHash(boundShape(firstClient)), + ), + ) + oracleAssertion('expect(planIdentity).toBe(', () => + expect(planIdentity).toBe( + getStableQueryBuilderHash(boundShape(secondClient)), + ), + ) + let firstReceivingClient = firstClient + const first = renderHook(() => useLiveQuery({ query }), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }) + const second = renderHook(() => useLiveQuery({ query }), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }) + // Compare after each publication. The same descriptor ID and query shape + // must not make either provider observe the other provider's Collection. + const check = async ( + firstScopeModel: ReadonlyMap = firstModel, + ) => { + await waitFor(() => { + for (const [observed, model] of [ + [first.result.current.data, firstScopeModel], + [second.result.current.data, secondModel], + ] as const) { + oracleAssertion('expect(', () => + expect( + observed + .map(({ id, value }) => ({ id, value })) + .sort((left, right) => left.id.localeCompare(right.id)), + ).toEqual(expectedRows(model)), + ) + for (const row of observed) { + oracleAssertion('expect(row.$key).toBe(row.id)', () => + expect(row.$key).toBe(row.id), + ) + oracleAssertion('expect(Object.keys(row).sort()).toEqual(', () => + expect(Object.keys(row).sort()).toEqual( + [ + `$collectionId`, + `$hasPendingWrites`, + `$key`, + `$origin`, + `id`, + `value`, + ].sort(), + ), + ) + } + } }) - const second = renderHook(() => useLiveQuery({ query }), { - wrapper: ({ children }: { children: ReactNode }) => ( - {children} + } + await check() + oracleAssertion( + 'expect(firstClient.collection(descriptor)).not.toBe(', + () => + expect(firstClient.collection(descriptor)).not.toBe( + secondClient.collection(descriptor), ), + ) + const write = (client: DbClient, model: Map, next: Row) => { + act(() => { + const collection = client.collection(descriptor) + if (model.has(next.id)) { + collection.update(next.id, (draft) => { + draft.value = next.value + }) + } else { + collection.insert({ ...next }) + } }) - // Compare after each publication. The same descriptor ID and query shape - // must not make either provider observe the other provider's Collection. - const check = async ( - firstScopeModel: ReadonlyMap = firstModel, - ) => { - await waitFor(() => { - for (const [observed, model] of [ - [first.result.current.data, firstScopeModel], - [second.result.current.data, secondModel], - ] as const) { - oracleAssertion('expect(', () => - expect( - observed - .map(({ id, value }) => ({ id, value })) - .sort((left, right) => left.id.localeCompare(right.id)), - ).toEqual(expectedRows(model)), - ) - for (const row of observed) { - oracleAssertion('expect(row.$key).toBe(row.id)', () => - expect(row.$key).toBe(row.id), - ) - oracleAssertion('expect(Object.keys(row).sort()).toEqual(', () => - expect(Object.keys(row).sort()).toEqual( - [ - `$collectionId`, - `$hasPendingWrites`, - `$key`, - `$origin`, - `id`, - `value`, - ].sort(), - ), - ) - } - } - }) - } - await check() - oracleAssertion( - 'expect(firstClient.collection(descriptor)).not.toBe(', - () => - expect(firstClient.collection(descriptor)).not.toBe( - secondClient.collection(descriptor), - ), - ) - const write = (client: DbClient, model: Map, next: Row) => { - act(() => { - const collection = client.collection(descriptor) - if (model.has(next.id)) { - collection.update(next.id, (draft) => { - draft.value = next.value - }) - } else { - collection.insert({ ...next }) - } - }) - applyModelWrite(model, next) - } - write(firstClient, firstModel, writes.first) - await check() - write(secondClient, secondModel, writes.second) - await check() - firstReceivingClient = secondClient - first.rerender() - await check(secondModel) - firstReceivingClient = firstClient - first.rerender() - await check() - first.unmount() - second.unmount() + applyModelWrite(model, next) + } + write(firstClient, firstModel, writes.first) + await check() + write(secondClient, secondModel, writes.second) + await check() + firstReceivingClient = secondClient + first.rerender() + await check(secondModel) + firstReceivingClient = firstClient + first.rerender() + await check() + first.unmount() + second.unmount() + } + oracleProperty({ + id: `react.descriptor-prebuilt-query-binding`, + law: { + statement: `One prebuilt descriptor Query binds to each receiving DbClient, and each client's rows follow only its own writes, including across a mounted provider switch.`, + authority: `The standalone Query contract and SSR factory-descriptor promise documented above; the per-client row model expectedRows and applyModelWrite.`, + limits: `The disjoint-key and colliding-key two-client histories; no on-demand load or other query clause.`, }, - ) + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories, + label: ({ name }) => + `binds one prebuilt Query to each receiving DbClient with ${name}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `prebuilt descriptor Query binding`, + plan, + observe, + () => runPrebuiltQueryBinding(plan.history), + ), + check: forwardAssertionJudgment, + }) }) /** * React receives the source-error premise through actual descriptor startup. @@ -781,6 +830,10 @@ describe(`SSR snapshot admission`, () => { ordinary: `react.ssr-ordinary`, infinite: `react.ssr-infinite`, } as const + const exampleProperties = { + ordinary: `react.ssr-ordinary-examples`, + infinite: `react.ssr-infinite-examples`, + } as const for (const hook of [`ordinary`, `infinite`] as const) { const snapshotStates = [ `cold-loading`, @@ -1080,12 +1133,33 @@ describe(`SSR snapshot admission`, () => { await server.cleanup() } } - for (const history of ssrSnapshotExamples) { - it(`replays ${hook} SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, async () => { - oracleRoute(`case`) - await checkHistory(history, makeReach()) - }) - } + // The shared examples replayed with a reach recorder that records + // nothing, as the former per-example tests did. The generated property + // below also runs them as fixed histories. + oracleProperty({ + id: exampleProperties[hook], + law: { + statement: `Server rows and readiness are admitted only when the first browser hook can reproduce them; transported seeds give way to browser sync after commit.`, + authority: `expectedSsrSnapshot from ssr-snapshot-admission-oracle.ts, maintainer server-only snapshot rejection decision and SSR guide rendered-view law.`, + limits: `The shared ssrSnapshotExamples through the unchanged ordinary/infinite server render and hydrateRoot admission paths.`, + }, + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories: ssrSnapshotExamples, + label: (history) => + `replays ${hook} SSR ${history.preparation}/${history.sourceState}/${history.rows.length}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`SSR snapshot admission`, plan, observe, () => + checkHistory(plan.history, makeReach()), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: properties[hook], law: { @@ -1600,77 +1674,97 @@ const createStreamReach = ( reach: (state: (typeof streamStates)[number]) => observe(`reach`, state), }) 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}`, () => { - oracleRoute(`case`) - return checkStreamCut( - { - count: 0, + // Named histories that the generated stream grammar cannot take back: each + // omits fields that the grammar always supplies. They replay through the + // same checkStreamCut driver with a reach recorder that records nothing, as + // the former per-history tests did. + const streamFixedHistories: Array<{ + name: string + history: SsrStreamHistory + }> = [ + ...[false, true].flatMap((ordinaryFirst) => + [false, true].map((splitHydration) => ({ + name: `retains an ordinary loading view when its stream later fails, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, + history: { + count: 0, + writes: 0, + ordinaryFirst, + ordinaryLast: !ordinaryFirst, + holdSourceReadiness: true, + sourceError: true, + splitHydration, + }, + })), + ), + ...[0, 1].flatMap((count) => + [false, true].flatMap((ordinaryFirst) => + [false, true].map((splitHydration) => ({ + name: `hydrates each held-source view with count=${count}, ordinaryFirst=${ordinaryFirst}, split=${splitHydration}`, + history: { + count, writes: 0, ordinaryFirst, ordinaryLast: !ordinaryFirst, holdSourceReadiness: true, - sourceError: true, + browserWrite: true, 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}`, () => { - oracleRoute(`case`) - return checkStreamCut( - { - count, - writes: 0, - ordinaryFirst, - ordinaryLast: !ordinaryFirst, - holdSourceReadiness: true, - browserWrite: true, - splitHydration, - }, - createStreamReach(), - ) - }) - 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`, () => { - oracleRoute(`fixed history`) - return checkStreamCut( - { count: 1, writes: 0, ordinaryLast: true, browserWrite: true }, - createStreamReach(), - ) - }) - it(`retains the Suspense view after its ordinary sibling commits`, () => { - oracleRoute(`fixed history`) - return checkStreamCut( - { + })), + ), + ), + { + name: `keeps an ordinary reader from blocking a shared Suspense preload`, + history: { count: 1, writes: 0, ordinaryFirst: true, browserWrite: true }, + }, + { + name: `hydrates an ordinary reader rendered after its Suspense sibling`, + history: { count: 1, writes: 0, ordinaryLast: true, browserWrite: true }, + }, + { + name: `retains the Suspense view after its ordinary sibling commits`, + history: { count: 1, writes: 0, ordinaryFirst: true, browserWrite: true, splitHydration: true, }, - createStreamReach(), - ) + }, + ...[ + { count: 0, writes: 1 }, + { count: 1, writes: 1 }, + { count: 1, writes: 0 }, + ].map((history) => ({ + name: `replays count=${history.count} later-writes=${history.writes}`, + history, + })), + ] + oracleProperty({ + id: `react.ssr-stream-cut-fixed`, + law: { + statement: `Each first browser reader reproduces its own rendered server view, including ordinary loading beside a pending or failed Suspense stream.`, + authority: `SSR guide per-reader hydration promise and ssrStreamHydrationMismatches independent metamorphic comparison.`, + limits: `Named regressions that the generated stream grammar cannot produce; each is replayed through the same driver and comparison.`, + }, + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories: streamFixedHistories, + label: ({ name }) => name, + resolve: ({ history }) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `SSR stream first-view transport`, + plan, + observe, + () => checkStreamCut(plan.history, createStreamReach()), + ), + check: forwardAssertionJudgment, }) - 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}`, () => { - oracleRoute(`case`) - return checkStreamCut(history, createStreamReach()) - }) const streamArbitrary = fc .record({ count: fc.integer({ min: 0, max: 3 }), diff --git a/packages/react-db/tests/descriptor-start-release-calibration.test.tsx b/packages/react-db/tests/descriptor-start-release-calibration.test.tsx new file mode 100644 index 0000000000..55d1069e75 --- /dev/null +++ b/packages/react-db/tests/descriptor-start-release-calibration.test.tsx @@ -0,0 +1,26 @@ +// Checker calibration for the React descriptor start and release oracle: a +// hand-built multi-source observation, with no production render, must fail +// checkDescriptorStart for each planted fault. Moved unchanged from +// descriptor-start-release-oracle.test.tsx, which registers only +// oracleProperty tests. +import { expect, it } from 'vitest' +import { + checkDescriptorStart, + expectedDescriptorStart, +} from '../../db/tests/descriptor-start-release-oracle' + +it.each([ + `blocked-peer`, + `duplicate-start`, + `leaked-resource`, + `wrong-row`, +] as const)(`rejects wrong multi-source release observation: %s`, (fault) => { + const history = { acquired: 3, failedStarts: 3, eagerStarts: 7 } + const actual = expectedDescriptorStart(history, `cleaned`) + checkDescriptorStart(actual, history, `cleaned`) + if (fault === `blocked-peer`) actual.starts[2] = 0 + if (fault === `duplicate-start`) actual.starts[2] = actual.starts[2]! + 1 + if (fault === `leaked-resource`) actual.cleanups[2] = 0 + if (fault === `wrong-row`) actual.rows[2] = [{ id: `bad`, value: `bad` }] + expect(() => checkDescriptorStart(actual, history, `cleaned`)).toThrow() +}) 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 644676493c..58eb52b0ab 100644 --- a/packages/react-db/tests/descriptor-start-release-oracle.test.tsx +++ b/packages/react-db/tests/descriptor-start-release-oracle.test.tsx @@ -7,7 +7,6 @@ * No graph from the rejected input may start a lazy source on its own. */ import { cleanup, renderHook } from '@testing-library/react' -import { expect, it } from 'vitest' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' @@ -19,7 +18,6 @@ import { import { differs, oracleProperty, - oracleRoute, pass, violation, } from '../../db/tests/oracle-config' @@ -151,20 +149,3 @@ for (const hook of hooks) { check: checkRecordedHistory, }) } - -it.each([ - `blocked-peer`, - `duplicate-start`, - `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`) - if (fault === `blocked-peer`) actual.starts[2] = 0 - if (fault === `duplicate-start`) actual.starts[2] = actual.starts[2]! + 1 - if (fault === `leaked-resource`) actual.cleanups[2] = 0 - if (fault === `wrong-row`) actual.rows[2] = [{ id: `bad`, value: `bad` }] - expect(() => checkDescriptorStart(actual, history, `cleaned`)).toThrow() -}) diff --git a/packages/react-db/tests/effect-callback-oracle.test.tsx b/packages/react-db/tests/effect-callback-oracle.test.tsx index 24bd6d750f..9fa423965c 100644 --- a/packages/react-db/tests/effect-callback-oracle.test.tsx +++ b/packages/react-db/tests/effect-callback-oracle.test.tsx @@ -5,7 +5,7 @@ * Observation cuts follow act and one host turn. This covers eager sources, * not arbitrary host scheduling or other framework bindings. */ -import { afterAll, test } from 'vitest' +import { afterAll } from 'vitest' import { act, renderHook } from '@testing-library/react' import { Query, createCollection } from '@tanstack/db' import { useLiveQueryEffect } from '../src/useLiveQueryEffect' @@ -15,9 +15,15 @@ import { runCallbackHistory, } from '../../db/tests/query/effect-callback-oracle' import { checkWithCleanup } from '../../db/tests/query/virtual-row-fields-support' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { flushPromises, mockSyncCollectionOptions } from '../../db/tests/utils' import type { + CallbackHistory, CallbackOutput, CallbackRow, } from '../../db/tests/query/effect-callback-oracle' @@ -33,73 +39,93 @@ afterAll(() => { Object.fromEntries(reach.counts()), ) }) -test.each(callbackHistories)( - `receives current Effect values across callback profiles: %j`, - async (history) => { - oracleRoute(`case`) - ran = true - await runCallbackHistory( - history, - { - mount: async ({ skipInitial, ordered, project, handlers }) => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `callback-react`, - getKey: (row) => row.id, - initialData: [{ id: 1, version: 0 }], - }), - ) - await source.preload() - const rows = new Query().from({ row: source }) - const query = ( - ordered ? rows.orderBy(({ row }) => row.id).limit(10) : rows - ).fn.select(({ row }) => project(row)) - const mounted = renderHook( - ({ config }: { config: EffectConfig }) => - useLiveQueryEffect(config), - { initialProps: { config: { query, skipInitial, ...handlers } } }, - ) - await act(async () => { - await flushPromises() - }) - return { - write: async (type, version) => { - await act(async () => { - source.utils.begin() - source.utils.write({ type, value: { id: 1, version } }) - source.utils.commit() - await flushPromises() - }) - }, - replaceHandlers: async (replacement) => { - mounted.rerender({ - config: { query, skipInitial, ...replacement }, - }) - await act(async () => { - await flushPromises() - }) - }, - dispose: async () => { +async function runReactCallbackHistory( + history: CallbackHistory, +): Promise { + ran = true + await runCallbackHistory( + history, + { + mount: async ({ skipInitial, ordered, project, handlers }) => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `callback-react`, + getKey: (row) => row.id, + initialData: [{ id: 1, version: 0 }], + }), + ) + await source.preload() + const rows = new Query().from({ row: source }) + const query = ( + ordered ? rows.orderBy(({ row }) => row.id).limit(10) : rows + ).fn.select(({ row }) => project(row)) + const mounted = renderHook( + ({ config }: { config: EffectConfig }) => + useLiveQueryEffect(config), + { initialProps: { config: { query, skipInitial, ...handlers } } }, + ) + await act(async () => { + await flushPromises() + }) + return { + write: async (type, version) => { + await act(async () => { + source.utils.begin() + source.utils.write({ type, value: { id: 1, version } }) + source.utils.commit() + await flushPromises() + }) + }, + replaceHandlers: async (replacement) => { + mounted.rerender({ + config: { query, skipInitial, ...replacement }, + }) + await act(async () => { + await flushPromises() + }) + }, + dispose: async () => { + mounted.unmount() + await act(async () => { + await flushPromises() + }) + }, + cleanup: () => + checkWithCleanup(async () => { mounted.unmount() await act(async () => { await flushPromises() }) - }, - cleanup: () => - checkWithCleanup(async () => { - mounted.unmount() - await act(async () => { - await flushPromises() - }) - }, [ - async () => { - await source.cleanup() - }, - ]), - } - }, + }, [ + async () => { + await source.cleanup() + }, + ]), + } }, - reach, - ) + }, + reach, + ) +} + +const CALLBACK_LAW = `React Effect callback values` + +oracleProperty({ + id: `react.effect-callback`, + law: { + statement: `useLiveQueryEffect callbacks receive the current Effect values that the shared callback model owes across callback profiles and handler replacement.`, + authority: `The core Effect metadata owner's callback law in effect-callback-oracle.ts.`, + limits: `The shared bounded callback histories with eager sources; observation cuts follow act and one host turn, not arbitrary host scheduling or other framework bindings.`, }, -) + owns: [`src/useLiveQueryEffect.ts#useLiveQueryEffect`], + histories: callbackHistories, + label: (history) => + `receives current Effect values across callback profiles: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(CALLBACK_LAW, plan, observe, () => + runReactCallbackHistory(plan.history), + ), + check: forwardAssertionJudgment, +}) 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 859ac27a2b..e26b168a82 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 @@ -32,14 +32,19 @@ */ import { StrictMode, Suspense, useLayoutEffect } from 'react' import { act, render, renderHook, waitFor } from '@testing-library/react' -import { afterEach, describe, expect, it } from 'vitest' +import { afterEach, describe, expect } from 'vitest' import { BTreeIndex, createCollection, createLiveQueryCollection, } from '@tanstack/db' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveQuery } from '../src/useLiveQuery' import { @@ -52,10 +57,25 @@ import { import type { Row } from './infinite-query-render-cuts-support' import type { ReactNode } from 'react' -afterEach(async () => { - // Unmount hooks before cleaning up the sources they read. +// Unmount hooks before cleaning up the sources they read. +async function runCleanups(): Promise { for (const cleanup of cleanups.splice(0).reverse()) await cleanup() -}) +} + +afterEach(runCleanups) + +// Each history drains its own cleanups, so a property test that runs several +// histories in turn, such as the determinism test, unmounts each history's +// hooks and sources before the next one starts. +async function withHistoryCleanups(run: () => Promise): Promise { + try { + await run() + } finally { + await runCleanups() + } +} + +const RENDER_CUTS_LAW = `React infinite-query pre-commit cut` type HookName = `live` | `infinite` type InputForm = `query` | `supplied` @@ -183,28 +203,55 @@ async function checkAbandonedRenderAcquisitions( ) } +async function checkStrictModeFirstWindow(): Promise { + const reference = await firstWindowLoadsForStrictMount(`live`) + // No provider work before commit: the on-demand window is unpublished. + expect(reference.firstCommit).toEqual({ status: `loading`, ids: [] }) + // React 19 keeps refs across the double render, so both hooks request the + // first window once, after commit. React 18 does not; see the coverage map. + expect(reference.loads).toBe(1) + expect(await firstWindowLoadsForStrictMount(`infinite`)).toEqual(reference) +} + +type OnDemandHistory = + | { law: `abandoned render`; form: InputForm; wrapped: boolean } + | { law: `StrictMode double render` } + +function runOnDemandHistory(history: OnDemandHistory): Promise { + return history.law === `abandoned render` + ? checkAbandonedRenderAcquisitions(history.form, history.wrapped) + : checkStrictModeFirstWindow() +} + describe(`on-demand sources match useLiveQuery`, () => { - it.each([ - { form: `query` as const, wrapped: false }, - { form: `query` as const, wrapped: true }, - { form: `supplied` as const, wrapped: true }, - ])( - `starts no acquisition attempt for a render that never commits, as useLiveQuery does (%o)`, - async ({ form, wrapped }) => { - oracleRoute(`case`) - await checkAbandonedRenderAcquisitions(form, wrapped) + oracleProperty({ + id: `react.infinite-query-render-cuts.on-demand`, + law: { + statement: `A render that never commits starts no acquisition attempt on an on-demand source, and a StrictMode double render requests the first window once after commit with a loading first commit, as useLiveQuery does.`, + authority: `The pre-commit cut laws documented above; useLiveQuery under the same history is the reference.`, + limits: `Query-callback, wrapped and supplied on-demand reads, and one StrictMode mount, under React 19; React 18 ref retention is recorded in the coverage map.`, }, - ) - - 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: [] }) - // React 19 keeps refs across the double render, so both hooks request the - // first window once, after commit. React 18 does not; see the coverage map. - expect(reference.loads).toBe(1) - expect(await firstWindowLoadsForStrictMount(`infinite`)).toEqual(reference) + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveQuery.ts#useLiveQuery`, + ], + histories: [ + { law: `abandoned render`, form: `query`, wrapped: false }, + { law: `abandoned render`, form: `query`, wrapped: true }, + { law: `abandoned render`, form: `supplied`, wrapped: true }, + { law: `StrictMode double render` }, + ] satisfies Array, + label: (history: OnDemandHistory) => + history.law === `abandoned render` + ? `starts no acquisition attempt for a render that never commits, as useLiveQuery does ({ form: '${history.form}', wrapped: ${history.wrapped} })` + : `requests the first window and commits the first value as useLiveQuery does across a StrictMode double render`, + resolve: (history: OnDemandHistory) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(RENDER_CUTS_LAW, plan, observe, () => + withHistoryCleanups(() => runOnDemandHistory(plan.history)), + ), + check: forwardAssertionJudgment, }) }) @@ -217,43 +264,65 @@ function expectedReadyFirstCommit(count: number): { return { status: `ready`, ids: rows(count).map((row) => row.id) } } -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 }, - ) => { - const commits: Array<{ status: string; ids: Array }> = [] - const hook = renderHook(() => { - const result = useResult() - useLayoutEffect(() => { - commits.push({ - status: result.status, - ids: result.data.map((row) => row.id), - }) +async function checkSyncFirstCommitAgreement(): Promise { + const source = makeSyncSource(10) + const firstCommit = ( + useResult: () => { status: string; data: Array }, + ) => { + const commits: Array<{ status: string; ids: Array }> = [] + const hook = renderHook(() => { + const result = useResult() + useLayoutEffect(() => { + commits.push({ + status: result.status, + ids: result.data.map((row) => row.id), }) - return result }) - cleanups.push(() => hook.unmount()) - return commits - } + return result + }) + cleanups.push(() => hook.unmount()) + return commits + } - const live = firstCommit(() => - useLiveQuery((q) => - q.from({ row: source }).orderBy(({ row }) => row.rank, `desc`), - ), - ) - const infinite = firstCommit(() => - useLiveInfiniteQuery( - (q) => q.from({ row: source }).orderBy(({ row }) => row.rank, `desc`), - { pageSize: 20 }, - ), - ) - await waitFor(() => expect(infinite.length).toBeGreaterThan(0)) + const live = firstCommit(() => + useLiveQuery((q) => + q.from({ row: source }).orderBy(({ row }) => row.rank, `desc`), + ), + ) + const infinite = firstCommit(() => + useLiveInfiniteQuery( + (q) => q.from({ row: source }).orderBy(({ row }) => row.rank, `desc`), + { pageSize: 20 }, + ), + ) + await waitFor(() => expect(infinite.length).toBeGreaterThan(0)) + + const expected = expectedReadyFirstCommit(10) + expect(live[0]).toEqual(expected) + expect(infinite[0]).toEqual(expected) +} - const expected = expectedReadyFirstCommit(10) - expect(live[0]).toEqual(expected) - expect(infinite[0]).toEqual(expected) +describe(`agreement with useLiveQuery`, () => { + oracleProperty({ + id: `react.infinite-query-render-cuts.sync-agreement`, + law: { + statement: `For the same synchronous source, useLiveInfiniteQuery and useLiveQuery commit the same ready rows first, in source order.`, + authority: `The agreement law documented above; expectedReadyFirstCommit.`, + limits: `One ten-row synchronous source and one mount of each hook.`, + }, + owns: [ + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveQuery.ts#useLiveQuery`, + ], + histories: [{ source: `synchronous` }], + label: () => + `commits the same ready rows first as useLiveQuery for a synchronous source`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(RENDER_CUTS_LAW, plan, observe, () => + withHistoryCleanups(checkSyncFirstCommitAgreement), + ), + check: forwardAssertionJudgment, }) }) 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 97ff17eb66..7382525dfa 100644 --- a/packages/react-db/tests/source-id-reuse-oracle.test.tsx +++ b/packages/react-db/tests/source-id-reuse-oracle.test.tsx @@ -56,13 +56,13 @@ import { eq, gt, } from '@tanstack/db' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { Suspense } from 'react' import { DbProvider } from '../src/DbProvider' import { useLiveQuery } from '../src/useLiveQuery' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' import { useLiveSuspenseQuery } from '../src/useLiveSuspenseQuery' -import { oracleProperty, oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -76,12 +76,17 @@ import { } from '../../db/tests/live-query-bound-options-oracle' import { withOracleCleanup } from '../../db/tests/utils' import { TestErrorBoundary, source } from './source-id-reuse-support' +import { + checkReleaseObservation, + expectedReleaseObservation, +} from './source-id-reuse-release-oracle' import type { Collection, InitialQueryBuilder, NonSingleResult, } from '@tanstack/db' import type { Row } from './source-id-reuse-support' +import type { ReleaseObservation } from './source-id-reuse-release-oracle' /** * A render may allocate getKey/schema closures without changing the query. * Maintainer decision: options bind until the query or explicit queryKey changes. @@ -546,11 +551,6 @@ releaseHooks[`useLiveSuspenseQuery with queryKey`] = { } type SharedReleaseCollection = Collection & NonSingleResult -type ReleaseObservation = { - starts: number - status: string - rows: Array -} type ReleaseReader = (shared: SharedReleaseCollection) => { observe: () => Omit ready?: Promise @@ -618,23 +618,6 @@ const releaseReaders: Record = { } }, } -/** The release judgment knows authored source facts, not hook/cache machinery. - * Independent demand starts one healthy source. A later source publication - * replaces the row's value, and every direct reader must receive that whole row. - * An after-hook throw/suspension can be reclaimed before or after the read; it - * owes the same final result. No synchronous reclaim deadline is imposed. - */ -function expectedReleaseObservation(row: Row): ReleaseObservation { - return { starts: 1, status: `ready`, rows: [{ ...row }] } -} -function checkReleaseObservation( - actual: ReleaseObservation, - expected: ReleaseObservation, -): void { - oracleAssertion('expect(actual).toEqual(expected)', () => - expect(actual).toEqual(expected), - ) -} /** Every rendered history must first materialize its shared descriptor. The * premise is checked before independent demand, rather than inferred from final * rows. Initial mount and rerender are different receiving paths. The source-ID @@ -888,39 +871,20 @@ describe(`React source ID reuse: rejected renders release deferred sync starts`, check: forwardAssertionJudgment, }) }) -it.each([ - `missing-start`, - `extra-start`, - `wrong-row`, - `duplicate-row`, -] as const)(`rejects wrong descriptor release observation: %s`, (fault) => { - oracleRoute(`calibration`) - const expected = expectedReleaseObservation({ - id: `one`, - value: `authored`, - rank: 2, - }) - const actual = { ...expected, rows: expected.rows.map((row) => ({ ...row })) } - oracleAssertion('checkReleaseObservation', () => - checkReleaseObservation(actual, expected), - ) - if (fault === `missing-start`) actual.starts = 0 - if (fault === `extra-start`) actual.starts = 2 - if (fault === `wrong-row`) actual.rows[0]!.rank++ - if (fault === `duplicate-row`) actual.rows.push({ ...actual.rows[0]! }) - oracleAssertion( - 'expect(() => checkReleaseObservation(actual, expected)).toThrow()', - () => - expect(() => - oracleAssertion('checkReleaseObservation', () => - checkReleaseObservation(actual, expected), - ), - ).toThrow(), - ) -}) describe(`React source ID reuse`, () => { - it(`rejects a different active source with the same ID before exposing stale rows`, async () => { - oracleRoute(`fixed history`) + // Named fixed histories, each replayed through checkRerender and its + // expectedCollision model. The generated grammar cannot produce them, so + // they form one enumerated property; each name selects its former test body. + const liveFixedHistories = [ + `rejects a different active source with the same ID before exposing stale rows`, + `rejects a previously used ID after another source and a query change`, + `accepts the same source object and a different source ID`, + `keeps a mounted hook's ID binding after source cleanup`, + `remembers each client scope when one mounted hook switches clients`, + `rejects same-ID reuse after a suspended render starts a source query`, + ] as const + type LiveFixedHistory = (typeof liveFixedHistories)[number] + async function checkActiveSameIdSource(): Promise { const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook( @@ -937,9 +901,8 @@ describe(`React source ID reuse`, () => { checkRerender([{ source: first }], { source: second }, () => hook.rerender({ current: second }), ) - }) - it(`rejects a previously used ID after another source and a query change`, async () => { - oracleRoute(`fixed history`) + } + async function checkReusedIdAfterOtherSource(): Promise { const first = source(`settings`, `first`) const other = source(`other`, `other`) const second = source(`settings`, `second`) @@ -971,9 +934,8 @@ describe(`React source ID reuse`, () => { { source: second }, () => hook.rerender({ current: second, minimum: -1 }), ) - }) - it(`accepts the same source object and a different source ID`, async () => { - oracleRoute(`fixed history`) + } + async function checkSameObjectAndFreshId(): Promise { const first = source(`settings`, `first`) const other = source(`other`, `other`) const hook = renderHook( @@ -1003,9 +965,8 @@ describe(`React source ID reuse`, () => { () => expect(hook.result.current.data[0]?.value).toBe(`other`), ), ) - }) - it(`keeps a mounted hook's ID binding after source cleanup`, async () => { - oracleRoute(`fixed history`) + } + async function checkBindingAfterSourceCleanup(): Promise { const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook( @@ -1023,9 +984,8 @@ describe(`React source ID reuse`, () => { checkRerender([{ source: first }], { source: second }, () => hook.rerender({ current: second }), ) - }) - it(`remembers each client scope when one mounted hook switches clients`, async () => { - oracleRoute(`fixed history`) + } + async function checkClientScopeSwitch(): Promise { const firstClient = new DbClient() const secondClient = new DbClient() const first = source(`settings`, `first`) @@ -1060,9 +1020,8 @@ describe(`React source ID reuse`, () => { { source: replacement, client: firstClient }, () => hook.rerender({ client: firstClient, current: replacement }), ) - }) - it(`rejects same-ID reuse after a suspended render starts a source query`, async () => { - oracleRoute(`fixed history`) + } + async function checkSuspendedRenderClaim(): Promise { const first = source(`first`, `first`) const abandoned = source(`candidate`, `abandoned`) const replacement = source(`candidate`, `replacement`) @@ -1105,6 +1064,45 @@ describe(`React source ID reuse`, () => { , ), ) + } + function runLiveFixedHistory(name: LiveFixedHistory): Promise { + switch (name) { + case `rejects a different active source with the same ID before exposing stale rows`: + return checkActiveSameIdSource() + case `rejects a previously used ID after another source and a query change`: + return checkReusedIdAfterOtherSource() + case `accepts the same source object and a different source ID`: + return checkSameObjectAndFreshId() + case `keeps a mounted hook's ID binding after source cleanup`: + return checkBindingAfterSourceCleanup() + case `remembers each client scope when one mounted hook switches clients`: + return checkClientScopeSwitch() + case `rejects same-ID reuse after a suspended render starts a source query`: + return checkSuspendedRenderClaim() + } + } + oracleProperty({ + id: `react.source-id-reuse-fixed`, + law: { + statement: `A mounted derived-identity hook rejects a different source object under an ID it remembers in its DbClient scope before it can return the old source rows; the same object and a fresh ID are accepted.`, + authority: `The source ID reuse contract documented above; expectedCollision.`, + limits: `Six named useLiveQuery rerender histories, including a client switch and a suspended render; no process-wide registry or explicit-key claim.`, + }, + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories: liveFixedHistories.map((name) => ({ name })), + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`source ID reuse`, plan, observe, () => + runLiveFixedHistory(plan.history.name), + ), + check: forwardAssertionJudgment, }) }) /** @@ -1125,8 +1123,17 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { q.from({ settings: current }).orderBy(({ settings }) => settings.rank), { pageSize: 1 }, ) - it(`rejects a different active source with the same ID before exposing stale rows`, async () => { - oracleRoute(`fixed history`) + // Named fixed histories, each replayed through checkRerender and its + // expectedCollision model. The generated grammar cannot produce them, so + // they form one enumerated property; each name selects its former test body. + const infiniteFixedHistories = [ + `rejects a different active source with the same ID before exposing stale rows`, + `rejects a previously used ID after another source`, + `accepts the same source object and a different source ID`, + `rejects same-ID reuse after a suspended render starts a source query`, + ] as const + type InfiniteFixedHistory = (typeof infiniteFixedHistories)[number] + async function checkActiveSameIdSource(): Promise { const first = source(`settings`, `first`) const second = source(`settings`, `second`) const hook = renderHook(({ current }) => useInfinite(current), { @@ -1141,9 +1148,8 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { checkRerender([{ source: first }], { source: second }, () => hook.rerender({ current: second }), ) - }) - it(`rejects a previously used ID after another source`, async () => { - oracleRoute(`fixed history`) + } + async function checkReusedIdAfterOtherSource(): Promise { const first = source(`settings`, `first`) const other = source(`other`, `other`) const second = source(`settings`, `second`) @@ -1168,9 +1174,8 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { { source: second }, () => hook.rerender({ current: second }), ) - }) - it(`accepts the same source object and a different source ID`, async () => { - oracleRoute(`fixed history`) + } + async function checkSameObjectAndFreshId(): Promise { const first = source(`settings`, `first`) const other = source(`other`, `other`) const hook = renderHook(({ current }) => useInfinite(current), { @@ -1194,9 +1199,8 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { () => expect(hook.result.current.data[0]?.value).toBe(`other`), ), ) - }) - it(`rejects same-ID reuse after a suspended render starts a source query`, async () => { - oracleRoute(`fixed history`) + } + async function checkSuspendedRenderClaim(): Promise { const first = source(`first`, `first`) const abandoned = source(`candidate`, `abandoned`) const replacement = source(`candidate`, `replacement`) @@ -1237,5 +1241,40 @@ describe(`React source ID reuse: useLiveInfiniteQuery`, () => { , ), ) + } + function runInfiniteFixedHistory(name: InfiniteFixedHistory): Promise { + switch (name) { + case `rejects a different active source with the same ID before exposing stale rows`: + return checkActiveSameIdSource() + case `rejects a previously used ID after another source`: + return checkReusedIdAfterOtherSource() + case `accepts the same source object and a different source ID`: + return checkSameObjectAndFreshId() + case `rejects same-ID reuse after a suspended render starts a source query`: + return checkSuspendedRenderClaim() + } + } + oracleProperty({ + id: `react.source-id-reuse-infinite-fixed`, + law: { + statement: `useLiveInfiniteQuery rejects a different same-ID source before it can publish the old source rows, from a committed or a suspended render; the same object and a fresh ID are accepted.`, + authority: `The source ID reuse contract documented above; expectedCollision.`, + limits: `Four named useLiveInfiniteQuery rerender histories with page size one.`, + }, + owns: [ + `src/DbProvider.tsx#DbProvider`, + `src/useLiveQuery.ts#useLiveQuery`, + `src/useLiveInfiniteQuery.ts#useLiveInfiniteQuery`, + `src/useLiveSuspenseQuery.ts#useLiveSuspenseQuery`, + ], + histories: infiniteFixedHistories.map((name) => ({ name })), + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`source ID reuse`, plan, observe, () => + runInfiniteFixedHistory(plan.history.name), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/react-db/tests/source-id-reuse-release-calibration.test.tsx b/packages/react-db/tests/source-id-reuse-release-calibration.test.tsx new file mode 100644 index 0000000000..a913b6df3b --- /dev/null +++ b/packages/react-db/tests/source-id-reuse-release-calibration.test.tsx @@ -0,0 +1,41 @@ +// Checker calibration for the React source ID reuse release law: a +// hand-built release observation, with no production render, passes +// checkReleaseObservation, and each planted fault makes it throw. Moved +// unchanged from source-id-reuse-oracle.test.tsx, which registers only +// oracleProperty tests. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { + checkReleaseObservation, + expectedReleaseObservation, +} from './source-id-reuse-release-oracle' + +it.each([ + `missing-start`, + `extra-start`, + `wrong-row`, + `duplicate-row`, +] as const)(`rejects wrong descriptor release observation: %s`, (fault) => { + const expected = expectedReleaseObservation({ + id: `one`, + value: `authored`, + rank: 2, + }) + const actual = { ...expected, rows: expected.rows.map((row) => ({ ...row })) } + oracleAssertion('checkReleaseObservation', () => + checkReleaseObservation(actual, expected), + ) + if (fault === `missing-start`) actual.starts = 0 + if (fault === `extra-start`) actual.starts = 2 + if (fault === `wrong-row`) actual.rows[0]!.rank++ + if (fault === `duplicate-row`) actual.rows.push({ ...actual.rows[0]! }) + oracleAssertion( + 'expect(() => checkReleaseObservation(actual, expected)).toThrow()', + () => + expect(() => + oracleAssertion('checkReleaseObservation', () => + checkReleaseObservation(actual, expected), + ), + ).toThrow(), + ) +}) diff --git a/packages/react-db/tests/source-id-reuse-release-oracle.ts b/packages/react-db/tests/source-id-reuse-release-oracle.ts new file mode 100644 index 0000000000..ec4a3ad715 --- /dev/null +++ b/packages/react-db/tests/source-id-reuse-release-oracle.ts @@ -0,0 +1,32 @@ +// The release observation, its independent model, and its check, shared by +// the React source ID reuse oracle (source-id-reuse-oracle.test.tsx) and its +// checker calibration (source-id-reuse-release-calibration.test.tsx). Moved +// unchanged from the oracle file, so the calibration can import them without +// registering the oracle's properties a second time. +import { expect } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import type { Row } from './source-id-reuse-support' + +export type ReleaseObservation = { + starts: number + status: string + rows: Array +} + +/** The release judgment knows authored source facts, not hook/cache machinery. + * Independent demand starts one healthy source. A later source publication + * replaces the row's value, and every direct reader must receive that whole row. + * An after-hook throw/suspension can be reclaimed before or after the read; it + * owes the same final result. No synchronous reclaim deadline is imposed. + */ +export function expectedReleaseObservation(row: Row): ReleaseObservation { + return { starts: 1, status: `ready`, rows: [{ ...row }] } +} +export function checkReleaseObservation( + actual: ReleaseObservation, + expected: ReleaseObservation, +): void { + oracleAssertion('expect(actual).toEqual(expected)', () => + expect(actual).toEqual(expected), + ) +} diff --git a/packages/react-db/tests/ssr-effects-calibration.test.ts b/packages/react-db/tests/ssr-effects-calibration.test.ts new file mode 100644 index 0000000000..f54fd34e0e --- /dev/null +++ b/packages/react-db/tests/ssr-effects-calibration.test.ts @@ -0,0 +1,41 @@ +// @vitest-environment node +// Checker calibration for the SSR effects oracle: a hand-built server +// observation, with no production render, must pass checkRender with an +// unrelated diagnostic and fail it with a client-layout diagnostic. Moved +// unchanged from ssr-effects-oracle.test.ts, which registers only +// oracleProperty tests. +import { expect, it } from 'vitest' +import { expectedSsrSnapshot } from '../../db/tests/ssr-snapshot-admission-oracle' +import { checkRender } from './ssr-effects-check-oracle' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' +import type { Observation } from './ssr-effects-check-oracle' + +it(`distinguishes a client-layout diagnostic from unrelated messages`, () => { + const history: SsrSnapshotHistory = { + preparation: `none`, + sourceState: `idle`, + rows: [], + } + const expected = expectedSsrSnapshot(history) + const actual: Observation = { + reactVersion: `calibration`, + rendererVersion: `calibration`, + calls: 1, + view: { rows: expected.rows, status: expected.status }, + diagnostics: [`unrelated diagnostic`], + cleanupFailures: [], + } + expect(() => checkRender(history, actual, `ordinary-provider`)).not.toThrow() + expect(() => + checkRender( + history, + { + ...actual, + diagnostics: [ + `Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded.`, + ], + }, + `ordinary-provider`, + ), + ).toThrow(/SSR client-layout diagnostic/) +}) diff --git a/packages/react-db/tests/ssr-effects-check-oracle.ts b/packages/react-db/tests/ssr-effects-check-oracle.ts new file mode 100644 index 0000000000..32ce31a568 --- /dev/null +++ b/packages/react-db/tests/ssr-effects-check-oracle.ts @@ -0,0 +1,80 @@ +// The server-render observation and its admission and diagnostic judgment, +// shared by the SSR effects oracle (ssr-effects-oracle.test.ts) and its +// checker calibration (ssr-effects-calibration.test.ts). Moved unchanged from +// the oracle file so the calibration can import it without registering the +// oracle's properties a second time. +import { expect } from 'vitest' +import { expectedSsrSnapshot } from '../../db/tests/ssr-snapshot-admission-oracle' +import { atOracleCheckpoint } from '../../db/tests/oracle-failure-fidelity' +import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' + +export type Observation = { + reactVersion: string + rendererVersion: string + calls: number + view?: { rows: unknown; status?: string } + failure?: string + diagnostics: Array + cleanupFailures: Array +} + +const isLayoutDiagnostic = (message: string) => + /useLayoutEffect does nothing on the server/i.test(message) + +// Preparation independently decides ordinary admission and rows/status. +// Completed-query Suspense shares the ready judgment. Standalone builders and +// supplied Collections check only return/diagnostics, without a transport claim. +// Keeping raw messages permits unrelated diagnostics and exposes exact warnings. +export function checkRender( + history: SsrSnapshotHistory, + actual: Observation, + path: string, + expected = expectedSsrSnapshot(history), +) { + try { + atOracleCheckpoint(`SSR path reached`, path, () => { + expect( + actual.calls, + `real server renderer invoked the hook`, + ).toBeGreaterThan(0) + }) + const descriptor = path.includes(`provider`) || path.includes(`explicit`) + atOracleCheckpoint(`SSR preparation admission`, path, () => { + if (descriptor && expected.rejects) { + expect(actual.failure, `untransported server view rejects`).toMatch( + /SSR.*(?:preload|hydrat)/i, + ) + } else { + expect(actual.failure, `legal SSR render`).toBeUndefined() + expect(actual.view, `hook returned its public view`).toBeDefined() + if (path.startsWith(`suspense`)) + expect(actual.view?.rows, `prepared Suspense rows`).toEqual( + expected.rows, + ) + else if (descriptor) + expect(actual.view, `server public view`).toEqual({ + rows: expected.rows, + status: expected.status, + }) + } + }) + if (!descriptor || !expected.rejects) + atOracleCheckpoint(`SSR client-layout diagnostic`, path, () => { + expect( + actual.diagnostics.filter(isLayoutDiagnostic), + `legal SSR emits no client-layout diagnostic: ${JSON.stringify(actual.diagnostics)}`, + ).toEqual([]) + }) + } catch (error) { + if (actual.cleanupFailures.length) + throw new AggregateError( + [error, ...actual.cleanupFailures.map((failure) => new Error(failure))], + `SSR comparison and cleanup failed`, + { cause: error }, + ) + throw error + } + atOracleCheckpoint(`SSR cleanup`, path, () => { + expect(actual.cleanupFailures, `server cleanup diagnostics`).toEqual([]) + }) +} diff --git a/packages/react-db/tests/ssr-effects-oracle.test.ts b/packages/react-db/tests/ssr-effects-oracle.test.ts index 2e8e72fdae..cc32dd7a62 100644 --- a/packages/react-db/tests/ssr-effects-oracle.test.ts +++ b/packages/react-db/tests/ssr-effects-oracle.test.ts @@ -22,7 +22,7 @@ import { tmpdir } from 'node:os' import { dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { build } from 'esbuild' -import { afterAll, beforeAll, expect, it } from 'vitest' +import { afterAll, beforeAll } from 'vitest' import { expectedSsrSnapshot, ssrSnapshotExamples, @@ -32,17 +32,16 @@ import { OracleCheckpointFailure, differs, oracleProperty, - oracleReplayTest, - oracleRoute, pass, violation, } from '../../db/tests/oracle-config' -import { atOracleCheckpoint } from '../../db/tests/oracle-failure-fidelity' +import { checkRender } from './ssr-effects-check-oracle' import type { Observation as OracleObservation, Verdict, } from '../../db/tests/oracle-config' import type { SsrSnapshotHistory } from '../../db/tests/ssr-snapshot-admission-oracle' +import type { Observation } from './ssr-effects-check-oracle' const here = dirname(fileURLToPath(import.meta.url)) const require = createRequire(import.meta.url) @@ -95,15 +94,6 @@ const histories = ssrSnapshotHistoryArbitrary.map(serverHistory, (value) => ({ browserPreload: false, })) const examples = ssrSnapshotExamples.map(serverHistory) -type Observation = { - reactVersion: string - rendererVersion: string - calls: number - view?: { rows: unknown; status?: string } - failure?: string - diagnostics: Array - cleanupFailures: Array -} const paths = [ `ordinary-provider`, `ordinary-explicit`, @@ -112,67 +102,6 @@ const paths = [ `standalone`, `supplied`, ] as const -const isLayoutDiagnostic = (message: string) => - /useLayoutEffect does nothing on the server/i.test(message) - -// Preparation independently decides ordinary admission and rows/status. -// Completed-query Suspense shares the ready judgment. Standalone builders and -// supplied Collections check only return/diagnostics, without a transport claim. -// Keeping raw messages permits unrelated diagnostics and exposes exact warnings. -function checkRender( - history: SsrSnapshotHistory, - actual: Observation, - path: string, - expected = expectedSsrSnapshot(history), -) { - try { - atOracleCheckpoint(`SSR path reached`, path, () => { - expect( - actual.calls, - `real server renderer invoked the hook`, - ).toBeGreaterThan(0) - }) - const descriptor = path.includes(`provider`) || path.includes(`explicit`) - atOracleCheckpoint(`SSR preparation admission`, path, () => { - if (descriptor && expected.rejects) { - expect(actual.failure, `untransported server view rejects`).toMatch( - /SSR.*(?:preload|hydrat)/i, - ) - } else { - expect(actual.failure, `legal SSR render`).toBeUndefined() - expect(actual.view, `hook returned its public view`).toBeDefined() - if (path.startsWith(`suspense`)) - expect(actual.view?.rows, `prepared Suspense rows`).toEqual( - expected.rows, - ) - else if (descriptor) - expect(actual.view, `server public view`).toEqual({ - rows: expected.rows, - status: expected.status, - }) - } - }) - if (!descriptor || !expected.rejects) - atOracleCheckpoint(`SSR client-layout diagnostic`, path, () => { - expect( - actual.diagnostics.filter(isLayoutDiagnostic), - `legal SSR emits no client-layout diagnostic: ${JSON.stringify(actual.diagnostics)}`, - ).toEqual([]) - }) - } catch (error) { - if (actual.cleanupFailures.length) - throw new AggregateError( - [error, ...actual.cleanupFailures.map((failure) => new Error(failure))], - `SSR comparison and cleanup failed`, - { cause: error }, - ) - throw error - } - atOracleCheckpoint(`SSR cleanup`, path, () => { - expect(actual.cleanupFailures, `server cleanup diagnostics`).toEqual([]) - }) -} - // The child process records native server facts. The same admission/diagnostic // judgment runs afterward, with runtime identity checked first as before. function checkEffects( @@ -377,39 +306,3 @@ for (const runtime of runtimes) { checkEffects(history, trace, expected, `disabled`, runtime.version), }) } - -oracleReplayTest(it)( - `distinguishes a client-layout diagnostic from unrelated messages`, - () => { - oracleRoute(`calibration`) - const history: SsrSnapshotHistory = { - preparation: `none`, - sourceState: `idle`, - rows: [], - } - const expected = expectedSsrSnapshot(history) - const actual: Observation = { - reactVersion: `calibration`, - rendererVersion: `calibration`, - calls: 1, - view: { rows: expected.rows, status: expected.status }, - diagnostics: [`unrelated diagnostic`], - cleanupFailures: [], - } - expect(() => - checkRender(history, actual, `ordinary-provider`), - ).not.toThrow() - expect(() => - checkRender( - history, - { - ...actual, - diagnostics: [ - `Warning: useLayoutEffect does nothing on the server, because its effect cannot be encoded.`, - ], - }, - `ordinary-provider`, - ), - ).toThrow(/SSR client-layout diagnostic/) - }, -) 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 91cadf06d1..530a64b633 100644 --- a/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx +++ b/packages/react-db/tests/usePacedMutations-render-cuts-oracle.test.tsx @@ -35,10 +35,15 @@ import { useState, } from 'react' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createCollection, debounceStrategy } from '@tanstack/db' import { usePacedMutations } from '../src/usePacedMutations' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { mockSyncCollectionOptions, resetCleanupQueue, @@ -104,23 +109,27 @@ const histories: ReadonlyArray = [ const collections: Array<{ cleanup: () => Promise }> = [] let collectionSequence = 0 -beforeEach(() => { +// The per-history setup and teardown, formerly beforeEach and afterEach. A +// history owns them, so a property test that runs several histories in turn, +// such as the determinism test, starts each one from the same state. +async function runPacedHistory(history: History, strictMode: boolean) { resetCleanupQueue() vi.useFakeTimers({ toFake: [`setTimeout`, `clearTimeout`] }) -}) - -afterEach(async () => { try { - cleanup() - await vi.advanceTimersByTimeAsync(100) - await Promise.all( - collections.splice(0).map((collection) => collection.cleanup()), - ) + await driveHistory(history, strictMode) } finally { - resetCleanupQueue() - vi.useRealTimers() + try { + cleanup() + await vi.advanceTimersByTimeAsync(100) + await Promise.all( + collections.splice(0).map((collection) => collection.cleanup()), + ) + } finally { + resetCleanupQueue() + vi.useRealTimers() + } } -}) +} // Rendering B does not change the committed callback owner. Committing B does. function expectedOutcome(history: History): ExpectedOutcome { @@ -294,12 +303,26 @@ async function driveHistory(history: History, strictMode: boolean) { expect(trace, `callback trace after persistence`).toEqual(expected.trace) } -describe.each([false, true])( - `paced callback ownership with StrictMode=%s`, - (strictMode) => { - it.each(histories)(`models $name`, async (history) => { - oracleRoute(`case`) - await driveHistory(history, strictMode) - }) +const PACED_LAW = `paced callback ownership` + +oracleProperty({ + id: `react.paced-mutations-render-cuts`, + law: { + statement: `usePacedMutations exposes callbacks from the latest committed render: a suspended render cannot replace them, a later commit makes its callbacks available before descendant layout effects, and mutate keeps its identity.`, + authority: `The paced callback ownership contract and expectedOutcome documented above.`, + limits: `The five bounded histories under ordinary and StrictMode schedules; no server delivery, transaction-admission affinity, or other frameworks.`, }, -) + owns: [`src/usePacedMutations.ts#usePacedMutations`], + histories: [false, true].flatMap((strictMode) => + histories.map((history) => ({ strictMode, history })), + ), + label: ({ strictMode, history }) => + `paced callback ownership with StrictMode=${strictMode}: models ${history.name}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(PACED_LAW, plan, observe, () => + runPacedHistory(plan.history.history, plan.history.strictMode), + ), + check: forwardAssertionJudgment, +}) From 95e5c000b06ed6e6348eebbca34999eb3885b961 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:59:28 -0700 Subject: [PATCH 048/116] test(db): register includes temporal fixed histories and cases through oracleProperty Move the two checker calibrations to the ordinary temporal tests, with their checks in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 9 + .../query/includes-temporal-check-oracle.ts | 50 ++++ .../query/includes-temporal-oracle.test.ts | 250 +++++++++--------- .../db/tests/query/includes-temporal.test.ts | 35 +++ 4 files changed, 225 insertions(+), 119 deletions(-) create mode 100644 packages/db/tests/query/includes-temporal-check-oracle.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 5bca5bcdb2..525ee2f3c9 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -26,6 +26,10 @@ export const properties: ReadonlyArray = [ `includes-publication.confirmation-sides`, `includes-publication.missing-callbacks`, `includes-publication.held-repair`, + `includes-temporal.readiness-fixed`, + `includes-temporal.demand-scheduling-orders`, + `includes-temporal.release-reentry-fixed`, + `includes-temporal.lagging-source`, ] export const owners: ReadonlyArray = [ @@ -64,4 +68,9 @@ export const owners: ReadonlyArray = [ `includes-publication`, `plain-write-checkpoints transient-tear callback-only-tear confirmation-sides missing-callbacks held-repair`, ], + [ + `db/tests/query/includes-temporal-oracle.test.ts`, + `includes-temporal`, + `readiness-fixed demand-scheduling-orders release-reentry-fixed lagging-source`, + ], ] diff --git a/packages/db/tests/query/includes-temporal-check-oracle.ts b/packages/db/tests/query/includes-temporal-check-oracle.ts new file mode 100644 index 0000000000..96ef336b67 --- /dev/null +++ b/packages/db/tests/query/includes-temporal-check-oracle.ts @@ -0,0 +1,50 @@ +import { expect } from 'vitest' + +// Two public-observation checks of the includes temporal oracle, moved here +// unchanged from includes-temporal-oracle.test.ts. The oracle applies them at +// its scheduled-demand and release-reentry checkpoints; the ordinary checker +// calibrations in includes-temporal.test.ts feed them wrong observations. This +// module registers no tests. + +export type ScheduledDemandObservation = { + completed: Array + ready: boolean + rows: Array<{ id: number; comments: Array<{ id: number; body: string }> }> +} + +// The public row depends on whether the current acquisition's promise fulfilled. +// An obsolete acquisition's settlement alone cannot add its child row. +export function expectScheduledDemandObservation( + observation: ScheduledDemandObservation, + index: number, +): void { + expect(observation.completed).toHaveLength(index + 1) + const currentCompleted = observation.completed.includes(`demand-1`) + expect(observation.rows).toEqual([ + { + id: 1, + comments: currentCompleted ? [{ id: 200, body: `current` }] : [], + }, + ]) + if (currentCompleted) expect(observation.ready).toBe(true) +} + +export type ReleaseReentryObservation = { + changed: boolean + empty: boolean + loadCount: number + requestedKeys: Array> +} + +export function expectReleaseReentryObservation( + observed: ReleaseReentryObservation, + keys: ReadonlyArray, +): void { + const expectedKeys = [...keys].sort((left, right) => left - right) + expect(observed).toEqual({ + changed: true, + empty: false, + loadCount: 2, + requestedKeys: [expectedKeys, expectedKeys], + }) +} diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 680cd0c477..9d8dd30502 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -1,5 +1,5 @@ import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' import { BasicIndex } from '../../src/indexes/basic-index.js' @@ -16,14 +16,23 @@ import { import { runTrace } from '../trace-runner.js' import { oracleFixedSeed, + oracleProperty, oraclePropertyOptions, oracleReplayTest, oracleRoute, oracleRouteOf, oracleRuns, } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' +import { + expectReleaseReentryObservation, + expectScheduledDemandObservation, +} from './includes-temporal-check-oracle.js' import { correlationKeys, createColdPosts, @@ -35,6 +44,7 @@ import { nextCollectionId, startPreload, } from './includes-temporal-support.js' +import type { ScheduledDemandObservation } from './includes-temporal-check-oracle.js' import type { Collection } from '../../src/collection/index.js' import type { Deferred } from '../../src/deferred.js' import type { LoadSubsetOptions } from '../../src/types.js' @@ -358,29 +368,6 @@ async function expectScheduledAcquisitionsRespectCurrentDemand( ]) } -type ScheduledDemandObservation = { - completed: Array - ready: boolean - rows: Array<{ id: number; comments: Array<{ id: number; body: string }> }> -} - -// The public row depends on whether the current acquisition's promise fulfilled. -// An obsolete acquisition's settlement alone cannot add its child row. -function expectScheduledDemandObservation( - observation: ScheduledDemandObservation, - index: number, -): void { - expect(observation.completed).toHaveLength(index + 1) - const currentCompleted = observation.completed.includes(`demand-1`) - expect(observation.rows).toEqual([ - { - id: 1, - comments: currentCompleted ? [{ id: 200, body: `current` }] : [], - }, - ]) - if (currentCompleted) expect(observation.ready).toBe(true) -} - async function expectDemandReactivationRetriesAfterReleaseFailure( keys: ReadonlyArray, ): Promise { @@ -459,26 +446,6 @@ async function expectDemandReactivationRetriesAfterReleaseFailure( ]) } -type ReleaseReentryObservation = { - changed: boolean - empty: boolean - loadCount: number - requestedKeys: Array> -} - -function expectReleaseReentryObservation( - observed: ReleaseReentryObservation, - keys: ReadonlyArray, -): void { - const expectedKeys = [...keys].sort((left, right) => left - right) - expect(observed).toEqual({ - changed: true, - empty: false, - loadCount: 2, - requestedKeys: [expectedKeys, expectedKeys], - }) -} - // Model D: source Collection readiness gates initial-query readiness only for // an eager source. // @@ -1296,43 +1263,23 @@ 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, - expectReleaseReentryObservation, - expectLaggingSourceTraceMatches, - ], - model: [ - expectedReadinessObservation, - expectedLiveQueryObservation, - expectedReadObservation, - expectedEffectObservation, - ], -}) +const readinessLaw = `Initial preload completes when every reachable demand is covered by a settled request: an empty outer relation does not wait for an undemanded child, and a demanded child loads before readiness.` +const demandSchedulingLaw = `An obsolete acquisition's settlement cannot add its child row or settle current readiness, in either completion order.` +const releaseReentryLaw = `A failed adapter release never suppresses the reacquisition of a later demand incarnation.` +const laggingSourceLaw = `Source Collection readiness gates initial-query readiness only for an eager source; an on-demand source's settled demand is the evidence.` -describe(`includes temporal oracle`, () => { - 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([ +const readinessFixedHistories = [ + { name: `an empty outer does not wait for an undemanded child`, posts: [] }, + { + name: `loads a demanded child before becoming ready`, + posts: [ { id: 1, authorId: `selected`, title: `one` }, { id: 2, authorId: `selected`, title: `two` }, - ]) - }) + ], + }, +] +describe(`includes temporal oracle`, () => { // Grammar: one route is retired and reactivated, leaving one obsolete and // one current acquisition. The scheduler permutes their two completions. // Retire, reactivate, and completion order each distinguish the stale-demand @@ -1358,48 +1305,90 @@ describe(`includes temporal oracle`, () => { ) } - it.each([{ order: [1, 2] }, { order: [2, 1] }])( - `observes every completion in fixed scheduler order $order`, - ({ order }) => { - oracleRoute(`case`) - return expectScheduledAcquisitionsRespectCurrentDemand( - fc.schedulerFor(order), - ) + oracleProperty({ + id: `includes-temporal.demand-scheduling-orders`, + law: { + statement: demandSchedulingLaw, + authority: `The demand, cancellation, progressive timing, and source readiness contracts in packages/db/src/query/live/ARCHITECTURE.md (initial readiness, stale demand, and applied settlement laws), stated at the start of this owner.`, + limits: `The two enumerated completion orders of one obsolete and one current acquisition.`, }, - ) - - it(`rejects an obsolete child row at the first scheduled checkpoint`, () => { - oracleRoute(`calibration`) - expect(() => - expectScheduledDemandObservation( - { - completed: [`demand-0`], - ready: false, - rows: [{ id: 1, comments: [{ id: 100, body: `obsolete` }] }], - }, - 0, + 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`, + ], + histories: [{ order: [1, 2] }, { order: [2, 1] }], + label: ({ order }) => + `observes every completion in fixed scheduler order [ ${order.join(`, `)} ]`, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(demandSchedulingLaw, plan, observe, () => + expectScheduledAcquisitionsRespectCurrentDemand( + fc.schedulerFor(plan.order), + ), ), - ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + check: forwardAssertionJudgment, }) - it(`reactivated demand retries after its prior release fails`, () => { - oracleRoute(`fixed history`) - return expectDemandReactivationRetriesAfterReleaseFailure([1]) + oracleProperty({ + id: `includes-temporal.readiness-fixed`, + law: { + statement: readinessLaw, + authority: `The demand, cancellation, progressive timing, and source readiness contracts in packages/db/src/query/live/ARCHITECTURE.md (initial readiness, stale demand, and applied settlement laws), stated at the start of this owner.`, + limits: `Two named outer relations: an empty outer and two rows demanding one selected child.`, + }, + 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`, + ], + histories: readinessFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(readinessLaw, plan, observe, () => + expectReadinessMatches(plan.history.posts), + ), + check: forwardAssertionJudgment, }) - it(`rejects a failed release that suppresses reacquisition`, () => { - oracleRoute(`calibration`) - expect(() => - expectReleaseReentryObservation( - { - changed: false, - empty: false, - loadCount: 1, - requestedKeys: [[1]], - }, - [1], + oracleProperty({ + id: `includes-temporal.release-reentry-fixed`, + law: { + statement: releaseReentryLaw, + authority: `The demand, cancellation, progressive timing, and source readiness contracts in packages/db/src/query/live/ARCHITECTURE.md (initial readiness, stale demand, and applied settlement laws), stated at the start of this owner.`, + limits: `The single-key fixed witness, the smallest history of the release-reentry grammar.`, + }, + 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`, + ], + histories: [ + { + name: `reactivated demand retries after its prior release fails`, + keys: [1], + }, + ], + label: ({ name }) => name, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(releaseReentryLaw, plan, observe, () => + expectDemandReactivationRetriesAfterReleaseFailure(plan.keys), ), - ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + check: forwardAssertionJudgment, }) // Grammar: a nonempty unique key set is acquired, retired through a failed @@ -1461,13 +1450,36 @@ describe(`includes temporal oracle`, () => { })), ), ) - it.each(cells)( - `$consumer $shape: $history`, - ({ consumer, shape, steps }) => { - oracleRoute(`case`) - return expectLaggingSourceTraceMatches(consumer, shape, steps) + oracleProperty({ + id: `includes-temporal.lagging-source`, + law: { + statement: laggingSourceLaw, + authority: `The demand, cancellation, progressive timing, and source readiness contracts in packages/db/src/query/live/ARCHITECTURE.md (initial readiness, stale demand, and applied settlement laws), stated at the start of this owner.`, + limits: `The enumerated consumer, shape, and disturbed-history cells over controlled source Collections.`, }, - ) + 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`, + ], + histories: cells, + label: ({ consumer, shape, history }) => + `${consumer} ${shape}: ${history}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(laggingSourceLaw, plan, observe, () => + expectLaggingSourceTraceMatches( + plan.consumer, + plan.shape, + plan.steps, + ), + ), + check: forwardAssertionJudgment, + }) }) // Grammar: one parent receives 2..4 distinct-key children while its acquisition diff --git a/packages/db/tests/query/includes-temporal.test.ts b/packages/db/tests/query/includes-temporal.test.ts index b188170134..893dc07d8b 100644 --- a/packages/db/tests/query/includes-temporal.test.ts +++ b/packages/db/tests/query/includes-temporal.test.ts @@ -33,6 +33,10 @@ import { nextCollectionId, startPreload, } from './includes-temporal-support.js' +import { + expectReleaseReentryObservation, + expectScheduledDemandObservation, +} from './includes-temporal-check-oracle.js' import type { Collection } from '../../src/collection/index.js' import type { CollectionSubscription } from '../../src/collection/subscription.js' import type { Deferred } from '../../src/deferred.js' @@ -1976,3 +1980,34 @@ describe(`includes temporal oracle`, () => { it(`a nested progressive subset loads inside the fast-path window`, () => expectProgressiveTraceMatches(`nested`)) }) + +// Checker calibrations for the includes temporal oracle. They run no +// production: each feeds a wrong public observation to the oracle's check. +describe(`includes temporal checks`, () => { + it(`rejects an obsolete child row at the first scheduled checkpoint`, () => { + expect(() => + expectScheduledDemandObservation( + { + completed: [`demand-0`], + ready: false, + rows: [{ id: 1, comments: [{ id: 100, body: `obsolete` }] }], + }, + 0, + ), + ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + }) + + it(`rejects a failed release that suppresses reacquisition`, () => { + expect(() => + expectReleaseReentryObservation( + { + changed: false, + empty: false, + loadCount: 1, + requestedKeys: [[1]], + }, + [1], + ), + ).toThrowError(expect.objectContaining({ name: `AssertionError` })) + }) +}) From 45886e66a8b10da6c4751b7951b64dc6b0a9ae1c Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 08:59:36 -0700 Subject: [PATCH 049/116] test(db): register aggregate select regressions through oracleProperty The four fixed regressions replay through runHistory in an aggregate.select-fixed enumerated property. requiredHistories was named only by the deleted declareOracle call, so it is removed. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 54 ++++++- .../aggregate-select-oracle.property.test.ts | 133 ++++++++++-------- 2 files changed, 124 insertions(+), 63 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index e99be348e7..d315a98a86 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -6,8 +6,52 @@ import type { ConvertedOwnerGroup } from './index.js' // pagination-oracle.property.test.ts -const paginationProperties: ReadonlyArray = [] -const paginationOwners: ReadonlyArray = [] +const paginationProperties: ReadonlyArray = [ + `pagination.async-cursor-fixed`, + `pagination.boundary-refinement-failure`, + `pagination.capped-prefix-repair`, + `pagination.custom-collation`, + `pagination.failed-window-replay`, + `pagination.filtered-bounded-repair`, + `pagination.foreign-local-boundary`, + `pagination.inherited-locale`, + `pagination.invalid-date-ties`, + `pagination.linear-row-volume`, + `pagination.locale-initial-window`, + `pagination.multi-order-fixed`, + `pagination.nan-key-tie`, + `pagination.null-placement-refinement`, + `pagination.optimistic-prefix-refill`, + `pagination.optimistic-provider-coverage`, + `pagination.ordered-window-fixed`, + `pagination.pending-history-fixed`, + `pagination.pending-mutation-calibration`, + `pagination.pending-mutation-fixed`, + `pagination.post-cleanup-calibration`, + `pagination.provider-tie-expansion`, + `pagination.reference-initial-window`, + `pagination.rejected-cursor-retry`, + `pagination.rejected-first-prefix`, + `pagination.sse-boundary-refinement`, + `pagination.sse-prefix-refresh`, + `pagination.underfilled-bounded-repair`, + `pagination.window-transition-fixed`, + `pagination.zero-window-local-prefix`, + `pagination.limit-admission-fixed`, +] +const paginationOwners: ReadonlyArray = [ + [ + `db/tests/query/pagination-oracle.property.test.ts`, + `pagination`, + `async-cursor-fixed boundary-refinement-failure capped-prefix-repair custom-collation failed-window-replay filtered-bounded-repair foreign-local-boundary inherited-locale invalid-date-ties linear-row-volume locale-initial-window multi-order-fixed nan-key-tie null-placement-refinement optimistic-prefix-refill optimistic-provider-coverage ordered-window-fixed pending-history-fixed pending-mutation-calibration pending-mutation-fixed post-cleanup-calibration provider-tie-expansion reference-initial-window rejected-cursor-retry rejected-first-prefix sse-boundary-refinement sse-prefix-refresh underfilled-bounded-repair window-transition-fixed zero-window-local-prefix`, + ], + [ + `db/tests/query/pagination-oracle.property.test.ts`, + `pagination`, + `limit-admission-fixed`, + `db/tests/query/limit-admission-oracle.ts`, + ], +] // ordered-work-oracle.property.test.ts const orderedWorkProperties: ReadonlyArray = [] @@ -38,6 +82,7 @@ const otherProperties: ReadonlyArray = [ `custom-index-order.rank-window`, `custom-index-order.later-eligible`, `custom-index-order.eager-reader`, + `aggregate.select-fixed`, ] const otherOwners: ReadonlyArray = [ [ @@ -120,6 +165,11 @@ const otherOwners: ReadonlyArray = [ `custom-index-order`, `eager-reader`, ], + [ + `db/tests/query/aggregate-select-oracle.property.test.ts`, + `aggregate`, + `select-fixed`, + ], ] export const properties: ReadonlyArray = [ 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 73ee94b767..ea6df5bc3f 100644 --- a/packages/db/tests/query/aggregate-select-oracle.property.test.ts +++ b/packages/db/tests/query/aggregate-select-oracle.property.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { add, @@ -36,18 +36,13 @@ import { NonAggregateExpressionNotInGroupByError, } from '../../src/errors.js' import { mockSyncCollectionOptions, stripVirtualProps } from '../utils.js' -import { - oracleProperty, - oracleReplayTest, - oracleRoute, -} from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { declareOracle } from '../oracle-declaration.js' /** * # What does an aggregate query publish beside its aggregates? @@ -1760,7 +1755,6 @@ const resolveRequiredHistories = ( inputs.map((input, index) => requiredHistory(structuralHistories[index]!, input), ) -const requiredHistories = requiredInputs.map(resolveRequiredHistories) /** The states the operator laws need; each campaign must reach all of them. */ const reachStates = [ @@ -1838,13 +1832,8 @@ const aggregateLaw = { // The check is `runHistory`: it runs each generated history, and after every // step it compares the published rows and the compile outcome with -// `modelGroup` and `modelError`. Generated histories, structural examples, and -// existing model replays use this same check. -declareOracle({ - check: [runHistory], - model: [modelGroup, modelError], - grammar: [history, requiredHistories], -}) +// `modelGroup` and `modelError`. Generated histories, structural examples, +// fixed regressions, and existing model replays use this same check. describe(`aggregate select shapes`, () => { // One finite history is the original complete twelve-family structural @@ -1917,42 +1906,44 @@ describe(`aggregate select shapes`, () => { check: forwardAssertionJudgment, }) - // 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` } } }, + // Fixed regressions of review and generated counterexamples. The broad + // grammar cannot take them back, so each replays through `runHistory`, the + // shared check, under its old test name. + const aggregateRegressions: Array<{ name: string; history: History }> = [ + // A review counterexample: a grouped select value that holds the + // group key inside a function must read the group's key. + { + name: `reads the group key inside a function`, + history: { + top: { + fields: { + f0: { k: `add`, a: { k: `lit`, n: 0 }, b: { k: `key` } }, + }, + }, + nested: { fields: { f0: { k: `agg`, fn: `count` } } }, + grouping: `key`, }, - nested: { fields: { f0: { k: `agg`, fn: `count` } } }, - grouping: `key`, - }) - }) - - // 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: { - f0: { k: `key` }, - f1: { k: `agg`, fn: `count` }, + }, + // A published row keeps the select's field order, also when a group + // key comes before a plain aggregate. + { + name: `keeps the select's field order`, + history: { + top: { + fields: { + f0: { k: `key` }, + f1: { k: `agg`, fn: `count` }, + }, }, + nested: { fields: { f0: { k: `agg`, fn: `count` } } }, + grouping: `key`, }, - nested: { fields: { f0: { k: `agg`, fn: `count` } } }, - grouping: `key`, - }) - }) - - // 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({ + }, + // A generated counterexample: an aggregate query without groupBy + // whose select wraps its only aggregate still accepts HAVING. + { + name: `accepts HAVING beside a wrapped aggregate without groupBy`, + history: { top: { fields: { f0: { @@ -1965,22 +1956,22 @@ describe(`aggregate select shapes`, () => { }, nested: { fields: { - f0: { k: `add`, a: { k: `agg`, fn: `count` }, b: { k: `parent` } }, + f0: { + k: `add`, + a: { k: `agg`, fn: `count` }, + b: { k: `parent` }, + }, }, having: { part: { k: `parent` }, lit: 1 }, }, grouping: `none`, - }) + }, }, - ) - - // 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({ + // A parent field stays legal when the include's source shadows the + // parent alias. + { + name: `reads a parent field under a shadowing child alias`, + history: { top: { fields: { f0: { k: `agg`, fn: `count` } } }, nested: { fields: { @@ -1990,7 +1981,27 @@ describe(`aggregate select shapes`, () => { }, grouping: `none`, shadow: true, - }) + }, }, - ) + ] + oracleProperty({ + id: `aggregate.select-fixed`, + law: { + ...aggregateLaw, + limits: `Four named regressions that the broad generated grammar cannot take back.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: aggregateRegressions, + label: ({ name }) => name, + resolve: (regression) => ({ regression }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(PROPERTY, plan, observe, () => + runHistory(plan.regression.history), + ), + check: forwardAssertionJudgment, + }) }) From 49e33485d69abae1346f13563a9497b1e1117a09 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:00:16 -0700 Subject: [PATCH 050/116] test(vue-db): register the publication cases through oracleProperty Converts the one plain-test site in useLiveQuery-publication-oracle.test.ts (P2) into vue.use-live-query-publication, whose six histories are the former cases. The former body runs unchanged as runPublicationCase through recordAssertionJudgment, and owns moves from the deleted declareOracle call to the property (M2). Co-authored-by: Isaac --- .../converted-properties/frameworks-ivm.ts | 6 + .../useLiveQuery-publication-oracle.test.ts | 209 ++++++++++-------- 2 files changed, 126 insertions(+), 89 deletions(-) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index 1445615247..c8a71e208d 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -29,6 +29,7 @@ export const properties: ReadonlyArray = [ `react.source-id-reuse-fixed`, `react.source-id-reuse-infinite-fixed`, // vue-db and scripts + `vue.use-live-query-publication`, ] export const owners: ReadonlyArray = [ @@ -125,4 +126,9 @@ export const owners: ReadonlyArray = [ `source-id-reuse-infinite-fixed`, ], // vue-db and scripts + [ + `vue-db/tests/useLiveQuery-publication-oracle.test.ts`, + `vue`, + `use-live-query-publication`, + ], ] diff --git a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts index d4d9b370af..55af8bd916 100644 --- a/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts +++ b/packages/vue-db/tests/useLiveQuery-publication-oracle.test.ts @@ -29,8 +29,13 @@ */ import { createCollection } from '@tanstack/db' import { effectScope, watch } from 'vue' -import { expect, it, vi } from 'vitest' -import { oracleRoute } from '../../db/tests/oracle-config' +import { expect, vi } from 'vitest' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { mockSyncCollectionOptions } from '../../db/tests/utils' import { useLiveQuery } from '../src/useLiveQuery' @@ -97,103 +102,129 @@ function expectCoherentPublication( expect(observed.at(-1)?.publicData).toEqual(expected.after) } -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}`, - getKey: (row) => row.id, - initialData: initial, - }), +// The former test body, unchanged: mount the real hook, apply one sync +// transaction, and judge the watcher's observations with +// expectCoherentPublication. Cleanup failures keep their original precedence. +async function runPublicationCase({ + name, + initial, + change, + input, +}: (typeof cases)[number]): Promise { + const source = createCollection( + mockSyncCollectionOptions({ + id: `vue-publication-${name}-${input}`, + getKey: (row) => row.id, + initialData: initial, + }), + ) + const scope = effectScope() + const expectedBefore = snapshot(initial) + let resultCollection: { value: unknown } | undefined + let primaryFailure: unknown + try { + const result = scope.run(() => + input === `collection` + ? useLiveQuery(source) + : useLiveQuery((q) => + q.from({ items: source }).select(({ items }) => ({ + id: items.id, + value: items.value, + })), + ), + )! + resultCollection = result.collection + await vi.waitFor(() => + expect(snapshot(result.data.value)).toEqual(expectedBefore), ) - const scope = effectScope() - const expectedBefore = snapshot(initial) - let resultCollection: { value: unknown } | undefined - let primaryFailure: unknown - try { - const result = scope.run(() => - input === `collection` - ? useLiveQuery(source) - : useLiveQuery((q) => - q.from({ items: source }).select(({ items }) => ({ - id: items.id, - value: items.value, - })), - ), - )! - resultCollection = result.collection - await vi.waitFor(() => - expect(snapshot(result.data.value)).toEqual(expectedBefore), - ) - const observed: Array<{ - publicData: Snapshot - sourceAtCallback: Snapshot - }> = [] - scope.run(() => - watch( - () => snapshot(result.data.value), - (publicData) => { - observed.push({ - publicData, - sourceAtCallback: snapshot(source.values()), - }) - }, - { flush: `sync` }, - ), - ) + const observed: Array<{ + publicData: Snapshot + sourceAtCallback: Snapshot + }> = [] + scope.run(() => + watch( + () => snapshot(result.data.value), + (publicData) => { + observed.push({ + publicData, + sourceAtCallback: snapshot(source.values()), + }) + }, + { flush: `sync` }, + ), + ) - source.utils.begin() - source.utils.write(change) - source.utils.commit() + source.utils.begin() + source.utils.write(change) + source.utils.commit() - const expectedAfter = snapshot(source.values()) - expect(expectedAfter).not.toEqual(expectedBefore) - expectCoherentPublication( - { before: expectedBefore, after: expectedAfter }, - observed, - snapshot(result.data.value), - ) - } catch (error) { - primaryFailure = error - } - const cleanupErrors: Array = [] - let liveQueryCollection: typeof source | null = null - if (resultCollection) { - try { - liveQueryCollection = resultCollection.value as typeof source | null - } catch (error) { - cleanupErrors.push(error) - } - } + const expectedAfter = snapshot(source.values()) + expect(expectedAfter).not.toEqual(expectedBefore) + expectCoherentPublication( + { before: expectedBefore, after: expectedAfter }, + observed, + snapshot(result.data.value), + ) + } catch (error) { + primaryFailure = error + } + const cleanupErrors: Array = [] + let liveQueryCollection: typeof source | null = null + if (resultCollection) { try { - scope.stop() + liveQueryCollection = resultCollection.value as typeof source | null } catch (error) { cleanupErrors.push(error) } - if (liveQueryCollection && liveQueryCollection !== source) { - try { - await liveQueryCollection.cleanup() - } catch (error) { - cleanupErrors.push(error) - } - } + } + try { + scope.stop() + } catch (error) { + cleanupErrors.push(error) + } + if (liveQueryCollection && liveQueryCollection !== source) { try { - await source.cleanup() + await liveQueryCollection.cleanup() } catch (error) { cleanupErrors.push(error) } - if (cleanupErrors.length > 0) { - throw new AggregateError( - primaryFailure === undefined - ? cleanupErrors - : [primaryFailure, ...cleanupErrors], - `Vue publication oracle cleanup failed`, - { cause: primaryFailure }, - ) - } - if (primaryFailure !== undefined) throw primaryFailure + } + try { + await source.cleanup() + } catch (error) { + cleanupErrors.push(error) + } + if (cleanupErrors.length > 0) { + throw new AggregateError( + primaryFailure === undefined + ? cleanupErrors + : [primaryFailure, ...cleanupErrors], + `Vue publication oracle cleanup failed`, + { cause: primaryFailure }, + ) + } + if (primaryFailure !== undefined) throw primaryFailure +} + +const PUBLICATION_LAW = `One applied source change exposes only the preceding or the final data snapshot to a synchronous Vue watcher, which fires once with the final result.` + +oracleProperty({ + id: `vue.use-live-query-publication`, + law: { + statement: PUBLICATION_LAW, + authority: `packages/db/tests/conformance/contract.ts data; the project glossary's publication; this file's contract above.`, + limits: `Two or three initial rows, a supplied Collection or an identity live query, and one insert, update, or nonterminal delete; empty results, multi-change transactions, and recompilation are not covered.`, }, -) + owns: [`src/useLiveQuery.ts#useLiveQuery`], + histories: cases, + label: ({ name, input }) => + `publishes one coherent Vue data snapshot for a source ${name} through ${input}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(PUBLICATION_LAW, plan, observe, () => + runPublicationCase(plan.history), + ), + check: forwardAssertionJudgment, +}) From cc9c5560e82438e20088542728ae9b932b2cf4f1 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:00:46 -0700 Subject: [PATCH 051/116] test(db): register window-history fixed inputs through oracleProperty The authored window examples replay per driver and form as the enumerated framework..window-history-fixed property. The two checker calibrations run no production or driver, so they move to conformance-window-history-calibration.test.ts and run once. The infinite suite loses its declareOracle call only. Co-authored-by: Isaac --- ...ormance-window-history-calibration.test.ts | 82 +++++++++ .../conformance/infinite-suite-oracle.ts | 9 - .../conformance/window-history-oracle.ts | 156 ++++-------------- .../db/tests/converted-properties/db-core.ts | 29 ++++ 4 files changed, 140 insertions(+), 136 deletions(-) create mode 100644 packages/db/tests/conformance-window-history-calibration.test.ts diff --git a/packages/db/tests/conformance-window-history-calibration.test.ts b/packages/db/tests/conformance-window-history-calibration.test.ts new file mode 100644 index 0000000000..9526f24be6 --- /dev/null +++ b/packages/db/tests/conformance-window-history-calibration.test.ts @@ -0,0 +1,82 @@ +// Checker calibrations for the shared window-history comparison in +// conformance/window-history-oracle.ts. These tests run no production and use +// no framework driver: each hands a hand-built observation to the shared check +// and requires it to accept or reject it, so they run once here instead of once +// per framework registration. +import { describe, expect, it } from 'vitest' +import { oracleAssertion } from './oracle-assertion-adapter' +import { + checkWindowObservation, + checkWindowState, + checkWindowSubscriptions, + windowHistoryExamples, + windowRows, +} from './conformance/window-history-oracle' + +describe(`window history`, () => { + it(`rejects a pending keyed map that differs from the settled rows`, () => { + const history = windowHistoryExamples[0]![0] + const input = history.inputs[0]! + const rows = windowRows(history.rows, input, false) + const collection = {} + // data and Collection are valid. The empty map alone is the wrong answer. + oracleAssertion('checkWindowObservation', () => + checkWindowObservation( + { rows, collection }, + { rows, previousCollection: collection, retain: true }, + `held acquisition`, + ), + ) + oracleAssertion('expect(() =>', () => + expect(() => + oracleAssertion('checkWindowState', () => + checkWindowState(new Map(), rows, `held acquisition`), + ), + ).toThrow(`keyed window rows at held acquisition`), + ) + oracleAssertion('checkWindowState', () => + checkWindowState( + new Map(rows.map((row) => [row.id, row])), + rows, + `held acquisition`, + ), + ) + }) + // These controls use the campaign's shared comparison. Owner-only recording + // accepts 1 -> 2 -> 1 when a replacement subscribes before its predecessor + // retires. A retired Collection's 2 -> 1 decrement must still be accepted. + it(`retained subscriptions distinguish overlapping replacement from GC`, () => { + const collection = {} + const overlappingReplacement = [ + { + owners: [collection], + previousSubscriberCount: 1, + subscriberCount: 2, + }, + { + owners: [collection], + previousSubscriberCount: 2, + subscriberCount: 1, + }, + ] + oracleAssertion('expect(() =>', () => + expect(() => + oracleAssertion('checkWindowSubscriptions', () => + checkWindowSubscriptions(overlappingReplacement, collection), + ), + ).toThrow(`retained upstream subscription count`), + ) + oracleAssertion('checkWindowSubscriptions', () => + checkWindowSubscriptions( + [ + { + owners: [collection], + previousSubscriberCount: 2, + subscriberCount: 1, + }, + ], + collection, + ), + ) + }) +}) diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite-oracle.ts index 013377f3d1..6d461b0467 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite-oracle.ts @@ -22,7 +22,6 @@ */ import { describe, expect, it, vi } from 'vitest' import { oracleRoute } from '../oracle-config' -import { declareOracle } from '../oracle-declaration.js' import { captureError, createScenarioState, @@ -174,14 +173,6 @@ 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], -}) - export function runInfiniteQuerySuite( rawDriver: InfiniteQueryDriver, registerUnitScenarios: (context: InfiniteQueryContext) => void, diff --git a/packages/db/tests/conformance/window-history-oracle.ts b/packages/db/tests/conformance/window-history-oracle.ts index a94cf4b073..451fc9f466 100644 --- a/packages/db/tests/conformance/window-history-oracle.ts +++ b/packages/db/tests/conformance/window-history-oracle.ts @@ -20,15 +20,9 @@ * React's abandoned-render cut is checked by its receiving driver separately. */ import fc from 'fast-check' -import { describe, expect, it } from 'vitest' -import { declareOracle } from '../oracle-declaration' +import { describe, expect } from 'vitest' import { atOracleCheckpoint } from '../oracle-failure-fidelity' -import { - oracleProperty, - oracleReplayTest, - oracleRoute, - oracleRuns, -} from '../oracle-config' +import { oracleProperty, oracleRuns } from '../oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -635,77 +629,6 @@ function windowHistoryProperty( // another's missing witness. Replay goes directly to the requested campaign. export function registerWindowHistoryOracle(driver: LiveQueryDriver) { describe(`window history :: ${driver.name}`, () => { - oracleReplayTest(it)( - `rejects a pending keyed map that differs from the settled rows`, - () => { - const history = windowHistoryExamples[0]![0] - const input = history.inputs[0]! - const rows = windowRows(history.rows, input, false) - const collection = {} - // data and Collection are valid. The empty map alone is the wrong answer. - oracleAssertion('checkWindowObservation', () => - checkWindowObservation( - { rows, collection }, - { rows, previousCollection: collection, retain: true }, - `held acquisition`, - ), - ) - oracleAssertion('expect(() =>', () => - expect(() => - oracleAssertion('checkWindowState', () => - checkWindowState(new Map(), rows, `held acquisition`), - ), - ).toThrow(`keyed window rows at held acquisition`), - ) - oracleAssertion('checkWindowState', () => - checkWindowState( - new Map(rows.map((row) => [row.id, row])), - rows, - `held acquisition`, - ), - ) - }, - ) - // These controls use the campaign's shared comparison. Owner-only recording - // accepts 1 -> 2 -> 1 when a replacement subscribes before its predecessor - // retires. A retired Collection's 2 -> 1 decrement must still be accepted. - oracleReplayTest(it)( - `retained subscriptions distinguish overlapping replacement from GC`, - () => { - const collection = {} - const overlappingReplacement = [ - { - owners: [collection], - previousSubscriberCount: 1, - subscriberCount: 2, - }, - { - owners: [collection], - previousSubscriberCount: 2, - subscriberCount: 1, - }, - ] - oracleAssertion('expect(() =>', () => - expect(() => - oracleAssertion('checkWindowSubscriptions', () => - checkWindowSubscriptions(overlappingReplacement, collection), - ), - ).toThrow(`retained upstream subscription count`), - ) - oracleAssertion('checkWindowSubscriptions', () => - checkWindowSubscriptions( - [ - { - owners: [collection], - previousSubscriberCount: 2, - subscriberCount: 1, - }, - ], - collection, - ), - ) - }, - ) registerPeerWindowOracle(driver) if (driver.mountKeyedControllable) registerImmutableKeyOracle(driver) // Disabled input is legal only for ordinary bindings that accept keys. @@ -743,17 +666,33 @@ export function registerWindowHistoryOracle(driver: LiveQueryDriver) { ) describe(`${form} window histories`, () => { // Preserve both original example executions without pretending the - // authored input list is the generator's compulsory-move template. - for (const campaign of [`fixed`, `fresh`] as const) - for (const [index, [history]] of windowHistoryExamples.entries()) - oracleReplayTest(it)( - `fixed input ${index}/${campaign}`, + // authored input list is the generator's compulsory-move template: + // the arbitrary cannot take these authored inputs back, so one + // enumerated property replays each example per campaign name. + oracleProperty({ + id: windowHistoryProperty(driver, `window-history-fixed`), + law: { + statement: `An adapter-owned outer page move retains its Collection; a changed plan replaces it, and source writes keep the current page live.`, + authority: `conformance/contract.ts query identity and committed-window laws; maintainer window decision 2026-10-30 and independent-output decision 2026-11-03.`, + limits: `The authored fixed window examples, each run twice (fixed and fresh), through the same driver-specific receiving path and comparison; each must enter every required state of its form.`, + }, + histories: ([`fixed`, `fresh`] as const).flatMap((campaign) => + windowHistoryExamples.map((_, index) => ({ campaign, index })), + ), + label: ({ campaign, index }) => + `fixed input ${index}/${campaign}`, + resolve: ({ index }) => ({ index }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `adapter-owned page window`, + plan, + observe, async () => { - oracleRoute(`fixed history`) const observed = new Set<(typeof states)[number]>() await runWindowHistory( driver, - { ...history, nested }, + { ...windowHistoryExamples[plan.index]![0], nested }, keyed, { reach: (state) => observed.add(state) }, keyPolicy, @@ -768,8 +707,10 @@ export function registerWindowHistoryOracle(driver: LiveQueryDriver) { ).toEqual([]), ) }, - oracleRuns(30000), - ) + ), + check: forwardAssertionJudgment, + timeout: oracleRuns(30000), + }) oracleProperty({ id: windowHistoryProperty(driver, `window-history`), law: { @@ -818,19 +759,6 @@ 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], - check: [ - checkWindowObservation, - checkWindowState, - checkWindowSubscriptions, - runWindowHistory, - ], -}) /** * Pending window moves keep the last settled page. A later commit supersedes * the requested window; an older acquisition cannot publish its own page. This @@ -1052,13 +980,6 @@ 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], -}) function registerPendingWindowOracle(driver: LiveQueryDriver) { oracleProperty({ id: windowHistoryProperty(driver, `pending-window-history`), @@ -1329,13 +1250,6 @@ function registerPublicationWindowOracle(driver: LiveQueryDriver) { timeout: oracleRuns(30000), }) } -// 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], -}) /** Independent bindings may read the same source, but their mutable pages are * local. The shared sort/slice model judges both outputs after declarative and * imperative moves, source writes, and retirement of the other binding. A @@ -1505,10 +1419,6 @@ function registerPeerWindowOracle(driver: LiveQueryDriver) { check: forwardAssertionJudgment, }) } -declareOracle({ - grammar: [pendingWindowArbitrary], - check: [checkPeerWindowHistory], -}) // A disabled input admits no authored plan. The enablement contract therefore // precedes stable-key retention: first admit the enabled plan, then keep it // until its key changes. These histories cross the two formerly separate lanes. @@ -1657,10 +1567,6 @@ function registerKeyedAdmissionOracle(driver: LiveQueryDriver) { check: forwardAssertionJudgment, }) } -declareOracle({ - grammar: [keyedAdmissionArbitrary], - check: [checkKeyedAdmission], -}) // Immutable explicit keys obey contract.ts (maintainer decision, 2026-11-03). // Equal values keep the authored query even when a new callback proposes a // different predicate. Changed nested values admit that predicate. Mutation is @@ -1929,7 +1835,3 @@ function registerImmutableKeyOracle(driver: LiveQueryDriver) { check: forwardAssertionJudgment, }) } -declareOracle({ - grammar: [immutableKeyArbitrary], - check: [checkImmutableKeyHistory], -}) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 29a2e427ef..cba0fced6d 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -5,6 +5,23 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' +// The framework drivers that register the shared window-history oracle, with +// the test file that registers each (the same list as the replay manifest's +// window-history owners). +const windowHistoryDrivers = [ + [`react`, `react-db/tests/conformance.test.tsx`], + [`react-dependency-list`, `react-db/tests/conformance.test.tsx`], + [`react-DbClient`, `react-db/tests/conformance.test.tsx`], + [`react-Suspense`, `react-db/tests/conformance.test.tsx`], + [`react-Suspense-DbClient`, `react-db/tests/conformance.test.tsx`], + [`preact`, `preact-db/tests/conformance.test.ts`], + [`vue`, `vue-db/tests/conformance.test.ts`], + [`solid`, `solid-db/tests/conformance.test.tsx`], + [`angular`, `angular-db/tests/conformance.test.ts`], + [`svelte`, `svelte-db/tests/conformance.svelte.test.ts`], + [`svelte-DbClient`, `svelte-db/tests/conformance.svelte.test.ts`], +] as const + export const properties: ReadonlyArray = [ // indexes and comparison // transactions and mutations @@ -28,6 +45,9 @@ export const properties: ReadonlyArray = [ `proxy.revert-fixed-histories`, `proxy.revert-native-fixed-histories`, `flat-change-tracking.pinned`, + ...windowHistoryDrivers.map( + ([driver]) => `framework.${driver}.window-history-fixed`, + ), ] export const owners: ReadonlyArray = [ @@ -65,4 +85,13 @@ export const owners: ReadonlyArray = [ `flat-change-tracking`, `pinned`, ], + ...windowHistoryDrivers.map( + ([driver, owner]) => + [ + owner, + `framework.${driver}`, + `window-history-fixed`, + `db/tests/conformance/window-history-oracle.ts`, + ] as const, + ), ] From 3bf9cdf92b92ef13fa804d3ed7d9cdfb04e8637b Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:01:33 -0700 Subject: [PATCH 052/116] test(db): register exact loadSubset demand tests through oracleProperty The identity-axis pairs and the named rejected-waiter scenarios become enumerated properties through the existing helpers. The nullish model check and the fixed-seed grammar coverage check run no production, so they move to an ordinary test beside an extracted, test-free grammar module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 39 ++- .../tests/query/load-subset-grammar-oracle.ts | 48 ++++ .../db/tests/query/load-subset-model.test.ts | 52 ++++ .../query/load-subset-oracle.property.test.ts | 237 +++++++----------- 4 files changed, 229 insertions(+), 147 deletions(-) create mode 100644 packages/db/tests/query/load-subset-grammar-oracle.ts create mode 100644 packages/db/tests/query/load-subset-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index d315a98a86..2515a22c4e 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -54,8 +54,31 @@ const paginationOwners: ReadonlyArray = [ ] // ordered-work-oracle.property.test.ts -const orderedWorkProperties: ReadonlyArray = [] -const orderedWorkOwners: ReadonlyArray = [] +const orderedWorkProperties: ReadonlyArray = [ + `ordered-work.work-order-term-parity`, + `ordered-work.work-finite-prefix-parity`, + `ordered-work.work-value-update`, + `ordered-work.work-anti-join-existing-row`, + `ordered-work.work-none-filter-bound`, + `ordered-work.work-left-join-bound`, + `ordered-work.work-joined-demand-settles`, + `ordered-work.work-joined-demand-waits`, + `ordered-work.work-joined-filter-refill`, + `ordered-work.work-joined-filter-stops`, + `ordered-work.work-underfilled-source`, + `ordered-work.work-eager-fixed`, + `ordered-work.work-optimistic-window`, + `ordered-work.work-installed-departure`, + `ordered-work.work-compiler-cache`, + `ordered-work.work-joined-plan`, +] +const orderedWorkOwners: ReadonlyArray = [ + [ + `db/tests/query/ordered-work-oracle.property.test.ts`, + `ordered-work`, + `work-order-term-parity work-finite-prefix-parity work-value-update work-anti-join-existing-row work-none-filter-bound work-left-join-bound work-joined-demand-settles work-joined-demand-waits work-joined-filter-refill work-joined-filter-stops work-underfilled-source work-eager-fixed work-optimistic-window work-installed-departure work-compiler-cache work-joined-plan`, + ], +] // ordered-lifecycle, bucket-facade-rollback, cold-join-reconciliation, and // ordered-source-loader-state oracles @@ -83,6 +106,8 @@ const otherProperties: ReadonlyArray = [ `custom-index-order.later-eligible`, `custom-index-order.eager-reader`, `aggregate.select-fixed`, + `load-subset.exact-axis-pairs`, + `load-subset.rejected-waiter-fixed`, ] const otherOwners: ReadonlyArray = [ [ @@ -170,6 +195,16 @@ const otherOwners: ReadonlyArray = [ `aggregate`, `select-fixed`, ], + [ + `db/tests/query/load-subset-oracle.property.test.ts`, + `load-subset`, + `exact-axis-pairs`, + ], + [ + `db/tests/query/load-subset-oracle.property.test.ts`, + `load-subset`, + `rejected-waiter-fixed`, + ], ] export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/query/load-subset-grammar-oracle.ts b/packages/db/tests/query/load-subset-grammar-oracle.ts new file mode 100644 index 0000000000..749defd790 --- /dev/null +++ b/packages/db/tests/query/load-subset-grammar-oracle.ts @@ -0,0 +1,48 @@ +/** + * The exact-demand history grammar of `load-subset-oracle.property.test.ts`, + * moved unchanged so that its grammar check in `load-subset-model.test.ts` + * can read it. That oracle file states the law and its authority. This module + * registers no tests. + */ +import fc from 'fast-check' +import type { ExactDemand } from './load-subset-support.js' + +export const exactDemandArbitrary: fc.Arbitrary = fc + .record({ + values: fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { + minLength: 1, + maxLength: 5, + }), + orderField: fc.constantFrom(`rank` as const, `score` as const), + direction: fc.constantFrom(`asc` as const, `desc` as const), + nulls: fc.constantFrom(`first` as const, `last` as const), + stringSort: fc.constantFrom(`lexical` as const, `locale` as const), + offset: fc.integer({ min: 0, max: 4 }), + limit: fc.option(fc.integer({ min: 0, max: 5 }), { nil: undefined }), + cursorBoundary: fc.option(fc.integer({ min: -3, max: 3 }), { + nil: undefined, + }), + }) + .map((demand) => ({ + ...demand, + values: [...demand.values].sort((left, right) => left - right), + })) + +export function exactDemandFingerprint(demand: ExactDemand): string { + return JSON.stringify(demand) +} + +export const exactDemandTraceArbitrary = fc + .uniqueArray(exactDemandArbitrary, { + minLength: 1, + maxLength: 6, + selector: exactDemandFingerprint, + }) + .chain((pool) => + fc + .array(fc.integer({ min: 0, max: pool.length - 1 }), { + minLength: 1, + maxLength: 20, + }) + .map((indices) => indices.map((index) => pool[index]!)), + ) diff --git a/packages/db/tests/query/load-subset-model.test.ts b/packages/db/tests/query/load-subset-model.test.ts new file mode 100644 index 0000000000..3d5e09d301 --- /dev/null +++ b/packages/db/tests/query/load-subset-model.test.ts @@ -0,0 +1,52 @@ +// Model and grammar checks for load-subset-oracle.property.test.ts. They run +// no production: the first checks the independent SQL expression evaluator's +// nullish rule, and the second checks that the fixed-seed exact-demand +// grammar generates repeated, cursor, empty, and unbounded demands. The law +// and the production comparisons are in that oracle file; the grammar is in +// load-subset-grammar-oracle.ts. +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { Func, PropRef, Value } from '../../src/query/ir.js' +import { evaluateReferenceExpression } from '../reference-expression-oracle.js' +import { oracleFixedSeed } from '../oracle-config.js' +import { + exactDemandFingerprint, + exactDemandTraceArbitrary, +} from './load-subset-grammar-oracle.js' + +describe(`exact loadSubset demand oracle`, () => { + it(`uses SQL unknown for nullish comparisons in the independent model`, () => { + const missing = new PropRef([`missing`]) + + expect( + evaluateReferenceExpression( + new Func(`lte`, [missing, new Value(null)]), + {}, + ), + ).toBeNull() + expect( + evaluateReferenceExpression(new Func(`lt`, [missing, new Value(0)]), {}), + ).toBeNull() + }) + + it(`generates repeated, cursor, empty, and unbounded exact demands`, () => { + const traces = fc.sample( + exactDemandTraceArbitrary, + oracleFixedSeed(1656, { numRuns: 200 }), + ) + const demands = traces.flat() + + expect( + traces.some( + (trace) => + new Set(trace.map(exactDemandFingerprint)).size < trace.length, + ), + ).toBe(true) + expect( + demands.some(({ cursorBoundary }) => cursorBoundary !== undefined), + ).toBe(true) + expect(demands.some(({ limit }) => limit === 0)).toBe(true) + expect(demands.some(({ limit }) => limit === undefined)).toBe(true) + expect(new Set(demands.map(({ offset }) => offset)).size).toBeGreaterThan(1) + }) +}) 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 60b3c2da64..4fcd9af462 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -1,26 +1,23 @@ import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createDeferred } from '../../src/deferred.js' -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 { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { - oracleFixedSeed, - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { exactDemandAxisPairs, toLoadSubsetOptions, } from './load-subset-support.js' +import { + exactDemandArbitrary, + exactDemandFingerprint, + exactDemandTraceArbitrary, +} from './load-subset-grammar-oracle.js' import type { ExactDemand } from './load-subset-support.js' import type { LoadSubsetOptions, @@ -57,46 +54,6 @@ type ConcurrentExactScenario = { settlementOrder: `forward` | `reverse` } -const exactDemandArbitrary: fc.Arbitrary = fc - .record({ - values: fc.uniqueArray(fc.integer({ min: -3, max: 3 }), { - minLength: 1, - maxLength: 5, - }), - orderField: fc.constantFrom(`rank` as const, `score` as const), - direction: fc.constantFrom(`asc` as const, `desc` as const), - nulls: fc.constantFrom(`first` as const, `last` as const), - stringSort: fc.constantFrom(`lexical` as const, `locale` as const), - offset: fc.integer({ min: 0, max: 4 }), - limit: fc.option(fc.integer({ min: 0, max: 5 }), { nil: undefined }), - cursorBoundary: fc.option(fc.integer({ min: -3, max: 3 }), { - nil: undefined, - }), - }) - .map((demand) => ({ - ...demand, - values: [...demand.values].sort((left, right) => left - right), - })) - -function exactDemandFingerprint(demand: ExactDemand): string { - return JSON.stringify(demand) -} - -const exactDemandTraceArbitrary = fc - .uniqueArray(exactDemandArbitrary, { - minLength: 1, - maxLength: 6, - selector: exactDemandFingerprint, - }) - .chain((pool) => - fc - .array(fc.integer({ min: 0, max: pool.length - 1 }), { - minLength: 1, - maxLength: 20, - }) - .map((indices) => indices.map((index) => pool[index]!)), - ) - const concurrentExactScenarioArbitrary: fc.Arbitrary = fc.record({ trace: exactDemandTraceArbitrary, @@ -332,64 +289,40 @@ async function expectExactWaitersShareRejection({ // demand-identity model: a completed demand starts once, and only identical // in-flight demands share a promise. The rejected-waiter driver compares each // attempt with the transports that it expects. The grammar is the exact-demand -// trace arbitrary, which the calibration test below reads for coverage. -declareOracle({ - check: [ - assertCompletedExactDemandTrace, - assertConcurrentExactDemandTrace, - expectExactWaitersShareRejection, - ], - grammar: [exactDemandTraceArbitrary], -}) +// trace arbitrary in load-subset-grammar-oracle.ts; its coverage check and the +// independent model's nullish rule are checked in load-subset-model.test.ts. 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], - settlementOrder: `reverse`, - }) + // One demand pair per identity axis, each through both exact-demand + // drivers: the completed trace and the reverse-settled concurrent trace. + oracleProperty({ + id: `load-subset.exact-axis-pairs`, + law: { + statement: `A change to any one exact-demand axis makes a distinct demand: a completed demand starts once, and only identical in-flight demands share a promise.`, + authority: `ARCHITECTURE.md Demand grouping and ownership; the exact-demand law above.`, + limits: `One demand pair per identity axis in exactDemandAxisPairs, as the trace [first, second, first], completed and concurrent with reverse settlement.`, }, - ) - - it(`uses SQL unknown for nullish comparisons in the independent model`, () => { - oracleRoute(`fixed history`) - const missing = new PropRef([`missing`]) - - expect( - evaluateReferenceExpression( - new Func(`lte`, [missing, new Value(null)]), - {}, - ), - ).toBeNull() - expect( - evaluateReferenceExpression(new Func(`lt`, [missing, new Value(0)]), {}), - ).toBeNull() - }) - - it(`generates repeated, cursor, empty, and unbounded exact demands`, () => { - oracleRoute(`fixed seed`) - const traces = fc.sample( - exactDemandTraceArbitrary, - oracleFixedSeed(1656, { numRuns: 200 }), - ) - const demands = traces.flat() - - expect( - traces.some( - (trace) => - new Set(trace.map(exactDemandFingerprint)).size < trace.length, + owns: ['src/query/subset-dedupe.ts'], + histories: exactDemandAxisPairs, + label: ([axis]) => + `distinguishes a change to ${axis} in completed and concurrent demands`, + resolve: (pair) => ({ pair }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `load-subset.exact-axis-pairs`, + plan, + observe, + async () => { + const [, first, second] = plan.pair + assertCompletedExactDemandTrace([first, second, first]) + await assertConcurrentExactDemandTrace({ + trace: [first, second, first], + settlementOrder: `reverse`, + }) + }, ), - ).toBe(true) - expect( - demands.some(({ cursorBoundary }) => cursorBoundary !== undefined), - ).toBe(true) - expect(demands.some(({ limit }) => limit === 0)).toBe(true) - expect(demands.some(({ limit }) => limit === undefined)).toBe(true) - expect(new Set(demands.map(({ offset }) => offset)).size).toBeGreaterThan(1) + check: forwardAssertionJudgment, }) // All three native registrations retain the existing independent helpers. @@ -445,47 +378,6 @@ describe(`exact loadSubset demand oracle`, () => { check: forwardAssertionJudgment, }) - it(`reports one rejection to every exact waiter and then retries`, async () => { - oracleRoute(`fixed history`) - await expectExactWaitersShareRejection({ - demand: { - values: [1, 2], - orderField: `rank`, - direction: `asc`, - nulls: `last`, - stringSort: `lexical`, - offset: 0, - limit: 2, - cursorBoundary: undefined, - }, - failures: 1, - waiters: 2, - failureKind: `error`, - }) - }) - - // This was the fixed-seed campaign's explicit example. It still runs the - // same helper once, separately from the unchanged generated campaign. - if (!oracleReplayRequested()) { - it(`retries an undefined rejection through a fresh successful acquisition`, () => { - oracleRoute(`fixed history`) - return expectExactWaitersShareRejection({ - demand: { - values: [1, 2], - orderField: `rank`, - direction: `asc`, - nulls: `last`, - stringSort: `lexical`, - offset: 0, - limit: 2, - cursorBoundary: undefined, - }, - failures: 3, - waiters: 4, - failureKind: `undefined`, - }) - }) - } oracleProperty({ id: `load-subset.rejected-waiter`, law: { @@ -512,4 +404,59 @@ describe(`exact loadSubset demand oracle`, () => { ), check: forwardAssertionJudgment, }) + + // Named rejected-waiter scenarios. The generated demand grammar maps its + // values without an unmapper, so it cannot take them back; each replays + // through the same helper under its old test name. The second was the + // fixed-seed campaign's explicit example. + const rejectedWaiterDemand: ExactDemand = { + values: [1, 2], + orderField: `rank`, + direction: `asc`, + nulls: `last`, + stringSort: `lexical`, + offset: 0, + limit: 2, + cursorBoundary: undefined, + } + oracleProperty({ + id: `load-subset.rejected-waiter-fixed`, + law: { + statement: `Every exact waiter receives the same rejection and each later attempt starts fresh work until success is cached.`, + authority: `ARCHITECTURE.md Demand grouping and ownership; the rejection and retry law above.`, + limits: `Two named scenarios: one Error rejection with two waiters, and three undefined rejections with four waiters.`, + }, + owns: ['src/query/subset-dedupe.ts'], + histories: [ + { + name: `reports one rejection to every exact waiter and then retries`, + scenario: { + demand: rejectedWaiterDemand, + failures: 1, + waiters: 2, + failureKind: `error`, + }, + }, + { + name: `retries an undefined rejection through a fresh successful acquisition`, + scenario: { + demand: rejectedWaiterDemand, + failures: 3, + waiters: 4, + failureKind: `undefined`, + }, + }, + ] satisfies Array<{ name: string; scenario: RejectedWaiterScenario }>, + label: ({ name }) => name, + resolve: ({ scenario }) => ({ scenario }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `load-subset.rejected-waiter`, + plan, + observe, + () => expectExactWaitersShareRejection(plan.scenario), + ), + check: forwardAssertionJudgment, + }) }) From 4ec25c138c3e964ea0726eb614e8eb84aa48b3af Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:01:34 -0700 Subject: [PATCH 053/116] test(db): register includes query-shape fixed histories and calibrations through oracleProperty Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 8 + .../query/includes-query-shape-oracle.test.ts | 492 ++++++++++++------ 2 files changed, 344 insertions(+), 156 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 525ee2f3c9..dbe9a87e3d 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -30,6 +30,9 @@ export const properties: ReadonlyArray = [ `includes-temporal.demand-scheduling-orders`, `includes-temporal.release-reentry-fixed`, `includes-temporal.lagging-source`, + `includes-query-shape.multiplicity-fixed`, + `includes-query-shape.correlation-fixed`, + `includes-query-shape.nullable-fixed`, ] export const owners: ReadonlyArray = [ @@ -73,4 +76,9 @@ export const owners: ReadonlyArray = [ `includes-temporal`, `readiness-fixed demand-scheduling-orders release-reentry-fixed lagging-source`, ], + [ + `db/tests/query/includes-query-shape-oracle.test.ts`, + `includes-query-shape`, + `multiplicity-fixed correlation-fixed nullable-fixed`, + ], ] 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 b1a21b373a..09547ee56b 100644 --- a/packages/db/tests/query/includes-query-shape-oracle.test.ts +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -1,6 +1,12 @@ -import { fc, test as fcTest } from '@fast-check/vitest' +import { fc } from '@fast-check/vitest' import { describe, expect } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { createLiveQueryCollection, eq, @@ -8,8 +14,7 @@ import { } from '../../src/query/index.js' import { BasicIndex } from '../../src/indexes/basic-index.js' import { runTrace } from '../trace-runner.js' -import { oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { registerIncludesProperty } from './includes-registration-oracle.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { cleanupQuery } from './includes-query-shape-support.js' @@ -450,30 +455,118 @@ const nullableProjection: TraceProjection< * the first divergent checkpoint. The models are the three recomputations above, * 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, - expectedNullableRows, - ], -}) - function campaigns(fixedSeed: number, property: string) { return [ { name: `fixed and random`, options: { runs: 12, property, fixedSeed } }, ] } +const multiplicityLaw = `Deleting one joined contributor preserves the remaining multiplicity, and deleting the final one removes the parent's child.` +const correlationLaw = `Materialization follows its correlation alias, whether the source or the joined alias, and stays distinct from join identity through route moves.` +const nullableLaw = `A null or unmatched correlation key materializes an undefined singleton, and a key that returns from null starts a fresh route.` + +// Fixed histories of the three query-shape families, replayed through each +// family's shared driver and recomputation. The first history of each list is +// the clean replay of that family's calibration, which the calibration +// corrupts; the framework tries candidates in this order. +const multiplicityFixedHistories = [ + { + name: `matches recomputation after one of two joined contributors leaves`, + childCount: 2, + }, + { + name: `matches recomputation when the final joined contributor is deleted`, + childCount: 1, + }, +] + +const correlationFixedHistories: Array<{ + name: string + target: CorrelationTarget + correlationId: number + productionId: number + orderId: number + steps: Array +}> = [ + { + name: `matches recomputation for a source correlation beside a distinct order part`, + target: `source`, + correlationId: 1, + productionId: 101, + orderId: 7, + steps: [], + }, + { + name: `matches recomputation when materialization correlates through its source alias`, + target: `source`, + correlationId: 1, + productionId: 101, + orderId: 1, + steps: [], + }, + // Part 1 is the joined correlation. Part 7 is the source correlation. + // Moving order.partId changes only the joined query, then restores it. + ...([`source`, `joined`] as const).map((target) => ({ + name: `keeps ${target} correlation distinct from join identity through route moves`, + target, + correlationId: 1, + productionId: 101, + orderId: 7, + steps: [ + { id: 7, partId: 7 }, + { id: 7, partId: 1 }, + ], + })), +] + +const nullableFixedHistories: Array<{ + name: string + authors: Array + posts: Array + steps: Array +}> = [ + { + name: `matches recomputation when a null correlation key becomes non-null`, + authors: [{ id: 1, name: `Ada` }], + posts: [{ id: 1, authorId: null }], + steps: [{ id: 1, authorId: 1 }], + }, + { + name: `matches recomputation for an unmatched non-null correlation key`, + authors: [], + posts: [{ id: 1, authorId: 999 }], + steps: [], + }, + { + name: `matches recomputation when an existing correlation key becomes null`, + authors: [{ id: 1, name: `Ada` }], + posts: [{ id: 1, authorId: 1 }], + steps: [{ id: 1, authorId: null }], + }, + { + name: `reactivates a singleton after null and repeated route retirement`, + authors: [{ id: 1, name: `Ada` }], + posts: [{ id: 1, authorId: null }], + steps: [ + { id: 1, authorId: 1 }, + { id: 1, authorId: null }, + { id: 1, authorId: 1 }, + ], + }, +] + describe(`includes query-shape recompute oracle`, () => { for (const campaign of campaigns(1703, `includes-query-shape.multiplicity`)) { registerIncludesProperty( [fc.integer({ min: 2, max: 5 })], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `deleting one joined contributor preserves remaining multiplicity (${campaign.name})`, async (childCount) => { await runTrace({ @@ -485,38 +578,6 @@ describe(`includes query-shape recompute oracle`, () => { ) } - fcTest( - `matches recomputation when the final joined contributor is deleted`, - () => { - 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], - driver: createMultiplicityDriver(2), - projection: { - ...multiplicityProjection, - observe: (context) => - context.children.size === 1 - ? [] - : multiplicityProjection.observe(context), - }, - }), - ).rejects.toMatchObject({ name: `TraceAssertionError`, checkpoint: 1 }) - }, - ) - for (const campaign of campaigns(1704, `includes-query-shape.correlation`)) { registerIncludesProperty( [ @@ -525,7 +586,14 @@ describe(`includes query-shape recompute oracle`, () => { productionId: fc.integer({ min: 101, max: 200 }), }), ], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `materialization follows correlation through a joined alias (${campaign.name})`, async ({ correlationId, productionId }) => { await runTrace({ @@ -542,57 +610,17 @@ describe(`includes query-shape recompute oracle`, () => { ) } - fcTest( - `matches recomputation when materialization correlates through its source alias`, - () => { - 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) => { - 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: [ - { id: 7, partId: 7 }, - { id: 7, partId: 1 }, - ], - 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: [], - driver: createCorrelationDriver(`source`, 1, 101, 7), - projection: { - ...correlationProjection, - recompute: (context) => - correlationProjection.recompute({ ...context, target: `joined` }), - }, - }), - ).rejects.toMatchObject({ name: `TraceAssertionError`, checkpoint: 0 }) - }, - ) - for (const campaign of campaigns(1706, `includes-query-shape.nullable`)) { registerIncludesProperty( [fc.integer({ min: 1, max: 100 })], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `findOne maps a null correlation key to undefined (${campaign.name})`, async (postId) => { await runTrace({ @@ -604,70 +632,222 @@ describe(`includes query-shape recompute oracle`, () => { ) } - fcTest( - `matches recomputation for an unmatched non-null correlation key`, - () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [], - driver: createNullableDriver([], [{ id: 1, authorId: 999 }]), - projection: nullableProjection, - }) + oracleProperty({ + id: `includes-query-shape.multiplicity-fixed`, + law: { + statement: multiplicityLaw, + authority: `The joined multiplicity, alias identity, and null-key normalization contracts in packages/db/src/query/live/ARCHITECTURE.md (contribution conservation and total materialization), stated at the start of this owner.`, + limits: `Two fixed contributor counts, replayed through the multiplicity driver and recomputation, and the premature-removal calibration.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: multiplicityFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(multiplicityLaw, plan, observe, () => + runTrace({ + steps: [1], + driver: createMultiplicityDriver(plan.history.childCount), + projection: multiplicityProjection, + }), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a premature parent removal after one contributor leaves`, + law: multiplicityLaw, + // The former calibration body. Exactly the old matcher: only a + // TraceAssertionError at checkpoint 1 counts as the check + // rejecting a premature parent removal. The shared trace check marks its own comparison + // failures, so any other failure is rethrown without that marking and + // fails this calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(multiplicityLaw, plan, observe, async () => { + try { + await runTrace({ + steps: [1], + driver: createMultiplicityDriver(plan.history.childCount), + projection: { + ...multiplicityProjection, + observe: (context) => + context.children.size === 1 + ? [] + : multiplicityProjection.observe(context), + }, + }) + } catch (error) { + if ( + error instanceof Error && + error.name === `TraceAssertionError` && + (error as Error & { checkpoint?: unknown }).checkpoint === 1 + ) + rejectAt( + `rejects a premature parent removal after one contributor leaves`, + error, + ) + throw new Error( + `the corrupted projection failed outside the expected rejection: ${String(error)}`, + ) + } + }), + }, + ], + }) - fcTest( - `matches recomputation when an existing correlation key becomes null`, - () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [{ id: 1, authorId: null }], - driver: createNullableDriver( - [{ id: 1, name: `Ada` }], - [{ id: 1, authorId: 1 }], - ), - projection: nullableProjection, - }) + oracleProperty({ + id: `includes-query-shape.correlation-fixed`, + law: { + statement: correlationLaw, + authority: `The joined multiplicity, alias identity, and null-key normalization contracts in packages/db/src/query/live/ARCHITECTURE.md (contribution conservation and total materialization), stated at the start of this owner.`, + limits: `Four fixed source and joined correlation histories, replayed through the correlation driver and recomputation, and the wrong-alias calibration.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: correlationFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(correlationLaw, plan, observe, () => + runTrace({ + steps: plan.history.steps, + driver: createCorrelationDriver( + plan.history.target, + plan.history.correlationId, + plan.history.productionId, + plan.history.orderId, + ), + projection: correlationProjection, + }), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects the wrong correlation alias at the initial checkpoint`, + law: correlationLaw, + // The former calibration body. Exactly the old matcher: only a + // TraceAssertionError at checkpoint 0 counts as the check + // rejecting the wrong correlation alias. The shared trace check marks its own comparison + // failures, so any other failure is rethrown without that marking and + // fails this calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(correlationLaw, plan, observe, async () => { + try { + await runTrace({ + steps: plan.history.steps, + driver: createCorrelationDriver( + plan.history.target, + plan.history.correlationId, + plan.history.productionId, + plan.history.orderId, + ), + projection: { + ...correlationProjection, + recompute: (context) => + correlationProjection.recompute({ + ...context, + target: `joined`, + }), + }, + }) + } catch (error) { + if ( + error instanceof Error && + error.name === `TraceAssertionError` && + (error as Error & { checkpoint?: unknown }).checkpoint === 0 + ) + rejectAt( + `rejects the wrong correlation alias at the initial checkpoint`, + error, + ) + throw new Error( + `the corrupted projection failed outside the expected rejection: ${String(error)}`, + ) + } + }), + }, + ], + }) - fcTest( - `reactivates a singleton after null and repeated route retirement`, - () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [ - { id: 1, authorId: 1 }, - { id: 1, authorId: null }, - { id: 1, authorId: 1 }, - ], - driver: createNullableDriver( - [{ id: 1, name: `Ada` }], - [{ id: 1, authorId: null }], - ), - projection: nullableProjection, - }) + oracleProperty({ + id: `includes-query-shape.nullable-fixed`, + law: { + statement: nullableLaw, + authority: `The joined multiplicity, alias identity, and null-key normalization contracts in packages/db/src/query/live/ARCHITECTURE.md (contribution conservation and total materialization), stated at the start of this owner.`, + limits: `Four fixed null and unmatched correlation-key histories, replayed through the nullable driver and recomputation, and the stale-singleton calibration.`, }, - ) - - fcTest( - `rejects a stale empty singleton at its reactivation checkpoint`, - async () => { - oracleRoute(`calibration`) - await expect( + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/basic-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: nullableFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(nullableLaw, plan, observe, () => runTrace({ - steps: [{ id: 1, authorId: 1 }], + steps: plan.history.steps, driver: createNullableDriver( - [{ id: 1, name: `Ada` }], - [{ id: 1, authorId: null }], + plan.history.authors, + plan.history.posts, ), - projection: { - ...nullableProjection, - observe: ({ live }) => - live.toArray.map((row) => ({ id: row.id, author: undefined })), - }, + projection: nullableProjection, }), - ).rejects.toMatchObject({ name: `TraceAssertionError`, checkpoint: 1 }) - }, - ) + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a stale empty singleton at its reactivation checkpoint`, + law: nullableLaw, + // The former calibration body. Exactly the old matcher: only a + // TraceAssertionError at checkpoint 1 counts as the check + // rejecting a stale empty singleton. The shared trace check marks its own comparison + // failures, so any other failure is rethrown without that marking and + // fails this calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(nullableLaw, plan, observe, async () => { + try { + await runTrace({ + steps: plan.history.steps, + driver: createNullableDriver( + plan.history.authors, + plan.history.posts, + ), + projection: { + ...nullableProjection, + observe: ({ live }) => + live.toArray.map((row) => ({ + id: row.id, + author: undefined, + })), + }, + }) + } catch (error) { + if ( + error instanceof Error && + error.name === `TraceAssertionError` && + (error as Error & { checkpoint?: unknown }).checkpoint === 1 + ) + rejectAt( + `rejects a stale empty singleton at its reactivation checkpoint`, + error, + ) + throw new Error( + `the corrupted projection failed outside the expected rejection: ${String(error)}`, + ) + } + }), + }, + ], + }) }) From 1075a463a1d6e83fe02599e4837d5a7d7939ac50 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:03:01 -0700 Subject: [PATCH 054/116] test(db): register optimizer semantics oracle tests through oracleProperty Each aggregate and outer-join production path becomes an enumerated property through a named driver that keeps its comparisons. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 36 ++ .../query/optimizer-semantics-oracle.test.ts | 576 +++++++++++------- 2 files changed, 389 insertions(+), 223 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 2515a22c4e..1c1a9b2cab 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -108,6 +108,12 @@ const otherProperties: ReadonlyArray = [ `aggregate.select-fixed`, `load-subset.exact-axis-pairs`, `load-subset.rejected-waiter-fixed`, + `optimizer-semantics.aggregate-shapes`, + `optimizer-semantics.literal-field`, + `optimizer-semantics.group-key`, + `optimizer-semantics.residual-pushdown`, + `optimizer-semantics.preserved-join`, + `optimizer-semantics.two-active-chain`, ] const otherOwners: ReadonlyArray = [ [ @@ -205,6 +211,36 @@ const otherOwners: ReadonlyArray = [ `load-subset`, `rejected-waiter-fixed`, ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `aggregate-shapes`, + ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `literal-field`, + ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `group-key`, + ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `residual-pushdown`, + ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `preserved-join`, + ], + [ + `db/tests/query/optimizer-semantics-oracle.test.ts`, + `optimizer-semantics`, + `two-active-chain`, + ], ] export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/query/optimizer-semantics-oracle.test.ts b/packages/db/tests/query/optimizer-semantics-oracle.test.ts index 56e1f7d4d2..2d9f5c3643 100644 --- a/packages/db/tests/query/optimizer-semantics-oracle.test.ts +++ b/packages/db/tests/query/optimizer-semantics-oracle.test.ts @@ -24,7 +24,7 @@ * The nested matching cases also reject an all-empty QueryRef implementation. */ -import { describe, expect, test } from 'vitest' +import { describe, expect } from 'vitest' import { createLiveQueryCollection } from '../../src/query/index.js' import { createCollection } from '../../src/collection/index.js' import { optimizeQuery } from '../../src/query/optimizer.js' @@ -38,9 +38,13 @@ import { sum, } from '../../src/query/builder/functions.js' 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { expectedTerms, inertCollection, @@ -55,35 +59,67 @@ import type { Member, Row, Team } from './optimizer-semantics-support.js' import type { QueryIR, Where } from '../../src/query/ir.js' describe('optimizer aggregate semantics', () => { - for (const shape of [ + const aggregateShapes = [ `direct`, `wrapped`, `caseWhenCondition`, `caseWhenDefault`, - ] as const) { - test(`${shape} global aggregate keeps post-aggregate filtering`, () => { - oracleRoute(`case`) - const source = createCollection( - mockSyncCollectionOptions({ - id: `optimizer-aggregate-source-${shape}`, - getKey: (row) => row.id, - initialData: rows, - }), - ) - const anchor = createCollection( - mockSyncCollectionOptions({ - id: `optimizer-aggregate-anchor-${shape}`, - getKey: (row: { id: number; target: number }) => row.id, - initialData: [{ id: 1, target: 30 }], - }), - ) - const modelTotal = rows.reduce((total, row) => total + row.v, 0) - const expected = expectedAggregateTotals(shape, rows) + ] as const + // One global aggregate shape: the materialized and nested queries are + // compared with `expectedAggregateTotals`. + function runAggregateShape(shape: (typeof aggregateShapes)[number]): void { + const source = createCollection( + mockSyncCollectionOptions({ + id: `optimizer-aggregate-source-${shape}`, + getKey: (row) => row.id, + initialData: rows, + }), + ) + const anchor = createCollection( + mockSyncCollectionOptions({ + id: `optimizer-aggregate-anchor-${shape}`, + getKey: (row: { id: number; target: number }) => row.id, + initialData: [{ id: 1, target: 30 }], + }), + ) + const modelTotal = rows.reduce((total, row) => total + row.v, 0) + const expected = expectedAggregateTotals(shape, rows) + + const aggregate = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ b: source }).select(({ b }) => ({ + k: max(b.k), + total: + shape === `direct` + ? sum(b.v) + : shape === `wrapped` + ? add(sum(b.v), 0) + : shape === `caseWhenCondition` + ? caseWhen(eq(sum(b.v), modelTotal), 1, 0) + : caseWhen(eq(1, 2), 0, sum(b.v)), + })), + }) + expect(aggregate.toArray.map(stripVirtualProps)).toEqual( + expected.map((row) => ({ k: 2, ...row })), + ) - const aggregate = createLiveQueryCollection({ + const materialized = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ s: aggregate }) + .leftJoin({ a: anchor }, ({ s, a }) => eq(s.total, a.target)) + .where(({ s }) => eq(s.k, 2)) + .select(({ s }) => ({ total: s.total })), + }) + expect(materialized.toArray.map(stripVirtualProps)).toEqual(expected) + + for (const outerPredicate of [`accept`, `reject`] as const) { + const nested = createLiveQueryCollection({ startSync: true, - query: (q) => - q.from({ b: source }).select(({ b }) => ({ + query: (q) => { + const summary = q.from({ b: source }).select(({ b }) => ({ k: max(b.k), total: shape === `direct` @@ -93,60 +129,27 @@ describe('optimizer aggregate semantics', () => { : shape === `caseWhenCondition` ? caseWhen(eq(sum(b.v), modelTotal), 1, 0) : caseWhen(eq(1, 2), 0, sum(b.v)), - })), - }) - expect(aggregate.toArray.map(stripVirtualProps)).toEqual( - expected.map((row) => ({ k: 2, ...row })), - ) - - const materialized = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ s: aggregate }) + })) + return q + .from({ s: summary }) .leftJoin({ a: anchor }, ({ s, a }) => eq(s.total, a.target)) - .where(({ s }) => eq(s.k, 2)) - .select(({ s }) => ({ total: s.total })), + .where(({ s }) => + outerPredicate === `accept` ? eq(s.k, 2) : eq(s.k, 1), + ) + .select(({ s }) => ({ total: s.total })) + }, }) - expect(materialized.toArray.map(stripVirtualProps)).toEqual(expected) - - for (const outerPredicate of [`accept`, `reject`] as const) { - const nested = createLiveQueryCollection({ - startSync: true, - query: (q) => { - const summary = q.from({ b: source }).select(({ b }) => ({ - k: max(b.k), - total: - shape === `direct` - ? sum(b.v) - : shape === `wrapped` - ? add(sum(b.v), 0) - : shape === `caseWhenCondition` - ? caseWhen(eq(sum(b.v), modelTotal), 1, 0) - : caseWhen(eq(1, 2), 0, sum(b.v)), - })) - return q - .from({ s: summary }) - .leftJoin({ a: anchor }, ({ s, a }) => eq(s.total, a.target)) - .where(({ s }) => - outerPredicate === `accept` ? eq(s.k, 2) : eq(s.k, 1), - ) - .select(({ s }) => ({ total: s.total })) - }, - }) - expect(nested.toArray.map(stripVirtualProps)).toEqual( - outerPredicate === `accept` ? expected : [], - ) - } - }) + expect(nested.toArray.map(stripVirtualProps)).toEqual( + outerPredicate === `accept` ? expected : [], + ) + } } // A literal field of an aggregate subquery has one value for every group, // but no source row holds it. Under a join the optimizer pushes outer // 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`) + function runLiteralFieldHistory(): void { const source = createCollection( mockSyncCollectionOptions({ id: `optimizer-literal-source`, @@ -185,10 +188,9 @@ describe('optimizer aggregate semantics', () => { grouped ? `grouped` : `single group`, ).toEqual(expectedLiteralFieldRows(grouped, rows)) } - }) + } - test('group-key filtering keeps each grouped aggregate under a left join', () => { - oracleRoute(`fixed history`) + function runGroupKeyHistory(): void { const source = createCollection( mockSyncCollectionOptions({ id: `optimizer-grouped-source`, @@ -220,6 +222,80 @@ describe('optimizer aggregate semantics', () => { expect(grouped.toArray.map(stripVirtualProps)).toEqual( expectedGroupKeyRows(rows), ) + } + + oracleProperty({ + id: `optimizer-semantics.aggregate-shapes`, + law: { + statement: `An outer predicate on an aggregate subquery's result runs after the aggregate, so a nested global aggregate publishes its one total row and an accepting outer predicate keeps it.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `Direct, arithmetic-wrapped, conditional-branch, and conditional-default global aggregates, each with accepting and rejecting outer predicates.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: aggregateShapes, + label: (shape) => + `${shape} global aggregate keeps post-aggregate filtering`, + resolve: (shape) => ({ shape }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runAggregateShape(plan.shape), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `optimizer-semantics.literal-field`, + law: { + statement: `A predicate on a literal field of an aggregate subquery stays outside the aggregate, so each aggregate stays whole under a left join.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `One grouped and one single-group summary under a left join.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: [ + `literal-field filtering keeps each aggregate whole under a left join`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runLiteralFieldHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `optimizer-semantics.group-key`, + law: { + statement: `Filtering on the grouping key keeps that group's whole total under a left join.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `One grouped summary under a left join.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: [ + `group-key filtering keeps each grouped aggregate under a left join`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runGroupKeyHistory(), + ), + check: forwardAssertionJudgment, }) }) @@ -262,147 +338,146 @@ describe(`optimizer residual convergence`, () => { { type: `full`, teamPush: false, memberPush: false, residual: false }, ] as const - for (const join of joins) { - for (const form of [`separate`, `and`] as const) { - 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 = [ - predicate(`team`, `active`, true), - predicate(`member`, `userId`, 100), - ] - if (twoActive) { - if (join.type === `right`) { - memberKeys.push(`member.role="admin"`) - clauses.push(predicate(`member`, `role`, `admin`)) - } else { - teamKeys.push(`team.region="west"`) - clauses.push(predicate(`team`, `region`, `west`)) - } - } - const ordered = reversed ? [...clauses].reverse() : clauses - const where: Array = - form === `and` ? [new Func(`and`, ordered)] : ordered - const collection = inertCollection(`residual-oracle`) - const query: QueryIR = { - from: new CollectionRef(collection, `team`), - join: [ - { - type: join.type, - from: new CollectionRef(collection, `member`), - on: new Func('eq', [ - new PropRef([`team`, `id`]), - new PropRef([`member`, `teamId`]), - ]), - }, - ], - where, - } + const residualCells = joins.flatMap((join) => + ([`separate`, `and`] as const).flatMap((form) => + [false, true].flatMap((reversed) => + [false, true].map((twoActive) => ({ join, form, reversed, twoActive })), + ), + ), + ) + // One structural cell: `optimizeQuery` output, and a later pass over it, + // compared with `expectedPushdownShape`. + function runResidualCell({ + join, + form, + reversed, + twoActive, + }: (typeof residualCells)[number]): void { + const teamKeys = [`team.active=true`] + const memberKeys = [`member.userId=100`] + const clauses = [ + predicate(`team`, `active`, true), + predicate(`member`, `userId`, 100), + ] + if (twoActive) { + if (join.type === `right`) { + memberKeys.push(`member.role="admin"`) + clauses.push(predicate(`member`, `role`, `admin`)) + } else { + teamKeys.push(`team.region="west"`) + clauses.push(predicate(`team`, `region`, `west`)) + } + } + const ordered = reversed ? [...clauses].reverse() : clauses + const where: Array = + form === `and` ? [new Func(`and`, ordered)] : ordered + const collection = inertCollection(`residual-oracle`) + const query: QueryIR = { + from: new CollectionRef(collection, `team`), + join: [ + { + type: join.type, + from: new CollectionRef(collection, `member`), + on: new Func('eq', [ + new PropRef([`team`, `id`]), + new PropRef([`member`, `teamId`]), + ]), + }, + ], + where, + } - const result = optimizeQuery(query) - const optimized = result.optimizedQuery - const shape = expectedPushdownShape(join, teamKeys, memberKeys) - expect([...result.sourceWhereClauses.keys()].sort()).toEqual( - shape.sourceAliases, - ) - for (const [alias, keys] of [ - [`team`, teamKeys], - [`member`, memberKeys], - ] as const) { - const clause = result.sourceWhereClauses.get(alias) - if (clause) { - expect(observedTerms([clause])).toEqual( - expectedTerms(keys, false), - ) - } - } - const laterPass = optimizeQuery(optimized).optimizedQuery - for (const actual of [optimized, laterPass]) { - expect(sourceTerms(actual.from)).toEqual(shape.teamSource) - expect(sourceTerms(actual.join![0]!.from)).toEqual( - shape.memberSource, - ) - expect(observedTerms(actual.where)).toEqual(shape.where) - expect(actual.where).toHaveLength(shape.whereLength) - } - }) - } + const result = optimizeQuery(query) + const optimized = result.optimizedQuery + const shape = expectedPushdownShape(join, teamKeys, memberKeys) + expect([...result.sourceWhereClauses.keys()].sort()).toEqual( + shape.sourceAliases, + ) + for (const [alias, keys] of [ + [`team`, teamKeys], + [`member`, memberKeys], + ] as const) { + const clause = result.sourceWhereClauses.get(alias) + if (clause) { + expect(observedTerms([clause])).toEqual(expectedTerms(keys, false)) } } + const laterPass = optimizeQuery(optimized).optimizedQuery + for (const actual of [optimized, laterPass]) { + expect(sourceTerms(actual.from)).toEqual(shape.teamSource) + expect(sourceTerms(actual.join![0]!.from)).toEqual(shape.memberSource) + expect(observedTerms(actual.where)).toEqual(shape.where) + expect(actual.where).toHaveLength(shape.whereLength) + } } - 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`, - getKey: (row) => row.id, - initialData: teams, - }), - ) - const memberCollection = createCollection( - mockSyncCollectionOptions({ - id: `optimizer-residual-${joinType}-members`, - getKey: (row) => row.id, - initialData: members, - }), - ) + // One outer join's first public snapshot, compared with + // `expectedPreservedJoinRows`. + function runPreservedJoin(joinType: `left` | `right`): void { + const teamCollection = createCollection( + mockSyncCollectionOptions({ + id: `optimizer-residual-${joinType}-teams`, + getKey: (row) => row.id, + initialData: teams, + }), + ) + const memberCollection = createCollection( + mockSyncCollectionOptions({ + id: `optimizer-residual-${joinType}-members`, + getKey: (row) => row.id, + initialData: members, + }), + ) - const expected = expectedPreservedJoinRows(joinType, teams, members) + const expected = expectedPreservedJoinRows(joinType, teams, members) - const result = - joinType === `left` - ? createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ team: teamCollection }) - .leftJoin({ member: memberCollection }, ({ team, member }) => - eq(team.id, member.teamId), - ) - .where(({ team }) => eq(team.active, true)) - .where(({ member }) => - or(isUndefined(member.id), eq(member.userId, 100)), - ) - .select(({ team, member }) => ({ - teamId: team.id, - memberId: member.id, - })), - }) - : createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ team: teamCollection }) - .rightJoin({ member: memberCollection }, ({ team, member }) => - eq(team.id, member.teamId), - ) - .where(({ member }) => eq(member.userId, 100)) - .where(({ team }) => - or(isUndefined(team.id), eq(team.active, true)), - ) - .select(({ team, member }) => ({ - teamId: team.id, - memberId: member.id, - })), - }) - const actual = result.toArray - .map(stripVirtualProps) - .map((row) => { - if (row === undefined) throw new Error(`Missing joined result row`) - return [row.teamId ?? null, row.memberId ?? null] - }) - .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) - expect(actual).toEqual(expected) - }) + const result = + joinType === `left` + ? createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamCollection }) + .leftJoin({ member: memberCollection }, ({ team, member }) => + eq(team.id, member.teamId), + ) + .where(({ team }) => eq(team.active, true)) + .where(({ member }) => + or(isUndefined(member.id), eq(member.userId, 100)), + ) + .select(({ team, member }) => ({ + teamId: team.id, + memberId: member.id, + })), + }) + : createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ team: teamCollection }) + .rightJoin({ member: memberCollection }, ({ team, member }) => + eq(team.id, member.teamId), + ) + .where(({ member }) => eq(member.userId, 100)) + .where(({ team }) => + or(isUndefined(team.id), eq(team.active, true)), + ) + .select(({ team, member }) => ({ + teamId: team.id, + memberId: member.id, + })), + }) + const actual = result.toArray + .map(stripVirtualProps) + .map((row) => { + if (row === undefined) throw new Error(`Missing joined result row`) + return [row.teamId ?? null, row.memberId ?? null] + }) + .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) + expect(actual).toEqual(expected) } - test(`two active sources publish the independently joined rows`, () => { - oracleRoute(`fixed history`) + function runTwoActiveSourcesHistory(): void { const tags = [ { id: 20, teamId: 1, region: `west` }, { id: 21, teamId: 2, region: `west` }, @@ -462,6 +537,77 @@ describe(`optimizer residual convergence`, () => { }) .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) expect(actual).toEqual(expected) + } + + oracleProperty({ + id: `optimizer-semantics.residual-pushdown`, + law: { + statement: `A predicate enters a nonnullable source once, its outer copy stays residual where an outer join applies it, and a nullable-side predicate stays regular, at optimizeQuery return and after a later pass.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `LEFT, RIGHT, INNER, and FULL joins with one or two pushable predicates, separate or AND clauses, and both clause orders.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: residualCells, + label: ({ join, form, reversed, twoActive }) => + `${join.type} ${form} reversed=${reversed} twoActive=${twoActive} preserves each predicate once`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runResidualCell(plan.cell), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `optimizer-semantics.preserved-join`, + law: { + statement: `An outer join with residual predicates publishes the independently joined rows at its first public snapshot.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `One LEFT and one RIGHT join with an OR predicate that accepts an unmatched row.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: [`left`, `right`] as const, + label: (joinType) => + `${joinType} join publishes the independently joined rows`, + resolve: (joinType) => ({ joinType }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runPreservedJoin(plan.joinType), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `optimizer-semantics.two-active-chain`, + law: { + statement: `A LEFT/INNER chain with two distinct pushable sources publishes the independently joined rows.`, + authority: `The optimizer's predicate-pushdown and outer-join contract stated above.`, + limits: `One LEFT then INNER join chain.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/query/optimizer.ts#optimizeQuery', + ], + histories: [`two active sources publish the independently joined rows`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`optimizer semantics`, plan, observe, () => + runTwoActiveSourcesHistory(), + ), + check: forwardAssertionJudgment, }) }) @@ -603,19 +749,3 @@ function expectedChainRows( }) .sort((a, b) => JSON.stringify(a).localeCompare(JSON.stringify(b))) } - -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/query/live-query-collection.ts#createLiveQueryCollection', - 'src/query/optimizer.ts#optimizeQuery', - ], - model: [ - expectedAggregateTotals, - expectedLiteralFieldRows, - expectedGroupKeyRows, - expectedPushdownShape, - expectedPreservedJoinRows, - expectedChainRows, - ], -}) From e882f6a97d7179a3dec81ab4cf9179769a5329ee Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:04:06 -0700 Subject: [PATCH 055/116] test(db): register subquery user-value histories through oracleProperty Each named QueryRef history becomes an enumerated property through a named driver that keeps its model comparisons. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 36 ++++ .../query/subquery-user-value-oracle.test.ts | 199 +++++++++++++++--- 2 files changed, 202 insertions(+), 33 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 1c1a9b2cab..d3c378489b 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -114,6 +114,12 @@ const otherProperties: ReadonlyArray = [ `optimizer-semantics.residual-pushdown`, `optimizer-semantics.preserved-join`, `optimizer-semantics.two-active-chain`, + `subquery-user-value.distinct-outer-where`, + `subquery-user-value.joined-distinct-writes`, + `subquery-user-value.joined-aggregate-writes`, + `subquery-user-value.flat-aggregate-join`, + `subquery-user-value.ordered-find-one`, + `subquery-user-value.materialized-find-one`, ] const otherOwners: ReadonlyArray = [ [ @@ -241,6 +247,36 @@ const otherOwners: ReadonlyArray = [ `optimizer-semantics`, `two-active-chain`, ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `distinct-outer-where`, + ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `joined-distinct-writes`, + ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `joined-aggregate-writes`, + ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `flat-aggregate-join`, + ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `ordered-find-one`, + ], + [ + `db/tests/query/subquery-user-value-oracle.test.ts`, + `subquery-user-value`, + `materialized-find-one`, + ], ] export const properties: ReadonlyArray = [ 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 663404bbf2..4f913e7771 100644 --- a/packages/db/tests/query/subquery-user-value-oracle.test.ts +++ b/packages/db/tests/query/subquery-user-value-oracle.test.ts @@ -1,5 +1,5 @@ import { D2, MultiSet, output } from '@tanstack/db-ivm' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { count, createLiveQueryCollection, @@ -10,8 +10,12 @@ 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { stripVirtualProps } from '../utils.js' import { createControlledCollection } from './includes-oracle-helpers.js' @@ -124,23 +128,13 @@ function expectedFindOneCandidates( .sort((left, right) => left.personId - right.personId) } -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/query/compiler/index.ts#compileQuery', - 'src/query/live-query-collection.ts#createLiveQueryCollection', - ], - check: [expectPublicSnapshotFollowsModel], - model: [ - expectedDistinctRows, - expectedAggregateJoinRows, - expectedFindOneCandidates, - ], -}) +// Each named history below runs real live-query Collections and compares the +// public snapshot with the models above, through +// `expectPublicSnapshotFollowsModel` or the history's own comparisons. +const subqueryAuthority = `The query API and the live-query architecture's public surface law, as stated at the start of this owner.` describe('subquery boundaries preserve operators and user rows', () => { - it('joins the distinct subquery result before applying the outer WHERE', async () => { - oracleRoute(`fixed history`) + async function runDistinctOuterWhereHistory(): Promise { const persons = createControlledCollection( 'distinct-outer-persons', people, @@ -228,10 +222,9 @@ describe('subquery boundaries preserve operators and user rows', () => { await persons.collection.cleanup() await tagRows.collection.cleanup() } - }) + } - it('recomputes a joined DISTINCT result after source writes', async () => { - oracleRoute(`fixed history`) + async function runJoinedDistinctWritesHistory(): Promise { const persons = createControlledCollection( 'distinct-history-persons', people, @@ -300,10 +293,9 @@ describe('subquery boundaries preserve operators and user rows', () => { () => tagRows.collection.cleanup(), ], ) - }) + } - it('recomputes a joined aggregate QueryRef after source writes', async () => { - oracleRoute(`fixed history`) + async function runJoinedAggregateWritesHistory(): Promise { const initialItems = [{ id: 1 }, { id: 2 }] const initialPeople = [{ id: 2 }] const source = createControlledCollection( @@ -374,13 +366,12 @@ describe('subquery boundaries preserve operators and user rows', () => { () => persons.collection.cleanup(), ], ) - }) + } // A flat aggregate is legal by the query builder and has the same one-row // 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`) + async function runFlatAggregateJoinHistory(): Promise { const source = createControlledCollection('flat-aggregate-items', [ { id: 1 }, { id: 2 }, @@ -439,12 +430,11 @@ describe('subquery boundaries preserve operators and user rows', () => { () => persons.collection.cleanup(), ], ) - }) + } // 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`) + async function runOrderedFindOneHistory(): Promise { const initialPeople = [ { id: 1, groupId: 1 }, { id: 2, groupId: 2 }, @@ -547,10 +537,9 @@ describe('subquery boundaries preserve operators and user rows', () => { for (const { checkpoint, actual, expected } of snapshots) { expect(actual, checkpoint).toEqual(expected) } - }) + } - it('materializes one joined findOne value after source writes', async () => { - oracleRoute(`fixed history`) + async function runMaterializedFindOneHistory(): Promise { const initialPeople = [ { id: 1, groupId: 1, scope: 1 }, { id: 2, groupId: 2, scope: 1 }, @@ -630,5 +619,149 @@ describe('subquery boundaries preserve operators and user rows', () => { () => anchors.collection.cleanup(), ], ) + } + + oracleProperty({ + id: `subquery-user-value.distinct-outer-where`, + law: { + statement: `A DISTINCT QueryRef result is formed before the outer join and WHERE.`, + authority: subqueryAuthority, + limits: `One excluded outer row at preload.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `joins the distinct subquery result before applying the outer WHERE`, + ], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runDistinctOuterWhereHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subquery-user-value.joined-distinct-writes`, + law: { + statement: `A joined DISTINCT QueryRef result is recomputed from source rows after each source write.`, + authority: subqueryAuthority, + limits: `One history that removes and restores duplicate support, then flips the outer predicate.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [`recomputes a joined DISTINCT result after source writes`], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runJoinedDistinctWritesHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subquery-user-value.joined-aggregate-writes`, + law: { + statement: `A joined aggregate QueryRef is recomputed from source rows after each source write.`, + authority: subqueryAuthority, + limits: `One nested aggregate history, compared at each synchronous source write.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [`recomputes a joined aggregate QueryRef after source writes`], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runJoinedAggregateWritesHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subquery-user-value.flat-aggregate-join`, + law: { + statement: `A flat aggregate QueryRef join publishes the model rows through source writes.`, + authority: subqueryAuthority, + limits: `One flat aggregate join history.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `publishes a flat aggregate QueryRef join through source writes`, + ], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runFlatAggregateJoinHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subquery-user-value.ordered-find-one`, + law: { + statement: `A joined findOne QueryRef publishes one ordered candidate row through source writes.`, + authority: subqueryAuthority, + limits: `One joined findOne history across the initial, singleton, empty, restored, and default-key candidate cuts.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `publishes one ordered joined findOne QueryRef row through source writes`, + ], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runOrderedFindOneHistory(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subquery-user-value.materialized-find-one`, + law: { + statement: `A materialized joined findOne value follows the model candidate after source writes.`, + authority: subqueryAuthority, + limits: `One materialized control at the receiving boundary.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/query/compiler/index.ts#compileQuery', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [`materializes one joined findOne value after source writes`], + label: (history) => history, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`subquery user values`, plan, observe, () => + runMaterializedFindOneHistory(), + ), + check: forwardAssertionJudgment, }) }) From ab448b4539cbc2ff6701d709015163d3e714c5a8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:04:17 -0700 Subject: [PATCH 056/116] test(db): register cursor matrices and calibrations through oracleProperty The fixed scalar, width and local-order cases become enumerated properties over the same shared checks. Each wrong-builder calibration becomes a mutant-driver calibration that accepts exactly the rejections the old test accepted. Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 9 + .../db/tests/cursor-oracle.property.test.ts | 365 +++++++++++++----- 2 files changed, 267 insertions(+), 107 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index cba0fced6d..6aa1d94088 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -24,6 +24,10 @@ const windowHistoryDrivers = [ export const properties: ReadonlyArray = [ // indexes and comparison + `cursor.scalar-matrix`, + `cursor.composite-width-matrix`, + `cursor.leading-boundary-matrix`, + `cursor.local-order-fixed`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -52,6 +56,11 @@ export const properties: ReadonlyArray = [ export const owners: ReadonlyArray = [ // indexes and comparison + [ + `db/tests/cursor-oracle.property.test.ts`, + `cursor`, + `scalar-matrix composite-width-matrix leading-boundary-matrix local-order-fixed`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance diff --git a/packages/db/tests/cursor-oracle.property.test.ts b/packages/db/tests/cursor-oracle.property.test.ts index 17764f02a1..f2573801f8 100644 --- a/packages/db/tests/cursor-oracle.property.test.ts +++ b/packages/db/tests/cursor-oracle.property.test.ts @@ -1,19 +1,15 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it as vitestIt } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../src/collection/index.js' import { PropRef, Value } from '../src/query/ir.js' import { buildCursor } from '../src/utils/cursor.js' -import { - oracleProperty, - oracleReplayTest, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import { withHistoryCleanup } from './optimistic-history-oracle.js' import { orderBy } from './cursor-order-terms.js' @@ -39,8 +35,6 @@ import type { Term } from './cursor-order-terms.js' * paginated query's acquisition and publication behavior. */ -const it = oracleReplayTest(vitestIt) - const fixedSeed = 20260930 const termArbitrary = fc.record({ @@ -235,10 +229,31 @@ const assertRepeatedCursor = ( ) } -declareOracle({ - check: [expectCursorDenotation, expectLocalTupleOrder, assertRepeatedCursor], - model: [compareValue, compareTuple], -}) +// A calibration's wrong builder or observation must make the shared check +// throw. Each old calibration accepted any thrown Error, so exactly those +// rejections count as the check rejecting the wrong answer. +function acceptRejection(name: string, error: unknown): never { + if (error instanceof Error) rejectAt(`calibration ${name}`, error) + throw new Error( + `${name}: the check threw a non-Error value, which the old calibration did not accept: ${String(error)}`, + ) +} + +const cursorAuthority = `CursorExpressions.whereFrom API contract and ordered continuation in query/live/ARCHITECTURE.md` + +// The two width-two boundary values that a composite-width builder must reject. +const compositeTerm: Term = { direction: `asc`, nulls: `last` } + +// The fixed public-snapshot history: a leading tie ordered by a nullable +// trailing term. +const localOrderFixed: CursorCase = [ + [ + { direction: `asc`, nulls: `last` }, + { direction: `desc`, nulls: `first` }, + ], + [0, 1], + [0, null], +] // Boundary width distinguishes one-value continuation from rejected composite // values; one value remains legal with several order terms. Direction reverses @@ -251,19 +266,89 @@ declareOracle({ // 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) { - for (const candidate of scalarValues) { - expectCursorDenotation([term], [boundary], [candidate]) - } - } + oracleProperty({ + id: `cursor.scalar-matrix`, + law: { + statement: `A leading-column cursor denotes exactly the strict ordered suffix, including direction and null placement.`, + authority: cursorAuthority, + limits: `Every small scalar/null boundary and candidate pair under each of the four direction and null-placement cells.`, }, - ) + owns: [`src/utils/cursor.ts#buildCursor`], + histories: scalarTerms, + label: ({ direction, nulls }) => + `checks every small scalar/null boundary for ${direction} / nulls ${nulls}`, + resolve: (term) => ({ term }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`cursor.scalar-matrix`, plan, observe, () => { + // 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) { + for (const candidate of scalarValues) { + expectCursorDenotation([plan.term], [boundary], [candidate]) + } + } + }), + check: forwardAssertionJudgment, + // A constant predicate is a wrong builder: true admits the equal boundary + // value, false drops the following one. The check must reject each. + calibrations: scalarTerms.flatMap((term) => { + const following = term.direction === `asc` ? 1 : -1 + const name = `rejects constant cursor predicates for ${term.direction} / nulls ${term.nulls}` + return [ + { + name: `${name}: constant true`, + law: `cursor.scalar-matrix`, + driver: ( + plan: { term: Term }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `cursor.scalar-matrix`, + plan, + observe, + () => { + try { + expectCursorDenotation( + [term], + [0], + [0], + () => new Value(true), + ) + } catch (error) { + acceptRejection(name, error) + } + }, + ), + }, + { + name: `${name}: constant false`, + law: `cursor.scalar-matrix`, + driver: ( + plan: { term: Term }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `cursor.scalar-matrix`, + plan, + observe, + () => { + try { + expectCursorDenotation( + [term], + [0], + [following], + () => new Value(false), + ) + } catch (error) { + acceptRejection(name, error) + } + }, + ), + }, + ] + }), + }) oracleProperty({ id: `cursor.scalar-continuation`, @@ -289,97 +374,137 @@ describe(`buildCursor properties`, () => { check: forwardAssertionJudgment, }) - 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), - Array(width).fill(0), - Array(width).fill(1), - ) - } - }, - ) - - 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, - })) - expectCursorDenotation(terms, [0], Array(width).fill(1)) - expectCursorDenotation(terms, [0], Array(width).fill(-1)) + oracleProperty({ + id: `cursor.composite-width-matrix`, + law: { + statement: `One boundary value is supported; composite cursor values reject.`, + authority: cursorAuthority, + limits: `Composite widths 2, 3 and 4 with every term equal, under each of the four option cells.`, }, - ) - - it(`rejects a builder that accepts two boundary values`, () => { - oracleRoute(`calibration`) - const term: Term = { direction: `asc`, nulls: `last` } - expect(() => - expectCursorDenotation( - [term, term], - [0, 0], - [1, 1], - () => new Value(true), + owns: [`src/utils/cursor.ts#buildCursor`], + histories: [2, 3, 4], + label: (width) => `rejects composite width ${width} in every option cell`, + resolve: (width) => ({ width }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `cursor.composite-width-matrix`, + plan, + observe, + () => { + for (const term of scalarTerms) { + expectCursorDenotation( + Array.from({ length: plan.width }, () => term), + Array(plan.width).fill(0), + Array(plan.width).fill(1), + ) + } + }, ), - ).toThrow() - expectCursorDenotation([term, term], [0, 0], [1, 1]) + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a builder that accepts two boundary values`, + law: `cursor.composite-width-matrix`, + driver: (plan, observe) => + recordAssertionJudgment( + `cursor.composite-width-matrix`, + plan, + observe, + () => { + try { + expectCursorDenotation( + [compositeTerm, compositeTerm], + [0, 0], + [1, 1], + () => new Value(true), + ) + } catch (error) { + acceptRejection( + `rejects a builder that accepts two boundary values`, + error, + ) + } + }, + ), + }, + ], }) - 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` }, - ] - const boundary = [0, 1] - const candidate = [0, null] - await expectLocalTupleOrder(terms, boundary, candidate) - // A leading-only order would place boundary first by the id tie-breaker. - await expect( - expectLocalTupleOrder( - terms, - boundary, - candidate, - `reverse-observed-order`, + oracleProperty({ + id: `cursor.leading-boundary-matrix`, + law: { + statement: `One leading boundary value remains legal with several order terms.`, + authority: cursorAuthority, + limits: `Two, three and four ascending nulls-last terms with one boundary value and candidates above and below it.`, + }, + owns: [`src/utils/cursor.ts#buildCursor`], + histories: [2, 3, 4], + label: (width) => + `accepts one leading boundary value for ${width} ordered terms`, + resolve: (width) => ({ width }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `cursor.leading-boundary-matrix`, + plan, + observe, + () => { + const terms = Array.from({ length: plan.width }, () => ({ + direction: `asc` as const, + nulls: `last` as const, + })) + expectCursorDenotation(terms, [0], Array(plan.width).fill(1)) + expectCursorDenotation(terms, [0], Array(plan.width).fill(-1)) + }, ), - ).rejects.toThrow() + check: forwardAssertionJudgment, }) - it.each(scalarTerms)( - `rejects constant cursor predicates for $direction / nulls $nulls`, - (term) => { - oracleRoute(`calibration`) - expect(() => - expectCursorDenotation([term], [0], [0], () => new Value(true)), - ).toThrow() - const following = term.direction === `asc` ? 1 : -1 - expect(() => - expectCursorDenotation( - [term], - [0], - [following], - () => new Value(false), - ), - ).toThrow() - expectCursorDenotation([term], [0], [0]) - expectCursorDenotation([term], [0], [following]) + oracleProperty({ + id: `cursor.local-order-fixed`, + law: { + statement: `The public snapshot orders nullable tuples by all authored terms after preload fulfills.`, + authority: cursorAuthority, + limits: `One fixed leading-tie history with a nullable descending trailing term, which the generated width grammars cannot take back.`, }, - ) - - it(`rejects an unstable cursor expression on repeated construction`, () => { - oracleRoute(`calibration`) - let calls = 0 - expect(() => - assertRepeatedCursor( - [[{ direction: `asc`, nulls: `first` }], [0], [0]], - () => new Value(++calls === 1), + owns: [`src/utils/cursor.ts#buildCursor`], + histories: [localOrderFixed], + label: () => + `orders a leading tie by a nullable trailing term in the public snapshot after preload fulfills`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`cursor.local-order-fixed`, plan, observe, () => + assertLocalTupleOrder(plan.history), ), - ).toThrow() + check: forwardAssertionJudgment, + // A leading-only order would place boundary first by the id tie-breaker. + calibrations: [ + { + name: `rejects a reversed observed order`, + law: `cursor.local-order-fixed`, + driver: (plan, observe) => + recordAssertionJudgment( + `cursor.local-order-fixed`, + plan, + observe, + async () => { + const [terms, boundary, candidate] = localOrderFixed + try { + await expectLocalTupleOrder( + terms, + boundary, + candidate, + `reverse-observed-order`, + ) + } catch (error) { + acceptRejection(`rejects a reversed observed order`, error) + } + }, + ), + }, + ], }) oracleProperty({ @@ -490,5 +615,31 @@ describe(`buildCursor properties`, () => { assertRepeatedCursor(plan.history), ), check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects an unstable cursor expression on repeated construction`, + law: `cursor.repeat-construction`, + driver: (plan, observe) => + recordAssertionJudgment( + `cursor.repeat-construction`, + plan, + observe, + () => { + let calls = 0 + try { + assertRepeatedCursor( + [[{ direction: `asc`, nulls: `first` }], [0], [0]], + () => new Value(++calls === 1), + ) + } catch (error) { + acceptRejection( + `rejects an unstable cursor expression on repeated construction`, + error, + ) + } + }, + ), + }, + ], }) }) From 987e985e7b3e9d6f6f783ddc49a8214e4e01f730 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:05:42 -0700 Subject: [PATCH 057/116] test(db): register Duration equality cases through oracleProperty The direct, public and calendar cases become enumerated properties per index type. The model calibration runs no production, so it moves to duration-query-equality-calibration.test.ts, with the model and fixed inputs in the test-free duration-query-equality-model-oracle.ts. Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 8 + ...uration-query-equality-calibration.test.ts | 49 +++ .../duration-query-equality-model-oracle.ts | 72 ++++ .../duration-query-equality-oracle.test.ts | 347 ++++++++---------- 4 files changed, 284 insertions(+), 192 deletions(-) create mode 100644 packages/db/tests/duration-query-equality-calibration.test.ts create mode 100644 packages/db/tests/duration-query-equality-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 6aa1d94088..7a8056cde0 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -28,6 +28,9 @@ export const properties: ReadonlyArray = [ `cursor.composite-width-matrix`, `cursor.leading-boundary-matrix`, `cursor.local-order-fixed`, + `duration-query-equality.direct-lookup`, + `duration-query-equality.public-eq`, + `duration-query-equality.calendar-indexing`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -61,6 +64,11 @@ export const owners: ReadonlyArray = [ `cursor`, `scalar-matrix composite-width-matrix leading-boundary-matrix local-order-fixed`, ], + [ + `db/tests/duration-query-equality-oracle.test.ts`, + `duration-query-equality`, + `direct-lookup public-eq calendar-indexing`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance diff --git a/packages/db/tests/duration-query-equality-calibration.test.ts b/packages/db/tests/duration-query-equality-calibration.test.ts new file mode 100644 index 0000000000..9156d087ac --- /dev/null +++ b/packages/db/tests/duration-query-equality-calibration.test.ts @@ -0,0 +1,49 @@ +// Calibration of the Duration equality model in +// duration-query-equality-model-oracle.ts. This test runs no production: it +// requires the model to separate every field and to reject three plausible +// wrong keys at the oracle's exact-key-set comparison. +import { expect, it } from 'vitest' +import { + cases, + fields, + modelEqual, + modelKeys, + oppositeSigns, + sameDisplay, + secondFieldChange, +} from './duration-query-equality-model-oracle.js' +import type { Case } from './duration-query-equality-model-oracle.js' +import type { Temporal } from 'temporal-polyfill' + +// Every field changes the model's answer, while a separately constructed +// 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`, () => { + for (const [equal, changed] of Array.from({ length: 10 }, (_, index) => + cases.slice(index * 2, index * 2 + 2), + )) { + expect(modelEqual(equal!.stored, equal!.probe)).toBe(true) + expect(modelEqual(changed!.stored, changed!.probe)).toBe(false) + } + expect(String(sameDisplay.stored)).toBe(String(sameDisplay.probe)) + expect(modelEqual(sameDisplay.stored, sameDisplay.probe)).toBe(false) + const magnitudeKey = (value: Temporal.Duration) => + fields.map((field) => Math.abs(value[field])).join(`,`) + const firstNonzeroKey = (value: Temporal.Duration) => { + const field = fields.find((candidate) => value[candidate] !== 0)! + return `${field}:${value[field]}` + } + const wrongKeys = ( + testCase: Case, + key: (value: Temporal.Duration) => string, + ) => new Set(key(testCase.stored) === key(testCase.probe) ? [`row`] : []) + for (const [testCase, key] of [ + [sameDisplay, String], + [oppositeSigns, magnitudeKey], + [secondFieldChange, firstNonzeroKey], + ] as const) { + expect(() => + expect(wrongKeys(testCase, key)).toEqual(modelKeys(testCase)), + ).toThrow() + } +}) diff --git a/packages/db/tests/duration-query-equality-model-oracle.ts b/packages/db/tests/duration-query-equality-model-oracle.ts new file mode 100644 index 0000000000..cd796d9a1b --- /dev/null +++ b/packages/db/tests/duration-query-equality-model-oracle.ts @@ -0,0 +1,72 @@ +// The independent Duration equality model and the fixed input grammar of +// duration-query-equality-oracle.test.ts, which states the law and its limits. +// duration-query-equality-calibration.test.ts calibrates the same model. This +// module registers no tests and has no side effects. +import { Temporal } from 'temporal-polyfill' + +export const fields = [ + `years`, + `months`, + `weeks`, + `days`, + `hours`, + `minutes`, + `seconds`, + `milliseconds`, + `microseconds`, + `nanoseconds`, +] as const + +export function modelEqual( + left: Temporal.Duration, + right: Temporal.Duration, +): boolean { + return fields.every((field) => left[field] === right[field]) +} + +export type Case = { + name: string + stored: Temporal.Duration + probe: Temporal.Duration +} + +export const sameDisplay: Case = { + name: `seconds versus milliseconds with identical display`, + stored: Temporal.Duration.from({ seconds: 1 }), + probe: Temporal.Duration.from({ milliseconds: 1000 }), +} +export const oppositeSigns: Case = { + name: `opposite signs`, + stored: Temporal.Duration.from({ seconds: 1 }), + probe: Temporal.Duration.from({ seconds: -1 }), +} +export const secondFieldChange: Case = { + name: `same first nonzero field with a changed second field`, + stored: Temporal.Duration.from({ hours: 1, minutes: 2 }), + probe: Temporal.Duration.from({ hours: 1, minutes: 3 }), +} + +export const cases: ReadonlyArray = [ + ...fields.flatMap((field) => { + const stored = Temporal.Duration.from({ [field]: 1 }) + return [ + { + name: `${field} equal copy`, + stored, + probe: Temporal.Duration.from({ [field]: 1 }), + }, + { + name: `${field} changed`, + stored, + probe: Temporal.Duration.from({ [field]: 2 }), + }, + ] + }), + sameDisplay, + oppositeSigns, + secondFieldChange, +] + +export function modelKeys({ stored, probe }: Case): Set { + return new Set(modelEqual(stored, probe) ? [`row`] : []) +} diff --git a/packages/db/tests/duration-query-equality-oracle.test.ts b/packages/db/tests/duration-query-equality-oracle.test.ts index b8864e38c6..69985d75a9 100644 --- a/packages/db/tests/duration-query-equality-oracle.test.ts +++ b/packages/db/tests/duration-query-equality-oracle.test.ts @@ -16,7 +16,7 @@ * make no claim about Duration ordering, other Temporal kinds, mutations, or * live subscriber publication. */ -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { Temporal } from 'temporal-polyfill' import { createCollection } from '../src/collection/index.js' import { BasicIndex } from '../src/indexes/basic-index.js' @@ -24,75 +24,17 @@ 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 = [ - `years`, - `months`, - `weeks`, - `days`, - `hours`, - `minutes`, - `seconds`, - `milliseconds`, - `microseconds`, - `nanoseconds`, -] as const - -function modelEqual( - left: Temporal.Duration, - right: Temporal.Duration, -): boolean { - return fields.every((field) => left[field] === right[field]) -} - -type Case = { - name: string - stored: Temporal.Duration - probe: Temporal.Duration -} - -const sameDisplay: Case = { - name: `seconds versus milliseconds with identical display`, - stored: Temporal.Duration.from({ seconds: 1 }), - probe: Temporal.Duration.from({ milliseconds: 1000 }), -} -const oppositeSigns: Case = { - name: `opposite signs`, - stored: Temporal.Duration.from({ seconds: 1 }), - probe: Temporal.Duration.from({ seconds: -1 }), -} -const secondFieldChange: Case = { - name: `same first nonzero field with a changed second field`, - stored: Temporal.Duration.from({ hours: 1, minutes: 2 }), - probe: Temporal.Duration.from({ hours: 1, minutes: 3 }), -} - -const cases: ReadonlyArray = [ - ...fields.flatMap((field) => { - const stored = Temporal.Duration.from({ [field]: 1 }) - return [ - { - name: `${field} equal copy`, - stored, - probe: Temporal.Duration.from({ [field]: 1 }), - }, - { - name: `${field} changed`, - stored, - probe: Temporal.Duration.from({ [field]: 2 }), - }, - ] - }), - sameDisplay, - oppositeSigns, - secondFieldChange, -] - -function modelKeys({ stored, probe }: Case): Set { - return new Set(modelEqual(stored, probe) ? [`row`] : []) -} +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' +import { + cases, + modelEqual, + modelKeys, +} from './duration-query-equality-model-oracle.js' // The check: a public or direct read returns exactly the model's key set, // in the same representation, at the read that was made. @@ -104,129 +46,63 @@ 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], -}) - const indexTypes = [ [`BasicIndex`, BasicIndex], [`BTreeIndex`, BTreeIndex], ] as const +type IndexName = (typeof indexTypes)[number][0] +const indexNames = indexTypes.map(([name]) => name) +const indexType = (name: IndexName) => + indexTypes.find(([candidate]) => candidate === name)![1] -// Every field changes the model's answer, while a separately constructed -// 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), - )) { - expect(modelEqual(equal!.stored, equal!.probe)).toBe(true) - expect(modelEqual(changed!.stored, changed!.probe)).toBe(false) - } - expect(String(sameDisplay.stored)).toBe(String(sameDisplay.probe)) - expect(modelEqual(sameDisplay.stored, sameDisplay.probe)).toBe(false) - const magnitudeKey = (value: Temporal.Duration) => - fields.map((field) => Math.abs(value[field])).join(`,`) - const firstNonzeroKey = (value: Temporal.Duration) => { - const field = fields.find((candidate) => value[candidate] !== 0)! - return `${field}:${value[field]}` - } - const wrongKeys = ( - testCase: Case, - key: (value: Temporal.Duration) => string, - ) => new Set(key(testCase.stored) === key(testCase.probe) ? [`row`] : []) - for (const [testCase, key] of [ - [sameDisplay, String], - [oppositeSigns, magnitudeKey], - [secondFieldChange, firstNonzeroKey], - ] as const) { - expect(() => - expect(wrongKeys(testCase, key)).toEqual(modelKeys(testCase)), - ).toThrow() - } -}) - -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 }) - expectExactKeys( - index.lookup(`eq`, probe), - modelKeys({ name, stored, probe }), - name, - ) - } - }, -) - -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({ - id: `duration-query-equality-${name}`, - getKey: (row: { id: string; value: Temporal.Duration }) => row.id, - initialData: [{ id: `row`, value: stored }], - autoIndex: `off`, - defaultIndexType: IndexType, - }), - ) - try { - await source.stateWhenReady() - const where = eq(new PropRef([`value`]), probe) - const expected = [...modelKeys({ name, stored, probe })] - const readKeys = () => - source.currentStateAsChanges({ where })!.map((change) => change.key) - expectExactKeys(readKeys(), expected, `${name}: scan`) - const index = source.createIndex((row) => row.value) - const lookup = vi.spyOn(index, `lookup`) - expectExactKeys(readKeys(), expected, `${name}: indexed`) - expect(lookup, `${name}: index path`).toHaveBeenCalled() - } finally { - await source.cleanup() - } - } - }, -) +const durationLaw = { + statement: `Query equality and exact index lookup on Temporal.Duration preserve each of its ten public fields, before and after indexing.`, + authority: `Changelog #1519: equivalent Duration forms stay unequal under eq; the ten-field model stated above`, + limits: `The fixed one-row field, same-display, sign and second-field probes and the two-row calendar history, through BasicIndex and BTreeIndex; no ordering, other Temporal kinds, mutations or live publication.`, +} -// The model distinguishes year counts by their public fields. Both rows are -// legal equality inputs even though comparing their duration requires a -// relative date. The scan, direct index, and indexed Collection read observe -// exact key sets; this test makes no claim about their relative order. -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 }) }, - ] - const probe = Temporal.Duration.from({ years: 2 }) - const expected = new Set( - rows.filter((row) => modelEqual(row.value, probe)).map((row) => row.id), - ) +function runDirectLookup(IndexType: typeof BasicIndex | typeof BTreeIndex) { + for (const { name, stored, probe } of cases) { const index = new IndexType(1, new PropRef([`value`])) - for (const row of rows) index.add(row.id, row) - expect(index.lookup(`eq`, probe)).toEqual(expected) + index.add(`row`, { value: stored }) + expectExactKeys( + index.lookup(`eq`, probe), + modelKeys({ name, stored, probe }), + name, + ) + } +} +// What this oracle owns: the exact-key lookups of BasicIndex and BTreeIndex, +// which the public eq read reaches, by this file's Duration cases. +oracleProperty({ + id: `duration-query-equality.direct-lookup`, + law: durationLaw, + owns: [ + `src/indexes/basic-index.ts#BasicIndex.lookup`, + `src/indexes/btree-index.ts#BTreeIndex.lookup`, + ], + histories: indexNames, + label: (name) => `${name} exact lookup follows Duration fields`, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `duration-query-equality.direct-lookup`, + plan, + observe, + () => runDirectLookup(indexType(plan.name)), + ), + check: forwardAssertionJudgment, +}) + +async function runPublicEq(IndexType: typeof BasicIndex | typeof BTreeIndex) { + for (const { name, stored, probe } of cases) { const source = createCollection( mockSyncCollectionOptions({ - id: `calendar-duration-equality-${IndexType.name}`, - getKey: (row: (typeof rows)[number]) => row.id, - initialData: rows, + id: `duration-query-equality-${name}`, + getKey: (row: { id: string; value: Temporal.Duration }) => row.id, + initialData: [{ id: `row`, value: stored }], autoIndex: `off`, defaultIndexType: IndexType, }), @@ -234,15 +110,102 @@ it.each(indexTypes)( try { await source.stateWhenReady() const where = eq(new PropRef([`value`]), probe) - const read = () => - new Set(source.currentStateAsChanges({ where })!.map((row) => row.key)) - expect(read()).toEqual(expected) - const publicIndex = source.createIndex((row) => row.value) - const lookup = vi.spyOn(publicIndex, `lookup`) - expect(read()).toEqual(expected) - expect(lookup).toHaveBeenCalled() + const expected = [...modelKeys({ name, stored, probe })] + const readKeys = () => + source.currentStateAsChanges({ where })!.map((change) => change.key) + expectExactKeys(readKeys(), expected, `${name}: scan`) + const index = source.createIndex((row) => row.value) + const lookup = vi.spyOn(index, `lookup`) + expectExactKeys(readKeys(), expected, `${name}: indexed`) + expect(lookup, `${name}: index path`).toHaveBeenCalled() } finally { await source.cleanup() } - }, -) + } +} + +oracleProperty({ + id: `duration-query-equality.public-eq`, + law: durationLaw, + owns: [ + `src/indexes/basic-index.ts#BasicIndex.lookup`, + `src/indexes/btree-index.ts#BTreeIndex.lookup`, + ], + histories: indexNames, + label: (name) => `public eq agrees before and after ${name} indexing`, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `duration-query-equality.public-eq`, + plan, + observe, + () => runPublicEq(indexType(plan.name)), + ), + check: forwardAssertionJudgment, +}) + +// The model distinguishes year counts by their public fields. Both rows are +// legal equality inputs even though comparing their duration requires a +// relative date. The scan, direct index, and indexed Collection read observe +// exact key sets; this test makes no claim about their relative order. +async function runCalendarIndexing( + IndexType: typeof BasicIndex | typeof BTreeIndex, +) { + const rows = [ + { id: `one`, value: Temporal.Duration.from({ years: 1 }) }, + { id: `two`, value: Temporal.Duration.from({ years: 2 }) }, + ] + const probe = Temporal.Duration.from({ years: 2 }) + const expected = new Set( + rows.filter((row) => modelEqual(row.value, probe)).map((row) => row.id), + ) + const index = new IndexType(1, new PropRef([`value`])) + for (const row of rows) index.add(row.id, row) + expect(index.lookup(`eq`, probe)).toEqual(expected) + + const source = createCollection( + mockSyncCollectionOptions({ + id: `calendar-duration-equality-${IndexType.name}`, + getKey: (row: (typeof rows)[number]) => row.id, + initialData: rows, + autoIndex: `off`, + defaultIndexType: IndexType, + }), + ) + try { + await source.stateWhenReady() + const where = eq(new PropRef([`value`]), probe) + const read = () => + new Set(source.currentStateAsChanges({ where })!.map((row) => row.key)) + expect(read()).toEqual(expected) + const publicIndex = source.createIndex((row) => row.value) + const lookup = vi.spyOn(publicIndex, `lookup`) + expect(read()).toEqual(expected) + expect(lookup).toHaveBeenCalled() + } finally { + await source.cleanup() + } +} + +oracleProperty({ + id: `duration-query-equality.calendar-indexing`, + law: durationLaw, + owns: [ + `src/indexes/basic-index.ts#BasicIndex.lookup`, + `src/indexes/btree-index.ts#BTreeIndex.lookup`, + ], + histories: indexNames, + label: (name) => + `${name} preserves equality indexing for multiple calendar Durations`, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `duration-query-equality.calendar-indexing`, + plan, + observe, + () => runCalendarIndexing(indexType(plan.name)), + ), + check: forwardAssertionJudgment, +}) From 7c9965f6522dfc42634784102966ff875d61170c Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:05:54 -0700 Subject: [PATCH 058/116] test(db): register query identity output-shape tests through oracleProperty Replay the per-form witnesses as fixed histories of query-identity.compiled-output and its captured result mutants as calibrations on their old histories. The compound-join boundaries become query-identity.compound-join. runShapes was used only by the removed tests, so it is deleted. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 6 + .../identity-output-shape-oracle.test.ts | 372 +++++++++--------- 2 files changed, 186 insertions(+), 192 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index d3c378489b..805ad35666 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -120,6 +120,7 @@ const otherProperties: ReadonlyArray = [ `subquery-user-value.flat-aggregate-join`, `subquery-user-value.ordered-find-one`, `subquery-user-value.materialized-find-one`, + `query-identity.compound-join`, ] const otherOwners: ReadonlyArray = [ [ @@ -277,6 +278,11 @@ const otherOwners: ReadonlyArray = [ `subquery-user-value`, `materialized-find-one`, ], + [ + `db/tests/query/identity-output-shape-oracle.test.ts`, + `query-identity`, + `compound-join`, + ], ] export const properties: ReadonlyArray = [ 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 2a5cf6272a..5784c65f62 100644 --- a/packages/db/tests/query/identity-output-shape-oracle.test.ts +++ b/packages/db/tests/query/identity-output-shape-oracle.test.ts @@ -2,7 +2,7 @@ import { isDeepStrictEqual } from 'node:util' import { D2, MultiSet, output } from '@tanstack/db-ivm' import { Temporal } from 'temporal-polyfill' import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { getQueryIR } from '../../src/query/builder/query-ir.js' import { createCollection } from '../../src/collection/index.js' import { compileQuery } from '../../src/query/compiler/index.js' @@ -22,14 +22,12 @@ import { UnionFrom, } from '../../src/query/ir.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' +import { oracleProperty, pass, violation } from '../oracle-config.js' import { - oracleProperty, - oracleReplayTest, - oracleRoute, - pass, - violation, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { mockSyncCollectionOptions } from '../utils.js' import { observeEqualityPartition } from './identity-output-shape-support.js' import type { EqualityPair } from './identity-output-shape-support.js' @@ -102,7 +100,7 @@ function expectBag( /** * Predicts the complete output bag of one form over the initial sources. The - * shape law: runShapes checks every compiled result against this prediction. + * shape law: checkShapes checks every compiled result against this prediction. */ function expectedShapeRows( form: Form, @@ -377,29 +375,6 @@ function checkShapes( return pass } -async function runShapes( - form: Form, - aliases: [string, string, string, string], - label: string, - mutant?: ResultMutant, -) { - const trace: Array = [] - await driveShapes( - form, - aliases, - label, - (checkpoint, value) => trace.push({ checkpoint, value }), - mutant, - ) - const verdict = checkShapes( - [form, aliases, label], - trace, - modelShapes([form, aliases, label]), - ) - if (verdict.kind === `violation`) - throw verdict.cause ?? new Error(verdict.difference) -} - const shapeHistories = fc.tuple( fc.constantFrom(...forms), fc.shuffledSubarray( @@ -431,6 +406,16 @@ oracleProperty({ fixedSeeds: [101600], states: [], constructed: [], + // Every form with the base aliases and label, which the calibrations + // below also corrupt. + fixed: forms.map((form) => ({ + name: `preserves full results and multiplicity for ${form}`, + history: [ + form, + ['user', 'post', 'account', 'article'], + 'author', + ] satisfies ShapeHistory, + })), }, model: modelShapes, driver: ([form, aliases, label], observe) => @@ -441,39 +426,31 @@ oracleProperty({ observe, ), check: checkShapes, -}) - -const test = oracleReplayTest(it) -describe('query identity agrees with compiled lexical output shape', () => { - test.each(forms)( - 'preserves full results and multiplicity for %s', - async (form) => { - oracleRoute(`case`) - await runShapes(form, ['user', 'post', 'account', 'article'], 'author') - }, - ) - test.each([ - 'alias', - 'missing-peer', - 'constant-identity', - 'shared-wrong-output', - 'fractional-weight', - ] 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( - runShapes( - form, - ['user', 'post', 'account', 'article'], - 'author', - mutant, - ), - ).rejects.toMatchObject({ name: 'AssertionError' }) - }, - ) + // A captured result mutant must fail the independent output comparison on + // the history its old witness used: explicit-join for a missing peer, + // implicit-join otherwise, with the base aliases and label. + calibrations: ( + [ + 'alias', + 'missing-peer', + 'constant-identity', + 'shared-wrong-output', + 'fractional-weight', + ] as const + ).map((mutant) => ({ + name: `rejects captured ${mutant} against independent results`, + law: `compiled output shape`, + driver: ([form, aliases, label]: ShapeHistory, observe) => + driveShapes( + form, + aliases as [string, string, string, string], + label, + observe, + form === (mutant === 'missing-peer' ? 'explicit-join' : 'implicit-join') + ? mutant + : undefined, + ), + })), }) const equalityPairArbitrary: fc.Arbitrary = fc.oneof( @@ -657,133 +634,144 @@ function expectedCompoundJoinRows( * rows; fresh public Collections must match it before identity is compared. * These are initial-publication witnesses, not arbitrary cache histories. */ -describe(`compound join identity and output`, () => { - for (const boundary of [`direct`, `from`, `joined`] as const) { - test(`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 }, - ] - const rightRows = [ - { id: 10, a: 1, b: 2, c: 3 }, - { id: 11, a: 1, b: 3, c: 7 }, - ] - const left = createCollection( - mockSyncCollectionOptions({ - id: `compound-identity-left`, - getKey: (row: (typeof leftRows)[number]) => row.id, - initialData: leftRows, - }), - ) - const right = createCollection( - mockSyncCollectionOptions({ - id: `compound-identity-right`, - getKey: (row: (typeof rightRows)[number]) => row.id, - initialData: rightRows, - }), - ) - const cleanups: Array<() => Promise> = [ - () => left.cleanup(), - () => right.cleanup(), - ] - await withHistoryCleanup( - async () => { - const own = Promise }>( - live: T, - ): T => { - cleanups.unshift(() => live.cleanup()) - return live - } - const makeQuery = ( - variant: `base` | `reordered` | `nested` | `different`, - ) => { - const joined = new Query() - .from({ left }) - .innerJoin({ right }, ({ left: l, right: r }) => { - const a = eq(l.a, r.a) - const b = eq(l.b, variant === `different` ? r.c : r.b) - if (variant === `reordered`) - return and(eq(r.b, l.b), eq(r.a, l.a)) - if (variant === `nested`) return and(b, and(a, b)) - return and(a, b) - }) - .select(({ left: l, right: r }) => ({ - leftId: l.id, - rightId: r.id, - })) - if (boundary === `direct`) - return { - ir: getQueryIR(joined), - live: own(createLiveQueryCollection({ query: joined })), - } - if (boundary === `from`) { - const query = new Query() - .from({ result: joined }) - .select(({ result }) => ({ - leftId: result.leftId, - rightId: result.rightId, - })) - return { - ir: getQueryIR(query), - live: own(createLiveQueryCollection({ query })), - } - } - const query = new Query() - .from({ anchor: left }) - .innerJoin({ result: joined }, ({ anchor, result }) => - eq(anchor.id, result.leftId), - ) - .select(({ result }) => ({ - leftId: result.leftId, - rightId: result.rightId, - })) - return { - ir: getQueryIR(query), - live: own(createLiveQueryCollection({ query })), - } - } - const queries = { - base: makeQuery(`base`), - reordered: makeQuery(`reordered`), - nested: makeQuery(`nested`), - different: makeQuery(`different`), +async function runCompoundJoinBoundary( + boundary: `direct` | `from` | `joined`, +): Promise { + const leftRows = [ + { id: 1, a: 1, b: 2, c: 9 }, + { id: 2, a: 1, b: 3, c: 8 }, + ] + const rightRows = [ + { id: 10, a: 1, b: 2, c: 3 }, + { id: 11, a: 1, b: 3, c: 7 }, + ] + const left = createCollection( + mockSyncCollectionOptions({ + id: `compound-identity-left`, + getKey: (row: (typeof leftRows)[number]) => row.id, + initialData: leftRows, + }), + ) + const right = createCollection( + mockSyncCollectionOptions({ + id: `compound-identity-right`, + getKey: (row: (typeof rightRows)[number]) => row.id, + initialData: rightRows, + }), + ) + const cleanups: Array<() => Promise> = [ + () => left.cleanup(), + () => right.cleanup(), + ] + await withHistoryCleanup( + async () => { + const own = Promise }>(live: T): T => { + cleanups.unshift(() => live.cleanup()) + return live + } + const makeQuery = ( + variant: `base` | `reordered` | `nested` | `different`, + ) => { + const joined = new Query() + .from({ left }) + .innerJoin({ right }, ({ left: l, right: r }) => { + const a = eq(l.a, r.a) + const b = eq(l.b, variant === `different` ? r.c : r.b) + if (variant === `reordered`) return and(eq(r.b, l.b), eq(r.a, l.a)) + if (variant === `nested`) return and(b, and(a, b)) + return and(a, b) + }) + .select(({ left: l, right: r }) => ({ + leftId: l.id, + rightId: r.id, + })) + if (boundary === `direct`) + return { + ir: getQueryIR(joined), + live: own(createLiveQueryCollection({ query: joined })), } - for (const [variant, { live }] of Object.entries(queries)) { - await live.preload() - const expected = expectedCompoundJoinRows( - leftRows, - rightRows, - variant, - ) - expectBag( - live.toArray.map(({ leftId, rightId }) => ({ leftId, rightId })), - expected, - ) + if (boundary === `from`) { + const query = new Query() + .from({ result: joined }) + .select(({ result }) => ({ + leftId: result.leftId, + rightId: result.rightId, + })) + return { + ir: getQueryIR(query), + live: own(createLiveQueryCollection({ query })), } - const { base, reordered, nested, different } = queries - expect(getQueryIdentity(base.ir)).toBe(getQueryIdentity(reordered.ir)) - expect(getQueryIdentity(base.ir)).toBe(getQueryIdentity(nested.ir)) - expect( - getQueryIdentity(base.ir), - `different later operand changes identity`, - ).not.toBe(getQueryIdentity(different.ir)) - expect( - queriesMatchForCaching(base.ir, different.ir), - `different later operand cannot reuse a subquery`, - ).toBe(false) - }, - () => cleanups, - ) - }) - } -}) -declareOracle({ - check: [checkShapes, checkEqualityPair], - model: [ - expectedShapeRows, - expectedCompoundJoinRows, - modelShapes, - modelEqualityPair, - ], + } + const query = new Query() + .from({ anchor: left }) + .innerJoin({ result: joined }, ({ anchor, result }) => + eq(anchor.id, result.leftId), + ) + .select(({ result }) => ({ + leftId: result.leftId, + rightId: result.rightId, + })) + return { + ir: getQueryIR(query), + live: own(createLiveQueryCollection({ query })), + } + } + const queries = { + base: makeQuery(`base`), + reordered: makeQuery(`reordered`), + nested: makeQuery(`nested`), + different: makeQuery(`different`), + } + for (const [variant, { live }] of Object.entries(queries)) { + await live.preload() + const expected = expectedCompoundJoinRows(leftRows, rightRows, variant) + expectBag( + live.toArray.map(({ leftId, rightId }) => ({ leftId, rightId })), + expected, + ) + } + const { base, reordered, nested, different } = queries + expect(getQueryIdentity(base.ir)).toBe(getQueryIdentity(reordered.ir)) + expect(getQueryIdentity(base.ir)).toBe(getQueryIdentity(nested.ir)) + expect( + getQueryIdentity(base.ir), + `different later operand changes identity`, + ).not.toBe(getQueryIdentity(different.ir)) + expect( + queriesMatchForCaching(base.ir, different.ir), + `different later operand cannot reuse a subquery`, + ).toBe(false) + }, + () => cleanups, + ) +} + +describe(`compound join identity and output`, () => { + oracleProperty({ + id: `query-identity.compound-join`, + law: { + statement: `A compound join retains every equality: AND is commutative, associative and idempotent, but changing an operand changes the output, and query identity follows the output.`, + authority: `The query identity and compiled output shape contract stated above, extended to compound joins by the feature request.`, + limits: `Direct, from, and joined QueryRef boundaries at initial publication; no arbitrary cache histories.`, + }, + 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', + ], + histories: [`direct`, `from`, `joined`] as const, + label: (boundary) => `retains every equality across ${boundary}`, + resolve: (boundary) => ({ boundary }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`compound join identity`, plan, observe, () => + runCompoundJoinBoundary(plan.boundary), + ), + check: forwardAssertionJudgment, + }) }) From 1751df4bb910815d6e94ba8d3c27952055d4b26e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:06:16 -0700 Subject: [PATCH 059/116] test(db): register route-context transport cells through oracleProperty Move the grammar-size checks and checker calibrations to the ordinary route-context transport tests, with the grammars, models, and checks they read in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 9 + ...includes-context-transport-model-oracle.ts | 385 ++++++ .../includes-context-transport-oracle.test.ts | 1111 ++++++----------- .../query/includes-context-transport.test.ts | 128 ++ 4 files changed, 883 insertions(+), 750 deletions(-) create mode 100644 packages/db/tests/query/includes-context-transport-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index dbe9a87e3d..52cd13905e 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -33,6 +33,10 @@ export const properties: ReadonlyArray = [ `includes-query-shape.multiplicity-fixed`, `includes-query-shape.correlation-fixed`, `includes-query-shape.nullable-fixed`, + `includes-context-transport.grammar`, + `includes-context-transport.compound-join`, + `includes-context-transport.materialized-input`, + `includes-context-transport.outer-join-keys`, ] export const owners: ReadonlyArray = [ @@ -81,4 +85,9 @@ export const owners: ReadonlyArray = [ `includes-query-shape`, `multiplicity-fixed correlation-fixed nullable-fixed`, ], + [ + `db/tests/query/includes-context-transport-oracle.test.ts`, + `includes-context-transport`, + `grammar compound-join materialized-input outer-join-keys`, + ], ] diff --git a/packages/db/tests/query/includes-context-transport-model-oracle.ts b/packages/db/tests/query/includes-context-transport-model-oracle.ts new file mode 100644 index 0000000000..4e2096007c --- /dev/null +++ b/packages/db/tests/query/includes-context-transport-model-oracle.ts @@ -0,0 +1,385 @@ +import { expect } from 'vitest' +import { + publicFieldCells, + publicFieldGrammar, + publicFieldOverlapCells, + publicFieldOverlapFunctionalCells, +} from '../conformance/public-field-oracle.js' +import type { + PublicFieldCell, + PublicFieldOverlapCell, +} from '../conformance/public-field-oracle.js' + +// The route-context grammar, the materialized-input grammar and model, and the +// outer-join key model and check of the route-context transport oracle, moved +// here unchanged from includes-context-transport-oracle.test.ts. The oracle +// drives production through them; the ordinary grammar-size checks and checker +// calibrations in includes-context-transport.test.ts import them without +// registering the oracle's tests again. This module registers no tests. + +export const checkpoints = [`initial`, `parent-update`, `child-update`] as const + +// These axes name semantic boundaries, not implementation functions. Adding a +// compiler feature means adding its boundary here or stating why it cannot +// carry correlated context. Each declared tuple gets one named test below; +// the path-specific witnesses in `includes-context-transport.test.ts` +// reconstruct the separate dotted-path cases. +// +// Removing a family loses a distinct question: field versus whole-parent +// projection; unmatched versus null correlation; immediate versus ancestor +// scope; grouping versus aggregate placement; recursive boundary versus +// evaluation phase; join-key side versus correlation attachment; union form; +// derived-result boundary versus selection and nullability; user-name location +// versus boundary and spelling; or public-surface shape. Within a family, the +// asserted parent and child changes distinguish route reuse from recomputation. +// The fixtures bound the range to small finite source Collections, three public +// forms, and the listed updates. New numeric values with the same relations +// change parameters, while arbitrary path segments or deeper recursive shapes +// require new grammar rules. Ancestor alias shadowing and compiled +// Collection-valued functional inputs are invalid query forms and excluded. +export const routeContextGrammar = { + parentProjection: { + shapes: [`field`, `whole-row`] as const, + }, + correlationDomain: { + states: [`unmatched`, `null`] as const, + }, + lexicalScope: { + scopes: [`immediate-parent`, `lexical-ancestor`] as const, + }, + aggregation: { + groupings: [`implicit`, `explicit`] as const, + placements: [`inside-aggregate`, `wrapped-aggregate`] as const, + }, + recursiveSource: { + boundaries: [`from-query-ref`, `joined-query-ref`, `union-branch`] as const, + phases: [ + `filter`, + `projection`, + `aggregate`, + `having`, + `order-window`, + ] as const, + }, + join: { + keySides: [`main`, `joined`] as const, + correlationAttachments: [`main`, `joined`] as const, + }, + unionIdentity: { + forms: [`union-from`, `union-all`] as const, + }, + derivedResult: { + boundaries: [`from-query-ref`, `joined-query-ref`, `union-all`] as const, + selections: [`expression`, `functional`] as const, + domains: [`non-null`, `nullable`] as const, + }, + namespaceCollision: { + locations: [`parent-alias`, `selected-field`] as const, + boundaries: [`direct`, `query-ref`, `join`, `group`] as const, + names: [ + `__parentContextIdentity`, + `__parentContext`, + `__correlationKey`, + `value`, + `identity`, + ] as const, + }, + publicField: publicFieldGrammar, + publicSurface: { + shapes: [ + `object-query-ref-scalar`, + `nested-functional-spread`, + `opaque-wrapper`, + `functional-having-input`, + `nested-reference`, + `adversarial-key`, + `user-symbol`, + `implicit-join`, + ] as const, + }, +} as const + +export type ParentProjectionCell = { + family: `parent-projection` + shape: (typeof routeContextGrammar.parentProjection.shapes)[number] +} + +export type CorrelationDomainCell = { + family: `correlation-domain` + state: (typeof routeContextGrammar.correlationDomain.states)[number] +} + +export type LexicalScopeCell = { + family: `lexical-scope` + scope: (typeof routeContextGrammar.lexicalScope.scopes)[number] +} + +export type AggregationCell = { + family: `aggregation` + grouping: (typeof routeContextGrammar.aggregation.groupings)[number] + placement: (typeof routeContextGrammar.aggregation.placements)[number] +} + +export type RecursiveSourceCell = { + family: `recursive-source` + boundary: (typeof routeContextGrammar.recursiveSource.boundaries)[number] + phase: (typeof routeContextGrammar.recursiveSource.phases)[number] +} + +export type JoinCell = { + family: `join` + keySide: (typeof routeContextGrammar.join.keySides)[number] + correlationAttachment: (typeof routeContextGrammar.join.correlationAttachments)[number] +} + +export type UnionIdentityCell = { + family: `union-identity` + form: (typeof routeContextGrammar.unionIdentity.forms)[number] +} + +export type DerivedResultCell = { + family: `derived-result` + boundary: (typeof routeContextGrammar.derivedResult.boundaries)[number] + selection: (typeof routeContextGrammar.derivedResult.selections)[number] + domain: (typeof routeContextGrammar.derivedResult.domains)[number] +} + +export type NamespaceCollisionCell = { + family: `namespace-collision` + location: (typeof routeContextGrammar.namespaceCollision.locations)[number] + boundary: (typeof routeContextGrammar.namespaceCollision.boundaries)[number] + name: (typeof routeContextGrammar.namespaceCollision.names)[number] +} + +export type PublicSurfaceCell = { + family: `public-surface` + shape: (typeof routeContextGrammar.publicSurface.shapes)[number] +} + +export type GrammarCell = + | ParentProjectionCell + | CorrelationDomainCell + | LexicalScopeCell + | AggregationCell + | RecursiveSourceCell + | JoinCell + | UnionIdentityCell + | DerivedResultCell + | NamespaceCollisionCell + | PublicSurfaceCell + | PublicFieldCell + | PublicFieldOverlapCell + +// Expand every declared product once. The calibration test below rejects both +// missing cells and duplicate cells. +export const grammarCells: Array = [ + ...routeContextGrammar.parentProjection.shapes.map( + (shape): ParentProjectionCell => ({ + family: `parent-projection`, + shape, + }), + ), + ...routeContextGrammar.correlationDomain.states.map( + (state): CorrelationDomainCell => ({ + family: `correlation-domain`, + state, + }), + ), + ...routeContextGrammar.lexicalScope.scopes.map((scope): LexicalScopeCell => ({ + family: `lexical-scope`, + scope, + })), + ...routeContextGrammar.aggregation.groupings.flatMap((grouping) => + routeContextGrammar.aggregation.placements.map( + (placement): AggregationCell => ({ + family: `aggregation`, + grouping, + placement, + }), + ), + ), + ...routeContextGrammar.recursiveSource.boundaries.flatMap((boundary) => + routeContextGrammar.recursiveSource.phases.map( + (phase): RecursiveSourceCell => ({ + family: `recursive-source`, + boundary, + phase, + }), + ), + ), + ...routeContextGrammar.join.keySides.flatMap((keySide) => + routeContextGrammar.join.correlationAttachments.map( + (correlationAttachment): JoinCell => ({ + family: `join`, + keySide, + correlationAttachment, + }), + ), + ), + ...routeContextGrammar.unionIdentity.forms.map((form): UnionIdentityCell => ({ + family: `union-identity`, + form, + })), + ...routeContextGrammar.derivedResult.boundaries.flatMap((boundary) => + routeContextGrammar.derivedResult.selections.flatMap((selection) => + routeContextGrammar.derivedResult.domains.map( + (domain): DerivedResultCell => ({ + family: `derived-result`, + boundary, + selection, + domain, + }), + ), + ), + ), + ...routeContextGrammar.namespaceCollision.locations.flatMap((location) => + routeContextGrammar.namespaceCollision.boundaries.flatMap((boundary) => + routeContextGrammar.namespaceCollision.names.map( + (name): NamespaceCollisionCell => ({ + family: `namespace-collision`, + location, + boundary, + name, + }), + ), + ), + ), + ...publicFieldCells, + ...publicFieldOverlapCells, + ...publicFieldOverlapFunctionalCells, + ...routeContextGrammar.publicSurface.shapes.map( + (shape): PublicSurfaceCell => ({ family: `public-surface`, shape }), + ), +] + +export function grammarCellName(cell: GrammarCell): string { + switch (cell.family) { + case `parent-projection`: + return `${cell.family} / ${cell.shape}` + case `correlation-domain`: + return `${cell.family} / ${cell.state}` + case `lexical-scope`: + return `${cell.family} / ${cell.scope}` + case `aggregation`: + return `${cell.family} / ${cell.grouping} / ${cell.placement}` + case `recursive-source`: + return `${cell.family} / ${cell.boundary} / ${cell.phase}` + case `join`: + return `${cell.family} / ${cell.keySide}-side key / ${cell.correlationAttachment}-side correlation` + case `union-identity`: + return `${cell.family} / ${cell.form}` + case `derived-result`: + return `${cell.family} / ${cell.boundary} / ${cell.selection} / ${cell.domain}` + case `namespace-collision`: + return `${cell.family} / ${cell.location} / ${cell.boundary} / ${cell.name}` + case `public-field`: + return `${cell.family} / ${String(cell.key)} / ${cell.location} / ${cell.selection} / ${cell.destination}` + case `public-field-overlap`: + return `${cell.family} / ${cell.destination} / ${cell.operation} / ${cell.descriptor}` + case `public-surface`: + return `${cell.family} / ${cell.shape}` + } +} + +export const materializedInputGrammar = { + boundaries: [`from-query-ref`, `joined-query-ref`] as const, + selections: [`expression`, `functional`] as const, + inputs: [`array`, `materialized`] as const, + checkpoints: [ + `initial`, + `parent-move`, + `child-move`, + `grandchild-update`, + `grandchild-empty`, + `grandchild-reactivation`, + ] as const, +} + +export type MaterializedInputCell = { + boundary: (typeof materializedInputGrammar.boundaries)[number] + selection: (typeof materializedInputGrammar.selections)[number] + input: (typeof materializedInputGrammar.inputs)[number] +} +export type MaterializedInputChild = { id: number; parentGroup: number } +export type MaterializedInputGrandchild = { + id: number + childId: number + label: string +} +export type MaterializedInputRow = MaterializedInputChild & { + grandchildren: Array<{ id: number; label: string }> +} + +export const materializedInputCells = + materializedInputGrammar.boundaries.flatMap((boundary) => + materializedInputGrammar.selections.flatMap((selection) => + materializedInputGrammar.inputs.map((input) => ({ + boundary, + selection, + input, + })), + ), + ) + +// The model enumerates the current source rows from independently retained +// facts. A child's membership depends only on its parent's group. Its nested +// array contains exactly the grandchildren naming that child, even when empty. +export function expectedMaterializedInputRows( + parent: { group: number }, + children: ReadonlyArray, + grandchildren: ReadonlyArray, +): Array { + return children + .filter((child) => child.parentGroup === parent.group) + .map((child) => ({ + ...child, + grandchildren: grandchildren + .filter((grandchild) => grandchild.childId === child.id) + .map(({ id, label }) => ({ id, label })) + .sort((left, right) => left.id - right.id), + })) + .sort((left, right) => left.id - right.id) +} + +export function checkMaterializedInputRows( + actual: unknown, + expected: ReadonlyArray, + checkpoint: string, +): void { + expect(actual, checkpoint).toEqual(expected) +} + +export type OuterKeyRow = { id: number; link: number; parentGroup: number } +export function expectedOuterJoinKeyRows( + mains: ReadonlyArray, + children: ReadonlyArray, + group: number, + join: (typeof outerJoinKeyGrammar)[number][`join`], +) { + return children + .filter((child) => child.parentGroup === group) + .flatMap((child) => { + const matched = mains.filter((main) => main.link === child.link) + return ( + matched.length + ? matched + : join === `right` || join === `full` + ? [undefined] + : [] + ).map((main) => ({ + id: child.id, + mainId: main?.id, + })) + }) + .sort((a, b) => a.id - b.id) +} +export function checkOuterJoinKeyRows( + observed: Iterable<{ id: unknown; mainId: unknown }>, + expected: ReturnType, +) { + expect( + [...observed] + .map(({ id, mainId }) => ({ id, mainId })) + .sort((a, b) => Number(a.id) - Number(b.id)), + ).toEqual(expected) +} 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 877a105c13..ee5ce01a3a 100644 --- a/packages/db/tests/query/includes-context-transport-oracle.test.ts +++ b/packages/db/tests/query/includes-context-transport-oracle.test.ts @@ -1,17 +1,9 @@ -import { describe, expect, test } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' +import { describe, expect } from 'vitest' import { expectRowSnapshot } from '../conformance/row-snapshot-oracle.js' import { expectedPublicField, - expectedPublicFieldOverlapSource, expectedSelectedPublicField, - expectedSelectedPublicFieldOverlap, fieldStages, - overlapStages, - publicFieldCells, - publicFieldGrammar, - publicFieldOverlapCells, - publicFieldOverlapFunctionalCells, publicFieldSymbol, runPublicFieldOverlapCell, } from '../conformance/public-field-oracle.js' @@ -33,8 +25,23 @@ import { sum, toArray, } from '../../src/query/index.js' -import { oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { requiredIncludesObservations } from './includes-state-observations.js' +import { + checkMaterializedInputRows, + checkOuterJoinKeyRows, + expectedMaterializedInputRows, + expectedOuterJoinKeyRows, + grammarCellName, + grammarCells, + materializedInputCells, + materializedInputGrammar, +} from './includes-context-transport-model-oracle.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { createGrammarCollection, @@ -46,6 +53,25 @@ import { readEveryForm, runWithCleanup, } from './includes-context-transport-helpers.js' +import type { + AggregationCell, + CorrelationDomainCell, + DerivedResultCell, + GrammarCell, + JoinCell, + LexicalScopeCell, + MaterializedInputCell, + MaterializedInputChild, + MaterializedInputGrandchild, + MaterializedInputRow, + NamespaceCollisionCell, + OuterKeyRow, + ParentProjectionCell, + PublicSurfaceCell, + RecursiveSourceCell, + UnionIdentityCell, + routeContextGrammar, +} from './includes-context-transport-model-oracle.js' import type { PublicFieldCell, PublicFieldOverlapCell, @@ -106,269 +132,8 @@ import type { * materialization forms beyond the displayed `toArray` queries. */ -const checkpoints = [`initial`, `parent-update`, `child-update`] as const - -// These axes name semantic boundaries, not implementation functions. Adding a -// compiler feature means adding its boundary here or stating why it cannot -// carry correlated context. Each declared tuple gets one named test below; -// the path-specific witnesses in `includes-context-transport.test.ts` -// reconstruct the separate dotted-path cases. -// -// Removing a family loses a distinct question: field versus whole-parent -// projection; unmatched versus null correlation; immediate versus ancestor -// scope; grouping versus aggregate placement; recursive boundary versus -// evaluation phase; join-key side versus correlation attachment; union form; -// derived-result boundary versus selection and nullability; user-name location -// versus boundary and spelling; or public-surface shape. Within a family, the -// asserted parent and child changes distinguish route reuse from recomputation. -// The fixtures bound the range to small finite source Collections, three public -// forms, and the listed updates. New numeric values with the same relations -// change parameters, while arbitrary path segments or deeper recursive shapes -// require new grammar rules. Ancestor alias shadowing and compiled -// Collection-valued functional inputs are invalid query forms and excluded. -const routeContextGrammar = { - parentProjection: { - shapes: [`field`, `whole-row`] as const, - }, - correlationDomain: { - states: [`unmatched`, `null`] as const, - }, - lexicalScope: { - scopes: [`immediate-parent`, `lexical-ancestor`] as const, - }, - aggregation: { - groupings: [`implicit`, `explicit`] as const, - placements: [`inside-aggregate`, `wrapped-aggregate`] as const, - }, - recursiveSource: { - boundaries: [`from-query-ref`, `joined-query-ref`, `union-branch`] as const, - phases: [ - `filter`, - `projection`, - `aggregate`, - `having`, - `order-window`, - ] as const, - }, - join: { - keySides: [`main`, `joined`] as const, - correlationAttachments: [`main`, `joined`] as const, - }, - unionIdentity: { - forms: [`union-from`, `union-all`] as const, - }, - derivedResult: { - boundaries: [`from-query-ref`, `joined-query-ref`, `union-all`] as const, - selections: [`expression`, `functional`] as const, - domains: [`non-null`, `nullable`] as const, - }, - namespaceCollision: { - locations: [`parent-alias`, `selected-field`] as const, - boundaries: [`direct`, `query-ref`, `join`, `group`] as const, - names: [ - `__parentContextIdentity`, - `__parentContext`, - `__correlationKey`, - `value`, - `identity`, - ] as const, - }, - publicField: publicFieldGrammar, - publicSurface: { - shapes: [ - `object-query-ref-scalar`, - `nested-functional-spread`, - `opaque-wrapper`, - `functional-having-input`, - `nested-reference`, - `adversarial-key`, - `user-symbol`, - `implicit-join`, - ] as const, - }, -} as const - -type ParentProjectionCell = { - family: `parent-projection` - shape: (typeof routeContextGrammar.parentProjection.shapes)[number] -} - -type CorrelationDomainCell = { - family: `correlation-domain` - state: (typeof routeContextGrammar.correlationDomain.states)[number] -} - -type LexicalScopeCell = { - family: `lexical-scope` - scope: (typeof routeContextGrammar.lexicalScope.scopes)[number] -} - -type AggregationCell = { - family: `aggregation` - grouping: (typeof routeContextGrammar.aggregation.groupings)[number] - placement: (typeof routeContextGrammar.aggregation.placements)[number] -} - -type RecursiveSourceCell = { - family: `recursive-source` - boundary: (typeof routeContextGrammar.recursiveSource.boundaries)[number] - phase: (typeof routeContextGrammar.recursiveSource.phases)[number] -} - -type JoinCell = { - family: `join` - keySide: (typeof routeContextGrammar.join.keySides)[number] - correlationAttachment: (typeof routeContextGrammar.join.correlationAttachments)[number] -} - -type UnionIdentityCell = { - family: `union-identity` - form: (typeof routeContextGrammar.unionIdentity.forms)[number] -} - -type DerivedResultCell = { - family: `derived-result` - boundary: (typeof routeContextGrammar.derivedResult.boundaries)[number] - selection: (typeof routeContextGrammar.derivedResult.selections)[number] - domain: (typeof routeContextGrammar.derivedResult.domains)[number] -} - -type NamespaceCollisionCell = { - family: `namespace-collision` - location: (typeof routeContextGrammar.namespaceCollision.locations)[number] - boundary: (typeof routeContextGrammar.namespaceCollision.boundaries)[number] - name: (typeof routeContextGrammar.namespaceCollision.names)[number] -} - -type PublicSurfaceCell = { - family: `public-surface` - shape: (typeof routeContextGrammar.publicSurface.shapes)[number] -} - -type GrammarCell = - | ParentProjectionCell - | CorrelationDomainCell - | LexicalScopeCell - | AggregationCell - | RecursiveSourceCell - | JoinCell - | UnionIdentityCell - | DerivedResultCell - | NamespaceCollisionCell - | PublicSurfaceCell - | PublicFieldCell - | PublicFieldOverlapCell - -// Expand every declared product once. The calibration test below rejects both -// missing cells and duplicate cells. -const grammarCells: Array = [ - ...routeContextGrammar.parentProjection.shapes.map( - (shape): ParentProjectionCell => ({ - family: `parent-projection`, - shape, - }), - ), - ...routeContextGrammar.correlationDomain.states.map( - (state): CorrelationDomainCell => ({ - family: `correlation-domain`, - state, - }), - ), - ...routeContextGrammar.lexicalScope.scopes.map((scope): LexicalScopeCell => ({ - family: `lexical-scope`, - scope, - })), - ...routeContextGrammar.aggregation.groupings.flatMap((grouping) => - routeContextGrammar.aggregation.placements.map( - (placement): AggregationCell => ({ - family: `aggregation`, - grouping, - placement, - }), - ), - ), - ...routeContextGrammar.recursiveSource.boundaries.flatMap((boundary) => - routeContextGrammar.recursiveSource.phases.map( - (phase): RecursiveSourceCell => ({ - family: `recursive-source`, - boundary, - phase, - }), - ), - ), - ...routeContextGrammar.join.keySides.flatMap((keySide) => - routeContextGrammar.join.correlationAttachments.map( - (correlationAttachment): JoinCell => ({ - family: `join`, - keySide, - correlationAttachment, - }), - ), - ), - ...routeContextGrammar.unionIdentity.forms.map((form): UnionIdentityCell => ({ - family: `union-identity`, - form, - })), - ...routeContextGrammar.derivedResult.boundaries.flatMap((boundary) => - routeContextGrammar.derivedResult.selections.flatMap((selection) => - routeContextGrammar.derivedResult.domains.map( - (domain): DerivedResultCell => ({ - family: `derived-result`, - boundary, - selection, - domain, - }), - ), - ), - ), - ...routeContextGrammar.namespaceCollision.locations.flatMap((location) => - routeContextGrammar.namespaceCollision.boundaries.flatMap((boundary) => - routeContextGrammar.namespaceCollision.names.map( - (name): NamespaceCollisionCell => ({ - family: `namespace-collision`, - location, - boundary, - name, - }), - ), - ), - ), - ...publicFieldCells, - ...publicFieldOverlapCells, - ...publicFieldOverlapFunctionalCells, - ...routeContextGrammar.publicSurface.shapes.map( - (shape): PublicSurfaceCell => ({ family: `public-surface`, shape }), - ), -] - -function grammarCellName(cell: GrammarCell): string { - switch (cell.family) { - case `parent-projection`: - return `${cell.family} / ${cell.shape}` - case `correlation-domain`: - return `${cell.family} / ${cell.state}` - case `lexical-scope`: - return `${cell.family} / ${cell.scope}` - case `aggregation`: - return `${cell.family} / ${cell.grouping} / ${cell.placement}` - case `recursive-source`: - return `${cell.family} / ${cell.boundary} / ${cell.phase}` - case `join`: - return `${cell.family} / ${cell.keySide}-side key / ${cell.correlationAttachment}-side correlation` - case `union-identity`: - return `${cell.family} / ${cell.form}` - case `derived-result`: - return `${cell.family} / ${cell.boundary} / ${cell.selection} / ${cell.domain}` - case `namespace-collision`: - return `${cell.family} / ${cell.location} / ${cell.boundary} / ${cell.name}` - case `public-field`: - return `${cell.family} / ${String(cell.key)} / ${cell.location} / ${cell.selection} / ${cell.destination}` - case `public-field-overlap`: - return `${cell.family} / ${cell.destination} / ${cell.operation} / ${cell.descriptor}` - case `public-surface`: - return `${cell.family} / ${cell.shape}` - } -} +// The route-context grammar (checkpoints, routeContextGrammar, grammarCells, +// and grammarCellName) lives in includes-context-transport-model-oracle.ts. // Law: a child row carries each parent snapshot in the form its projection // selected, read from the current parent row, so a parent update changes every @@ -2457,92 +2222,42 @@ 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, - expectedCorrelatedIds, - expectedThresholdChildIds, - expectedGrandchildIds, - expectedNestedRows, - expectedAggregateScores, - expectedCandidateRows, - expectedUnionRows, - expectedDerivedRows, - expectedJoinRows, - expectedCollisionRows, - expectedPublicField, - expectedSelectedPublicField, - expectedPublicFieldOverlapSource, - expectedSelectedPublicFieldOverlap, - ], - grammar: [grammarCells], -}) +const routeContextLaw = `Each parent reference is discovered across nested includes, QueryRef sources, union branches, and joined sources, enters route identity, is attached before the first operator that evaluates it, and is preserved through later boundaries.` +const materializedInputLaw = `An inherited inline input keeps each parent's route: a child's membership depends only on its parent's group, and its nested array holds exactly the grandchildren naming it.` + +// Grammar cells are enumerated by name, because a public-field cell can hold a +// symbol key, which is not history data. Resolution finds the cell again. +const grammarCellsByName = new Map( + grammarCells.map((cell) => [grammarCellName(cell), cell]), +) +const observationsOf = (cell: GrammarCell) => + cell.family === `public-field` || cell.family === `public-field-overlap` + ? `root and correlated forms × field admission stages` + : `every materialization form × parent/child updates` 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 + - routeContextGrammar.lexicalScope.scopes.length + - routeContextGrammar.aggregation.groupings.length * - routeContextGrammar.aggregation.placements.length + - routeContextGrammar.recursiveSource.boundaries.length * - routeContextGrammar.recursiveSource.phases.length + - routeContextGrammar.join.keySides.length * - routeContextGrammar.join.correlationAttachments.length + - routeContextGrammar.unionIdentity.forms.length + - routeContextGrammar.derivedResult.boundaries.length * - routeContextGrammar.derivedResult.selections.length * - routeContextGrammar.derivedResult.domains.length + - routeContextGrammar.namespaceCollision.locations.length * - routeContextGrammar.namespaceCollision.boundaries.length * - routeContextGrammar.namespaceCollision.names.length + - routeContextGrammar.publicSurface.shapes.length + - publicFieldCells.length + - publicFieldOverlapCells.length + - publicFieldOverlapFunctionalCells.length - const names = grammarCells.map(grammarCellName) - - expect(grammarCells).toHaveLength(expectedCellCount) - expect(new Set(names)).toHaveLength(expectedCellCount) - expect( - grammarCells.filter( - (cell) => - cell.family !== `public-field` && - cell.family !== `public-field-overlap`, - ).length * - materializationForms.length * - checkpoints.length, - ).toBe(819) - expect( - grammarCells.filter((cell) => cell.family === `public-field`).length * - fieldStages.length * - 4, - ).toBe(1200) - expect( - (publicFieldOverlapCells.length + - publicFieldOverlapFunctionalCells.length) * - overlapStages.length * - 4, - ).toBe(432) + oracleProperty({ + id: `includes-context-transport.grammar`, + law: { + statement: routeContextLaw, + authority: `Route-context transport in packages/db/src/query/live/ARCHITECTURE.md (discovery, route identity, attachment before evaluation, and preservation across recursive and join boundaries), stated at the start of this owner.`, + limits: `Every declared route-context grammar cell, enumerated by name, over small finite source Collections, three public forms, and the listed updates.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: grammarCells.map(grammarCellName), + label: (name) => + `${name} × ${observationsOf(grammarCellsByName.get(name)!)}`, + resolve: (name) => ({ cell: grammarCellsByName.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(routeContextLaw, plan, observe, () => + runGrammarCell(plan.cell), + ), + check: forwardAssertionJudgment, }) - - for (const cell of grammarCells) { - const observations = - 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}`, () => { - oracleRoute(`case`) - return runGrammarCell(cell) - }) - } }) // Law: a compound join pairs a child with a peer when the first equality holds @@ -2572,7 +2287,103 @@ function expectedCompoundRows( ) } -declareOracle({ model: [expectedCompoundRows] }) +const compoundJoinLaw = `A parent value used only in the second join equality is still route context, so each parent pairs children with peers by its own parameter.` + +// The former case body, unchanged, for one boundary and parent side. +async function judgeCompoundParentContext( + boundary: `direct` | `queryRef`, + parentSide: `main` | `joined`, +): Promise { + const parents = createGrammarCollection(`compound-parents`, [ + { id: 1, group: 1, parameter: 1 }, + { id: 2, group: 1, parameter: 2 }, + ]) + const children = createGrammarCollection(`compound-children`, [ + { id: 10, group: 1, a: 1, b: 1 }, + { id: 11, group: 1, a: 1, b: 2 }, + ]) + const peers = createGrammarCollection(`compound-peers`, [ + { id: 20, a: 1, b: 2 }, + { id: 21, a: 1, b: 3 }, + ]) + let cleanupLive: () => Promise = async () => {} + await runWithCleanup( + { cleanup: () => cleanupLive() }, + [parents, children, peers], + async () => { + const live = createLiveQueryCollection((q) => + q.from({ parent: parents.collection }).select(({ parent }) => { + const base = q + .from({ child: children.collection }) + .where(({ child }) => eq(child.group, parent.group)) + const joinCondition = ({ + child, + peer, + }: { + child: { + a: RefLeaf + b: RefLeaf + } + peer: { + a: RefLeaf + b: RefLeaf + } + }) => + and( + eq(child.a, peer.a), + parentSide === `main` + ? eq(add(child.b, parent.parameter), peer.b) + : eq(child.b, add(peer.b, parent.parameter)), + ) + const joined = + boundary === `direct` + ? base.innerJoin({ peer: peers.collection }, joinCondition) + : base.innerJoin( + { + peer: q + .from({ peer: peers.collection }) + .select(({ peer }) => ({ + id: peer.id, + a: peer.a, + b: peer.b, + })), + }, + joinCondition, + ) + const rows = joined.select(({ child, peer }) => ({ + id: child.id, + peerId: peer.id, + })) + return { id: parent.id, ...includeInEveryForm(rows) } + }), + ) + cleanupLive = () => live.cleanup() + const project = (rows: Iterable<{ id: number; peerId: number }>) => + [...rows] + .map(({ id, peerId }) => [id, peerId]) + .sort((a, b) => a[0]! - b[0]! || a[1]! - b[1]!) + const check = () => { + for (const parent of parents.collection.toArray) { + const expected = expectedCompoundRows( + children, + peers, + parentSide, + parent, + ) + expectEveryForm(live.get(parent.id)!, project, project(expected)) + } + } + await live.preload() + check() + parents.write(`update`, { id: 1, group: 1, parameter: -1 }) + check() + peers.write(`update`, { id: 20, a: 1, b: 1 }) + check() + children.write(`update`, { id: 10, group: 1, a: 1, b: 3 }) + check() + }, + ) +} /** * A parent value used only in the second join equality is still route context. @@ -2584,106 +2395,33 @@ declareOracle({ model: [expectedCompoundRows] }) * updates. This bounded matrix makes no asynchronous publication claim. */ 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 }, - ]) - const children = createGrammarCollection(`compound-children`, [ - { id: 10, group: 1, a: 1, b: 1 }, - { id: 11, group: 1, a: 1, b: 2 }, - ]) - const peers = createGrammarCollection(`compound-peers`, [ - { id: 20, a: 1, b: 2 }, - { id: 21, a: 1, b: 3 }, - ]) - let cleanupLive: () => Promise = async () => {} - await runWithCleanup( - { cleanup: () => cleanupLive() }, - [parents, children, peers], - async () => { - const live = createLiveQueryCollection((q) => - q.from({ parent: parents.collection }).select(({ parent }) => { - const base = q - .from({ child: children.collection }) - .where(({ child }) => eq(child.group, parent.group)) - const joinCondition = ({ - child, - peer, - }: { - child: { - a: RefLeaf - b: RefLeaf - } - peer: { - a: RefLeaf - b: RefLeaf - } - }) => - and( - eq(child.a, peer.a), - parentSide === `main` - ? eq(add(child.b, parent.parameter), peer.b) - : eq(child.b, add(peer.b, parent.parameter)), - ) - const joined = - boundary === `direct` - ? base.innerJoin({ peer: peers.collection }, joinCondition) - : base.innerJoin( - { - peer: q - .from({ peer: peers.collection }) - .select(({ peer }) => ({ - id: peer.id, - a: peer.a, - b: peer.b, - })), - }, - joinCondition, - ) - const rows = joined.select(({ child, peer }) => ({ - id: child.id, - peerId: peer.id, - })) - return { id: parent.id, ...includeInEveryForm(rows) } - }), - ) - cleanupLive = () => live.cleanup() - const project = (rows: Iterable<{ id: number; peerId: number }>) => - [...rows] - .map(({ id, peerId }) => [id, peerId]) - .sort((a, b) => a[0]! - b[0]! || a[1]! - b[1]!) - const check = () => { - for (const parent of parents.collection.toArray) { - const expected = expectedCompoundRows( - children, - peers, - parentSide, - parent, - ) - expectEveryForm( - live.get(parent.id)!, - project, - project(expected), - ) - } - } - await live.preload() - check() - parents.write(`update`, { id: 1, group: 1, parameter: -1 }) - check() - peers.write(`update`, { id: 20, a: 1, b: 1 }) - check() - children.write(`update`, { id: 10, group: 1, a: 1, b: 3 }) - check() - }, - ) - }) - } - } + oracleProperty({ + id: `includes-context-transport.compound-join`, + law: { + statement: compoundJoinLaw, + authority: `Route-context transport in packages/db/src/query/live/ARCHITECTURE.md (discovery, route identity, attachment before evaluation, and preservation across recursive and join boundaries), stated at the start of this owner.`, + limits: `The four enumerated direct/QueryRef by main/joined parent-side cells; no asynchronous publication claim.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: ([`direct`, `queryRef`] as const).flatMap((boundary) => + ([`main`, `joined`] as const).map((parentSide) => ({ + boundary, + parentSide, + })), + ), + label: ({ boundary, parentSide }) => + `preserves the later parent operand through ${boundary}, ${parentSide}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(compoundJoinLaw, plan, observe, () => + judgeCompoundParentContext(plan.boundary, plan.parentSide), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -2702,74 +2440,8 @@ describe(`compound join parent context`, () => { * grammar. The checks cover synchronous eager-source writes, not async demand, * callback publication counts, ordering guarantees or metadata attribution. */ -const materializedInputGrammar = { - boundaries: [`from-query-ref`, `joined-query-ref`] as const, - selections: [`expression`, `functional`] as const, - inputs: [`array`, `materialized`] as const, - checkpoints: [ - `initial`, - `parent-move`, - `child-move`, - `grandchild-update`, - `grandchild-empty`, - `grandchild-reactivation`, - ] as const, -} - -type MaterializedInputCell = { - boundary: (typeof materializedInputGrammar.boundaries)[number] - selection: (typeof materializedInputGrammar.selections)[number] - input: (typeof materializedInputGrammar.inputs)[number] -} -type MaterializedInputChild = { id: number; parentGroup: number } -type MaterializedInputGrandchild = { - id: number - childId: number - label: string -} -type MaterializedInputRow = MaterializedInputChild & { - grandchildren: Array<{ id: number; label: string }> -} - -const materializedInputCells = materializedInputGrammar.boundaries.flatMap( - (boundary) => - materializedInputGrammar.selections.flatMap((selection) => - materializedInputGrammar.inputs.map((input) => ({ - boundary, - selection, - input, - })), - ), -) - -// The model enumerates the current source rows from independently retained -// facts. A child's membership depends only on its parent's group. Its nested -// array contains exactly the grandchildren naming that child, even when empty. -function expectedMaterializedInputRows( - parent: { group: number }, - children: ReadonlyArray, - grandchildren: ReadonlyArray, -): Array { - return children - .filter((child) => child.parentGroup === parent.group) - .map((child) => ({ - ...child, - grandchildren: grandchildren - .filter((grandchild) => grandchild.childId === child.id) - .map(({ id, label }) => ({ id, label })) - .sort((left, right) => left.id - right.id), - })) - .sort((left, right) => left.id - right.id) -} - -function checkMaterializedInputRows( - actual: unknown, - expected: ReadonlyArray, - checkpoint: string, -): void { - expect(actual, checkpoint).toEqual(expected) -} - +// materializedInputGrammar, its cells, model, and check live in +// includes-context-transport-model-oracle.ts. async function runMaterializedInputCell({ boundary, selection, @@ -2938,61 +2610,29 @@ async function runMaterializedInputCell({ }) } -declareOracle({ - check: [runMaterializedInputCell, checkMaterializedInputRows], - model: [expectedMaterializedInputRows], - grammar: [materializedInputCells], -}) - 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 * - materializedInputGrammar.inputs.length - expect(materializedInputCells).toHaveLength(expected) - expect( - new Set( - materializedInputCells.map( - ({ boundary, selection, input }) => - `${boundary}/${selection}/${input}`, - ), + oracleProperty({ + id: `includes-context-transport.materialized-input`, + law: { + statement: materializedInputLaw, + authority: `Route-context transport in packages/db/src/query/live/ARCHITECTURE.md (discovery, route identity, attachment before evaluation, and preservation across recursive and join boundaries), stated at the start of this owner.`, + limits: `The enumerated boundary, selection, and input cells over synchronous eager-source writes; no async demand, callback publication count, ordering, or metadata attribution claim.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: materializedInputCells, + label: ({ boundary, selection, input }) => + `${boundary} / ${selection} / ${input}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(materializedInputLaw, plan, observe, () => + runMaterializedInputCell(plan.cell), ), - ).toHaveLength(expected) + check: forwardAssertionJudgment, }) - - test(`rejects lost children and cross-parent reuse at the public checkpoint`, () => { - oracleRoute(`calibration`) - const children = [ - { id: 10, parentGroup: 1 }, - { id: 20, parentGroup: 2 }, - ] - const grandchildren = [{ id: 100, childId: 10, label: `ten` }] - const expected = expectedMaterializedInputRows( - { group: 1 }, - children, - grandchildren, - ) - const wrongRoute = expectedMaterializedInputRows( - { group: 2 }, - children, - grandchildren, - ) - checkMaterializedInputRows(expected, expected, `allowed`) - for (const wrong of [[], wrongRoute]) { - expect(() => - checkMaterializedInputRows(wrong, expected, `calibration`), - ).toThrow() - } - }) - - for (const cell of materializedInputCells) { - test(`${cell.boundary} / ${cell.selection} / ${cell.input}`, () => { - oracleRoute(`case`) - return runMaterializedInputCell(cell) - }) - } }) /** @@ -3021,188 +2661,159 @@ const outerJoinKeyGrammar = ( ), ), ) -type OuterKeyRow = { id: number; link: number; parentGroup: number } -function expectedOuterJoinKeyRows( - mains: ReadonlyArray, - children: ReadonlyArray, - group: number, - join: (typeof outerJoinKeyGrammar)[number][`join`], -) { - return children - .filter((child) => child.parentGroup === group) - .flatMap((child) => { - const matched = mains.filter((main) => main.link === child.link) - return ( - matched.length - ? matched - : join === `right` || join === `full` - ? [undefined] - : [] - ).map((main) => ({ - id: child.id, - mainId: main?.id, - })) - }) - .sort((a, b) => a.id - b.id) -} -function checkOuterJoinKeyRows( - observed: Iterable<{ id: unknown; mainId: unknown }>, - expected: ReturnType, -) { - expect( - [...observed] - .map(({ id, mainId }) => ({ id, mainId })) - .sort((a, b) => Number(a.id) - Number(b.id)), - ).toEqual(expected) -} -declareOracle({ - grammar: [outerJoinKeyGrammar], - model: [expectedOuterJoinKeyRows], - check: [checkOuterJoinKeyRows], -}) -test(`rejects missing and duplicated joined contributions`, () => { - oracleRoute(`calibration`) - const mains = [{ id: 10, link: 10, parentGroup: 1 }] - const children = [ +// The former case body, unchanged, for one join, reference, and key cell. +async function judgeOuterJoinKeyCell({ + join, + mainRef, + joinedRef, + keys, +}: (typeof outerJoinKeyGrammar)[number]): Promise { + const cuts = [`initial`, `main-departed`, `main-returned`, `child-departed`] + const reach = requiredIncludesObservations( + `outer join independent source keys`, + cuts.flatMap((cut) => materializationForms.map((form) => `${cut}/${form}`)), + ) + reach.beginHistory() + const parents = createGrammarCollection(`outer-key-parents`, [ + { id: 1, group: 1 }, + { id: 2, group: 2 }, + { id: 3, group: 1 }, + ]) + let modelMains: Array = [{ id: 10, link: 10, parentGroup: 1 }] + let modelChildren: Array = [ { id: 20, link: 10, parentGroup: 1 }, { id: 21, link: 10, parentGroup: 1 }, { id: 10, link: 30, parentGroup: 1 }, + { id: 30, link: 50, parentGroup: 2 }, ] - for (const join of [`inner`, `left`, `right`, `full`] as const) { - const expected = expectedOuterJoinKeyRows(mains, children, 1, join) - checkOuterJoinKeyRows(expected, expected) - for (const wrong of [expected.slice(1), [...expected, expected[0]!]]) - expect(() => checkOuterJoinKeyRows(wrong, expected)).toThrow() - } -}) -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 = requiredIncludesObservations( - `outer join independent source keys`, - cuts.flatMap((cut) => - materializationForms.map((form) => `${cut}/${form}`), - ), - ) - reach.beginHistory() - const parents = createGrammarCollection(`outer-key-parents`, [ - { id: 1, group: 1 }, - { id: 2, group: 2 }, - { id: 3, group: 1 }, - ]) - let modelMains: Array = [{ id: 10, link: 10, parentGroup: 1 }] - let modelChildren: Array = [ - { id: 20, link: 10, parentGroup: 1 }, - { id: 21, link: 10, parentGroup: 1 }, - { id: 10, link: 30, parentGroup: 1 }, - { id: 30, link: 50, parentGroup: 2 }, - ] - const mains = createControlledCollection(`outer-key-mains`, modelMains, { + const mains = createControlledCollection(`outer-key-mains`, modelMains, { + autoIndex: `eager`, + getKey: (row) => (keys === `strings` ? String(row.id) : row.id), + }) + const children = createControlledCollection( + `outer-key-children`, + modelChildren, + { autoIndex: `eager`, - getKey: (row) => (keys === `strings` ? String(row.id) : row.id), - }) - const children = createControlledCollection( - `outer-key-children`, - modelChildren, - { - autoIndex: `eager`, - getKey: (row) => (keys === `numbers` ? row.id : String(row.id)), - }, - ) - const live = createLiveQueryCollection({ - query: (q) => - q.from({ parent: parents.collection }).select(({ parent }) => { - const mainQuery = q - .from({ main: mains.collection }) - .select(({ main }) => ({ - id: main.id, - link: main.link, - parentGroup: main.parentGroup, - })) - const joinedQuery = q - .from({ child: children.collection }) - .select(({ child }) => ({ - id: child.id, - link: child.link, - parentGroup: child.parentGroup, - })) - // Runtime source kinds vary independently. This driver uses only their - // shared ordinary fields; the native type owner checks inference. - const base = q.from({ - main: (mainRef - ? mainQuery - : mains.collection) as unknown as typeof mains.collection, - }) - const source = { - child: (joinedRef - ? joinedQuery - : children.collection) as unknown as typeof children.collection, - } - const joined = - join === `inner` - ? base.innerJoin(source, ({ main, child }) => + getKey: (row) => (keys === `numbers` ? row.id : String(row.id)), + }, + ) + const live = createLiveQueryCollection({ + query: (q) => + q.from({ parent: parents.collection }).select(({ parent }) => { + const mainQuery = q + .from({ main: mains.collection }) + .select(({ main }) => ({ + id: main.id, + link: main.link, + parentGroup: main.parentGroup, + })) + const joinedQuery = q + .from({ child: children.collection }) + .select(({ child }) => ({ + id: child.id, + link: child.link, + parentGroup: child.parentGroup, + })) + // Runtime source kinds vary independently. This driver uses only their + // shared ordinary fields; the native type owner checks inference. + const base = q.from({ + main: (mainRef + ? mainQuery + : mains.collection) as unknown as typeof mains.collection, + }) + const source = { + child: (joinedRef + ? joinedQuery + : children.collection) as unknown as typeof children.collection, + } + const joined = + join === `inner` + ? base.innerJoin(source, ({ main, child }) => + eq(main.link, child.link), + ) + : join === `left` + ? base.leftJoin(source, ({ main, child }) => eq(main.link, child.link), ) - : join === `left` - ? base.leftJoin(source, ({ main, child }) => + : join === `right` + ? base.rightJoin(source, ({ main, child }) => eq(main.link, child.link), ) - : join === `right` - ? base.rightJoin(source, ({ main, child }) => - eq(main.link, child.link), - ) - : base.fullJoin(source, ({ main, child }) => - eq(main.link, child.link), - ) - // Full-join optionality is a conservative shared carrier for the - // runtime grammar; the model still checks actual absence and values. - const typedJoin = joined as ReturnType< - typeof base.fullJoin - > - const selected = typedJoin - .where(({ child }) => eq(child.parentGroup, parent.group)) - .select(({ main, child }) => ({ id: child.id, mainId: main.id })) - return { id: parent.id, ...includeInEveryForm(selected) } - }), - }) - const compare = (cut: string) => { - for (const parent of parents.collection.toArray) { - const expected = expectedOuterJoinKeyRows( - modelMains, - modelChildren, - parent.group, - join, - ) - const forms = live.get(parent.id)! - const values = readEveryForm(forms, (rows) => [...rows]) - for (const form of materializationForms) { - checkOuterJoinKeyRows(values[form], expected) - reach.reach(`${cut}/${form}`) - } + : base.fullJoin(source, ({ main, child }) => + eq(main.link, child.link), + ) + // Full-join optionality is a conservative shared carrier for the + // runtime grammar; the model still checks actual absence and values. + const typedJoin = joined as ReturnType< + typeof base.fullJoin + > + const selected = typedJoin + .where(({ child }) => eq(child.parentGroup, parent.group)) + .select(({ main, child }) => ({ id: child.id, mainId: main.id })) + return { id: parent.id, ...includeInEveryForm(selected) } + }), + }) + const compare = (cut: string) => { + for (const parent of parents.collection.toArray) { + const expected = expectedOuterJoinKeyRows( + modelMains, + modelChildren, + parent.group, + join, + ) + const forms = live.get(parent.id)! + const values = readEveryForm(forms, (rows) => [...rows]) + for (const form of materializationForms) { + checkOuterJoinKeyRows(values[form], expected) + reach.reach(`${cut}/${form}`) } } - await runWithCleanup(live, [parents, mains, children], async () => { - await live.preload() - compare(`initial`) - const original = modelMains[0]! - modelMains = [] - mains.write(`delete`, original) - compare(`main-departed`) - modelMains = [original] - mains.write(`insert`, original) - compare(`main-returned`) - const removed = modelChildren.find(({ id }) => id === 10)! - modelChildren = modelChildren.filter(({ id }) => id !== 10) - children.write(`delete`, removed) - compare(`child-departed`) - reach.assertReached() - if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) - console.log( - `outer-key reach ${JSON.stringify({ join, mainRef, joinedRef, keys })}`, - Object.fromEntries(reach.counts()), - ) - }) + } + await runWithCleanup(live, [parents, mains, children], async () => { + await live.preload() + compare(`initial`) + const original = modelMains[0]! + modelMains = [] + mains.write(`delete`, original) + compare(`main-departed`) + modelMains = [original] + mains.write(`insert`, original) + compare(`main-returned`) + const removed = modelChildren.find(({ id }) => id === 10)! + modelChildren = modelChildren.filter(({ id }) => id !== 10) + children.write(`delete`, removed) + compare(`child-departed`) + reach.assertReached() + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.log( + `outer-key reach ${JSON.stringify({ join, mainRef, joinedRef, keys })}`, + Object.fromEntries(reach.counts()), + ) + }) +} + +const outerJoinKeyLaw = `An unmatched RIGHT or FULL joined QueryRef does not donate its source key to the joined result: each distinct projected contribution keeps one public child across independent source keys.` + +oracleProperty({ + id: `includes-context-transport.outer-join-keys`, + law: { + statement: outerJoinKeyLaw, + authority: `Route-context transport in packages/db/src/query/live/ARCHITECTURE.md (discovery, route identity, attachment before evaluation, and preservation across recursive and join boundaries), stated at the start of this owner.`, + limits: `The enumerated join, main/joined reference, and key-domain cells; rows and stable distinct membership only, without key serialization or callback timing.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: outerJoinKeyGrammar, + label: (cell) => + `preserves outer-join child identities across independent source keys: ${JSON.stringify(cell)}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(outerJoinKeyLaw, plan, observe, () => + judgeOuterJoinKeyCell(plan), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/query/includes-context-transport.test.ts b/packages/db/tests/query/includes-context-transport.test.ts index d08f70a949..8217a1a88f 100644 --- a/packages/db/tests/query/includes-context-transport.test.ts +++ b/packages/db/tests/query/includes-context-transport.test.ts @@ -18,13 +18,33 @@ import { attachRouteMetadata, transformPublicContainers, } from '../../src/query/compiler/route-metadata.js' +import { + fieldStages, + overlapStages, + publicFieldCells, + publicFieldOverlapCells, + publicFieldOverlapFunctionalCells, +} from '../conformance/public-field-oracle.js' import { createGrammarCollection, expectEveryForm, expectNoPrivateSymbolsDeep, includeInEveryForm, + materializationForms, runWithCleanup, } from './includes-context-transport-helpers.js' +import { + checkMaterializedInputRows, + checkOuterJoinKeyRows, + checkpoints, + expectedMaterializedInputRows, + expectedOuterJoinKeyRows, + grammarCellName, + grammarCells, + materializedInputCells, + materializedInputGrammar, + routeContextGrammar, +} from './includes-context-transport-model-oracle.js' import type { Cleanable, MaterializedForms, @@ -517,3 +537,111 @@ describe(`correlated include route-context transport grammar`, () => { runQueryRefMetadataCell(routeMode)) } }) + +// Grammar-size checks and checker calibrations for the route-context +// transport oracle. They run no production: the size checks count the grammar +// tables, and each calibration feeds a wrong row set to the oracle's check. +describe(`route-context transport grammar and checks`, () => { + test(`expands every declared product without duplicate cells`, () => { + const expectedCellCount = + routeContextGrammar.parentProjection.shapes.length + + routeContextGrammar.correlationDomain.states.length + + routeContextGrammar.lexicalScope.scopes.length + + routeContextGrammar.aggregation.groupings.length * + routeContextGrammar.aggregation.placements.length + + routeContextGrammar.recursiveSource.boundaries.length * + routeContextGrammar.recursiveSource.phases.length + + routeContextGrammar.join.keySides.length * + routeContextGrammar.join.correlationAttachments.length + + routeContextGrammar.unionIdentity.forms.length + + routeContextGrammar.derivedResult.boundaries.length * + routeContextGrammar.derivedResult.selections.length * + routeContextGrammar.derivedResult.domains.length + + routeContextGrammar.namespaceCollision.locations.length * + routeContextGrammar.namespaceCollision.boundaries.length * + routeContextGrammar.namespaceCollision.names.length + + routeContextGrammar.publicSurface.shapes.length + + publicFieldCells.length + + publicFieldOverlapCells.length + + publicFieldOverlapFunctionalCells.length + const names = grammarCells.map(grammarCellName) + + expect(grammarCells).toHaveLength(expectedCellCount) + expect(new Set(names)).toHaveLength(expectedCellCount) + expect( + grammarCells.filter( + (cell) => + cell.family !== `public-field` && + cell.family !== `public-field-overlap`, + ).length * + materializationForms.length * + checkpoints.length, + ).toBe(819) + expect( + grammarCells.filter((cell) => cell.family === `public-field`).length * + fieldStages.length * + 4, + ).toBe(1200) + expect( + (publicFieldOverlapCells.length + + publicFieldOverlapFunctionalCells.length) * + overlapStages.length * + 4, + ).toBe(432) + }) + + test(`expands every declared input boundary without duplicates`, () => { + const expected = + materializedInputGrammar.boundaries.length * + materializedInputGrammar.selections.length * + materializedInputGrammar.inputs.length + expect(materializedInputCells).toHaveLength(expected) + expect( + new Set( + materializedInputCells.map( + ({ boundary, selection, input }) => + `${boundary}/${selection}/${input}`, + ), + ), + ).toHaveLength(expected) + }) + + test(`rejects lost children and cross-parent reuse at the public checkpoint`, () => { + const children = [ + { id: 10, parentGroup: 1 }, + { id: 20, parentGroup: 2 }, + ] + const grandchildren = [{ id: 100, childId: 10, label: `ten` }] + const expected = expectedMaterializedInputRows( + { group: 1 }, + children, + grandchildren, + ) + const wrongRoute = expectedMaterializedInputRows( + { group: 2 }, + children, + grandchildren, + ) + checkMaterializedInputRows(expected, expected, `allowed`) + for (const wrong of [[], wrongRoute]) { + expect(() => + checkMaterializedInputRows(wrong, expected, `calibration`), + ).toThrow() + } + }) + + test(`rejects missing and duplicated joined contributions`, () => { + const mains = [{ id: 10, link: 10, parentGroup: 1 }] + const children = [ + { id: 20, link: 10, parentGroup: 1 }, + { id: 21, link: 10, parentGroup: 1 }, + { id: 10, link: 30, parentGroup: 1 }, + ] + for (const join of [`inner`, `left`, `right`, `full`] as const) { + const expected = expectedOuterJoinKeyRows(mains, children, 1, join) + checkOuterJoinKeyRows(expected, expected) + for (const wrong of [expected.slice(1), [...expected, expected[0]!]]) + expect(() => checkOuterJoinKeyRows(wrong, expected)).toThrow() + } + }) +}) From f15a5504288b3058a09e73c3ccb79f1ff4802a02 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:06:23 -0700 Subject: [PATCH 060/116] test(scripts): register the release-comment cases through oracleProperty Converts the one plain-test site in release-comments-oracle.property.test.js (P2) into scripts.release-comments, whose histories are the former cases, labeled with the former test names. exercise runs unchanged through recordAssertionJudgment, and the case-selection variable and the afterAll reach census are unchanged. The file owns nothing, as before. Co-authored-by: Isaac --- .../converted-properties/frameworks-ivm.ts | 6 +++ .../release-comments-oracle.property.test.js | 40 ++++++++++++++----- 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/converted-properties/frameworks-ivm.ts index c8a71e208d..79cb33f4c6 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/converted-properties/frameworks-ivm.ts @@ -29,6 +29,7 @@ export const properties: ReadonlyArray = [ `react.source-id-reuse-fixed`, `react.source-id-reuse-infinite-fixed`, // vue-db and scripts + `scripts.release-comments`, `vue.use-live-query-publication`, ] @@ -126,6 +127,11 @@ export const owners: ReadonlyArray = [ `source-id-reuse-infinite-fixed`, ], // vue-db and scripts + [ + `../scripts/release-comments-oracle.property.test.js`, + `scripts`, + `release-comments`, + ], [ `vue-db/tests/useLiveQuery-publication-oracle.test.ts`, `vue`, diff --git a/scripts/release-comments-oracle.property.test.js b/scripts/release-comments-oracle.property.test.js index bef5d0968e..7b36470362 100644 --- a/scripts/release-comments-oracle.property.test.js +++ b/scripts/release-comments-oracle.property.test.js @@ -25,8 +25,13 @@ import { 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 { afterAll } from 'vitest' +import { oracleProperty } from '../packages/db/tests/oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../packages/db/tests/oracle-assertion-adapter.js' // The grammar authors structured releases before serializing Markdown. Removing // version suffixes loses the reported RC witness; removing neighboring sections @@ -784,13 +789,30 @@ if ( throw new Error( 'TANSTACK_DB_RELEASE_COMMENT_CASE must name an existing history index', ) -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}`, () => { - oracleRoute(`case`) - return exercise(input) - }) -} +const RELEASE_COMMENTS_LAW = + 'Each contributing PR and linked issue of the exact published version receives one aggregated notification; lookup and comment-check failures warn without suppressing unrelated notifications.' +oracleProperty({ + id: `scripts.release-comments`, + law: { + statement: RELEASE_COMMENTS_LAW, + authority: + "The shared release-comment action's README and the maintainer's 2026-11-06 decisions documented above.", + limits: + 'Selection and aggregation at the gh command boundary only; no GitHub authentication, delivery, linked-issue pagination beyond ten, retries, or concurrent releases.', + }, + histories: histories.flatMap((input, index) => + replay !== undefined && Number(replay) !== index ? [] : [{ index, input }], + ), + label: ({ index, input }) => + `release-comments case ${index}: ${input.version}, reverse ${input.reverse}, issues ${Object.keys(input.edges).length}, packages ${input.packages.length}`, + resolve: (releaseCase) => ({ releaseCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(RELEASE_COMMENTS_LAW, plan, observe, () => + exercise(plan.releaseCase.input), + ), + check: forwardAssertionJudgment, +}) afterAll(() => { process.stdout.write( `release-comments reach ${JSON.stringify(Object.fromEntries(reach))}\n`, From b67ff53f41139558e7b9dc71187cec7b5dfe67df Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:06:38 -0700 Subject: [PATCH 061/116] test(db): register ordered-work oracle tests through oracleProperty Replay the exhaustive-domain loop as fixed histories of ordered-work.consumer-parity and the eager fixed windows through ordered-work.work-eager-fixed. Convert each inline production path to an ordered-work.work-* enumerated property through the file's existing comparison adapter, and turn the two optimistic-window mutants into calibrations. Tests that compare production with written literals, and model and checker calibrations that run no production, move to ordered-work-examples.test.ts beside extracted, test-free model and fixture modules. Co-authored-by: Isaac --- .../tests/query/ordered-work-examples.test.ts | 2491 +++++++ .../db/tests/query/ordered-work-fixtures.ts | 10 + .../tests/query/ordered-work-model-oracle.ts | 929 +++ .../ordered-work-oracle.property.test.ts | 6481 +++++------------ 4 files changed, 5176 insertions(+), 4735 deletions(-) create mode 100644 packages/db/tests/query/ordered-work-examples.test.ts create mode 100644 packages/db/tests/query/ordered-work-fixtures.ts create mode 100644 packages/db/tests/query/ordered-work-model-oracle.ts diff --git a/packages/db/tests/query/ordered-work-examples.test.ts b/packages/db/tests/query/ordered-work-examples.test.ts new file mode 100644 index 0000000000..bab580374f --- /dev/null +++ b/packages/db/tests/query/ordered-work-examples.test.ts @@ -0,0 +1,2491 @@ +// Ordinary tests moved out of ordered-work-oracle.property.test.ts. Each +// compares production with values written in the test, or checks the oracle's +// grammar or checker without running production, so none of them is an +// oracle registration. The laws, their authority, and the oracle properties +// are in that file; the grammar, model, and checker definitions that the +// calibrations read are in ordered-work-model-oracle.ts. +import { fc } from '@fast-check/vitest' +import { describe, expect, it, vi } from 'vitest' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { LoadSubsetOperationAbortedError } from '../../src/errors.js' +import { BTreeIndex } from '../../src/indexes/btree-index.js' +import { createEffect } from '../../src/query/effect.js' +import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' +import { eq, isUndefined, not } from '../../src/query/builder/functions.js' +import { toArray } from '../../src/query/index.js' +import { oracleFixedSeed, readOracleRunConfig } from '../oracle-config.js' +import { evaluateReferenceExpression } from '../reference-expression-oracle.js' +import { flushPromises } from '../utils.js' +import { withHistoryCleanup } from '../optimistic-history-oracle.js' +import { copiedRow } from './ordered-work-fixtures.js' +import { + FULL_JOIN_SEED, + eagerFullJoinCase, + eagerWindow, + exhaustiveScenarios, + fillerRows, + optimisticWindowViolations, +} from './ordered-work-model-oracle.js' +import type { Marker, Row } from './ordered-work-support.js' +import type { + OptimisticWindowCase, + OptimisticWindowObservation, +} from './ordered-work-model-oracle.js' +import type { InitialQueryBuilder } from '../../src/query/builder/index.js' +import type { DeltaEvent } from '../../src/query/effect.js' +import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' + +let harnessId = 0 + +async function observeMutationDuringBoundedRepair(): Promise<{ + rows: Array + requests: number + publications: Array> +}> { + const truth = new Map([ + [1, { id: 1, rank: 1, eligible: true, label: `first` }], + [2, { id: 2, rank: 2, eligible: true, label: `second` }], + [3, { id: 3, rank: 3, eligible: true, label: `third` }], + ]) + const delivered = new Set() + const repairPrefixStarted = createDeferred() + const releaseRepairPrefix = createDeferred() + const repairTieStarted = createDeferred() + const releaseRepairTie = createDeferred() + const publications: Array> = [] + let heldRepairPrefix = false + let heldRepairTie = false + let requests = 0 + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: Row + }) => void + let commit!: () => true | Promise + + const source = createCollection({ + id: `ordered-mutation-during-repair-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + const apply = async (options: LoadSubsetOptions) => { + let selected = [...truth.values()].filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === true) && + (!options.cursor || + evaluateReferenceExpression(options.cursor.whereFrom, row) === + true), + ) + selected.sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const offset = options.cursor ? 0 : (options.offset ?? 0) + selected = selected.slice( + offset, + options.limit === undefined ? undefined : offset + options.limit, + ) + const additions = selected.filter(({ id }) => !delivered.has(id)) + if (additions.length === 0) return + begin() + for (const row of additions) { + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + if (receipt !== true) await receipt + } + return { + loadSubset: (options: LoadSubsetOptions) => { + requests++ + const isRepairPrefix = + options.refetch === true && + options.orderBy !== undefined && + options.cursor === undefined + if (isRepairPrefix && !heldRepairPrefix) { + heldRepairPrefix = true + repairPrefixStarted.resolve() + return releaseRepairPrefix.promise.then(() => apply(options)) + } + const isRepairTie = + options.refetch === true && + options.orderBy === undefined && + options.where !== undefined + if (isRepairTie && !heldRepairTie) { + heldRepairTie = true + repairTieStarted.resolve() + return releaseRepairTie.promise.then(() => apply(options)) + } + return apply(options) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + ) + const subscription = live.subscribeChanges(() => { + publications.push(live.toArray.map(({ id }) => id)) + }) + + return withHistoryCleanup( + async () => { + await live.preload() + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + publications.length = 0 + + const first = { ...truth.get(1)!, rank: 10 } + truth.set(1, first) + begin() + write({ type: `update`, value: { ...first } }) + const firstReceipt = commit() + if (firstReceipt !== true) await firstReceipt + await repairPrefixStarted.promise + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + releaseRepairPrefix.resolve() + await repairTieStarted.promise + + const second = { ...truth.get(2)!, rank: 20 } + truth.set(2, second) + begin() + write({ type: `update`, value: { ...second } }) + const secondReceipt = commit() + if (secondReceipt !== true) await secondReceipt + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + releaseRepairTie.resolve() + await vi.waitFor(() => + expect(live.toArray.map(({ id }) => id)).toEqual([3]), + ) + expect(publications).toEqual([[3]]) + expect(requests).toBeGreaterThanOrEqual(6) + return { + rows: live.toArray.map(({ id }) => id), + requests, + publications: publications.map((rows) => [...rows]), + } + }, + () => [ + () => releaseRepairPrefix.resolve(), + () => releaseRepairTie.resolve(), + () => subscription.unsubscribe(), + () => live.cleanup(), + () => source.cleanup(), + ], + ) +} + +async function observeHeldEffectRepair( + repairPrefix: `synchronous` | `asynchronous`, +): Promise>> { + const truth: Array = [ + { id: 1, rank: 1, eligible: true, label: `first` }, + { id: 2, rank: 2, eligible: true, label: `second` }, + ] + const repairStarted = createDeferred() + const releaseRepair = createDeferred() + const effectRows = new Set() + const publications: Array> = [] + let recovering = false + let loadCount = 0 + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: Row + }) => void + let commit!: () => void + + const source = createCollection({ + id: `ordered-held-effect-repair-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + const apply = (options: LoadSubsetOptions) => { + const selected = truth + .filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === true) && + (!options.cursor || + evaluateReferenceExpression(options.cursor.whereFrom, row) === + true), + ) + .sort((left, right) => left.rank - right.rank || left.id - right.id) + .slice( + options.offset ?? 0, + options.limit === undefined + ? undefined + : (options.offset ?? 0) + options.limit, + ) + begin() + for (const row of selected) { + write({ type: `insert`, value: { ...row } }) + } + commit() + } + return { + loadSubset: (options) => { + loadCount++ + const isRepair = + recovering && + options.orderBy !== undefined && + options.limit === 1 && + options.cursor === undefined + if (!isRepair) { + apply(options) + return true + } + if (repairPrefix === `synchronous`) { + apply(options) + return true + } + repairStarted.resolve() + return releaseRepair.promise.then(() => apply(options)) + }, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) effectRows.delete(event.key) + else effectRows.add(event.key) + } + publications.push([...effectRows].sort((left, right) => left - right)) + }, + }) + + return withHistoryCleanup( + async () => { + await vi.waitFor(() => expect([...effectRows]).toEqual([1])) + await vi.waitFor(() => expect(loadCount).toBeGreaterThanOrEqual(2)) + await flushPromises() + publications.length = 0 + recovering = true + const removed = truth.shift()! + begin() + write({ type: `delete`, value: removed }) + commit() + if (repairPrefix === `asynchronous`) { + await repairStarted.promise + await flushPromises() + + expect( + [...effectRows], + `last complete effect result while held`, + ).toEqual([1]) + expect(publications, `no intermediate effect publication`).toEqual([]) + + releaseRepair.resolve() + } + await vi.waitFor(() => expect([...effectRows]).toEqual([2])) + expect(publications).toEqual([[2]]) + return publications.map((rows) => [...rows]) + }, + () => [ + () => releaseRepair.resolve(), + () => effect.dispose(), + () => source.cleanup(), + ], + ) +} + +async function observeEffectAfterObsoleteRepairAbort(): Promise<{ + rows: Array + batches: Array> + finalLabel: string | undefined +}> { + const truth = new Map([ + [1, { id: 1, rank: 1, eligible: true, label: `first` }], + [2, { id: 2, rank: 2, eligible: true, label: `second` }], + ]) + const installed = new Set() + const effectRows = new Map() + const batches: Array> = [] + let repairHeld = false + let requests = 0 + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: Row + }) => void + let truncate!: () => void + let commit!: () => true | Promise + + const source = createCollection({ + id: `ordered-obsolete-effect-repair-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + truncate = operations.truncate + commit = operations.commit + operations.markReady() + const apply = async (options: LoadSubsetOptions) => { + let selected = [...truth.values()].filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === true) && + (!options.cursor || + evaluateReferenceExpression(options.cursor.whereFrom, row) === + true), + ) + selected.sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const offset = options.cursor ? 0 : (options.offset ?? 0) + selected = selected.slice( + offset, + options.limit === undefined ? undefined : offset + options.limit, + ) + const additions = selected.filter(({ id }) => !installed.has(id)) + if (additions.length === 0) return + begin() + for (const row of additions) { + installed.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + if (receipt !== true) await receipt + } + return { + loadSubset: (options: LoadSubsetOptions) => { + requests++ + const isRepairPrefix = + options.refetch === true && + options.orderBy !== undefined && + options.cursor === undefined + if (isRepairPrefix && !repairHeld) { + repairHeld = true + return new Promise((_resolve, reject) => { + const abort = () => + reject(new LoadSubsetOperationAbortedError()) + if (options.signal?.aborted) abort() + else { + options.signal?.addEventListener(`abort`, abort, { + once: true, + }) + } + }) + } + return apply(options) + }, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + onBatch: (events) => { + const labels: Array = [] + for (const event of events) { + labels.push(`${event.type}:${event.key}`) + if (event.type === `exit`) effectRows.delete(event.key) + else effectRows.set(event.key, copiedRow(event.value)) + } + batches.push(labels) + }, + }) + + return withHistoryCleanup( + async () => { + await vi.waitFor(() => expect([...effectRows.keys()]).toEqual([1])) + await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(2)) + await flushPromises() + batches.length = 0 + + const removed = truth.get(1)! + truth.delete(1) + installed.delete(1) + begin() + write({ type: `delete`, value: removed }) + const deleteReceipt = commit() + if (deleteReceipt !== true) await deleteReceipt + await vi.waitFor(() => expect(repairHeld).toBe(true)) + + installed.clear() + begin() + truncate() + const truncateReceipt = commit() + if (truncateReceipt !== true) await truncateReceipt + await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(5)) + await flushPromises() + + const replacement = truth.get(2)! + const updated = { ...replacement, label: `updated after replay` } + truth.set(2, updated) + begin() + write({ type: `update`, value: updated }) + const updateReceipt = commit() + if (updateReceipt !== true) await updateReceipt + await vi.waitFor(() => + expect(effectRows.get(2)?.label).toBe(`updated after replay`), + ) + + return { + rows: [...effectRows.keys()], + batches: batches.map((batch) => [...batch]), + finalLabel: effectRows.get(2)?.label, + } + }, + () => [() => effect.dispose(), () => source.cleanup()], + ) +} + +async function observeSkipInitialOrderedLoad(): Promise<{ + initialEvents: Array + laterEvents: Array +}> { + const truth: Array = [ + { id: 1, rank: 1, eligible: true, label: `first` }, + { id: 2, rank: 2, eligible: true, label: `second` }, + ] + const firstRequest = createDeferred() + const installed = new Set() + const events: Array = [] + let requests = 0 + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: Row + }) => void + let commit!: () => true | Promise + + const source = createCollection({ + id: `ordered-skip-initial-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: async (options: LoadSubsetOptions) => { + requests++ + if (requests === 1) await firstRequest.promise + let selected = truth.filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === true) && + (!options.cursor || + evaluateReferenceExpression(options.cursor.whereFrom, row) === + true), + ) + selected.sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const offset = options.cursor ? 0 : (options.offset ?? 0) + selected = selected.slice( + offset, + options.limit === undefined ? undefined : offset + options.limit, + ) + const additions = selected.filter(({ id }) => !installed.has(id)) + if (additions.length === 0) return + begin() + for (const row of additions) { + installed.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + if (receipt !== true) await receipt + }, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + skipInitial: true, + onBatch: (batch) => { + events.push(...batch.map((event) => `${event.type}:${event.key}`)) + }, + }) + + return withHistoryCleanup( + async () => { + await vi.waitFor(() => expect(requests).toBe(1)) + firstRequest.resolve() + await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(2)) + await flushPromises() + const initialEvents = [...events] + events.length = 0 + + const inserted: Row = { + id: 3, + rank: 0, + eligible: true, + label: `later`, + } + truth.push(inserted) + installed.add(inserted.id) + begin() + write({ type: `insert`, value: inserted }) + const receipt = commit() + if (receipt !== true) await receipt + await vi.waitFor(() => expect(events.length).toBeGreaterThan(0)) + + return { initialEvents, laterEvents: [...events] } + }, + () => [ + () => firstRequest.resolve(), + () => effect.dispose(), + () => source.cleanup(), + ], + ) +} + +async function observeHeldEffectDelta( + history: `absent-cycle` | `present-update-exit`, +): Promise<{ + batches: Array> + rows: Array + exitedValues: Map +}> { + const limit = history === `absent-cycle` ? 1 : 2 + const truth = new Map( + (history === `absent-cycle` + ? [ + { id: 1, rank: 1, eligible: true, label: `first` }, + { id: 3, rank: 3, eligible: true, label: `third` }, + ] + : [ + { id: 1, rank: 1, eligible: true, label: `first` }, + { id: 2, rank: 2, eligible: true, label: `second` }, + { id: 3, rank: 3, eligible: true, label: `third` }, + { id: 4, rank: 4, eligible: true, label: `fourth` }, + ] + ).map((row) => [row.id, row]), + ) + const delivered = new Set() + const repairStarted = createDeferred() + const releaseRepair = createDeferred() + const effectRows = new Map() + const batches: Array> = [] + const exitedValues = new Map() + let heldRepair = false + let loadCount = 0 + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: Row + }) => void + let commit!: () => true | Promise + + const source = createCollection({ + id: `ordered-held-effect-delta-${history}-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + const apply = async (options: LoadSubsetOptions) => { + let selected = [...truth.values()].filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === true) && + (!options.cursor || + evaluateReferenceExpression(options.cursor.whereFrom, row) === + true), + ) + selected.sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const offset = options.cursor ? 0 : (options.offset ?? 0) + selected = selected.slice( + offset, + options.limit === undefined ? undefined : offset + options.limit, + ) + const additions = selected.filter(({ id }) => !delivered.has(id)) + if (additions.length === 0) return + begin() + for (const row of additions) { + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + if (receipt !== true) await receipt + } + return { + loadSubset: (options: LoadSubsetOptions) => { + loadCount++ + const isRepairPrefix = + options.refetch === true && + options.orderBy !== undefined && + options.cursor === undefined + if (isRepairPrefix && !heldRepair) { + heldRepair = true + repairStarted.resolve() + return releaseRepair.promise.then(() => apply(options)) + } + return apply(options) + }, + } + }, + }, + }) + const effect = createEffect({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(limit), + onBatch: (events: Array>) => { + const labels: Array = [] + for (const event of events) { + labels.push(`${event.type}:${String(event.key)}`) + if (event.type === `exit`) { + exitedValues.set(event.key, copiedRow(event.value)) + effectRows.delete(event.key) + } else { + effectRows.set(event.key, { ...event.value }) + } + } + batches.push(labels) + }, + }) + + const applySourceChange = async ( + type: `insert` | `update` | `delete`, + row: Row, + ) => { + begin() + write({ type, value: { ...row } }) + const receipt = commit() + if (receipt !== true) await receipt + } + + return withHistoryCleanup( + async () => { + const initialIds = history === `absent-cycle` ? [1] : [1, 2] + await vi.waitFor(() => + expect([...effectRows.keys()].sort((a, b) => a - b)).toEqual( + initialIds, + ), + ) + await vi.waitFor(() => expect(loadCount).toBeGreaterThanOrEqual(2)) + await flushPromises() + batches.length = 0 + + const first = truth.get(1)! + truth.delete(1) + delivered.delete(1) + await applySourceChange(`delete`, first) + await repairStarted.promise + + if (history === `absent-cycle`) { + const transient: Row = { + id: 2, + rank: 2, + eligible: true, + label: `transient`, + } + delivered.add(transient.id) + await applySourceChange(`insert`, transient) + delivered.delete(transient.id) + await applySourceChange(`delete`, transient) + } else { + const previous = truth.get(2)! + const updated = { ...previous, rank: 20, label: `updated` } + truth.set(2, updated) + await applySourceChange(`update`, updated) + truth.delete(2) + delivered.delete(2) + await applySourceChange(`delete`, updated) + } + + expect(batches).toEqual([]) + releaseRepair.resolve() + const expectedIds = history === `absent-cycle` ? [3] : [3, 4] + await vi.waitFor(() => + expect([...effectRows.keys()].sort((a, b) => a - b)).toEqual( + expectedIds, + ), + ) + + expect(batches).toHaveLength(1) + const flattened = batches.flat() + if (history === `absent-cycle`) { + expect(flattened).not.toContain(`update:2`) + expect(flattened).not.toContain(`enter:2`) + expect(flattened).not.toContain(`exit:2`) + } else { + expect(flattened).toContain(`exit:2`) + expect(flattened).not.toContain(`update:2`) + expect(exitedValues.get(2)).toEqual({ + id: 2, + rank: 2, + eligible: true, + label: `second`, + }) + } + return { + batches: batches.map((batch) => [...batch]), + rows: [...effectRows.keys()].sort((a, b) => a - b), + exitedValues, + } + }, + () => [ + () => releaseRepair.resolve(), + () => effect.dispose(), + () => source.cleanup(), + ], + ) +} + +describe(`ordered source work oracle`, () => { + it(`reconstructs every legal small scenario exactly once`, () => { + expect(exhaustiveScenarios).toHaveLength(64) + expect( + new Set(exhaustiveScenarios.map((scenario) => JSON.stringify(scenario))) + .size, + ).toBe(64) + }) + + it(`holds the last complete Effect result across an asynchronous prefix and synchronous tie repair`, async () => { + await expect(observeHeldEffectRepair(`asynchronous`)).resolves.toEqual([ + [2], + ]) + }) + + it(`publishes one Effect replacement when ordered repair settles synchronously`, async () => { + await expect(observeHeldEffectRepair(`synchronous`)).resolves.toEqual([[2]]) + }) + + it(`publishes the repaired ordered Effect delta when a source changes during startup`, async () => { + // 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 + // callback checkpoint, only row 2 may enter the width-one result. + type Sync = Parameters[`sync`]>[0] + const truth = new Map([ + [1, { id: 1, rank: 1, eligible: true, label: `first` }], + [2, { id: 2, rank: 2, eligible: true, label: `second` }], + ]) + let rootSync!: Sync + const delivered = new Set() + const requests: Array = [] + const root = createCollection({ + id: `ordered-effect-start-root-${harnessId++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + rootSync = operations + operations.markReady() + return { + loadSubset: (options) => { + requests.push(options) + const selected = [...truth.values()] + .filter( + (row) => + (!options.where || + evaluateReferenceExpression(options.where, row) === + true) && + (!options.cursor || + evaluateReferenceExpression( + options.cursor.whereFrom, + row, + ) === true), + ) + .sort((a, b) => a.rank - b.rank || a.id - b.id) + .slice( + options.offset ?? 0, + options.limit === undefined + ? undefined + : (options.offset ?? 0) + options.limit, + ) + operations.begin() + for (const row of selected) { + if (!delivered.has(row.id)) { + delivered.add(row.id) + operations.write({ type: `insert`, value: { ...row } }) + } + } + expect( + operations.commit(), + `ordered page applies synchronously`, + ).toBe(true) + return true + }, + } + }, + }, + }) + const marker = createCollection({ + id: `ordered-effect-start-marker-${harnessId++}`, + getKey: ({ id }) => id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + operations.markReady() + }, + }, + }) + const originalSubscribe = marker.subscribeChanges.bind(marker) + let changedDuringStart = false + vi.spyOn(marker, `subscribeChanges`).mockImplementation(((...args) => { + const subscription = originalSubscribe(...args) + // Only the first subscription after the ordered page can drive this + // history; other subscribers must not repeat or preempt the update. + if (changedDuringStart || requests.length === 0) return subscription + expect(requests[0]?.orderBy, `first request is ordered`).toBeDefined() + expect(requests[0]?.limit, `first request loads one row`).toBe(1) + expect(delivered.has(1), `first page installed row 1`).toBe(true) + expect(root.get(1)?.rank, `row 1 is present before its update`).toBe(1) + changedDuringStart = true + const updated = { ...truth.get(1)!, rank: 3, label: `moved` } + truth.set(1, updated) + rootSync.begin() + rootSync.write({ type: `update`, value: updated }) + expect(rootSync.commit(), `startup update applies synchronously`).toBe( + true, + ) + return subscription + }) as typeof marker.subscribeChanges) + + const batches: Array>> = [] + let effect: ReturnType | undefined + // Drain the scheduled callback queue at the assertion checkpoint. A single + // event-loop turn can miss a later duplicate batch. + vi.useFakeTimers() + try { + await withHistoryCleanup( + async () => { + effect = createEffect({ + query: (q) => + q + .from({ row: root }) + .leftJoin({ marker }, ({ row, marker: child }) => + eq(row.id, child.rowId), + ) + .orderBy(({ row }) => row.rank) + .limit(1) + .select(({ row }) => ({ + id: row.id, + rank: row.rank, + eligible: row.eligible, + label: row.label, + })), + onBatch: (events) => { + batches.push(events) + }, + }) + expect(changedDuringStart).toBe(true) + await vi.runAllTimersAsync() + expect(batches).toEqual([ + [{ type: `enter`, key: `[2,null]`, value: truth.get(2) }], + ]) + expect(requests.some((options) => options.refetch === true)).toBe( + true, + ) + }, + () => [ + () => effect?.dispose(), + () => root.cleanup(), + () => marker.cleanup(), + ], + ) + } finally { + vi.useRealTimers() + } + }) + + it(`keeps an Effect live when truncate replay aborts an obsolete repair participant`, async () => { + await expect( + observeEffectAfterObsoleteRepairAbort(), + ).resolves.toMatchObject({ + rows: [2], + finalLabel: `updated after replay`, + }) + }) + + it(`suppresses an asynchronous initial ordered window when skipInitial is set`, async () => { + await expect(observeSkipInitialOrderedLoad()).resolves.toEqual({ + initialEvents: [], + laterEvents: expect.arrayContaining([`enter:3`, `exit:1`]), + }) + }) + + it(`restarts bounded repair when another order mutation arrives during its tie request`, async () => { + await expect(observeMutationDuringBoundedRepair()).resolves.toMatchObject({ + rows: [3], + publications: [[3]], + }) + }) + + it(`emits no Effect delta for a row that enters and exits during held repair`, async () => { + await expect(observeHeldEffectDelta(`absent-cycle`)).resolves.toMatchObject( + { + rows: [3], + }, + ) + }) + + it(`emits the published value when an updated row exits during held repair`, async () => { + await expect( + observeHeldEffectDelta(`present-update-exit`), + ).resolves.toMatchObject({ rows: [3, 4] }) + }) + + it.each([ + { + name: `matching`, + secondaryRows: [ + { id: `c-child`, joinKey: `c` }, + { id: `d-child`, joinKey: `d` }, + ], + expected: [`c:c-child`, `d:d-child`], + }, + { + name: `empty`, + secondaryRows: [] as Array<{ id: string; joinKey: string }>, + expected: [] as Array, + }, + ])( + `waits for a $name joined source after exhausting tied ordered rows`, + async ({ name, secondaryRows, expected }) => { + type Primary = { id: string; rank: number; joinKey: string } + type Secondary = { id: string; joinKey: string } + const primaryRows: Array = [`a`, `b`, `c`, `d`].map((id) => ({ + id, + rank: 0, + joinKey: id, + })) + const deliveredPrimary = new Set() + const deliveredSecondary = new Set() + const secondaryLoads: Array<{ + options: LoadSubsetOptions + gate: ReturnType> + }> = [] + let primaryExhausted = false + + const primary = createCollection({ + id: `ordered-late-join-primary-${name}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async (options) => { + const rows = options.orderBy + ? [ + primaryRows[ + options.cursor?.lastKey + ? primaryRows.findIndex( + ({ id }) => id === options.cursor?.lastKey, + ) + 1 + : 0 + ], + ].filter((row): row is Primary => row !== undefined) + : primaryRows.filter( + (row) => + !options.where || + evaluateReferenceExpression(options.where, row) === + true, + ) + const fresh = rows.filter(({ id }) => !deliveredPrimary.has(id)) + if (fresh.length > 0) { + begin() + for (const row of fresh) { + deliveredPrimary.add(row.id) + write({ type: `insert`, value: row }) + } + const receipt = commit(options.signal) + if (receipt !== true) await receipt + } + primaryExhausted = deliveredPrimary.size === primaryRows.length + }, + } + }, + }, + }) + const secondary = createCollection({ + id: `ordered-late-join-secondary-${name}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async (options) => { + const gate = createDeferred() + secondaryLoads.push({ options, gate }) + await gate.promise + const fresh = secondaryRows.filter( + (row) => + !deliveredSecondary.has(row.id) && + (!options.where || + evaluateReferenceExpression(options.where, row) === true), + ) + for (const row of fresh) { + deliveredSecondary.add(row.id) + begin() + write({ type: `insert`, value: row }) + const receipt = commit(options.signal) + if (receipt !== true) await receipt + } + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ primaryRow: primary }) + .innerJoin( + { secondaryRow: secondary }, + ({ primaryRow, secondaryRow }) => + eq(primaryRow.joinKey, secondaryRow.joinKey), + ) + .orderBy(({ primaryRow }) => primaryRow.rank) + .limit(2), + ) + + try { + const preload = live.preload() + let settled = false + void preload.finally(() => { + settled = true + }) + await vi.waitFor(() => + expect( + primaryExhausted, + JSON.stringify({ + deliveredPrimary: [...deliveredPrimary], + secondaryLoads: secondaryLoads.length, + }), + ).toBe(true), + ) + expect(secondaryLoads.length).toBeGreaterThan(0) + expect(settled).toBe(false) + + for (const load of [...secondaryLoads].reverse()) { + load.gate.resolve() + await flushPromises() + } + await preload + + expect( + live.toArray.map( + ({ primaryRow, secondaryRow }) => + `${primaryRow.id}:${secondaryRow.id}`, + ), + ).toEqual(expected) + expect(live.isLoadingSubset).toBe(false) + expect(live.utils.lastSubsetError).toBeUndefined() + } finally { + for (const { gate } of secondaryLoads) gate.resolve() + await Promise.all([ + live.cleanup(), + primary.cleanup(), + secondary.cleanup(), + ]) + } + }, + ) + + it(`keeps independent joined loads isolated when they settle in reverse`, async () => { + type Primary = { id: string; rank: number; joinKey: string } + type Secondary = { id: string; joinKey: string } + const pending: Array<{ + options: LoadSubsetOptions + gate: ReturnType> + }> = [] + const completionOrder: Array = [] + const primary = createCollection({ + id: `ordered-independent-primary`, + getKey: ({ id }) => id, + syncMode: `eager`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: `a`, rank: 1, joinKey: `a` } }) + write({ type: `insert`, value: { id: `b`, rank: 2, joinKey: `b` } }) + commit() + markReady() + }, + }, + }) + const secondaryRows: Array = [ + { id: `a-child`, joinKey: `a` }, + { id: `b-child`, joinKey: `b` }, + ] + const delivered = new Set() + const secondary = createCollection({ + id: `ordered-independent-secondary`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async (options) => { + const index = pending.length + const gate = createDeferred() + pending.push({ options, gate }) + await gate.promise + const rows = secondaryRows.filter( + (candidate) => + !delivered.has(candidate.id) && + (!options.where || + evaluateReferenceExpression(options.where, candidate) === + true), + ) + for (const row of rows) { + delivered.add(row.id) + begin() + write({ type: `insert`, value: row }) + const receipt = commit(options.signal) + if (receipt !== true) await receipt + } + completionOrder.push(index) + }, + } + }, + }, + }) + const createJoined = (id: `a` | `b`) => + createLiveQueryCollection((q) => + q + .from({ primaryRow: primary }) + .where(({ primaryRow }) => eq(primaryRow.id, id)) + .innerJoin( + { secondaryRow: secondary }, + ({ primaryRow, secondaryRow }) => + eq(primaryRow.joinKey, secondaryRow.joinKey), + ) + .orderBy(({ primaryRow }) => primaryRow.rank) + .limit(1), + ) + const first = createJoined(`a`) + const second = createJoined(`b`) + + try { + const firstPreload = first.preload() + await vi.waitFor(() => expect(pending).toHaveLength(1)) + const secondPreload = second.preload() + await vi.waitFor(() => expect(pending).toHaveLength(2)) + let firstSettled = false + void firstPreload.finally(() => { + firstSettled = true + }) + + pending[1]!.gate.resolve() + await secondPreload + await flushPromises() + expect(firstSettled).toBe(false) + expect( + second.toArray.map( + ({ primaryRow, secondaryRow }) => + `${primaryRow.id}:${secondaryRow.id}`, + ), + ).toEqual([`b:b-child`]) + + pending[0]!.gate.resolve() + await firstPreload + expect(completionOrder).toEqual([1, 0]) + expect( + first.toArray.map( + ({ primaryRow, secondaryRow }) => + `${primaryRow.id}:${secondaryRow.id}`, + ), + ).toEqual([`a:a-child`]) + expect(first.utils.lastSubsetError).toBeUndefined() + expect(second.utils.lastSubsetError).toBeUndefined() + } finally { + for (const { gate } of pending) gate.resolve() + await Promise.all([ + first.cleanup(), + second.cleanup(), + primary.cleanup(), + secondary.cleanup(), + ]) + } + }) + + it(`keeps one source's replay from suppressing another source's recovery`, async () => { + type Primary = { id: number; rank: number } + type Secondary = { id: number; primaryId: number } + const primaryTruth = new Map([ + [1, { id: 1, rank: 0 }], + [2, { id: 2, rank: 1 }], + ]) + const deliveredPrimary = new Set() + const secondaryReplay = createDeferred() + let primaryRequests = 0 + let replayingSecondary = false + let primarySync!: Parameters[`sync`]>[0] + let secondarySync!: Parameters[`sync`]>[0] + let secondaryReplayCalls = 0 + + const primary = createCollection({ + id: `ordered-independent-recovery-primary`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + primarySync = operations + operations.markReady() + return { + loadSubset: async (options) => { + primaryRequests++ + let selected = [...primaryTruth.values()].sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + if (options.where) { + selected = selected.filter( + (row) => + evaluateReferenceExpression(options.where!, row) === true, + ) + } + if (options.cursor) { + selected = selected.filter( + (row) => + evaluateReferenceExpression( + options.cursor!.whereFrom, + row, + ) === true, + ) + } else if (options.offset) { + selected = selected.slice(options.offset) + } + if (options.limit !== undefined) { + selected = selected.slice(0, options.limit) + } + const fresh = selected.filter( + ({ id }) => !deliveredPrimary.has(id), + ) + if (fresh.length === 0) return + operations.begin() + for (const row of fresh) { + deliveredPrimary.add(row.id) + operations.write({ type: `insert`, value: { ...row } }) + } + const receipt = operations.commit(options.signal) + if (receipt !== true) await receipt + }, + unloadSubset: () => {}, + } + }, + }, + }) + const secondary = createCollection({ + id: `ordered-independent-recovery-secondary`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + secondarySync = operations + operations.markReady() + return { + loadSubset: async (options) => { + if (replayingSecondary) { + secondaryReplayCalls++ + await secondaryReplay.promise + } + operations.begin() + operations.write({ + type: `insert`, + value: { id: 1, primaryId: 1 }, + }) + const receipt = operations.commit(options.signal) + if (receipt !== true) await receipt + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: primary }) + .leftJoin({ child: secondary }, ({ row, child }) => + eq(row.id, child.primaryId), + ) + .orderBy(({ row }) => row.rank) + .limit(1) + .select(({ row, child }) => ({ + id: row.id, + rank: row.rank, + childId: child.id, + })), + ) + + try { + await live.preload() + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + replayingSecondary = true + secondarySync.begin() + secondarySync.truncate() + const truncateReceipt = secondarySync.commit() + if (truncateReceipt !== true) await truncateReceipt + await vi.waitFor(() => expect(secondaryReplayCalls).toBeGreaterThan(0)) + + const requestsBeforeMutation = primaryRequests + const moved = { id: 1, rank: 10 } + primaryTruth.set(1, moved) + primarySync.begin() + primarySync.write({ type: `update`, value: moved }) + const mutationReceipt = primarySync.commit() + if (mutationReceipt !== true) await mutationReceipt + await vi.waitFor(() => + expect(primaryRequests).toBeGreaterThan(requestsBeforeMutation), + ) + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + secondaryReplay.resolve() + await vi.waitFor(() => + expect(live.toArray.map(({ id }) => id)).toEqual([2]), + ) + expect(primaryRequests).toBeGreaterThan(requestsBeforeMutation) + } finally { + secondaryReplay.resolve() + await Promise.all([ + live.cleanup(), + primary.cleanup(), + secondary.cleanup(), + ]) + } + }) + + it(`publishes one complete batch after an indexed loader fills a window`, async () => { + const remoteRows: ReadonlyArray = [ + { id: 1, rank: 1, eligible: true, label: `one` }, + { id: 2, rank: 2, eligible: true, label: `two` }, + ] + const batches: Array> = [] + const callbackReads: Array> = [] + let loads = 0 + const delivered = new Set() + let sync!: Parameters[`sync`]>[0] + const source = createCollection({ + id: `ordered-atomic-indexed-window`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: (options) => { + loads++ + const row = remoteRows.find( + (candidate) => + !delivered.has(candidate.id) && + (!options.where || + evaluateReferenceExpression(options.where, candidate) === + true), + ) + if (!row) return true + delivered.add(row.id) + sync.begin() + sync.write({ type: `insert`, value: row }) + const receipt = sync.commit() + if (receipt !== true) { + throw new Error(`Expected synchronous source application`) + } + return true + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(0), + ) + const readIds = () => live.toArray.map(({ id }) => id) + const subscription = live.subscribeChanges( + (changes) => { + batches.push(changes.map(({ key }) => Number(key)).sort()) + callbackReads.push(readIds()) + }, + { includeInitialState: false }, + ) + + await withHistoryCleanup( + async () => { + await live.preload() + await live.utils.setWindow({ offset: 0, limit: 2 }) + await flushPromises() + + // Each page turn is followed by a tie-boundary request. The source + // returns one row at a time while honoring both predicates. + expect(loads).toBe(4) + expect(readIds()).toEqual([1, 2]) + expect(batches).toEqual([[1, 2]]) + expect(callbackReads).toEqual([[1, 2]]) + }, + () => [ + () => subscription.unsubscribe(), + () => live.cleanup(), + () => source.cleanup(), + ], + ) + }) + + it(`processes a synchronous root refill before joined publication`, async () => { + type Root = { id: number; rank: number } + type Child = { id: number; rootId: number } + const truth: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + ] + const installed = new Set() + const rootRequests: Array = [] + const firstChildGate = createDeferred() + let childLoads = 0 + const rootCollection = createCollection({ + id: `ordered-synchronous-refill-root`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + operations.markReady() + return { + loadSubset: (options) => { + rootRequests.push(options) + let selected = truth + .filter( + (row) => + !options.where || + evaluateReferenceExpression(options.where, row) === true, + ) + .filter( + (row) => + !options.cursor || + evaluateReferenceExpression( + options.cursor.whereFrom, + row, + ) === true, + ) + .sort((left, right) => left.rank - right.rank) + if (!options.cursor && options.offset) { + selected = selected.slice(options.offset) + } + if (options.limit !== undefined) { + selected = selected.slice(0, options.limit) + } + const fresh = selected.filter(({ id }) => !installed.has(id)) + if (fresh.length > 0) { + operations.begin() + for (const row of fresh) { + installed.add(row.id) + operations.write({ type: `insert`, value: row }) + } + const receipt = operations.commit(options.signal) + if (receipt !== true) { + throw new Error(`Expected synchronous root application`) + } + } + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const childCollection = createCollection({ + id: `ordered-synchronous-refill-child`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + operations.begin() + operations.write({ + type: `insert`, + value: { id: 20, rootId: 2 }, + }) + if (operations.commit() !== true) { + throw new Error(`Expected synchronous child application`) + } + operations.markReady() + return { + loadSubset: () => { + childLoads++ + return childLoads === 1 ? firstChildGate.promise : true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `ordered-synchronous-refill-live`, + startSync: false, + query: (q) => + q + .from({ root: rootCollection }) + .leftJoin({ child: childCollection }, ({ root, child }) => + eq(root.id, child.rootId), + ) + .where(({ child }) => not(isUndefined(child.rootId))) + .orderBy(({ root }) => root.rank) + .limit(1) + .select(({ root }) => ({ id: root.id })), + }) + + await withHistoryCleanup( + async () => { + const preload = live.preload() + await vi.waitFor(() => { + expect(childLoads).toBe(1) + expect(rootRequests).toHaveLength(1) + }) + expect(live.toArray).toEqual([]) + + firstChildGate.resolve() + await preload + expect(childLoads).toBe(2) + expect(live.toArray.map(({ id }) => id)).toEqual([2]) + expect(rootRequests.some(({ cursor }) => cursor !== undefined)).toBe( + true, + ) + }, + () => [ + () => firstChildGate.resolve(), + () => live.cleanup(), + () => rootCollection.cleanup(), + () => childCollection.cleanup(), + ], + ) + }) + + it(`resumes bounded ordered repair after joined demand settles`, async () => { + type Root = { id: number; rank: number } + type Child = { id: number; rootId: number } + const truth: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const installed = new Set() + const rootRequests: Array = [] + const secondChildGate = createDeferred() + let childLoads = 0 + let rootSync!: Parameters[`sync`]>[0] + const rootCollection = createCollection({ + id: `ordered-held-repair-root`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + rootSync = operations + operations.markReady() + return { + loadSubset: async (options) => { + rootRequests.push(options) + let selected = truth + .filter( + (row) => + !options.where || + evaluateReferenceExpression(options.where, row) === true, + ) + .filter( + (row) => + !options.cursor || + evaluateReferenceExpression( + options.cursor.whereFrom, + row, + ) === true, + ) + .sort((left, right) => left.rank - right.rank) + if (!options.cursor && options.offset) { + selected = selected.slice(options.offset) + } + if (options.limit !== undefined) { + selected = selected.slice(0, options.limit) + } + const fresh = selected.filter(({ id }) => !installed.has(id)) + if (fresh.length > 0) { + operations.begin() + for (const row of fresh) { + installed.add(row.id) + operations.write({ type: `insert`, value: row }) + } + const receipt = operations.commit(options.signal) + if (receipt !== true) await receipt + } + }, + unloadSubset: () => {}, + } + }, + }, + }) + const childCollection = createCollection({ + id: `ordered-held-repair-child`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + childLoads++ + return childLoads === 2 ? secondChildGate.promise : true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection({ + id: `ordered-held-repair-live`, + startSync: false, + query: (q) => + q + .from({ root: rootCollection }) + .leftJoin({ child: childCollection }, ({ root, child }) => + eq(root.id, child.rootId), + ) + .where(({ child }) => isUndefined(child.rootId)) + .orderBy(({ root }) => root.rank) + .limit(1) + .select(({ root }) => ({ id: root.id })), + }) + + const deleteRoot = async (id: number) => { + const row = truth.find((candidate) => candidate.id === id)! + truth.splice(truth.indexOf(row), 1) + installed.delete(id) + rootSync.begin() + rootSync.write({ type: `delete`, value: row }) + const receipt = rootSync.commit() + if (receipt !== true) await receipt + } + + await withHistoryCleanup( + async () => { + await live.preload() + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + await deleteRoot(1) + await vi.waitFor(() => { + expect(childLoads).toBe(2) + expect(rootRequests.filter(({ refetch }) => refetch)).toHaveLength(2) + }) + expect(live.toArray.map(({ id }) => id)).toEqual([1]) + + secondChildGate.resolve() + await vi.waitFor(() => + expect(live.toArray.map(({ id }) => id)).toEqual([2]), + ) + + await deleteRoot(2) + await vi.waitFor(() => + expect(live.toArray.map(({ id }) => id)).toEqual([3]), + ) + expect( + rootRequests.filter(({ refetch }) => refetch).length, + ).toBeGreaterThan(2) + }, + () => [ + () => secondChildGate.resolve(), + () => live.cleanup(), + () => rootCollection.cleanup(), + () => childCollection.cleanup(), + ], + ) + }) + + it(`replaces an ordered snapshot after truncate without repeating void loads`, async () => { + const initial: ReadonlyArray = [ + { id: 1, rank: 1, eligible: true, label: `old-one` }, + { id: 2, rank: 2, eligible: true, label: `old-two` }, + ] + const replacement: ReadonlyArray = [ + { id: 3, rank: 3, eligible: true, label: `new-three` }, + { id: 4, rank: 4, eligible: true, label: `new-four` }, + ] + let truth = initial + let sync!: Parameters[`sync`]>[0] + let loads = 0 + const installed = new Set() + const source = createCollection({ + id: `ordered-void-truncate`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: async (options) => { + loads++ + if (loads > 12) { + throw new Error( + `ordered void loading did not reach a fixed point`, + ) + } + const matching = options.where + ? truth.filter( + (row) => + evaluateReferenceExpression(options.where!, row) === true, + ) + : truth + const rows = matching + .slice( + options.offset ?? 0, + options.limit === undefined + ? undefined + : (options.offset ?? 0) + options.limit, + ) + .filter(({ id }) => !installed.has(id)) + if (rows.length === 0) return + sync.begin() + for (const row of rows) { + installed.add(row.id) + sync.write({ type: `insert`, value: row }) + } + const receipt = sync.commit() + if (receipt !== true) await receipt + }, + unloadSubset: () => {}, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(2), + ) + + try { + await live.preload() + expect(live.toArray.map(({ id }) => id)).toEqual([1, 2]) + + truth = replacement + installed.clear() + sync.begin() + sync.truncate() + const receipt = sync.commit() + if (receipt !== true) await receipt + await flushPromises() + + expect(live.toArray.map(({ id }) => id)).toEqual([3, 4]) + expect(loads).toBeLessThanOrEqual(8) + } finally { + await Promise.all([live.cleanup(), source.cleanup()]) + } + }) + + it.each([ + { name: `until full-source recovery settles`, failure: undefined }, + { + name: `when full-source recovery throws synchronously`, + failure: { + mode: `sync` as const, + attempts: 1, + error: new Error(`full-source recovery failed`), + }, + }, + { + name: `after two synchronous full-source recovery failures`, + failure: { + mode: `sync` as const, + attempts: 2, + error: new Error(`full-source recovery failed twice`), + }, + }, + { + name: `after asynchronous full-source recovery retries`, + failure: { + mode: `async` as const, + attempts: 1, + error: new Error(`full-source recovery rejected`), + }, + }, + { + name: `after two asynchronous full-source recovery failures`, + failure: { + mode: `async` as const, + attempts: 2, + error: new Error(`full-source recovery rejected twice`), + }, + }, + ])(`keeps an ordered snapshot unchanged $name`, async ({ failure }) => { + const makeRows = (ranks: ReadonlyArray): Array => + ranks.map((rank, index) => ({ + id: index + 1, + rank, + eligible: true, + label: `row-${rank}`, + })) + let truth = makeRows([1, 2, 3, 4, 5]) + let sync!: Parameters[`sync`]>[0] + let recovering = false + let fullSourceRequests = 0 + const fullSource = createDeferred() + const installed = new Set() + const publications: Array> = [] + const escapedErrors: Array = [] + const acquisitions: Array = [] + const releases: Array = [] + const enqueueMicrotask = globalThis.queueMicrotask.bind(globalThis) + const queueMicrotaskSpy = + failure?.mode === `sync` + ? vi + .spyOn(globalThis, `queueMicrotask`) + .mockImplementation((callback) => + enqueueMicrotask(() => { + try { + callback() + } catch (error) { + escapedErrors.push(error) + } + }), + ) + : undefined + + const source = createCollection({ + id: `delayed-full-source-recovery`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: (options) => { + const isFullSource = + options.where === undefined && options.limit === undefined + if (recovering && isFullSource) { + fullSourceRequests++ + if (failure && fullSourceRequests <= failure.attempts) { + // A synchronous throw is a rejection (maintainer + // decision): an acquisition that core releases. + acquisitions.push(options) + if (failure.mode === `sync`) throw failure.error + return Promise.reject(failure.error) + } + } + acquisitions.push(options) + + return (async () => { + if (recovering && isFullSource && !failure) { + await fullSource.promise + } + + let selected = options.where + ? truth.filter( + (row) => + evaluateReferenceExpression(options.where!, row) === + true, + ) + : [...truth] + if (options.cursor) { + selected = selected.filter( + (row) => + evaluateReferenceExpression( + options.cursor!.whereFrom, + row, + ) === true, + ) + } + selected.sort((left, right) => left.rank - right.rank) + if (!options.cursor && options.offset) { + selected = selected.slice(options.offset) + } + if (options.limit !== undefined) { + selected = selected.slice(0, options.limit) + } + + const fresh = selected.filter(({ id }) => !installed.has(id)) + if (fresh.length === 0) return + sync.begin() + for (const row of fresh) { + installed.add(row.id) + sync.write({ type: `insert`, value: row }) + } + const receipt = sync.commit() + if (receipt !== true) await receipt + })() + }, + unloadSubset: (options) => { + releases.push(options) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(2), + ) + const subscription = live.subscribeChanges(() => { + publications.push(live.toArray.map(({ rank }) => rank)) + }) + + try { + await live.preload() + await live.utils.setWindow({ offset: 0, limit: 4 }) + await flushPromises() + expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) + const publicationCount = publications.length + + truth = makeRows([0, 0.5, 1, 1.5, 2, 3]) + installed.clear() + recovering = true + sync.begin() + sync.truncate() + const receipt = sync.commit() + if (receipt !== true) await receipt + await vi.waitFor(() => expect(fullSourceRequests).toBe(1)) + for (let index = 0; index < 4; index++) await flushPromises() + + expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) + expect(publications).toHaveLength(publicationCount) + + if (failure) { + expect(live.utils.lastSubsetError).toBe(failure.error) + expect(escapedErrors).toEqual([]) + truth[0] = { ...truth[0]!, label: `updated during failed recovery` } + sync.begin() + sync.write({ type: `update`, value: truth[0] }) + const updateReceipt = sync.commit() + if (updateReceipt !== true) await updateReceipt + await flushPromises() + expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) + expect(live.get(1)?.label).toBe(`row-1`) + expect(publications).toHaveLength(publicationCount) + expect(fullSourceRequests).toBe(1) + const retryCount = failure.attempts + for (let retry = 0; retry < retryCount; retry++) { + installed.clear() + sync.begin() + sync.truncate() + const retryReceipt = sync.commit() + if (retryReceipt !== true) await retryReceipt + await vi.waitFor(() => expect(fullSourceRequests).toBe(retry + 2)) + if (retry + 1 < retryCount) { + for (let index = 0; index < 4; index++) await flushPromises() + expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) + expect(publications).toHaveLength(publicationCount) + expect(live.utils.lastSubsetError).toBe(failure.error) + expect(escapedErrors).toEqual([]) + } + } + await vi.waitFor(() => + expect(source.toArray.map(({ rank }) => rank).sort()).toEqual([ + 0, 0.5, 1, 1.5, 2, 3, + ]), + ) + await vi.waitFor(() => + expect(live.toArray.map(({ rank }) => rank)).toEqual([ + 0, 0.5, 1, 1.5, + ]), + ) + expect(fullSourceRequests).toBe(retryCount + 1) + expect(live.get(1)?.label).toBe(`updated during failed recovery`) + } else { + fullSource.resolve() + await flushPromises() + expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5, 1, 1.5]) + } + expect(publications.slice(publicationCount)).toEqual([[0, 0.5, 1, 1.5]]) + expect(escapedErrors).toEqual([]) + await live.utils.setWindow({ offset: 0, limit: 2 }) + expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5]) + const loadsBeforeReplay = fullSourceRequests + installed.clear() + sync.begin() + sync.truncate() + const nextReplay = sync.commit() + if (nextReplay !== true) await nextReplay + await flushPromises() + expect(fullSourceRequests).toBeGreaterThan(loadsBeforeReplay) + expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5]) + } finally { + queueMicrotaskSpy?.mockRestore() + fullSource.resolve() + subscription.unsubscribe() + await Promise.all([live.cleanup(), source.cleanup()]) + } + expect(releases).toHaveLength(acquisitions.length) + for (const acquisition of acquisitions) { + expect( + releases.filter((release) => release === acquisition), + ).toHaveLength(1) + } + }) + + it.each([`collection`, `effect`] as const)( + `continues an ordered anti-join %s while an unrelated include demand is pending`, + async (kind) => { + const rows = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const delivered = new Set() + const rootRequests: Array<{ cursor: boolean; limit?: number }> = [] + const extraGate = createDeferred() + let extraLoads = 0 + + const root = createCollection({ + id: `ordered-unrelated-root-${harnessId++}`, + getKey: (row: (typeof rows)[number]) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: async (options) => { + rootRequests.push({ + cursor: !!options.cursor, + limit: options.limit, + }) + if (rootRequests.length > 15) throw new Error(`request loop`) + const matching = options.where + ? rows.filter( + (row) => + evaluateReferenceExpression(options.where!, row) === + true, + ) + : rows + const candidates = options.cursor + ? matching.filter( + (row) => + evaluateReferenceExpression( + options.cursor!.whereFrom, + row, + ) === true, + ) + : matching.slice(options.offset ?? 0) + const fresh = candidates + .slice(0, options.limit) + .filter((row) => !delivered.has(row.id)) + if (fresh.length === 0) return + begin() + for (const row of fresh) { + delivered.add(row.id) + write({ type: `insert`, value: row }) + } + const receipt = commit() + if (receipt !== true) await receipt + }, + unloadSubset: () => {}, + } + }, + }, + }) + const markerSource = createCollection({ + id: `ordered-unrelated-marker-${harnessId++}`, + getKey: (row: { id: number; rowId: number }) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: 2, rowId: 2 } }) + commit() + markReady() + }, + }, + }) + const extraSource = createCollection({ + id: `ordered-unrelated-extra-${harnessId++}`, + getKey: (row: { id: number; rowId: number }) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: async () => { + extraLoads++ + await extraGate.promise + }, + unloadSubset: () => {}, + } + }, + }, + }) + const query = (q: InitialQueryBuilder) => + q + .from({ row: root }) + .leftJoin({ marker: markerSource }, ({ row, marker }) => + eq(row.id, marker.rowId), + ) + .where(({ marker }) => isUndefined(marker.rowId)) + .orderBy(({ row }) => row.rank) + .limit(2) + .select(({ row }) => ({ + id: row.id, + extras: toArray( + q + .from({ extra: extraSource }) + .where(({ extra }) => eq(extra.rowId, row.id)), + ), + })) + const live = + kind === `collection` ? createLiveQueryCollection(query) : undefined + const effectRows = new Set() + const effect = + kind === `effect` + ? createEffect<{ id: number; extras: Array }>({ + query, + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) effectRows.delete(event.value.id) + else effectRows.add(event.value.id) + } + }, + }) + : undefined + const preload = live?.preload() + + try { + await vi.waitFor(() => expect(extraLoads).toBeGreaterThan(0)) + for (let i = 0; i < 12; i++) await flushPromises() + expect( + rootRequests.some((request) => request.cursor), + JSON.stringify({ + rootRequests, + extraLoads, + rows: live?.toArray ?? [...effectRows], + }), + ).toBe(true) + extraGate.resolve() + await preload + await vi.waitFor(() => + expect(rootRequests.some((request) => request.cursor)).toBe(true), + ) + expect(live?.toArray.map(({ id }) => id) ?? [...effectRows]).toEqual([ + 1, 3, + ]) + } finally { + extraGate.resolve() + await Promise.all([ + effect?.dispose(), + live?.cleanup(), + root.cleanup(), + markerSource.cleanup(), + extraSource.cleanup(), + ]) + } + }, + ) +}) + +describe(`eager indexed ordered windows`, () => { + const { multiplier } = readOracleRunConfig() + const runs = 40 * multiplier + + it(`rejects a hostile optimistic observation that makes no refill progress`, () => { + const testCase: OptimisticWindowCase = { + shape: `joined`, + mutation: `delete`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [4, 3, 2], + acquisition: `required`, + } + const observation: OptimisticWindowObservation = { + initial: [3, 2], + immediate: [2], + acquiring: [2], + answerQueued: [2], + settled: [2, 1], + rowsAfterFirstTurn: [2, 1], + rowsAfterSecondTurn: [2, 1], + immediatePublications: [{ rows: [2] }], + preSettlementPublications: [{ rows: [2] }], + publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], + publicationsAfterSettlement: [], + attempts: [], + settlementOutcome: `fulfilled`, + } + + expect(optimisticWindowViolations(observation, testCase)).toEqual([ + `refill acquisition did not start`, + `refill answer did not submit row 1`, + ]) + }) + + it(`rejects a delayed Collection publication`, () => { + const testCase: OptimisticWindowCase = { + shape: `joined`, + mutation: `update`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [4, 3, 2], + acquisition: `required`, + } + const observation: OptimisticWindowObservation = { + initial: [3, 2], + immediate: [2], + acquiring: [2], + answerQueued: [2], + settled: [2, 1], + rowsAfterFirstTurn: [2, 1], + rowsAfterSecondTurn: [2, 1], + immediatePublications: [], + preSettlementPublications: [{ rows: [2] }], + publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], + publicationsAfterSettlement: [], + attempts: [ + { + submittedRows: [1], + limit: 2, + direction: `desc`, + appliedReceiptPending: true, + providerPending: true, + }, + ], + settlementOutcome: `fulfilled`, + } + + expect(optimisticWindowViolations(observation, testCase)).toEqual([ + `immediate publications: [] instead of [[2]]`, + ]) + }) + + it(`rejects a stale publication after transaction settlement`, () => { + const testCase: OptimisticWindowCase = { + shape: `joined`, + mutation: `update`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [4, 3, 2], + acquisition: `required`, + } + const observation: OptimisticWindowObservation = { + initial: [3, 2], + immediate: [2], + acquiring: [2], + answerQueued: [2], + settled: [2, 1], + rowsAfterFirstTurn: [2, 1], + rowsAfterSecondTurn: [2], + immediatePublications: [{ rows: [2] }], + preSettlementPublications: [{ rows: [2] }], + publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], + publicationsAfterSettlement: [{ rows: [2] }], + attempts: [ + { + submittedRows: [1], + limit: 2, + direction: `desc`, + appliedReceiptPending: true, + providerPending: true, + }, + ], + settlementOutcome: `fulfilled`, + } + + expect(optimisticWindowViolations(observation, testCase)).toEqual([ + `rows after second settlement turn: [2] instead of [2,1]`, + `post-settlement publications: [[2]] instead of []`, + ]) + }) + + it(`rejects a full-source refill disguised as useful bounded progress`, () => { + const testCase: OptimisticWindowCase = { + shape: `joined`, + mutation: `delete`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [4, 3, 2], + acquisition: `required`, + } + const observation: OptimisticWindowObservation = { + initial: [3, 2], + immediate: [2], + acquiring: [2], + answerQueued: [2], + settled: [2, 1], + rowsAfterFirstTurn: [2, 1], + rowsAfterSecondTurn: [2, 1], + immediatePublications: [{ rows: [2] }], + preSettlementPublications: [{ rows: [2] }], + publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], + publicationsAfterSettlement: [], + attempts: [ + { + submittedRows: [4, 3, 2, 1, 0, -1], + limit: 6, + direction: `desc`, + appliedReceiptPending: true, + providerPending: true, + }, + ], + settlementOutcome: `fulfilled`, + } + + expect(optimisticWindowViolations(observation, testCase)).toEqual([ + `refill used an unbounded provider request`, + ]) + const repeated = { + ...observation, + attempts: [{ ...observation.attempts[0]!, limit: 2 }], + requestHistory: [ + { identity: `same ordered page`, newRows: 1 }, + { identity: `same ordered page`, newRows: 0 }, + ], + } + expect(optimisticWindowViolations(repeated, testCase)).toEqual([ + `repeated an unchanged provider request without progress`, + ]) + }) + + // Reconstruction: the fixed-seed campaign reaches the premise. Sampling + // its arbitrary with its seed and run count yields exactly the cases it + // executes. Their initial windows include rootless rows and, separately, + // windows that hold a rootless row beside a root row with a null rank. + it(`generates full-join windows that reach the null group`, () => { + let rootless = 0 + let mixedNullGroup = 0 + for (const [testCase] of fc.sample( + fc.tuple(eagerFullJoinCase), + oracleFixedSeed(FULL_JOIN_SEED, { numRuns: runs }), + )) { + const roots = [ + ...fillerRows(testCase.direction, testCase.fillers), + ...testCase.initial, + ] + const model = new Map() + for (const row of roots) { + model.set(row.id, { label: `row`, ...row, eligible: true }) + } + for (const markerId of testCase.orphans ?? []) { + model.set(-1 - markerId, { + id: -1 - markerId, + rank: null as unknown as number, + eligible: true, + label: `marker`, + }) + } + const window = eagerWindow(model, testCase) + const hasRootless = window.some((id) => id < 0) + if (hasRootless) rootless++ + if ( + hasRootless && + window.some( + (id) => id >= 0 && (model.get(id)!.rank as number | null) == null, + ) + ) { + mixedNullGroup++ + } + } + // At one times the run count: 19 and 7 of 40 cases. + expect(rootless).toBeGreaterThanOrEqual(10) + expect(mixedNullGroup).toBeGreaterThanOrEqual(5) + }) +}) diff --git a/packages/db/tests/query/ordered-work-fixtures.ts b/packages/db/tests/query/ordered-work-fixtures.ts new file mode 100644 index 0000000000..5d50824afb --- /dev/null +++ b/packages/db/tests/query/ordered-work-fixtures.ts @@ -0,0 +1,10 @@ +// Fixture helpers shared by ordered-work-oracle.property.test.ts and +// ordered-work-examples.test.ts. They drive no comparison. +import type { Row } from './ordered-work-support.js' + +export function copiedRow(row: Row): Row { + const copy: Record = { ...row } + for (const field of [`$key`, `$collectionId`, `$origin`, `$hasPendingWrites`]) + delete copy[field] + return copy as Row +} diff --git a/packages/db/tests/query/ordered-work-model-oracle.ts b/packages/db/tests/query/ordered-work-model-oracle.ts new file mode 100644 index 0000000000..315d2d4581 --- /dev/null +++ b/packages/db/tests/query/ordered-work-model-oracle.ts @@ -0,0 +1,929 @@ +/** + * Definitions of `ordered-work-oracle.property.test.ts`, moved unchanged so + * that its grammar checks and checker calibrations in + * `ordered-work-examples.test.ts`, which run no production, can read them: + * the consumer-parity scenario grammar, the eager-window history grammar and + * row model, and the optimistic-window case model and its violation check. + * That oracle file states each law, its authority, and its limits. This + * module registers no tests. + */ +import { fc } from '@fast-check/vitest' +import { expect } from 'vitest' +import { compareAt } from './ordered-comparison-oracle.js' +import type { Row, Scenario } from './ordered-work-support.js' + +// The fixed seed of the full-join campaign, which both the campaign and its +// reconstruction census sample. +export const FULL_JOIN_SEED = 2073 + +// Grammar for the generated consumer-parity law: zero to three middle rows, +// either marker eligibility for middle and last rows, equal versus distinct +// middle ranks, and both order directions. Every combination is legal and the +// exhaustive matrix reconstructs all 64 cells before random sampling. Removing +// middleCount loses empty and refill histories; removing either eligibility +// bit loses matched/underfilled alternatives; removing tied loses the leading +// tie boundary; removing direction loses descending continuation. Counts four +// and 40 are an adjacent fixed range probe and a larger stress probe below. +// Negative counts, duplicate row IDs, and directions other than asc/desc are +// invalid in this grammar. +// "Scenario" is a model-only input description, not a runtime demand state. +export const scenarioArbitrary: fc.Arbitrary = fc.record({ + middleCount: fc.constantFrom(0 as const, 1 as const, 2 as const, 3 as const), + middleEligible: fc.boolean(), + lastEligible: fc.boolean(), + tied: fc.boolean(), + direction: fc.constantFrom(`asc` as const, `desc` as const), +}) + +export const exhaustiveScenarios: ReadonlyArray = ( + [0, 1, 2, 3] as const +).flatMap((middleCount) => + [false, true].flatMap((middleEligible) => + [false, true].flatMap((lastEligible) => + [false, true].flatMap((tied) => + ([`asc`, `desc`] as const).map((direction) => ({ + middleCount, + middleEligible, + lastEligible, + tied, + direction, + })), + ), + ), + ), +) + +export type EagerCommand = + | { type: `upsert`; id: number; rank: number; eligible: boolean } + | { type: `delete`; id: number } + // Changes only the label, so a row outside the window enters the query's + // input without changing which rows are eligible or their order. + | { type: `touch`; id: number } + // Changes only eligibility. The order value stays, so a joined plan sees + // the row leave or return only through the join. + | { type: `flip`; id: number } + // Adds another marker to a row that already has one, so a join matches + // the row more than once. + | { type: `dup`; id: number } + | { type: `delete-visible` } + // A refetching provider re-runs every request it served and sends the + // results; any other path skips it. + | { type: `refetch` } + // A holding provider answers its oldest held request; `drain` answers + // every held request, including the ones answers start, until none is + // left. Any other provider skips both. (Not the glossary's acquisition + // release, which aborts and unloads.) + | { type: `answer` } + // Cleans up the peer while its loads may be held. Its acquisition + // release aborts each held request and gives the adapter an unload. + | { type: `release-peer` } + | { type: `drain` } + // Moves the first query's window with `setWindow`. The Effect and the peer + // keep their windows. + | { type: `move`; offset: number; limit: number } + +export type EagerCase = { + // How rows reach the source: sync writes to an eager source, local-only + // direct writes, or an on-demand source whose provider serves each + // request from the rows and installs only what it returns. + path: `sync` | `local-only` | `on-demand` + // How an on-demand provider delivers later changes. `forward` sends each + // change to a row it sent, or to a row that matches a subset it served. + // `refetch` sends nothing until a refetch, which keeps every served + // ordered range whole: rows that now sort inside one arrive then, and rows + // in no range leave. `refetch-signal` also waits for a refetch, but re-runs + // each served request alone, as query-db-collection does, and then reports + // `invalidateLoadedSubsets()` when any answer changed. Omitted means + // `forward`. + provider?: `forward` | `refetch` | `refetch-signal` + // When an on-demand provider answers a load. `immediate` answers within + // the request. `held` answers only at an `answer` or `drain` step, from the + // rows at that moment, so live changes can arrive while a load is in flight. + // Initial loads answer immediately. Omitted means `immediate`. + latency?: `immediate` | `held` + // What a holding provider does with an aborted or unloaded held request: + // `cooperative` installs none of its rows and fulfills at once; `late` + // cannot cancel it, so it answers it anyway. Omitted means `cooperative`. + cancellation?: `cooperative` | `late` + // A second live query over the same sources, with its own window: the + // first `offset + limit + 1` rows. Its window law holds beside the first + // query's, and the work laws, which count one consumer, do not apply. + peer?: boolean + // Where eligibility is decided: a predicate on the ordered source itself, + // a JavaScript function filter, or a filter on a LEFT-joined marker, where + // a marker admits its row (`joined`) or excludes it (`anti-joined`). The + // last three drop rows only after the source's limit. `full-joined` keeps + // every root row and adds a rootless row for each marker no root row + // matches; deleting a matched root row leaves its marker behind. + filter: `source` | `function` | `joined` | `anti-joined` | `full-joined` + // How a joined plan changes a row's eligibility: by inserting or deleting + // its marker, or by pointing the row's join key at a marker or at none. + // A join-key change leaves the row's order value as it was. + via?: `marker` | `link` + indexed: boolean + direction: `asc` | `desc` + // Where null ranks sort, and a full join's rootless rows with them. The + // index uses the same placement. Omitted means first, the default. + nulls?: `first` | `last` + // Filler rows sort after every domain row. Omitted means 200; a full-join + // case may have none, so its window can reach the null group. + fillers?: number + // Full join only: markers whose id names no root row. + orphans?: Array + terms: 1 | 2 + limit: number + offset: number + initial: Array<{ id: number; rank: number; eligible: boolean }> + history: Array +} + +export const EAGER_FILLER = 200 + +export function fillerRows( + direction: `asc` | `desc`, + count: number = EAGER_FILLER, +): Array { + // Eligible rows that sort after every domain row, so a correct window never + // needs them and a full rescan must deliver them. + return Array.from({ length: count }, (_, index) => ({ + id: 1000 + index, + rank: direction === `asc` ? 100 + index : -100 - index, + eligible: true, + label: `filler`, + })) +} + +export function eagerRowOf(maxId: number) { + return fc.record({ + id: fc.integer({ min: 0, max: maxId }), + // NaN and null exercise comparator placement outside the integer order. + rank: fc.oneof( + { weight: 6, arbitrary: fc.integer({ min: 0, max: 15 }) }, + { weight: 1, arbitrary: fc.constant(Number.NaN) }, + { weight: 1, arbitrary: fc.constant(null as unknown as number) }, + { weight: 1, arbitrary: fc.constant(undefined as unknown as number) }, + ), + eligible: fc.boolean(), + }) +} + +export const eagerRow = eagerRowOf(29) + +export function eagerHistory( + flipWeight: number, + maxId: number, +): fc.Arbitrary> { + return fc.array( + fc.oneof( + eagerRowOf(maxId).map((row) => ({ type: `upsert` as const, ...row })), + fc.record({ + type: fc.constant(`delete` as const), + id: fc.integer({ min: 0, max: maxId }), + }), + fc.record({ + type: fc.constant(`touch` as const), + id: fc.integer({ min: 0, max: maxId }), + }), + { + weight: flipWeight, + arbitrary: fc.record({ + type: fc.constant(`flip` as const), + id: fc.integer({ min: 0, max: maxId }), + }), + }, + fc.record({ + type: fc.constant(`dup` as const), + id: fc.integer({ min: 0, max: maxId }), + }), + // Deletes the first visible row, so a repair runs on most histories. + fc.constant({ type: `delete-visible` as const }), + fc.constant({ type: `refetch` as const }), + fc.constant({ type: `answer` as const }), + fc.record({ + type: fc.constant(`move` as const), + offset: fc.integer({ min: 0, max: 2 }), + limit: fc.integer({ min: 1, max: 4 }), + }), + ), + { minLength: 1, maxLength: 12 }, + ) +} + +// Every dimension, before the campaign exclusion below. Derived campaigns +// start from this and apply the exclusion after their own maps. +export const eagerCaseBase: fc.Arbitrary = fc.record({ + path: fc.constantFrom( + `sync` as const, + `local-only` as const, + `on-demand` as const, + ), + provider: fc.constantFrom( + `forward` as const, + `refetch` as const, + `refetch-signal` as const, + ), + latency: fc.constantFrom(`immediate` as const, `held` as const), + cancellation: fc.constantFrom(`cooperative` as const, `late` as const), + // One case in four has a peer, so most cases keep the work laws. + peer: fc.oneof( + { weight: 3, arbitrary: fc.constant(false) }, + { weight: 1, arbitrary: fc.constant(true) }, + ), + filter: fc.constantFrom( + `source` as const, + `function` as const, + `joined` as const, + `anti-joined` as const, + `full-joined` as const, + ), + via: fc.constantFrom(`marker` as const, `link` as const), + indexed: fc.boolean(), + direction: fc.constantFrom(`asc` as const, `desc` as const), + nulls: fc.constantFrom(`first` as const, `last` as const), + terms: fc.constantFrom(1 as const, 2 as const), + limit: fc.integer({ min: 1, max: 3 }), + offset: fc.integer({ min: 0, max: 1 }), + initial: fc.uniqueArray(eagerRow, { + selector: (row) => row.id, + maxLength: 20, + }), + history: eagerHistory(1, 29), +}) + +// A peer over a refetching on-demand provider can keep a short joined window +// 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 +// keeps its peer. +export function withoutPeerRefetch(testCase: EagerCase): EagerCase { + return testCase.path === `on-demand` && + (testCase.provider === `refetch` || + testCase.provider === `refetch-signal`) && + testCase.peer && + testCase.filter !== `source` + ? { ...testCase, peer: false } + : testCase +} + +export const eagerCase = eagerCaseBase.map(withoutPeerRefetch) + +// The premise of the joined-filter law: an indexed single-term plan whose +// filter drops rows after the bounded read. +// A repair before the gap forms reads the whole source for a joined plan, so +// no row can be missing afterwards. These histories therefore insert fresh +// keys (10 to 19), which can land past the bounded prefix, and change initial +// rows (0 to 9) mostly by eligibility alone, so a window row leaves only +// through the join. +export const eagerJoinedHistory: fc.Arbitrary> = fc.array( + fc.oneof( + { + weight: 2, + // Ids 10 to 29: fresh rows, and the root rows of a full join's orphan + // markers (20 to 29), so an orphan can become matched. + arbitrary: eagerRowOf(19).map((row) => ({ + type: `upsert` as const, + ...row, + id: row.id + 10, + })), + }, + { + weight: 3, + arbitrary: fc.record({ + type: fc.constant(`flip` as const), + id: fc.integer({ min: 0, max: 9 }), + }), + }, + fc.record({ + type: fc.constant(`dup` as const), + id: fc.integer({ min: 0, max: 9 }), + }), + fc.record({ + type: fc.constant(`touch` as const), + id: fc.integer({ min: 0, max: 19 }), + }), + fc.record({ + type: fc.constant(`delete` as const), + id: fc.integer({ min: 0, max: 19 }), + }), + fc.constant({ type: `delete-visible` as const }), + fc.constant({ type: `refetch` as const }), + fc.constant({ type: `answer` as const }), + fc.record({ + type: fc.constant(`move` as const), + offset: fc.integer({ min: 0, max: 2 }), + limit: fc.integer({ min: 1, max: 4 }), + }), + ), + { minLength: 1, maxLength: 8 }, +) + +// Its histories favor eligibility-only changes: a row inserted past the +// bounded prefix, then a window row leaving only through the join. +export const eagerJoinedCase: fc.Arbitrary = fc + .tuple( + eagerCaseBase, + fc.constantFrom( + `joined` as const, + `anti-joined` as const, + `full-joined` as const, + ), + fc.uniqueArray(eagerRowOf(9), { + selector: (row) => row.id, + minLength: 4, + maxLength: 8, + }), + eagerJoinedHistory, + ) + .map(([testCase, filter, initial, history]) => ({ + ...testCase, + filter, + indexed: true, + terms: 1 as const, + initial, + history, + })) + .map(withoutPeerRefetch) + +// The law's premise directly: eligible rows at distinct ranks, so the bounded +// prefix leaves rows unsent; a fresh row that may land past the prefix; then +// an initial row changes eligibility alone, so it can leave the window only +// through the join. Random commands may follow. +export const gapRank: fc.Arbitrary = fc.oneof( + { weight: 6, arbitrary: fc.integer({ min: 0, max: 15 }) }, + { weight: 1, arbitrary: fc.constant(Number.NaN) }, + { weight: 1, arbitrary: fc.constant(null as unknown as number) }, + { weight: 1, arbitrary: fc.constant(undefined as unknown as number) }, +) + +export const eagerJoinedGapCase: fc.Arbitrary = fc + .record({ + base: eagerJoinedCase, + // A null or NaN rank can end the bounded prefix. Neither is an + // expressible cursor, so the loader takes its prefix fallback there. + ranks: fc.uniqueArray(gapRank, { + minLength: 4, + maxLength: 8, + selector: String, + }), + insertRank: gapRank, + flipped: fc.nat(7), + rest: eagerJoinedHistory, + }) + .map(({ base, ranks, insertRank, flipped, rest }) => ({ + ...base, + initial: ranks.map((rank, id) => ({ id, rank, eligible: true })), + history: [ + { type: `upsert` as const, id: 10, rank: insertRank, eligible: true }, + { type: `flip` as const, id: flipped % ranks.length }, + ...rest, + ], + })) + .map(withoutPeerRefetch) + +// The full-join law's premise: a window that can reach the null group, where +// rootless rows and root rows with a null rank tie on the first order term. +// Cases favor no filler rows, so a few domain rows fill the window. Initial +// orphan markers (ids 20 to 29) start as rootless rows; root deletes and +// eligibility changes create and remove more. +export const eagerFullJoinCase: fc.Arbitrary = fc + .record({ + base: eagerCaseBase, + fillers: fc.oneof( + { weight: 3, arbitrary: fc.constant(0) }, + { weight: 1, arbitrary: fc.constant(EAGER_FILLER) }, + ), + initial: fc.uniqueArray( + fc.record({ id: fc.nat(9), rank: gapRank, eligible: fc.boolean() }), + { minLength: 2, maxLength: 8, selector: (row) => row.id }, + ), + orphans: fc.uniqueArray(fc.integer({ min: 20, max: 29 }), { + minLength: 1, + maxLength: 3, + }), + history: eagerJoinedHistory, + }) + .map(({ base, fillers, initial, orphans, history }) => ({ + ...base, + filter: `full-joined` as const, + via: `marker` as const, + indexed: true, + fillers, + // Every case has a matched root row with a null rank and at least one + // orphan marker, so the null group holds both kinds of row. + initial: [ + ...initial.filter(({ id }) => id !== 9), + { id: 9, rank: null as unknown as number, eligible: true }, + ], + orphans, + history, + })) + .map(withoutPeerRefetch) + +// The in-flight premise: a holding on-demand provider, and a history that +// first deletes a visible row, so the refill request is in flight while the +// later commands change rows. Releases answer it from the rows at that +// moment, between those changes. +// The proven-report premise: a peer over a signalling refetch provider and a +// source filter, so the peer installs the row after the first query's page. +// Histories upsert rows that tie with the peer's end or sort past both +// windows, then refetch, so a report fires while the first query's own page +// and tie stay the same; later moves check that it owes no repair. +export const eagerProvenReportCase: fc.Arbitrary = fc + .record({ + base: eagerCaseBase, + ranks: fc.uniqueArray(fc.integer({ min: 0, max: 15 }), { + minLength: 3, + maxLength: 6, + }), + upserts: fc.array( + fc.record({ id: fc.integer({ min: 10, max: 19 }), offset: fc.nat(3) }), + { minLength: 1, maxLength: 3 }, + ), + rest: eagerJoinedHistory, + }) + .map(({ base, ranks, upserts, rest }) => { + const sorted = [...ranks].sort((left, right) => left - right) + const size = base.offset + base.limit + // Ranks at or after the peer's end, so the first query's page keeps its + // rows while the peer's answers change. + const peerEnd = sorted[Math.min(size, sorted.length - 1)]! + return { + ...base, + path: `on-demand` as const, + provider: `refetch-signal` as const, + latency: `immediate` as const, + peer: true, + filter: `source` as const, + indexed: true, + direction: `asc` as const, + terms: 1 as const, + initial: sorted.map((rank, id) => ({ id, rank, eligible: true })), + history: [ + ...upserts.map(({ id, offset }) => ({ + type: `upsert` as const, + id, + rank: peerEnd + offset, + eligible: true, + })), + { type: `refetch` as const }, + ...rest, + ], + } + }) + +export const eagerHeldCase: fc.Arbitrary = fc + .tuple( + eagerCaseBase, + // A change that lands while the refill is in flight: an eligible row + // with an id no other command uses (40 to 49), so the forwarding + // provider always sends it. + eagerRowOf(9).map((row) => ({ + type: `upsert` as const, + ...row, + id: row.id + 40, + eligible: true, + })), + fc.array( + fc.oneof( + { weight: 3, arbitrary: eagerHistory(1, 29).map(([first]) => first!) }, + { weight: 2, arbitrary: fc.constant({ type: `answer` as const }) }, + { + weight: 1, + arbitrary: fc.constant({ type: `release-peer` as const }), + }, + { + weight: 1, + arbitrary: fc.constant({ type: `delete-visible` as const }), + }, + ), + { minLength: 2, maxLength: 10 }, + ), + ) + .map(([testCase, change, rest]) => ({ + ...testCase, + path: `on-demand` as const, + // 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 fixed, this campaign needs no peer exclusion (#2155 concerns + // only a refetching provider). + provider: `forward` as const, + filter: + testCase.filter === `function` || testCase.filter === `full-joined` + ? (`source` as const) + : testCase.filter, + indexed: + testCase.filter === `function` || + testCase.filter === `full-joined` || + testCase.filter === `source` + ? true + : testCase.indexed, + // One order term, so the refill pages by cursor. + terms: 1 as const, + latency: `held` as const, + // A loaded prefix can hold headroom rows, so three visible deletes make + // a refill request likely before the change. + history: [ + { type: `delete-visible` as const }, + { type: `delete-visible` as const }, + { type: `delete-visible` as const }, + change, + ...rest, + ], + })) + +export function isJoinedFilter({ filter }: EagerCase): boolean { + return filter === `joined` || filter === `anti-joined` +} + +export function eagerOrder( + rows: ReadonlyMap, + { direction, nulls }: EagerCase, +): Array { + // Documented order (live-queries guide): nulls first in either direction + // unless the query asks for them last; NaN is greater than every other + // non-null value; ties by ascending id. `null` and `undefined` are both + // nullish, so they tie with each other. + const position = (rank: number | null | undefined): [number, number] => { + if (rank == null) return [nulls === `last` ? 3 : 0, 0] + if (Number.isNaN(rank)) return [direction === `asc` ? 2 : 1, 0] + return [direction === `asc` ? 1 : 2, direction === `asc` ? rank : -rank] + } + return [...rows.values()] + .filter((row) => row.eligible) + .sort((left, right) => { + const [leftGroup, leftValue] = position(left.rank) + const [rightGroup, rightValue] = position(right.rank) + return ( + leftGroup - rightGroup || leftValue - rightValue || left.id - right.id + ) + }) +} + +export function eagerWindow( + rows: ReadonlyMap, + testCase: EagerCase, +): Array { + return eagerOrder(rows, testCase) + .slice(testCase.offset, testCase.offset + testCase.limit) + .map((row) => row.id) +} + +/** + * The visible window agrees with the model. The guide promises order only by + * the query's `orderBy` terms; rows that tie on every term may appear in any + * order. A source-filter plan's ties follow the source key, which the model + * also uses, so its window must match exactly. A joined plan's ties follow the + * joined result key, so its window must be any valid first `limit` rows: the + * same order values in sequence, each from an eligible row with that value. + */ +export function expectEagerWindow( + actual: Array, + rows: ReadonlyMap, + testCase: EagerCase, + label: string, +): void { + const expected = eagerWindow(rows, testCase) + if (!isJoinedFilter(testCase) && testCase.filter !== `full-joined`) { + compareAt(label, () => expect(actual, label).toEqual(expected)) + return + } + // `null` and `undefined` tie, so either may come first among nullish rows. + const rank = (id: number) => { + const row = rows.get(id) + return row?.eligible + ? ((row.rank as number | null | undefined) ?? null) + : `ineligible ${id}` + } + compareAt(label, () => + expect(actual.map(rank), label).toEqual(expected.map(rank)), + ) + compareAt(`${label}: distinct rows`, () => + expect(new Set(actual).size, `${label}: distinct rows`).toBe(actual.length), + ) +} + +/** + * Whether a bounded prefix of the source leaves the window short. The prefix + * is the first `offset + limit` source rows in the query's order. A source + * filter runs inside the request, so its prefix holds only eligible rows. A + * joined or function filter drops rows only after the source's limit, so its + * prefix can hold fewer eligible rows than the window needs. + */ +export function prefixLeavesWindowShort( + rows: ReadonlyMap, + testCase: EagerCase, +): boolean { + const size = testCase.offset + testCase.limit + const candidates = + testCase.filter === `source` + ? rows + : new Map( + [...rows].map(([rowId, row]) => [rowId, { ...row, eligible: true }]), + ) + const prefix = eagerOrder(candidates, testCase).slice(0, size) + return prefix.filter(({ id }) => rows.get(id)!.eligible).length < size +} + +/** Ranks of the last window row and the first row after the window. */ +export function boundaryRanks( + rows: ReadonlyMap, + testCase: EagerCase, +): Array { + const end = testCase.offset + testCase.limit + // Generated rows may carry a null rank behind the `number` row type. + return eagerOrder(rows, testCase) + .slice(end - 1, end + 1) + .map((row): number | null => row.rank) +} + +export type OptimisticWindowShape = `source` | `joined` | `anti-joined` +export type OptimisticWindowMutation = `update` | `delete` +export type OptimisticWindowConsumer = `collection` | `effect` +export type OptimisticWindowOutcome = `acknowledge` | `rollback` +export type OptimisticWindowExtent = `missing` | `installed` | `non-prefix` +export type OptimisticWindowAcquisition = `required` | `forbidden` | `allowed` +export type OptimisticWindowCheckpoint = + `initial` | `optimistic` | `settled` | `restored` +export const MAX_OPTIMISTIC_WINDOW_REQUESTS = 8 +export const OPTIMISTIC_ACQUISITION_OBSERVATION_TURNS = 8 + +// The most rows a bounded refill may ask for. An indexed plan pages past +// the window. An unindexed plan grows its prefix to twice the rows it +// installed (ARCHITECTURE.md, "an underfilled unindexed prefix grows"), and +// a request also covers the one row that the pending transaction overlays. +export function refillBound({ + indexed, + width, + installedIds, +}: OptimisticWindowCase) { + return indexed ? width + 2 : 2 * installedIds.length + 1 +} + +export type OptimisticWindowCase = { + shape: OptimisticWindowShape + mutation: OptimisticWindowMutation + consumer: OptimisticWindowConsumer + outcome: OptimisticWindowOutcome + width: 1 | 2 + indexed: boolean + extent: OptimisticWindowExtent + installedIds: Array + acquisition: OptimisticWindowAcquisition + answerTiming?: `before-settlement` | `after-settlement` + mutationSource?: `root` | `marker` + rankMove?: boolean + rankDeparture?: boolean + direction?: `asc` | `desc` +} + +export type OptimisticWindowPublication = { + rows: Array +} + +export function copyOptimisticWindowPublication( + publication: OptimisticWindowPublication, +): OptimisticWindowPublication { + return { rows: [...publication.rows] } +} + +export type OptimisticWindowObservation = { + initial: Array + immediate: Array + acquiring: Array + answerQueued: Array + settled: Array + afterAnswer?: Array + afterRestore?: Array + afterRestorePublications?: Array + rowsAfterFirstTurn: Array + rowsAfterSecondTurn: Array + immediatePublications: Array + preSettlementPublications: Array + publicationsAtSettlement: Array + publicationsAfterSettlement: Array + attempts: Array<{ + submittedRows: Array + limit: number | undefined + direction: string | undefined + // The request had a limit or a predicate, so it read no full source. + bounded?: boolean + appliedReceiptPending: boolean + providerPending: boolean + }> + acquisitionStartedBeforeSettlement?: boolean + requestHistory?: Array<{ + identity: string + newRows: number + phase?: string + submittedRows?: Array + limit?: number + direction?: string + allowsRejectedRoot?: boolean + selectedRanks?: Array + }> + settlementOutcome: `fulfilled` | `rejected` +} + +export function referenceOptimisticWindow( + testCase: OptimisticWindowCase, + checkpoint: OptimisticWindowCheckpoint, +): Array { + // All three predicates accept 1, 2, and 3 in the remote ledger; 4 is the + // rejected leading root. The model starts from installed keys, applies the + // one overlay, and recomputes the window without the loader's prefix logic. + const authoritative = + (checkpoint === `settled` || checkpoint === `restored`) && + testCase.outcome === `acknowledge` + const installed = authoritative + ? testCase.rankMove + ? [1, 2, 3, 4] + : testCase.mutationSource === `marker` + ? [1, 2, 3, 4] + : [1, 2, 4] + : testCase.installedIds + const overlayActive = checkpoint === `optimistic` + const markerDeparturePersisted = + checkpoint === `settled` && + testCase.outcome === `acknowledge` && + testCase.mutationSource === `marker` + return installed + .filter( + (id) => + id !== 4 && + !( + (overlayActive || markerDeparturePersisted) && + id === 3 && + (!testCase.rankMove || testCase.rankDeparture) + ), + ) + .sort((left, right) => { + const moved = (overlayActive || authoritative) && testCase.rankMove + const leftRank = moved && left === 3 ? 0 : left + const rightRank = moved && right === 3 ? 0 : right + return rightRank - leftRank + }) + .slice(0, testCase.width) +} + +export function optimisticWindowViolations( + observation: OptimisticWindowObservation, + testCase: OptimisticWindowCase, +): Array { + const violations: Array = [] + const { outcome, width } = testCase + const optimistic = referenceOptimisticWindow(testCase, `optimistic`) + const settled = referenceOptimisticWindow(testCase, `settled`) + const lateAnswer = testCase.answerTiming === `after-settlement` + const expectedAtSettlement = + lateAnswer && outcome === `acknowledge` ? optimistic : settled + const compare = (label: string, actual: unknown, expected: unknown) => { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + violations.push( + `${label}: ${JSON.stringify(actual)} instead of ${JSON.stringify(expected)}`, + ) + } + } + compare(`immediate rows`, observation.immediate, optimistic) + compare( + `immediate publications`, + observation.immediatePublications.map(({ rows }) => rows), + [optimistic], + ) + compare(`acquiring rows`, observation.acquiring, optimistic) + compare(`queued-answer rows`, observation.answerQueued, optimistic) + compare( + `first optimistic publication`, + observation.preSettlementPublications[0]?.rows, + optimistic, + ) + compare( + `other pre-settlement publications`, + observation.preSettlementPublications.slice(1).map(({ rows }) => rows), + [], + ) + compare(`settled rows`, observation.settled, expectedAtSettlement) + if (lateAnswer) + compare(`rows after late answer`, observation.afterAnswer, settled) + if (testCase.mutationSource === `marker` && outcome === `acknowledge`) { + compare( + `rows after marker restoration`, + observation.afterRestore, + referenceOptimisticWindow(testCase, `restored`), + ) + compare( + `marker restoration publications`, + observation.afterRestorePublications?.map(({ rows }) => rows), + [referenceOptimisticWindow(testCase, `restored`)], + ) + } + compare( + `rows after first settlement turn`, + observation.rowsAfterFirstTurn, + settled, + ) + compare( + `rows after second settlement turn`, + observation.rowsAfterSecondTurn, + settled, + ) + const expectedSettledPublications = + JSON.stringify(optimistic) === JSON.stringify(expectedAtSettlement) + ? [] + : [expectedAtSettlement] + compare( + `settled publications`, + observation.publicationsAtSettlement + .slice(observation.preSettlementPublications.length) + .map(({ rows }) => rows), + expectedSettledPublications, + ) + compare( + `post-settlement publications`, + observation.publicationsAfterSettlement.map(({ rows }) => rows), + lateAnswer && outcome === `acknowledge` ? [settled] : [], + ) + const needsAcquisition = testCase.acquisition === `required` + if ( + needsAcquisition && + observation.acquisitionStartedBeforeSettlement === false + ) { + violations.push(`refill acquisition did not start before settlement`) + } + if (needsAcquisition && observation.attempts.length === 0) { + violations.push(`refill acquisition did not start`) + } + const refillId = width === 1 ? 2 : 1 + const refillAttempt = observation.attempts.find(({ submittedRows }) => + submittedRows.includes(refillId), + ) + if (needsAcquisition && !refillAttempt) { + violations.push(`refill answer did not submit row ${refillId}`) + } else if ( + needsAcquisition && + (refillAttempt?.direction !== (testCase.direction ?? `desc`) || + refillAttempt.limit === undefined || + refillAttempt.limit > refillBound(testCase)) + ) { + violations.push(`refill used an unbounded provider request`) + } else if ( + !lateAnswer && + refillAttempt && + !refillAttempt.appliedReceiptPending + ) { + violations.push(`applied receipt was not pending at the queued-answer cut`) + } + if (!lateAnswer && refillAttempt && !refillAttempt.providerPending) { + violations.push(`provider was not pending at the queued-answer cut`) + } + if (testCase.acquisition === `forbidden` && observation.attempts.length > 0) { + violations.push(`refill acquisition started with complete local extent`) + } + // A refill the case allows but does not require is still bounded: an + // ordered read within the refill bound, or a predicate read, never the + // full source. + if (testCase.acquisition === `allowed`) { + for (const attempt of observation.attempts) { + if ( + attempt.bounded === false || + (attempt.direction !== undefined && + (attempt.limit === undefined || + attempt.limit > refillBound(testCase))) + ) { + violations.push(`an allowed refill used an unbounded provider request`) + break + } + } + } + const requested = new Set() + for (const request of observation.requestHistory ?? []) { + if (request.phase === `pending` && testCase.indexed) { + const finiteOrdered = + request.direction === (testCase.direction ?? `desc`) && + request.limit !== undefined && + request.limit <= width + 2 + const boundaryTie = + request.direction === undefined && + request.selectedRanks !== undefined && + new Set(request.selectedRanks).size === 1 + if (!finiteOrdered && !boundaryTie) { + violations.push(`optimistic acquisition used a broad provider request`) + } + if (testCase.shape === `source` && request.allowsRejectedRoot) { + violations.push(`source refill omitted the source predicate`) + } + } + if (requested.has(request.identity) && request.newRows === 0) { + violations.push(`repeated an unchanged provider request without progress`) + break + } + requested.add(request.identity) + } + compare( + `settlement outcome`, + observation.settlementOutcome, + outcome === `acknowledge` ? `fulfilled` : `rejected`, + ) + return violations +} 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 bdce2804fb..820220a40c 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -1,18 +1,9 @@ +import { isDeepStrictEqual } from 'node:util' import { fc } from '@fast-check/vitest' -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it, - vi, -} from 'vitest' -import { toArray } from '../../src/query/index.js' +import { afterAll, afterEach, beforeEach, describe, expect, vi } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { localOnlyCollectionOptions } from '../../src/local-only.js' import { createDeferred } from '../../src/deferred.js' -import { LoadSubsetOperationAbortedError } from '../../src/errors.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { createEffect } from '../../src/query/effect.js' import { getLoadSubsetDemandKey } from '../../src/query/ir-stable-identity.js' @@ -23,10 +14,8 @@ import { oracleFixedSeed, oracleProperty, oracleReplayRequested, - oracleRoute, readOracleRunConfig, } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { flushPromises } from '../utils.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' @@ -34,6 +23,7 @@ import { compareAt, observeComparison, recordedComparison, + rejectAt, } from './ordered-comparison-oracle.js' import { receiveOrderedCompilerCacheHistory } from './ordered-compiler-cache-driver.js' import { receiveOrderedJoinedPlanHistory } from './ordered-joined-plan-driver.js' @@ -46,9 +36,41 @@ import { requestFingerprint, rowsForScenario, } from './ordered-work-support.js' +import { copiedRow } from './ordered-work-fixtures.js' +import { + FULL_JOIN_SEED, + MAX_OPTIMISTIC_WINDOW_REQUESTS, + OPTIMISTIC_ACQUISITION_OBSERVATION_TURNS, + boundaryRanks, + copyOptimisticWindowPublication, + eagerCase, + eagerFullJoinCase, + eagerHeldCase, + eagerJoinedCase, + eagerJoinedGapCase, + eagerOrder, + eagerProvenReportCase, + eagerWindow, + exhaustiveScenarios, + expectEagerWindow, + fillerRows, + isJoinedFilter, + optimisticWindowViolations, + prefixLeavesWindowShort, + referenceOptimisticWindow, + scenarioArbitrary, +} from './ordered-work-model-oracle.js' import type { Marker, Row, Scenario } from './ordered-work-support.js' +import type { + EagerCase, + EagerCommand, + OptimisticWindowCase, + OptimisticWindowExtent, + OptimisticWindowObservation, + OptimisticWindowPublication, +} from './ordered-work-model-oracle.js' import type { InitialQueryBuilder } from '../../src/query/builder/index.js' -import type { DeltaEvent } from '../../src/query/effect.js' +import type { Observation } from '../oracle-config.js' import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' /** @@ -130,56 +152,12 @@ type ConsumerObservation = { live: boolean } -// Grammar for the generated consumer-parity law: zero to three middle rows, -// either marker eligibility for middle and last rows, equal versus distinct -// middle ranks, and both order directions. Every combination is legal and the -// exhaustive matrix reconstructs all 64 cells before random sampling. Removing -// middleCount loses empty and refill histories; removing either eligibility -// bit loses matched/underfilled alternatives; removing tied loses the leading -// tie boundary; removing direction loses descending continuation. Counts four -// and 40 are an adjacent fixed range probe and a larger stress probe below. -// Negative counts, duplicate row IDs, and directions other than asc/desc are -// invalid in this grammar. -// "Scenario" is a model-only input description, not a runtime demand state. -const scenarioArbitrary: fc.Arbitrary = fc.record({ - middleCount: fc.constantFrom(0 as const, 1 as const, 2 as const, 3 as const), - middleEligible: fc.boolean(), - lastEligible: fc.boolean(), - tied: fc.boolean(), - direction: fc.constantFrom(`asc` as const, `desc` as const), -}) - -const exhaustiveScenarios: ReadonlyArray = ( - [0, 1, 2, 3] as const -).flatMap((middleCount) => - [false, true].flatMap((middleEligible) => - [false, true].flatMap((lastEligible) => - [false, true].flatMap((tied) => - ([`asc`, `desc`] as const).map((direction) => ({ - middleCount, - middleEligible, - lastEligible, - tied, - direction, - })), - ), - ), - ), -) - function matchesJoinFilter(row: Row, joinFilter: JoinFilter): boolean { return joinFilter === `none` ? !row.eligible : row.eligible } let harnessId = 0 -function copiedRow(row: Row): Row { - const copy: Record = { ...row } - for (const field of [`$key`, `$collectionId`, `$origin`, `$hasPendingWrites`]) - delete copy[field] - return copy as Row -} - async function observeConsumer( kind: `collection` | `effect`, scenario: Scenario, @@ -875,2620 +853,628 @@ async function observeFinitePrefixMutation( ) } -async function observeMutationDuringBoundedRepair(): Promise<{ - rows: Array - requests: number - publications: Array> -}> { - const truth = new Map([ - [1, { id: 1, rank: 1, eligible: true, label: `first` }], - [2, { id: 2, rank: 2, eligible: true, label: `second` }], - [3, { id: 3, rank: 3, eligible: true, label: `third` }], - ]) - const delivered = new Set() - const repairPrefixStarted = createDeferred() - const releaseRepairPrefix = createDeferred() - const repairTieStarted = createDeferred() - const releaseRepairTie = createDeferred() - const publications: Array> = [] - let heldRepairPrefix = false - let heldRepairTie = false - let requests = 0 - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: Row - }) => void - let commit!: () => true | Promise +// Registration transports an existing judgment. The named function runs +// production with its authored comparisons and models; observeComparison +// forwards only a comparison marked with compareAt as a verdict, and keeps any +// other error as a driver error. Each history is wrapped in a plan object, +// which keys that judgment. +function orderedWorkJudgment( + judge: (history: THistory) => Promise, +) { + return { + resolve: (history: THistory) => ({ history }), + model: ( + plan: { history: THistory }, + observations: ReadonlyArray, + ) => recordedComparison(plan, observations), + driver: ( + plan: { history: THistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => observeComparison(plan, observe, () => judge(plan.history)), + } +} - const source = createCollection({ - id: `ordered-mutation-during-repair-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - const apply = async (options: LoadSubsetOptions) => { - let selected = [...truth.values()].filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === true) && - (!options.cursor || - evaluateReferenceExpression(options.cursor.whereFrom, row) === - true), - ) - selected.sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - const offset = options.cursor ? 0 : (options.offset ?? 0) - selected = selected.slice( - offset, - options.limit === undefined ? undefined : offset + options.limit, - ) - const additions = selected.filter(({ id }) => !delivered.has(id)) - if (additions.length === 0) return - begin() - for (const row of additions) { - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - const receipt = commit() - if (receipt !== true) await receipt - } - return { - loadSubset: (options: LoadSubsetOptions) => { - requests++ - const isRepairPrefix = - options.refetch === true && - options.orderBy !== undefined && - options.cursor === undefined - if (isRepairPrefix && !heldRepairPrefix) { - heldRepairPrefix = true - repairPrefixStarted.resolve() - return releaseRepairPrefix.promise.then(() => apply(options)) - } - const isRepairTie = - options.refetch === true && - options.orderBy === undefined && - options.where !== undefined - if (isRepairTie && !heldRepairTie) { - heldRepairTie = true - repairTieStarted.resolve() - return releaseRepairTie.promise.then(() => apply(options)) - } - return apply(options) - }, - } - }, +describe(`ordered source work oracle`, () => { + async function runOrderTermParity(): Promise { + const [collection, effect] = await Promise.all([ + observeLaterOrderTermMutation(`collection`), + observeLaterOrderTermMutation(`effect`), + ]) + expect(effect.rows).toEqual(collection.rows) + // The two graph entry points may take a different bounded number of + // refinement passes, but they must exercise the same demand forms. + expect(new Set(effect.requests)).toEqual(new Set(collection.requests)) + } + oracleProperty({ + id: `ordered-work.work-order-term-parity`, + law: { + statement: `A live Collection and an Effect keep equal rows and exercise the same demand forms after a later order-term invalidation.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `One fixed history through the controlled on-demand adapter.`, }, + 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', + ], + histories: [`keeps later order-term invalidation equal across consumers`], + label: (name) => name, + ...orderedWorkJudgment(runOrderTermParity), + check: (_plan, _observations, verdict) => verdict, }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - ) - const subscription = live.subscribeChanges(() => { - publications.push(live.toArray.map(({ id }) => id)) - }) - - return withHistoryCleanup( - async () => { - await live.preload() - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - publications.length = 0 - - const first = { ...truth.get(1)!, rank: 10 } - truth.set(1, first) - begin() - write({ type: `update`, value: { ...first } }) - const firstReceipt = commit() - if (firstReceipt !== true) await firstReceipt - await repairPrefixStarted.promise - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - releaseRepairPrefix.resolve() - await repairTieStarted.promise - - const second = { ...truth.get(2)!, rank: 20 } - truth.set(2, second) - begin() - write({ type: `update`, value: { ...second } }) - const secondReceipt = commit() - if (secondReceipt !== true) await secondReceipt - expect(live.toArray.map(({ id }) => id)).toEqual([1]) + const finitePrefixParityHistories = [`move`, `delete`] as const + async function runFinitePrefixParity( + mutation: (typeof finitePrefixParityHistories)[number], + ): Promise { + const [collection, effect] = await Promise.all([ + observeFinitePrefixMutation(`collection`, mutation), + observeFinitePrefixMutation(`effect`, mutation), + ]) - releaseRepairTie.resolve() - await vi.waitFor(() => - expect(live.toArray.map(({ id }) => id)).toEqual([3]), - ) - expect(publications).toEqual([[3]]) - expect(requests).toBeGreaterThanOrEqual(6) - return { - rows: live.toArray.map(({ id }) => id), - requests, - publications: publications.map((rows) => [...rows]), - } + expect(effect.rows).toEqual(collection.rows) + expect(effect.publications.at(-1)).toEqual(collection.publications.at(-1)) + } + oracleProperty({ + id: `ordered-work.work-finite-prefix-parity`, + law: { + statement: `A live Collection and an Effect recover a finite source prefix to equal rows and an equal final publication after a move or a delete.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The move and delete histories through the controlled on-demand adapter.`, }, - () => [ - () => releaseRepairPrefix.resolve(), - () => releaseRepairTie.resolve(), - () => subscription.unsubscribe(), - () => live.cleanup(), - () => source.cleanup(), + 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', ], - ) -} - -async function observeHeldEffectRepair( - repairPrefix: `synchronous` | `asynchronous`, -): Promise>> { - const truth: Array = [ - { id: 1, rank: 1, eligible: true, label: `first` }, - { id: 2, rank: 2, eligible: true, label: `second` }, - ] - const repairStarted = createDeferred() - const releaseRepair = createDeferred() - const effectRows = new Set() - const publications: Array> = [] - let recovering = false - let loadCount = 0 - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: Row - }) => void - let commit!: () => void - - const source = createCollection({ - id: `ordered-held-effect-repair-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - const apply = (options: LoadSubsetOptions) => { - const selected = truth - .filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === true) && - (!options.cursor || - evaluateReferenceExpression(options.cursor.whereFrom, row) === - true), - ) - .sort((left, right) => left.rank - right.rank || left.id - right.id) - .slice( - options.offset ?? 0, - options.limit === undefined - ? undefined - : (options.offset ?? 0) + options.limit, - ) - begin() - for (const row of selected) { - write({ type: `insert`, value: { ...row } }) - } - commit() - } - return { - loadSubset: (options) => { - loadCount++ - const isRepair = - recovering && - options.orderBy !== undefined && - options.limit === 1 && - options.cursor === undefined - if (!isRepair) { - apply(options) - return true - } - if (repairPrefix === `synchronous`) { - apply(options) - return true - } - repairStarted.resolve() - return releaseRepair.promise.then(() => apply(options)) - }, - } - }, - }, - }) - const effect = createEffect({ - query: (q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - onBatch: (events) => { - for (const event of events) { - if (event.type === `exit`) effectRows.delete(event.key) - else effectRows.add(event.key) - } - publications.push([...effectRows].sort((left, right) => left - right)) - }, + histories: finitePrefixParityHistories, + label: (mutation) => + `recovers a finite source prefix equally across consumers after ${mutation}`, + ...orderedWorkJudgment(runFinitePrefixParity), + check: (_plan, _observations, verdict) => verdict, }) - return withHistoryCleanup( - async () => { - await vi.waitFor(() => expect([...effectRows]).toEqual([1])) - await vi.waitFor(() => expect(loadCount).toBeGreaterThanOrEqual(2)) - await flushPromises() - publications.length = 0 - recovering = true - const removed = truth.shift()! - begin() - write({ type: `delete`, value: removed }) - commit() - if (repairPrefix === `asynchronous`) { - await repairStarted.promise - await flushPromises() - - expect( - [...effectRows], - `last complete effect result while held`, - ).toEqual([1]) - expect(publications, `no intermediate effect publication`).toEqual([]) - - releaseRepair.resolve() - } - await vi.waitFor(() => expect([...effectRows]).toEqual([2])) - expect(publications).toEqual([[2]]) - return publications.map((rows) => [...rows]) - }, - () => [ - () => releaseRepair.resolve(), - () => effect.dispose(), - () => source.cleanup(), - ], + const valueUpdateHistories = ([`collection`, `effect`] as const).flatMap( + (consumer) => + [2, 5].flatMap((limit) => + ([`first`, `last`] as const).flatMap((position) => + ([`asc`, `desc`] as const).map((direction) => ({ + consumer, + limit, + position, + direction, + })), + ), + ), ) -} - -async function observeEffectAfterObsoleteRepairAbort(): Promise<{ - rows: Array - batches: Array> - finalLabel: string | undefined -}> { - const truth = new Map([ - [1, { id: 1, rank: 1, eligible: true, label: `first` }], - [2, { id: 2, rank: 2, eligible: true, label: `second` }], - ]) - const installed = new Set() - const effectRows = new Map() - const batches: Array> = [] - let repairHeld = false - let requests = 0 - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: Row - }) => void - let truncate!: () => void - let commit!: () => true | Promise - - const source = createCollection({ - id: `ordered-obsolete-effect-repair-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - truncate = operations.truncate - commit = operations.commit - operations.markReady() - const apply = async (options: LoadSubsetOptions) => { - let selected = [...truth.values()].filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === true) && - (!options.cursor || - evaluateReferenceExpression(options.cursor.whereFrom, row) === - true), - ) - selected.sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - const offset = options.cursor ? 0 : (options.offset ?? 0) - selected = selected.slice( - offset, - options.limit === undefined ? undefined : offset + options.limit, - ) - const additions = selected.filter(({ id }) => !installed.has(id)) - if (additions.length === 0) return - begin() - for (const row of additions) { - installed.add(row.id) - write({ type: `insert`, value: { ...row } }) + async function runValueUpdate({ + consumer, + limit, + position, + direction, + }: (typeof valueUpdateHistories)[number]): Promise { + let sync!: Parameters[`sync`]>[0] + let loads = 0 + const rows = rowsForScenario({ + middleCount: 1, + middleEligible: true, + lastEligible: true, + tied: false, + direction, + }) + const source = createCollection({ + id: `ordered-value-update`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: async () => { + loads++ + if (loads > 1) return + operations.begin() + for (const row of rows) { + operations.write({ type: `insert`, value: { ...row } }) + } + const receipt = operations.commit() + if (receipt !== true) await receipt + }, } - const receipt = commit() - if (receipt !== true) await receipt - } - return { - loadSubset: (options: LoadSubsetOptions) => { - requests++ - const isRepairPrefix = - options.refetch === true && - options.orderBy !== undefined && - options.cursor === undefined - if (isRepairPrefix && !repairHeld) { - repairHeld = true - return new Promise((_resolve, reject) => { - const abort = () => - reject(new LoadSubsetOperationAbortedError()) - if (options.signal?.aborted) abort() - else { - options.signal?.addEventListener(`abort`, abort, { - once: true, - }) - } - }) - } - return apply(options) - }, - } + }, }, - }, - }) - const effect = createEffect({ - query: (q) => + }) + const query = (q: InitialQueryBuilder) => q .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - onBatch: (events) => { - const labels: Array = [] - for (const event of events) { - labels.push(`${event.type}:${event.key}`) - if (event.type === `exit`) effectRows.delete(event.key) - else effectRows.set(event.key, copiedRow(event.value)) - } - batches.push(labels) - }, - }) + .orderBy(({ row }) => row.rank, direction) + .limit(limit) + const effectRows = new Map() + const live = + consumer === `collection` ? createLiveQueryCollection(query) : undefined + const effect = + consumer === `effect` + ? createEffect({ + query, + onBatch: (events) => { + for (const event of events) { + if (event.type === `exit`) effectRows.delete(event.key) + else effectRows.set(event.key, event.value) + } + }, + }) + : undefined + const readRows = () => + (live ? [...live.values()] : [...effectRows.values()]) + .map(({ id, rank, eligible, label }) => ({ + id, + rank, + eligible, + label, + })) + .sort(compareRows(direction)) - return withHistoryCleanup( - async () => { - await vi.waitFor(() => expect([...effectRows.keys()]).toEqual([1])) - await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(2)) + try { + if (live) await live.preload() await flushPromises() - batches.length = 0 - - const removed = truth.get(1)! - truth.delete(1) - installed.delete(1) - begin() - write({ type: `delete`, value: removed }) - const deleteReceipt = commit() - if (deleteReceipt !== true) await deleteReceipt - await vi.waitFor(() => expect(repairHeld).toBe(true)) - - installed.clear() - begin() - truncate() - const truncateReceipt = commit() - if (truncateReceipt !== true) await truncateReceipt - await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(5)) + const expected = expectedVisibleWindow(rows, direction, limit) + expect(readRows()).toEqual(expected) + const loadCount = loads + const row = position === `first` ? expected[0]! : expected.at(-1)! + sync.begin() + sync.write({ type: `update`, value: { ...row, label: `changed` } }) + sync.commit() await flushPromises() - const replacement = truth.get(2)! - const updated = { ...replacement, label: `updated after replay` } - truth.set(2, updated) - begin() - write({ type: `update`, value: updated }) - const updateReceipt = commit() - if (updateReceipt !== true) await updateReceipt - await vi.waitFor(() => - expect(effectRows.get(2)?.label).toBe(`updated after replay`), + expect(readRows()).toEqual( + expected.map((value) => + value.id === row.id ? { ...value, label: `changed` } : value, + ), ) - - return { - rows: [...effectRows.keys()], - batches: batches.map((batch) => [...batch]), - finalLabel: effectRows.get(2)?.label, - } + expect(loads).toBe(loadCount) + } finally { + if (effect) await effect.dispose() + if (live) await live.cleanup() + await source.cleanup() + } + } + oracleProperty({ + id: `ordered-work.work-value-update`, + law: { + statement: `A value change outside the ordering key keeps the visible window equal to the model window with the changed value and starts no request.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Both consumers, limits 2 and 5, the first and last window row, and both directions.`, }, - () => [() => effect.dispose(), () => source.cleanup()], - ) -} + 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', + ], + histories: valueUpdateHistories, + label: (history) => + `does not refetch when a visible row changes outside the ordering key: ${JSON.stringify(history)}`, + ...orderedWorkJudgment(runValueUpdate), + check: (_plan, _observations, verdict) => verdict, + }) -async function observeSkipInitialOrderedLoad(): Promise<{ - initialEvents: Array - laterEvents: Array -}> { - const truth: Array = [ - { id: 1, rank: 1, eligible: true, label: `first` }, - { id: 2, rank: 2, eligible: true, label: `second` }, - ] - const firstRequest = createDeferred() - const installed = new Set() - const events: Array = [] - let requests = 0 - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: Row - }) => void - let commit!: () => true | Promise - - const source = createCollection({ - id: `ordered-skip-initial-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - return { - loadSubset: async (options: LoadSubsetOptions) => { - requests++ - if (requests === 1) await firstRequest.promise - let selected = truth.filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === true) && - (!options.cursor || - evaluateReferenceExpression(options.cursor.whereFrom, row) === - true), - ) - selected.sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - const offset = options.cursor ? 0 : (options.offset ?? 0) - selected = selected.slice( - offset, - options.limit === undefined ? undefined : offset + options.limit, - ) - const additions = selected.filter(({ id }) => !installed.has(id)) - if (additions.length === 0) return - begin() - for (const row of additions) { - installed.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - const receipt = commit() - if (receipt !== true) await receipt - }, - } - }, + const antiJoinExistingRowHistories = [ + { + name: `keeps the collection anti-join result when the joined row exists`, + consumer: `collection`, + }, + { + name: `removes a matched parent from the anti-join Effect`, + consumer: `effect`, + }, + ] as const + async function runAntiJoinExistingRow({ + consumer, + }: (typeof antiJoinExistingRowHistories)[number]): Promise { + const observed = await observeConsumer( + consumer, + exhaustiveScenarios[0]!, + `none`, + ) + expect(observed.rows.map(({ id }) => id)).toEqual([2]) + } + oracleProperty({ + id: `ordered-work.work-anti-join-existing-row`, + law: { + statement: `A none-filtered anti-join keeps only the unmatched row when the joined row exists, for a live Collection and an Effect.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The first exhaustive scenario through the shared consumer driver and its window model, for both consumers.`, }, + 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', + ], + histories: antiJoinExistingRowHistories, + label: ({ name }) => name, + ...orderedWorkJudgment(runAntiJoinExistingRow), + check: (_plan, _observations, verdict) => verdict, }) - const effect = createEffect({ - query: (q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - skipInitial: true, - onBatch: (batch) => { - events.push(...batch.map((event) => `${event.type}:${event.key}`)) + + async function runNoneFilterBound(): Promise { + for (const scenario of exhaustiveScenarios) { + const observed = await observeConsumer(`collection`, scenario, `none`) + expect(observed.requests.length).toBeGreaterThan(0) + expect(observed.requests[0]?.kind).toBe(`with-order`) + expect(observed.requests[0]?.limit).toBeGreaterThan(0) + } + } + oracleProperty({ + id: `ordered-work.work-none-filter-bound`, + law: { + statement: `A direct LEFT-joined none filter keeps finite ordered root acquisition across the exhaustive small domain.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The 64 exhaustive scenarios in one history, through the shared consumer driver and its window model.`, }, + 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', + ], + histories: [`bounds ordered root acquisition for a none left-join filter`], + label: (name) => name, + ...orderedWorkJudgment(runNoneFilterBound), + check: (_plan, _observations, verdict) => verdict, }) - return withHistoryCleanup( - async () => { - await vi.waitFor(() => expect(requests).toBe(1)) - firstRequest.resolve() - await vi.waitFor(() => expect(requests).toBeGreaterThanOrEqual(2)) - await flushPromises() - const initialEvents = [...events] - events.length = 0 + const leftJoinBoundHistories = [`joined-only`, `some`, `to-one`] as const + async function runLeftJoinBound( + joinFilter: (typeof leftJoinBoundHistories)[number], + ): Promise { + for (const scenario of exhaustiveScenarios) { + const [collection, effect] = await Promise.all([ + observeConsumer(`collection`, scenario, joinFilter), + observeConsumer(`effect`, scenario, joinFilter), + ]) + expect(collection.rows).toEqual(effect.rows) + expect(collection.errors).toEqual([]) + expect(effect.errors).toEqual([]) - const inserted: Row = { - id: 3, - rank: 0, - eligible: true, - label: `later`, + // A direct LEFT-joined filter retains finite root acquisition. + for (const observation of [collection, effect]) { + expect(observation.requests.length).toBeGreaterThan(0) + expect(observation.requests[0]?.kind).toBe(`with-order`) + expect(observation.requests[0]?.limit).toBeGreaterThan(0) } - truth.push(inserted) - installed.add(inserted.id) - begin() - write({ type: `insert`, value: inserted }) - const receipt = commit() - if (receipt !== true) await receipt - await vi.waitFor(() => expect(events.length).toBeGreaterThan(0)) - - return { initialEvents, laterEvents: [...events] } + } + } + oracleProperty({ + id: `ordered-work.work-left-join-bound`, + law: { + statement: `A direct LEFT-joined filter keeps finite ordered root acquisition, equal rows across consumers, and no errors across the exhaustive small domain.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The joined-only, some, and to-one filters, each over the 64 exhaustive scenarios.`, }, - () => [ - () => firstRequest.resolve(), - () => effect.dispose(), - () => source.cleanup(), + 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', ], - ) -} + histories: leftJoinBoundHistories, + label: (joinFilter) => + `bounds ordered root acquisition for a ${joinFilter} left-join filter`, + ...orderedWorkJudgment(runLeftJoinBound), + check: (_plan, _observations, verdict) => verdict, + }) -async function observeHeldEffectDelta( - history: `absent-cycle` | `present-update-exit`, -): Promise<{ - batches: Array> - rows: Array - exitedValues: Map -}> { - const limit = history === `absent-cycle` ? 1 : 2 - const truth = new Map( - (history === `absent-cycle` - ? [ - { id: 1, rank: 1, eligible: true, label: `first` }, - { id: 3, rank: 3, eligible: true, label: `third` }, - ] - : [ - { id: 1, rank: 1, eligible: true, label: `first` }, - { id: 2, rank: 2, eligible: true, label: `second` }, - { id: 3, rank: 3, eligible: true, label: `third` }, - { id: 4, rank: 4, eligible: true, label: `fourth` }, - ] - ).map((row) => [row.id, row]), + const joinedDemandSettlesHistories = ( + [`collection`, `effect`] as const + ).flatMap((consumer) => + ([`unindexed`, `custom`] as const).map((loadingMode) => ({ + consumer, + loadingMode, + })), ) - const delivered = new Set() - const repairStarted = createDeferred() - const releaseRepair = createDeferred() - const effectRows = new Map() - const batches: Array> = [] - const exitedValues = new Map() - let heldRepair = false - let loadCount = 0 - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: Row - }) => void - let commit!: () => true | Promise - - const source = createCollection({ - id: `ordered-held-effect-delta-${history}-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - const apply = async (options: LoadSubsetOptions) => { - let selected = [...truth.values()].filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === true) && - (!options.cursor || - evaluateReferenceExpression(options.cursor.whereFrom, row) === - true), - ) - selected.sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - const offset = options.cursor ? 0 : (options.offset ?? 0) - selected = selected.slice( - offset, - options.limit === undefined ? undefined : offset + options.limit, - ) - const additions = selected.filter(({ id }) => !delivered.has(id)) - if (additions.length === 0) return - begin() - for (const row of additions) { - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - const receipt = commit() - if (receipt !== true) await receipt - } - return { - loadSubset: (options: LoadSubsetOptions) => { - loadCount++ - const isRepairPrefix = - options.refetch === true && - options.orderBy !== undefined && - options.cursor === undefined - if (isRepairPrefix && !heldRepair) { - heldRepair = true - repairStarted.resolve() - return releaseRepair.promise.then(() => apply(options)) - } - return apply(options) - }, - } + async function runJoinedDemandSettles({ + consumer, + loadingMode, + }: (typeof joinedDemandSettlesHistories)[number]): Promise { + const observed = await observeConsumer( + consumer, + { + middleCount: 1, + middleEligible: false, + lastEligible: true, + tied: false, + direction: `asc`, }, + `none`, + true, + false, + loadingMode, + ) + expect(observed.rows.map(({ id }) => id)).toEqual([3]) + expect(observed.errors).toEqual([]) + if (loadingMode === `custom`) { + // The full-source acquisition uses the without-order request shape. + expect(observed.requests).toHaveLength(1) + expect(observed.requests[0]?.kind).toBe(`without-order`) + expect(observed.requests[0]?.limit).toBeUndefined() + expect(observed.requests[0]?.hasCursor).toBe(false) + expect(observed.requests[0]?.offset).toBeUndefined() + } else { + expect(observed.requests[0]?.kind).toBe(`with-order`) + expect(observed.requests.some(({ limit }) => limit === undefined)).toBe( + true, + ) + } + } + oracleProperty({ + id: `ordered-work.work-joined-demand-settles`, + law: { + statement: `Unindexed and custom-collation loading settle joined demand before one complete window publishes.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Both consumers and both loading modes over one fixed scenario.`, }, - }) - const effect = createEffect({ - query: (q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(limit), - onBatch: (events: Array>) => { - const labels: Array = [] - for (const event of events) { - labels.push(`${event.type}:${String(event.key)}`) - if (event.type === `exit`) { - exitedValues.set(event.key, copiedRow(event.value)) - effectRows.delete(event.key) - } else { - effectRows.set(event.key, { ...event.value }) - } - } - batches.push(labels) - }, + 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', + ], + histories: joinedDemandSettlesHistories, + label: (history) => + `settles joined demand and publishes a complete ${history.loadingMode} ${history.consumer} window`, + ...orderedWorkJudgment(runJoinedDemandSettles), + check: (_plan, _observations, verdict) => verdict, }) - const applySourceChange = async ( - type: `insert` | `update` | `delete`, - row: Row, - ) => { - begin() - write({ type, value: { ...row } }) - const receipt = commit() - if (receipt !== true) await receipt + const joinedDemandWaitsHistories = ( + [`collection`, `effect`] as const + ).flatMap((consumer) => + ([`some`, `none`] as const).map((joinFilter) => ({ + consumer, + joinFilter, + })), + ) + async function runJoinedDemandWaits({ + consumer, + joinFilter, + }: (typeof joinedDemandWaitsHistories)[number]): Promise { + const observed = await observeConsumer( + consumer, + { + middleCount: 1, + middleEligible: false, + lastEligible: true, + tied: false, + direction: `asc`, + }, + joinFilter, + true, + ) + expect(observed.errors).toEqual([]) + expect(observed.requests[0]?.kind).toBe(`with-order`) } - - return withHistoryCleanup( - async () => { - const initialIds = history === `absent-cycle` ? [1] : [1, 2] - await vi.waitFor(() => - expect([...effectRows.keys()].sort((a, b) => a - b)).toEqual( - initialIds, - ), - ) - await vi.waitFor(() => expect(loadCount).toBeGreaterThanOrEqual(2)) - await flushPromises() - batches.length = 0 - - const first = truth.get(1)! - truth.delete(1) - delivered.delete(1) - await applySourceChange(`delete`, first) - await repairStarted.promise - - if (history === `absent-cycle`) { - const transient: Row = { - id: 2, - rank: 2, - eligible: true, - label: `transient`, - } - delivered.add(transient.id) - await applySourceChange(`insert`, transient) - delivered.delete(transient.id) - await applySourceChange(`delete`, transient) - } else { - const previous = truth.get(2)! - const updated = { ...previous, rank: 20, label: `updated` } - truth.set(2, updated) - await applySourceChange(`update`, updated) - truth.delete(2) - delivered.delete(2) - await applySourceChange(`delete`, updated) - } - - expect(batches).toEqual([]) - releaseRepair.resolve() - const expectedIds = history === `absent-cycle` ? [3] : [3, 4] - await vi.waitFor(() => - expect([...effectRows.keys()].sort((a, b) => a - b)).toEqual( - expectedIds, - ), - ) - - expect(batches).toHaveLength(1) - const flattened = batches.flat() - if (history === `absent-cycle`) { - expect(flattened).not.toContain(`update:2`) - expect(flattened).not.toContain(`enter:2`) - expect(flattened).not.toContain(`exit:2`) - } else { - expect(flattened).toContain(`exit:2`) - expect(flattened).not.toContain(`update:2`) - expect(exitedValues.get(2)).toEqual({ - id: 2, - rank: 2, - eligible: true, - label: `second`, - }) - } - return { - batches: batches.map((batch) => [...batch]), - rows: [...effectRows.keys()].sort((a, b) => a - b), - exitedValues, - } + oracleProperty({ + id: `ordered-work.work-joined-demand-waits`, + law: { + statement: `Joined demand settles before the ordered root requests another page.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Both consumers and the some and none filters over one fixed scenario.`, }, - () => [ - () => releaseRepair.resolve(), - () => effect.dispose(), - () => source.cleanup(), + 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', ], - ) -} - -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))) - .size, - ).toBe(64) + histories: joinedDemandWaitsHistories, + label: (history) => + `waits for a ${history.joinFilter} joined demand before requesting another root page in a ${history.consumer}`, + ...orderedWorkJudgment(runJoinedDemandWaits), + check: (_plan, _observations, verdict) => verdict, }) - it(`keeps later order-term invalidation equal across consumers`, async () => { - oracleRoute(`fixed history`) - const [collection, effect] = await Promise.all([ - observeLaterOrderTermMutation(`collection`), - observeLaterOrderTermMutation(`effect`), - ]) - expect(effect.rows).toEqual(collection.rows) - // The two graph entry points may take a different bounded number of - // refinement passes, but they must exercise the same demand forms. - expect(new Set(effect.requests)).toEqual(new Set(collection.requests)) + const joinedFilterRefillHistories = [`collection`, `effect`] as const + async function runJoinedFilterRefill( + consumer: (typeof joinedFilterRefillHistories)[number], + ): Promise { + const observed = await observeConsumer( + consumer, + { + middleCount: 1, + middleEligible: true, + lastEligible: true, + tied: false, + direction: `asc`, + }, + `some`, + false, + true, + ) + expect(observed.rows.map(({ id }) => id)).toEqual([3, 2]) + expect(observed.requests.some(({ hasCursor }) => hasCursor)).toBe(true) + } + oracleProperty({ + id: `ordered-work.work-joined-filter-refill`, + law: { + statement: `A joined-filter window refills through a cursor after a child leaves.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Both consumers over one fixed scenario.`, + }, + 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', + ], + histories: joinedFilterRefillHistories, + label: (consumer) => + `refills a joined-filter ${consumer} window after a child leaves`, + ...orderedWorkJudgment(runJoinedFilterRefill), + check: (_plan, _observations, verdict) => verdict, }) - 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), - ]) - - expect(effect.rows).toEqual(collection.rows) - expect(effect.publications.at(-1)).toEqual(collection.publications.at(-1)) - }, - ) - - 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 - // callback checkpoint, only row 2 may enter the width-one result. - type Sync = Parameters[`sync`]>[0] - const truth = new Map([ - [1, { id: 1, rank: 1, eligible: true, label: `first` }], - [2, { id: 2, rank: 2, eligible: true, label: `second` }], - ]) - let rootSync!: Sync - const delivered = new Set() - const requests: Array = [] - const root = createCollection({ - id: `ordered-effect-start-root-${harnessId++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - rootSync = operations - operations.markReady() - return { - loadSubset: (options) => { - requests.push(options) - const selected = [...truth.values()] - .filter( - (row) => - (!options.where || - evaluateReferenceExpression(options.where, row) === - true) && - (!options.cursor || - evaluateReferenceExpression( - options.cursor.whereFrom, - row, - ) === true), - ) - .sort((a, b) => a.rank - b.rank || a.id - b.id) - .slice( - options.offset ?? 0, - options.limit === undefined - ? undefined - : (options.offset ?? 0) + options.limit, - ) - operations.begin() - for (const row of selected) { - if (!delivered.has(row.id)) { - delivered.add(row.id) - operations.write({ type: `insert`, value: { ...row } }) - } - } - expect( - operations.commit(), - `ordered page applies synchronously`, - ).toBe(true) - return true - }, - } - }, - }, - }) - const marker = createCollection({ - id: `ordered-effect-start-marker-${harnessId++}`, - getKey: ({ id }) => id, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - operations.markReady() - }, - }, - }) - const originalSubscribe = marker.subscribeChanges.bind(marker) - let changedDuringStart = false - vi.spyOn(marker, `subscribeChanges`).mockImplementation(((...args) => { - const subscription = originalSubscribe(...args) - // Only the first subscription after the ordered page can drive this - // history; other subscribers must not repeat or preempt the update. - if (changedDuringStart || requests.length === 0) return subscription - expect(requests[0]?.orderBy, `first request is ordered`).toBeDefined() - expect(requests[0]?.limit, `first request loads one row`).toBe(1) - expect(delivered.has(1), `first page installed row 1`).toBe(true) - expect(root.get(1)?.rank, `row 1 is present before its update`).toBe(1) - changedDuringStart = true - const updated = { ...truth.get(1)!, rank: 3, label: `moved` } - truth.set(1, updated) - rootSync.begin() - rootSync.write({ type: `update`, value: updated }) - expect(rootSync.commit(), `startup update applies synchronously`).toBe( - true, - ) - return subscription - }) as typeof marker.subscribeChanges) - - const batches: Array>> = [] - let effect: ReturnType | undefined - // Drain the scheduled callback queue at the assertion checkpoint. A single - // event-loop turn can miss a later duplicate batch. - vi.useFakeTimers() - try { - await withHistoryCleanup( - async () => { - effect = createEffect({ - query: (q) => - q - .from({ row: root }) - .leftJoin({ marker }, ({ row, marker: child }) => - eq(row.id, child.rowId), - ) - .orderBy(({ row }) => row.rank) - .limit(1) - .select(({ row }) => ({ - id: row.id, - rank: row.rank, - eligible: row.eligible, - label: row.label, - })), - onBatch: (events) => { - batches.push(events) - }, - }) - expect(changedDuringStart).toBe(true) - await vi.runAllTimersAsync() - expect(batches).toEqual([ - [{ type: `enter`, key: `[2,null]`, value: truth.get(2) }], - ]) - expect(requests.some((options) => options.refetch === true)).toBe( - true, - ) - }, - () => [ - () => effect?.dispose(), - () => root.cleanup(), - () => marker.cleanup(), - ], - ) - } finally { - vi.useRealTimers() - } - }) - - it(`keeps an Effect live when truncate replay aborts an obsolete repair participant`, async () => { - oracleRoute(`fixed history`) - await expect( - observeEffectAfterObsoleteRepairAbort(), - ).resolves.toMatchObject({ - rows: [2], - finalLabel: `updated after replay`, - }) - }) - - 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`]), - }) - }) - - 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]], - }) - }) - - 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], - }, - ) - }) - - 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] }) - }) - - it.each( - ([`collection`, `effect`] as const).flatMap((consumer) => - [2, 5].flatMap((limit) => - ([`first`, `last`] as const).flatMap((position) => - ([`asc`, `desc`] as const).map((direction) => ({ - consumer, - limit, - position, - direction, - })), - ), - ), - ), - )( - `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({ - middleCount: 1, - middleEligible: true, - lastEligible: true, - tied: false, - direction, - }) - const source = createCollection({ - id: `ordered-value-update`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: async () => { - loads++ - if (loads > 1) return - operations.begin() - for (const row of rows) { - operations.write({ type: `insert`, value: { ...row } }) - } - const receipt = operations.commit() - if (receipt !== true) await receipt - }, - } - }, - }, - }) - const query = (q: InitialQueryBuilder) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - .limit(limit) - const effectRows = new Map() - const live = - consumer === `collection` ? createLiveQueryCollection(query) : undefined - const effect = - consumer === `effect` - ? createEffect({ - query, - onBatch: (events) => { - for (const event of events) { - if (event.type === `exit`) effectRows.delete(event.key) - else effectRows.set(event.key, event.value) - } - }, - }) - : undefined - const readRows = () => - (live ? [...live.values()] : [...effectRows.values()]) - .map(({ id, rank, eligible, label }) => ({ - id, - rank, - eligible, - label, - })) - .sort(compareRows(direction)) - - try { - if (live) await live.preload() - await flushPromises() - const expected = expectedVisibleWindow(rows, direction, limit) - expect(readRows()).toEqual(expected) - const loadCount = loads - const row = position === `first` ? expected[0]! : expected.at(-1)! - sync.begin() - sync.write({ type: `update`, value: { ...row, label: `changed` } }) - sync.commit() - await flushPromises() - - expect(readRows()).toEqual( - expected.map((value) => - value.id === row.id ? { ...value, label: `changed` } : value, - ), - ) - expect(loads).toBe(loadCount) - } finally { - if (effect) await effect.dispose() - if (live) await live.cleanup() - await source.cleanup() - } - }, - ) - - it.each([ - { - name: `matching`, - secondaryRows: [ - { id: `c-child`, joinKey: `c` }, - { id: `d-child`, joinKey: `d` }, - ], - expected: [`c:c-child`, `d:d-child`], - }, - { - name: `empty`, - secondaryRows: [] as Array<{ id: string; joinKey: string }>, - expected: [] as Array, - }, - ])( - `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) => ({ - id, - rank: 0, - joinKey: id, - })) - const deliveredPrimary = new Set() - const deliveredSecondary = new Set() - const secondaryLoads: Array<{ - options: LoadSubsetOptions - gate: ReturnType> - }> = [] - let primaryExhausted = false - - const primary = createCollection({ - id: `ordered-late-join-primary-${name}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: async (options) => { - const rows = options.orderBy - ? [ - primaryRows[ - options.cursor?.lastKey - ? primaryRows.findIndex( - ({ id }) => id === options.cursor?.lastKey, - ) + 1 - : 0 - ], - ].filter((row): row is Primary => row !== undefined) - : primaryRows.filter( - (row) => - !options.where || - evaluateReferenceExpression(options.where, row) === - true, - ) - const fresh = rows.filter(({ id }) => !deliveredPrimary.has(id)) - if (fresh.length > 0) { - begin() - for (const row of fresh) { - deliveredPrimary.add(row.id) - write({ type: `insert`, value: row }) - } - const receipt = commit(options.signal) - if (receipt !== true) await receipt - } - primaryExhausted = deliveredPrimary.size === primaryRows.length - }, - } - }, - }, - }) - const secondary = createCollection({ - id: `ordered-late-join-secondary-${name}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: async (options) => { - const gate = createDeferred() - secondaryLoads.push({ options, gate }) - await gate.promise - const fresh = secondaryRows.filter( - (row) => - !deliveredSecondary.has(row.id) && - (!options.where || - evaluateReferenceExpression(options.where, row) === true), - ) - for (const row of fresh) { - deliveredSecondary.add(row.id) - begin() - write({ type: `insert`, value: row }) - const receipt = commit(options.signal) - if (receipt !== true) await receipt - } - }, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ primaryRow: primary }) - .innerJoin( - { secondaryRow: secondary }, - ({ primaryRow, secondaryRow }) => - eq(primaryRow.joinKey, secondaryRow.joinKey), - ) - .orderBy(({ primaryRow }) => primaryRow.rank) - .limit(2), - ) - - try { - const preload = live.preload() - let settled = false - void preload.finally(() => { - settled = true - }) - await vi.waitFor(() => - expect( - primaryExhausted, - JSON.stringify({ - deliveredPrimary: [...deliveredPrimary], - secondaryLoads: secondaryLoads.length, - }), - ).toBe(true), - ) - expect(secondaryLoads.length).toBeGreaterThan(0) - expect(settled).toBe(false) - - for (const load of [...secondaryLoads].reverse()) { - load.gate.resolve() - await flushPromises() - } - await preload - - expect( - live.toArray.map( - ({ primaryRow, secondaryRow }) => - `${primaryRow.id}:${secondaryRow.id}`, - ), - ).toEqual(expected) - expect(live.isLoadingSubset).toBe(false) - expect(live.utils.lastSubsetError).toBeUndefined() - } finally { - for (const { gate } of secondaryLoads) gate.resolve() - await Promise.all([ - live.cleanup(), - primary.cleanup(), - secondary.cleanup(), - ]) - } - }, - ) - - 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<{ - options: LoadSubsetOptions - gate: ReturnType> - }> = [] - const completionOrder: Array = [] - const primary = createCollection({ - id: `ordered-independent-primary`, - getKey: ({ id }) => id, - syncMode: `eager`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ type: `insert`, value: { id: `a`, rank: 1, joinKey: `a` } }) - write({ type: `insert`, value: { id: `b`, rank: 2, joinKey: `b` } }) - commit() - markReady() - }, - }, - }) - const secondaryRows: Array = [ - { id: `a-child`, joinKey: `a` }, - { id: `b-child`, joinKey: `b` }, - ] - const delivered = new Set() - const secondary = createCollection({ - id: `ordered-independent-secondary`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: async (options) => { - const index = pending.length - const gate = createDeferred() - pending.push({ options, gate }) - await gate.promise - const rows = secondaryRows.filter( - (candidate) => - !delivered.has(candidate.id) && - (!options.where || - evaluateReferenceExpression(options.where, candidate) === - true), - ) - for (const row of rows) { - delivered.add(row.id) - begin() - write({ type: `insert`, value: row }) - const receipt = commit(options.signal) - if (receipt !== true) await receipt - } - completionOrder.push(index) - }, - } - }, - }, - }) - const createJoined = (id: `a` | `b`) => - createLiveQueryCollection((q) => - q - .from({ primaryRow: primary }) - .where(({ primaryRow }) => eq(primaryRow.id, id)) - .innerJoin( - { secondaryRow: secondary }, - ({ primaryRow, secondaryRow }) => - eq(primaryRow.joinKey, secondaryRow.joinKey), - ) - .orderBy(({ primaryRow }) => primaryRow.rank) - .limit(1), - ) - const first = createJoined(`a`) - const second = createJoined(`b`) - - try { - const firstPreload = first.preload() - await vi.waitFor(() => expect(pending).toHaveLength(1)) - const secondPreload = second.preload() - await vi.waitFor(() => expect(pending).toHaveLength(2)) - let firstSettled = false - void firstPreload.finally(() => { - firstSettled = true - }) - - pending[1]!.gate.resolve() - await secondPreload - await flushPromises() - expect(firstSettled).toBe(false) - expect( - second.toArray.map( - ({ primaryRow, secondaryRow }) => - `${primaryRow.id}:${secondaryRow.id}`, - ), - ).toEqual([`b:b-child`]) - - pending[0]!.gate.resolve() - await firstPreload - expect(completionOrder).toEqual([1, 0]) - expect( - first.toArray.map( - ({ primaryRow, secondaryRow }) => - `${primaryRow.id}:${secondaryRow.id}`, - ), - ).toEqual([`a:a-child`]) - expect(first.utils.lastSubsetError).toBeUndefined() - expect(second.utils.lastSubsetError).toBeUndefined() - } finally { - for (const { gate } of pending) gate.resolve() - await Promise.all([ - first.cleanup(), - second.cleanup(), - primary.cleanup(), - secondary.cleanup(), - ]) - } - }) - - 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([ - [1, { id: 1, rank: 0 }], - [2, { id: 2, rank: 1 }], - ]) - const deliveredPrimary = new Set() - const secondaryReplay = createDeferred() - let primaryRequests = 0 - let replayingSecondary = false - let primarySync!: Parameters[`sync`]>[0] - let secondarySync!: Parameters[`sync`]>[0] - let secondaryReplayCalls = 0 - - const primary = createCollection({ - id: `ordered-independent-recovery-primary`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - primarySync = operations - operations.markReady() - return { - loadSubset: async (options) => { - primaryRequests++ - let selected = [...primaryTruth.values()].sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - if (options.where) { - selected = selected.filter( - (row) => - evaluateReferenceExpression(options.where!, row) === true, - ) - } - if (options.cursor) { - selected = selected.filter( - (row) => - evaluateReferenceExpression( - options.cursor!.whereFrom, - row, - ) === true, - ) - } else if (options.offset) { - selected = selected.slice(options.offset) - } - if (options.limit !== undefined) { - selected = selected.slice(0, options.limit) - } - const fresh = selected.filter( - ({ id }) => !deliveredPrimary.has(id), - ) - if (fresh.length === 0) return - operations.begin() - for (const row of fresh) { - deliveredPrimary.add(row.id) - operations.write({ type: `insert`, value: { ...row } }) - } - const receipt = operations.commit(options.signal) - if (receipt !== true) await receipt - }, - unloadSubset: () => {}, - } - }, - }, - }) - const secondary = createCollection({ - id: `ordered-independent-recovery-secondary`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - secondarySync = operations - operations.markReady() - return { - loadSubset: async (options) => { - if (replayingSecondary) { - secondaryReplayCalls++ - await secondaryReplay.promise - } - operations.begin() - operations.write({ - type: `insert`, - value: { id: 1, primaryId: 1 }, - }) - const receipt = operations.commit(options.signal) - if (receipt !== true) await receipt - }, - unloadSubset: () => {}, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: primary }) - .leftJoin({ child: secondary }, ({ row, child }) => - eq(row.id, child.primaryId), - ) - .orderBy(({ row }) => row.rank) - .limit(1) - .select(({ row, child }) => ({ - id: row.id, - rank: row.rank, - childId: child.id, - })), - ) - - try { - await live.preload() - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - - replayingSecondary = true - secondarySync.begin() - secondarySync.truncate() - const truncateReceipt = secondarySync.commit() - if (truncateReceipt !== true) await truncateReceipt - await vi.waitFor(() => expect(secondaryReplayCalls).toBeGreaterThan(0)) - - const requestsBeforeMutation = primaryRequests - const moved = { id: 1, rank: 10 } - primaryTruth.set(1, moved) - primarySync.begin() - primarySync.write({ type: `update`, value: moved }) - const mutationReceipt = primarySync.commit() - if (mutationReceipt !== true) await mutationReceipt - await vi.waitFor(() => - expect(primaryRequests).toBeGreaterThan(requestsBeforeMutation), - ) - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - - secondaryReplay.resolve() - await vi.waitFor(() => - expect(live.toArray.map(({ id }) => id)).toEqual([2]), - ) - expect(primaryRequests).toBeGreaterThan(requestsBeforeMutation) - } finally { - secondaryReplay.resolve() - await Promise.all([ - live.cleanup(), - primary.cleanup(), - secondary.cleanup(), - ]) - } - }) - - 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` }, - ] - const batches: Array> = [] - const callbackReads: Array> = [] - let loads = 0 - const delivered = new Set() - let sync!: Parameters[`sync`]>[0] - const source = createCollection({ - id: `ordered-atomic-indexed-window`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: (options) => { - loads++ - const row = remoteRows.find( - (candidate) => - !delivered.has(candidate.id) && - (!options.where || - evaluateReferenceExpression(options.where, candidate) === - true), - ) - if (!row) return true - delivered.add(row.id) - sync.begin() - sync.write({ type: `insert`, value: row }) - const receipt = sync.commit() - if (receipt !== true) { - throw new Error(`Expected synchronous source application`) - } - return true - }, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(0), - ) - const readIds = () => live.toArray.map(({ id }) => id) - const subscription = live.subscribeChanges( - (changes) => { - batches.push(changes.map(({ key }) => Number(key)).sort()) - callbackReads.push(readIds()) - }, - { includeInitialState: false }, - ) - - await withHistoryCleanup( - async () => { - await live.preload() - await live.utils.setWindow({ offset: 0, limit: 2 }) - await flushPromises() - - // Each page turn is followed by a tie-boundary request. The source - // returns one row at a time while honoring both predicates. - expect(loads).toBe(4) - expect(readIds()).toEqual([1, 2]) - expect(batches).toEqual([[1, 2]]) - expect(callbackReads).toEqual([[1, 2]]) - }, - () => [ - () => subscription.unsubscribe(), - () => live.cleanup(), - () => source.cleanup(), - ], - ) - }) - - 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]!, - `none`, - ) - expect(observed.rows.map(({ id }) => id)).toEqual([2]) - }) - - it(`removes a matched parent from the anti-join Effect`, async () => { - oracleRoute(`fixed history`) - const observed = await observeConsumer( - `effect`, - exhaustiveScenarios[0]!, - `none`, - ) - expect(observed.rows.map(({ id }) => id)).toEqual([2]) - }) - - 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 = [ - { id: 1, rank: 1 }, - { id: 2, rank: 2 }, - ] - const installed = new Set() - const rootRequests: Array = [] - const firstChildGate = createDeferred() - let childLoads = 0 - const rootCollection = createCollection({ - id: `ordered-synchronous-refill-root`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - operations.markReady() - return { - loadSubset: (options) => { - rootRequests.push(options) - let selected = truth - .filter( - (row) => - !options.where || - evaluateReferenceExpression(options.where, row) === true, - ) - .filter( - (row) => - !options.cursor || - evaluateReferenceExpression( - options.cursor.whereFrom, - row, - ) === true, - ) - .sort((left, right) => left.rank - right.rank) - if (!options.cursor && options.offset) { - selected = selected.slice(options.offset) - } - if (options.limit !== undefined) { - selected = selected.slice(0, options.limit) - } - const fresh = selected.filter(({ id }) => !installed.has(id)) - if (fresh.length > 0) { - operations.begin() - for (const row of fresh) { - installed.add(row.id) - operations.write({ type: `insert`, value: row }) - } - const receipt = operations.commit(options.signal) - if (receipt !== true) { - throw new Error(`Expected synchronous root application`) - } - } - return true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const childCollection = createCollection({ - id: `ordered-synchronous-refill-child`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - operations.begin() - operations.write({ - type: `insert`, - value: { id: 20, rootId: 2 }, - }) - if (operations.commit() !== true) { - throw new Error(`Expected synchronous child application`) - } - operations.markReady() - return { - loadSubset: () => { - childLoads++ - return childLoads === 1 ? firstChildGate.promise : true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const live = createLiveQueryCollection({ - id: `ordered-synchronous-refill-live`, - startSync: false, - query: (q) => - q - .from({ root: rootCollection }) - .leftJoin({ child: childCollection }, ({ root, child }) => - eq(root.id, child.rootId), - ) - .where(({ child }) => not(isUndefined(child.rootId))) - .orderBy(({ root }) => root.rank) - .limit(1) - .select(({ root }) => ({ id: root.id })), - }) - - await withHistoryCleanup( - async () => { - const preload = live.preload() - await vi.waitFor(() => { - expect(childLoads).toBe(1) - expect(rootRequests).toHaveLength(1) - }) - expect(live.toArray).toEqual([]) - - firstChildGate.resolve() - await preload - expect(childLoads).toBe(2) - expect(live.toArray.map(({ id }) => id)).toEqual([2]) - expect(rootRequests.some(({ cursor }) => cursor !== undefined)).toBe( - true, - ) - }, - () => [ - () => firstChildGate.resolve(), - () => live.cleanup(), - () => rootCollection.cleanup(), - () => childCollection.cleanup(), - ], - ) - }) - - 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 = [ - { id: 1, rank: 1 }, - { id: 2, rank: 2 }, - { id: 3, rank: 3 }, - ] - const installed = new Set() - const rootRequests: Array = [] - const secondChildGate = createDeferred() - let childLoads = 0 - let rootSync!: Parameters[`sync`]>[0] - const rootCollection = createCollection({ - id: `ordered-held-repair-root`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - rootSync = operations - operations.markReady() - return { - loadSubset: async (options) => { - rootRequests.push(options) - let selected = truth - .filter( - (row) => - !options.where || - evaluateReferenceExpression(options.where, row) === true, - ) - .filter( - (row) => - !options.cursor || - evaluateReferenceExpression( - options.cursor.whereFrom, - row, - ) === true, - ) - .sort((left, right) => left.rank - right.rank) - if (!options.cursor && options.offset) { - selected = selected.slice(options.offset) - } - if (options.limit !== undefined) { - selected = selected.slice(0, options.limit) - } - const fresh = selected.filter(({ id }) => !installed.has(id)) - if (fresh.length > 0) { - operations.begin() - for (const row of fresh) { - installed.add(row.id) - operations.write({ type: `insert`, value: row }) - } - const receipt = operations.commit(options.signal) - if (receipt !== true) await receipt - } - }, - unloadSubset: () => {}, - } - }, - }, - }) - const childCollection = createCollection({ - id: `ordered-held-repair-child`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: () => { - childLoads++ - return childLoads === 2 ? secondChildGate.promise : true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const live = createLiveQueryCollection({ - id: `ordered-held-repair-live`, - startSync: false, - query: (q) => - q - .from({ root: rootCollection }) - .leftJoin({ child: childCollection }, ({ root, child }) => - eq(root.id, child.rootId), - ) - .where(({ child }) => isUndefined(child.rootId)) - .orderBy(({ root }) => root.rank) - .limit(1) - .select(({ root }) => ({ id: root.id })), - }) - - const deleteRoot = async (id: number) => { - const row = truth.find((candidate) => candidate.id === id)! - truth.splice(truth.indexOf(row), 1) - installed.delete(id) - rootSync.begin() - rootSync.write({ type: `delete`, value: row }) - const receipt = rootSync.commit() - if (receipt !== true) await receipt - } - - await withHistoryCleanup( - async () => { - await live.preload() - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - - await deleteRoot(1) - await vi.waitFor(() => { - expect(childLoads).toBe(2) - expect(rootRequests.filter(({ refetch }) => refetch)).toHaveLength(2) - }) - expect(live.toArray.map(({ id }) => id)).toEqual([1]) - - secondChildGate.resolve() - await vi.waitFor(() => - expect(live.toArray.map(({ id }) => id)).toEqual([2]), - ) - - await deleteRoot(2) - await vi.waitFor(() => - expect(live.toArray.map(({ id }) => id)).toEqual([3]), - ) - expect( - rootRequests.filter(({ refetch }) => refetch).length, - ).toBeGreaterThan(2) - }, - () => [ - () => secondChildGate.resolve(), - () => live.cleanup(), - () => rootCollection.cleanup(), - () => childCollection.cleanup(), - ], - ) - }) - - 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) - expect(observed.requests[0]?.kind).toBe(`with-order`) - expect(observed.requests[0]?.limit).toBeGreaterThan(0) - } - }) - - 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), - observeConsumer(`effect`, scenario, joinFilter), - ]) - expect(collection.rows).toEqual(effect.rows) - expect(collection.errors).toEqual([]) - expect(effect.errors).toEqual([]) - - // A direct LEFT-joined filter retains finite root acquisition. - for (const observation of [collection, effect]) { - expect(observation.requests.length).toBeGreaterThan(0) - expect(observation.requests[0]?.kind).toBe(`with-order`) - expect(observation.requests[0]?.limit).toBeGreaterThan(0) - } - } - }, - ) - - it.each( - ([`collection`, `effect`] as const).flatMap((consumer) => - ([`unindexed`, `custom`] as const).map((loadingMode) => ({ - consumer, - loadingMode, - })), - ), - )( - `settles joined demand and publishes a complete $loadingMode $consumer window`, - async ({ consumer, loadingMode }) => { - oracleRoute(`case`) - const observed = await observeConsumer( - consumer, - { - middleCount: 1, - middleEligible: false, - lastEligible: true, - tied: false, - direction: `asc`, - }, - `none`, - true, - false, - loadingMode, - ) - expect(observed.rows.map(({ id }) => id)).toEqual([3]) - expect(observed.errors).toEqual([]) - if (loadingMode === `custom`) { - // The full-source acquisition uses the without-order request shape. - expect(observed.requests).toHaveLength(1) - expect(observed.requests[0]?.kind).toBe(`without-order`) - expect(observed.requests[0]?.limit).toBeUndefined() - expect(observed.requests[0]?.hasCursor).toBe(false) - expect(observed.requests[0]?.offset).toBeUndefined() - } else { - expect(observed.requests[0]?.kind).toBe(`with-order`) - expect(observed.requests.some(({ limit }) => limit === undefined)).toBe( - true, - ) - } - }, - ) - - it.each( - ([`collection`, `effect`] as const).flatMap((consumer) => - ([`some`, `none`] as const).map((joinFilter) => ({ - consumer, - joinFilter, - })), - ), - )( - `waits for a $joinFilter joined demand before requesting another root page in a $consumer`, - async ({ consumer, joinFilter }) => { - oracleRoute(`case`) - const observed = await observeConsumer( - consumer, - { - middleCount: 1, - middleEligible: false, - lastEligible: true, - tied: false, - direction: `asc`, - }, - joinFilter, - true, - ) - expect(observed.errors).toEqual([]) - expect(observed.requests[0]?.kind).toBe(`with-order`) - }, - ) - - 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, - { - middleCount: 1, - middleEligible: true, - lastEligible: true, - tied: false, - direction: `asc`, - }, - `some`, - false, - true, - ) - expect(observed.rows.map(({ id }) => id)).toEqual([3, 2]) - expect(observed.requests.some(({ hasCursor }) => hasCursor)).toBe(true) - }, - ) - - 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, - { - middleCount: 40, - middleEligible: true, - lastEligible: true, - tied: false, - direction: `asc`, - }, - `some`, - ) - expect(observed.requests.length).toBeLessThan(10) - expect( - observed.requests.every( - ({ kind, key }) => kind === `with-order` || key !== undefined, - ), - ).toBe(true) - }, - ) - - it.each( - [0, 1, 2, 3, 4].flatMap((middleCount) => - ([`asc`, `desc`] as const).flatMap((direction) => - [false, true].map((tied) => ({ middleCount, direction, tied })), - ), - ), - )( - `settles an underfilled source without repeating a continuation: %j`, - async ({ middleCount, direction, tied }) => { - oracleRoute(`case`) - const scenario: Scenario = { - middleCount, - middleEligible: false, - lastEligible: false, - tied, - direction, - } - const [collection, effect] = await Promise.all([ - observeConsumer(`collection`, scenario), - observeConsumer(`effect`, scenario), - ]) - - expect(collection.rows.map(({ id }) => id)).toEqual([1]) - expect(effect.rows).toEqual(collection.rows) - expect(effect.errors).toEqual(collection.errors) - expect(effect.live).toBe(collection.live) - for (const observation of [collection, effect]) { - expect(observation.errors).toEqual([]) - expect(observation.live).toBe(true) - // At most one page and one tie-boundary load per source row, including - // the final empty page. A fixed cap mistakes longer finite walks for loops. - const sourceSize = rowsForScenario(scenario).length - expect( - observation.requests.length, - JSON.stringify(observation.requests), - ).toBeLessThanOrEqual(2 * sourceSize) - expect( - observation.requests.filter(({ kind }) => kind === `with-order`) - .length, - ).toBeLessThanOrEqual(sourceSize) - expect( - observation.requests.filter(({ kind }) => kind === `without-order`) - .length, - ).toBeLessThanOrEqual(sourceSize) - expect( - new Set(observation.requests.map(({ key }) => key)).size, - JSON.stringify(observation.requests), - ).toBe(observation.requests.length) - expectDistinctRequests(observation.requests) - } - }, - ) - - 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` }, - ] - const replacement: ReadonlyArray = [ - { id: 3, rank: 3, eligible: true, label: `new-three` }, - { id: 4, rank: 4, eligible: true, label: `new-four` }, - ] - let truth = initial - let sync!: Parameters[`sync`]>[0] - let loads = 0 - const installed = new Set() - const source = createCollection({ - id: `ordered-void-truncate`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: async (options) => { - loads++ - if (loads > 12) { - throw new Error( - `ordered void loading did not reach a fixed point`, - ) - } - const matching = options.where - ? truth.filter( - (row) => - evaluateReferenceExpression(options.where!, row) === true, - ) - : truth - const rows = matching - .slice( - options.offset ?? 0, - options.limit === undefined - ? undefined - : (options.offset ?? 0) + options.limit, - ) - .filter(({ id }) => !installed.has(id)) - if (rows.length === 0) return - sync.begin() - for (const row of rows) { - installed.add(row.id) - sync.write({ type: `insert`, value: row }) - } - const receipt = sync.commit() - if (receipt !== true) await receipt - }, - unloadSubset: () => {}, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(2), - ) - - try { - await live.preload() - expect(live.toArray.map(({ id }) => id)).toEqual([1, 2]) - - truth = replacement - installed.clear() - sync.begin() - sync.truncate() - const receipt = sync.commit() - if (receipt !== true) await receipt - await flushPromises() - - expect(live.toArray.map(({ id }) => id)).toEqual([3, 4]) - expect(loads).toBeLessThanOrEqual(8) - } finally { - await Promise.all([live.cleanup(), source.cleanup()]) - } - }) - - it.each([ - { name: `until full-source recovery settles`, failure: undefined }, - { - name: `when full-source recovery throws synchronously`, - failure: { - mode: `sync` as const, - attempts: 1, - error: new Error(`full-source recovery failed`), - }, - }, - { - name: `after two synchronous full-source recovery failures`, - failure: { - mode: `sync` as const, - attempts: 2, - error: new Error(`full-source recovery failed twice`), - }, - }, - { - name: `after asynchronous full-source recovery retries`, - failure: { - mode: `async` as const, - attempts: 1, - error: new Error(`full-source recovery rejected`), - }, - }, - { - name: `after two asynchronous full-source recovery failures`, - failure: { - mode: `async` as const, - attempts: 2, - error: new Error(`full-source recovery rejected twice`), - }, - }, - ])(`keeps an ordered snapshot unchanged $name`, async ({ failure }) => { - oracleRoute(`case`) - const makeRows = (ranks: ReadonlyArray): Array => - ranks.map((rank, index) => ({ - id: index + 1, - rank, - eligible: true, - label: `row-${rank}`, - })) - let truth = makeRows([1, 2, 3, 4, 5]) - let sync!: Parameters[`sync`]>[0] - let recovering = false - let fullSourceRequests = 0 - const fullSource = createDeferred() - const installed = new Set() - const publications: Array> = [] - const escapedErrors: Array = [] - const acquisitions: Array = [] - const releases: Array = [] - const enqueueMicrotask = globalThis.queueMicrotask.bind(globalThis) - const queueMicrotaskSpy = - failure?.mode === `sync` - ? vi - .spyOn(globalThis, `queueMicrotask`) - .mockImplementation((callback) => - enqueueMicrotask(() => { - try { - callback() - } catch (error) { - escapedErrors.push(error) - } - }), - ) - : undefined - - const source = createCollection({ - id: `delayed-full-source-recovery`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: (options) => { - const isFullSource = - options.where === undefined && options.limit === undefined - if (recovering && isFullSource) { - fullSourceRequests++ - if (failure && fullSourceRequests <= failure.attempts) { - // A synchronous throw is a rejection (maintainer - // decision): an acquisition that core releases. - acquisitions.push(options) - if (failure.mode === `sync`) throw failure.error - return Promise.reject(failure.error) - } - } - acquisitions.push(options) - - return (async () => { - if (recovering && isFullSource && !failure) { - await fullSource.promise - } - - let selected = options.where - ? truth.filter( - (row) => - evaluateReferenceExpression(options.where!, row) === - true, - ) - : [...truth] - if (options.cursor) { - selected = selected.filter( - (row) => - evaluateReferenceExpression( - options.cursor!.whereFrom, - row, - ) === true, - ) - } - selected.sort((left, right) => left.rank - right.rank) - if (!options.cursor && options.offset) { - selected = selected.slice(options.offset) - } - if (options.limit !== undefined) { - selected = selected.slice(0, options.limit) - } - - const fresh = selected.filter(({ id }) => !installed.has(id)) - if (fresh.length === 0) return - sync.begin() - for (const row of fresh) { - installed.add(row.id) - sync.write({ type: `insert`, value: row }) - } - const receipt = sync.commit() - if (receipt !== true) await receipt - })() - }, - unloadSubset: (options) => { - releases.push(options) - }, - } - }, + const joinedFilterStopsHistories = [`collection`, `effect`] as const + async function runJoinedFilterStops( + consumer: (typeof joinedFilterStopsHistories)[number], + ): Promise { + const observed = await observeConsumer( + consumer, + { + middleCount: 40, + middleEligible: true, + lastEligible: true, + tied: false, + direction: `asc`, }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(2), + `some`, ) - const subscription = live.subscribeChanges(() => { - publications.push(live.toArray.map(({ rank }) => rank)) - }) - - try { - await live.preload() - await live.utils.setWindow({ offset: 0, limit: 4 }) - await flushPromises() - expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) - const publicationCount = publications.length - - truth = makeRows([0, 0.5, 1, 1.5, 2, 3]) - installed.clear() - recovering = true - sync.begin() - sync.truncate() - const receipt = sync.commit() - if (receipt !== true) await receipt - await vi.waitFor(() => expect(fullSourceRequests).toBe(1)) - for (let index = 0; index < 4; index++) await flushPromises() - - expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) - expect(publications).toHaveLength(publicationCount) + expect(observed.requests.length).toBeLessThan(10) + expect( + observed.requests.every( + ({ kind, key }) => kind === `with-order` || key !== undefined, + ), + ).toBe(true) + } + oracleProperty({ + id: `ordered-work.work-joined-filter-stops`, + law: { + statement: `A joined-filter window stops acquisition after the root prefix that its result needs.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Both consumers over one fixed 40-row scenario.`, + }, + 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', + ], + histories: joinedFilterStopsHistories, + label: (consumer) => + `stops a joined-filter ${consumer} after the needed root prefix`, + ...orderedWorkJudgment(runJoinedFilterStops), + check: (_plan, _observations, verdict) => verdict, + }) - if (failure) { - expect(live.utils.lastSubsetError).toBe(failure.error) - expect(escapedErrors).toEqual([]) - truth[0] = { ...truth[0]!, label: `updated during failed recovery` } - sync.begin() - sync.write({ type: `update`, value: truth[0] }) - const updateReceipt = sync.commit() - if (updateReceipt !== true) await updateReceipt - await flushPromises() - expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) - expect(live.get(1)?.label).toBe(`row-1`) - expect(publications).toHaveLength(publicationCount) - expect(fullSourceRequests).toBe(1) - const retryCount = failure.attempts - for (let retry = 0; retry < retryCount; retry++) { - installed.clear() - sync.begin() - sync.truncate() - const retryReceipt = sync.commit() - if (retryReceipt !== true) await retryReceipt - await vi.waitFor(() => expect(fullSourceRequests).toBe(retry + 2)) - if (retry + 1 < retryCount) { - for (let index = 0; index < 4; index++) await flushPromises() - expect(live.toArray.map(({ rank }) => rank)).toEqual([1, 2, 3, 4]) - expect(publications).toHaveLength(publicationCount) - expect(live.utils.lastSubsetError).toBe(failure.error) - expect(escapedErrors).toEqual([]) - } - } - await vi.waitFor(() => - expect(source.toArray.map(({ rank }) => rank).sort()).toEqual([ - 0, 0.5, 1, 1.5, 2, 3, - ]), - ) - await vi.waitFor(() => - expect(live.toArray.map(({ rank }) => rank)).toEqual([ - 0, 0.5, 1, 1.5, - ]), - ) - expect(fullSourceRequests).toBe(retryCount + 1) - expect(live.get(1)?.label).toBe(`updated during failed recovery`) - } else { - fullSource.resolve() - await flushPromises() - expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5, 1, 1.5]) - } - expect(publications.slice(publicationCount)).toEqual([[0, 0.5, 1, 1.5]]) - expect(escapedErrors).toEqual([]) - await live.utils.setWindow({ offset: 0, limit: 2 }) - expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5]) - const loadsBeforeReplay = fullSourceRequests - installed.clear() - sync.begin() - sync.truncate() - const nextReplay = sync.commit() - if (nextReplay !== true) await nextReplay - await flushPromises() - expect(fullSourceRequests).toBeGreaterThan(loadsBeforeReplay) - expect(live.toArray.map(({ rank }) => rank)).toEqual([0, 0.5]) - } finally { - queueMicrotaskSpy?.mockRestore() - fullSource.resolve() - subscription.unsubscribe() - await Promise.all([live.cleanup(), source.cleanup()]) + const underfilledSourceHistories = [0, 1, 2, 3, 4].flatMap((middleCount) => + ([`asc`, `desc`] as const).flatMap((direction) => + [false, true].map((tied) => ({ middleCount, direction, tied })), + ), + ) + async function runUnderfilledSource({ + middleCount, + direction, + tied, + }: (typeof underfilledSourceHistories)[number]): Promise { + const scenario: Scenario = { + middleCount, + middleEligible: false, + lastEligible: false, + tied, + direction, } - expect(releases).toHaveLength(acquisitions.length) - for (const acquisition of acquisitions) { + const [collection, effect] = await Promise.all([ + observeConsumer(`collection`, scenario), + observeConsumer(`effect`, scenario), + ]) + + expect(collection.rows.map(({ id }) => id)).toEqual([1]) + expect(effect.rows).toEqual(collection.rows) + expect(effect.errors).toEqual(collection.errors) + expect(effect.live).toBe(collection.live) + for (const observation of [collection, effect]) { + expect(observation.errors).toEqual([]) + expect(observation.live).toBe(true) + // At most one page and one tie-boundary load per source row, including + // the final empty page. A fixed cap mistakes longer finite walks for loops. + const sourceSize = rowsForScenario(scenario).length expect( - releases.filter((release) => release === acquisition), - ).toHaveLength(1) + observation.requests.length, + JSON.stringify(observation.requests), + ).toBeLessThanOrEqual(2 * sourceSize) + expect( + observation.requests.filter(({ kind }) => kind === `with-order`).length, + ).toBeLessThanOrEqual(sourceSize) + expect( + observation.requests.filter(({ kind }) => kind === `without-order`) + .length, + ).toBeLessThanOrEqual(sourceSize) + expect( + new Set(observation.requests.map(({ key }) => key)).size, + JSON.stringify(observation.requests), + ).toBe(observation.requests.length) + expectDistinctRequests(observation.requests) } - }) - - 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 }, - { id: 3, rank: 3 }, - ] - const delivered = new Set() - const rootRequests: Array<{ cursor: boolean; limit?: number }> = [] - const extraGate = createDeferred() - let extraLoads = 0 - - const root = createCollection({ - id: `ordered-unrelated-root-${harnessId++}`, - getKey: (row: (typeof rows)[number]) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: async (options) => { - rootRequests.push({ - cursor: !!options.cursor, - limit: options.limit, - }) - if (rootRequests.length > 15) throw new Error(`request loop`) - const matching = options.where - ? rows.filter( - (row) => - evaluateReferenceExpression(options.where!, row) === - true, - ) - : rows - const candidates = options.cursor - ? matching.filter( - (row) => - evaluateReferenceExpression( - options.cursor!.whereFrom, - row, - ) === true, - ) - : matching.slice(options.offset ?? 0) - const fresh = candidates - .slice(0, options.limit) - .filter((row) => !delivered.has(row.id)) - if (fresh.length === 0) return - begin() - for (const row of fresh) { - delivered.add(row.id) - write({ type: `insert`, value: row }) - } - const receipt = commit() - if (receipt !== true) await receipt - }, - unloadSubset: () => {}, - } - }, - }, - }) - const markerSource = createCollection({ - id: `ordered-unrelated-marker-${harnessId++}`, - getKey: (row: { id: number; rowId: number }) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ type: `insert`, value: { id: 2, rowId: 2 } }) - commit() - markReady() - }, - }, - }) - const extraSource = createCollection({ - id: `ordered-unrelated-extra-${harnessId++}`, - getKey: (row: { id: number; rowId: number }) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: async () => { - extraLoads++ - await extraGate.promise - }, - unloadSubset: () => {}, - } - }, - }, - }) - const query = (q: InitialQueryBuilder) => - q - .from({ row: root }) - .leftJoin({ marker: markerSource }, ({ row, marker }) => - eq(row.id, marker.rowId), - ) - .where(({ marker }) => isUndefined(marker.rowId)) - .orderBy(({ row }) => row.rank) - .limit(2) - .select(({ row }) => ({ - id: row.id, - extras: toArray( - q - .from({ extra: extraSource }) - .where(({ extra }) => eq(extra.rowId, row.id)), - ), - })) - const live = - kind === `collection` ? createLiveQueryCollection(query) : undefined - const effectRows = new Set() - const effect = - kind === `effect` - ? createEffect<{ id: number; extras: Array }>({ - query, - onBatch: (events) => { - for (const event of events) { - if (event.type === `exit`) effectRows.delete(event.value.id) - else effectRows.add(event.value.id) - } - }, - }) - : undefined - const preload = live?.preload() - - try { - await vi.waitFor(() => expect(extraLoads).toBeGreaterThan(0)) - for (let i = 0; i < 12; i++) await flushPromises() - expect( - rootRequests.some((request) => request.cursor), - JSON.stringify({ - rootRequests, - extraLoads, - rows: live?.toArray ?? [...effectRows], - }), - ).toBe(true) - extraGate.resolve() - await preload - await vi.waitFor(() => - expect(rootRequests.some((request) => request.cursor)).toBe(true), - ) - expect(live?.toArray.map(({ id }) => id) ?? [...effectRows]).toEqual([ - 1, 3, - ]) - } finally { - extraGate.resolve() - await Promise.all([ - effect?.dispose(), - live?.cleanup(), - root.cleanup(), - markerSource.cleanup(), - extraSource.cleanup(), - ]) - } + } + oracleProperty({ + id: `ordered-work.work-underfilled-source`, + law: { + statement: `An underfilled source settles to equal rows across consumers without repeating a continuation, within the per-row request bound.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Zero to four middle rows, both directions, and tied or distinct ranks.`, }, - ) + 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', + ], + histories: underfilledSourceHistories, + label: (history) => + `settles an underfilled source without repeating a continuation: ${JSON.stringify(history)}`, + ...orderedWorkJudgment(runUnderfilledSource), + check: (_plan, _observations, verdict) => verdict, + }) oracleProperty({ id: `ordered-work.consumer-parity`, @@ -3507,7 +1493,17 @@ describe(`ordered source work oracle`, () => { 'src/query/live/ordered-source-loader.ts', 'src/query/subset-dedupe.ts', ], - histories: { arbitrary: scenarioArbitrary, runs: 20, fixedSeeds: [17801] }, + histories: { + arbitrary: scenarioArbitrary, + runs: 20, + fixedSeeds: [17801], + // The exhaustive small domain: every legal scenario, replayed through + // the same consumer-parity judgment. + fixed: exhaustiveScenarios.map((scenario) => ({ + name: `keeps live collections and Effects equal across the exhaustive small domain: ${JSON.stringify(scenario)}`, + history: scenario, + })), + }, model: (history, observations) => recordedComparison(history, observations), driver: async (history, observe) => observeComparison(history, observe, async () => { @@ -3697,590 +1693,6 @@ describe(`ordered source work oracle`, () => { * LEFT-joined filter and a full join keep one result row per matched * (row, marker) pair, and an anti-join keeps a row once. */ -type EagerCommand = - | { type: `upsert`; id: number; rank: number; eligible: boolean } - | { type: `delete`; id: number } - // Changes only the label, so a row outside the window enters the query's - // input without changing which rows are eligible or their order. - | { type: `touch`; id: number } - // Changes only eligibility. The order value stays, so a joined plan sees - // the row leave or return only through the join. - | { type: `flip`; id: number } - // Adds another marker to a row that already has one, so a join matches - // the row more than once. - | { type: `dup`; id: number } - | { type: `delete-visible` } - // A refetching provider re-runs every request it served and sends the - // results; any other path skips it. - | { type: `refetch` } - // A holding provider answers its oldest held request; `drain` answers - // every held request, including the ones answers start, until none is - // left. Any other provider skips both. (Not the glossary's acquisition - // release, which aborts and unloads.) - | { type: `answer` } - // Cleans up the peer while its loads may be held. Its acquisition - // release aborts each held request and gives the adapter an unload. - | { type: `release-peer` } - | { type: `drain` } - // Moves the first query's window with `setWindow`. The Effect and the peer - // keep their windows. - | { type: `move`; offset: number; limit: number } - -type EagerCase = { - // How rows reach the source: sync writes to an eager source, local-only - // direct writes, or an on-demand source whose provider serves each - // request from the rows and installs only what it returns. - path: `sync` | `local-only` | `on-demand` - // How an on-demand provider delivers later changes. `forward` sends each - // change to a row it sent, or to a row that matches a subset it served. - // `refetch` sends nothing until a refetch, which keeps every served - // ordered range whole: rows that now sort inside one arrive then, and rows - // in no range leave. `refetch-signal` also waits for a refetch, but re-runs - // each served request alone, as query-db-collection does, and then reports - // `invalidateLoadedSubsets()` when any answer changed. Omitted means - // `forward`. - provider?: `forward` | `refetch` | `refetch-signal` - // When an on-demand provider answers a load. `immediate` answers within - // the request. `held` answers only at an `answer` or `drain` step, from the - // rows at that moment, so live changes can arrive while a load is in flight. - // Initial loads answer immediately. Omitted means `immediate`. - latency?: `immediate` | `held` - // What a holding provider does with an aborted or unloaded held request: - // `cooperative` installs none of its rows and fulfills at once; `late` - // cannot cancel it, so it answers it anyway. Omitted means `cooperative`. - cancellation?: `cooperative` | `late` - // A second live query over the same sources, with its own window: the - // first `offset + limit + 1` rows. Its window law holds beside the first - // query's, and the work laws, which count one consumer, do not apply. - peer?: boolean - // Where eligibility is decided: a predicate on the ordered source itself, - // a JavaScript function filter, or a filter on a LEFT-joined marker, where - // a marker admits its row (`joined`) or excludes it (`anti-joined`). The - // last three drop rows only after the source's limit. `full-joined` keeps - // every root row and adds a rootless row for each marker no root row - // matches; deleting a matched root row leaves its marker behind. - filter: `source` | `function` | `joined` | `anti-joined` | `full-joined` - // How a joined plan changes a row's eligibility: by inserting or deleting - // its marker, or by pointing the row's join key at a marker or at none. - // A join-key change leaves the row's order value as it was. - via?: `marker` | `link` - indexed: boolean - direction: `asc` | `desc` - // Where null ranks sort, and a full join's rootless rows with them. The - // index uses the same placement. Omitted means first, the default. - nulls?: `first` | `last` - // Filler rows sort after every domain row. Omitted means 200; a full-join - // case may have none, so its window can reach the null group. - fillers?: number - // Full join only: markers whose id names no root row. - orphans?: Array - terms: 1 | 2 - limit: number - offset: number - initial: Array<{ id: number; rank: number; eligible: boolean }> - history: Array -} - -const EAGER_FILLER = 200 - -function fillerRows( - direction: `asc` | `desc`, - count: number = EAGER_FILLER, -): Array { - // Eligible rows that sort after every domain row, so a correct window never - // needs them and a full rescan must deliver them. - return Array.from({ length: count }, (_, index) => ({ - id: 1000 + index, - rank: direction === `asc` ? 100 + index : -100 - index, - eligible: true, - label: `filler`, - })) -} - -function eagerRowOf(maxId: number) { - return fc.record({ - id: fc.integer({ min: 0, max: maxId }), - // NaN and null exercise comparator placement outside the integer order. - rank: fc.oneof( - { weight: 6, arbitrary: fc.integer({ min: 0, max: 15 }) }, - { weight: 1, arbitrary: fc.constant(Number.NaN) }, - { weight: 1, arbitrary: fc.constant(null as unknown as number) }, - { weight: 1, arbitrary: fc.constant(undefined as unknown as number) }, - ), - eligible: fc.boolean(), - }) -} - -const eagerRow = eagerRowOf(29) - -function eagerHistory( - flipWeight: number, - maxId: number, -): fc.Arbitrary> { - return fc.array( - fc.oneof( - eagerRowOf(maxId).map((row) => ({ type: `upsert` as const, ...row })), - fc.record({ - type: fc.constant(`delete` as const), - id: fc.integer({ min: 0, max: maxId }), - }), - fc.record({ - type: fc.constant(`touch` as const), - id: fc.integer({ min: 0, max: maxId }), - }), - { - weight: flipWeight, - arbitrary: fc.record({ - type: fc.constant(`flip` as const), - id: fc.integer({ min: 0, max: maxId }), - }), - }, - fc.record({ - type: fc.constant(`dup` as const), - id: fc.integer({ min: 0, max: maxId }), - }), - // Deletes the first visible row, so a repair runs on most histories. - fc.constant({ type: `delete-visible` as const }), - fc.constant({ type: `refetch` as const }), - fc.constant({ type: `answer` as const }), - fc.record({ - type: fc.constant(`move` as const), - offset: fc.integer({ min: 0, max: 2 }), - limit: fc.integer({ min: 1, max: 4 }), - }), - ), - { minLength: 1, maxLength: 12 }, - ) -} - -// Every dimension, before the campaign exclusion below. Derived campaigns -// start from this and apply the exclusion after their own maps. -const eagerCaseBase: fc.Arbitrary = fc.record({ - path: fc.constantFrom( - `sync` as const, - `local-only` as const, - `on-demand` as const, - ), - provider: fc.constantFrom( - `forward` as const, - `refetch` as const, - `refetch-signal` as const, - ), - latency: fc.constantFrom(`immediate` as const, `held` as const), - cancellation: fc.constantFrom(`cooperative` as const, `late` as const), - // One case in four has a peer, so most cases keep the work laws. - peer: fc.oneof( - { weight: 3, arbitrary: fc.constant(false) }, - { weight: 1, arbitrary: fc.constant(true) }, - ), - filter: fc.constantFrom( - `source` as const, - `function` as const, - `joined` as const, - `anti-joined` as const, - `full-joined` as const, - ), - via: fc.constantFrom(`marker` as const, `link` as const), - indexed: fc.boolean(), - direction: fc.constantFrom(`asc` as const, `desc` as const), - nulls: fc.constantFrom(`first` as const, `last` as const), - terms: fc.constantFrom(1 as const, 2 as const), - limit: fc.integer({ min: 1, max: 3 }), - offset: fc.integer({ min: 0, max: 1 }), - initial: fc.uniqueArray(eagerRow, { - selector: (row) => row.id, - maxLength: 20, - }), - history: eagerHistory(1, 29), -}) - -// A peer over a refetching on-demand provider can keep a short joined window -// 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 -// keeps its peer. -function withoutPeerRefetch(testCase: EagerCase): EagerCase { - return testCase.path === `on-demand` && - (testCase.provider === `refetch` || - testCase.provider === `refetch-signal`) && - testCase.peer && - testCase.filter !== `source` - ? { ...testCase, peer: false } - : testCase -} - -const eagerCase = eagerCaseBase.map(withoutPeerRefetch) - -// The premise of the joined-filter law: an indexed single-term plan whose -// filter drops rows after the bounded read. -// A repair before the gap forms reads the whole source for a joined plan, so -// no row can be missing afterwards. These histories therefore insert fresh -// keys (10 to 19), which can land past the bounded prefix, and change initial -// rows (0 to 9) mostly by eligibility alone, so a window row leaves only -// through the join. -const eagerJoinedHistory: fc.Arbitrary> = fc.array( - fc.oneof( - { - weight: 2, - // Ids 10 to 29: fresh rows, and the root rows of a full join's orphan - // markers (20 to 29), so an orphan can become matched. - arbitrary: eagerRowOf(19).map((row) => ({ - type: `upsert` as const, - ...row, - id: row.id + 10, - })), - }, - { - weight: 3, - arbitrary: fc.record({ - type: fc.constant(`flip` as const), - id: fc.integer({ min: 0, max: 9 }), - }), - }, - fc.record({ - type: fc.constant(`dup` as const), - id: fc.integer({ min: 0, max: 9 }), - }), - fc.record({ - type: fc.constant(`touch` as const), - id: fc.integer({ min: 0, max: 19 }), - }), - fc.record({ - type: fc.constant(`delete` as const), - id: fc.integer({ min: 0, max: 19 }), - }), - fc.constant({ type: `delete-visible` as const }), - fc.constant({ type: `refetch` as const }), - fc.constant({ type: `answer` as const }), - fc.record({ - type: fc.constant(`move` as const), - offset: fc.integer({ min: 0, max: 2 }), - limit: fc.integer({ min: 1, max: 4 }), - }), - ), - { minLength: 1, maxLength: 8 }, -) - -// Its histories favor eligibility-only changes: a row inserted past the -// bounded prefix, then a window row leaving only through the join. -const eagerJoinedCase: fc.Arbitrary = fc - .tuple( - eagerCaseBase, - fc.constantFrom( - `joined` as const, - `anti-joined` as const, - `full-joined` as const, - ), - fc.uniqueArray(eagerRowOf(9), { - selector: (row) => row.id, - minLength: 4, - maxLength: 8, - }), - eagerJoinedHistory, - ) - .map(([testCase, filter, initial, history]) => ({ - ...testCase, - filter, - indexed: true, - terms: 1 as const, - initial, - history, - })) - .map(withoutPeerRefetch) - -// The law's premise directly: eligible rows at distinct ranks, so the bounded -// prefix leaves rows unsent; a fresh row that may land past the prefix; then -// an initial row changes eligibility alone, so it can leave the window only -// through the join. Random commands may follow. -const gapRank: fc.Arbitrary = fc.oneof( - { weight: 6, arbitrary: fc.integer({ min: 0, max: 15 }) }, - { weight: 1, arbitrary: fc.constant(Number.NaN) }, - { weight: 1, arbitrary: fc.constant(null as unknown as number) }, - { weight: 1, arbitrary: fc.constant(undefined as unknown as number) }, -) - -const eagerJoinedGapCase: fc.Arbitrary = fc - .record({ - base: eagerJoinedCase, - // A null or NaN rank can end the bounded prefix. Neither is an - // expressible cursor, so the loader takes its prefix fallback there. - ranks: fc.uniqueArray(gapRank, { - minLength: 4, - maxLength: 8, - selector: String, - }), - insertRank: gapRank, - flipped: fc.nat(7), - rest: eagerJoinedHistory, - }) - .map(({ base, ranks, insertRank, flipped, rest }) => ({ - ...base, - initial: ranks.map((rank, id) => ({ id, rank, eligible: true })), - history: [ - { type: `upsert` as const, id: 10, rank: insertRank, eligible: true }, - { type: `flip` as const, id: flipped % ranks.length }, - ...rest, - ], - })) - .map(withoutPeerRefetch) - -// The full-join law's premise: a window that can reach the null group, where -// rootless rows and root rows with a null rank tie on the first order term. -// Cases favor no filler rows, so a few domain rows fill the window. Initial -// orphan markers (ids 20 to 29) start as rootless rows; root deletes and -// eligibility changes create and remove more. -const eagerFullJoinCase: fc.Arbitrary = fc - .record({ - base: eagerCaseBase, - fillers: fc.oneof( - { weight: 3, arbitrary: fc.constant(0) }, - { weight: 1, arbitrary: fc.constant(EAGER_FILLER) }, - ), - initial: fc.uniqueArray( - fc.record({ id: fc.nat(9), rank: gapRank, eligible: fc.boolean() }), - { minLength: 2, maxLength: 8, selector: (row) => row.id }, - ), - orphans: fc.uniqueArray(fc.integer({ min: 20, max: 29 }), { - minLength: 1, - maxLength: 3, - }), - history: eagerJoinedHistory, - }) - .map(({ base, fillers, initial, orphans, history }) => ({ - ...base, - filter: `full-joined` as const, - via: `marker` as const, - indexed: true, - fillers, - // Every case has a matched root row with a null rank and at least one - // orphan marker, so the null group holds both kinds of row. - initial: [ - ...initial.filter(({ id }) => id !== 9), - { id: 9, rank: null as unknown as number, eligible: true }, - ], - orphans, - history, - })) - .map(withoutPeerRefetch) - -// The in-flight premise: a holding on-demand provider, and a history that -// first deletes a visible row, so the refill request is in flight while the -// later commands change rows. Releases answer it from the rows at that -// moment, between those changes. -// The proven-report premise: a peer over a signalling refetch provider and a -// source filter, so the peer installs the row after the first query's page. -// Histories upsert rows that tie with the peer's end or sort past both -// windows, then refetch, so a report fires while the first query's own page -// and tie stay the same; later moves check that it owes no repair. -const eagerProvenReportCase: fc.Arbitrary = fc - .record({ - base: eagerCaseBase, - ranks: fc.uniqueArray(fc.integer({ min: 0, max: 15 }), { - minLength: 3, - maxLength: 6, - }), - upserts: fc.array( - fc.record({ id: fc.integer({ min: 10, max: 19 }), offset: fc.nat(3) }), - { minLength: 1, maxLength: 3 }, - ), - rest: eagerJoinedHistory, - }) - .map(({ base, ranks, upserts, rest }) => { - const sorted = [...ranks].sort((left, right) => left - right) - const size = base.offset + base.limit - // Ranks at or after the peer's end, so the first query's page keeps its - // rows while the peer's answers change. - const peerEnd = sorted[Math.min(size, sorted.length - 1)]! - return { - ...base, - path: `on-demand` as const, - provider: `refetch-signal` as const, - latency: `immediate` as const, - peer: true, - filter: `source` as const, - indexed: true, - direction: `asc` as const, - terms: 1 as const, - initial: sorted.map((rank, id) => ({ id, rank, eligible: true })), - history: [ - ...upserts.map(({ id, offset }) => ({ - type: `upsert` as const, - id, - rank: peerEnd + offset, - eligible: true, - })), - { type: `refetch` as const }, - ...rest, - ], - } - }) - -const eagerHeldCase: fc.Arbitrary = fc - .tuple( - eagerCaseBase, - // A change that lands while the refill is in flight: an eligible row - // with an id no other command uses (40 to 49), so the forwarding - // provider always sends it. - eagerRowOf(9).map((row) => ({ - type: `upsert` as const, - ...row, - id: row.id + 40, - eligible: true, - })), - fc.array( - fc.oneof( - { weight: 3, arbitrary: eagerHistory(1, 29).map(([first]) => first!) }, - { weight: 2, arbitrary: fc.constant({ type: `answer` as const }) }, - { - weight: 1, - arbitrary: fc.constant({ type: `release-peer` as const }), - }, - { - weight: 1, - arbitrary: fc.constant({ type: `delete-visible` as const }), - }, - ), - { minLength: 2, maxLength: 10 }, - ), - ) - .map(([testCase, change, rest]) => ({ - ...testCase, - path: `on-demand` as const, - // 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 fixed, this campaign needs no peer exclusion (#2155 concerns - // only a refetching provider). - provider: `forward` as const, - filter: - testCase.filter === `function` || testCase.filter === `full-joined` - ? (`source` as const) - : testCase.filter, - indexed: - testCase.filter === `function` || - testCase.filter === `full-joined` || - testCase.filter === `source` - ? true - : testCase.indexed, - // One order term, so the refill pages by cursor. - terms: 1 as const, - latency: `held` as const, - // A loaded prefix can hold headroom rows, so three visible deletes make - // a refill request likely before the change. - history: [ - { type: `delete-visible` as const }, - { type: `delete-visible` as const }, - { type: `delete-visible` as const }, - change, - ...rest, - ], - })) - -function isJoinedFilter({ filter }: EagerCase): boolean { - return filter === `joined` || filter === `anti-joined` -} - -function eagerOrder( - rows: ReadonlyMap, - { direction, nulls }: EagerCase, -): Array { - // Documented order (live-queries guide): nulls first in either direction - // unless the query asks for them last; NaN is greater than every other - // non-null value; ties by ascending id. `null` and `undefined` are both - // nullish, so they tie with each other. - const position = (rank: number | null | undefined): [number, number] => { - if (rank == null) return [nulls === `last` ? 3 : 0, 0] - if (Number.isNaN(rank)) return [direction === `asc` ? 2 : 1, 0] - return [direction === `asc` ? 1 : 2, direction === `asc` ? rank : -rank] - } - return [...rows.values()] - .filter((row) => row.eligible) - .sort((left, right) => { - const [leftGroup, leftValue] = position(left.rank) - const [rightGroup, rightValue] = position(right.rank) - return ( - leftGroup - rightGroup || leftValue - rightValue || left.id - right.id - ) - }) -} - -function eagerWindow( - rows: ReadonlyMap, - testCase: EagerCase, -): Array { - return eagerOrder(rows, testCase) - .slice(testCase.offset, testCase.offset + testCase.limit) - .map((row) => row.id) -} - -/** - * The visible window agrees with the model. The guide promises order only by - * the query's `orderBy` terms; rows that tie on every term may appear in any - * order. A source-filter plan's ties follow the source key, which the model - * also uses, so its window must match exactly. A joined plan's ties follow the - * joined result key, so its window must be any valid first `limit` rows: the - * same order values in sequence, each from an eligible row with that value. - */ -function expectEagerWindow( - actual: Array, - rows: ReadonlyMap, - testCase: EagerCase, - label: string, -): void { - const expected = eagerWindow(rows, testCase) - if (!isJoinedFilter(testCase) && testCase.filter !== `full-joined`) { - compareAt(label, () => expect(actual, label).toEqual(expected)) - return - } - // `null` and `undefined` tie, so either may come first among nullish rows. - const rank = (id: number) => { - const row = rows.get(id) - return row?.eligible - ? ((row.rank as number | null | undefined) ?? null) - : `ineligible ${id}` - } - compareAt(label, () => - expect(actual.map(rank), label).toEqual(expected.map(rank)), - ) - compareAt(`${label}: distinct rows`, () => - expect(new Set(actual).size, `${label}: distinct rows`).toBe(actual.length), - ) -} - -/** - * Whether a bounded prefix of the source leaves the window short. The prefix - * is the first `offset + limit` source rows in the query's order. A source - * filter runs inside the request, so its prefix holds only eligible rows. A - * joined or function filter drops rows only after the source's limit, so its - * prefix can hold fewer eligible rows than the window needs. - */ -function prefixLeavesWindowShort( - rows: ReadonlyMap, - testCase: EagerCase, -): boolean { - const size = testCase.offset + testCase.limit - const candidates = - testCase.filter === `source` - ? rows - : new Map( - [...rows].map(([rowId, row]) => [rowId, { ...row, eligible: true }]), - ) - const prefix = eagerOrder(candidates, testCase).slice(0, size) - return prefix.filter(({ id }) => rows.get(id)!.eligible).length < size -} - -/** Ranks of the last window row and the first row after the window. */ -function boundaryRanks( - rows: ReadonlyMap, - testCase: EagerCase, -): Array { - const end = testCase.offset + testCase.limit - // Generated rows may carry a null rank behind the `number` row type. - return eagerOrder(rows, testCase) - .slice(end - 1, end + 1) - .map((row): number | null => row.rank) -} // Properties take this as their predicate, which must return nothing; // runEagerWindow also returns the held-load reach counts. @@ -5676,132 +3088,7 @@ async function runEagerWindow(testCase: EagerCase): Promise<{ await currentPeer()?.cleanup() await source.cleanup() await markers.cleanup() - } -} - -type OptimisticWindowShape = `source` | `joined` | `anti-joined` -type OptimisticWindowMutation = `update` | `delete` -type OptimisticWindowConsumer = `collection` | `effect` -type OptimisticWindowOutcome = `acknowledge` | `rollback` -type OptimisticWindowExtent = `missing` | `installed` | `non-prefix` -type OptimisticWindowAcquisition = `required` | `forbidden` | `allowed` -type OptimisticWindowCheckpoint = - `initial` | `optimistic` | `settled` | `restored` -const MAX_OPTIMISTIC_WINDOW_REQUESTS = 8 -const OPTIMISTIC_ACQUISITION_OBSERVATION_TURNS = 8 - -// The most rows a bounded refill may ask for. An indexed plan pages past -// the window. An unindexed plan grows its prefix to twice the rows it -// installed (ARCHITECTURE.md, "an underfilled unindexed prefix grows"), and -// a request also covers the one row that the pending transaction overlays. -function refillBound({ indexed, width, installedIds }: OptimisticWindowCase) { - return indexed ? width + 2 : 2 * installedIds.length + 1 -} - -type OptimisticWindowCase = { - shape: OptimisticWindowShape - mutation: OptimisticWindowMutation - consumer: OptimisticWindowConsumer - outcome: OptimisticWindowOutcome - width: 1 | 2 - indexed: boolean - extent: OptimisticWindowExtent - installedIds: Array - acquisition: OptimisticWindowAcquisition - answerTiming?: `before-settlement` | `after-settlement` - mutationSource?: `root` | `marker` - rankMove?: boolean - rankDeparture?: boolean - direction?: `asc` | `desc` -} - -type OptimisticWindowPublication = { - rows: Array -} - -function copyOptimisticWindowPublication( - publication: OptimisticWindowPublication, -): OptimisticWindowPublication { - return { rows: [...publication.rows] } -} - -type OptimisticWindowObservation = { - initial: Array - immediate: Array - acquiring: Array - answerQueued: Array - settled: Array - afterAnswer?: Array - afterRestore?: Array - afterRestorePublications?: Array - rowsAfterFirstTurn: Array - rowsAfterSecondTurn: Array - immediatePublications: Array - preSettlementPublications: Array - publicationsAtSettlement: Array - publicationsAfterSettlement: Array - attempts: Array<{ - submittedRows: Array - limit: number | undefined - direction: string | undefined - // The request had a limit or a predicate, so it read no full source. - bounded?: boolean - appliedReceiptPending: boolean - providerPending: boolean - }> - acquisitionStartedBeforeSettlement?: boolean - requestHistory?: Array<{ - identity: string - newRows: number - phase?: string - submittedRows?: Array - limit?: number - direction?: string - allowsRejectedRoot?: boolean - selectedRanks?: Array - }> - settlementOutcome: `fulfilled` | `rejected` -} - -function referenceOptimisticWindow( - testCase: OptimisticWindowCase, - checkpoint: OptimisticWindowCheckpoint, -): Array { - // All three predicates accept 1, 2, and 3 in the remote ledger; 4 is the - // rejected leading root. The model starts from installed keys, applies the - // one overlay, and recomputes the window without the loader's prefix logic. - const authoritative = - (checkpoint === `settled` || checkpoint === `restored`) && - testCase.outcome === `acknowledge` - const installed = authoritative - ? testCase.rankMove - ? [1, 2, 3, 4] - : testCase.mutationSource === `marker` - ? [1, 2, 3, 4] - : [1, 2, 4] - : testCase.installedIds - const overlayActive = checkpoint === `optimistic` - const markerDeparturePersisted = - checkpoint === `settled` && - testCase.outcome === `acknowledge` && - testCase.mutationSource === `marker` - return installed - .filter( - (id) => - id !== 4 && - !( - (overlayActive || markerDeparturePersisted) && - id === 3 && - (!testCase.rankMove || testCase.rankDeparture) - ), - ) - .sort((left, right) => { - const moved = (overlayActive || authoritative) && testCase.rankMove - const leftRank = moved && left === 3 ? 0 : left - const rightRank = moved && right === 3 ? 0 : right - return rightRank - leftRank - }) - .slice(0, testCase.width) + } } async function observeAcquisitionAcrossTaskTurns( @@ -6362,652 +3649,677 @@ async function checkOptimisticWindow( ) } -function optimisticWindowViolations( - observation: OptimisticWindowObservation, - testCase: OptimisticWindowCase, -): Array { - const violations: Array = [] - const { outcome, width } = testCase - const optimistic = referenceOptimisticWindow(testCase, `optimistic`) - const settled = referenceOptimisticWindow(testCase, `settled`) - const lateAnswer = testCase.answerTiming === `after-settlement` - const expectedAtSettlement = - lateAnswer && outcome === `acknowledge` ? optimistic : settled - const compare = (label: string, actual: unknown, expected: unknown) => { - if (JSON.stringify(actual) !== JSON.stringify(expected)) { - violations.push( - `${label}: ${JSON.stringify(actual)} instead of ${JSON.stringify(expected)}`, - ) - } - } - compare(`immediate rows`, observation.immediate, optimistic) - compare( - `immediate publications`, - observation.immediatePublications.map(({ rows }) => rows), - [optimistic], - ) - compare(`acquiring rows`, observation.acquiring, optimistic) - compare(`queued-answer rows`, observation.answerQueued, optimistic) - compare( - `first optimistic publication`, - observation.preSettlementPublications[0]?.rows, - optimistic, - ) - compare( - `other pre-settlement publications`, - observation.preSettlementPublications.slice(1).map(({ rows }) => rows), - [], - ) - compare(`settled rows`, observation.settled, expectedAtSettlement) - if (lateAnswer) - compare(`rows after late answer`, observation.afterAnswer, settled) - if (testCase.mutationSource === `marker` && outcome === `acknowledge`) { - compare( - `rows after marker restoration`, - observation.afterRestore, - referenceOptimisticWindow(testCase, `restored`), - ) - compare( - `marker restoration publications`, - observation.afterRestorePublications?.map(({ rows }) => rows), - [referenceOptimisticWindow(testCase, `restored`)], - ) - } - compare( - `rows after first settlement turn`, - observation.rowsAfterFirstTurn, - settled, - ) - compare( - `rows after second settlement turn`, - observation.rowsAfterSecondTurn, - settled, - ) - const expectedSettledPublications = - JSON.stringify(optimistic) === JSON.stringify(expectedAtSettlement) - ? [] - : [expectedAtSettlement] - compare( - `settled publications`, - observation.publicationsAtSettlement - .slice(observation.preSettlementPublications.length) - .map(({ rows }) => rows), - expectedSettledPublications, - ) - compare( - `post-settlement publications`, - observation.publicationsAfterSettlement.map(({ rows }) => rows), - lateAnswer && outcome === `acknowledge` ? [settled] : [], - ) - const needsAcquisition = testCase.acquisition === `required` - if ( - needsAcquisition && - observation.acquisitionStartedBeforeSettlement === false - ) { - violations.push(`refill acquisition did not start before settlement`) - } - if (needsAcquisition && observation.attempts.length === 0) { - violations.push(`refill acquisition did not start`) - } - const refillId = width === 1 ? 2 : 1 - const refillAttempt = observation.attempts.find(({ submittedRows }) => - submittedRows.includes(refillId), - ) - if (needsAcquisition && !refillAttempt) { - violations.push(`refill answer did not submit row ${refillId}`) - } else if ( - needsAcquisition && - (refillAttempt?.direction !== (testCase.direction ?? `desc`) || - refillAttempt.limit === undefined || - refillAttempt.limit > refillBound(testCase)) - ) { - violations.push(`refill used an unbounded provider request`) - } else if ( - !lateAnswer && - refillAttempt && - !refillAttempt.appliedReceiptPending - ) { - violations.push(`applied receipt was not pending at the queued-answer cut`) - } - if (!lateAnswer && refillAttempt && !refillAttempt.providerPending) { - violations.push(`provider was not pending at the queued-answer cut`) - } - if (testCase.acquisition === `forbidden` && observation.attempts.length > 0) { - violations.push(`refill acquisition started with complete local extent`) - } - // A refill the case allows but does not require is still bounded: an - // ordered read within the refill bound, or a predicate read, never the - // full source. - if (testCase.acquisition === `allowed`) { - for (const attempt of observation.attempts) { - if ( - attempt.bounded === false || - (attempt.direction !== undefined && - (attempt.limit === undefined || - attempt.limit > refillBound(testCase))) - ) { - violations.push(`an allowed refill used an unbounded provider request`) - break - } - } - } - const requested = new Set() - for (const request of observation.requestHistory ?? []) { - if (request.phase === `pending` && testCase.indexed) { - const finiteOrdered = - request.direction === (testCase.direction ?? `desc`) && - request.limit !== undefined && - request.limit <= width + 2 - const boundaryTie = - request.direction === undefined && - request.selectedRanks !== undefined && - new Set(request.selectedRanks).size === 1 - if (!finiteOrdered && !boundaryTie) { - violations.push(`optimistic acquisition used a broad provider request`) - } - if (testCase.shape === `source` && request.allowsRejectedRoot) { - violations.push(`source refill omitted the source predicate`) - } - } - if (requested.has(request.identity) && request.newRows === 0) { - violations.push(`repeated an unchanged provider request without progress`) - break - } - requested.add(request.identity) - } - compare( - `settlement outcome`, - observation.settlementOutcome, - outcome === `acknowledge` ? `fulfilled` : `rejected`, - ) - return violations -} - describe(`eager indexed ordered windows`, () => { const { multiplier } = readOracleRunConfig() const runs = 40 * multiplier - it(`places null ranks first in an indexed descending window`, async () => { - oracleRoute(`fixed history`) - await checkEagerWindow({ - path: `sync`, - filter: `source`, - indexed: true, - direction: `desc`, - terms: 1, - limit: 1, - offset: 0, - initial: [{ id: 0, rank: null as unknown as number, eligible: true }], - history: [{ type: `upsert`, id: 1, rank: 2, eligible: true }], - }) - }) - - it(`refills after a visible delete without resending the source`, async () => { - oracleRoute(`fixed history`) - await checkEagerWindow({ - path: `local-only`, - filter: `source`, - indexed: true, - direction: `desc`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: 3, eligible: true }, - { id: 1, rank: 2, eligible: true }, - { id: 2, rank: 1, eligible: true }, - ], - history: [ - { type: `upsert`, id: 5, rank: 3, eligible: true }, - { type: `delete`, id: 5 }, - { type: `delete`, id: 0 }, - { type: `upsert`, id: 1, rank: 0, eligible: true }, - ], - }) - }) - - 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({ + // Named eager regressions, each replayed through the same checkEagerWindow + // driver and its row, work, and scan comparisons. The eager campaign + // arbitraries end in maps without unmappers, so they cannot take these + // histories back; they run as one enumerated source. + type EagerNamedHistory = { name: string; testCase: EagerCase } + const eagerNamedHistories: Array = [ + { + name: `places null ranks first in an indexed descending window`, + testCase: { + path: `sync`, + filter: `source`, + indexed: true, + direction: `desc`, + terms: 1, + limit: 1, + offset: 0, + initial: [{ id: 0, rank: null as unknown as number, eligible: true }], + history: [{ type: `upsert`, id: 1, rank: 2, eligible: true }], + }, + }, + { + name: `refills after a visible delete without resending the source`, + testCase: { + path: `local-only`, + filter: `source`, + indexed: true, + direction: `desc`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: 3, eligible: true }, + { id: 1, rank: 2, eligible: true }, + { id: 2, rank: 1, eligible: true }, + ], + history: [ + { type: `upsert`, id: 5, rank: 3, eligible: true }, + { type: `delete`, id: 5 }, + { type: `delete`, id: 0 }, + { type: `upsert`, id: 1, rank: 0, eligible: true }, + ], + }, + }, + // The bounded prefix ends inside a NaN tie group, which cannot be a + // cursor, so the repair falls back to a full-source read. + ...([`sync`, `local-only`] as const).map((path): EagerNamedHistory => ({ + name: `publishes one complete window when a repair reaches a NaN tie group (${path})`, + testCase: { path, filter: `source`, indexed: true, - direction: `desc`, + direction: `desc`, + terms: 1, + limit: 2, + offset: 0, + initial: [1, 2, 3, 4, 5].map((id) => ({ + id, + rank: Number.NaN, + eligible: true, + })), + history: [ + { type: `delete`, id: 1 }, + { type: `delete`, id: 3 }, + { type: `upsert`, id: 6, rank: Number.NaN, eligible: true }, + ], + }, + })), + // 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). + { + name: `refills a joined window over several pages after a refetch empties it`, + testCase: { + path: `on-demand`, + provider: `refetch`, + filter: `joined`, + via: `link`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 1, + initial: [ + { id: 0, rank: 1, eligible: true }, + { id: 1, rank: 2, eligible: true }, + { id: 2, rank: 0, eligible: true }, + { id: 3, rank: 3, eligible: true }, + ], + history: [ + { type: `upsert`, id: 10, rank: 0, eligible: true }, + { type: `flip`, id: 1 }, + { type: `flip`, id: 3 }, + { type: `delete`, id: 0 }, + { type: `flip`, id: 2 }, + ], + }, + }, + // 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 + // keeps served ranges whole sends it; one that re-runs each page alone + // (query-db-collection today) never does, and the window shows [4, 1]. + { + name: `shows a row that moved between two served pages after a refetch`, + testCase: { + path: `on-demand`, + provider: `refetch`, + filter: `joined`, + via: `marker`, + indexed: true, + direction: `desc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: Number.NaN, eligible: true }, + { id: 1, rank: 0, eligible: true }, + { id: 2, rank: 4, eligible: true }, + { id: 3, rank: 1, eligible: true }, + ], + history: [ + { type: `upsert`, id: 10, rank: 5, eligible: true }, + { type: `flip`, id: 0 }, + { type: `upsert`, id: 11, rank: 6, eligible: false }, + { type: `refetch` }, + ], + }, + }, + // The same trace with a provider that refetches each page alone and then + // 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. + { + name: `shows a row that moved between two served pages after a signalling refetch`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + filter: `joined`, + via: `marker`, + indexed: true, + direction: `desc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: Number.NaN, eligible: true }, + { id: 1, rank: 0, eligible: true }, + { id: 2, rank: 4, eligible: true }, + { id: 3, rank: 1, eligible: true }, + ], + history: [ + { type: `upsert`, id: 10, rank: 5, eligible: true }, + { type: `flip`, id: 0 }, + { type: `upsert`, id: 11, rank: 6, eligible: false }, + { type: `refetch` }, + ], + }, + }, + // An unindexed joined window whose only domain row is ineligible. After a + // signalling refetch the repair's bounded prefix holds just that row, so + // the window is short. The refill grows the prefix instead of reading the + // full source, which the refetch law forbids. + { + name: `grows the prefix of a short unindexed window after a signalling refetch`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed: false, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [], + history: [ + { type: `upsert`, id: 0, rank: 0, eligible: false }, + { type: `dup`, id: 0 }, + { type: `refetch` }, + ], + }, + }, + // The trace from #2221. A descending, nulls-first, unindexed window behind + // an anti-joined marker has a NaN rank, so its boundary has no cursor. + // After a signalling refetch the loader read the full source twice: the + // short prefix took the full-source fallback. The prefix now grows, so the + // refetch law, which allows no full-source read here, holds. + { + name: `reads no full source after a signalling refetch past a NaN boundary`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `anti-joined`, + via: `marker`, + indexed: false, + direction: `desc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: 0, eligible: true }, + { id: 2, rank: Number.NaN, eligible: true }, + { id: 1, rank: 0, eligible: false }, + ], + history: [ + { type: `delete-visible` }, + { type: `move`, offset: 0, limit: 1 }, + ], + }, + }, + // Trace 1 from a comment on #2221: an ascending unindexed window behind an + // anti-joined marker whose only row is ineligible. Before prefix growth the + // short prefix took the full-source fallback, twice, after a signalling + // refetch; the refetch law allows none. + { + name: `reads no full source after a signalling refetch of a short anti-joined window`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `anti-joined`, + via: `marker`, + indexed: false, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [], + history: [ + { type: `upsert`, id: 0, rank: 0, eligible: false }, + { type: `refetch` }, + ], + }, + }, + // Found by the unindexed-refetch random campaign. A refetch report moves + // the page's end onto a null rank, which no cursor can express. Reading + // that end's tie would take the boundary's full-source fallback, so the + // loader repairs with its bounded prefix instead; a loader that read the + // tie broke the refetch law. + { + name: `repairs instead of reading an unexpressible end's tie after a signalling refetch`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed: false, + direction: `desc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 1, + initial: [ + { id: 0, rank: null as unknown as number, eligible: true }, + { id: 2, rank: Number.NaN, eligible: true }, + { id: 1, rank: 0, eligible: true }, + { id: 3, rank: 0, eligible: true }, + ], + history: [ + { + type: `upsert`, + id: 10, + rank: null as unknown as number, + eligible: false, + }, + { type: `dup`, id: 0 }, + ], + }, + }, + // A peer's tie answer changes, so the provider reports, but the first + // query's page and tie answers stay the same and the peer installed the + // row after its page. That page proves the window, so the report must + // start no request for the first query and leave it owing no repair: the + // window move that follows continues from its boundary instead. + { + name: `starts no request for a window its own first page still proves after a report`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: true, + filter: `source`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [ + { id: 0, rank: 1, eligible: true }, + { id: 1, rank: 2, eligible: true }, + { id: 2, rank: 3, eligible: true }, + ], + history: [ + { type: `upsert`, id: 10, rank: 2, eligible: true }, + { type: `refetch` }, + { type: `move`, offset: 0, limit: 3 }, + ], + }, + }, + // A signalling refetch moves the first page's end earlier, to a rank that + // a row the provider never sent ties with. The window is still inside the + // page, but the rank-2 row past it is local only because the old tie + // request read it, so it proves nothing about the rows that now tie with + // 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. + { + name: `repairs a signalling refetch before a window move reads past its page`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: null as unknown as number, eligible: true }, + { id: 1, rank: 2, eligible: true }, + { id: 2, rank: 3, eligible: true }, + { id: 3, rank: Number.NaN, eligible: true }, + ], + history: [ + { type: `upsert`, id: 10, rank: 0, eligible: true }, + { type: `flip`, id: 2 }, + { type: `upsert`, id: 11, rank: 0, eligible: true }, + { type: `refetch` }, + { type: `move`, offset: 0, limit: 3 }, + { type: `delete`, id: 0 }, + ], + }, + }, + // A page with no later page whose end moves earlier. Two rank-0 rows + // arrive while the provider lags; the refetched page ends at rank 0, but + // the tie request still holds the rank-1 row, so the loader sees no + // delete. The report repairs the window. + { + name: `repairs a signalling refetch whose page now ends before its tie`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + filter: `anti-joined`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 3, + offset: 0, + initial: [ + { id: 0, rank: 0, eligible: true }, + { id: 1, rank: 0, eligible: true }, + { id: 2, rank: 1, eligible: true }, + { id: 5, rank: 13, eligible: false }, + ], + history: [ + { type: `upsert`, id: 10, rank: 0, eligible: false }, + { type: `upsert`, id: 11, rank: 0, eligible: true }, + { type: `refetch` }, + ], + }, + }, + // A second live-query Collection over a refetched on-demand source kept a + // short joined window (#2155): after the refetch 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 this trace. + { + name: `shows a peer's full joined window after a refetch removes a tied row`, + testCase: { + path: `on-demand`, + provider: `refetch`, + latency: `immediate`, + peer: true, + filter: `joined`, + via: `link`, + indexed: true, + direction: `asc`, + nulls: `first`, terms: 1, limit: 2, offset: 0, - initial: [1, 2, 3, 4, 5].map((id) => ({ - id, - rank: Number.NaN, - eligible: true, - })), + initial: [ + { id: 9, rank: 0, eligible: true }, + { id: 0, rank: 0, eligible: true }, + { id: 1, rank: 0, eligible: true }, + { id: 2, rank: 1, eligible: false }, + ], history: [ - { type: `delete`, id: 1 }, - { type: `delete`, id: 3 }, - { type: `upsert`, id: 6, rank: Number.NaN, eligible: true }, + { type: `upsert`, id: 10, rank: 1, eligible: false }, + { type: `flip`, id: 0 }, ], - }) + }, }, - ) - - 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). - await checkEagerWindow({ - path: `on-demand`, - provider: `refetch`, - filter: `joined`, - via: `link`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 1, - initial: [ - { id: 0, rank: 1, eligible: true }, - { id: 1, rank: 2, eligible: true }, - { id: 2, rank: 0, eligible: true }, - { id: 3, rank: 3, eligible: true }, - ], - history: [ - { type: `upsert`, id: 10, rank: 0, eligible: true }, - { type: `flip`, id: 1 }, - { type: `flip`, id: 3 }, - { type: `delete`, id: 0 }, - { type: `flip`, id: 2 }, - ], - }) - }) - - 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 - // keeps served ranges whole sends it; one that re-runs each page alone - // (query-db-collection today) never does, and the window shows [4, 1]. - await checkEagerWindow({ - path: `on-demand`, - provider: `refetch`, - filter: `joined`, - via: `marker`, - indexed: true, - direction: `desc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: Number.NaN, eligible: true }, - { id: 1, rank: 0, eligible: true }, - { id: 2, rank: 4, eligible: true }, - { id: 3, rank: 1, eligible: true }, - ], - history: [ - { type: `upsert`, id: 10, rank: 5, eligible: true }, - { type: `flip`, id: 0 }, - { type: `upsert`, id: 11, rank: 6, eligible: false }, - { type: `refetch` }, - ], - }) - }) - - // The same trace with a provider that refetches each page alone and then - // 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`, - filter: `joined`, - via: `marker`, - indexed: true, - direction: `desc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: Number.NaN, eligible: true }, - { id: 1, rank: 0, eligible: true }, - { id: 2, rank: 4, eligible: true }, - { id: 3, rank: 1, eligible: true }, - ], - history: [ - { type: `upsert`, id: 10, rank: 5, eligible: true }, - { type: `flip`, id: 0 }, - { type: `upsert`, id: 11, rank: 6, eligible: false }, - { type: `refetch` }, - ], - }) - }) - - // An unindexed joined window whose only domain row is ineligible. After a - // signalling refetch the repair's bounded prefix holds just that row, so - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `joined`, - via: `marker`, - indexed: false, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [], - history: [ - { type: `upsert`, id: 0, rank: 0, eligible: false }, - { type: `dup`, id: 0 }, - { type: `refetch` }, - ], - }) - }) - - // The trace from #2221. A descending, nulls-first, unindexed window behind - // an anti-joined marker has a NaN rank, so its boundary has no cursor. - // After a signalling refetch the loader read the full source twice: the - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `anti-joined`, - via: `marker`, - indexed: false, - direction: `desc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: 0, eligible: true }, - { id: 2, rank: Number.NaN, eligible: true }, - { id: 1, rank: 0, eligible: false }, - ], - history: [ - { type: `delete-visible` }, - { type: `move`, offset: 0, limit: 1 }, - ], - }) - }) - - // Trace 1 from a comment on #2221: an ascending unindexed window behind an - // anti-joined marker whose only row is ineligible. Before prefix growth the - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `anti-joined`, - via: `marker`, - indexed: false, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [], - history: [ - { type: `upsert`, id: 0, rank: 0, eligible: false }, - { type: `refetch` }, - ], - }) - }) - - // Found by the unindexed-refetch random campaign. A refetch report moves - // the page's end onto a null rank, which no cursor can express. Reading - // that end's tie would take the boundary's full-source fallback, so the - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `joined`, - via: `marker`, - indexed: false, - direction: `desc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 1, - initial: [ - { id: 0, rank: null as unknown as number, eligible: true }, - { id: 2, rank: Number.NaN, eligible: true }, - { id: 1, rank: 0, eligible: true }, - { id: 3, rank: 0, eligible: true }, - ], - history: [ - { - type: `upsert`, - id: 10, - rank: null as unknown as number, - eligible: false, - }, - { type: `dup`, id: 0 }, - ], - }) - }) - - // A peer's tie answer changes, so the provider reports, but the first - // query's page and tie answers stay the same and the peer installed the - // row after its page. That page proves the window, so the report must - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: true, - filter: `source`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [ - { id: 0, rank: 1, eligible: true }, - { id: 1, rank: 2, eligible: true }, - { id: 2, rank: 3, eligible: true }, - ], - history: [ - { type: `upsert`, id: 10, rank: 2, eligible: true }, - { type: `refetch` }, - { type: `move`, offset: 0, limit: 3 }, - ], - }) - }) - - // A signalling refetch moves the first page's end earlier, to a rank that - // a row the provider never sent ties with. The window is still inside the - // page, but the rank-2 row past it is local only because the old tie - // request read it, so it proves nothing about the rows that now tie with - // 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `joined`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: null as unknown as number, eligible: true }, - { id: 1, rank: 2, eligible: true }, - { id: 2, rank: 3, eligible: true }, - { id: 3, rank: Number.NaN, eligible: true }, - ], - history: [ - { type: `upsert`, id: 10, rank: 0, eligible: true }, - { type: `flip`, id: 2 }, - { type: `upsert`, id: 11, rank: 0, eligible: true }, - { type: `refetch` }, - { type: `move`, offset: 0, limit: 3 }, - { type: `delete`, id: 0 }, - ], - }) - }) - - // A page with no later page whose end moves earlier. Two rank-0 rows - // arrive while the provider lags; the refetched page ends at rank 0, but - // 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`, - filter: `anti-joined`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 3, - offset: 0, - initial: [ - { id: 0, rank: 0, eligible: true }, - { id: 1, rank: 0, eligible: true }, - { id: 2, rank: 1, eligible: true }, - { id: 5, rank: 13, eligible: false }, - ], - history: [ - { type: `upsert`, id: 10, rank: 0, eligible: false }, - { type: `upsert`, id: 11, rank: 0, eligible: true }, - { type: `refetch` }, - ], - }) - }) - - // A second live-query Collection over a refetched on-demand source kept a - // short joined window (#2155): after the refetch 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 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`, - latency: `immediate`, - peer: true, - filter: `joined`, - via: `link`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 9, rank: 0, eligible: true }, - { id: 0, rank: 0, eligible: true }, - { id: 1, rank: 0, eligible: true }, - { id: 2, rank: 1, eligible: false }, - ], - history: [ - { type: `upsert`, id: 10, rank: 1, eligible: false }, - { type: `flip`, id: 0 }, - ], - }) - }) - - 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 // receive the change. - await checkEagerWindow({ - path: `sync`, - filter: `full-joined`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 1, - initial: [ - { id: 0, rank: 0, eligible: false }, - { id: 1, rank: Number.NaN, eligible: false }, - ], - history: [ - { type: `delete`, id: 0 }, - { type: `flip`, id: 0 }, - ], - fillers: 0, - orphans: [], - }) - }) - - it(`refills an unindexed on-demand anti-join window after a visible delete`, async () => { - oracleRoute(`fixed history`) + { + name: `publishes a full-join window that only loses a row`, + testCase: { + path: `sync`, + filter: `full-joined`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 1, + initial: [ + { id: 0, rank: 0, eligible: false }, + { id: 1, rank: Number.NaN, eligible: false }, + ], + history: [ + { type: `delete`, id: 0 }, + { type: `flip`, id: 0 }, + ], + fillers: 0, + orphans: [], + }, + }, // 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 // full source. - await checkEagerWindow({ - path: `on-demand`, - filter: `anti-joined`, - via: `marker`, - indexed: false, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [], - history: [ - { type: `upsert`, id: 0, rank: 0, eligible: false }, - { type: `delete-visible` }, - { type: `dup`, id: 0 }, - ], - }) + { + name: `refills an unindexed on-demand anti-join window after a visible delete`, + testCase: { + path: `on-demand`, + filter: `anti-joined`, + via: `marker`, + indexed: false, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [], + history: [ + { type: `upsert`, id: 0, rank: 0, eligible: false }, + { type: `delete-visible` }, + { type: `dup`, id: 0 }, + ], + }, + }, + // 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 + // the null group would leave row 3 unloaded and could show its marker + // as a rootless row; restoring the old join rule fails this case. + ...( + [ + [`sync`, 1], + [`local-only`, 1], + [`sync`, 2], + [`local-only`, 2], + ] as const + ).map(([path, terms]): EagerNamedHistory => ({ + name: `shows no rootless full-join row for a null-ranked root past the prefix (${path}, ${terms} terms)`, + testCase: { + path, + filter: `full-joined`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `last`, + fillers: 0, + terms, + limit: 2, + offset: 0, + initial: [ + { id: 1, rank: 1, eligible: false }, + { id: 2, rank: null as unknown as number, eligible: false }, + { id: 3, rank: null as unknown as number, eligible: true }, + ], + history: [{ type: `touch`, id: 1 }], + }, + })), + // 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 + // from the first root row. + ...([`sync`, `local-only`] as const).map((path): EagerNamedHistory => ({ + name: `shows no rootless full-join row whose root row is past the prefix (${path})`, + testCase: { + path, + filter: `full-joined`, + via: `marker`, + indexed: true, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [0, 1, 2, 3, 4, 5].map((id) => ({ + id, + rank: id, + eligible: true, + })), + history: [{ type: `delete-visible` }], + }, + })), + // 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. + // Shrunk from seed -1253945411. + ...([`sync`, `local-only`] as const).map((path): EagerNamedHistory => ({ + name: `refills a joined-filter window when a row leaves only through the join (${path})`, + testCase: { + path, + filter: `joined`, + indexed: true, + direction: `asc`, + terms: 1, + limit: 1, + offset: 1, + initial: [ + { id: 1, rank: 0, eligible: true }, + { id: 3, rank: null as unknown as number, eligible: true }, + { id: 4, rank: 1, eligible: true }, + ], + history: [ + { type: `upsert`, id: 0, rank: 2, eligible: true }, + { + type: `upsert`, + id: 3, + rank: null as unknown as number, + eligible: false, + }, + ], + }, + })), + // 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. + ...([`sync`, `local-only`] as const).map((path): EagerNamedHistory => ({ + name: `refills a joined-filter window past a retained distant row (${path})`, + testCase: { + path, + filter: `joined`, + indexed: true, + direction: `asc`, + terms: 1, + limit: 1, + offset: 0, + initial: Array.from({ length: 10 }, (_, index) => ({ + id: index + 1, + rank: index + 1, + eligible: (index + 1) % 2 === 0, + })), + history: [ + { type: `touch`, id: 10 }, + { type: `delete`, id: 2 }, + ], + }, + })), + ...[false, true].map((indexed): EagerNamedHistory => ({ + name: `revalidates an empty joined prefix after an ineligible insert (indexed: ${indexed})`, + testCase: { + path: `on-demand`, + provider: `refetch-signal`, + latency: `immediate`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed, + direction: `asc`, + nulls: `first`, + terms: 1, + limit: 1, + offset: 0, + initial: [], + history: [ + { type: `upsert`, id: 0, rank: 0, eligible: false }, + { type: `flip`, id: 1 }, + ], + }, + })), + // 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. + { + name: `keeps a joined window within its settled source prefix after held answers`, + testCase: { + path: `on-demand`, + provider: `forward`, + latency: `held`, + cancellation: `cooperative`, + peer: false, + filter: `joined`, + via: `marker`, + indexed: false, + direction: `desc`, + nulls: `last`, + terms: 1, + limit: 2, + offset: 0, + initial: [ + { id: 0, rank: 0, eligible: false }, + { id: 1, rank: 0, eligible: true }, + { id: 2, rank: Number.NaN, eligible: true }, + ], + history: [ + { type: `delete-visible` }, + { type: `delete-visible` }, + { type: `delete-visible` }, + { + type: `upsert`, + id: 40, + rank: null as unknown as number, + eligible: true, + }, + { type: `delete-visible` }, + { type: `delete`, id: 1 }, + ], + }, + }, + ] + oracleProperty({ + id: `ordered-work.work-eager-fixed`, + law: { + statement: `keeps rows and per-change work bounded for each named eager history`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named regressions outside the generated eager arbitraries, through the same driver and comparisons as the eager campaigns.`, + }, + 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', + ], + histories: eagerNamedHistories, + label: ({ name }) => name, + ...orderedWorkJudgment(({ testCase }: EagerNamedHistory) => + checkEagerWindow(testCase), + ), + check: (_plan, _observations, verdict) => verdict, }) const baseOptimisticWindowCases = ( @@ -7147,596 +4459,361 @@ 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. - expect(actual).toEqual([]) - }, - ) - - it(`refills an optimistic rank move that leaves a full installed window`, async () => { - oracleRoute(`fixed history`) + // Every optimistic-window history replays through the same production + // driver and the independent public-window check. + type OptimisticNamedHistory = { + name: string + testCase: OptimisticWindowCase + } + const optimisticNamedHistories: Array = [ + ...optimisticWindowCases.map((testCase) => ({ + name: `characterizes a width-${testCase.width} ${testCase.indexed} indexed optimistic ${testCase.shape} ${testCase.mutation} from ${testCase.mutationSource} with ${testCase.extent} replacement extent for a ${testCase.consumer} before ${testCase.outcome} (${testCase.answerTiming})`, + testCase, + })), // 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. - const testCase: OptimisticWindowCase = { - shape: `source`, - mutation: `update`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [3, 2], - acquisition: `required`, - rankMove: true, - } - const observation = await checkOptimisticWindow(testCase) - expect(optimisticWindowViolations(observation, testCase)).toEqual([]) - }) - - it(`restores an optimistic membership departure with a rank change on rollback`, async () => { - oracleRoute(`fixed history`) + { + name: `refills an optimistic rank move that leaves a full installed window`, + testCase: { + shape: `source`, + mutation: `update`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [3, 2], + acquisition: `required`, + rankMove: true, + }, + }, // 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. - const testCase: OptimisticWindowCase = { - shape: `source`, - mutation: `update`, - consumer: `collection`, - outcome: `rollback`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [3, 2], - acquisition: `required`, - rankMove: true, - rankDeparture: true, - } - const observation = await checkOptimisticWindow(testCase) - expect(optimisticWindowViolations(observation, testCase)).toEqual([]) - }) - - it.each( - ([`joined`, `anti-joined`] as const).flatMap((shape) => - ([`acknowledge`, `rollback`] as const).map((outcome) => ({ - shape, - outcome, - })), - ), - )( - `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 = { - shape, - mutation: `delete`, + { + name: `restores an optimistic membership departure with a rank change on rollback`, + testCase: { + shape: `source`, + mutation: `update`, consumer: `collection`, - outcome, - width: 1, + outcome: `rollback`, + width: 2, indexed: true, extent: `missing`, - installedIds: [4, 3], + installedIds: [3, 2], acquisition: `required`, - direction: `asc`, - } - const observation = await checkOptimisticWindow(testCase) - expect(optimisticWindowViolations(observation, testCase)).toEqual([]) + rankMove: true, + rankDeparture: true, + }, }, - ) - - it(`rejects a hostile optimistic observation that makes no refill progress`, () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `joined`, - mutation: `delete`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [4, 3, 2], - acquisition: `required`, - } - const observation: OptimisticWindowObservation = { - initial: [3, 2], - immediate: [2], - acquiring: [2], - answerQueued: [2], - settled: [2, 1], - rowsAfterFirstTurn: [2, 1], - rowsAfterSecondTurn: [2, 1], - immediatePublications: [{ rows: [2] }], - preSettlementPublications: [{ rows: [2] }], - publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], - publicationsAfterSettlement: [], - attempts: [], - settlementOutcome: `fulfilled`, - } - - expect(optimisticWindowViolations(observation, testCase)).toEqual([ - `refill acquisition did not start`, - `refill answer did not submit row 1`, - ]) - }) - - it(`rejects a delayed Collection publication`, () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `joined`, - mutation: `update`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [4, 3, 2], - acquisition: `required`, - } - const observation: OptimisticWindowObservation = { - initial: [3, 2], - immediate: [2], - acquiring: [2], - answerQueued: [2], - settled: [2, 1], - rowsAfterFirstTurn: [2, 1], - rowsAfterSecondTurn: [2, 1], - immediatePublications: [], - preSettlementPublications: [{ rows: [2] }], - publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], - publicationsAfterSettlement: [], - attempts: [ - { - submittedRows: [1], - limit: 2, - direction: `desc`, - appliedReceiptPending: true, - providerPending: true, - }, - ], - settlementOutcome: `fulfilled`, - } - - expect(optimisticWindowViolations(observation, testCase)).toEqual([ - `immediate publications: [] instead of [[2]]`, - ]) - }) - - it(`rejects a stale publication after transaction settlement`, () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `joined`, - mutation: `update`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [4, 3, 2], - acquisition: `required`, - } - const observation: OptimisticWindowObservation = { - initial: [3, 2], - immediate: [2], - acquiring: [2], - answerQueued: [2], - settled: [2, 1], - rowsAfterFirstTurn: [2, 1], - rowsAfterSecondTurn: [2], - immediatePublications: [{ rows: [2] }], - preSettlementPublications: [{ rows: [2] }], - publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], - publicationsAfterSettlement: [{ rows: [2] }], - attempts: [ - { - submittedRows: [1], - limit: 2, - direction: `desc`, - appliedReceiptPending: true, - providerPending: true, - }, - ], - settlementOutcome: `fulfilled`, - } - - expect(optimisticWindowViolations(observation, testCase)).toEqual([ - `rows after second settlement turn: [2] instead of [2,1]`, - `post-settlement publications: [[2]] instead of []`, - ]) - }) - - it(`rejects a full-source refill disguised as useful bounded progress`, () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `joined`, - mutation: `delete`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [4, 3, 2], - acquisition: `required`, - } - const observation: OptimisticWindowObservation = { - initial: [3, 2], - immediate: [2], - acquiring: [2], - answerQueued: [2], - settled: [2, 1], - rowsAfterFirstTurn: [2, 1], - rowsAfterSecondTurn: [2, 1], - immediatePublications: [{ rows: [2] }], - preSettlementPublications: [{ rows: [2] }], - publicationsAtSettlement: [{ rows: [2] }, { rows: [2, 1] }], - publicationsAfterSettlement: [], - attempts: [ - { - submittedRows: [4, 3, 2, 1, 0, -1], - limit: 6, - direction: `desc`, - appliedReceiptPending: true, - providerPending: true, - }, - ], - settlementOutcome: `fulfilled`, - } - - expect(optimisticWindowViolations(observation, testCase)).toEqual([ - `refill used an unbounded provider request`, - ]) - const repeated = { - ...observation, - attempts: [{ ...observation.attempts[0]!, limit: 2 }], - requestHistory: [ - { identity: `same ordered page`, newRows: 1 }, - { identity: `same ordered page`, newRows: 0 }, - ], - } - expect(optimisticWindowViolations(repeated, testCase)).toEqual([ - `repeated an unchanged provider request without progress`, - ]) - }) - - it(`rejects a source refill that omits eligibility or adds broad work`, async () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `source`, - mutation: `delete`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [3, 2], - acquisition: `required`, - } - const observation = await checkOptimisticWindow(testCase) - expect(optimisticWindowViolations(observation, testCase)).toEqual([]) - const refill = observation.requestHistory!.find( - ({ phase, submittedRows }) => - phase === `pending` && submittedRows?.includes(1), - )! - - // A finite, ordered request that drops the source predicate still returns - // the needed row, but a rejected peer at the same rank exposes the omission. - const missingPredicate: OptimisticWindowObservation = { - ...observation, - requestHistory: observation.requestHistory!.map((request) => - request === refill ? { ...request, allowsRejectedRoot: true } : request, - ), - } - expect(optimisticWindowViolations(missingPredicate, testCase)).toContain( - `source refill omitted the source predicate`, - ) - - // Useful progress from the first page cannot license a later, unrelated - // full-source fetch while the optimistic transaction remains pending. - const extraBroadRequest: OptimisticWindowObservation = { - ...observation, - requestHistory: [ - ...observation.requestHistory!, - { - identity: `distinct full-source request`, - phase: `pending`, - newRows: 0, - submittedRows: [4, 3, 2, 1, 0, -1], - selectedRanks: [4, 3, 2, 1, 0, -1], - allowsRejectedRoot: true, + // Negated ranks make row 3 the ascending head and row 2 its provider + // successor. The leading rejected row 4 remains ahead of both. + ...([`joined`, `anti-joined`] as const) + .flatMap((shape) => + ([`acknowledge`, `rollback`] as const).map((outcome) => ({ + shape, + outcome, + })), + ) + .map(({ shape, outcome }): OptimisticNamedHistory => ({ + name: `publishes an ascending ${shape} limit-one optimistic delete before ${outcome}`, + testCase: { + shape, + mutation: `delete`, + consumer: `collection`, + outcome, + width: 1, + indexed: true, + extent: `missing`, + installedIds: [4, 3], + acquisition: `required`, + direction: `asc`, }, - ], - } - expect(optimisticWindowViolations(extraBroadRequest, testCase)).toContain( - `optimistic acquisition used a broad provider request`, - ) - }) - - it(`rejects acquisition deferred until after optimistic settlement`, async () => { - oracleRoute(`calibration`) - const testCase: OptimisticWindowCase = { - shape: `source`, - mutation: `delete`, - consumer: `collection`, - outcome: `acknowledge`, - width: 2, - indexed: true, - extent: `missing`, - installedIds: [3, 2], - acquisition: `required`, - } + })), + ] + async function judgeOptimisticWindow({ + testCase, + }: OptimisticNamedHistory): Promise { const observation = await checkOptimisticWindow(testCase) - expect(optimisticWindowViolations(observation, testCase)).toEqual([]) - expect( - optimisticWindowViolations( - { ...observation, acquisitionStartedBeforeSettlement: false }, - testCase, - ), - ).toContain(`refill acquisition did not start before settlement`) - }) - - /** - * These complete-local-extent histories isolate the immediate publication - * law from provider timing. The reference filters one deleted key from a - * plain row list, orders it, then takes the requested slice. A tie deletes - * whichever equal-ranked key the initial window selected; the remaining - * peer is then the only valid top row, independent of tie-breaking policy. - */ - it.each( - [ - { - name: `ascending head`, - ranks: [1, 2, 3], - direction: `asc` as const, - offset: 0, - limit: 1, - }, - { - name: `descending offset`, - ranks: [1, 2, 3, 4], - direction: `desc` as const, - offset: 1, - limit: 1, - }, + const actual = optimisticWindowViolations(observation, testCase) + // Each legal history must refine the independent public-window model. + expect(actual).toEqual([]) + } + oracleProperty({ + id: `ordered-work.work-optimistic-window`, + law: { + statement: `An optimistic departure from an on-demand ordered window is public before settlement, starts one useful bounded acquisition when extent is missing, keeps a pending answer private, and settles to the acknowledged or restored window.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The finite optimistic-window grammar and named histories over one six-row ledger; callback payload coherence is owned elsewhere.`, + }, + 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', + ], + histories: optimisticNamedHistories, + label: ({ name }) => name, + ...orderedWorkJudgment(judgeOptimisticWindow), + check: (_plan, _observations, verdict) => verdict, + calibrations: [ { - name: `tied head`, - ranks: [2, 3, 3], - direction: `desc` as const, - offset: 0, - limit: 1, + name: `rejects a source refill that omits eligibility or adds broad work`, + law: `calibration: source refill eligibility and broad work`, + // The old calibration ran this exact case; any other history runs + // the clean judgment alone. Its matchers stay as they were: only when + // they all hold has the check rejected the corrupted observation. + driver: ( + plan: { history: OptimisticNamedHistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(plan, observe, async () => { + const { testCase } = plan.history + const observation = await checkOptimisticWindow(testCase) + expect(optimisticWindowViolations(observation, testCase)).toEqual( + [], + ) + if ( + !isDeepStrictEqual(testCase, { + shape: `source`, + mutation: `delete`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [3, 2], + acquisition: `required`, + }) + ) + return + const refill = observation.requestHistory!.find( + ({ phase, submittedRows }) => + phase === `pending` && submittedRows?.includes(1), + )! + + // A finite, ordered request that drops the source predicate still returns + // the needed row, but a rejected peer at the same rank exposes the omission. + const missingPredicate: OptimisticWindowObservation = { + ...observation, + requestHistory: observation.requestHistory!.map((request) => + request === refill + ? { ...request, allowsRejectedRoot: true } + : request, + ), + } + expect( + optimisticWindowViolations(missingPredicate, testCase), + ).toContain(`source refill omitted the source predicate`) + + // Useful progress from the first page cannot license a later, unrelated + // full-source fetch while the optimistic transaction remains pending. + const extraBroadRequest: OptimisticWindowObservation = { + ...observation, + requestHistory: [ + ...observation.requestHistory!, + { + identity: `distinct full-source request`, + phase: `pending`, + newRows: 0, + submittedRows: [4, 3, 2, 1, 0, -1], + selectedRanks: [4, 3, 2, 1, 0, -1], + allowsRejectedRoot: true, + }, + ], + } + expect( + optimisticWindowViolations(extraBroadRequest, testCase), + ).toContain(`optimistic acquisition used a broad provider request`) + rejectAt( + `calibration: source refill eligibility and broad work`, + new Error( + `the optimistic window check reported the corrupted observation`, + ), + ) + }), }, { - name: `exhausted window`, - ranks: [1, 2, 3], - direction: `desc` as const, - offset: 0, - limit: 4, + name: `rejects acquisition deferred until after optimistic settlement`, + law: `calibration: acquisition deferred until settlement`, + // The old calibration ran this exact case; any other history runs + // the clean judgment alone. Its matchers stay as they were: only when + // they all hold has the check rejected the corrupted observation. + driver: ( + plan: { history: OptimisticNamedHistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(plan, observe, async () => { + const { testCase } = plan.history + const observation = await checkOptimisticWindow(testCase) + expect(optimisticWindowViolations(observation, testCase)).toEqual( + [], + ) + if ( + !isDeepStrictEqual(testCase, { + shape: `source`, + mutation: `delete`, + consumer: `collection`, + outcome: `acknowledge`, + width: 2, + indexed: true, + extent: `missing`, + installedIds: [3, 2], + acquisition: `required`, + }) + ) + return + expect( + optimisticWindowViolations( + { ...observation, acquisitionStartedBeforeSettlement: false }, + testCase, + ), + ).toContain(`refill acquisition did not start before settlement`) + rejectAt( + `calibration: acquisition deferred until settlement`, + new Error( + `the optimistic window check reported the corrupted observation`, + ), + ) + }), }, - ].flatMap((window) => - ([`acknowledge`, `rollback`] as const).map((outcome) => ({ - ...window, - outcome, - })), - ), - )( - `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( - localOnlyCollectionOptions({ - id: `optimistic-installed-${Math.random()}`, - getKey: (row) => row.id, - initialData: rows, - onDelete: async () => { - await persistence.promise - }, - }), - ) - source.createIndex((row) => row.rank, { indexType: BTreeIndex }) - const query = createLiveQueryCollection({ - query: (q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - .offset(offset) - .limit(limit), - }) - const ids = () => query.toArray.map(({ id }) => id) - const order = (removed?: number) => - expectedInstalledWindow(rows, { direction, offset, limit }, removed) - let unsubscribe: (() => void) | undefined - try { - await query.preload() - const initial = ids() - if (ranks[1] !== ranks[2]) expect(initial).toEqual(order()) - const departed = initial[0]! - const publications: Array> = [] - const subscription = query.subscribeChanges( - () => publications.push(ids()), - { includeInitialState: true }, - ) - unsubscribe = () => subscription.unsubscribe() - publications.length = 0 - - const transaction = source.delete(departed) - const expected = order(departed) - expect(ids(), `immediate installed rows`).toEqual(expected) - expect(publications, `synchronous installed publication`).toEqual([ - expected, - ]) - if (outcome === `acknowledge`) persistence.resolve() - else persistence.reject(new Error(`rollback`)) - await transaction.when(`settled`).catch(() => undefined) - expect(ids(), `settled installed rows`).toEqual( - outcome === `acknowledge` ? expected : initial, - ) - } finally { - persistence.resolve() - unsubscribe?.() - await query.cleanup() - await source.cleanup() - } - }, - ) + ], + }) - it.each([ - [`sync`, 1], - [`local-only`, 1], - [`sync`, 2], - [`local-only`, 2], - ] 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 - // the null group would leave row 3 unloaded and could show its marker - // as a rootless row; restoring the old join rule fails this case. - await checkEagerWindow({ - path, - filter: `full-joined`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `last`, - fillers: 0, - terms, - limit: 2, - offset: 0, - initial: [ - { id: 1, rank: 1, eligible: false }, - { id: 2, rank: null as unknown as number, eligible: false }, - { id: 3, rank: null as unknown as number, eligible: true }, - ], - history: [{ type: `touch`, id: 1 }], - }) + /** + * These complete-local-extent histories isolate the immediate publication + * law from provider timing. The reference filters one deleted key from a + * plain row list, orders it, then takes the requested slice. A tie deletes + * whichever equal-ranked key the initial window selected; the remaining + * peer is then the only valid top row, independent of tie-breaking policy. + */ + const installedDepartureHistories = [ + { + name: `ascending head`, + ranks: [1, 2, 3], + direction: `asc` as const, + offset: 0, + limit: 1, }, - ) - - 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 - // from the first root row. - await checkEagerWindow({ - path, - filter: `full-joined`, - via: `marker`, - indexed: true, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [0, 1, 2, 3, 4, 5].map((id) => ({ - id, - rank: id, - eligible: true, - })), - history: [{ type: `delete-visible` }], - }) + { + name: `descending offset`, + ranks: [1, 2, 3, 4], + direction: `desc` as const, + offset: 1, + limit: 1, }, - ) - - 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. - // Shrunk from seed -1253945411. - await checkEagerWindow({ - path, - filter: `joined`, - indexed: true, - direction: `asc`, - terms: 1, - limit: 1, - offset: 1, - initial: [ - { id: 1, rank: 0, eligible: true }, - { id: 3, rank: null as unknown as number, eligible: true }, - { id: 4, rank: 1, eligible: true }, - ], - history: [ - { type: `upsert`, id: 0, rank: 2, eligible: true }, - { - type: `upsert`, - id: 3, - rank: null as unknown as number, - eligible: false, - }, - ], - }) + { + name: `tied head`, + ranks: [2, 3, 3], + direction: `desc` as const, + offset: 0, + limit: 1, }, - ) - - 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. - await checkEagerWindow({ - path, - filter: `joined`, - indexed: true, - direction: `asc`, - terms: 1, - limit: 1, - offset: 0, - initial: Array.from({ length: 10 }, (_, index) => ({ - id: index + 1, - rank: index + 1, - eligible: (index + 1) % 2 === 0, - })), - history: [ - { type: `touch`, id: 10 }, - { type: `delete`, id: 2 }, - ], - }) + { + name: `exhausted window`, + ranks: [1, 2, 3], + direction: `desc` as const, + offset: 0, + limit: 4, }, + ].flatMap((window) => + ([`acknowledge`, `rollback`] as const).map((outcome) => ({ + ...window, + outcome, + })), ) + async function runInstalledDeparture({ + ranks, + direction, + offset, + limit, + outcome, + }: (typeof installedDepartureHistories)[number]): Promise { + const persistence = createDeferred() + const rows = ranks.map((rank, index) => ({ id: index + 1, rank })) + const source = createCollection( + localOnlyCollectionOptions({ + id: `optimistic-installed-${Math.random()}`, + getKey: (row) => row.id, + initialData: rows, + onDelete: async () => { + await persistence.promise + }, + }), + ) + source.createIndex((row) => row.rank, { indexType: BTreeIndex }) + const query = createLiveQueryCollection({ + query: (q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank, direction) + .offset(offset) + .limit(limit), + }) + const ids = () => query.toArray.map(({ id }) => id) + const order = (removed?: number) => + expectedInstalledWindow(rows, { direction, offset, limit }, removed) + let unsubscribe: (() => void) | undefined + try { + await query.preload() + const initial = ids() + if (ranks[1] !== ranks[2]) expect(initial).toEqual(order()) + const departed = initial[0]! + const publications: Array> = [] + const subscription = query.subscribeChanges( + () => publications.push(ids()), + { includeInitialState: true }, + ) + unsubscribe = () => subscription.unsubscribe() + publications.length = 0 - 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`, - latency: `immediate`, - cancellation: `cooperative`, - peer: false, - filter: `joined`, - via: `marker`, - indexed, - direction: `asc`, - nulls: `first`, - terms: 1, - limit: 1, - offset: 0, - initial: [], - history: [ - { type: `upsert`, id: 0, rank: 0, eligible: false }, - { type: `flip`, id: 1 }, - ], - }) + const transaction = source.delete(departed) + const expected = order(departed) + expect(ids(), `immediate installed rows`).toEqual(expected) + expect(publications, `synchronous installed publication`).toEqual([ + expected, + ]) + if (outcome === `acknowledge`) persistence.resolve() + else persistence.reject(new Error(`rollback`)) + await transaction.when(`settled`).catch(() => undefined) + expect(ids(), `settled installed rows`).toEqual( + outcome === `acknowledge` ? expected : initial, + ) + } finally { + persistence.resolve() + unsubscribe?.() + await query.cleanup() + await source.cleanup() + } + } + oracleProperty({ + id: `ordered-work.work-installed-departure`, + law: { + statement: `An optimistic departure from a complete installed window publishes the model window at once, and settlement keeps it or restores the initial window.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Four installed windows crossed with acknowledgement and rollback.`, }, - ) + 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', + ], + histories: installedDepartureHistories, + label: (history) => + `publishes an optimistic departure in an installed ${history.name} before ${history.outcome}`, + ...orderedWorkJudgment(runInstalledDeparture), + check: (_plan, _observations, verdict) => verdict, + }) oracleProperty({ id: `ordered-work.eager-indexed-window`, @@ -7766,52 +4843,6 @@ describe(`eager indexed ordered windows`, () => { check: (_history, _observations, verdict) => verdict, }) - // Reconstruction: the fixed-seed campaign reaches the premise. Sampling - // its arbitrary with its seed and run count yields exactly the cases it - // executes. Their initial windows include rootless rows and, separately, - // 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), - oracleFixedSeed(FULL_JOIN_SEED, { numRuns: runs }), - )) { - const roots = [ - ...fillerRows(testCase.direction, testCase.fillers), - ...testCase.initial, - ] - const model = new Map() - for (const row of roots) { - model.set(row.id, { label: `row`, ...row, eligible: true }) - } - for (const markerId of testCase.orphans ?? []) { - model.set(-1 - markerId, { - id: -1 - markerId, - rank: null as unknown as number, - eligible: true, - label: `marker`, - }) - } - const window = eagerWindow(model, testCase) - const hasRootless = window.some((id) => id < 0) - if (hasRootless) rootless++ - if ( - hasRootless && - window.some( - (id) => id >= 0 && (model.get(id)!.rank as number | null) == null, - ) - ) { - mixedNullGroup++ - } - } - // At one times the run count: 19 and 7 of 40 cases. - expect(rootless).toBeGreaterThanOrEqual(10) - expect(mixedNullGroup).toBeGreaterThanOrEqual(5) - }) - oracleProperty({ id: `ordered-work.eager-full-join`, law: { @@ -8050,46 +5081,6 @@ describe(`eager indexed ordered windows`, () => { }) }) - // 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`, - latency: `held`, - cancellation: `cooperative`, - peer: false, - filter: `joined`, - via: `marker`, - indexed: false, - direction: `desc`, - nulls: `last`, - terms: 1, - limit: 2, - offset: 0, - initial: [ - { id: 0, rank: 0, eligible: false }, - { id: 1, rank: 0, eligible: true }, - { id: 2, rank: Number.NaN, eligible: true }, - ], - history: [ - { type: `delete-visible` }, - { type: `delete-visible` }, - { type: `delete-visible` }, - { - type: `upsert`, - id: 40, - rank: null as unknown as number, - eligible: true, - }, - { type: `delete-visible` }, - { type: `delete`, id: 1 }, - ], - }) - }) - oracleProperty({ id: `ordered-work.eager-joined-gap`, law: { @@ -8184,20 +5175,6 @@ function expectedInstalledWindow( .map(({ id }) => id) } -// Each check compares production with the model it names. The grammar parts -// are the generators of legal histories: the exhaustive small domain and the -// full-join case that the null-group calibration samples. -declareOracle({ - check: [expectEagerWindow, optimisticWindowViolations, assertConsumerParity], - model: [ - eagerWindow, - referenceOptimisticWindow, - expectedVisibleWindow, - expectedInstalledWindow, - ], - grammar: [exhaustiveScenarios, eagerFullJoinCase], -}) - /** A source's ordered acquisition must serve every compiled window that reads * that lexical source. Authored/source metadata modes may have separate result * streams; overwriting one window's demand with the other's cannot change the @@ -8257,66 +5234,83 @@ afterAll(() => { `compiled ordered cache windows`, ).toEqual([]) }) -declareOracle({ - model: [expectedInstalledWindow], - grammar: [compilerCacheWindows], -}) -it.each(compilerCacheWindows)( - `serves every metadata-mode window of one ordered source: %j`, - async (history) => { - oracleRoute(`case`) - compilerWindowEntered = true - const entered = new Set() - const rows = [ - { id: 1, rank: 1 }, - { id: 2, rank: 2 }, - { id: 3, rank: 3 }, - ] - const failures: Array = [] - const traces: Array = [] - await receiveOrderedCompilerCacheHistory(history, rows, (cut, actual) => { - const window = cut === `moved` ? history.moved : history.initial - const expected = { - authored: expectedInstalledWindow(rows, { - ...window, - direction: history.direction, - }), - callback: expectedInstalledWindow(rows, { - ...history.initial, - direction: history.direction, - }), - } - // Compare signed-bag occurrences, not D2 output delivery order. Rank order - // is independently exercised by the enclosing ordered-work owner. - const byId = (ids: Array) => [...ids].sort((a, b) => a - b) - try { - expect( - { authored: byId(actual.authored), callback: byId(actual.callback) }, - `${compilerWindowKey(history)}/${cut}`, - ).toStrictEqual({ - authored: byId(expected.authored), - callback: byId(expected.callback), - }) - } catch (error) { - failures.push(error) - traces.push({ cut, actual, expected }) - } - const state = `${compilerWindowKey(history)}/${cut}` - if (!compilerWindowCounts.has(state)) - throw new Error(`Undeclared compiled window cut: ${state}`) - if (!entered.has(state)) { - entered.add(state) - compilerWindowCounts.set(state, compilerWindowCounts.get(state)! + 1) - } - }) - if (failures.length) - throw new AggregateError( - failures, - `ordered compiler cache window mismatch: ${JSON.stringify(traces)}`, - { cause: failures[0] }, - ) +async function runCompilerCacheWindow( + history: (typeof compilerCacheWindows)[number], +): Promise { + compilerWindowEntered = true + const entered = new Set() + const rows = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const failures: Array = [] + const traces: Array = [] + await receiveOrderedCompilerCacheHistory(history, rows, (cut, actual) => { + const window = cut === `moved` ? history.moved : history.initial + const expected = { + authored: expectedInstalledWindow(rows, { + ...window, + direction: history.direction, + }), + callback: expectedInstalledWindow(rows, { + ...history.initial, + direction: history.direction, + }), + } + // Compare signed-bag occurrences, not D2 output delivery order. Rank order + // is independently exercised by the enclosing ordered-work owner. + const byId = (ids: Array) => [...ids].sort((a, b) => a - b) + try { + expect( + { authored: byId(actual.authored), callback: byId(actual.callback) }, + `${compilerWindowKey(history)}/${cut}`, + ).toStrictEqual({ + authored: byId(expected.authored), + callback: byId(expected.callback), + }) + } catch (error) { + failures.push(error) + traces.push({ cut, actual, expected }) + } + const state = `${compilerWindowKey(history)}/${cut}` + if (!compilerWindowCounts.has(state)) + throw new Error(`Undeclared compiled window cut: ${state}`) + if (!entered.has(state)) { + entered.add(state) + compilerWindowCounts.set(state, compilerWindowCounts.get(state)! + 1) + } + }) + if (failures.length) + throw new AggregateError( + failures, + `ordered compiler cache window mismatch: ${JSON.stringify(traces)}`, + { cause: failures[0] }, + ) +} +oracleProperty({ + id: `ordered-work.work-compiler-cache`, + law: { + statement: `An ordered source acquisition serves every compiled window that reads that lexical source, as the independent sorted slice.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The finite compiler-cache window grammar through the exported compiler and real loader; no universal operator-count bound.`, }, -) + 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', + ], + histories: compilerCacheWindows, + label: (history) => + `serves every metadata-mode window of one ordered source: ${JSON.stringify(history)}`, + ...orderedWorkJudgment(runCompilerCacheWindow), + check: (_plan, _observations, verdict) => verdict, +}) /** Ordered publication waits for every joined dependency that any retained * source plan requires (ARCHITECTURE.md, atomic window publication). A QueryRef @@ -8379,10 +5373,6 @@ function expectedJoinedPlanWindow( limit: history.width, }) } -declareOracle({ - model: [expectedJoinedPlanWindow], - grammar: [joinedPlanHistories], -}) const joinedPlanCounts = new Map( joinedPlanHistories.flatMap((history) => [ @@ -8399,54 +5389,75 @@ afterAll(() => { `ordered joined plan publication`, ).toEqual([]) }) -it.each(joinedPlanHistories)( - `retains every joined dependency through ordered plans: %j`, - async (history) => { - oracleRoute(`case`) - joinedPlanEntered = true - const entered = new Set() - const reach = (state: string) => { - if (!joinedPlanCounts.has(state)) - throw new Error(`Undeclared joined plan cut: ${state}`) - if (!entered.has(state)) { - entered.add(state) - joinedPlanCounts.set(state, joinedPlanCounts.get(state)! + 1) - } +async function runJoinedPlan( + history: (typeof joinedPlanHistories)[number], +): Promise { + joinedPlanEntered = true + const entered = new Set() + const reach = (state: string) => { + if (!joinedPlanCounts.has(state)) + throw new Error(`Undeclared joined plan cut: ${state}`) + if (!entered.has(state)) { + entered.add(state) + joinedPlanCounts.set(state, joinedPlanCounts.get(state)! + 1) } - const failures: Array = [] - const traces: Array = [] - await receiveOrderedJoinedPlanHistory(history, (observation) => { - const added = observation.cut !== `initial` && observation.pending === 0 - const expected = expectedJoinedPlanWindow(history, added) - try { - expect( - observation.rows, - `${JSON.stringify(history)}/${observation.cut}`, - ).toEqual(expected) - // Ordinary Effect startup may deliver progressive callbacks - // (ARCHITECTURE.md, atomic window publication). This history starts its - // publication claim after readiness; the initial settled rows establish - // the preceding complete result. - if (observation.cut !== `initial`) - for (const publication of observation.publications) - expect( - publication, - `complete ${observation.cut} publication`, - ).toEqual(expected) - } catch (error) { - failures.push(error) - traces.push({ ...observation, expected }) - } - if (observation.cut === `held` && observation.pending > 0) - reach(`${JSON.stringify(history)}/held`) - if (observation.cut === `settled` && observation.pending === 0) - reach(`${JSON.stringify(history)}/settled`) - }) - if (failures.length) - throw new AggregateError( - failures, - `ordered joined plan publication mismatch: ${JSON.stringify(traces)}`, - { cause: failures[0] }, - ) + } + const failures: Array = [] + const traces: Array = [] + await receiveOrderedJoinedPlanHistory(history, (observation) => { + const added = observation.cut !== `initial` && observation.pending === 0 + const expected = expectedJoinedPlanWindow(history, added) + try { + expect( + observation.rows, + `${JSON.stringify(history)}/${observation.cut}`, + ).toEqual(expected) + // Ordinary Effect startup may deliver progressive callbacks + // (ARCHITECTURE.md, atomic window publication). This history starts its + // publication claim after readiness; the initial settled rows establish + // the preceding complete result. + if (observation.cut !== `initial`) + for (const publication of observation.publications) + expect( + publication, + `complete ${observation.cut} publication`, + ).toEqual(expected) + } catch (error) { + failures.push(error) + traces.push({ ...observation, expected }) + } + if (observation.cut === `held` && observation.pending > 0) + reach(`${JSON.stringify(history)}/held`) + if (observation.cut === `settled` && observation.pending === 0) + reach(`${JSON.stringify(history)}/settled`) + }) + if (failures.length) + throw new AggregateError( + failures, + `ordered joined plan publication mismatch: ${JSON.stringify(traces)}`, + { cause: failures[0] }, + ) +} +oracleProperty({ + id: `ordered-work.work-joined-plan`, + law: { + statement: `Ordered publication waits for every joined dependency that a retained source plan requires, and each public read and callback keeps the preceding complete result.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The finite joined-plan grammar with controlled adapters; no provider or framework timing claim.`, }, -) + 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', + ], + histories: joinedPlanHistories, + label: (history) => + `retains every joined dependency through ordered plans: ${JSON.stringify(history)}`, + ...orderedWorkJudgment(runJoinedPlan), + check: (_plan, _observations, verdict) => verdict, +}) From 42928b5d5c5607faf73ae13d9316db1d655f2888 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:06:56 -0700 Subject: [PATCH 062/116] test(db): register index suggestion scenarios through oracleProperty Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 7 + .../db/tests/index-suggestion-oracle.test.ts | 360 ++++++++++-------- 2 files changed, 209 insertions(+), 158 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 7a8056cde0..a75e3edbe2 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -31,6 +31,8 @@ export const properties: ReadonlyArray = [ `duration-query-equality.direct-lookup`, `duration-query-equality.public-eq`, `duration-query-equality.calendar-indexing`, + `index-suggestion.collection-size`, + `index-suggestion.ordered-diagnostic`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -69,6 +71,11 @@ export const owners: ReadonlyArray = [ `duration-query-equality`, `direct-lookup public-eq calendar-indexing`, ], + [ + `db/tests/index-suggestion-oracle.test.ts`, + `index-suggestion`, + `collection-size ordered-diagnostic`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance diff --git a/packages/db/tests/index-suggestion-oracle.test.ts b/packages/db/tests/index-suggestion-oracle.test.ts index 6fe2a0be30..b9615cdfac 100644 --- a/packages/db/tests/index-suggestion-oracle.test.ts +++ b/packages/db/tests/index-suggestion-oracle.test.ts @@ -12,7 +12,7 @@ * oracle does not judge slow-query timing, repeated query warnings, index * benefit, or production-build suppression. */ -import { afterEach, describe, expect, test, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { BasicIndex } from '../src/indexes/basic-index.js' import { @@ -22,8 +22,12 @@ 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 { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import type { IndexSuggestion } from '../src/indexes/index-registry.js' type Row = { id: number; url: string } @@ -68,23 +72,17 @@ 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, - expectedIndexCount, - expectedOrderedIndexDiagnostic, - ], -}) +const indexSuggestionAuthority = `The index dev-mode suggestion and bounded orderBy fallback-warning contracts in src/indexes/index-registry.ts and src/query/compiler/order-by.ts, as modelled above` + +type SizeScenario = { + mode: `off` | `eager` | undefined + size: number + index: `none` | `url` | `id` + enabled: boolean +} describe(`collection-size index suggestions`, () => { - for (const scenario of [ + const sizeScenarios: ReadonlyArray = [ { mode: `off`, size: 1101, index: `none`, enabled: true }, { mode: undefined, size: 1101, index: `none`, enabled: true }, { mode: `off`, size: 1000, index: `none`, enabled: true }, @@ -94,93 +92,118 @@ describe(`collection-size index suggestions`, () => { { mode: `off`, size: 1101, index: `id`, enabled: true }, { mode: `eager`, size: 1101, index: `none`, enabled: true }, { 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, - collectionSizeThreshold: 1000, - slowQueryThresholdMs: Number.MAX_VALUE, - onSuggestion: (suggestion) => suggestions.push(suggestion), - }) + ] + const runSizeScenario = async (scenario: SizeScenario) => { + const suggestions: Array = [] + configureIndexDevMode({ + enabled: scenario.enabled, + collectionSizeThreshold: 1000, + slowQueryThresholdMs: Number.MAX_VALUE, + onSuggestion: (suggestion) => suggestions.push(suggestion), + }) - const rows = Array.from({ length: scenario.size }, (_, id): Row => ({ - id, - url: `url-${id}`, - })) - const source = createCollection( - mockSyncCollectionOptions({ - id: `suggestion-${scenario.mode}-${scenario.size}-${scenario.index}`, - getKey: (row) => row.id, - initialData: rows, - autoIndex: scenario.mode, - defaultIndexType: BasicIndex, - }), - ) - await source.stateWhenReady() - if (scenario.index === `url`) source.createIndex((row) => row.url) - if (scenario.index === `id`) source.createIndex((row) => row.id) + const rows = Array.from({ length: scenario.size }, (_, id): Row => ({ + id, + url: `url-${id}`, + })) + const source = createCollection( + mockSyncCollectionOptions({ + id: `suggestion-${scenario.mode}-${scenario.size}-${scenario.index}`, + getKey: (row) => row.id, + initialData: rows, + autoIndex: scenario.mode, + defaultIndexType: BasicIndex, + }), + ) + await source.stateWhenReady() + if (scenario.index === `url`) source.createIndex((row) => row.url) + if (scenario.index === `id`) source.createIndex((row) => row.id) - const query = createLiveQueryCollection({ - startSync: true, - query: (q) => - q.from({ row: source }).where(({ row }) => eq(row.url, `url-7`)), - }) + const query = createLiveQueryCollection({ + startSync: true, + query: (q) => + q.from({ row: source }).where(({ row }) => eq(row.url, `url-7`)), + }) - let primaryFailure: { error: unknown } | undefined - try { - await query.stateWhenReady() - expect(query.toArray.map((row) => row.id)).toEqual([7]) - const sizeSuggestions = suggestions.filter( - (suggestion) => suggestion.type === `collection-size`, - ) - const expected = expectedSuggestions( - scenario.mode, - scenario.size, - scenario.index, - scenario.enabled, - ) - expectSizeSuggestionOutcome( - sizeSuggestions, - expected, - source.id, - scenario.size, - ) - expect(source.indexes.size).toBe( - expectedIndexCount(scenario.mode, scenario.index), - ) - } catch (error) { - primaryFailure = { error } - } + let primaryFailure: { error: unknown } | undefined + try { + await query.stateWhenReady() + expect(query.toArray.map((row) => row.id)).toEqual([7]) + const sizeSuggestions = suggestions.filter( + (suggestion) => suggestion.type === `collection-size`, + ) + const expected = expectedSuggestions( + scenario.mode, + scenario.size, + scenario.index, + scenario.enabled, + ) + expectSizeSuggestionOutcome( + sizeSuggestions, + expected, + source.id, + scenario.size, + ) + expect(source.indexes.size).toBe( + expectedIndexCount(scenario.mode, scenario.index), + ) + } catch (error) { + primaryFailure = { error } + } - const cleanupFailures: Array = [] - try { - await query.cleanup() - } catch (error) { - cleanupFailures.push(error) - } - try { - await source.cleanup() - } catch (error) { - cleanupFailures.push(error) - } - if (primaryFailure && cleanupFailures.length > 0) { - throw new AggregateError( - [primaryFailure.error, ...cleanupFailures], - `Index suggestion check and cleanup failed`, - { cause: primaryFailure.error }, - ) - } - if (primaryFailure) throw primaryFailure.error - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `Index suggestion cleanup failed`, - ) - } - }) + const cleanupFailures: Array = [] + try { + await query.cleanup() + } catch (error) { + cleanupFailures.push(error) + } + try { + await source.cleanup() + } catch (error) { + cleanupFailures.push(error) + } + if (primaryFailure && cleanupFailures.length > 0) { + throw new AggregateError( + [primaryFailure.error, ...cleanupFailures], + `Index suggestion check and cleanup failed`, + { cause: primaryFailure.error }, + ) + } + if (primaryFailure) throw primaryFailure.error + if (cleanupFailures.length > 0) { + throw new AggregateError( + cleanupFailures, + `Index suggestion cleanup failed`, + ) + } } + // What this oracle owns: the collection-size suggestion and the + // ordered-index fallback warning, by this file's scenarios. + oracleProperty({ + id: `index-suggestion.collection-size`, + law: { + statement: `A collection-size suggestion is emitted exactly when advice is enabled, the source exceeds the threshold, the queried field lacks an index, and the Collection will not create one automatically.`, + authority: indexSuggestionAuthority, + limits: `The eight fixed mode, size, index and advice scenarios at a 1,000-row threshold.`, + }, + owns: [ + `src/indexes/index-registry.ts#checkCollectionSizeForIndex`, + `src/query/compiler/order-by.ts#warnOrderIndexFallback`, + ], + histories: sizeScenarios, + label: (scenario) => + `${scenario.mode}, ${scenario.size} rows, index=${scenario.index}, advice=${scenario.enabled}`, + resolve: (scenario) => ({ scenario }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-suggestion.collection-size`, + plan, + observe, + () => runSizeScenario(plan.scenario), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -320,69 +343,90 @@ 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 }>({ - id: `ordered-index-diagnostic-${scenario.name}`, - getKey: (row) => row.id, - initialData: [ - { id: 1, name: `Charlie` }, - { id: 2, name: `Alice` }, - { id: 3, name: `Bob` }, - ], - autoIndex: `off`, - }), - ) - let query: ReturnType | undefined - await withOracleCleanup(async () => { - await source.stateWhenReady() - for (const candidate of scenario.candidates) { - source.createIndex((row) => row[candidate.field], { - indexType: BasicIndex, - options: { - compareOptions: { - direction: candidate.direction ?? `asc`, - nulls: candidate.nulls, - stringSort: `locale`, - }, - ...(candidate.ordered - ? {} - : { compareFn: (a: string, b: string) => b.localeCompare(a) }), + const runOrderedScenario = async (scenario: (typeof scenarios)[number]) => { + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const source = createCollection( + mockSyncCollectionOptions<{ id: number; name: string }>({ + id: `ordered-index-diagnostic-${scenario.name}`, + getKey: (row) => row.id, + initialData: [ + { id: 1, name: `Charlie` }, + { id: 2, name: `Alice` }, + { id: 3, name: `Bob` }, + ], + autoIndex: `off`, + }), + ) + let query: ReturnType | undefined + await withOracleCleanup(async () => { + await source.stateWhenReady() + for (const candidate of scenario.candidates) { + source.createIndex((row) => row[candidate.field], { + indexType: BasicIndex, + options: { + compareOptions: { + direction: candidate.direction ?? `asc`, + nulls: candidate.nulls, + stringSort: `locale`, }, + ...(candidate.ordered + ? {} + : { compareFn: (a: string, b: string) => b.localeCompare(a) }), + }, + }) + } + + query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.name, { + direction: `asc`, + nulls: `last`, }) - } + .limit(2), + ) - query = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.name, { - direction: `asc`, - nulls: `last`, - }) - .limit(2), + await query.preload() + expect(query.toArray.map((row) => row.name)).toEqual([`Alice`, `Bob`]) + const messages = warning.mock.calls + .flat() + .filter( + (message): message is string => + typeof message === `string` && + message.includes(`orderBy with limit`), ) - - await query.preload() - expect(query.toArray.map((row) => row.name)).toEqual([`Alice`, `Bob`]) - const messages = warning.mock.calls - .flat() - .filter( - (message): message is string => - typeof message === `string` && - message.includes(`orderBy with limit`), - ) - const expected = expectedOrderedIndexDiagnostic(scenario.candidates) - expectOrderedIndexMessages(messages, expected) - }, [ - () => warning.mockRestore(), - () => query?.cleanup(), - () => source.cleanup(), - ]) - }) + const expected = expectedOrderedIndexDiagnostic(scenario.candidates) + expectOrderedIndexMessages(messages, expected) + }, [ + () => warning.mockRestore(), + () => query?.cleanup(), + () => source.cleanup(), + ]) } + oracleProperty({ + id: `index-suggestion.ordered-diagnostic`, + law: { + statement: `A bounded ordered query warns with the cause that remains after considering every index candidate: no field index, mismatched compareOptions, or no ordered-read capability; any usable candidate suppresses the warning.`, + authority: indexSuggestionAuthority, + limits: `The fixed finite candidate grammar over field identity, null placement and ordered-read capability, with up to two candidates.`, + }, + owns: [ + `src/indexes/index-registry.ts#checkCollectionSizeForIndex`, + `src/query/compiler/order-by.ts#warnOrderIndexFallback`, + ], + histories: scenarios, + label: ({ name }) => name, + resolve: (scenario) => ({ scenario }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-suggestion.ordered-diagnostic`, + plan, + observe, + () => runOrderedScenario(plan.scenario), + ), + check: forwardAssertionJudgment, + }) // A local index cannot confer a provider collation identity. Even a // comparator-compatible index must leave the full-source cost visible. From 62b3d4e5e36b75e3090be7c3d8811a47316388b6 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:08:38 -0700 Subject: [PATCH 063/116] test(sqlite): register the demand coverage storage-fault and release sweeps through oracleProperty P1b: each sweep becomes a one-history enumerated property whose driver is the unchanged test body. P6: the checker calibration builds wrong observations by hand, with no production, so it moves unchanged to persisted-demand-coverage-campaign-calibration.test.ts. The demands it needs move unchanged to the companion persisted-demand-coverage-campaign-grammar-oracle.ts. Co-authored-by: Isaac --- ...mand-coverage-campaign-calibration.test.ts | 134 ++++++++ ...demand-coverage-campaign-grammar-oracle.ts | 21 ++ ...ed-demand-coverage-campaign-oracle.test.ts | 293 ++++++------------ 3 files changed, 256 insertions(+), 192 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-calibration.test.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-grammar-oracle.ts diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-calibration.test.ts new file mode 100644 index 0000000000..e811b4ab36 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-calibration.test.ts @@ -0,0 +1,134 @@ +/** + * Checker calibration (ORC-006) of the persisted demand coverage comparison, + * coverageViolations in persisted-demand-coverage-model-oracle.ts, with the + * demands of the generated campaign. It builds wrong observations by hand and + * runs no production, so it is an ordinary test. The campaign that drives + * production is persisted-demand-coverage-campaign-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { IR } from '@tanstack/db' +import { oracleReplayRequested } from '../../db/tests/oracle-config' +import { coverageViolations } from './persisted-demand-coverage-model-oracle.js' +import { + SPECS, + WINDOW_LIMIT, +} from './persisted-demand-coverage-campaign-grammar-oracle.js' +import type { + CoverageRecord, + QueryObservation, + SourceEvent, +} from './persisted-demand-coverage-model-oracle.js' +import type { LoadSubsetOptions } from '@tanstack/db' + +describe(`persisted demand coverage over generated histories`, () => { + it.skipIf(oracleReplayRequested())( + `rejects observations that ignore lost rows, obsolete loads, or other window rows`, + () => { + // 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. + const loadA: LoadSubsetOptions = { + where: new IR.Func(`eq`, [ + new IR.PropRef([`group`]), + new IR.Value(`a`), + ]), + } + const loadW = { + ...loadA, + orderBy: [ + { + expression: new IR.PropRef([`rank`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + limit: WINDOW_LIMIT, + } as unknown as LoadSubsetOptions + const rows: SourceEvent = { + type: `rows`, + rows: [ + [`r1`, { group: `a`, rank: 1 }], + [`r2`, { group: `a`, rank: 2 }], + ], + } + const covered: Array = [ + rows, + { type: `settled`, load: loadA, current: true }, + { type: `settled`, load: loadW, current: true }, + ] + const ready = (ids: Array): QueryObservation => ({ + status: `ready`, + rows: ids, + }) + const record = ( + events: ReadonlyArray, + a: QueryObservation, + w: QueryObservation, + ): CoverageRecord => ({ + events, + // The offline session's own loads of the same demands. + queries: { + a: { spec: SPECS.a, sent: [{ ...loadA }], observed: a }, + w: { spec: SPECS.w, sent: [{ ...loadW }], observed: w }, + }, + upstream: `never answers`, + waitPassed: true, + }) + // The right observations pass. + expect( + coverageViolations( + record(covered, ready([`r1`, `r2`]), ready([`r1`, `r2`])), + ), + ).toEqual([]) + // A demand that lost a row, settled anyway. + expect( + coverageViolations( + record([...covered, { type: `delete`, id: `r1` }], ready([`r2`]), { + status: `loading`, + }), + ), + ).toHaveLength(1) + // An obsolete load treated as coverage. + expect( + coverageViolations( + record( + [rows, { type: `settled`, load: loadA, current: false }], + ready([`r1`, `r2`]), + { status: `loading` }, + ), + ), + ).toHaveLength(1) + // A released load that the source resolves must not certify its demand. + expect( + coverageViolations( + record( + [ + rows, + { type: `settled`, load: loadA, current: true, aborted: true }, + ], + ready([`r1`, `r2`]), + { status: `loading` }, + ), + ), + ).toHaveLength(1) + // A window that holds another row, settled anyway. + expect( + coverageViolations( + record( + [ + ...covered, + { type: `rows`, rows: [[`r3`, { group: `a`, rank: 0 }]] }, + ], + ready([`r1`, `r2`, `r3`]), + ready([`r3`, `r1`]), + ), + ), + ).toHaveLength(1) + // A window shown out of order. + expect( + coverageViolations( + record(covered, ready([`r1`, `r2`]), ready([`r2`, `r1`])), + ), + ).toHaveLength(1) + }, + ) +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-grammar-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-grammar-oracle.ts new file mode 100644 index 0000000000..5821c4f983 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-grammar-oracle.ts @@ -0,0 +1,21 @@ +/** + * The demands of the generated persisted demand coverage campaign in + * persisted-demand-coverage-campaign-oracle.test.ts: the row groups, the + * three demands that live queries read, and the query each demand sends. + * Owns nothing: the campaign drives production with them, and + * persisted-demand-coverage-campaign-calibration.test.ts calibrates the shared + * comparison with them. + */ +import type { QuerySpec } from './persisted-demand-coverage-model-oracle.js' + +export type Group = `a` | `b` +// The demands that live queries read: each group unordered, and the window +// `group = 'a' orderBy rank limit 2`. +export type Demand = `a` | `b` | `w` +export const WINDOW_LIMIT = 2 + +export const SPECS: Record = { + a: { group: `a` }, + b: { group: `b` }, + w: { group: `a`, orderByRank: true, limit: WINDOW_LIMIT }, +} 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 312ed3cb58..a76fc57e86 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 @@ -1,13 +1,8 @@ import { DatabaseSync } from 'node:sqlite' import { fc } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' -import { - IR, - createCollection, - createLiveQueryCollection, - eq, -} from '@tanstack/db' -import { oracleReplayRequested, oracleRuns } from '../../db/tests/oracle-config' +import { describe, expect, vi } from 'vitest' +import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' +import { oracleRuns } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -15,7 +10,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' import { SQLiteCorePersistenceAdapter, persistedCollectionOptions, @@ -32,13 +26,20 @@ import { withMacrotaskReads, } from './helpers/demand-coverage-driver.js' import { coverageViolations } from './persisted-demand-coverage-model-oracle.js' +import { + SPECS, + WINDOW_LIMIT, +} from './persisted-demand-coverage-campaign-grammar-oracle.js' +import type { + Demand, + Group, +} from './persisted-demand-coverage-campaign-grammar-oracle.js' import type { Row } from './helpers/demand-coverage-driver.js' import type { CoverageReach, CoverageRecord, OfflineUpstream, QueryObservation, - QuerySpec, SourceEvent, } from './persisted-demand-coverage-model-oracle.js' import type { Collection, LoadSubsetOptions, SyncConfig } from '@tanstack/db' @@ -155,17 +156,12 @@ import type { SQLiteDriver } from '../src/index.js' * recovery (Part 2b of the fixed oracle owns it). */ -type Group = `a` | `b` const IDS = [`r1`, `r2`, `r3`, `r4`] as const type Id = (typeof IDS)[number] type Rank = 1 | 2 | 3 type RowState = { group: Group; rank: Rank } -// The demands that live queries read: each group unordered, and the window -// `group = 'a' orderBy rank limit 2`. -type Demand = `a` | `b` | `w` const DEMANDS: ReadonlyArray = [`a`, `b`, `w`] const groupOf = (demand: Demand): Group => (demand === `b` ? `b` : `a`) -const WINDOW_LIMIT = 2 type Command = | { type: `start`; demand: Demand } @@ -189,12 +185,6 @@ type History = { commands: ReadonlyArray } -const SPECS: Record = { - a: { group: `a` }, - b: { group: `b` }, - w: { group: `a`, orderByRank: true, limit: WINDOW_LIMIT }, -} - // --------------------------------------------------------------------------- // Model and comparison // @@ -1248,181 +1238,100 @@ function checkRelease( }) } -declareOracle({ - check: [coverageViolations, checkFault, checkRelease], - grammar: [historyArb, requiredHistoriesArb], -}) - -describe(`persisted demand coverage over generated histories`, () => { - it.skipIf(oracleReplayRequested())( - `keeps the law when any one storage call fails`, - async () => { - quietConsole() - const baseline = await runWithFault(undefined) - // Positive witness: without a failure, session 2 settles D offline. - expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) - const reached = new Set() - const expectedReach: Array = [ - `coverage read failed`, - `row write failed`, - `coverage check failed`, - `coverage write failed`, - ] - for (const session of [1, 2] as const) { - const count = baseline.calls.filter((c) => c.session === session).length - for (let index = 0; index < count; index++) { - for (const timing of [`before effect`, `after effect`] as const) { - const run = await runWithFault({ session, index, timing }) - const failed = run.calls.filter((c) => c.session === session)[index] - if (!failed) continue - try { - checkFault(failed, timing, run, (state) => reached.add(state)) - } catch (error) { - throw new Error( - `failure at session ${session} call ${index} ` + - `(${failed.method}, ${timing}, late: ${failed.late})`, - { cause: error }, - ) - } - } +// Part 2's sweep: every call that the history makes without a failure fails +// once, before and after its effect, and every fault reach is entered. +async function runStorageFaultSweep(): Promise { + quietConsole() + const baseline = await runWithFault(undefined) + // Positive witness: without a failure, session 2 settles D offline. + expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) + const reached = new Set() + const expectedReach: Array = [ + `coverage read failed`, + `row write failed`, + `coverage check failed`, + `coverage write failed`, + ] + for (const session of [1, 2] as const) { + const count = baseline.calls.filter((c) => c.session === session).length + for (let index = 0; index < count; index++) { + for (const timing of [`before effect`, `after effect`] as const) { + const run = await runWithFault({ session, index, timing }) + const failed = run.calls.filter((c) => c.session === session)[index] + if (!failed) continue + try { + checkFault(failed, timing, run, (state) => reached.add(state)) + } catch (error) { + throw new Error( + `failure at session ${session} call ${index} ` + + `(${failed.method}, ${timing}, late: ${failed.late})`, + { cause: error }, + ) } } - expect(reached).toEqual(new Set(expectedReach)) - }, - 600_000, - ) + } + } + expect(reached).toEqual(new Set(expectedReach)) +} - it.skipIf(oracleReplayRequested())( - `owns no wait after a release at any step of a load`, - async () => { - quietConsole() - const driver = createDriver(new DatabaseSync(`:memory:`)) - await coverGroupA(driver) - const reached = new Set() - const expectedReach: Array = [ - `released before the source load`, - `released while the source load waited`, - `released during a coverage read`, - ] - for (let steps = 0; steps <= 24; steps++) { - checkRelease( - steps, - await runReleaseAt(driver, steps, (state) => reached.add(state)), - ) - } - expect(reached).toEqual(new Set(expectedReach)) +// Part 3's sweep: one covered driver, released after every step up to the +// step at which the load would have settled, and every release reach is +// entered. +async function runReleaseSweep(): Promise { + quietConsole() + const driver = createDriver(new DatabaseSync(`:memory:`)) + await coverGroupA(driver) + const reached = new Set() + const expectedReach: Array = [ + `released before the source load`, + `released while the source load waited`, + `released during a coverage read`, + ] + for (let steps = 0; steps <= 24; steps++) { + checkRelease( + steps, + await runReleaseAt(driver, steps, (state) => reached.add(state)), + ) + } + expect(reached).toEqual(new Set(expectedReach)) +} + +describe(`persisted demand coverage over generated histories`, () => { + oracleProperty({ + id: `persisted-demand-coverage.storage-fault`, + law: { + statement: `With one storage call failed before or after its effect, for every call of the history, session 2 settles D from SQLite only with exactly r1 and r2; a failed coverage read, row write, or coverage check leaves session 2 waiting, and a failed coverage write crashes the Collection.`, + authority: `The persisted demand coverage contract stated above, and the glossary's "crash" for a terminal local persistence failure.`, + limits: `One history, with the failure swept over every call that it makes without a failure; node:sqlite and the default coordinator only.`, }, - 300_000, - ) + owns: [`src/persisted.ts`], + histories: [{ name: `keeps the law when any one storage call fails` }], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`storage fault`, plan, observe, () => + runStorageFaultSweep(), + ), + check: forwardAssertionJudgment, + timeout: 600_000, + }) - it.skipIf(oracleReplayRequested())( - `rejects observations that ignore lost rows, obsolete loads, or other window rows`, - () => { - // 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. - const loadA: LoadSubsetOptions = { - where: new IR.Func(`eq`, [ - new IR.PropRef([`group`]), - new IR.Value(`a`), - ]), - } - const loadW = { - ...loadA, - orderBy: [ - { - expression: new IR.PropRef([`rank`]), - compareOptions: { direction: `asc`, nulls: `first` }, - }, - ], - limit: WINDOW_LIMIT, - } as unknown as LoadSubsetOptions - const rows: SourceEvent = { - type: `rows`, - rows: [ - [`r1`, { group: `a`, rank: 1 }], - [`r2`, { group: `a`, rank: 2 }], - ], - } - const covered: Array = [ - rows, - { type: `settled`, load: loadA, current: true }, - { type: `settled`, load: loadW, current: true }, - ] - const ready = (ids: Array): QueryObservation => ({ - status: `ready`, - rows: ids, - }) - const record = ( - events: ReadonlyArray, - a: QueryObservation, - w: QueryObservation, - ): CoverageRecord => ({ - events, - // The offline session's own loads of the same demands. - queries: { - a: { spec: SPECS.a, sent: [{ ...loadA }], observed: a }, - w: { spec: SPECS.w, sent: [{ ...loadW }], observed: w }, - }, - upstream: `never answers`, - waitPassed: true, - }) - // The right observations pass. - expect( - coverageViolations( - record(covered, ready([`r1`, `r2`]), ready([`r1`, `r2`])), - ), - ).toEqual([]) - // A demand that lost a row, settled anyway. - expect( - coverageViolations( - record([...covered, { type: `delete`, id: `r1` }], ready([`r2`]), { - status: `loading`, - }), - ), - ).toHaveLength(1) - // An obsolete load treated as coverage. - expect( - coverageViolations( - record( - [rows, { type: `settled`, load: loadA, current: false }], - ready([`r1`, `r2`]), - { status: `loading` }, - ), - ), - ).toHaveLength(1) - // A released load that the source resolves must not certify its demand. - expect( - coverageViolations( - record( - [ - rows, - { type: `settled`, load: loadA, current: true, aborted: true }, - ], - ready([`r1`, `r2`]), - { status: `loading` }, - ), - ), - ).toHaveLength(1) - // A window that holds another row, settled anyway. - expect( - coverageViolations( - record( - [ - ...covered, - { type: `rows`, rows: [[`r3`, { group: `a`, rank: 0 }]] }, - ], - ready([`r1`, `r2`, `r3`]), - ready([`r3`, `r1`]), - ), - ), - ).toHaveLength(1) - // A window shown out of order. - expect( - coverageViolations( - record(covered, ready([`r1`, `r2`]), ready([`r2`, `r1`])), - ), - ).toHaveLength(1) + oracleProperty({ + id: `persisted-demand-coverage.release`, + law: { + statement: `A released load owns no wait: after the release, at every step of a load, the source Collection is not loading a subset, and it stays so when the clock passes the wait.`, + authority: `ARCHITECTURE.md law 6, as Part 3 above states it.`, + limits: `One covered driver, released after each of steps 0 to 24 under a fake clock; node:sqlite and the default coordinator only.`, }, - ) + owns: [`src/persisted.ts`], + histories: [{ name: `owns no wait after a release at any step of a load` }], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`release`, plan, observe, () => + runReleaseSweep(), + ), + check: forwardAssertionJudgment, + timeout: 300_000, + }) }) From 5e1e63030bff8112bd219a12276e461d8450ba99 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:09:08 -0700 Subject: [PATCH 064/116] test(db): register IR identity output and operator-code tests through oracleProperty The named output programs replay through assertOutputProgram in ir-identity.output-denotation-fixed, whose calibrations keep the old shared-wrong-output and constant-identity corruptions and matcher. The operator-code products become enumerated properties through named drivers. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 18 ++ .../query/ir-stable-identity-oracle.test.ts | 213 +++++++++++++----- 2 files changed, 169 insertions(+), 62 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 805ad35666..08529168ec 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -121,6 +121,9 @@ const otherProperties: ReadonlyArray = [ `subquery-user-value.ordered-find-one`, `subquery-user-value.materialized-find-one`, `query-identity.compound-join`, + `ir-identity.output-denotation-fixed`, + `ir-identity.operator-code-outcome`, + `ir-identity.operator-code-wide`, ] const otherOwners: ReadonlyArray = [ [ @@ -283,6 +286,21 @@ const otherOwners: ReadonlyArray = [ `query-identity`, `compound-join`, ], + [ + `db/tests/query/ir-stable-identity-oracle.test.ts`, + `ir-identity`, + `output-denotation-fixed`, + ], + [ + `db/tests/query/ir-stable-identity-oracle.test.ts`, + `ir-identity`, + `operator-code-outcome`, + ], + [ + `db/tests/query/ir-stable-identity-oracle.test.ts`, + `ir-identity`, + `operator-code-wide`, + ], ] export const properties: ReadonlyArray = [ 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 518297e35b..42ea386335 100644 --- a/packages/db/tests/query/ir-stable-identity-oracle.test.ts +++ b/packages/db/tests/query/ir-stable-identity-oracle.test.ts @@ -7,7 +7,7 @@ // The generated properties check canonical boolean forms, IN candidates as a // set, alias renaming, and reference-valued captures against inputs they read. // Fixed scenarios and literal identities live in ir-stable-identity.test.ts. -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import fc from 'fast-check' import { BaseQueryBuilder, @@ -29,14 +29,13 @@ import { getQueryIdentity, } from '../../src/query/ir-stable-identity.js' import { Func, IncludesSubquery, PropRef, Value } from '../../src/query/ir.js' +import { oracleProperty, pass, violation } from '../oracle-config.js' import { - oracleProperty, - oracleReplayTest, - oracleRoute, - pass, - violation, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { createAlphaRenamedIncludesQuery, createAlphaRenamedJoinQuery, @@ -54,8 +53,6 @@ import { import type { Observation, Verdict } from '../oracle-config.js' import type { BasicExpression, QueryIR } from '../../src/query/ir.js' -const test = oracleReplayTest(it) - // These metamorphic laws specify relations, rather than one literal identity. // Production identity strings can contain process-local reference handles, so // the production driver records equality of those strings. The reference @@ -342,15 +339,6 @@ const outputProgramArbitrary: fc.Arbitrary = fc.record({ ), }) -declareOracle({ - check: [ - expectIdentitySharingMatchesOutcome, - checkIdentityRelations, - checkOutputProgram, - ], - model: [interpretOutputProgram, modelOutcome, modelOutputProgram], -}) - describe(`semantic expression identity`, () => { const age = new PropRef([`user`, `age`]) const active = new PropRef([`user`, `active`]) @@ -711,27 +699,6 @@ describe(`stable QueryIR identity smoke test`, () => { check: checkIdentityRelations, }) - test(`preserves signed zero through coalesce and binary conversion through conditional output`, () => { - oracleRoute(`fixed history`) - assertOutputProgram( - observeOutputProgram({ - literals: { kind: `signed-zero` }, - wrappers: [{ kind: `coalesce` }], - }), - ) - for (const condition of [false, true]) { - assertOutputProgram( - observeOutputProgram({ - literals: { kind: `binary`, bytes: [65], same: false }, - wrappers: [ - { kind: `concat`, suffix: `` }, - { kind: `caseWhen`, condition }, - ], - }), - ) - } - }) - oracleProperty({ id: `ir-identity.output-denotation`, law: { @@ -780,24 +747,85 @@ describe(`stable QueryIR identity smoke test`, () => { check: checkOutputProgram, }) - test.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` }], - }) - assertOutputProgram(observations) - if (fault === `shared-wrong-output`) { - for (const observation of observations) observation.actual = 99 - } else { - for (const observation of observations) - observation.identity = `constant` - } - expect(() => assertOutputProgram(observations)).toThrowError(/expected/) + // Named programs: signed zero through coalesce, and binary conversion + // through a conditional output on both branches. Each is judged by + // `assertOutputProgram`, the program's own denotation comparison. + const namedOutputPrograms: Array = [ + { literals: { kind: `signed-zero` }, wrappers: [{ kind: `coalesce` }] }, + ...[false, true].map((condition): OutputProgram => ({ + literals: { kind: `binary`, bytes: [65], same: false }, + wrappers: [ + { kind: `concat`, suffix: `` }, + { kind: `caseWhen`, condition }, + ], + })), + ] + const outputProgramLaw = `output denotation` + oracleProperty({ + id: `ir-identity.output-denotation-fixed`, + law: { + statement: `Each compiled literal/wrapper program matches its independent denotation, and equal projected identities preserve its exact output.`, + authority: `The existing interpretOutputProgram denotation and output-sensitive identity law stated above.`, + limits: `Three named programs: signed zero through coalesce, and binary conversion through a conditional output with each condition.`, }, - ) + 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', + ], + histories: [ + { + name: `preserves signed zero through coalesce and binary conversion through conditional output`, + programs: namedOutputPrograms, + }, + ], + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(outputProgramLaw, plan, observe, () => { + for (const program of plan.history.programs) + assertOutputProgram(observeOutputProgram(program)) + }), + check: forwardAssertionJudgment, + // The comparison must reject a wrong output that both literals share, and + // a constant identity, despite their mutual agreement, on the signed-zero + // coalesce program. + calibrations: ([`shared-wrong-output`, `constant-identity`] as const).map( + (fault) => ({ + name: `rejects ${fault} despite mutual agreement`, + law: outputProgramLaw, + driver: ( + plan: { history: { programs: Array } }, + observe: Parameters[2], + ) => + recordAssertionJudgment(outputProgramLaw, plan, observe, () => { + const observations = observeOutputProgram(plan.history.programs[0]!) + assertOutputProgram(observations) + if (fault === `shared-wrong-output`) { + for (const observation of observations) observation.actual = 99 + } else { + for (const observation of observations) + observation.identity = `constant` + } + try { + assertOutputProgram(observations) + } catch (error) { + // Exactly the old matcher: an error whose message matches + // /expected/ is the comparison rejecting the wrong answer. + if (error instanceof Error && /expected/.test(error.message)) + rejectAt(`calibration ${fault}`, error) + throw error + } + }), + }), + ), + }) oracleProperty({ id: `ir-identity.union-output-aliases`, @@ -1017,8 +1045,8 @@ describe(`operator-code identity`, () => { ), ) - test(`keeps queries whose operator code changes the compile outcome distinct`, () => { - oracleRoute(`fixed history`) + // The narrow product, judged by `modelOutcome` and the identity law. + function runOperatorCodeOutcomes(): void { const results = cases.map(({ groupBy, agg, inc }) => { const ir = buildIR(groupBy, agg, inc) const label = `${groupBy} groupBy, ${agg} aggregate, ${inc} include` @@ -1030,7 +1058,7 @@ describe(`operator-code identity`, () => { for (const b of results) if (a.outcome !== b.outcome) expect(a.identity, `${a.label} vs ${b.label}`).not.toBe(b.identity) - }) + } /** * The wide product: every way the query can hold operator code, each @@ -1190,8 +1218,8 @@ describe(`operator-code identity`, () => { poolable, }) - test(`keeps every operator-code variant with a different compile outcome distinct`, () => { - oracleRoute(`case`) + // The wide product, judged by `expectIdentitySharingMatchesOutcome`. + function runWideOperatorCodeProduct(): void { const identities = { ir: (ir: QueryIR) => getQueryIdentity(ir), pooled: (ir: QueryIR) => @@ -1215,5 +1243,66 @@ describe(`operator-code identity`, () => { ]) expect(outcomes, `reaches ${expected}`).toContain(expected) expectIdentitySharingMatchesOutcome(built, identities) + } + + const operatorCodeLaw = { + statement: `Queries whose operator code changes the compile outcome have distinct identities, for the IR identity and for the prepared identity with a pool.`, + authority: `The output-sensitive identity law stated above.`, + } + oracleProperty({ + id: `ir-identity.operator-code-outcome`, + law: { + ...operatorCodeLaw, + limits: `The groupBy, aggregate, and include product with the independent compile-outcome model.`, + }, + 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', + ], + histories: [ + `keeps queries whose operator code changes the compile outcome distinct`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`operator-code identity`, plan, observe, () => + runOperatorCodeOutcomes(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `ir-identity.operator-code-wide`, + law: { + ...operatorCodeLaw, + limits: `The wide product of operator-code slots, each present or missing, with production's own compile outcome.`, + }, + 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', + ], + histories: [ + `keeps every operator-code variant with a different compile outcome distinct`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`operator-code identity`, plan, observe, () => + runWideOperatorCodeProduct(), + ), + check: forwardAssertionJudgment, }) }) From 754b87e5fc72aa9df82a2ee11becc0da68be0971 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:09:23 -0700 Subject: [PATCH 065/116] test(db): register pagination oracle tests through oracleProperty The pagination grammars map without unmappers, so every shared-driver fixed history replays through a pagination.*-fixed enumerated property. Inline production paths that use a model become pagination.* enumerated properties through named drivers, and the fault probes become calibrations with their old matchers. Tests that compare production with written literals move to pagination-recomputation.test.ts. Model and checker calibrations that run no production move to pagination-model.test.ts beside extracted, test-free fixture and model modules. The limit-admission fixed histories become pagination.limit-admission-fixed. expectMultiOrderBoundaryMatches is now a fixed history and is deleted. Co-authored-by: Isaac --- .../db/tests/query/limit-admission-oracle.ts | 65 +- .../db/tests/query/pagination-fixtures.ts | 131 + .../db/tests/query/pagination-model-oracle.ts | 269 + .../db/tests/query/pagination-model.test.ts | 124 + .../query/pagination-oracle.property.test.ts | 5976 +++++++---------- .../query/pagination-recomputation.test.ts | 1525 +++++ 6 files changed, 4469 insertions(+), 3621 deletions(-) create mode 100644 packages/db/tests/query/pagination-fixtures.ts create mode 100644 packages/db/tests/query/pagination-model-oracle.ts create mode 100644 packages/db/tests/query/pagination-model.test.ts create mode 100644 packages/db/tests/query/pagination-recomputation.test.ts diff --git a/packages/db/tests/query/limit-admission-oracle.ts b/packages/db/tests/query/limit-admission-oracle.ts index 3565189dea..dbf128df2b 100644 --- a/packages/db/tests/query/limit-admission-oracle.ts +++ b/packages/db/tests/query/limit-admission-oracle.ts @@ -17,17 +17,12 @@ * keep the previous window and rows (ARCHITECTURE.md, Atomic window publication). */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' 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 { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -420,12 +415,6 @@ async function runHistory( ) } -declareOracle({ - check: [checkAdmission, checkSettlement], - model: [admitted, selected], - grammar: [historyArbitrary], -}) - // Every payload already crosses every required admission/settlement cut for // its path. The identity builder preserves generated ranks, bounds and the // candidate/outcome loops. The negative map's inverse above only makes that @@ -458,14 +447,15 @@ async function judgeLimitHistory( export function registerLimitAdmissionOracle(): void { describe(`negative limit admission`, () => { - for (const path of [ + const paths = [ `builder`, `unstarted`, `ready`, `initial-pending`, `move-pending`, `replay-pending`, - ] as const) { + ] as const + for (const path of paths) { for (const negative of [false, true]) { const property = `pagination.limit-admission-${path}-${negative ? `negative` : `valid`}` const states = [ @@ -475,15 +465,6 @@ export function registerLimitAdmissionOracle(): void { ...(path.endsWith(`pending`) ? [`successful-settlement`] : []), ...(negative && path === `move-pending` ? [`failed-settlement`] : []), ] - // The previous explicit history has ranks outside the random grammar. - // Retain it through the same model, driver and comparison rather than - // widening that grammar to satisfy native fixed-history membership. - if (!oracleReplayRequested()) - it(`${path} ${negative ? `rejects negatives` : `admits valid neighbors`} (fixed history)`, async () => { - oracleRoute(`case`) - await judgeLimitHistory(example, path, negative, () => {}) - }, 30000) - // Native campaigns preserve the first law. Keep the existing fidelity // wrapper as well: it distinguishes the original law AND checkpoint // and retains original/reduced traces and cleanup diagnostics. A fresh @@ -538,5 +519,41 @@ export function registerLimitAdmissionOracle(): void { }) } } + + // The previous explicit history has ranks outside the random grammar. + // Retain it through the same model, driver and comparison rather than + // widening that grammar to satisfy native fixed-history membership. Each + // path and sign replays it under the law of its generated property. + const fixedCases = paths.flatMap((path) => + [false, true].map((negative) => ({ path, negative })), + ) + oracleProperty({ + id: `pagination.limit-admission-fixed`, + law: { + statement: `Negative limits reject synchronously without changing query construction, public windows, demand, rows or an admitted operation's outcome. Valid neighboring limits remain usable.`, + authority: `The 2026-11-02 maintainer decision and ARCHITECTURE.md, Atomic window publication, stated above.`, + limits: `The reported -2 example, outside the random grammar, on each of the unchanged six core admission paths with controlled on-demand acquisition settlement; no NaN, positive-fraction or real-provider timing policy.`, + }, + histories: fixedCases, + label: ({ path, negative }) => + `${path} ${negative ? `rejects negatives` : `admits valid neighbors`} (fixed history)`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan: { history: (typeof fixedCases)[number] }, observe) => + recordAssertionJudgment( + `pagination.limit-admission-${plan.history.path}-${plan.history.negative ? `negative` : `valid`}`, + plan, + observe, + () => + judgeLimitHistory( + example, + plan.history.path, + plan.history.negative, + () => {}, + ), + ), + check: forwardAssertionJudgment, + timeout: 30000, + }) }) } diff --git a/packages/db/tests/query/pagination-fixtures.ts b/packages/db/tests/query/pagination-fixtures.ts new file mode 100644 index 0000000000..c2ce4df853 --- /dev/null +++ b/packages/db/tests/query/pagination-fixtures.ts @@ -0,0 +1,131 @@ +/** + * Controlled-provider fixtures shared by `pagination-oracle.property.test.ts` + * and its ordinary tests in `pagination-recomputation.test.ts`, moved + * unchanged from the oracle file. They supply source behavior and row shapes; + * they compute no expected window. This module registers no tests and keeps no + * state. + */ +import { BTreeIndex } from '../../src/index.js' +import { createCollection } from '../../src/collection/index.js' +import { evaluateReferenceExpression } from '../reference-expression-oracle.js' +import type { createDeferred } from '../../src/deferred.js' +import type { PageRow } from './pagination-support.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +export type PublicPageRow = Pick + +export type MultiOrderRow = { + id: number + primary: number | null + secondary: number | null +} + +export type LocaleCursorRow = { + id: number + label: string +} + +// responseStarted is driver bookkeeping: provider response processing has started (delivery or rejection), although the +// acquisition promise may remain pending until its writes apply. +export type PendingCursorLoad = { + options: LoadSubsetOptions + deferred: ReturnType> + responseStarted?: boolean +} + +export function projectPageRow(row: PageRow): PublicPageRow { + return { id: row.id, rank: row.rank } +} + +export function rowsForLoadSubset( + rows: ReadonlyArray, + options: LoadSubsetOptions, +): Array { + const matchingRows = options.where + ? rows.filter( + (row) => evaluateReferenceExpression(options.where!, row) === true, + ) + : rows + if (!options.cursor) { + const start = options.offset ?? 0 + const end = + options.limit === undefined ? matchingRows.length : start + options.limit + return matchingRows.slice(start, end) + } + + const current = matchingRows.filter((row) => + Boolean(evaluateReferenceExpression(options.cursor!.whereCurrent, row)), + ) + const from = matchingRows.filter((row) => + Boolean(evaluateReferenceExpression(options.cursor!.whereFrom, row)), + ) + const limitedFrom = + options.limit === undefined ? from : from.slice(0, options.limit) + const requested = new Map() + for (const row of [...current, ...limitedFrom]) requested.set(row.id, row) + return [...requested.values()] +} + +export function createConformingOrderedSource( + id: string, + rows: ReadonlyArray, + autoIndex: `eager` | `off` = `eager`, +) { + const requests: Array = [] + const delivered = new Set() + const source = createCollection({ + id, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + const requested = rowsForLoadSubset(rows, options) + begin() + for (const row of requested) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: row }) + } + const receipt = commit(options.signal) + return receipt === true ? Promise.resolve() : receipt + }, + } + }, + }, + }) + + return { requests, source } +} + +export type CustomCollationRow = { + id: number + groupId: number + label: string + keep: boolean +} + +export const customCollationProviderRows = [ + { id: 0, groupId: 1, label: `3`, keep: false }, + { id: 1, groupId: 1, label: `pillow fort`, keep: true }, + { id: 3, groupId: 1, label: `+3 power`, keep: true }, + { id: 4, groupId: 1, label: `pillowfort`, keep: true }, + { id: 2, groupId: 1, label: `3`, keep: true }, +] satisfies ReadonlyArray + +export const customCollationRanks = new Map([ + [`3`, 0], + [`+3 power`, 0], + [`pillowfort`, 1], + [`pillow fort`, 2], +]) + +export function compareCustomCollationStrings(a: string, b: string): number { + return customCollationRanks.get(a)! - customCollationRanks.get(b)! +} diff --git a/packages/db/tests/query/pagination-model-oracle.ts b/packages/db/tests/query/pagination-model-oracle.ts new file mode 100644 index 0000000000..4fa0f127da --- /dev/null +++ b/packages/db/tests/query/pagination-model-oracle.ts @@ -0,0 +1,269 @@ +/** + * The independent window model, request-trace checks, and named model cases of + * `pagination-oracle.property.test.ts`, moved unchanged so that their model and + * checker calibrations in `pagination-model.test.ts` can read them. That oracle + * file states the law and its authority. This module registers no tests and + * keeps no state. + */ +import { expect } from 'vitest' +import { PropRef } from '../../src/query/ir.js' +import { evaluateReferenceExpression } from '../reference-expression-oracle.js' +import { compareAt } from './ordered-comparison-oracle.js' +import type { PageRow } from './pagination-support.js' +import type { PublicPageRow } from './pagination-fixtures.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +export type PaginationWindow = { + offset: number + limit: number +} + +export type PaginationAction = + | ({ type: `window` } & PaginationWindow) + | { type: `put`; id: number; rank: number; keep?: boolean } + | { type: `delete`; id: number } + +export type PaginationStateScenario = { + ranks: ReadonlyArray + keeps?: ReadonlyArray + direction: `asc` | `desc` + initialWindow: PaginationWindow + actions: ReadonlyArray + explicitPublicKeyOrder?: boolean + includeFilter?: boolean + reverseInsertion?: boolean +} + +export type PendingMutation = + | { type: `insert`; row: PageRow } + | { type: `delete`; id: number } + | { type: `update`; row: PageRow } + +export type PendingMutationScenario = { + ranks: ReadonlyArray + direction: `asc` | `desc` + limit: number + mutation: PendingMutation + responseOutcome: `resolve` | `reject` + includeFilter?: boolean + /** Provider response cap; the recorded request retains its requested limit. */ + orderedResponseLimit?: number +} + +export type PendingMutationResult = { + mutationRepairRequests: Array + releasedBeforeCleanup: Array +} + +export function orderSignature(options: LoadSubsetOptions) { + return options.orderBy?.map(({ expression, compareOptions }) => ({ + path: expression instanceof PropRef ? expression.path : undefined, + direction: compareOptions.direction, + })) +} + +export function expectedOrderSignature( + direction: `asc` | `desc`, + explicitPublicKeyOrder: boolean, +) { + return [ + { path: [`rank`], direction }, + ...(explicitPublicKeyOrder + ? [{ path: [`id`], direction: `asc` as const }] + : []), + ] +} + +export function expectPredicateBehavior( + where: LoadSubsetOptions[`where`], + accepted: ReadonlyArray, + rejected: ReadonlyArray, +) { + compareAt(`pagination-oracle.property.test.ts:214`, () => + expect(where).toBeDefined(), + ) + for (const row of accepted) { + compareAt(`pagination-oracle.property.test.ts:216`, () => + expect(evaluateReferenceExpression(where!, row)).toBe(true), + ) + } + for (const row of rejected) { + compareAt(`pagination-oracle.property.test.ts:219`, () => + expect(evaluateReferenceExpression(where!, row)).not.toBe(true), + ) + } +} + +export function classifyWhereOnlyRepairRequest( + request: LoadSubsetOptions, + scenario: PendingMutationScenario, +): `tie` | `filtered-full-source` { + if (!request.where || request.orderBy || request.limit || request.cursor) { + throw new Error(`Expected one predicate-only repair request`) + } + if (!scenario.includeFilter) return `tie` + + const distantMatchingRows: ReadonlyArray = [ + { id: 101, rank: -1_000_000, keep: true }, + { id: 102, rank: 1_000_000, keep: true }, + ] + const acceptsWholeFilteredSource = distantMatchingRows.every( + (row) => evaluateReferenceExpression(request.where!, row) === true, + ) + return acceptsWholeFilteredSource ? `filtered-full-source` : `tie` +} + +export function expectFilteredBoundedRepairTrace( + result: PendingMutationResult, + direction: `asc` | `desc`, + limit: number, +) { + expect(result.mutationRepairRequests).toHaveLength(2) + const [prefix, boundary] = result.mutationRepairRequests + expect(orderSignature(prefix!)).toStrictEqual( + expectedOrderSignature(direction, true), + ) + expect(prefix!.limit).toBe(limit) + expect(prefix!.refetch).toBe(true) + expect(prefix!.cursor).toBeUndefined() + expect(prefix!.offset).toBeUndefined() + expectPredicateBehavior( + prefix!.where, + [{ id: 10, rank: -100, keep: true }], + [{ id: 10, rank: -100, keep: false }], + ) + + expect(boundary!.orderBy).toBeUndefined() + expect(boundary!.limit).toBeUndefined() + expect(boundary!.refetch).toBe(true) + expect(boundary!.cursor).toBeUndefined() + expect(boundary!.offset).toBeUndefined() + expectPredicateBehavior( + boundary!.where, + [{ id: 4, rank: 3, keep: true }], + [ + { id: 4, rank: 2, keep: true }, + { id: 4, rank: 3, keep: false }, + ], + ) +} + +export function referenceWindow( + rows: ReadonlyArray, + direction: `asc` | `desc`, + window: PaginationWindow, +): Array { + return referenceWindowRows(rows, direction, window).map(({ id }) => id) +} + +export function referenceWindowRows( + rows: ReadonlyArray, + direction: `asc` | `desc`, + window: PaginationWindow, +): Array { + return [...rows] + .sort((left, right) => { + // The public NaN contract places NaN after finite ranks in ascending + // order. The key breaks ties between equal ranks, including two NaNs. + const rankOrder = Number.isNaN(left.rank) + ? Number.isNaN(right.rank) + ? 0 + : 1 + : Number.isNaN(right.rank) + ? -1 + : left.rank < right.rank + ? -1 + : left.rank > right.rank + ? 1 + : 0 + return rankOrder * (direction === `asc` ? 1 : -1) || left.id - right.id + }) + .slice(window.offset, window.offset + window.limit) + .map(({ id, rank }) => ({ id, rank })) +} + +export function expectWindowRows( + actual: ReadonlyArray, + expected: ReadonlyArray, +): void { + compareAt(`pagination-oracle.property.test.ts:640`, () => + expect(actual).toEqual(expected), + ) +} + +export function visibleRows( + rows: ReadonlyArray, + includeFilter: boolean | undefined, +): Array { + return includeFilter ? rows.filter(({ keep }) => keep) : [...rows] +} + +// The eager NaN window cells. The oracle replays each through +// runPaginationScenario; pagination-model.test.ts checks the model against +// the stated ids. +export const eagerNaNWindowCases = [ + { + direction: `asc` as const, + ranks: [10, 20, NaN, 30, 40], + expectedIds: [1, 2, 4], + limit: 3, + }, + { + direction: `desc` as const, + ranks: [10, 20, NaN, 30, 40], + expectedIds: [3, 5, 4], + limit: 3, + }, + { + direction: `desc` as const, + ranks: [10, NaN, 20], + expectedIds: [2, 3], + limit: 2, + }, + { + direction: `desc` as const, + ranks: [NaN, 20, NaN, 30], + expectedIds: [1, 3, 4], + limit: 3, + }, + { + direction: `desc` as const, + ranks: [10, 20, 25, 30, 40], + expectedIds: [5, 4, 3], + limit: 3, + }, +] + +// Rows entering and leaving the filter. The oracle replays each through +// runPaginationStateScenario; pagination-model.test.ts checks the model's +// final window against the stated ids. +export const filterUpdateCases = [ + { + name: `enters the filter`, + keeps: [false, true], + action: { type: `put` as const, id: 1, rank: 0, keep: true }, + expected: [1, 2], + }, + { + name: `leaves the filter`, + keeps: [true, true], + action: { type: `put` as const, id: 1, rank: 0, keep: false }, + expected: [2], + }, +] + +export function filterUpdateScenario({ + keeps, + action, +}: (typeof filterUpdateCases)[number]): PaginationStateScenario { + return { + ranks: [0, 1], + keeps, + direction: `asc`, + includeFilter: true, + explicitPublicKeyOrder: true, + reverseInsertion: false, + initialWindow: { offset: 0, limit: 2 }, + actions: [action], + } +} diff --git a/packages/db/tests/query/pagination-model.test.ts b/packages/db/tests/query/pagination-model.test.ts new file mode 100644 index 0000000000..08e1ae5e2a --- /dev/null +++ b/packages/db/tests/query/pagination-model.test.ts @@ -0,0 +1,124 @@ +/** + * Model and checker calibrations for `pagination-oracle.property.test.ts`. + * They run no production: each checks that the independent window model gives + * the stated ids, or that a request-trace check or classifier rejects or + * classifies a hand-built input. The law, its authority, and the production + * comparisons are in that oracle file; the model and checks are in + * `pagination-model-oracle.ts`. The model cases' production runs replay through + * the oracle's `pagination.ordered-window-fixed` and + * `pagination.window-transition-fixed` properties. + */ +import { describe, expect, it } from 'vitest' +import { and, eq } from '../../src/query/builder/functions.js' +import { PropRef } from '../../src/query/ir.js' +import { + classifyWhereOnlyRepairRequest, + eagerNaNWindowCases, + expectFilteredBoundedRepairTrace, + expectWindowRows, + filterUpdateCases, + filterUpdateScenario, + referenceWindow, + referenceWindowRows, + visibleRows, +} from './pagination-model-oracle.js' +import type { + PendingMutationResult, + PendingMutationScenario, +} from './pagination-model-oracle.js' + +describe(`pagination model`, () => { + it.each(eagerNaNWindowCases)( + `orders a $direction eager window with a NaN or finite middle rank`, + ({ direction, ranks, expectedIds, limit }) => { + const rows = ranks.map((rank, index) => ({ id: index + 1, rank })) + const modeled = referenceWindow(rows, direction, { offset: 0, limit }) + expect(modeled).toEqual(expectedIds) + }, + ) + + it.each(filterUpdateCases)(`updates a row that $name`, (row) => { + const { keeps, action, expected } = row + const scenario = filterUpdateScenario(row) + + const finalRows = scenario.ranks.map((rank, index) => ({ + id: index + 1, + rank, + keep: index === 0 ? action.keep : keeps[index], + })) + expect( + referenceWindow( + visibleRows(finalRows, true), + scenario.direction, + scenario.initialWindow, + ), + ).toEqual(expected) + }) + + it(`rejects the right NaN window members in the wrong order`, () => { + const rows = [ + { id: 1, rank: 10 }, + { id: 2, rank: 20 }, + { id: 3, rank: NaN }, + { id: 4, rank: 30 }, + { id: 5, rank: 40 }, + ] + const expected = referenceWindowRows(rows, `desc`, { offset: 0, limit: 3 }) + expect(expected.map(({ id }) => id)).toEqual([3, 5, 4]) + expect(() => + expectWindowRows([expected[1]!, expected[2]!, expected[0]!], expected), + ).toThrow() + }) + + it(`rejects a malformed bounded-repair request trace`, () => { + const malformed: PendingMutationResult = { + mutationRepairRequests: [ + { + orderBy: [ + { + expression: new PropRef([`wrongPrimary`]), + compareOptions: { direction: `desc`, nulls: `last` }, + }, + { + expression: new PropRef([`wrongSecondary`]), + compareOptions: { direction: `desc`, nulls: `last` }, + }, + ], + limit: 3, + offset: 99, + refetch: true, + }, + { refetch: true }, + ], + releasedBeforeCleanup: [], + } + + expect(() => + expectFilteredBoundedRepairTrace(malformed, `asc`, 3), + ).toThrow() + }) + + it(`distinguishes a filtered full-source fallback from a repair tie predicate`, () => { + const filter = eq(new PropRef([`keep`]), true) + const scenario: PendingMutationScenario = { + ranks: [0, 1, 2], + direction: `asc`, + limit: 2, + mutation: { type: `delete`, id: 1 }, + responseOutcome: `reject`, + includeFilter: true, + } + + expect(classifyWhereOnlyRepairRequest({ where: filter }, scenario)).toBe( + `filtered-full-source`, + ) + expect( + classifyWhereOnlyRepairRequest( + { + where: and(filter, eq(new PropRef([`rank`]), 1)), + }, + scenario, + ), + ).toBe(`tie`) + }) +}) diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index f093ab7395..aa01eaa329 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -1,14 +1,13 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { BTreeIndex } from '../../src/index.js' import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' -import { and, eq } from '../../src/query/builder/functions.js' +import { eq } from '../../src/query/builder/functions.js' import { PropRef } from '../../src/query/ir.js' import { makeComparator } from '../../src/utils/comparison.js' -import { oracleProperty, oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { TraceAssertionError } from '../trace-runner.js' import { flushPromises, mockSyncCollectionOptions } from '../utils.js' @@ -17,10 +16,47 @@ import { compareAt, observeComparison, recordedComparison, + rejectAt, } from './ordered-comparison-oracle.js' import { cleanupAll } from './pagination-support.js' +import { + compareCustomCollationStrings, + createConformingOrderedSource, + customCollationProviderRows, + projectPageRow, + rowsForLoadSubset, +} from './pagination-fixtures.js' +import { + classifyWhereOnlyRepairRequest, + eagerNaNWindowCases, + expectFilteredBoundedRepairTrace, + expectPredicateBehavior, + expectWindowRows, + expectedOrderSignature, + filterUpdateCases, + filterUpdateScenario, + orderSignature, + referenceWindow, + referenceWindowRows, + visibleRows, +} from './pagination-model-oracle.js' import { registerLimitAdmissionOracle } from './limit-admission-oracle.js' import type { PageRow } from './pagination-support.js' +import type { + CustomCollationRow, + MultiOrderRow, + PendingCursorLoad, + PublicPageRow, +} from './pagination-fixtures.js' +import type { + PaginationAction, + PaginationStateScenario, + PaginationWindow, + PendingMutation, + PendingMutationResult, + PendingMutationScenario, +} from './pagination-model-oracle.js' +import type { Observation } from '../oracle-property.js' import type { ChangeMessage, LoadSubsetOptions, @@ -72,7 +108,6 @@ import type { * parity. */ -type PublicPageRow = Pick type PublicPageChange = { type: `insert` | `update` | `delete` key: number @@ -80,12 +115,6 @@ type PublicPageChange = { previousValue?: PublicPageRow } -type MultiOrderRow = { - id: number - primary: number | null - secondary: number | null -} - type MultiOrderTerm = { direction: `asc` | `desc` nulls: `first` | `last` @@ -103,11 +132,6 @@ type NullableCursorRow = { rank: number | null } -type LocaleCursorRow = { - id: number - label: string -} - type AdversarialOrderedRow = { id: number rank: number | null | object @@ -119,11 +143,6 @@ type NullableCursorScenario = { direction: `asc` | `desc` } -type PaginationWindow = { - offset: number - limit: number -} - type PaginationScenario = { ranks: ReadonlyArray keeps?: ReadonlyArray @@ -136,114 +155,10 @@ type PaginationScenario = { localRowsBeforeFirstRequest?: ReadonlyArray } -type PaginationAction = - | ({ type: `window` } & PaginationWindow) - | { type: `put`; id: number; rank: number; keep?: boolean } - | { type: `delete`; id: number } - -type PaginationStateScenario = { - ranks: ReadonlyArray - keeps?: ReadonlyArray - direction: `asc` | `desc` - initialWindow: PaginationWindow - actions: ReadonlyArray - explicitPublicKeyOrder?: boolean - includeFilter?: boolean - reverseInsertion?: boolean -} - type PaginationStructure = Pick< PaginationScenario, `explicitPublicKeyOrder` | `includeFilter` | `reverseInsertion` > - -// responseStarted is driver bookkeeping: provider response processing has started (delivery or rejection), although the -// acquisition promise may remain pending until its writes apply. -type PendingCursorLoad = { - options: LoadSubsetOptions - deferred: ReturnType> - responseStarted?: boolean -} - -type PendingMutation = - | { type: `insert`; row: PageRow } - | { type: `delete`; id: number } - | { type: `update`; row: PageRow } - -type PendingMutationScenario = { - ranks: ReadonlyArray - direction: `asc` | `desc` - limit: number - mutation: PendingMutation - responseOutcome: `resolve` | `reject` - includeFilter?: boolean - /** Provider response cap; the recorded request retains its requested limit. */ - orderedResponseLimit?: number -} - -type PendingMutationResult = { - mutationRepairRequests: Array - releasedBeforeCleanup: Array -} - -function orderSignature(options: LoadSubsetOptions) { - return options.orderBy?.map(({ expression, compareOptions }) => ({ - path: expression instanceof PropRef ? expression.path : undefined, - direction: compareOptions.direction, - })) -} - -function expectedOrderSignature( - direction: `asc` | `desc`, - explicitPublicKeyOrder: boolean, -) { - return [ - { path: [`rank`], direction }, - ...(explicitPublicKeyOrder - ? [{ path: [`id`], direction: `asc` as const }] - : []), - ] -} - -function expectPredicateBehavior( - where: LoadSubsetOptions[`where`], - accepted: ReadonlyArray, - rejected: ReadonlyArray, -) { - compareAt(`pagination-oracle.property.test.ts:214`, () => - expect(where).toBeDefined(), - ) - for (const row of accepted) { - compareAt(`pagination-oracle.property.test.ts:216`, () => - expect(evaluateReferenceExpression(where!, row)).toBe(true), - ) - } - for (const row of rejected) { - compareAt(`pagination-oracle.property.test.ts:219`, () => - expect(evaluateReferenceExpression(where!, row)).not.toBe(true), - ) - } -} - -function classifyWhereOnlyRepairRequest( - request: LoadSubsetOptions, - scenario: PendingMutationScenario, -): `tie` | `filtered-full-source` { - if (!request.where || request.orderBy || request.limit || request.cursor) { - throw new Error(`Expected one predicate-only repair request`) - } - if (!scenario.includeFilter) return `tie` - - const distantMatchingRows: ReadonlyArray = [ - { id: 101, rank: -1_000_000, keep: true }, - { id: 102, rank: 1_000_000, keep: true }, - ] - const acceptsWholeFilteredSource = distantMatchingRows.every( - (row) => evaluateReferenceExpression(request.where!, row) === true, - ) - return acceptsWholeFilteredSource ? `filtered-full-source` : `tie` -} - function expectPendingMutationRequestWork( result: PendingMutationResult, scenario: PendingMutationScenario, @@ -321,41 +236,6 @@ function expectPendingMutationRequestWork( } } -function expectFilteredBoundedRepairTrace( - result: PendingMutationResult, - direction: `asc` | `desc`, - limit: number, -) { - expect(result.mutationRepairRequests).toHaveLength(2) - const [prefix, boundary] = result.mutationRepairRequests - expect(orderSignature(prefix!)).toStrictEqual( - expectedOrderSignature(direction, true), - ) - expect(prefix!.limit).toBe(limit) - expect(prefix!.refetch).toBe(true) - expect(prefix!.cursor).toBeUndefined() - expect(prefix!.offset).toBeUndefined() - expectPredicateBehavior( - prefix!.where, - [{ id: 10, rank: -100, keep: true }], - [{ id: 10, rank: -100, keep: false }], - ) - - expect(boundary!.orderBy).toBeUndefined() - expect(boundary!.limit).toBeUndefined() - expect(boundary!.refetch).toBe(true) - expect(boundary!.cursor).toBeUndefined() - expect(boundary!.offset).toBeUndefined() - expectPredicateBehavior( - boundary!.where, - [{ id: 4, rank: 3, keep: true }], - [ - { id: 4, rank: 2, keep: true }, - { id: 4, rank: 3, keep: false }, - ], - ) -} - class DeliveredRowsTraceAssertionError extends TraceAssertionError { constructor( cause: unknown, @@ -625,53 +505,6 @@ const nullableCursorScenarioArbitrary: fc.Arbitrary = let collectionSequence = 0 -function referenceWindow( - rows: ReadonlyArray, - direction: `asc` | `desc`, - window: PaginationWindow, -): Array { - return referenceWindowRows(rows, direction, window).map(({ id }) => id) -} - -function referenceWindowRows( - rows: ReadonlyArray, - direction: `asc` | `desc`, - window: PaginationWindow, -): Array { - return [...rows] - .sort((left, right) => { - // The public NaN contract places NaN after finite ranks in ascending - // order. The key breaks ties between equal ranks, including two NaNs. - const rankOrder = Number.isNaN(left.rank) - ? Number.isNaN(right.rank) - ? 0 - : 1 - : Number.isNaN(right.rank) - ? -1 - : left.rank < right.rank - ? -1 - : left.rank > right.rank - ? 1 - : 0 - return rankOrder * (direction === `asc` ? 1 : -1) || left.id - right.id - }) - .slice(window.offset, window.offset + window.limit) - .map(({ id, rank }) => ({ id, rank })) -} - -function expectWindowRows( - actual: ReadonlyArray, - expected: ReadonlyArray, -): void { - compareAt(`pagination-oracle.property.test.ts:640`, () => - expect(actual).toEqual(expected), - ) -} - -function projectPageRow(row: PageRow): PublicPageRow { - return { id: row.id, rank: row.rank } -} - function normalizePageChanges( changes: ReadonlyArray>, ): Array { @@ -723,80 +556,6 @@ function isKeptRow(id: number): boolean { return id % 3 !== 0 } -function visibleRows( - rows: ReadonlyArray, - includeFilter: boolean | undefined, -): Array { - return includeFilter ? rows.filter(({ keep }) => keep) : [...rows] -} - -function rowsForLoadSubset( - rows: ReadonlyArray, - options: LoadSubsetOptions, -): Array { - const matchingRows = options.where - ? rows.filter( - (row) => evaluateReferenceExpression(options.where!, row) === true, - ) - : rows - if (!options.cursor) { - const start = options.offset ?? 0 - const end = - options.limit === undefined ? matchingRows.length : start + options.limit - return matchingRows.slice(start, end) - } - - const current = matchingRows.filter((row) => - Boolean(evaluateReferenceExpression(options.cursor!.whereCurrent, row)), - ) - const from = matchingRows.filter((row) => - Boolean(evaluateReferenceExpression(options.cursor!.whereFrom, row)), - ) - const limitedFrom = - options.limit === undefined ? from : from.slice(0, options.limit) - const requested = new Map() - for (const row of [...current, ...limitedFrom]) requested.set(row.id, row) - return [...requested.values()] -} - -function createConformingOrderedSource( - id: string, - rows: ReadonlyArray, - autoIndex: `eager` | `off` = `eager`, -) { - const requests: Array = [] - const delivered = new Set() - const source = createCollection({ - id, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - const requested = rowsForLoadSubset(rows, options) - begin() - for (const row of requested) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: row }) - } - const receipt = commit(options.signal) - return receipt === true ? Promise.resolve() : receipt - }, - } - }, - }, - }) - - return { requests, source } -} - async function runPaginationScenario( scenario: PaginationScenario, ): Promise { @@ -863,27 +622,6 @@ async function runPaginationScenario( * must equal the model's window over the authoritative rows. The driver runs * one history, and `referenceWindowRows` predicts each window from the rows. */ -declareOracle({ - check: [runPaginationScenario, runMultiOrderScenario], - model: [referenceWindowRows, referenceMultiOrder], -}) - -async function expectMultiOrderBoundaryMatches(): Promise { - await runMultiOrderScenario({ - rows: [ - { id: 1, primary: 0, secondary: 2 }, - { id: 2, primary: 0, secondary: 0 }, - { id: 3, primary: 0, secondary: 1 }, - { id: 4, primary: 1, secondary: 1 }, - { id: 5, primary: 1, secondary: 0 }, - { id: 6, primary: 2, secondary: 0 }, - ], - primary: { direction: `asc`, nulls: `first` }, - secondary: { direction: `asc`, nulls: `first` }, - limit: 4, - }) -} - function compareNullableNumber( left: number | null, right: number | null, @@ -1473,95 +1211,6 @@ async function runOnDemandPaginationScenario( ]) } -async function expectOnDemandWindowsAreCompletionOrderIndependent( - deliveryOrder: `forward` | `reverse`, -): Promise { - const authoritativeRows: Array = [ - { id: 1, rank: 0 }, - { id: 2, rank: 1 }, - { id: 3, rank: 2 }, - { id: 4, rank: 3 }, - ] - const pending: Array = [] - const deliveredIds = new Set([1]) - let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void - let commit!: () => void - const apply = (options: LoadSubsetOptions) => { - begin() - for (const row of rowsForLoadSubset(authoritativeRows, options)) { - if (deliveredIds.has(row.id)) continue - deliveredIds.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - } - const source = createCollection({ - id: `pagination-completion-order-source-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - begin() - write({ type: `insert`, value: { ...authoritativeRows[0]! } }) - commit() - params.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - const deferred = createDeferred() - pending.push({ options, deferred }) - return deferred.promise - }, - } - }, - }, - }) - const createLive = (limit: number) => - createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .orderBy(({ row }) => row.id, `asc`) - .limit(limit), - ) - const firstLive = createLive(2) - const secondLive = createLive(3) - - try { - const first = firstLive.preload() - const second = secondLive.preload() - expect(pending).toHaveLength(2) - - const indices = deliveryOrder === `forward` ? [0, 1] : [1, 0] - for (const index of indices) { - const request = pending[index]! - apply(request.options) - request.deferred.resolve() - await flushPromises() - } - for (let index = 2; index < pending.length; index++) { - const request = pending[index]! - apply(request.options) - request.deferred.resolve() - await flushPromises() - } - await first - await second - - expect(Array.from(firstLive.values(), ({ id }) => id)).toEqual([1, 2]) - expect(Array.from(secondLive.values(), ({ id }) => id)).toEqual([1, 2, 3]) - } finally { - for (const request of pending) request.deferred.resolve() - await cleanupAll(firstLive, secondLive, source) - } -} - async function runAdversarialOrderedProviderScenario( options: { providerRows: ReadonlyArray @@ -1726,32 +1375,6 @@ async function runAdversarialOrderedProviderScenario( ) } -type CustomCollationRow = { - id: number - groupId: number - label: string - keep: boolean -} - -const customCollationProviderRows = [ - { id: 0, groupId: 1, label: `3`, keep: false }, - { id: 1, groupId: 1, label: `pillow fort`, keep: true }, - { id: 3, groupId: 1, label: `+3 power`, keep: true }, - { id: 4, groupId: 1, label: `pillowfort`, keep: true }, - { id: 2, groupId: 1, label: `3`, keep: true }, -] satisfies ReadonlyArray - -const customCollationRanks = new Map([ - [`3`, 0], - [`+3 power`, 0], - [`pillowfort`, 1], - [`pillow fort`, 2], -]) - -function compareCustomCollationStrings(a: string, b: string): number { - return customCollationRanks.get(a)! - customCollationRanks.get(b)! -} - async function runCustomCollationScenario(options: { direction: `asc` | `desc` autoIndex: `off` | `eager` @@ -2963,328 +2586,169 @@ function changedRankValue(previous: number, requested: number): number { : requested } -async function expectInflightRequestFillsNewWindow(): Promise { - const rows: Array = [ - { id: 1, rank: 0 }, - { id: 2, rank: 1 }, - { id: 3, rank: 2 }, - { id: 4, rank: 3 }, - ] - const pending: Array = [] - const deliveredIds = new Set([1]) - let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-late-window-source-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - begin() - write({ type: `insert`, value: { ...rows[0]! } }) - commit() - params.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - const deferred = createDeferred() - pending.push({ options, deferred }) - return deferred.promise - }, - } - }, - }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .orderBy(({ row }) => row.id, `asc`) - .limit(2), - ) - - const settle = async (request: PendingCursorLoad) => { - begin() - for (const row of rowsForLoadSubset(rows, request.options)) { - if (deliveredIds.has(row.id)) continue - deliveredIds.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - request.deferred.resolve() - await Promise.resolve() +/** + * Registration support for the named histories below. It transports the + * file's existing judgment (`ordered-comparison-oracle.ts`): the named driver + * keeps its model, production path, assertions, and cleanup, and only an + * assertion at an explicit `compareAt` cut becomes a violation. + */ +function orderedComparison( + run: (history: THistory) => Promise, +) { + return { + model: (history: THistory, observations: ReadonlyArray) => + recordedComparison(history, observations), + driver: ( + history: THistory, + observe: (checkpoint: string, value: unknown) => void, + ) => observeComparison(history, observe, () => run(history)), } +} - try { - const preload = live.preload() - expect(pending).toHaveLength(1) - const setWindow = live.utils.setWindow({ offset: 2, limit: 2 }) - expect(setWindow).toBeInstanceOf(Promise) - await flushPromises() - expect(pending.length).toBeGreaterThan(0) +/** The law of a property whose histories are named regressions or cells. */ +function namedHistoriesLaw(statement: string) { + return { + statement, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named histories that the generated grammars cannot take back; each runs through the same driver and its existing comparison.`, + } +} - for (let index = 0; index < pending.length; index++) { - if (index > rows.length * 2) { - throw new Error(`Ordered continuation exceeded its work bound`) - } - await settle(pending[index]!) - await flushPromises() - } - await preload - if (setWindow instanceof Promise) await setWindow +/** One call of runPendingMutationScenario, with the arguments an old test passed. */ +type PendingMutationRun = { + scenario: PendingMutationScenario + timing: `before-response` | `after-response` + finalLimitAfterMutation?: number + explicitPublicKeyOrder?: boolean + transport?: `cursor` | `offset` +} - try { - expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 4]) - } catch (error) { - throw new TraceAssertionError(0, error) - } - } finally { - for (const request of pending) request.deferred.resolve() - await cleanupAll(live, source) - } +/** A named pending-mutation history: the driver calls of one old test, in order. */ +type PendingMutationFixedHistory = { + name: string + runs: ReadonlyArray } -registerLimitAdmissionOracle() +type PendingMutationFault = NonNullable< + Parameters[5] +> -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 = [ - { id: 1, rank: 0, groupId: 1 }, - { id: 2, rank: 1, groupId: 2 }, - { id: 3, rank: 2, groupId: 3 }, - { id: 4, rank: 3, groupId: 4 }, - ] satisfies ReadonlyArray - const { requests, source: parentSource } = createConformingOrderedSource( - `pagination-joined-underfill-source-${collectionSequence++}`, - parents, - ) - const childSource = createCollection( - mockSyncCollectionOptions({ - id: `pagination-joined-underfill-child-${collectionSequence++}`, - initialData: [ - { id: 20, groupId: 2 }, - { id: 30, groupId: 3 }, - { id: 40, groupId: 4 }, - ] satisfies ReadonlyArray, - getKey: (row: ChildRow) => row.id, - }), - ) - const live = createLiveQueryCollection((query) => - query - .from({ parent: parentSource }) - .innerJoin({ child: childSource }, ({ parent, child }) => - eq(parent.groupId, child.groupId), - ) - .orderBy(({ parent }) => parent.rank, `asc`) - .orderBy(({ parent }) => parent.id, `asc`) - .limit(2) - .select(({ parent }) => ({ id: parent.id })), - ) - - try { - await live.preload() - await flushPromises() - - expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3]) - expect(requests).toHaveLength(1) - expect(requests[0]?.limit).toBeUndefined() - } finally { - await cleanupAll(live, childSource, parentSource) - } - }) - - 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 = [ - { id: 1, rank: 0, groupId: 1 }, - { id: 2, rank: 1, groupId: 2 }, - { id: 3, rank: 2, groupId: 3 }, - { id: 4, rank: 3, groupId: 4 }, - ] satisfies ReadonlyArray - const { requests, source: parentSource } = createConformingOrderedSource( - `pagination-no-index-underfill-source-${collectionSequence++}`, - parents, - `off`, - ) - const childSource = createCollection( - mockSyncCollectionOptions({ - id: `pagination-no-index-underfill-child-${collectionSequence++}`, - initialData: [ - { id: 20, groupId: 2 }, - { id: 30, groupId: 3 }, - { id: 40, groupId: 4 }, - ] satisfies ReadonlyArray, - getKey: (row: ChildRow) => row.id, - }), - ) - const live = createLiveQueryCollection((query) => - query - .from({ parent: parentSource }) - .innerJoin({ child: childSource }, ({ parent, child }) => - eq(parent.groupId, child.groupId), - ) - .orderBy(({ parent }) => parent.rank, `asc`) - .orderBy(({ parent }) => parent.id, `asc`) - .limit(2) - .select(({ parent }) => ({ id: parent.id })), +async function runPendingMutationRuns( + history: PendingMutationFixedHistory, +): Promise { + for (const run of history.runs) { + await runPendingMutationScenario( + run.scenario, + run.timing, + run.finalLimitAfterMutation, + run.explicitPublicKeyOrder, + run.transport, ) + } +} - try { - await live.preload() - await flushPromises() - - expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3]) - expect(requests).toHaveLength(1) - expect(requests[0]?.limit).toBeUndefined() - } finally { - await cleanupAll(live, childSource, parentSource) - } - }) - - 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 = [ - { id: 1, sourceRank: 0, childId: 1 }, - { id: 2, sourceRank: 0, childId: 2 }, - { id: 3, sourceRank: 0, childId: 3 }, - { id: 4, sourceRank: 0, childId: 4 }, - ] satisfies ReadonlyArray - const { requests, source: parentSource } = createConformingOrderedSource( - `pagination-joined-foreign-order-source-${collectionSequence++}`, - parents, - ) - const childSource = createCollection( - mockSyncCollectionOptions({ - id: `pagination-joined-foreign-order-child-${collectionSequence++}`, - initialData: [ - { id: 1, score: 10 }, - { id: 2, score: 20 }, - { id: 3, score: 0 }, - { id: 4, score: 30 }, - ] satisfies ReadonlyArray, - getKey: (row: ChildRow) => row.id, - }), - ) - const live = createLiveQueryCollection((query) => - query - .from({ parent: parentSource }) - .leftJoin({ child: childSource }, ({ parent, child }) => - eq(parent.childId, child.id), +/** + * A calibration's mutant driver: on the premise that the old fault test ran, + * it runs production with the fault and accepts exactly the old matcher + * (`rejects.toThrow(message)`). Every other history runs clean. + */ +function pendingMutationFaultDriver( + premise: string, + fault: PendingMutationFault, + message: RegExp, + checkpoint: string, +) { + return ( + history: PendingMutationFixedHistory, + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(history, observe, async () => { + if (history.name !== premise) return runPendingMutationRuns(history) + const [run] = history.runs + try { + await runPendingMutationScenario( + run!.scenario, + run!.timing, + undefined, + true, + `cursor`, + fault, ) - .orderBy(({ parent }) => parent.sourceRank, `asc`) - .orderBy(({ child }) => child.score, `asc`) - .orderBy(({ parent }) => parent.id, `asc`) - .limit(2) - .select(({ parent }) => ({ id: parent.id })), - ) - - try { - await live.preload() - await flushPromises() - - expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 1]) - expect(requests).toHaveLength(1) - expect(requests[0]?.limit).toBeUndefined() - } finally { - await cleanupAll(live, childSource, parentSource) - } - }) - - it(`materializes an empty source window`, async () => { - oracleRoute(`fixed history`) - await runPaginationScenario({ - ranks: [], - direction: `asc`, - windows: [{ offset: 0, limit: 3 }], - }) - }) - - it(`materializes an offset past the final row`, async () => { - oracleRoute(`fixed history`) - await runPaginationScenario({ - ranks: [0, 1], - direction: `asc`, - windows: [{ offset: 4, limit: 2 }], - }) - }) - - it(`materializes an initially empty zero-limit window`, async () => { - oracleRoute(`fixed history`) - await runPaginationScenario({ - ranks: [0, 1, 2], - direction: `asc`, - windows: [{ offset: 0, limit: 0 }], + } catch (error) { + if (error instanceof Error && message.test(error.message)) + rejectAt(checkpoint, error) + throw error + } }) - }) +} - it(`rejects the right NaN window members in the wrong order`, () => { - oracleRoute(`calibration`) - const rows = [ - { id: 1, rank: 10 }, - { id: 2, rank: 20 }, - { id: 3, rank: NaN }, - { id: 4, rank: 30 }, - { id: 5, rank: 40 }, - ] - const expected = referenceWindowRows(rows, `desc`, { offset: 0, limit: 3 }) - expect(expected.map(({ id }) => id)).toEqual([3, 5, 4]) - expect(() => - expectWindowRows([expected[1]!, expected[2]!, expected[0]!], expected), - ).toThrow() - }) +registerLimitAdmissionOracle() - it.each([ +describe(`pagination recomputation oracle`, () => { + // Named regressions and boundary cells of the ordered-window law. Each + // replays runPaginationScenario, whose comparison is referenceWindowRows. + // The generated grammar maps its payload without an unmapper, so it cannot + // take these histories back; they run as one enumerated source. + const orderedWindowFixedHistories: ReadonlyArray<{ + name: string + scenario: PaginationScenario + }> = [ { - direction: `asc` as const, - ranks: [10, 20, NaN, 30, 40], - expectedIds: [1, 2, 4], - limit: 3, + name: `materializes an empty source window`, + scenario: { + ranks: [], + direction: `asc`, + windows: [{ offset: 0, limit: 3 }], + }, }, { - direction: `desc` as const, - ranks: [10, 20, NaN, 30, 40], - expectedIds: [3, 5, 4], - limit: 3, + name: `materializes an offset past the final row`, + scenario: { + ranks: [0, 1], + direction: `asc`, + windows: [{ offset: 4, limit: 2 }], + }, }, { - direction: `desc` as const, - ranks: [10, NaN, 20], - expectedIds: [2, 3], - limit: 2, + name: `materializes an initially empty zero-limit window`, + scenario: { + ranks: [0, 1, 2], + direction: `asc`, + windows: [{ offset: 0, limit: 0 }], + }, }, { - direction: `desc` as const, - ranks: [NaN, 20, NaN, 30], - expectedIds: [1, 3, 4], - limit: 3, + name: `clears and restores a nonempty window across a zero limit`, + scenario: { + ranks: [0, 1, 2], + direction: `asc`, + windows: [ + { offset: 0, limit: 2 }, + { offset: 0, limit: 0 }, + { offset: 1, limit: 1 }, + ], + }, }, { - direction: `desc` as const, - ranks: [10, 20, 25, 30, 40], - expectedIds: [5, 4, 3], - limit: 3, + name: `advances past an implicit public-key tie class`, + scenario: { + ranks: [1, 2, 3, 4, 5, 5, 5, 5, 5, 5, 11, 12, 13, 14, 15, 16], + direction: `asc`, + explicitPublicKeyOrder: false, + includeFilter: true, + reverseInsertion: true, + windows: [ + { offset: 0, limit: 5 }, + { offset: 5, limit: 5 }, + { offset: 10, limit: 5 }, + ], + }, }, - ])( - `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) - await runPaginationScenario({ + // The eager NaN window cells. Their model expectations are checked in + // pagination-model.test.ts. + ...eagerNaNWindowCases.map(({ direction, ranks, limit }) => ({ + name: `orders a ${direction} eager window with a NaN or finite middle rank`, + scenario: { ranks, direction, explicitPublicKeyOrder: false, @@ -3292,893 +2756,608 @@ describe(`pagination recomputation oracle`, () => { { offset: 0, limit }, { offset: 1, limit }, ], - }) - }, - ) - - 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`, - explicitPublicKeyOrder: false, - initialWindow: { offset: 0, limit: 3 }, - actions: [ - { type: `put`, id: 3, rank: NaN }, - { type: `put`, id: 3, rank: 35 }, - { type: `put`, id: 3, rank: NaN }, - { type: `delete`, id: 3 }, - ], - }) - }) - - it(`clears and restores a nonempty window across a zero limit`, async () => { - oracleRoute(`fixed history`) - await runPaginationScenario({ - ranks: [0, 1, 2], - direction: `asc`, - windows: [ - { offset: 0, limit: 2 }, - { offset: 0, limit: 0 }, - { offset: 1, limit: 1 }, - ], - }) - }) - - 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`, - explicitPublicKeyOrder: false, - includeFilter: true, - reverseInsertion: true, - windows: [ - { offset: 0, limit: 5 }, - { offset: 5, limit: 5 }, - { offset: 10, limit: 5 }, - ], - }) - }) + }, + })), + ] - it(`keeps implicit ties stable across filtered source mutations`, async () => { - oracleRoute(`fixed history`) - await runPaginationStateScenario({ - ranks: [0, 0, 0, 1, 1, 1], - direction: `asc`, - explicitPublicKeyOrder: false, - includeFilter: true, - reverseInsertion: true, - initialWindow: { offset: 0, limit: 3 }, - actions: [ - { type: `put`, id: 7, rank: 0 }, - { type: `delete`, id: 2 }, - { type: `window`, offset: 1, limit: 3 }, - ], - }) + oracleProperty({ + id: `pagination.ordered-window-fixed`, + law: { + statement: `matches full recomputation across ordered windows for named regressions`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named histories that the generated window grammar cannot take back; each replays runPaginationScenario and its comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: orderedWindowFixedHistories, + label: ({ name }) => name, + ...orderedComparison( + ({ scenario }: (typeof orderedWindowFixedHistories)[number]) => + runPaginationScenario(scenario), + ), + check: (_history, _observations, verdict) => verdict, }) - it.each([ + // Named regressions and boundary cells of the window-transition law. Each + // replays runPaginationStateScenario and its recomputation comparison. The + // generated grammar maps its payload without an unmapper, so it cannot take + // these histories back; they run as one enumerated source. + const windowTransitionFixedHistories: ReadonlyArray<{ + name: string + scenario: PaginationStateScenario + }> = [ { - name: `enters the filter`, - keeps: [false, true], - action: { type: `put` as const, id: 1, rank: 0, keep: true }, - expected: [1, 2], + name: `keeps a NaN window coherent through equal and changed source ranks`, + scenario: { + ranks: [10, 20, NaN, 30, 40], + direction: `desc`, + explicitPublicKeyOrder: false, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 3, rank: NaN }, + { type: `put`, id: 3, rank: 35 }, + { type: `put`, id: 3, rank: NaN }, + { type: `delete`, id: 3 }, + ], + }, }, { - name: `leaves the filter`, - keeps: [true, true], - action: { type: `put` as const, id: 1, rank: 0, keep: false }, - expected: [2], + name: `keeps implicit ties stable across filtered source mutations`, + scenario: { + ranks: [0, 0, 0, 1, 1, 1], + direction: `asc`, + explicitPublicKeyOrder: false, + includeFilter: true, + reverseInsertion: true, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 7, rank: 0 }, + { type: `delete`, id: 2 }, + { type: `window`, offset: 1, limit: 3 }, + ], + }, }, - ])(`updates a row that $name`, async ({ keeps, action, expected }) => { - oracleRoute(`case`) - const scenario: PaginationStateScenario = { - ranks: [0, 1], - keeps, - direction: `asc`, - includeFilter: true, - explicitPublicKeyOrder: true, - reverseInsertion: false, - initialWindow: { offset: 0, limit: 2 }, - actions: [action], - } - await runPaginationStateScenario(scenario) - - const finalRows = scenario.ranks.map((rank, index) => ({ - id: index + 1, - rank, - keep: index === 0 ? action.keep : keeps[index], - })) - expect( - referenceWindow( - visibleRows(finalRows, true), - scenario.direction, - scenario.initialWindow, - ), - ).toEqual(expected) - }) - - it(`discovered trace: loads an on-demand window after a zero limit`, async () => { - oracleRoute(`fixed history`) - await runOnDemandPaginationScenario({ - ranks: [0, 0], - direction: `asc`, - windows: [ - { offset: 1, limit: 0 }, - { offset: 0, limit: 2 }, - ], - }) - }) - - 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`, - windows: [ - { offset: 1, limit: 0 }, - { offset: 4, limit: 1 }, - { offset: 4, limit: 2 }, - { offset: 0, limit: 0 }, - ], - }) - }) - - 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], + { + name: `discovered trace: a row moving across an offset window must refill its boundary`, + scenario: { + ranks: [0, 0, 0], + direction: `desc`, + initialWindow: { offset: 1, limit: 1 }, + actions: [{ type: `put`, id: 1, rank: -1 }], + }, + }, + { + name: `retains authoritative rows when a later window admits a prior insert`, + scenario: { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { type: `window`, offset: 0, limit: 3 }, + ], + }, + }, + { + name: `restores an out-of-window insert when a later offset selects it`, + scenario: { + ranks: [0, 0], direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { type: `window`, offset: 2, limit: 1 }, + ], + }, + }, + { + name: `restores an out-of-window rank update when a later offset selects it`, + scenario: { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { type: `window`, offset: 2, limit: 1 }, + ], + }, + }, + { + name: `discovered trace: inserting at an empty offset boundary refills the window`, + scenario: { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 1 }, + { type: `window`, offset: 3, limit: 1 }, + { type: `put`, id: 4, rank: 1 }, + ], + }, + }, + { + name: `discovered trace: an insert before a later offset does not skip its new boundary`, + scenario: { + ranks: [-1, -1, 0, -1, 0, -1, 0, 1, 1], + direction: `asc`, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 10, rank: 0 }, + { type: `window`, offset: 8, limit: 1 }, + ], + }, + }, + { + name: `discovered trace: a rank update must refill a top-1 window`, + scenario: { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [{ type: `put`, id: 1, rank: 1 }], + }, + }, + { + name: `refills an implicit tie window when a visible row moves below it`, + scenario: { + ranks: [0, 0, 0, -1, 0], + direction: `desc`, explicitPublicKeyOrder: false, - windows: [ - { offset: 0, limit: 0 }, - { offset: 0, limit: 1 }, + includeFilter: false, + reverseInsertion: false, + initialWindow: { offset: 0, limit: 4 }, + actions: [{ type: `put`, id: 1, rank: -2, keep: false }], + }, + }, + { + name: `opens an implicit tie window from zero at the lowest public key`, + scenario: { + ranks: [0], + direction: `asc`, + explicitPublicKeyOrder: false, + includeFilter: false, + reverseInsertion: false, + initialWindow: { offset: 0, limit: 0 }, + actions: [ + { type: `put`, id: 2, rank: 0, keep: false }, + { type: `window`, offset: 0, limit: 1 }, + { type: `delete`, id: 1 }, ], - localRowsBeforeFirstRequest: [{ id: 2, rank: 1 }], }, - (loads) => { - expect(loads[0]?.cursor).toBeUndefined() - expect(loads[0]?.offset).toBe(0) - expect(loads[0]?.limit).toBe(1) + }, + { + name: `ignores an out-of-window insert when refilling after a delete`, + scenario: { + ranks: [100, 90, 80, 70], + direction: `desc`, + initialWindow: { offset: 0, limit: 3 }, + actions: [ + { type: `put`, id: 5, rank: 10 }, + { type: `delete`, id: 2 }, + ], }, - ) - }) - - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - ([`sync`, `async`] as const).map((replayDelivery) => ({ - direction, - replayDelivery, - })), - ), - )( - `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 }, - ] - const failure = new Error(`window acquisition failed`) - const replayGate = createDeferred() - let operations!: Parameters[`sync`]>[0] - let loads = 0 - const source = createCollection({ - id: `pagination-failed-window-replay-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (sync) => { - operations = sync - sync.markReady() - return { - loadSubset: (options) => { - loads++ - sync.begin() - for (const row of loads === 1 ? rows.slice(0, 1) : rows) { - sync.write({ type: `insert`, value: { ...row } }) - } - const receipt = sync.commit(options.signal) - if (loads === 1) return Promise.reject(failure) - if (replayDelivery === `sync`) return receipt - return replayGate.promise.then(async () => { - if (receipt !== true) await receipt - }) - }, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - .limit(0) - .select(({ row }) => ({ id: row.id, rank: row.rank })) - .distinct(), - ) - const publications: Array> = [] - const subscriber = live.subscribeChanges(() => { - publications.push(Array.from(live.values(), ({ id }) => id)) - }) - const assertHeld = () => { - expect(Array.from(live.values())).toEqual([]) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 0 }) - expect(publications).toEqual([]) - } - try { - await live.preload() - await expect( - live.utils.setWindow({ offset: 0, limit: 2 }), - ).rejects.toBe(failure) - assertHeld() - operations.begin() - operations.truncate() - const receipt = operations.commit() - if (receipt !== true) await receipt - await flushPromises() - assertHeld() - replayGate.resolve() - await flushPromises() - await flushPromises() - expect(loads).toBe(2) - assertHeld() - - await live.utils.setWindow({ offset: 0, limit: 2 }) - const expected = referenceWindow(rows, direction, { - offset: 0, - limit: 2, - }) - expect(Array.from(live.values(), ({ id }) => id)).toEqual(expected) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) - expect(publications).toEqual([expected]) - } finally { - replayGate.resolve() - subscriber.unsubscribe() - await cleanupAll(live, source) - } }, - ) - - it.each([ - { offset: 0, limit: 1, failureKind: `error` as const }, - { offset: 2, limit: 1, failureKind: `error` as const }, - { offset: 0, limit: 1, failureKind: `abort` as const }, - { offset: 2, limit: 1, failureKind: `abort` as const }, - ])( - `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 }, - { id: 3, rank: 2 }, - { id: 4, rank: 3 }, - ] - const requests: Array = [] - const firstRequest = createDeferred() - const deliveredIds = new Set([4]) - let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-rejected-first-prefix-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - if (requests.length === 1) return firstRequest.promise - - begin() - for (const row of rowsForLoadSubset( - authoritativeRows, - options, - )) { - if (deliveredIds.has(row.id)) continue - deliveredIds.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - return true - }, - } - }, - }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .limit(0), - ) - - try { - await live.preload() - begin() - write({ type: `insert`, value: { ...authoritativeRows[3]! } }) - commit() - - const requestedPrefix = window.offset + window.limit - const failed = live.utils.setWindow(window) - expect(failed).toBeInstanceOf(Promise) - expect(requests[0]).toMatchObject({ - offset: 0, - limit: requestedPrefix, - }) - expect(requests[0]?.cursor).toBeUndefined() - const failure = - failureKind === `abort` - ? new DOMException(`first ordered request canceled`, `AbortError`) - : new Error(`first ordered request failed`) - firstRequest.reject(failure) - await expect(failed).rejects.toBe(failure) - - const retry = live.utils.setWindow(window) - if (retry instanceof Promise) await retry - expect(requests[1]?.limit).toBeUndefined() - expect(requests[1]?.offset).toBeUndefined() - expect(requests[1]?.cursor).toBeUndefined() - expect(requests).toHaveLength(2) - expect(Array.from(live.values(), ({ id }) => id)).toEqual( - referenceWindow(authoritativeRows, `asc`, window), - ) - } finally { - firstRequest.resolve() - await cleanupAll(live, source) - } + { + name: `ignores an out-of-window rank update when refilling after a delete`, + scenario: { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { type: `delete`, id: 1 }, + ], + }, }, - ) - - 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 }, - { id: 3, rank: 2 }, - { id: 4, rank: 99 }, - ] - const requests: Array = [] - const unloaded: Array = [] - const deliveredIds = new Set() - const rejectedPage = createDeferred() - let rejectNextPage = false - let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void - let commit!: () => void - let truncate!: () => void - const source = createCollection({ - id: `pagination-rejected-partial-page-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - truncate = operations.truncate - operations.markReady() + { + name: `ignores an out-of-window rank update when the visible row leaves`, + scenario: { + ranks: [0, 0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: 1 }, + { type: `put`, id: 1, rank: 2 }, + ], + }, + }, + { + name: `refills untouched rows when widening after an out-of-window rank update`, + scenario: { + ranks: [0, 0, 0], + direction: `desc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 2, rank: -1 }, + { type: `window`, offset: 0, limit: 3 }, + ], + }, + }, + { + name: `rebuilds the full boundary when widening after an out-of-window rank update`, + scenario: { + ranks: [1, 0, 1, 0, 1], + direction: `desc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 0 }, + { type: `window`, offset: 0, limit: 4 }, + ], + }, + }, + { + name: `ignores an out-of-window insert when widening a tied window`, + scenario: { + ranks: [0, 0], + direction: `asc`, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { type: `put`, id: 3, rank: 0 }, + { type: `window`, offset: 0, limit: 2 }, + ], + }, + }, + // Rows entering and leaving the filter. Their model expectations are + // checked in pagination-model.test.ts. + ...filterUpdateCases.map((row) => ({ + name: `updates a row that ${row.name}`, + scenario: filterUpdateScenario(row), + })), + ...([`asc`, `desc`] as const).flatMap((direction) => + [false, true].flatMap((explicitPublicKeyOrder) => + [false, true].flatMap((tied) => + [1, 2].map((limit) => { + const sign = direction === `asc` ? 1 : -1 return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - if (rejectNextPage) { - rejectNextPage = false - begin() - deliveredIds.add(4) - write({ type: `insert`, value: { ...authoritativeRows[3]! } }) - commit() - return rejectedPage.promise - } - - begin() - for (const row of rowsForLoadSubset( - authoritativeRows, - options, - )) { - if (deliveredIds.has(row.id)) continue - deliveredIds.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - return true + name: `keeps eager window membership when moving past an intervening insert: ${JSON.stringify({ direction, explicitPublicKeyOrder, tied, limit })}`, + scenario: { + direction, + initialWindow: { offset: 0, limit: 1 }, + actions: [ + { + type: `put` as const, + id: 3, + rank: sign * (tied ? 1 : 2), + keep: false, + }, + { type: `window` as const, offset: 1, limit }, + { type: `put` as const, id: 1, rank: 0, keep: false }, + ], + ranks: [0, sign], + keeps: [false, false], + explicitPublicKeyOrder, + includeFilter: false, + reverseInsertion: false, }, - unloadSubset: (options) => unloaded.push(options), } - }, - }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .limit(1), - ) - - try { - await live.preload() - const initialRequestCount = requests.length - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - - rejectNextPage = true - const failed = live.utils.setWindow({ offset: 0, limit: 4 }) - expect(failed).toBeInstanceOf(Promise) - const failure = - failureKind === `AbortError` - ? new DOMException(`partial ordered request canceled`, `AbortError`) - : new Error(`partial ordered request failed`) - rejectedPage.reject(failure) - await expect(failed).rejects.toBe(failure) - await flushPromises() - expect(requests).toHaveLength(initialRequestCount + 1) - - // Replay can replace the failed request's physical options object - // before the explicit retry retires its logical demand. - const beforeFailedReplay = requests.length - deliveredIds.clear() - begin() - truncate() - commit() - await flushPromises() - const failedReplayRequests = requests.slice(beforeFailedReplay) - // The settled first row permits a three-row continuation. Replay - // must preserve that exact demand even after its first attempt fails. - const replayedFailedRequest = failedReplayRequests.find( - ({ limit, cursor }) => limit === 3 && cursor !== undefined, - ) - expect(replayedFailedRequest).toBeDefined() - expect(replayedFailedRequest).toMatchObject({ offset: 1, limit: 3 }) - expect(replayedFailedRequest?.cursor).toEqual( - requests[initialRequestCount]?.cursor, - ) - - const releasesBeforeRetry = unloaded.length - const requestsBeforeRetry = requests.length - const retry = live.utils.setWindow({ offset: 0, limit: 2 }) - if (retry instanceof Promise) await retry - expect(unloaded.slice(releasesBeforeRetry)).toEqual([ - replayedFailedRequest, - ]) - expect(requests).toHaveLength(requestsBeforeRetry) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) - - const beforeWiden = requests.length - const widen = live.utils.setWindow({ offset: 0, limit: 3 }) - if (widen instanceof Promise) await widen - expect(requests).toHaveLength(beforeWiden) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 3]) - - const beforeReplay = requests.length - deliveredIds.clear() - begin() - truncate() - commit() - await flushPromises() - expect( - requests.slice(beforeReplay).every(({ cursor }) => !cursor), - ).toBe(true) - } finally { - rejectedPage.resolve() - await cleanupAll(live, source) - } - }, - ) + }), + ), + ), + ), + ...( + [ + [`top-one`, [0, 0], { offset: 0, limit: 1 }, 1, 1], + [`offset`, [0, 0, 1], { offset: 1, limit: 1 }, 2, 2], + ] as const + ).map(([name, ranks, initialWindow, id, rank]) => ({ + name: `refills an implicit ${name} tie window after a rank update`, + scenario: { + ranks: [...ranks], + direction: `asc` as const, + explicitPublicKeyOrder: false, + includeFilter: false, + reverseInsertion: false, + initialWindow, + actions: [{ type: `put` as const, id, rank, keep: false }], + }, + })), + ] - it.each([ - [`visible delete`, { type: `delete`, id: 1 }], - [ - `visible rank update`, - { type: `update`, row: { id: 2, rank: 4, keep: true } }, + oracleProperty({ + id: `pagination.window-transition-fixed`, + law: { + statement: `matches full recomputation across source and window transitions for named regressions`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named histories that the generated transition grammar cannot take back; each replays runPaginationStateScenario and its comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', ], - ] 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], - direction: `asc`, - limit: 3, - mutation, - responseOutcome: `resolve`, - includeFilter: true, - }, - `after-response`, - ) + histories: windowTransitionFixedHistories, + label: ({ name }) => name, + ...orderedComparison( + ({ scenario }: (typeof windowTransitionFixedHistories)[number]) => + runPaginationStateScenario(scenario), + ), + check: (_history, _observations, verdict) => verdict, + }) - expectFilteredBoundedRepairTrace(result, `asc`, 3) + // Named regressions of the on-demand cursor law. Each replays + // runOnDemandPaginationScenario and its recomputation comparison. The + // generated grammar maps its payload without an unmapper, so it cannot take + // these histories back; they run as one enumerated source. + const asyncCursorFixedHistories: ReadonlyArray<{ + name: string + scenario: PaginationScenario + }> = [ + { + name: `discovered trace: loads an on-demand window after a zero limit`, + scenario: { + ranks: [0, 0], + direction: `asc`, + windows: [ + { offset: 1, limit: 0 }, + { offset: 0, limit: 2 }, + ], + }, }, - ) - - it(`finishes bounded repair when a visible delete leaves the source underfilled`, async () => { - oracleRoute(`fixed history`) - const result = await runPendingMutationScenario( - { - ranks: [0, 1, 2], + { + name: `widens an offset on-demand window after starting at zero limit`, + scenario: { + ranks: [-1, 0, 0, 0, -1, 0], direction: `asc`, - limit: 10, - mutation: { type: `delete`, id: 1 }, - responseOutcome: `resolve`, + windows: [ + { offset: 1, limit: 0 }, + { offset: 4, limit: 1 }, + { offset: 4, limit: 2 }, + { offset: 0, limit: 0 }, + ], }, - `after-response`, - undefined, - false, - ) - - expectPendingMutationRequestWork( - result, - { - ranks: [0, 1, 2], + }, + { + name: `discovered trace: an async cursor loads the full offset window`, + scenario: { + ranks: [0, 0, 0, 0, 0, 0, 1], direction: `asc`, - limit: 10, - mutation: { type: `delete`, id: 1 }, - responseOutcome: `resolve`, + windows: [ + { offset: 0, limit: 1 }, + { offset: 2, limit: 5 }, + ], }, - false, - ) - expect(result.releasedBeforeCleanup).toHaveLength(3) - }) - - it(`rejects a malformed bounded-repair request trace`, () => { - oracleRoute(`calibration`) - const malformed: PendingMutationResult = { - mutationRepairRequests: [ - { - orderBy: [ - { - expression: new PropRef([`wrongPrimary`]), - compareOptions: { direction: `desc`, nulls: `last` }, - }, - { - expression: new PropRef([`wrongSecondary`]), - compareOptions: { direction: `desc`, nulls: `last` }, + }, + { + name: `discovered trace: an async cursor crosses an offset before filling one row`, + scenario: { + ranks: [0, 0, -1], + direction: `asc`, + windows: [ + { offset: 0, limit: 1 }, + { offset: 2, limit: 1 }, + ], + }, + }, + ...[`asc`, `desc`].flatMap((direction) => + [false, true].flatMap((explicitPublicKeyOrder) => + [false, true].map((includeFilter) => { + const structure = { + direction: direction as `asc` | `desc`, + explicitPublicKeyOrder, + includeFilter, + } + return { + name: `bounds requests for an underfilled source: ${structure.direction}, explicit key=${structure.explicitPublicKeyOrder}, filter=${structure.includeFilter}`, + scenario: { + ...structure, + ranks: [0, 0], + keeps: [true, false], + windows: [{ offset: 0, limit: 3 }], }, - ], - limit: 3, - offset: 99, - refetch: true, - }, - { refetch: true }, - ], - releasedBeforeCleanup: [], - } + } + }), + ), + ), + ] - expect(() => - expectFilteredBoundedRepairTrace(malformed, `asc`, 3), - ).toThrow() + oracleProperty({ + id: `pagination.async-cursor-fixed`, + law: { + statement: `matches full recomputation when exact async cursor loads widen ordered coverage for named regressions`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named histories that the generated window grammar cannot take back; each replays runOnDemandPaginationScenario and its comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: asyncCursorFixedHistories, + label: ({ name }) => name, + ...orderedComparison( + ({ scenario }: (typeof asyncCursorFixedHistories)[number]) => + runOnDemandPaginationScenario(scenario), + ), + check: (_history, _observations, verdict) => verdict, }) - 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], - direction: `asc`, - limit: 2, - mutation: { type: `delete`, id: 1 }, - responseOutcome: `reject`, - includeFilter: true, - } - - expect(classifyWhereOnlyRepairRequest({ where: filter }, scenario)).toBe( - `filtered-full-source`, + async function runZeroWindowLocalPrefix(): Promise { + await runOnDemandPaginationScenario( + { + ranks: [0, 1], + direction: `asc`, + explicitPublicKeyOrder: false, + windows: [ + { offset: 0, limit: 0 }, + { offset: 0, limit: 1 }, + ], + localRowsBeforeFirstRequest: [{ id: 2, rank: 1 }], + }, + (loads) => { + expect(loads[0]?.cursor).toBeUndefined() + expect(loads[0]?.offset).toBe(0) + expect(loads[0]?.limit).toBe(1) + }, ) - expect( - classifyWhereOnlyRepairRequest( - { - where: and(filter, eq(new PropRef([`rank`]), 1)), - }, - scenario, - ), - ).toBe(`tie`) - }) - - 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], - direction: `asc`, - limit: 3, - mutation: { type: `delete`, id: 1 }, - responseOutcome: `resolve`, - orderedResponseLimit: 1, - }, - `after-response`, - undefined, - explicitPublicKeyOrder, - ) - const requests = result.mutationRepairRequests + } - expect( - requests.map((request) => - request.orderBy ? (request.cursor ? `refill` : `prefix`) : `tie`, - ), - ).toEqual([`prefix`, `tie`, `refill`, `tie`]) - const [prefix, firstTie, refill, secondTie] = requests - expect(orderSignature(prefix!)).toStrictEqual( - expectedOrderSignature(`asc`, explicitPublicKeyOrder), - ) - expect(prefix!.where).toBeUndefined() - expect(prefix!.limit).toBe(3) - expect(prefix!.refetch).toBe(true) - expect(prefix!.cursor).toBeUndefined() - expect(prefix!.offset).toBeUndefined() - expectPredicateBehavior( - firstTie!.where, - [{ id: 2, rank: 1 }], - [ - { id: 1, rank: 0 }, - { id: 4, rank: 2 }, - ], - ) - expect(firstTie!.orderBy).toBeUndefined() - expect(firstTie!.limit).toBeUndefined() - expect(firstTie!.refetch).toBe(true) - expect(firstTie!.cursor).toBeUndefined() - expect(firstTie!.offset).toBeUndefined() - expect(orderSignature(refill!)).toStrictEqual( - expectedOrderSignature(`asc`, explicitPublicKeyOrder), - ) - expect(refill?.limit).toBe(1) - expect(refill?.refetch).toBe(true) - expect(refill?.cursor).toBeDefined() - expectPredicateBehavior( - secondTie!.where, - [{ id: 4, rank: 2 }], - [{ id: 2, rank: 1 }], - ) - expect(secondTie!.orderBy).toBeUndefined() - expect(secondTie!.limit).toBeUndefined() - expect(secondTie!.refetch).toBe(true) - expect(secondTie!.cursor).toBeUndefined() - expect(secondTie!.offset).toBeUndefined() - }, - ) + oracleProperty({ + id: `pagination.zero-window-local-prefix`, + law: namedHistoriesLaw( + `starts an on-demand source prefix after opening a zero window with a local row`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `starts an on-demand source prefix after opening a zero window with a local row`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runZeroWindowLocalPrefix), + check: (_history, _observations, verdict) => verdict, + }) - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - ([`throw`, `reject`] as const).map((delivery) => ({ + const failedWindowReplayCases = ([`asc`, `desc`] as const).flatMap( + (direction) => + ([`sync`, `async`] as const).map((replayDelivery) => ({ direction, - delivery, + replayDelivery, })), - ), - )( - `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 }, - { id: 2, rank: 2 * sign }, - ] - const liveInsert: PageRow = { id: 0, rank: 0 } - const delivered = new Set() - const failure = new Error(`later boundary failed`) - let widening = false - let failedBoundary: LoadSubsetOptions | undefined - let suppliedPage: Array | undefined - const source = createCollection({ - id: `pagination-boundary-publication-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: (options) => { - const selected = rowsForLoadSubset(rows, options) - if ( - widening && - options.where && - !options.orderBy && - selected.some(({ id }) => id === 2) && - !failedBoundary - ) { - failedBoundary = options - if (delivery === `throw`) throw failure - return Promise.reject(failure) - } - const newRows = selected.filter(({ id }) => !delivered.has(id)) - begin() - for (const row of newRows) { - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - if (widening && options.orderBy && !suppliedPage) { - suppliedPage = newRows - rows.push(liveInsert) - delivered.add(liveInsert.id) - write({ type: `insert`, value: { ...liveInsert } }) - } - const receipt = commit(options.signal) - // Make the page asynchronous so the failure is in its later - // refinement, not the synchronous setWindow call stack. - return Promise.resolve(receipt).then(() => undefined) - }, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - .limit(1), - ) - const publications: Array> = [] - const subscription = live.subscribeChanges(() => { - publications.push(Array.from(live.values(), ({ id }) => id)) - }) - try { - await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - publications.length = 0 - widening = true - await expect( - live.utils.setWindow({ offset: 0, limit: 2 }), - ).rejects.toBe(failure) - expect(suppliedPage?.map(({ id }) => id)).toEqual([2]) - expect(failedBoundary).toBeDefined() - expect( - rowsForLoadSubset(rows, failedBoundary!).map(({ id }) => id), - ).toEqual([2]) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) - expect(publications).toEqual([]) - await live.utils.setWindow({ offset: 0, limit: 2 }) - const expected = referenceWindow(rows, direction, { - offset: 0, - limit: 2, - }) - expect(Array.from(live.values(), ({ id }) => id)).toEqual(expected) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) - expect(publications).toEqual([expected]) - } finally { - subscription.unsubscribe() - await cleanupAll(live, source) - } - }, ) - 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 - // key tie-breaker must choose id 2 after boundary refinement. - { id: 4, rank: 0 }, - { id: 3, rank: 0 }, - { id: 2, rank: 0 }, - { id: 6, rank: 1 }, - { id: 5, rank: 99 }, + async function runFailedWindowReplay({ + direction, + replayDelivery, + }: (typeof failedWindowReplayCases)[number]): Promise { + const rows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, ] - const deliveredIds = new Set() - const requests: Array = [] - const failedPage = createDeferred() - let rejectNextPage = false - let begin!: () => void - let write!: (message: { type: `insert` | `delete`; value: PageRow }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-recovered-prefix-tie-${collectionSequence++}`, + const failure = new Error(`window acquisition failed`) + const replayGate = createDeferred() + let operations!: Parameters[`sync`]>[0] + let loads = 0 + const source = createCollection({ + id: `pagination-failed-window-replay-${collectionSequence++}`, getKey: (row) => row.id, syncMode: `on-demand`, - startSync: true, autoIndex: `eager`, defaultIndexType: BTreeIndex, sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() + sync: (sync) => { + operations = sync + sync.markReady() return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - if (rejectNextPage) { - rejectNextPage = false - begin() - deliveredIds.add(5) - write({ type: `insert`, value: { id: 5, rank: 99 } }) - commit() - return failedPage.promise - } - - begin() - for (const row of rowsForLoadSubset(authoritativeRows, options)) { - if (deliveredIds.has(row.id)) continue - deliveredIds.add(row.id) - write({ type: `insert`, value: { ...row } }) + loadSubset: (options) => { + loads++ + sync.begin() + for (const row of loads === 1 ? rows.slice(0, 1) : rows) { + sync.write({ type: `insert`, value: { ...row } }) } - commit() - return true + const receipt = sync.commit(options.signal) + if (loads === 1) return Promise.reject(failure) + if (replayDelivery === `sync`) return receipt + return replayGate.promise.then(async () => { + if (receipt !== true) await receipt + }) }, } }, }, }) - const live = createLiveQueryCollection((query) => - query + const live = createLiveQueryCollection((q) => + q .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .limit(1), + .orderBy(({ row }) => row.rank, direction) + .limit(0) + .select(({ row }) => ({ id: row.id, rank: row.rank })) + .distinct(), ) - + const publications: Array> = [] + const subscriber = live.subscribeChanges(() => { + publications.push(Array.from(live.values(), ({ id }) => id)) + }) + const assertHeld = () => { + expect(Array.from(live.values())).toEqual([]) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 0 }) + expect(publications).toEqual([]) + } try { await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - - rejectNextPage = true - const failed = live.utils.setWindow({ offset: 0, limit: 3 }) - expect(failed).toBeInstanceOf(Promise) - failedPage.reject(new Error(`later page failed`)) - await expect(failed).rejects.toThrow(`later page failed`) - - const retry = live.utils.setWindow({ offset: 0, limit: 2 }) - if (retry instanceof Promise) await retry - const recoveryRequest = requests.at(-1) - expect(recoveryRequest?.limit).toBeUndefined() - expect(recoveryRequest?.offset).toBeUndefined() - expect(recoveryRequest?.cursor).toBeUndefined() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) - - const deleted = authoritativeRows.filter(({ id }) => - [1, 2, 3].includes(id), + await expect(live.utils.setWindow({ offset: 0, limit: 2 })).rejects.toBe( + failure, ) - for (const row of deleted) { - authoritativeRows.splice(authoritativeRows.indexOf(row), 1) - deliveredIds.delete(row.id) - } - begin() - for (const row of deleted) write({ type: `delete`, value: { ...row } }) - commit() + assertHeld() + operations.begin() + operations.truncate() + const receipt = operations.commit() + if (receipt !== true) await receipt + await flushPromises() + assertHeld() + replayGate.resolve() + await flushPromises() await flushPromises() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([4, 6]) + expect(loads).toBe(2) + assertHeld() + + await live.utils.setWindow({ offset: 0, limit: 2 }) + const expected = referenceWindow(rows, direction, { + offset: 0, + limit: 2, + }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual(expected) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) + expect(publications).toEqual([expected]) } finally { - failedPage.resolve() + replayGate.resolve() + subscriber.unsubscribe() await cleanupAll(live, source) } + } + + oracleProperty({ + id: `pagination.failed-window-replay`, + law: namedHistoriesLaw( + `keeps a failed window private after source replay until explicit retry`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: failedWindowReplayCases, + label: (row) => + `keeps a failed ${String(row.direction)} window private after ${String(row.replayDelivery)} source replay until explicit retry`, + ...orderedComparison(runFailedWindowReplay), + check: (_history, _observations, verdict) => verdict, }) - it(`rejects a reentrant window move when an ordered request writes and then throws`, async () => { - oracleRoute(`fixed history`) + const rejectedFirstPrefixCases = [ + { offset: 0, limit: 1, failureKind: `error` as const }, + { offset: 2, limit: 1, failureKind: `error` as const }, + { offset: 0, limit: 1, failureKind: `abort` as const }, + { offset: 2, limit: 1, failureKind: `abort` as const }, + ] + + async function runRejectedFirstPrefix({ + failureKind, + ...window + }: (typeof rejectedFirstPrefixCases)[number]): Promise { const authoritativeRows: Array = [ { id: 1, rank: 0 }, { id: 2, rank: 1 }, { id: 3, rank: 2 }, + { id: 4, rank: 3 }, ] const requests: Array = [] - const deliveredIds = new Set() - const failure = new Error(`ordered request threw after writing`) - let reentrantError: unknown - let throwNextPage = false + const firstRequest = createDeferred() + const deliveredIds = new Set([4]) let begin!: () => void let write!: (message: { type: `insert`; value: PageRow }) => void let commit!: () => void const source = createCollection({ - id: `pagination-synchronous-partial-page-${collectionSequence++}`, + id: `pagination-rejected-first-prefix-${collectionSequence++}`, getKey: (row) => row.id, syncMode: `on-demand`, startSync: true, @@ -4193,19 +3372,7 @@ describe(`pagination recomputation oracle`, () => { return { loadSubset: (options: LoadSubsetOptions) => { requests.push(options) - if (throwNextPage) { - throwNextPage = false - begin() - deliveredIds.add(3) - write({ type: `insert`, value: { ...authoritativeRows[2]! } }) - commit() - try { - live.utils.setWindow({ offset: 0, limit: 3 }) - } catch (error) { - reentrantError = error - } - throw failure - } + if (requests.length === 1) return firstRequest.promise begin() for (const row of rowsForLoadSubset(authoritativeRows, options)) { @@ -4224,462 +3391,365 @@ describe(`pagination recomputation oracle`, () => { query .from({ row: source }) .orderBy(({ row }) => row.rank, `asc`) - .limit(1), + .limit(0), ) try { await live.preload() - const initialRequestCount = requests.length - throwNextPage = true - - // A synchronous throw is a rejection (maintainer decision): the move - // fails with it. - await expect( - Promise.resolve(live.utils.setWindow({ offset: 0, limit: 2 })), - ).rejects.toBe(failure) - expect(reentrantError).toMatchObject({ - name: `SetWindowReentrancyError`, - }) - expect(requests).toHaveLength(initialRequestCount + 1) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + begin() + write({ type: `insert`, value: { ...authoritativeRows[3]! } }) + commit() - const retry = live.utils.setWindow({ offset: 0, limit: 2 }) + const requestedPrefix = window.offset + window.limit + const failed = live.utils.setWindow(window) + expect(failed).toBeInstanceOf(Promise) + expect(requests[0]).toMatchObject({ + offset: 0, + limit: requestedPrefix, + }) + expect(requests[0]?.cursor).toBeUndefined() + const failure = + failureKind === `abort` + ? new DOMException(`first ordered request canceled`, `AbortError`) + : new Error(`first ordered request failed`) + firstRequest.reject(failure) + await expect(failed).rejects.toBe(failure) + + const retry = live.utils.setWindow(window) if (retry instanceof Promise) await retry - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + expect(requests[1]?.limit).toBeUndefined() + expect(requests[1]?.offset).toBeUndefined() + expect(requests[1]?.cursor).toBeUndefined() + expect(requests).toHaveLength(2) + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + referenceWindow(authoritativeRows, `asc`, window), + ) } finally { + firstRequest.resolve() await cleanupAll(live, source) } - }) + } - it.each( - ([`sync`, `async`] as const).flatMap((delivery) => - [1, 2].map((requestNumber) => ({ delivery, requestNumber })), + oracleProperty({ + id: `pagination.rejected-first-prefix`, + law: namedHistoriesLaw( + `recovers the first rejected ordered request for a window from the full source`, ), - )( - `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 }, - ] - const delivered = new Set() - let nestedResult: true | Promise | undefined - let nestedError: unknown - let requests = 0 - let firstRequestSettled = false - function createWindowedQuery() { - return createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - ) - } - const source = createCollection({ - id: `pagination-initial-request-reentrancy-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: (options) => { - requests++ - if (requests === requestNumber) { - if (delivery === `async` && requestNumber === 2) { - expect(firstRequestSettled).toBe(true) - } - try { - nestedResult = live.utils.setWindow({ offset: 0, limit: 2 }) - } catch (error) { - nestedError = error - } - } - const fresh = rowsForLoadSubset( - authoritativeRows, - options, - ).filter(({ id }) => !delivered.has(id)) - if (fresh.length === 0) return true - begin() - for (const row of fresh) { - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - return delivery === `async` - ? Promise.resolve().then(() => { - firstRequestSettled = true - }) - : true - }, - } - }, - }, - }) - const live = createWindowedQuery() + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: rejectedFirstPrefixCases, + label: (row) => + `recovers the first ${String(row.failureKind)}-rejected ordered request for window ${String(row.offset)}:${String(row.limit)} from the full source`, + ...orderedComparison(runRejectedFirstPrefix), + check: (_history, _observations, verdict) => verdict, + }) - try { - await live.preload() - expect(requests).toBeGreaterThanOrEqual(requestNumber) - expect(nestedResult).toBeUndefined() - expect(nestedError).toMatchObject({ - name: `SetWindowReentrancyError`, - }) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - await live.utils.setWindow({ offset: 0, limit: 2 }) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) - } finally { - await cleanupAll(live, source) - } - }, - ) + const filteredBoundedRepairCases = [ + [`visible delete`, { type: `delete`, id: 1 }], + [ + `visible rank update`, + { type: `update`, row: { id: 2, rank: 4, keep: true } }, + ], + ] satisfies ReadonlyArray - 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 }, - ] - const delivered = new Set() - let begin!: () => void - let write!: (message: { - type: `update` - value: PageRow - previousValue: PageRow - }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-publication-reentrancy-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - return { - loadSubset: (options) => { - const fresh = rowsForLoadSubset( - authoritativeRows, - options, - ).filter(({ id }) => !delivered.has(id)) - if (fresh.length === 0) return true - begin() - for (const row of fresh) { - delivered.add(row.id) - operations.write({ type: `insert`, value: { ...row } }) - } - commit() - return true - }, - } - }, + async function runFilteredBoundedRepair([ + _name, + mutation, + ]: (typeof filteredBoundedRepairCases)[number]): Promise { + const result = await runPendingMutationScenario( + { + ranks: [0, 1, 2, 3], + direction: `asc`, + limit: 3, + mutation, + responseOutcome: `resolve`, + includeFilter: true, }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), + `after-response`, ) - let nestedResult: true | Promise | undefined - let nestedError: unknown - try { - await live.preload() - const subscription = live.subscribeChanges(() => { - try { - nestedResult = live.utils.setWindow({ offset: 0, limit: 2 }) - } catch (error) { - nestedError = error - } - }) - const previous = authoritativeRows[0]! - const current = { ...previous, keep: false } - authoritativeRows[0] = current - begin() - write({ type: `update`, value: current, previousValue: previous }) - commit() - subscription.unsubscribe() + expectFilteredBoundedRepairTrace(result, `asc`, 3) + } - expect(nestedResult).toBeUndefined() - expect(nestedError).toMatchObject({ name: `SetWindowReentrancyError` }) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) - } finally { - await cleanupAll(live, source) - } + oracleProperty({ + id: `pagination.filtered-bounded-repair`, + law: namedHistoriesLaw( + `repairs an expressible order with a bounded prefix request`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: filteredBoundedRepairCases, + label: (row) => + `${String(row[0])} repairs an expressible order with a bounded prefix request`, + ...orderedComparison(runFilteredBoundedRepair), + check: (_history, _observations, verdict) => verdict, }) - 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 }, - ] - const delivered = new Set() - let cleanUpDuringNextRequest = false - let cleanupPromise: Promise | undefined - function createWindowedQuery() { - return createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(1), - ) - } - const source = createCollection({ - id: `pagination-window-cleanup-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: (options) => { - if (cleanUpDuringNextRequest) { - cleanUpDuringNextRequest = false - cleanupPromise = live.cleanup() - if (delivery === `return-only`) return true - } - const fresh = rowsForLoadSubset( - authoritativeRows, - options, - ).filter(({ id }) => !delivered.has(id)) - if (fresh.length === 0) return true - begin() - for (const row of fresh) { - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - return true - }, - } - }, - }, - }) - const live = createWindowedQuery() + async function runUnderfilledBoundedRepair(): Promise { + const result = await runPendingMutationScenario( + { + ranks: [0, 1, 2], + direction: `asc`, + limit: 10, + mutation: { type: `delete`, id: 1 }, + responseOutcome: `resolve`, + }, + `after-response`, + undefined, + false, + ) - try { - await live.preload() - cleanUpDuringNextRequest = true - const move = live.utils.setWindow({ offset: 0, limit: 2 }) - expect(cleanUpDuringNextRequest).toBe(false) - expect(cleanupPromise).toBeInstanceOf(Promise) - await cleanupPromise - - expect(move).toBeInstanceOf(Promise) - await expect(move).rejects.toMatchObject({ name: `AbortError` }) - expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) - } finally { - await cleanupAll(live, source) - } - }, - ) + expectPendingMutationRequestWork( + result, + { + ranks: [0, 1, 2], + direction: `asc`, + limit: 10, + mutation: { type: `delete`, id: 1 }, + responseOutcome: `resolve`, + }, + false, + ) + expect(result.releasedBeforeCleanup).toHaveLength(3) + } - it(`tracks an asynchronous cursor continuation after synchronous satisfaction`, async () => { - oracleRoute(`fixed history`) - const rows: Array = [ - { id: 1, rank: 1 }, - { id: 2, rank: 2 }, - { id: 3, rank: 3 }, - ] - const requests: Array = [] - const delivered = new Set() - const refinement = createDeferred() - let deferLoads = false - const source = createCollection({ - id: `pagination-async-refinement-source-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - const publish = (options: LoadSubsetOptions) => { - begin() - for (const row of rowsForLoadSubset(rows, options)) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - } + oracleProperty({ + id: `pagination.underfilled-bounded-repair`, + law: namedHistoriesLaw( + `finishes bounded repair when a visible delete leaves the source underfilled`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `finishes bounded repair when a visible delete leaves the source underfilled`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runUnderfilledBoundedRepair), + check: (_history, _observations, verdict) => verdict, + }) - return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - if (!deferLoads) { - publish(options) - return true - } + const cappedPrefixRepairCases = [false, true].map( + (explicitPublicKeyOrder) => ({ explicitPublicKeyOrder }), + ) - return refinement.promise.then(() => publish(options)) - }, - } - }, + async function runCappedPrefixRepair({ + explicitPublicKeyOrder, + }: (typeof cappedPrefixRepairCases)[number]): Promise { + const result = await runPendingMutationScenario( + { + ranks: [0, 1, 1, 2], + direction: `asc`, + limit: 3, + mutation: { type: `delete`, id: 1 }, + responseOutcome: `resolve`, + orderedResponseLimit: 1, }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .orderBy(({ row }) => row.id, `asc`) - .limit(1), + `after-response`, + undefined, + explicitPublicKeyOrder, ) + const requests = result.mutationRepairRequests - try { - await live.preload() - await flushPromises() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - const initialRequestCount = requests.length - expect(initialRequestCount).toBeGreaterThan(0) - expect( - requests.every( - ({ limit, where }) => limit !== undefined || where !== undefined, - ), - ).toBe(true) - deferLoads = true + expect( + requests.map((request) => + request.orderBy ? (request.cursor ? `refill` : `prefix`) : `tie`, + ), + ).toEqual([`prefix`, `tie`, `refill`, `tie`]) + const [prefix, firstTie, refill, secondTie] = requests + expect(orderSignature(prefix!)).toStrictEqual( + expectedOrderSignature(`asc`, explicitPublicKeyOrder), + ) + expect(prefix!.where).toBeUndefined() + expect(prefix!.limit).toBe(3) + expect(prefix!.refetch).toBe(true) + expect(prefix!.cursor).toBeUndefined() + expect(prefix!.offset).toBeUndefined() + expectPredicateBehavior( + firstTie!.where, + [{ id: 2, rank: 1 }], + [ + { id: 1, rank: 0 }, + { id: 4, rank: 2 }, + ], + ) + expect(firstTie!.orderBy).toBeUndefined() + expect(firstTie!.limit).toBeUndefined() + expect(firstTie!.refetch).toBe(true) + expect(firstTie!.cursor).toBeUndefined() + expect(firstTie!.offset).toBeUndefined() + expect(orderSignature(refill!)).toStrictEqual( + expectedOrderSignature(`asc`, explicitPublicKeyOrder), + ) + expect(refill?.limit).toBe(1) + expect(refill?.refetch).toBe(true) + expect(refill?.cursor).toBeDefined() + expectPredicateBehavior( + secondTie!.where, + [{ id: 4, rank: 2 }], + [{ id: 2, rank: 1 }], + ) + expect(secondTie!.orderBy).toBeUndefined() + expect(secondTie!.limit).toBeUndefined() + expect(secondTie!.refetch).toBe(true) + expect(secondTie!.cursor).toBeUndefined() + expect(secondTie!.offset).toBeUndefined() + } - const widened = live.utils.setWindow({ offset: 0, limit: 2 }) - expect(widened).toBeInstanceOf(Promise) - await flushPromises() - expect(requests.length).toBeGreaterThan(initialRequestCount) - const widenedRequest = requests - .slice(initialRequestCount) - .find(({ cursor }) => cursor !== undefined) - expect(widenedRequest).toBeDefined() - expect(widenedRequest).toMatchObject({ limit: 1, offset: 1 }) - expect(widenedRequest?.cursor?.whereFrom).toBeDefined() - expect(widenedRequest?.cursor?.whereCurrent).toBeDefined() - const settledBeforeRefinement = await Promise.race([ - Promise.resolve(widened).then(() => true), - new Promise((resolve) => setTimeout(() => resolve(false), 10)), - ]) - expect(settledBeforeRefinement).toBe(false) - - refinement.resolve() - if (widened instanceof Promise) await widened - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) - } finally { - await cleanupAll(live, source) - } + oracleProperty({ + id: `pagination.capped-prefix-repair`, + law: namedHistoriesLaw( + `repairs a capped prefix through its first-column tie and cursor refill`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: cappedPrefixRepairCases, + label: (row) => + `repairs a capped prefix through its first-column tie and cursor refill (explicit key=${String(row.explicitPublicKeyOrder)})`, + ...orderedComparison(runCappedPrefixRepair), + check: (_history, _observations, verdict) => verdict, }) - 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` }, - { id: 3, label: `item11` }, + const boundaryRefinementFailureCases = ([`asc`, `desc`] as const).flatMap( + (direction) => + ([`throw`, `reject`] as const).map((delivery) => ({ + direction, + delivery, + })), + ) + + async function runBoundaryRefinementFailure({ + direction, + delivery, + }: (typeof boundaryRefinementFailureCases)[number]): Promise { + const sign = direction === `asc` ? 1 : -1 + const rows: Array = [ + { id: 1, rank: sign }, + { id: 2, rank: 2 * sign }, ] - const pending: Array = [] + const liveInsert: PageRow = { id: 0, rank: 0 } const delivered = new Set() - let begin!: () => void - let write!: (message: { type: `insert`; value: LocaleCursorRow }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-locale-cursor-source-${collectionSequence++}`, + const failure = new Error(`later boundary failed`) + let widening = false + let failedBoundary: LoadSubsetOptions | undefined + let suppliedPage: Array | undefined + const source = createCollection({ + id: `pagination-boundary-publication-${collectionSequence++}`, getKey: (row) => row.id, syncMode: `on-demand`, - startSync: true, autoIndex: `eager`, defaultIndexType: BTreeIndex, sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - params.markReady() + sync: ({ begin, write, commit, markReady }) => { + markReady() return { - loadSubset: (options: LoadSubsetOptions) => { - const deferred = createDeferred() - pending.push({ options, deferred }) - return deferred.promise + loadSubset: (options) => { + const selected = rowsForLoadSubset(rows, options) + if ( + widening && + options.where && + !options.orderBy && + selected.some(({ id }) => id === 2) && + !failedBoundary + ) { + failedBoundary = options + if (delivery === `throw`) throw failure + return Promise.reject(failure) + } + const newRows = selected.filter(({ id }) => !delivered.has(id)) + begin() + for (const row of newRows) { + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + if (widening && options.orderBy && !suppliedPage) { + suppliedPage = newRows + rows.push(liveInsert) + delivered.add(liveInsert.id) + write({ type: `insert`, value: { ...liveInsert } }) + } + const receipt = commit(options.signal) + // Make the page asynchronous so the failure is in its later + // refinement, not the synchronous setWindow call stack. + return Promise.resolve(receipt).then(() => undefined) }, } }, }, }) - const live = createLiveQueryCollection((query) => - query + const live = createLiveQueryCollection((q) => + q .from({ row: source }) - .orderBy(({ row }) => row.label, { - direction: `asc`, - nulls: `first`, - stringSort: `locale`, - locale: `en-US`, - localeOptions: { numeric: true }, - }) - .orderBy(({ row }) => row.id, `asc`) + .orderBy(({ row }) => row.rank, direction) .limit(1), ) - + const publications: Array> = [] + const subscription = live.subscribeChanges(() => { + publications.push(Array.from(live.values(), ({ id }) => id)) + }) try { - const preload = live.preload() - expect(pending).toHaveLength(1) - // Settling one request can append its boundary-refinement request. - for (const request of pending) { - begin() - for (const row of rowsForLoadSubset(rows, request.options)) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - request.deferred.resolve() - await flushPromises() - } - await preload - + await live.preload() expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) - expect(pending.length).toBeLessThanOrEqual(rows.length * 2) + publications.length = 0 + widening = true + await expect(live.utils.setWindow({ offset: 0, limit: 2 })).rejects.toBe( + failure, + ) + expect(suppliedPage?.map(({ id }) => id)).toEqual([2]) + expect(failedBoundary).toBeDefined() expect( - pending.some( - ({ options }) => - options.limit === undefined && options.where === undefined, - ), - ).toBe(true) - - const transportCount = pending.length - const widened = live.utils.setWindow({ offset: 0, limit: 2 }) - for (let index = transportCount; index < pending.length; index++) { - const request = pending[index]! - begin() - for (const row of rowsForLoadSubset(rows, request.options)) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - request.deferred.resolve() - await flushPromises() - } - if (widened instanceof Promise) await widened - - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) - expect(pending.length).toBeLessThanOrEqual(rows.length * 3) + rowsForLoadSubset(rows, failedBoundary!).map(({ id }) => id), + ).toEqual([2]) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) + expect(publications).toEqual([]) + await live.utils.setWindow({ offset: 0, limit: 2 }) + const expected = referenceWindow(rows, direction, { + offset: 0, + limit: 2, + }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual(expected) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) + expect(publications).toEqual([expected]) } finally { - for (const request of pending) request.deferred.resolve() + subscription.unsubscribe() await cleanupAll(live, source) } + } + + oracleProperty({ + id: `pagination.boundary-refinement-failure`, + law: namedHistoriesLaw( + `holds a page and concurrent live insert when its boundary refinement fails`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: boundaryRefinementFailureCases, + label: (row) => + `holds a ${String(row.direction)} page and concurrent live insert when its boundary refinement fails by ${String(row.delivery)}`, + ...orderedComparison(runBoundaryRefinementFailure), + check: (_history, _observations, verdict) => verdict, }) const nullableBoundaryRows: ReadonlyArray = [ @@ -4691,87 +3761,343 @@ describe(`pagination recomputation oracle`, () => { { id: 6, primary: 2, secondary: 0 }, ] - it.each([ - [ - `discovered trace: orders an ascending nullable boundary by its second term`, - { - rows: nullableBoundaryRows, + // Named nullable multi-column boundaries. Each replays runMultiOrderScenario + // and its referenceMultiOrder comparison. The generated grammar maps its + // rows without an unmapper, so it cannot take these histories back. + const multiOrderFixedHistories: ReadonlyArray<{ + name: string + scenario: MultiOrderScenario + }> = [ + ...( + [ + [ + `discovered trace: orders an ascending nullable boundary by its second term`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `first` }, + secondary: { direction: `asc`, nulls: `first` }, + limit: 1, + }, + ], + [ + `orders a descending nullable boundary by its second term`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `first` }, + secondary: { direction: `desc`, nulls: `first` }, + limit: 1, + }, + ], + [ + `orders an ascending and descending mixed nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `first` }, + secondary: { direction: `desc`, nulls: `first` }, + limit: 1, + }, + ], + [ + `discovered trace: orders a descending and ascending mixed nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `first` }, + secondary: { direction: `asc`, nulls: `first` }, + limit: 1, + }, + ], + [ + `uses the public key to break a complete tuple tie`, + { + rows: [ + { id: 2, primary: 0, secondary: 0 }, + { id: 1, primary: 0, secondary: 0 }, + ], + primary: { direction: `asc`, nulls: `last` }, + secondary: { direction: `asc`, nulls: `last` }, + limit: 1, + }, + ], + [ + `discovered trace: places nulls last in an ascending nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `asc`, nulls: `last` }, + secondary: { direction: `asc`, nulls: `last` }, + limit: 1, + }, + ], + [ + `places nulls last in a descending nullable boundary`, + { + rows: nullableBoundaryRows, + primary: { direction: `desc`, nulls: `last` }, + secondary: { direction: `desc`, nulls: `last` }, + limit: 1, + }, + ], + ] satisfies ReadonlyArray + ).map(([name, scenario]) => ({ name, scenario })), + { + name: `expands a multi-column boundary before choosing top-K`, + scenario: { + rows: [ + { id: 1, primary: 0, secondary: 2 }, + { id: 2, primary: 0, secondary: 0 }, + { id: 3, primary: 0, secondary: 1 }, + { id: 4, primary: 1, secondary: 1 }, + { id: 5, primary: 1, secondary: 0 }, + { id: 6, primary: 2, secondary: 0 }, + ], primary: { direction: `asc`, nulls: `first` }, secondary: { direction: `asc`, nulls: `first` }, - limit: 1, - }, - ], - [ - `orders a descending nullable boundary by its second term`, - { - rows: nullableBoundaryRows, - primary: { direction: `desc`, nulls: `first` }, - secondary: { direction: `desc`, nulls: `first` }, - limit: 1, + limit: 4, }, + }, + ] + + oracleProperty({ + id: `pagination.multi-order-fixed`, + law: { + statement: `matches multi-column nullable ordering for named boundaries`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Named histories that the generated multi-order grammar cannot take back; each replays runMultiOrderScenario and its comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', ], - [ - `orders an ascending and descending mixed nullable boundary`, - { - rows: nullableBoundaryRows, - primary: { direction: `asc`, nulls: `first` }, - secondary: { direction: `desc`, nulls: `first` }, - limit: 1, - }, + histories: multiOrderFixedHistories, + label: ({ name }) => name, + ...orderedComparison( + ({ scenario }: (typeof multiOrderFixedHistories)[number]) => + runMultiOrderScenario(scenario), + ), + check: (_history, _observations, verdict) => verdict, + }) + + oracleProperty({ + id: `pagination.multi-order`, + law: { + statement: `matches multi-column nullable ordering for a random or replayed seed`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The existing bounded history grammar and controlled-provider observations; this registration preserves the existing independent judgment.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', ], - [ - `discovered trace: orders a descending and ascending mixed nullable boundary`, - { - rows: nullableBoundaryRows, - primary: { direction: `desc`, nulls: `first` }, - secondary: { direction: `asc`, nulls: `first` }, - limit: 1, - }, + histories: { + arbitrary: multiOrderScenarioArbitrary, + runs: 12, + fixedSeeds: [1663], + }, + model: (history, observations) => recordedComparison(history, observations), + driver: async (history, observe) => + observeComparison(history, observe, async () => { + const scenario = history + + await runMultiOrderScenario(scenario) + }), + check: (_history, _observations, verdict) => verdict, + }) + + oracleProperty({ + id: `pagination.nullable-cursor`, + law: { + statement: `matches nullable cursor ordering while an async response is pending for a random or replayed seed`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The existing bounded history grammar and controlled-provider observations; this registration preserves the existing independent judgment.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', ], + histories: { + arbitrary: nullableCursorScenarioArbitrary, + runs: 8, + fixedSeeds: [1665], + }, + model: (history, observations) => recordedComparison(history, observations), + driver: async (history, observe) => + observeComparison(history, observe, async () => { + const scenario = history + + await runNullableCursorScenario(scenario) + }), + check: (_history, _observations, verdict) => verdict, + }) + + // Named pending-mutation regressions. Each history lists the calls of + // runPendingMutationScenario that its old test made, with the same + // arguments; that driver keeps its own recomputation comparison. The + // generated grammar maps its payload without an unmapper, so it cannot take + // these histories back. + const pendingMutationFixedHistories: ReadonlyArray = [ - `uses the public key to break a complete tuple tie`, + ...( + [ + [`boundary insert`, { type: `insert`, row: { id: 5, rank: 0.5 } }], + [`visible delete`, { type: `delete`, id: 1 }], + [ + `boundary-crossing rank update`, + { type: `update`, row: { id: 4, rank: 0.5 } }, + ], + ] satisfies ReadonlyArray + ).map(([name, mutation]) => { + const scenario: PendingMutationScenario = { + ranks: [0, 1, 2, 3], + direction: `asc`, + limit: 3, + mutation, + responseOutcome: `resolve`, + } + return { + name: `${name} converges before and after a pending response`, + runs: [ + { scenario, timing: `before-response` as const }, + { scenario, timing: `after-response` as const }, + ], + } + }), + ...( + [ + [`insert`, { type: `insert`, row: { id: 9, rank: 0.5 } }], + [`delete`, { type: `delete`, id: 1 }], + [`rank update`, { type: `update`, row: { id: 2, rank: 10 } }], + ] satisfies ReadonlyArray + ).map(([name, mutation]) => ({ + name: `revalidates a finite ordered prefix after a settled SSE ${name}`, + runs: [ + { + scenario: { + ranks: [0, 1, 2, 3, 4, 5, 6, 7], + direction: `asc` as const, + limit: 2, + mutation, + responseOutcome: `resolve` as const, + }, + timing: `after-response` as const, + }, + ], + })), { - rows: [ - { id: 2, primary: 0, secondary: 0 }, - { id: 1, primary: 0, secondary: 0 }, + name: `does not use a new row beyond finite coverage as a widening boundary`, + runs: [ + { + scenario: { + ranks: [0, 1, 2, 3, 4, 5, 6, 7], + direction: `asc`, + limit: 2, + mutation: { type: `insert`, row: { id: 9, rank: 4.5 } }, + responseOutcome: `resolve`, + }, + timing: `after-response`, + finalLimitAfterMutation: 5, + }, ], - primary: { direction: `asc`, nulls: `last` }, - secondary: { direction: `asc`, nulls: `last` }, - limit: 1, }, - ], - [ - `discovered trace: places nulls last in an ascending nullable boundary`, { - rows: nullableBoundaryRows, - primary: { direction: `asc`, nulls: `last` }, - secondary: { direction: `asc`, nulls: `last` }, - limit: 1, + name: `discovered trace: a settled rank update refreshes top-k membership`, + runs: [ + { + scenario: { + ranks: [0, 0, 1], + direction: `desc`, + limit: 1, + mutation: { type: `update`, row: { id: 3, rank: 0 } }, + responseOutcome: `resolve`, + }, + timing: `after-response`, + }, + ], }, - ], - [ - `places nulls last in a descending nullable boundary`, { - rows: nullableBoundaryRows, - primary: { direction: `desc`, nulls: `last` }, - secondary: { direction: `desc`, nulls: `last` }, - limit: 1, + name: `a rejected cursor does not treat a live insert as remote coverage`, + runs: [ + { + scenario: { + ranks: [0, -1, 0], + direction: `asc`, + limit: 1, + mutation: { type: `insert`, row: { id: 4, rank: 0 } }, + responseOutcome: `reject`, + }, + timing: `before-response`, + }, + ], }, - ], - ] satisfies ReadonlyArray)( - `%s`, - async (_name, scenario) => { - oracleRoute(`case`) - return runMultiOrderScenario(scenario) - }, - ) + ...([`insert`, `update`, `delete`] as const).flatMap((mutationKind) => + ([`resolve`, `reject`] as const).flatMap((responseOutcome) => + ([`before-response`, `after-response`] as const).map((timing) => { + const mutation: PendingMutation = + mutationKind === `insert` + ? { type: `insert`, row: { id: 5, rank: -1 } } + : mutationKind === `update` + ? { type: `update`, row: { id: 2, rank: -1 } } + : { type: `delete`, id: 2 } + return { + name: `covers pending ${mutationKind} with a ${responseOutcome} response ${timing} deterministically`, + runs: [ + { + scenario: { + ranks: [0, 1, 2, 3], + direction: `asc` as const, + limit: 2, + mutation, + responseOutcome, + }, + timing, + }, + ], + } + }), + ), + ), + ...([`asc`, `desc`] as const).flatMap((direction) => + ([`before-response`, `after-response`] as const).flatMap((timing) => + [0.5, 100].flatMap((rank) => + ([`cursor`, `offset`] as const).map((transport) => { + const sign = direction === `asc` ? 1 : -1 + return { + name: `keeps live observations separate from a settled acquisition: ${JSON.stringify({ direction, timing, rank, transport })}`, + runs: [ + { + scenario: { + ranks: [0, sign, 2 * sign, 3 * sign], + direction, + limit: 1, + mutation: { + type: `insert` as const, + row: { id: 9, rank: sign * rank }, + }, + responseOutcome: `resolve` as const, + }, + timing, + finalLimitAfterMutation: 3, + explicitPublicKeyOrder: false, + transport, + }, + ], + } + }), + ), + ), + ), + ] oracleProperty({ - id: `pagination.multi-order`, + id: `pagination.pending-mutation-fixed`, law: { - statement: `matches multi-column nullable ordering for a random or replayed seed`, + statement: `matches recomputation when source mutations cross a pending cursor response for named regressions`, authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, - limits: `The existing bounded history grammar and controlled-provider observations; this registration preserves the existing independent judgment.`, + limits: `Named histories that the generated pending-mutation grammar cannot take back; each replays runPendingMutationScenario with its old arguments and that driver's comparison.`, }, owns: [ 'src/collection/index.ts#createCollection', @@ -4779,75 +4105,55 @@ describe(`pagination recomputation oracle`, () => { 'src/query/live-query-collection.ts#createLiveQueryCollection', 'src/utils/comparison.ts#makeComparator', ], - histories: { - arbitrary: multiOrderScenarioArbitrary, - runs: 12, - fixedSeeds: [1663], - }, - model: (history, observations) => recordedComparison(history, observations), - driver: async (history, observe) => - observeComparison(history, observe, async () => { - const scenario = history - - await runMultiOrderScenario(scenario) - }), + histories: pendingMutationFixedHistories, + label: ({ name }) => name, + ...orderedComparison(runPendingMutationRuns), check: (_history, _observations, verdict) => verdict, }) - 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 }, - { id: 1, primary: 0, secondary: 0 }, - { id: 5, primary: 1, secondary: 1 }, - { id: 4, primary: 1, secondary: 0 }, - { id: 6, primary: 2, secondary: 0 }, + // A reached fault must be rejected. Each calibration corrupts production + // through runPendingMutationScenario's fault argument, on the history whose + // clean run the old test made first, and accepts exactly the old matcher. + const pendingMutationCalibrationHistories: ReadonlyArray = + [ + ...([`resolve`, `reject`] as const).map((responseOutcome) => ({ + name: `inserts before a pending ${responseOutcome} response (calibration premise)`, + runs: [ + { + scenario: { + ranks: [0, 1, 2, 3], + direction: `asc` as const, + limit: 2, + mutation: { type: `insert` as const, row: { id: 5, rank: -1 } }, + responseOutcome, + }, + timing: `before-response` as const, + }, + ], + })), + { + name: `deletes a visible row after a pending response (calibration premise)`, + runs: [ + { + scenario: { + ranks: [0, 1, 2, 3], + direction: `asc`, + limit: 2, + mutation: { type: `delete`, id: 1 }, + responseOutcome: `resolve`, + }, + timing: `after-response`, + }, + ], + }, ] - const { requests, source } = createConformingOrderedSource( - `pagination-multi-order-cursor-${collectionSequence++}`, - rows, - ) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.primary, `asc`) - .orderBy(({ row }) => row.secondary, `desc`) - .limit(2) - .select(({ row }) => ({ id: row.id })), - ) - - try { - await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 2]) - - const initialRequestCount = requests.length - const widened = live.utils.setWindow({ offset: 0, limit: 5 }) - if (widened instanceof Promise) await widened - - expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 2, 1, 5, 4]) - const continuation = requests - .slice(initialRequestCount) - .find(({ cursor }) => cursor !== undefined) - expect(continuation).toMatchObject({ - limit: 2, - offset: 3, - orderBy: expect.any(Array), - }) - expect(continuation?.orderBy).toHaveLength(2) - expect(continuation?.cursor?.whereFrom).toBeDefined() - expect(continuation?.cursor?.whereCurrent).toBeDefined() - } finally { - await cleanupAll(live, source) - } - }) oracleProperty({ - id: `pagination.nullable-cursor`, + id: `pagination.pending-mutation-calibration`, law: { - statement: `matches nullable cursor ordering while an async response is pending for a random or replayed seed`, + statement: `matches recomputation when source mutations cross a pending cursor response, and rejects each reached publication fault`, authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, - limits: `The existing bounded history grammar and controlled-provider observations; this registration preserves the existing independent judgment.`, + limits: `The three calibration premises of the old fault tests; each fault runs on the premise whose clean run its old test made.`, }, owns: [ 'src/collection/index.ts#createCollection', @@ -4855,153 +4161,79 @@ describe(`pagination recomputation oracle`, () => { 'src/query/live-query-collection.ts#createLiveQueryCollection', 'src/utils/comparison.ts#makeComparator', ], - histories: { - arbitrary: nullableCursorScenarioArbitrary, - runs: 8, - fixedSeeds: [1665], - }, - model: (history, observations) => recordedComparison(history, observations), - driver: async (history, observe) => - observeComparison(history, observe, async () => { - const scenario = history - - await runNullableCursorScenario(scenario) - }), + histories: pendingMutationCalibrationHistories, + label: ({ name }) => name, + ...orderedComparison(runPendingMutationRuns), check: (_history, _observations, verdict) => verdict, - }) - - it.each([ - [`boundary insert`, { type: `insert`, row: { id: 5, rank: 0.5 } }], - [`visible delete`, { type: `delete`, id: 1 }], - [ - `boundary-crossing rank update`, - { type: `update`, row: { id: 4, rank: 0.5 } }, - ], - ] 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`, - limit: 3, - mutation, - responseOutcome: `resolve`, - } - await runPendingMutationScenario(scenario, `before-response`) - await runPendingMutationScenario(scenario, `after-response`) - }, - ) - - it.each( - ([`resolve`, `reject`] as const).flatMap((responseOutcome) => - ( - [ - [ - `held-publication`, - /held mutation publication|failed mutation keeps complete baseline/, - ], - [`held-window`, /held mutation window/], - [`early-settlement`, /held mutation logical settlement/], - [`callback-key`, /mutation native callback key/], - [`callback-value`, /mutation callback replica full values/], + calibrations: [ + ...([`resolve`, `reject`] as const).flatMap((responseOutcome) => + ( [ - `final-value`, - /mutation callback replica full values|complete final mutation values/, - ], - ...(responseOutcome === `reject` - ? [ - [ - `wrong-rejection`, - /failed mutation caller exact error/, - ] as const, - ] - : []), - ] as const - ).map(([fault, message]) => ({ responseOutcome, fault, message })), - ), - )( - `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`, - limit: 2, - mutation: { type: `insert`, row: { id: 5, rank: -1 } }, - responseOutcome, - } - await runPendingMutationScenario(scenario, `before-response`) - await expect( - runPendingMutationScenario( - scenario, - `before-response`, - undefined, - true, - `cursor`, - fault, - ), - ).rejects.toThrow(message) - }, - ) - - it(`rejects a wrong deleted payload before removing its replica row`, async () => { - oracleRoute(`calibration`) - const scenario: PendingMutationScenario = { - ranks: [0, 1, 2, 3], - direction: `asc`, - limit: 2, - mutation: { type: `delete`, id: 1 }, - responseOutcome: `resolve`, - } - await runPendingMutationScenario(scenario, `after-response`) - await expect( - runPendingMutationScenario( - scenario, - `after-response`, - undefined, - true, - `cursor`, - `delete-value`, + [ + `held-publication`, + /held mutation publication|failed mutation keeps complete baseline/, + ], + [`held-window`, /held mutation window/], + [`early-settlement`, /held mutation logical settlement/], + [`callback-key`, /mutation native callback key/], + [`callback-value`, /mutation callback replica full values/], + [ + `final-value`, + /mutation callback replica full values|complete final mutation values/, + ], + ...(responseOutcome === `reject` + ? [ + [ + `wrong-rejection`, + /failed mutation caller exact error/, + ] as const, + ] + : []), + ] as const + ).map(([fault, message]) => ({ + name: `rejects a reached ${fault} in a ${responseOutcome} mutation history`, + law: `calibration ${fault} ${responseOutcome}`, + driver: pendingMutationFaultDriver( + `inserts before a pending ${responseOutcome} response (calibration premise)`, + fault, + message, + `calibration ${fault} ${responseOutcome}`, + ), + })), ), - ).rejects.toThrow(/mutation deleted callback full value/) + { + name: `rejects a wrong deleted payload before removing its replica row`, + law: `calibration delete-value`, + driver: pendingMutationFaultDriver( + `deletes a visible row after a pending response (calibration premise)`, + `delete-value`, + /mutation deleted callback full value/, + `calibration delete-value`, + ), + }, + ], }) - it.each([ - [`insert`, { type: `insert`, row: { id: 9, rank: 0.5 } }], - [`delete`, { type: `delete`, id: 1 }], - [`rank update`, { type: `update`, row: { id: 2, rank: 10 } }], - ] 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], - direction: `asc`, - limit: 2, - mutation, - responseOutcome: `resolve`, - }, - `after-response`, - ) - }, - ) + const ssePrefixRefreshCases = ([`asc`, `desc`] as const).map((direction) => ({ + direction, + })) - it(`retains a finite inactive prefix across shrink, SSE, and re-expansion`, async () => { - oracleRoute(`fixed history`) + async function runSsePrefixRefresh({ + direction, + }: (typeof ssePrefixRefreshCases)[number]): Promise { const rows = new Map( - Array.from({ length: 5 }, (_, index) => [ + Array.from({ length: 6 }, (_, index) => [ index + 1, { id: index + 1, rank: index + 1 }, ]), ) const delivered = new Set() + const loads: Array = [] let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void + let write!: (message: { type: `insert` | `update`; value: PageRow }) => void let commit!: () => void const source = createCollection({ - id: `pagination-retained-prefix-live-${collectionSequence++}`, + id: `pagination-batch-prefix-refresh-${collectionSequence++}`, getKey: (row) => row.id, syncMode: `on-demand`, startSync: true, @@ -5015,18 +4247,25 @@ describe(`pagination recomputation oracle`, () => { params.markReady() return { loadSubset: (options: LoadSubsetOptions) => { - const ordered = [...rows.values()].sort( - (left, right) => left.rank - right.rank || left.id - right.id, - ) - const requested = rowsForLoadSubset(ordered, options) - begin() - for (const row of requested) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - const receipt = commit() - return Promise.resolve(receipt) + loads.push(options) + const settled = new Promise((resolve) => { + queueMicrotask(() => { + const ordered = referenceWindowRows( + [...rows.values()], + direction, + { offset: 0, limit: rows.size }, + ) + begin() + for (const row of rowsForLoadSubset(ordered, options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + resolve() + }) + }) + return settled }, } }, @@ -5035,130 +4274,60 @@ describe(`pagination recomputation oracle`, () => { const live = createLiveQueryCollection((query) => query .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.rank, direction) .orderBy(({ row }) => row.id, `asc`) - .limit(3), + .limit(2), ) try { await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 3]) + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + direction === `asc` ? [1, 2] : [6, 5], + ) - await live.utils.setWindow({ offset: 0, limit: 1 }) - const inserted = { id: 9, rank: 2.5 } - rows.set(inserted.id, inserted) - delivered.add(inserted.id) begin() - write({ type: `insert`, value: inserted }) + const movedIds = direction === `asc` ? [1, 2, 3, 4] : [3, 4, 5, 6] + for (const id of movedIds) { + const row = { + id, + rank: direction === `asc` ? 100 + id : -100 - id, + } + rows.set(id, row) + write({ type: `update`, value: { ...row } }) + } commit() - await flushPromises() + for (let index = 0; index < 5; index++) await flushPromises() - await live.utils.setWindow({ offset: 0, limit: 3 }) - expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 9]) + expect(Array.from(live.values(), ({ id }) => id)).toEqual( + direction === `asc` ? [5, 6] : [2, 1], + ) + expect( + loads.some(({ limit, cursor }) => limit === 2 && cursor === undefined), + ).toBe(true) } finally { await cleanupAll(live, source) } - }) - - 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, - { id: index + 1, rank: index + 1 }, - ]), - ) - const delivered = new Set() - const loads: Array = [] - let begin!: () => void - let write!: (message: { - type: `insert` | `update` - value: PageRow - }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-batch-prefix-refresh-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - params.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - loads.push(options) - const settled = new Promise((resolve) => { - queueMicrotask(() => { - const ordered = referenceWindowRows( - [...rows.values()], - direction, - { offset: 0, limit: rows.size }, - ) - begin() - for (const row of rowsForLoadSubset(ordered, options)) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - resolve() - }) - }) - return settled - }, - } - }, - }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - .orderBy(({ row }) => row.id, `asc`) - .limit(2), - ) - - try { - await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual( - direction === `asc` ? [1, 2] : [6, 5], - ) - - begin() - const movedIds = direction === `asc` ? [1, 2, 3, 4] : [3, 4, 5, 6] - for (const id of movedIds) { - const row = { - id, - rank: direction === `asc` ? 100 + id : -100 - id, - } - rows.set(id, row) - write({ type: `update`, value: { ...row } }) - } - commit() - for (let index = 0; index < 5; index++) await flushPromises() + } - expect(Array.from(live.values(), ({ id }) => id)).toEqual( - direction === `asc` ? [5, 6] : [2, 1], - ) - expect( - loads.some( - ({ limit, cursor }) => limit === 2 && cursor === undefined, - ), - ).toBe(true) - } finally { - await cleanupAll(live, source) - } - }, - ) + oracleProperty({ + id: `pagination.sse-prefix-refresh`, + law: namedHistoriesLaw( + `refreshes from the start when one SSE batch moves the retained prefix`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: ssePrefixRefreshCases, + label: (row) => + `refreshes from the start when one SSE batch moves the retained prefix (${String(row.direction)})`, + ...orderedComparison(runSsePrefixRefresh), + check: (_history, _observations, verdict) => verdict, + }) - it.each([ + const sseBoundaryRefinementCases = [ [`insert`, { type: `insert`, row: { id: 7, rank: 0 } }, [7, 1], [7, 1, 2]], [`update`, { type: `update`, row: { id: 2, rank: -1 } }, [2, 1], [2, 1, 3]], [`delete`, { type: `delete`, id: 1 }, [2, 3], [2, 3, 4]], @@ -5169,227 +4338,170 @@ describe(`pagination recomputation oracle`, () => { ReadonlyArray, ReadonlyArray, ] - >)( - `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, - { id: index + 1, rank: index + 1 }, - ]), - ) - const delivered = new Set() - const pending: Array = [] - let begin!: () => void - let write!: (message: { - type: `insert` | `update` | `delete` - value: PageRow - }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-pending-refinement-sse-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - params.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - const deferred = createDeferred() - pending.push({ options, deferred }) - return deferred.promise - }, - } - }, + > + + async function runSseBoundaryRefinement([ + _name, + mutation, + expectedIds, + expectedWideIds, + ]: (typeof sseBoundaryRefinementCases)[number]): Promise { + const rows = new Map( + Array.from({ length: 6 }, (_, index) => [ + index + 1, + { id: index + 1, rank: index + 1 }, + ]), + ) + const delivered = new Set() + const pending: Array = [] + let begin!: () => void + let write!: (message: { + type: `insert` | `update` | `delete` + value: PageRow + }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-pending-refinement-sse-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + const settle = async (request: PendingCursorLoad) => { + const ordered = referenceWindowRows([...rows.values()], `asc`, { + offset: 0, + limit: rows.size, }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .orderBy(({ row }) => row.id, `asc`) - .limit(2), - ) + begin() + for (const row of rowsForLoadSubset(ordered, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await flushPromises() + } - const settle = async (request: PendingCursorLoad) => { - const ordered = referenceWindowRows([...rows.values()], `asc`, { - offset: 0, - limit: rows.size, - }) - begin() - for (const row of rowsForLoadSubset(ordered, request.options)) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - request.deferred.resolve() - await flushPromises() + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + await settle(pending[0]!) + expect(pending).toHaveLength(2) + + begin() + if (mutation.type === `delete`) { + const row = rows.get(mutation.id)! + rows.delete(mutation.id) + delivered.delete(mutation.id) + write({ type: `delete`, value: { ...row } }) + } else { + rows.set(mutation.row.id, { ...mutation.row }) + delivered.add(mutation.row.id) + write({ type: mutation.type, value: { ...mutation.row } }) } + commit() - try { - const preload = live.preload() - expect(pending).toHaveLength(1) - await settle(pending[0]!) - expect(pending).toHaveLength(2) + await settle(pending[1]!) + expect( + pending.some( + ({ options }) => options.limit === 2 && options.cursor === undefined, + ), + ).toBe(true) + for (let index = 2; index < pending.length; index++) { + await settle(pending[index]!) + } + await preload - begin() - if (mutation.type === `delete`) { - const row = rows.get(mutation.id)! - rows.delete(mutation.id) - delivered.delete(mutation.id) - write({ type: `delete`, value: { ...row } }) - } else { - rows.set(mutation.row.id, { ...mutation.row }) - delivered.add(mutation.row.id) - write({ type: mutation.type, value: { ...mutation.row } }) - } - commit() + expect(Array.from(live.values(), ({ id }) => id)).toEqual(expectedIds) - await settle(pending[1]!) + const pendingBeforeWiden = pending.length + const widened = live.utils.setWindow({ offset: 0, limit: 3 }) + await flushPromises() + if (mutation.type !== `insert`) { + // When an SSE update or delete arrives during boundary refinement, after the + // boundary settles, the loader uses a bounded prefix repair instead of + // full-source: orderBy is defined, limit is defined (= offset + limit), + // and cursor/offset/where are undefined. expect( pending.some( ({ options }) => - options.limit === 2 && options.cursor === undefined, + options.orderBy !== undefined && + options.limit !== undefined && + options.where === undefined && + options.cursor === undefined && + options.offset === undefined, ), ).toBe(true) - for (let index = 2; index < pending.length; index++) { + } + if (mutation.type === `insert`) { + expect(widened).toBe(true) + expect(pending).toHaveLength(pendingBeforeWiden) + } else { + // The bounded repair proves coverage only through its own prefix, + // so widening continues with finite requests. None reads the full + // source. + for (let index = pendingBeforeWiden; index < pending.length; index++) { await settle(pending[index]!) } - await preload - - expect(Array.from(live.values(), ({ id }) => id)).toEqual(expectedIds) - - const pendingBeforeWiden = pending.length - const widened = live.utils.setWindow({ offset: 0, limit: 3 }) - await flushPromises() - if (mutation.type !== `insert`) { - // When an SSE update or delete arrives during boundary refinement, after the - // boundary settles, the loader uses a bounded prefix repair instead of - // full-source: orderBy is defined, limit is defined (= offset + limit), - // and cursor/offset/where are undefined. - expect( - pending.some( - ({ options }) => - options.orderBy !== undefined && - options.limit !== undefined && - options.where === undefined && - options.cursor === undefined && - options.offset === undefined, - ), - ).toBe(true) - } - if (mutation.type === `insert`) { - expect(widened).toBe(true) - expect(pending).toHaveLength(pendingBeforeWiden) - } else { - // The bounded repair proves coverage only through its own prefix, - // so widening continues with finite requests. None reads the full - // source. - for ( - let index = pendingBeforeWiden; - index < pending.length; - index++ - ) { - await settle(pending[index]!) - } - await widened - const widening = pending.slice(pendingBeforeWiden) - expect(widening.length).toBeGreaterThan(0) - expect( - widening.every( - ({ options }) => - options.orderBy !== undefined || options.where !== undefined, - ), - ).toBe(true) - } - expect(Array.from(live.values(), ({ id }) => id)).toEqual( - expectedWideIds, - ) - } finally { - for (const request of pending) request.deferred.resolve() - await cleanupAll(live, source) + await widened + const widening = pending.slice(pendingBeforeWiden) + expect(widening.length).toBeGreaterThan(0) + expect( + widening.every( + ({ options }) => + options.orderBy !== undefined || options.where !== undefined, + ), + ).toBe(true) } - }, - ) - - 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], - direction: `asc`, - limit: 2, - mutation: { type: `insert`, row: { id: 9, rank: 4.5 } }, - responseOutcome: `resolve`, - }, - `after-response`, - 5, - ) - }) - - it(`discovered trace: a settled rank update refreshes top-k membership`, async () => { - oracleRoute(`fixed history`) - const scenario: PendingMutationScenario = { - ranks: [0, 0, 1], - direction: `desc`, - limit: 1, - mutation: { type: `update`, row: { id: 3, rank: 0 } }, - responseOutcome: `resolve`, - } - await runPendingMutationScenario(scenario, `after-response`) - }) - - 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`, - limit: 1, - mutation: { type: `insert`, row: { id: 4, rank: 0 } }, - responseOutcome: `reject`, + expect(Array.from(live.values(), ({ id }) => id)).toEqual(expectedWideIds) + } finally { + for (const request of pending) request.deferred.resolve() + await cleanupAll(live, source) } + } - await runPendingMutationScenario(scenario, `before-response`) - }) - - it.each( - ([`insert`, `update`, `delete`] as const).flatMap((mutationKind) => - ([`resolve`, `reject`] as const).flatMap((responseOutcome) => - ([`before-response`, `after-response`] as const).map( - (timing) => [mutationKind, responseOutcome, timing] as const, - ), - ), + oracleProperty({ + id: `pagination.sse-boundary-refinement`, + law: namedHistoriesLaw( + `keeps an SSE mutation that arrives during boundary refinement`, ), - )( - `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 } } - : mutationKind === `update` - ? { type: `update`, row: { id: 2, rank: -1 } } - : { type: `delete`, id: 2 } - await runPendingMutationScenario( - { - ranks: [0, 1, 2, 3], - direction: `asc`, - limit: 2, - mutation, - responseOutcome, - }, - timing, - ) - }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: sseBoundaryRefinementCases, + label: (row) => + `keeps an SSE ${String(row[0])} that arrives during boundary refinement`, + ...orderedComparison(runSseBoundaryRefinement), + check: (_history, _observations, verdict) => verdict, + }) oracleProperty({ id: `pagination.pending-mutation`, @@ -5423,24 +4535,37 @@ describe(`pagination recomputation oracle`, () => { check: (_history, _observations, verdict) => verdict, }) - it(`discovered trace: retries a rejected cursor after a source and window transition`, async () => { - oracleRoute(`fixed history`) - await runRejectedCursorRetryAfterMutation() + oracleProperty({ + id: `pagination.rejected-cursor-retry`, + law: namedHistoriesLaw( + `discovered trace: retries a rejected cursor after a source and window transition`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `discovered trace: retries a rejected cursor after a source and window transition`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runRejectedCursorRetryAfterMutation), + check: (_history, _observations, verdict) => verdict, }) - it.each([ - [`partial-publication`, `held initial publication`], - [`wrong-window`, `held initial window`], - [`early-settlement`, `held logical settlement`], - [`wrong-final-value`, `complete publication final`], - [`wrong-callback-value`, `callback replica full values`], - [`wrong-callback-key`, `native callback key`], - [`retracted-publication`, `published window cannot withdraw`], - ] as const)( - `rejects %s in a captured pending history`, - async (fault, message) => { - oracleRoute(`calibration`) - const scenario: PendingHistoryScenario = { + // A captured pending history must reject each publication fault. The + // premise is the clean history that the old calibration ran first; each + // calibration accepts exactly the old matcher. + const pendingHistoryCalibrationHistories: ReadonlyArray<{ + name: string + scenario: PendingHistoryScenario + }> = [ + { + name: `captured pending history (calibration premise)`, + scenario: { ranks: [0, 0, 1, 2], direction: `asc`, initialLimit: 2, @@ -5448,13 +4573,59 @@ describe(`pagination recomputation oracle`, () => { wideLimit: 4, firstRank: 2, secondRank: -2, - } - await runPendingHistoryScenario(scenario) - await expect( - runPendingHistoryScenario(scenario, fault), - ).rejects.toThrowError(message) + }, }, - ) + ] + + oracleProperty({ + id: `pagination.pending-history-fixed`, + law: { + statement: `matches recomputation across multi-action pending histories, and rejects each captured publication fault`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The one captured pending history of the old calibrations, replayed through runPendingHistoryScenario and its comparison.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: pendingHistoryCalibrationHistories, + label: ({ name }) => name, + ...orderedComparison( + ({ scenario }: (typeof pendingHistoryCalibrationHistories)[number]) => + runPendingHistoryScenario(scenario), + ), + check: (_history, _observations, verdict) => verdict, + calibrations: ( + [ + [`partial-publication`, `held initial publication`], + [`wrong-window`, `held initial window`], + [`early-settlement`, `held logical settlement`], + [`wrong-final-value`, `complete publication final`], + [`wrong-callback-value`, `callback replica full values`], + [`wrong-callback-key`, `native callback key`], + [`retracted-publication`, `published window cannot withdraw`], + ] as const + ).map(([fault, message]) => ({ + name: `rejects ${fault} in a captured pending history`, + law: `calibration ${fault}`, + driver: ( + history: (typeof pendingHistoryCalibrationHistories)[number], + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(history, observe, async () => { + try { + await runPendingHistoryScenario(history.scenario, fault) + } catch (error) { + // Exactly the old matcher, toThrowError(message). + if (error instanceof Error && error.message.includes(message)) + rejectAt(`calibration ${fault}`, error) + throw error + } + }), + })), + }) oracleProperty({ id: `pagination.pending-history`, @@ -5484,143 +4655,6 @@ describe(`pagination recomputation oracle`, () => { check: (_history, _observations, verdict) => verdict, }) - 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`, - initialWindow: { offset: 1, limit: 1 }, - actions: [{ type: `put`, id: 1, rank: -1 }], - } - await runPaginationStateScenario(scenario) - }) - - it(`retains authoritative rows when a later window admits a prior insert`, async () => { - oracleRoute(`fixed history`) - const scenario: PaginationStateScenario = { - ranks: [0, 0], - direction: `asc`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: 1 }, - { type: `window`, offset: 0, limit: 3 }, - ], - } - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: 1 }, - { type: `window`, offset: 2, limit: 1 }, - ], - } - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 2, rank: 1 }, - { type: `window`, offset: 2, limit: 1 }, - ], - } - await runPaginationStateScenario(scenario) - }) - - it(`discovered trace: inserting at an empty offset boundary refills the window`, async () => { - oracleRoute(`fixed history`) - const scenario: PaginationStateScenario = { - ranks: [0, 0], - direction: `asc`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: 1 }, - { type: `window`, offset: 3, limit: 1 }, - { type: `put`, id: 4, rank: 1 }, - ], - } - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 3 }, - actions: [ - { type: `put`, id: 10, rank: 0 }, - { type: `window`, offset: 8, limit: 1 }, - ], - } - - await runPaginationStateScenario(scenario) - }) - - 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`, - windows: [ - { offset: 0, limit: 1 }, - { offset: 2, limit: 5 }, - ], - } - await runOnDemandPaginationScenario(scenario) - }) - - 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`, - windows: [ - { offset: 0, limit: 1 }, - { offset: 2, limit: 1 }, - ], - } - await runOnDemandPaginationScenario(scenario) - }) - - it.each( - [`asc`, `desc`].flatMap((direction) => - [false, true].flatMap((explicitPublicKeyOrder) => - [false, true].map((includeFilter) => ({ - direction: direction as `asc` | `desc`, - explicitPublicKeyOrder, - includeFilter, - })), - ), - ), - )( - `bounds requests for an underfilled source: $direction, explicit key=$explicitPublicKeyOrder, filter=$includeFilter`, - async (structure) => { - oracleRoute(`case`) - await runOnDemandPaginationScenario({ - ...structure, - ranks: [0, 0], - keeps: [true, false], - windows: [{ offset: 0, limit: 3 }], - }) - }, - ) - for (const [index, structure] of paginationStructures.entries()) { const name = `key=${structure.explicitPublicKeyOrder ? `explicit` : `implicit`}, filter=${structure.includeFilter ? `on` : `off`}, insertion=${structure.reverseInsertion ? `reverse` : `forward`}` const runWindowCell = async (scenario: PaginationScenario) => { @@ -5743,737 +4777,469 @@ describe(`pagination recomputation oracle`, () => { check: (_history, _observations, verdict) => verdict, }) - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - ([`pages`, `widen`] as const).flatMap((mode) => - [false, true].flatMap((explicitPublicKeyOrder) => - [3, 10].map((pageSize) => ({ - direction, - mode, - explicitPublicKeyOrder, - pageSize, - })), - ), + const linearRowVolumeCases = ([`asc`, `desc`] as const).flatMap((direction) => + ([`pages`, `widen`] as const).flatMap((mode) => + [false, true].flatMap((explicitPublicKeyOrder) => + [3, 10].map((pageSize) => ({ + direction, + mode, + explicitPublicKeyOrder, + pageSize, + })), ), ), - )( - `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, - rank, - })) - const ordered = direction === `asc` ? rows : [...rows].reverse() - const { source, requests } = createConformingOrderedSource( - `pagination-transfer-${collectionSequence++}`, - ordered, - ) - const live = createLiveQueryCollection((q) => { - const orderedQuery = q - .from({ row: source }) - .orderBy(({ row }) => row.rank, direction) - return ( - explicitPublicKeyOrder - ? orderedQuery.orderBy(({ row }) => row.id, direction) - : orderedQuery - ).limit(pageSize) - }) - try { - await live.preload() - for (let page = 0; page < pageCount; page++) { - const offset = mode === `pages` ? page * pageSize : 0 - const limit = mode === `pages` ? pageSize : (page + 1) * pageSize - if (page > 0) await live.utils.setWindow({ offset, limit }) - expect([...live.values()].map(projectPageRow)).toEqual( - ordered.slice(offset, offset + limit), - ) - } - // Count every provider-returned row, including duplicates and tie - // probes. Request counts alone cannot detect repeated growing prefixes. - const returnedRows = requests.reduce( - (total, request) => - total + rowsForLoadSubset(ordered, request).length, - 0, - ) - expect(returnedRows).toBeLessThanOrEqual(rows.length + 2 * pageCount) - expect( - requests - .filter(({ orderBy }) => orderBy !== undefined) - .every(({ limit }) => limit !== undefined), - ).toBe(true) - expect(requests.some((request) => request.cursor !== undefined)).toBe( - true, - ) - } finally { - await live.cleanup() - await source.cleanup() - } - }, ) - // A pending optimistic mutation changes the local ordered relation while - // the provider still ranks its original rows. The reference relation below - // applies those pending changes to a plain remote array. After the repair - // acquisition, both public window rows and the provider page that supplied - // them must agree with that relation. One and two changed rows distinguish - // a general bound from a one-row exception. - it.each( - ([`delete`, `move-after`] as const).flatMap((mutation) => - [1, 2].map((count) => ({ mutation, count })), - ), - )( - `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 = [] - const heldMutation = createDeferred() - let sourceOps!: Parameters< - SyncConfig<(typeof remote)[number], number>[`sync`] - >[0] - const source = createCollection({ - id: `pagination-optimistic-prefix-${collectionSequence++}`, - getKey: (row: (typeof remote)[number]) => row.id, - syncMode: `on-demand`, - onDelete: async () => heldMutation.promise, - onUpdate: async () => heldMutation.promise, - sync: { - sync: (operations) => { - sourceOps = operations - const { begin, write, commit, markReady } = operations - markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - const orderedRemote = [...remote].sort( - (left, right) => left.rank - right.rank, - ) - const fresh = rowsForLoadSubset(orderedRemote, options).filter( - ({ id }) => !delivered.has(id), - ) - if (fresh.length > 0) { - begin() - for (const row of fresh) { - delivered.add(row.id) - write({ type: `insert`, value: row }) - } - return commit(options.signal) - } - return true - }, - } - }, - }, - }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(count), - ) - try { - await live.preload() - expect([...live.values()].map(({ id }) => id)).toEqual( - remote.slice(0, count).map(({ id }) => id), + async function runLinearRowVolume({ + direction, + mode, + explicitPublicKeyOrder, + pageSize, + }: (typeof linearRowVolumeCases)[number]): Promise { + const pageCount = 10 + const rows = Array.from({ length: pageCount * pageSize }, (_, rank) => ({ + id: rank + 1, + rank, + })) + const ordered = direction === `asc` ? rows : [...rows].reverse() + const { source, requests } = createConformingOrderedSource( + `pagination-transfer-${collectionSequence++}`, + ordered, + ) + const live = createLiveQueryCollection((q) => { + const orderedQuery = q + .from({ row: source }) + .orderBy(({ row }) => row.rank, direction) + return ( + explicitPublicKeyOrder + ? orderedQuery.orderBy(({ row }) => row.id, direction) + : orderedQuery + ).limit(pageSize) + }) + try { + await live.preload() + for (let page = 0; page < pageCount; page++) { + const offset = mode === `pages` ? page * pageSize : 0 + const limit = mode === `pages` ? pageSize : (page + 1) * pageSize + if (page > 0) await live.utils.setWindow({ offset, limit }) + expect([...live.values()].map(projectPageRow)).toEqual( + ordered.slice(offset, offset + limit), ) - const affected = remote.slice(0, count) - const transactions = affected.map(({ id }) => { - if (mutation === `delete`) return source.delete(id) - else - return source.update(id, (draft) => { - draft.rank = 100 + id - }) - }) - await flushPromises() - - const expected = remote - .map((row) => - row.id <= count && mutation === `move-after` - ? { ...row, rank: 100 + row.id } - : row, - ) - .filter(({ id }) => mutation !== `delete` || id > count) - .sort((left, right) => left.rank - right.rank) - .slice(0, count) - .map(({ id }) => id) - expect( - requests.some((request) => - rowsForLoadSubset(remote, request).some( - ({ id }) => id === expected.at(-1), - ), - ), - ).toBe(true) - - // Accept the local mutations in one authoritative source transaction. - // Provider responses submitted while they were pending may finish - // afterward; the final public window must still match the model. - sourceOps.begin() - for (const row of affected) { - if (mutation === `delete`) { - remote.splice( - remote.findIndex(({ id }) => id === row.id), - 1, - ) - sourceOps.write({ type: `delete`, value: row }) - } else { - const updated = { ...row, rank: 100 + row.id } - remote[remote.findIndex(({ id }) => id === row.id)] = updated - sourceOps.write({ type: `update`, value: updated }) - } - } - const receipt = sourceOps.commit() - heldMutation.resolve() - await Promise.all(transactions.map((tx) => tx.when(`settled`))) - await receipt - await flushPromises() - expect([...live.values()].map(({ id }) => id)).toEqual(expected) - } finally { - heldMutation.resolve() - await live.cleanup() - await source.cleanup() } - }, - ) + // Count every provider-returned row, including duplicates and tie + // probes. Request counts alone cannot detect repeated growing prefixes. + const returnedRows = requests.reduce( + (total, request) => total + rowsForLoadSubset(ordered, request).length, + 0, + ) + expect(returnedRows).toBeLessThanOrEqual(rows.length + 2 * pageCount) + expect( + requests + .filter(({ orderBy }) => orderBy !== undefined) + .every(({ limit }) => limit !== undefined), + ).toBe(true) + expect(requests.some((request) => request.cursor !== undefined)).toBe( + true, + ) + } finally { + await live.cleanup() + await source.cleanup() + } + } + + oracleProperty({ + id: `pagination.linear-row-volume`, + law: namedHistoriesLaw( + `fetches linear row volume while traversing settled pages`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: linearRowVolumeCases, + label: (row) => + `fetches linear row volume while traversing settled pages: ${JSON.stringify(row)}`, + ...orderedComparison(runLinearRowVolume), + check: (_history, _observations, verdict) => verdict, + }) - // An optimistic insert or rank move changes the local relation without - // establishing a provider prefix. The remote array below is the independent - // source order. A finite request may use cursor expressions or a provider - // offset, but neither may skip a remote rank because a local row filled the - // window. Widening while the mutation is held tests both the continuation - // request and the public window after the authoritative source change. - it.each( - ([`insert-before`, `move-after`] as const).flatMap((mutation) => - [1, 2].flatMap((count) => - (mutation === `insert-before` ? [0, 1] : [count]).flatMap( - (initialLimit) => - ([`cursor`, `offset`] as const).map((transport) => ({ - mutation, - count, - initialLimit, - transport, - })), - ), - ), - ), - )( - `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() - const requests: Array = [] - const heldMutation = createDeferred() - let sourceOps!: Parameters< - SyncConfig<(typeof remote)[number], number>[`sync`] - >[0] - const source = createCollection({ - id: `pagination-optimistic-continuation-${collectionSequence++}`, - getKey: (row: (typeof remote)[number]) => row.id, - syncMode: `on-demand`, - onInsert: async () => heldMutation.promise, - onUpdate: async () => heldMutation.promise, - sync: { - sync: (operations) => { - sourceOps = operations - const { begin, write, commit, markReady } = operations - markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - requests.push(options) - const orderedRemote = [...remote].sort( - (left, right) => left.rank - right.rank, - ) - const page = rowsForLoadSubset( - orderedRemote, - transport === `offset` - ? { ...options, cursor: undefined } - : options, - ) - const fresh = page.filter(({ id }) => !delivered.has(id)) - if (fresh.length === 0) return true + const optimisticPrefixRefillCases = ( + [`delete`, `move-after`] as const + ).flatMap((mutation) => [1, 2].map((count) => ({ mutation, count }))) + + async function runOptimisticPrefixRefill({ + mutation, + count, + }: (typeof optimisticPrefixRefillCases)[number]): Promise { + const remote = [1, 2, 3, 4, 5].map((id) => ({ id, rank: id })) + const delivered = new Set() + const requests: Array = [] + const heldMutation = createDeferred() + let sourceOps!: Parameters< + SyncConfig<(typeof remote)[number], number>[`sync`] + >[0] + const source = createCollection({ + id: `pagination-optimistic-prefix-${collectionSequence++}`, + getKey: (row: (typeof remote)[number]) => row.id, + syncMode: `on-demand`, + onDelete: async () => heldMutation.promise, + onUpdate: async () => heldMutation.promise, + sync: { + sync: (operations) => { + sourceOps = operations + const { begin, write, commit, markReady } = operations + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + const orderedRemote = [...remote].sort( + (left, right) => left.rank - right.rank, + ) + const fresh = rowsForLoadSubset(orderedRemote, options).filter( + ({ id }) => !delivered.has(id), + ) + if (fresh.length > 0) { begin() for (const row of fresh) { delivered.add(row.id) write({ type: `insert`, value: row }) } return commit(options.signal) - }, - } - }, + } + return true + }, + } }, - }) - source.createIndex((row) => row.rank, { indexType: BTreeIndex }) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .limit(initialLimit), + }, + }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(count), + ) + try { + await live.preload() + expect([...live.values()].map(({ id }) => id)).toEqual( + remote.slice(0, count).map(({ id }) => id), ) - try { - await live.preload() - expect([...live.values()].map(({ id }) => id)).toEqual( - remote.slice(0, initialLimit).map(({ id }) => id), - ) + const affected = remote.slice(0, count) + const transactions = affected.map(({ id }) => { + if (mutation === `delete`) return source.delete(id) + else + return source.update(id, (draft) => { + draft.rank = 100 + id + }) + }) + await flushPromises() - const changed = Array.from({ length: count }, (_, index) => - mutation === `insert-before` - ? { id: -index, rank: -index } - : { id: index + 1, rank: 100 + index }, - ) - const transactions = changed.map((row) => - mutation === `insert-before` - ? source.insert(row) - : source.update(row.id, (draft) => { - draft.rank = row.rank - }), + const expected = remote + .map((row) => + row.id <= count && mutation === `move-after` + ? { ...row, rank: 100 + row.id } + : row, ) - await flushPromises() - const priorRequestCount = requests.length - const windowResult = live.utils.setWindow({ - offset: 0, - limit: finalLimit, - }) - if (windowResult instanceof Promise) { - void windowResult.catch(() => undefined) - } - await flushPromises() + .filter(({ id }) => mutation !== `delete` || id > count) + .sort((left, right) => left.rank - right.rank) + .slice(0, count) + .map(({ id }) => id) + expect( + requests.some((request) => + rowsForLoadSubset(remote, request).some( + ({ id }) => id === expected.at(-1), + ), + ), + ).toBe(true) - const expectedRemote = - mutation === `insert-before` - ? [...remote, ...changed] - : remote.map((row) => - row.id <= count ? { ...row, rank: 100 + row.id - 1 } : row, - ) - const expected = expectedRemote - .sort((left, right) => left.rank - right.rank) - .slice(0, finalLimit) - .map(({ id }) => id) - const lastUnchangedSourceId = expected - .filter((id) => !changed.some((row) => row.id === id)) - .at(-1)! - if (mutation === `insert-before` || count === 1) { - expect( - delivered.has(lastUnchangedSourceId) || - requests - .slice(priorRequestCount) - .some((request) => - rowsForLoadSubset( - remote, - transport === `offset` - ? { ...request, cursor: undefined } - : request, - ).some(({ id }) => id === lastUnchangedSourceId), - ), - ).toBe(true) - } - sourceOps.begin() - for (const row of changed) { - if (mutation === `insert-before`) { - remote.push(row) - sourceOps.write({ type: `insert`, value: row }) - } else { - remote[remote.findIndex(({ id }) => id === row.id)] = row - sourceOps.write({ type: `update`, value: row }) - } + // Accept the local mutations in one authoritative source transaction. + // Provider responses submitted while they were pending may finish + // afterward; the final public window must still match the model. + sourceOps.begin() + for (const row of affected) { + if (mutation === `delete`) { + remote.splice( + remote.findIndex(({ id }) => id === row.id), + 1, + ) + sourceOps.write({ type: `delete`, value: row }) + } else { + const updated = { ...row, rank: 100 + row.id } + remote[remote.findIndex(({ id }) => id === row.id)] = updated + sourceOps.write({ type: `update`, value: updated }) } - const receipt = sourceOps.commit() - heldMutation.resolve() - await Promise.all(transactions.map((tx) => tx.when(`settled`))) - await receipt - await windowResult - await flushPromises() - - // The public view is checked after source acceptance, so a missing - // provider row cannot hide behind the local overlay. - expect([...live.values()].map(({ id }) => id)).toEqual(expected) - } finally { - heldMutation.resolve() - await live.cleanup() - await source.cleanup() } - }, - ) + const receipt = sourceOps.commit() + heldMutation.resolve() + await Promise.all(transactions.map((tx) => tx.when(`settled`))) + await receipt + await flushPromises() + expect([...live.values()].map(({ id }) => id)).toEqual(expected) + } finally { + heldMutation.resolve() + await live.cleanup() + await source.cleanup() + } + } - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - [false, true].flatMap((explicitPublicKeyOrder) => - [false, true].flatMap((tied) => - [1, 2].map((limit) => ({ - direction, - explicitPublicKeyOrder, - tied, - limit, - })), - ), - ), + oracleProperty({ + id: `pagination.optimistic-prefix-refill`, + law: namedHistoriesLaw( + `refills an ordered window past pending optimistic rows`, ), - )( - `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, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: sign * (tied ? 1 : 2), keep: false }, - { type: `window`, offset: 1, limit }, - { type: `put`, id: 1, rank: 0, keep: false }, - ], - ranks: [0, sign], - keeps: [false, false], - explicitPublicKeyOrder, - includeFilter: false, - reverseInsertion: false, - }) - }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: optimisticPrefixRefillCases, + label: (row) => + `refills an ordered window past ${String(row.count)} pending optimistic ${String(row.mutation)} row(s)`, + ...orderedComparison(runOptimisticPrefixRefill), + check: (_history, _observations, verdict) => verdict, + }) - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - ([`before-response`, `after-response`] as const).flatMap((timing) => - [0.5, 100].flatMap((rank) => + const optimisticProviderCoverageCases = ( + [`insert-before`, `move-after`] as const + ).flatMap((mutation) => + [1, 2].flatMap((count) => + (mutation === `insert-before` ? [0, 1] : [count]).flatMap( + (initialLimit) => ([`cursor`, `offset`] as const).map((transport) => ({ - direction, - timing, - rank, + mutation, + count, + initialLimit, transport, })), - ), ), ), - )( - `keeps live observations separate from a settled acquisition: %j`, - async ({ direction, timing, rank, transport }) => { - oracleRoute(`case`) - const sign = direction === `asc` ? 1 : -1 - await runPendingMutationScenario( - { - ranks: [0, sign, 2 * sign, 3 * sign], - direction, - limit: 1, - mutation: { type: `insert`, row: { id: 9, rank: sign * rank } }, - responseOutcome: `resolve`, - }, - timing, - 3, - false, - transport, - ) - }, ) - it(`discovered trace: a rank update must refill a top-1 window`, async () => { - oracleRoute(`fixed history`) - const scenario: PaginationStateScenario = { - ranks: [0, 0], - direction: `asc`, - initialWindow: { offset: 0, limit: 1 }, - actions: [{ type: `put`, id: 1, rank: 1 }], - } - await runPaginationStateScenario(scenario) - }) - - 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`, - explicitPublicKeyOrder: false, - includeFilter: false, - reverseInsertion: false, - initialWindow: { offset: 0, limit: 4 }, - actions: [{ type: `put`, id: 1, rank: -2, keep: false }], + async function runOptimisticProviderCoverage({ + mutation, + count, + initialLimit, + transport, + }: (typeof optimisticProviderCoverageCases)[number]): Promise { + 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() + const requests: Array = [] + const heldMutation = createDeferred() + let sourceOps!: Parameters< + SyncConfig<(typeof remote)[number], number>[`sync`] + >[0] + const source = createCollection({ + id: `pagination-optimistic-continuation-${collectionSequence++}`, + getKey: (row: (typeof remote)[number]) => row.id, + syncMode: `on-demand`, + onInsert: async () => heldMutation.promise, + onUpdate: async () => heldMutation.promise, + sync: { + sync: (operations) => { + sourceOps = operations + const { begin, write, commit, markReady } = operations + markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + const orderedRemote = [...remote].sort( + (left, right) => left.rank - right.rank, + ) + const page = rowsForLoadSubset( + orderedRemote, + transport === `offset` + ? { ...options, cursor: undefined } + : options, + ) + const fresh = page.filter(({ id }) => !delivered.has(id)) + if (fresh.length === 0) return true + begin() + for (const row of fresh) { + delivered.add(row.id) + write({ type: `insert`, value: row }) + } + return commit(options.signal) + }, + } + }, + }, }) - }) + source.createIndex((row) => row.rank, { indexType: BTreeIndex }) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(initialLimit), + ) + try { + await live.preload() + expect([...live.values()].map(({ id }) => id)).toEqual( + remote.slice(0, initialLimit).map(({ id }) => id), + ) - 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 }], - ]) - const recovery = createDeferred() - const recoveryError = new Error(`background recovery failed`) - const loads: Array = [] - const delivered = new Set() - let recovering = false - let begin!: () => void - let write!: (message: { - type: `insert` | `update` - value: PageRow - }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-background-prefix-recovery-${collectionSequence++}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - startSync: true, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - return { - loadSubset: (options: LoadSubsetOptions) => { - loads.push(options) - const isFullSource = - options.where === undefined && - options.limit === undefined && - options.cursor === undefined - const isOrderedRepair = - options.where === undefined && - options.orderBy !== undefined && - options.limit === 1 && - options.cursor === undefined - const applyRows = () => { - const rows = rowsForLoadSubset( - [...authoritativeRows.values()].sort( - (left, right) => - left.rank - right.rank || left.id - right.id, - ), - options, - ) - begin() - for (const row of rows) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - commit() - } - if (!recovering || (!isFullSource && !isOrderedRepair)) { - applyRows() - return true - } - return recovery.promise.then(() => { - if (settlement === `reject`) throw recoveryError - applyRows() - }) - }, - } - }, - }, - }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `asc`) - .limit(1) - .select(({ row }) => ({ - id: row.id, - rank: row.rank, - keep: row.keep, - })), + const changed = Array.from({ length: count }, (_, index) => + mutation === `insert-before` + ? { id: -index, rank: -index } + : { id: index + 1, rank: 100 + index }, ) - const publications: Array> = [] - const subscription = live.subscribeChanges( - () => publications.push(live.toArray.map(projectPageRow)), - { includeInitialState: false }, + const transactions = changed.map((row) => + mutation === `insert-before` + ? source.insert(row) + : source.update(row.id, (draft) => { + draft.rank = row.rank + }), ) + await flushPromises() + const priorRequestCount = requests.length + const windowResult = live.utils.setWindow({ + offset: 0, + limit: finalLimit, + }) + if (windowResult instanceof Promise) { + void windowResult.catch(() => undefined) + } + await flushPromises() - try { - await live.preload() - expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) - publications.length = 0 - const loadsBeforeMutation = loads.length - - recovering = true - const moved = { id: 1, rank: 10, keep: true } - authoritativeRows.set(1, moved) - begin() - write({ type: `update`, value: { ...moved } }) - commit() - await flushPromises() - - const recoveryLoads = loads.slice(loadsBeforeMutation) - expect(recoveryLoads).toHaveLength(1) - expect(recoveryLoads[0]?.where).toBeUndefined() - expect(recoveryLoads[0]?.orderBy).toHaveLength(1) - expect(recoveryLoads[0]?.limit).toBe(1) - expect(recoveryLoads[0]?.cursor).toBeUndefined() - expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) - expect(publications).toEqual([]) - - recovery.resolve() - await flushPromises() - - if (settlement === `resolve`) { - expect(live.toArray.map(projectPageRow)).toEqual([{ id: 2, rank: 1 }]) - expect(publications).toEqual([[{ id: 2, rank: 1 }]]) - expect(live.utils.lastSubsetError).toBeUndefined() + const expectedRemote = + mutation === `insert-before` + ? [...remote, ...changed] + : remote.map((row) => + row.id <= count ? { ...row, rank: 100 + row.id - 1 } : row, + ) + const expected = expectedRemote + .sort((left, right) => left.rank - right.rank) + .slice(0, finalLimit) + .map(({ id }) => id) + const lastUnchangedSourceId = expected + .filter((id) => !changed.some((row) => row.id === id)) + .at(-1)! + if (mutation === `insert-before` || count === 1) { + expect( + delivered.has(lastUnchangedSourceId) || + requests + .slice(priorRequestCount) + .some((request) => + rowsForLoadSubset( + remote, + transport === `offset` + ? { ...request, cursor: undefined } + : request, + ).some(({ id }) => id === lastUnchangedSourceId), + ), + ).toBe(true) + } + sourceOps.begin() + for (const row of changed) { + if (mutation === `insert-before`) { + remote.push(row) + sourceOps.write({ type: `insert`, value: row }) } else { - expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) - expect(publications).toEqual([]) - expect(live.utils.lastSubsetError).toBe(recoveryError) + remote[remote.findIndex(({ id }) => id === row.id)] = row + sourceOps.write({ type: `update`, value: row }) } - } finally { - recovery.resolve() - subscription.unsubscribe() - await cleanupAll(live, source) } - }, - ) - - it.each([ - [`top-one`, [0, 0], { offset: 0, limit: 1 }, 1, 1], - [`offset`, [0, 0, 1], { offset: 1, limit: 1 }, 2, 2], - ] 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`, - explicitPublicKeyOrder: false, - includeFilter: false, - reverseInsertion: false, - initialWindow, - actions: [{ type: `put`, id, rank, keep: false }], - }) - }, - ) - - it(`opens an implicit tie window from zero at the lowest public key`, async () => { - oracleRoute(`fixed history`) - await runPaginationStateScenario({ - ranks: [0], - direction: `asc`, - explicitPublicKeyOrder: false, - includeFilter: false, - reverseInsertion: false, - initialWindow: { offset: 0, limit: 0 }, - actions: [ - { type: `put`, id: 2, rank: 0, keep: false }, - { type: `window`, offset: 0, limit: 1 }, - { type: `delete`, id: 1 }, - ], - }) - }) - - 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`, - initialWindow: { offset: 0, limit: 3 }, - actions: [ - { type: `put`, id: 5, rank: 10 }, - { type: `delete`, id: 2 }, - ], - } - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 2, rank: 1 }, - { type: `delete`, id: 1 }, - ], - } - - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 2, rank: 1 }, - { type: `put`, id: 1, rank: 2 }, - ], - } - - await runPaginationStateScenario(scenario) - }) - - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 2, rank: -1 }, - { type: `window`, offset: 0, limit: 3 }, - ], - } - - await runPaginationStateScenario(scenario) - }) + const receipt = sourceOps.commit() + heldMutation.resolve() + await Promise.all(transactions.map((tx) => tx.when(`settled`))) + await receipt + await windowResult + await flushPromises() - 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`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: 0 }, - { type: `window`, offset: 0, limit: 4 }, - ], + // The public view is checked after source acceptance, so a missing + // provider row cannot hide behind the local overlay. + expect([...live.values()].map(({ id }) => id)).toEqual(expected) + } finally { + heldMutation.resolve() + await live.cleanup() + await source.cleanup() } + } - await runPaginationStateScenario(scenario) - }) - - it(`ignores an out-of-window insert when widening a tied window`, async () => { - oracleRoute(`fixed history`) - const scenario: PaginationStateScenario = { - ranks: [0, 0], - direction: `asc`, - initialWindow: { offset: 0, limit: 1 }, - actions: [ - { type: `put`, id: 3, rank: 0 }, - { type: `window`, offset: 0, limit: 2 }, - ], - } - await runPaginationStateScenario(scenario) + oracleProperty({ + id: `pagination.optimistic-provider-coverage`, + law: namedHistoriesLaw( + `does not count optimistic rows as provider coverage after a window`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: optimisticProviderCoverageCases, + label: (row) => + `does not count ${String(row.count)} optimistic ${String(row.mutation)} row(s) as ${String(row.transport)} provider coverage after a ${String(row.initialLimit)}-row window`, + ...orderedComparison(runOptimisticProviderCoverage), + check: (_history, _observations, verdict) => verdict, }) - it(`expands a multi-column boundary before choosing top-K`, async () => { - oracleRoute(`fixed history`) - await expectMultiOrderBoundaryMatches() - }) + // A copied pre-cleanup trace must not hide a real provider entry. The + // premise is the clean run that the old calibration made first, with its + // assertion that the provider was asked at all. + const postCleanupCalibrationHistories = [ + { + name: `real provider entry (calibration premise)`, + options: { + providerRows: [ + { id: 1, rank: 1, label: `first` }, + { id: 2, rank: 2, label: `second` }, + ], + order: { kind: `rank`, direction: `asc`, nulls: `first` } as const, + limit: 1, + expectedIds: [1], + }, + }, + ] - 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` }, - { id: 2, rank: 2, label: `second` }, - ], - order: { kind: `rank`, direction: `asc`, nulls: `first` } as const, - limit: 1, - expectedIds: [1], - } + async function runPostCleanupPremise( + history: (typeof postCleanupCalibrationHistories)[number], + ): Promise { expect( - (await runAdversarialOrderedProviderScenario(scenario)).length, + (await runAdversarialOrderedProviderScenario(history.options)).length, ).toBeGreaterThan(0) - await expect( - runAdversarialOrderedProviderScenario(scenario, `post-cleanup-request`), - ).rejects.toMatchObject({ name: `AssertionError` }) + } + + oracleProperty({ + id: `pagination.post-cleanup-calibration`, + law: namedHistoriesLaw( + `rejects a real provider entry hidden by a copied pre-cleanup trace`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: postCleanupCalibrationHistories, + label: ({ name }) => name, + ...orderedComparison(runPostCleanupPremise), + check: (_history, _observations, verdict) => verdict, + calibrations: [ + { + name: `rejects a real provider entry hidden by a copied pre-cleanup trace`, + law: `calibration post-cleanup-request`, + driver: (history, observe) => + observeComparison(history, observe, async () => { + try { + await runAdversarialOrderedProviderScenario( + history.options, + `post-cleanup-request`, + ) + } catch (error) { + // Exactly the old matcher, toMatchObject({ name: 'AssertionError' }). + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`calibration post-cleanup-request`, error) + throw error + } + }), + }, + ], }) - it(`expands a provider tie before applying the public-key tie-breaker`, async () => { - oracleRoute(`fixed history`) + async function runProviderTieExpansion(): Promise { const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ { id: 2, rank: 0, label: `second` }, @@ -6488,10 +5254,30 @@ describe(`pagination recomputation oracle`, () => { expect(loads).toHaveLength(2) expect(loads[1]?.where).toBeDefined() expect(loads[1]?.cursor).toBeUndefined() + } + + oracleProperty({ + id: `pagination.provider-tie-expansion`, + law: namedHistoriesLaw( + `expands a provider tie before applying the public-key tie-breaker`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `expands a provider tie before applying the public-key tie-breaker`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runProviderTieExpansion), + check: (_history, _observations, verdict) => verdict, }) - it(`does not derive an ordered boundary from another demand's local row`, async () => { - oracleRoute(`fixed history`) + async function runForeignLocalBoundary(): Promise { const unrelated = { id: 100, rank: 100, label: `unrelated` } const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ @@ -6507,10 +5293,30 @@ describe(`pagination recomputation oracle`, () => { expect(loads[0]?.offset).toBe(0) expect(loads[0]?.cursor).toBeUndefined() + } + + oracleProperty({ + id: `pagination.foreign-local-boundary`, + law: namedHistoriesLaw( + `does not derive an ordered boundary from another demand's local row`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `does not derive an ordered boundary from another demand's local row`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runForeignLocalBoundary), + check: (_history, _observations, verdict) => verdict, }) - it(`refines an initial locale window without trusting provider collation`, async () => { - oracleRoute(`fixed history`) + async function runLocaleInitialWindow(): Promise { const loads = await runAdversarialOrderedProviderScenario({ // Lexical provider order disagrees with locale numeric order. providerRows: [ @@ -6527,9 +5333,30 @@ describe(`pagination recomputation oracle`, () => { expect(loads[1]?.limit).toBeUndefined() expect(loads[1]?.offset).toBeUndefined() expect(loads[1]?.cursor).toBeUndefined() + } + + oracleProperty({ + id: `pagination.locale-initial-window`, + law: namedHistoriesLaw( + `refines an initial locale window without trusting provider collation`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `refines an initial locale window without trusting provider collation`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runLocaleInitialWindow), + check: (_history, _observations, verdict) => verdict, }) - it.each([ + const customCollationCells = [ { cell: `C-01 inherited ascending scan`, direction: `asc`, @@ -6587,97 +5414,45 @@ describe(`pagination recomputation oracle`, () => { bounded: false, outputDefault: `lexical`, }, - ] as const)( - `uses one filtered full-source acquisition for custom collation ($cell)`, - async ({ direction, autoIndex, sourceDefault, bounded, outputDefault }) => { - oracleRoute(`case`) - await runCustomCollationScenario({ - direction, - autoIndex, - sourceDefault, - bounded, - outputDefault, - }) - }, - ) + ] as const - 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({ - id: `pagination-custom-collation-scope-${collectionSequence++}`, - initialData: [{ id: 1, groupId: 1 }] satisfies Array, - getKey: (row: ScopeRow) => row.id, - defaultStringCollation: { - stringSort: `custom`, - compare: compareCustomCollationStrings, - }, - }), - ) - const loads: Array = [] - const delivered = new Set() - const source = createCollection({ - id: `pagination-custom-collation-joined-${collectionSequence++}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - defaultStringCollation: { stringSort: `lexical` }, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: (loadOptions: LoadSubsetOptions) => { - loads.push(loadOptions) - const matchingRows = loadOptions.where - ? customCollationProviderRows.filter( - (row) => - evaluateReferenceExpression(loadOptions.where!, row) === - true, - ) - : customCollationProviderRows - begin() - for (const row of matchingRows) { - if (delivered.has(row.id)) continue - delivered.add(row.id) - write({ type: `insert`, value: { ...row } }) - } - const receipt = commit() - return receipt === true ? Promise.resolve() : receipt - }, - } - }, - }, + async function runCustomCollationCell({ + direction, + autoIndex, + sourceDefault, + bounded, + outputDefault, + }: (typeof customCollationCells)[number] & { + outputDefault?: `lexical` + }): Promise { + await runCustomCollationScenario({ + direction, + autoIndex, + sourceDefault, + bounded, + outputDefault, }) - const live = createLiveQueryCollection((query) => - query - .from({ scope }) - .innerJoin({ row: source }, ({ scope: outer, row }) => - eq(outer.groupId, row.groupId), - ) - .where(({ row }) => eq(row.keep, true)) - .orderBy(({ row }) => row.label, { - direction: `asc`, - nulls: `first`, - }) - .select(({ row }) => ({ id: row.id, label: row.label })), - ) + } - try { - await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3, 4, 1]) - expect(loads).toHaveLength(1) - expect(loads[0]?.where).toBeDefined() - expect(loads[0]?.orderBy).toBeUndefined() - expect(loads[0]?.limit).toBeUndefined() - expect(loads[0]?.offset).toBeUndefined() - expect(loads[0]?.cursor).toBeUndefined() - } finally { - await cleanupAll(live, source, scope) - } + oracleProperty({ + id: `pagination.custom-collation`, + law: namedHistoriesLaw( + `uses one filtered full-source acquisition for custom collation`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: customCollationCells, + label: (row) => + `uses one filtered full-source acquisition for custom collation (${String(row.cell)})`, + ...orderedComparison(runCustomCollationCell), + check: (_history, _observations, verdict) => verdict, }) - it(`inherits collection locale options through scan and auto-index ordering`, async () => { - oracleRoute(`fixed history`) + async function runInheritedLocale(): Promise { const defaultOnly = createCollection<{ id: number }>({ getKey: (row) => row.id, defaultStringCollation: { stringSort: `locale` }, @@ -6728,10 +5503,30 @@ describe(`pagination recomputation oracle`, () => { expect(loads[1]?.offset).toBeUndefined() expect(loads[1]?.cursor).toBeUndefined() } + } + + oracleProperty({ + id: `pagination.inherited-locale`, + law: namedHistoriesLaw( + `inherits collection locale options through scan and auto-index ordering`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { + name: `inherits collection locale options through scan and auto-index ordering`, + }, + ], + label: ({ name }) => name, + ...orderedComparison(runInheritedLocale), + check: (_history, _observations, verdict) => verdict, }) - it(`refines an initial reference-ordered window locally`, async () => { - oracleRoute(`fixed history`) + async function runReferenceInitialWindow(): Promise { const first = { value: `first` } const second = { value: `second` } // Fix their runtime reference order before the provider returns the @@ -6752,115 +5547,71 @@ describe(`pagination recomputation oracle`, () => { expect(loads).toHaveLength(2) expect(loads[1]?.limit).toBeUndefined() expect(loads[1]?.offset).toBeUndefined() - }) + } - it.each( - ([`asc`, `desc`] as const).flatMap((direction) => - ([`first`, `last`] as const).map((nulls) => ({ direction, nulls })), + oracleProperty({ + id: `pagination.reference-initial-window`, + law: namedHistoriesLaw( + `refines an initial reference-ordered window locally`, ), - )( - `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: [ - { id: 2, rank: invalid, label: `second` }, - { id: 1, rank: invalid, label: `first` }, - ], - order: { kind: `reference`, direction, nulls }, - limit: 1, - expectedIds: [1], - useOffsetWhenAvailable: true, - }) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { name: `refines an initial reference-ordered window locally` }, + ], + label: ({ name }) => name, + ...orderedComparison(runReferenceInitialWindow), + check: (_history, _observations, verdict) => verdict, + }) - expect(loads).toHaveLength(2) - expect(loads[1]?.limit).toBeUndefined() - expect(loads[1]?.offset).toBeUndefined() - }, + const invalidDateTieCases = ([`asc`, `desc`] as const).flatMap((direction) => + ([`first`, `last`] as const).map((nulls) => ({ direction, nulls })), ) - 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 }, - { id: 2, rank: 0 }, - ] - const loads: Array = [] - const loaded = new Set() - let begin!: () => void - let write!: (message: { type: `insert`; value: PageRow }) => void - let commit!: () => void - const source = createCollection({ - id: `pagination-reversed-index-ties-${collectionSequence++}`, - getKey: (row: PageRow) => row.id, - syncMode: `on-demand`, - startSync: true, - sync: { - sync: (operations) => { - begin = operations.begin - write = operations.write - commit = operations.commit - operations.markReady() - return { - loadSubset: (options) => { - loads.push(options) - begin() - for (const row of rowsForLoadSubset(rows, options)) { - if (loaded.has(row.id)) continue - loaded.add(row.id) - write({ type: `insert`, value: row }) - } - commit() - return true - }, - } - }, - }, - }) - source.createIndex((row) => row.rank, { - indexType: BTreeIndex, - options: { - compareOptions: { - direction: `asc`, - nulls: `first`, - stringSort: `locale`, - }, - }, + async function runInvalidDateTies({ + direction, + nulls, + }: (typeof invalidDateTieCases)[number]): Promise { + const invalid = new Date(Number.NaN) + const loads = await runAdversarialOrderedProviderScenario({ + providerRows: [ + { id: 2, rank: invalid, label: `second` }, + { id: 1, rank: invalid, label: `first` }, + ], + order: { kind: `reference`, direction, nulls }, + limit: 1, + expectedIds: [1], + useOffsetWhenAvailable: true, }) - const live = createLiveQueryCollection((query) => - query - .from({ row: source }) - .orderBy(({ row }) => row.rank, `desc`) - .limit(2), - ) - try { - await live.preload() - expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 1]) - expect(loads.length).toBeGreaterThan(0) - expect( - loads.every( - ({ limit, where }) => limit !== undefined || where !== undefined, - ), - JSON.stringify( - loads.map(({ limit, offset, cursor, orderBy, where }) => ({ - limit, - offset, - cursor: cursor !== undefined, - orderBy: orderBy !== undefined, - where: where !== undefined, - })), - ), - ).toBe(true) - } finally { - await cleanupAll(live, source) - } + expect(loads).toHaveLength(2) + expect(loads[1]?.limit).toBeUndefined() + expect(loads[1]?.offset).toBeUndefined() + } + + oracleProperty({ + id: `pagination.invalid-date-ties`, + law: namedHistoriesLaw( + `refines invalid Date ties with an unbounded local-order request`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: invalidDateTieCases, + label: (row) => + `refines invalid Date ties with an unbounded local-order request (${String(row.direction)}, nulls ${String(row.nulls)})`, + ...orderedComparison(runInvalidDateTies), + check: (_history, _observations, verdict) => verdict, }) - it(`stabilizes an on-demand window with a NaN public-key tie`, async () => { - oracleRoute(`fixed history`) + async function runNanKeyTie(): Promise { const loads = await runAdversarialOrderedProviderScenario({ providerRows: [ { id: 1, rank: 0, label: `finite` }, @@ -6872,34 +5623,73 @@ describe(`pagination recomputation oracle`, () => { }) expect(loads).toHaveLength(2) + } + + oracleProperty({ + id: `pagination.nan-key-tie`, + law: namedHistoriesLaw( + `stabilizes an on-demand window with a NaN public-key tie`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: [ + { name: `stabilizes an on-demand window with a NaN public-key tie` }, + ], + label: ({ name }) => name, + ...orderedComparison(runNanKeyTie), + check: (_history, _observations, verdict) => verdict, }) - it.each([ + const nullPlacementCases = [ { direction: `asc`, nulls: `first`, expectedIds: [1, 2] }, { direction: `asc`, nulls: `last`, expectedIds: [2, 3] }, { direction: `desc`, nulls: `first`, expectedIds: [1, 3] }, { direction: `desc`, nulls: `last`, expectedIds: [3, 2] }, - ] 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` }, - { id: 3, rank: 1, label: `one` }, - ].sort( - (left, right) => - compareNullableNumber(left.rank, right.rank, { direction, nulls }) || - left.id - right.id, - ) - await runAdversarialOrderedProviderScenario({ - providerRows, - order: { kind: `rank`, direction, nulls }, - limit: 2, - expectedIds, - }) - }, - ) + ] as const + + async function runNullPlacementRefinement({ + direction, + nulls, + expectedIds, + }: (typeof nullPlacementCases)[number]): Promise { + const providerRows = [ + { id: 1, rank: null, label: `null` }, + { id: 2, rank: 0, label: `zero` }, + { id: 3, rank: 1, label: `one` }, + ].sort( + (left, right) => + compareNullableNumber(left.rank, right.rank, { direction, nulls }) || + left.id - right.id, + ) + await runAdversarialOrderedProviderScenario({ + providerRows, + order: { kind: `rank`, direction, nulls }, + limit: 2, + expectedIds, + }) + } + + oracleProperty({ + id: `pagination.null-placement-refinement`, + law: namedHistoriesLaw( + `keeps null placement and direction across source refinement`, + ), + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + 'src/utils/comparison.ts#makeComparator', + ], + histories: nullPlacementCases, + label: (row) => + `keeps null placement and ${String(row.direction)} across source refinement (${String(row.nulls)})`, + ...orderedComparison(runNullPlacementRefinement), + check: (_history, _observations, verdict) => verdict, + }) oracleProperty({ id: `pagination.async-cursor`, @@ -6924,12 +5714,4 @@ describe(`pagination recomputation oracle`, () => { }), check: (_history, _observations, verdict) => verdict, }) - - it.each([`forward`, `reverse`] as const)( - `keeps concurrent on-demand windows correct under %s completion`, - async (scenario) => { - oracleRoute(`case`) - await expectOnDemandWindowsAreCompletionOrderIndependent(scenario) - }, - ) }) diff --git a/packages/db/tests/query/pagination-recomputation.test.ts b/packages/db/tests/query/pagination-recomputation.test.ts new file mode 100644 index 0000000000..1fe87db972 --- /dev/null +++ b/packages/db/tests/query/pagination-recomputation.test.ts @@ -0,0 +1,1525 @@ +/** + * Ordinary examples of ordered-window behavior, moved unchanged from + * `pagination-oracle.property.test.ts`. Each compares production with values + * written in the test, through the controlled providers in + * `pagination-fixtures.ts`; none replays the oracle's model or comparison. + * The law that an ordered window equals independent recomputation, and its + * authority, are in that oracle file. + */ +import { describe, expect, it } from 'vitest' +import { BTreeIndex } from '../../src/index.js' +import { createCollection } from '../../src/collection/index.js' +import { createDeferred } from '../../src/deferred.js' +import { createLiveQueryCollection } from '../../src/query/live-query-collection.js' +import { eq } from '../../src/query/builder/functions.js' +import { evaluateReferenceExpression } from '../reference-expression-oracle.js' +import { TraceAssertionError } from '../trace-runner.js' +import { flushPromises, mockSyncCollectionOptions } from '../utils.js' +import { cleanupAll } from './pagination-support.js' +import { + compareCustomCollationStrings, + createConformingOrderedSource, + customCollationProviderRows, + projectPageRow, + rowsForLoadSubset, +} from './pagination-fixtures.js' +import type { PageRow } from './pagination-support.js' +import type { + CustomCollationRow, + LocaleCursorRow, + MultiOrderRow, + PendingCursorLoad, + PublicPageRow, +} from './pagination-fixtures.js' +import type { LoadSubsetOptions } from '../../src/types.js' + +// Collection ids only need to be unique within this file. +let collectionSequence = 0 + +async function expectOnDemandWindowsAreCompletionOrderIndependent( + deliveryOrder: `forward` | `reverse`, +): Promise { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + { id: 4, rank: 3 }, + ] + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const apply = (options: LoadSubsetOptions) => { + begin() + for (const row of rowsForLoadSubset(authoritativeRows, options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + } + const source = createCollection({ + id: `pagination-completion-order-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + begin() + write({ type: `insert`, value: { ...authoritativeRows[0]! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const createLive = (limit: number) => + createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(limit), + ) + const firstLive = createLive(2) + const secondLive = createLive(3) + + try { + const first = firstLive.preload() + const second = secondLive.preload() + expect(pending).toHaveLength(2) + + const indices = deliveryOrder === `forward` ? [0, 1] : [1, 0] + for (const index of indices) { + const request = pending[index]! + apply(request.options) + request.deferred.resolve() + await flushPromises() + } + for (let index = 2; index < pending.length; index++) { + const request = pending[index]! + apply(request.options) + request.deferred.resolve() + await flushPromises() + } + await first + await second + + expect(Array.from(firstLive.values(), ({ id }) => id)).toEqual([1, 2]) + expect(Array.from(secondLive.values(), ({ id }) => id)).toEqual([1, 2, 3]) + } finally { + for (const request of pending) request.deferred.resolve() + await cleanupAll(firstLive, secondLive, source) + } +} + +async function expectInflightRequestFillsNewWindow(): Promise { + const rows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + { id: 4, rank: 3 }, + ] + const pending: Array = [] + const deliveredIds = new Set([1]) + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-late-window-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + begin() + write({ type: `insert`, value: { ...rows[0]! } }) + commit() + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(2), + ) + + const settle = async (request: PendingCursorLoad) => { + begin() + for (const row of rowsForLoadSubset(rows, request.options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await Promise.resolve() + } + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + const setWindow = live.utils.setWindow({ offset: 2, limit: 2 }) + expect(setWindow).toBeInstanceOf(Promise) + await flushPromises() + expect(pending.length).toBeGreaterThan(0) + + for (let index = 0; index < pending.length; index++) { + if (index > rows.length * 2) { + throw new Error(`Ordered continuation exceeded its work bound`) + } + await settle(pending[index]!) + await flushPromises() + } + await preload + if (setWindow instanceof Promise) await setWindow + + try { + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 4]) + } catch (error) { + throw new TraceAssertionError(0, error) + } + } finally { + for (const request of pending) request.deferred.resolve() + await cleanupAll(live, source) + } +} + +describe(`pagination recomputation`, () => { + it(`refills a joined result window through a contract-compliant source`, async () => { + type ParentRow = { id: number; rank: number; groupId: number } + type ChildRow = { id: number; groupId: number } + const parents = [ + { id: 1, rank: 0, groupId: 1 }, + { id: 2, rank: 1, groupId: 2 }, + { id: 3, rank: 2, groupId: 3 }, + { id: 4, rank: 3, groupId: 4 }, + ] satisfies ReadonlyArray + const { requests, source: parentSource } = createConformingOrderedSource( + `pagination-joined-underfill-source-${collectionSequence++}`, + parents, + ) + const childSource = createCollection( + mockSyncCollectionOptions({ + id: `pagination-joined-underfill-child-${collectionSequence++}`, + initialData: [ + { id: 20, groupId: 2 }, + { id: 30, groupId: 3 }, + { id: 40, groupId: 4 }, + ] satisfies ReadonlyArray, + getKey: (row: ChildRow) => row.id, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ parent: parentSource }) + .innerJoin({ child: childSource }, ({ parent, child }) => + eq(parent.groupId, child.groupId), + ) + .orderBy(({ parent }) => parent.rank, `asc`) + .orderBy(({ parent }) => parent.id, `asc`) + .limit(2) + .select(({ parent }) => ({ id: parent.id })), + ) + + try { + await live.preload() + await flushPromises() + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3]) + expect(requests).toHaveLength(1) + expect(requests[0]?.limit).toBeUndefined() + } finally { + await cleanupAll(live, childSource, parentSource) + } + }) + + it(`loads the full ordered source when no continuation index exists`, async () => { + type ParentRow = { id: number; rank: number; groupId: number } + type ChildRow = { id: number; groupId: number } + const parents = [ + { id: 1, rank: 0, groupId: 1 }, + { id: 2, rank: 1, groupId: 2 }, + { id: 3, rank: 2, groupId: 3 }, + { id: 4, rank: 3, groupId: 4 }, + ] satisfies ReadonlyArray + const { requests, source: parentSource } = createConformingOrderedSource( + `pagination-no-index-underfill-source-${collectionSequence++}`, + parents, + `off`, + ) + const childSource = createCollection( + mockSyncCollectionOptions({ + id: `pagination-no-index-underfill-child-${collectionSequence++}`, + initialData: [ + { id: 20, groupId: 2 }, + { id: 30, groupId: 3 }, + { id: 40, groupId: 4 }, + ] satisfies ReadonlyArray, + getKey: (row: ChildRow) => row.id, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ parent: parentSource }) + .innerJoin({ child: childSource }, ({ parent, child }) => + eq(parent.groupId, child.groupId), + ) + .orderBy(({ parent }) => parent.rank, `asc`) + .orderBy(({ parent }) => parent.id, `asc`) + .limit(2) + .select(({ parent }) => ({ id: parent.id })), + ) + + try { + await live.preload() + await flushPromises() + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3]) + expect(requests).toHaveLength(1) + expect(requests[0]?.limit).toBeUndefined() + } finally { + await cleanupAll(live, childSource, parentSource) + } + }) + + it(`refines a joined foreign order term through the source tie class`, async () => { + type ParentRow = { id: number; sourceRank: number; childId: number } + type ChildRow = { id: number; score: number } + const parents = [ + { id: 1, sourceRank: 0, childId: 1 }, + { id: 2, sourceRank: 0, childId: 2 }, + { id: 3, sourceRank: 0, childId: 3 }, + { id: 4, sourceRank: 0, childId: 4 }, + ] satisfies ReadonlyArray + const { requests, source: parentSource } = createConformingOrderedSource( + `pagination-joined-foreign-order-source-${collectionSequence++}`, + parents, + ) + const childSource = createCollection( + mockSyncCollectionOptions({ + id: `pagination-joined-foreign-order-child-${collectionSequence++}`, + initialData: [ + { id: 1, score: 10 }, + { id: 2, score: 20 }, + { id: 3, score: 0 }, + { id: 4, score: 30 }, + ] satisfies ReadonlyArray, + getKey: (row: ChildRow) => row.id, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ parent: parentSource }) + .leftJoin({ child: childSource }, ({ parent, child }) => + eq(parent.childId, child.id), + ) + .orderBy(({ parent }) => parent.sourceRank, `asc`) + .orderBy(({ child }) => child.score, `asc`) + .orderBy(({ parent }) => parent.id, `asc`) + .limit(2) + .select(({ parent }) => ({ id: parent.id })), + ) + + try { + await live.preload() + await flushPromises() + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 1]) + expect(requests).toHaveLength(1) + expect(requests[0]?.limit).toBeUndefined() + } finally { + await cleanupAll(live, childSource, parentSource) + } + }) + + it.each([`error`, `AbortError`] as const)( + `does not derive a retry cursor from rows written by a %s request`, + async (failureKind) => { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + { id: 4, rank: 99 }, + ] + const requests: Array = [] + const unloaded: Array = [] + const deliveredIds = new Set() + const rejectedPage = createDeferred() + let rejectNextPage = false + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + let truncate!: () => void + const source = createCollection({ + id: `pagination-rejected-partial-page-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + truncate = operations.truncate + operations.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + if (rejectNextPage) { + rejectNextPage = false + begin() + deliveredIds.add(4) + write({ type: `insert`, value: { ...authoritativeRows[3]! } }) + commit() + return rejectedPage.promise + } + + begin() + for (const row of rowsForLoadSubset( + authoritativeRows, + options, + )) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + unloadSubset: (options) => unloaded.push(options), + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1), + ) + + try { + await live.preload() + const initialRequestCount = requests.length + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + + rejectNextPage = true + const failed = live.utils.setWindow({ offset: 0, limit: 4 }) + expect(failed).toBeInstanceOf(Promise) + const failure = + failureKind === `AbortError` + ? new DOMException(`partial ordered request canceled`, `AbortError`) + : new Error(`partial ordered request failed`) + rejectedPage.reject(failure) + await expect(failed).rejects.toBe(failure) + await flushPromises() + expect(requests).toHaveLength(initialRequestCount + 1) + + // Replay can replace the failed request's physical options object + // before the explicit retry retires its logical demand. + const beforeFailedReplay = requests.length + deliveredIds.clear() + begin() + truncate() + commit() + await flushPromises() + const failedReplayRequests = requests.slice(beforeFailedReplay) + // The settled first row permits a three-row continuation. Replay + // must preserve that exact demand even after its first attempt fails. + const replayedFailedRequest = failedReplayRequests.find( + ({ limit, cursor }) => limit === 3 && cursor !== undefined, + ) + expect(replayedFailedRequest).toBeDefined() + expect(replayedFailedRequest).toMatchObject({ offset: 1, limit: 3 }) + expect(replayedFailedRequest?.cursor).toEqual( + requests[initialRequestCount]?.cursor, + ) + + const releasesBeforeRetry = unloaded.length + const requestsBeforeRetry = requests.length + const retry = live.utils.setWindow({ offset: 0, limit: 2 }) + if (retry instanceof Promise) await retry + expect(unloaded.slice(releasesBeforeRetry)).toEqual([ + replayedFailedRequest, + ]) + expect(requests).toHaveLength(requestsBeforeRetry) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + + const beforeWiden = requests.length + const widen = live.utils.setWindow({ offset: 0, limit: 3 }) + if (widen instanceof Promise) await widen + expect(requests).toHaveLength(beforeWiden) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 3]) + + const beforeReplay = requests.length + deliveredIds.clear() + begin() + truncate() + commit() + await flushPromises() + expect( + requests.slice(beforeReplay).every(({ cursor }) => !cursor), + ).toBe(true) + } finally { + rejectedPage.resolve() + await cleanupAll(live, source) + } + }, + ) + + it(`recovers a failed tie boundary from the authoritative full source`, async () => { + const authoritativeRows: Array = [ + { id: 1, rank: -1 }, + // A provider may return equal-order rows in any order. The local public + // key tie-breaker must choose id 2 after boundary refinement. + { id: 4, rank: 0 }, + { id: 3, rank: 0 }, + { id: 2, rank: 0 }, + { id: 6, rank: 1 }, + { id: 5, rank: 99 }, + ] + const deliveredIds = new Set() + const requests: Array = [] + const failedPage = createDeferred() + let rejectNextPage = false + let begin!: () => void + let write!: (message: { type: `insert` | `delete`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-recovered-prefix-tie-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + if (rejectNextPage) { + rejectNextPage = false + begin() + deliveredIds.add(5) + write({ type: `insert`, value: { id: 5, rank: 99 } }) + commit() + return failedPage.promise + } + + begin() + for (const row of rowsForLoadSubset(authoritativeRows, options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + + rejectNextPage = true + const failed = live.utils.setWindow({ offset: 0, limit: 3 }) + expect(failed).toBeInstanceOf(Promise) + failedPage.reject(new Error(`later page failed`)) + await expect(failed).rejects.toThrow(`later page failed`) + + const retry = live.utils.setWindow({ offset: 0, limit: 2 }) + if (retry instanceof Promise) await retry + const recoveryRequest = requests.at(-1) + expect(recoveryRequest?.limit).toBeUndefined() + expect(recoveryRequest?.offset).toBeUndefined() + expect(recoveryRequest?.cursor).toBeUndefined() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + + const deleted = authoritativeRows.filter(({ id }) => + [1, 2, 3].includes(id), + ) + for (const row of deleted) { + authoritativeRows.splice(authoritativeRows.indexOf(row), 1) + deliveredIds.delete(row.id) + } + begin() + for (const row of deleted) write({ type: `delete`, value: { ...row } }) + commit() + await flushPromises() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([4, 6]) + } finally { + failedPage.resolve() + await cleanupAll(live, source) + } + }) + + it(`rejects a reentrant window move when an ordered request writes and then throws`, async () => { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + { id: 3, rank: 2 }, + ] + const requests: Array = [] + const deliveredIds = new Set() + const failure = new Error(`ordered request threw after writing`) + let reentrantError: unknown + let throwNextPage = false + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-synchronous-partial-page-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + if (throwNextPage) { + throwNextPage = false + begin() + deliveredIds.add(3) + write({ type: `insert`, value: { ...authoritativeRows[2]! } }) + commit() + try { + live.utils.setWindow({ offset: 0, limit: 3 }) + } catch (error) { + reentrantError = error + } + throw failure + } + + begin() + for (const row of rowsForLoadSubset(authoritativeRows, options)) { + if (deliveredIds.has(row.id)) continue + deliveredIds.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1), + ) + + try { + await live.preload() + const initialRequestCount = requests.length + throwNextPage = true + + // A synchronous throw is a rejection (maintainer decision): the move + // fails with it. + await expect( + Promise.resolve(live.utils.setWindow({ offset: 0, limit: 2 })), + ).rejects.toBe(failure) + expect(reentrantError).toMatchObject({ + name: `SetWindowReentrancyError`, + }) + expect(requests).toHaveLength(initialRequestCount + 1) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + + const retry = live.utils.setWindow({ offset: 0, limit: 2 }) + if (retry instanceof Promise) await retry + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + await cleanupAll(live, source) + } + }) + + it.each( + ([`sync`, `async`] as const).flatMap((delivery) => + [1, 2].map((requestNumber) => ({ delivery, requestNumber })), + ), + )( + `rejects a window move reentered from startup request $requestNumber after $delivery delivery`, + async ({ delivery, requestNumber }) => { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + ] + const delivered = new Set() + let nestedResult: true | Promise | undefined + let nestedError: unknown + let requests = 0 + let firstRequestSettled = false + function createWindowedQuery() { + return createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + ) + } + const source = createCollection({ + id: `pagination-initial-request-reentrancy-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options) => { + requests++ + if (requests === requestNumber) { + if (delivery === `async` && requestNumber === 2) { + expect(firstRequestSettled).toBe(true) + } + try { + nestedResult = live.utils.setWindow({ offset: 0, limit: 2 }) + } catch (error) { + nestedError = error + } + } + const fresh = rowsForLoadSubset( + authoritativeRows, + options, + ).filter(({ id }) => !delivered.has(id)) + if (fresh.length === 0) return true + begin() + for (const row of fresh) { + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return delivery === `async` + ? Promise.resolve().then(() => { + firstRequestSettled = true + }) + : true + }, + } + }, + }, + }) + const live = createWindowedQuery() + + try { + await live.preload() + expect(requests).toBeGreaterThanOrEqual(requestNumber) + expect(nestedResult).toBeUndefined() + expect(nestedError).toMatchObject({ + name: `SetWindowReentrancyError`, + }) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + await live.utils.setWindow({ offset: 0, limit: 2 }) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 2 }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + await cleanupAll(live, source) + } + }, + ) + + it(`rejects a window move reentered from a public change callback`, async () => { + const authoritativeRows: Array = [ + { id: 1, rank: 0, keep: true }, + { id: 2, rank: 1, keep: true }, + ] + const delivered = new Set() + let begin!: () => void + let write!: (message: { + type: `update` + value: PageRow + previousValue: PageRow + }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-publication-reentrancy-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: (options) => { + const fresh = rowsForLoadSubset( + authoritativeRows, + options, + ).filter(({ id }) => !delivered.has(id)) + if (fresh.length === 0) return true + begin() + for (const row of fresh) { + delivered.add(row.id) + operations.write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + ) + let nestedResult: true | Promise | undefined + let nestedError: unknown + + try { + await live.preload() + const subscription = live.subscribeChanges(() => { + try { + nestedResult = live.utils.setWindow({ offset: 0, limit: 2 }) + } catch (error) { + nestedError = error + } + }) + const previous = authoritativeRows[0]! + const current = { ...previous, keep: false } + authoritativeRows[0] = current + begin() + write({ type: `update`, value: current, previousValue: previous }) + commit() + subscription.unsubscribe() + + expect(nestedResult).toBeUndefined() + expect(nestedError).toMatchObject({ name: `SetWindowReentrancyError` }) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) + } finally { + await cleanupAll(live, source) + } + }) + + it.each([`return-only`, `write-after-cleanup`])( + `rejects a window move when its sync run is cleaned up: %s`, + async (delivery) => { + const authoritativeRows: Array = [ + { id: 1, rank: 0 }, + { id: 2, rank: 1 }, + ] + const delivered = new Set() + let cleanUpDuringNextRequest = false + let cleanupPromise: Promise | undefined + function createWindowedQuery() { + return createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .limit(1), + ) + } + const source = createCollection({ + id: `pagination-window-cleanup-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (options) => { + if (cleanUpDuringNextRequest) { + cleanUpDuringNextRequest = false + cleanupPromise = live.cleanup() + if (delivery === `return-only`) return true + } + const fresh = rowsForLoadSubset( + authoritativeRows, + options, + ).filter(({ id }) => !delivered.has(id)) + if (fresh.length === 0) return true + begin() + for (const row of fresh) { + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + return true + }, + } + }, + }, + }) + const live = createWindowedQuery() + + try { + await live.preload() + cleanUpDuringNextRequest = true + const move = live.utils.setWindow({ offset: 0, limit: 2 }) + expect(cleanUpDuringNextRequest).toBe(false) + expect(cleanupPromise).toBeInstanceOf(Promise) + await cleanupPromise + + expect(move).toBeInstanceOf(Promise) + await expect(move).rejects.toMatchObject({ name: `AbortError` }) + expect(live.utils.getWindow()).toEqual({ offset: 0, limit: 1 }) + } finally { + await cleanupAll(live, source) + } + }, + ) + + it(`tracks an asynchronous cursor continuation after synchronous satisfaction`, async () => { + const rows: Array = [ + { id: 1, rank: 1 }, + { id: 2, rank: 2 }, + { id: 3, rank: 3 }, + ] + const requests: Array = [] + const delivered = new Set() + const refinement = createDeferred() + let deferLoads = false + const source = createCollection({ + id: `pagination-async-refinement-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + const publish = (options: LoadSubsetOptions) => { + begin() + for (const row of rowsForLoadSubset(rows, options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + } + + return { + loadSubset: (options: LoadSubsetOptions) => { + requests.push(options) + if (!deferLoads) { + publish(options) + return true + } + + return refinement.promise.then(() => publish(options)) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + try { + await live.preload() + await flushPromises() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + const initialRequestCount = requests.length + expect(initialRequestCount).toBeGreaterThan(0) + expect( + requests.every( + ({ limit, where }) => limit !== undefined || where !== undefined, + ), + ).toBe(true) + deferLoads = true + + const widened = live.utils.setWindow({ offset: 0, limit: 2 }) + expect(widened).toBeInstanceOf(Promise) + await flushPromises() + expect(requests.length).toBeGreaterThan(initialRequestCount) + const widenedRequest = requests + .slice(initialRequestCount) + .find(({ cursor }) => cursor !== undefined) + expect(widenedRequest).toBeDefined() + expect(widenedRequest).toMatchObject({ limit: 1, offset: 1 }) + expect(widenedRequest?.cursor?.whereFrom).toBeDefined() + expect(widenedRequest?.cursor?.whereCurrent).toBeDefined() + const settledBeforeRefinement = await Promise.race([ + Promise.resolve(widened).then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), 10)), + ]) + expect(settledBeforeRefinement).toBe(false) + + refinement.resolve() + if (widened instanceof Promise) await widened + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + } finally { + await cleanupAll(live, source) + } + }) + + it(`refines locale-ordered continuations locally when predicate IR cannot express the collation`, async () => { + const rows: Array = [ + { id: 1, label: `item2` }, + { id: 2, label: `item10` }, + { id: 3, label: `item11` }, + ] + const pending: Array = [] + const delivered = new Set() + let begin!: () => void + let write!: (message: { type: `insert`; value: LocaleCursorRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-locale-cursor-source-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const deferred = createDeferred() + pending.push({ options, deferred }) + return deferred.promise + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.label, { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + locale: `en-US`, + localeOptions: { numeric: true }, + }) + .orderBy(({ row }) => row.id, `asc`) + .limit(1), + ) + + try { + const preload = live.preload() + expect(pending).toHaveLength(1) + // Settling one request can append its boundary-refinement request. + for (const request of pending) { + begin() + for (const row of rowsForLoadSubset(rows, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await flushPromises() + } + await preload + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1]) + expect(pending.length).toBeLessThanOrEqual(rows.length * 2) + expect( + pending.some( + ({ options }) => + options.limit === undefined && options.where === undefined, + ), + ).toBe(true) + + const transportCount = pending.length + const widened = live.utils.setWindow({ offset: 0, limit: 2 }) + for (let index = transportCount; index < pending.length; index++) { + const request = pending[index]! + begin() + for (const row of rowsForLoadSubset(rows, request.options)) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + request.deferred.resolve() + await flushPromises() + } + if (widened instanceof Promise) await widened + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2]) + expect(pending.length).toBeLessThanOrEqual(rows.length * 3) + } finally { + for (const request of pending) request.deferred.resolve() + await cleanupAll(live, source) + } + }) + + it(`continues an indexed multi-column window from its leading boundary`, async () => { + const rows: Array = [ + { id: 3, primary: 0, secondary: 2 }, + { id: 2, primary: 0, secondary: 1 }, + { id: 1, primary: 0, secondary: 0 }, + { id: 5, primary: 1, secondary: 1 }, + { id: 4, primary: 1, secondary: 0 }, + { id: 6, primary: 2, secondary: 0 }, + ] + const { requests, source } = createConformingOrderedSource( + `pagination-multi-order-cursor-${collectionSequence++}`, + rows, + ) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.primary, `asc`) + .orderBy(({ row }) => row.secondary, `desc`) + .limit(2) + .select(({ row }) => ({ id: row.id })), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 2]) + + const initialRequestCount = requests.length + const widened = live.utils.setWindow({ offset: 0, limit: 5 }) + if (widened instanceof Promise) await widened + + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 2, 1, 5, 4]) + const continuation = requests + .slice(initialRequestCount) + .find(({ cursor }) => cursor !== undefined) + expect(continuation).toMatchObject({ + limit: 2, + offset: 3, + orderBy: expect.any(Array), + }) + expect(continuation?.orderBy).toHaveLength(2) + expect(continuation?.cursor?.whereFrom).toBeDefined() + expect(continuation?.cursor?.whereCurrent).toBeDefined() + } finally { + await cleanupAll(live, source) + } + }) + + it(`retains a finite inactive prefix across shrink, SSE, and re-expansion`, async () => { + const rows = new Map( + Array.from({ length: 5 }, (_, index) => [ + index + 1, + { id: index + 1, rank: index + 1 }, + ]), + ) + const delivered = new Set() + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-retained-prefix-live-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + params.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + const ordered = [...rows.values()].sort( + (left, right) => left.rank - right.rank || left.id - right.id, + ) + const requested = rowsForLoadSubset(ordered, options) + begin() + for (const row of requested) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + return Promise.resolve(receipt) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .orderBy(({ row }) => row.id, `asc`) + .limit(3), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 3]) + + await live.utils.setWindow({ offset: 0, limit: 1 }) + const inserted = { id: 9, rank: 2.5 } + rows.set(inserted.id, inserted) + delivered.add(inserted.id) + begin() + write({ type: `insert`, value: inserted }) + commit() + await flushPromises() + + await live.utils.setWindow({ offset: 0, limit: 3 }) + expect(Array.from(live.values(), ({ id }) => id)).toEqual([1, 2, 9]) + } finally { + await cleanupAll(live, source) + } + }) + + it(`discovered trace: an in-flight request does not underfill a new window`, async () => { + await expectInflightRequestFillsNewWindow() + }) + + it.each([`resolve`, `reject`] as const)( + `keeps the last complete implicit window while background recovery %ss`, + async (settlement) => { + const authoritativeRows = new Map([ + [1, { id: 1, rank: 0, keep: true }], + [2, { id: 2, rank: 1, keep: true }], + ]) + const recovery = createDeferred() + const recoveryError = new Error(`background recovery failed`) + const loads: Array = [] + const delivered = new Set() + let recovering = false + let begin!: () => void + let write!: (message: { + type: `insert` | `update` + value: PageRow + }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-background-prefix-recovery-${collectionSequence++}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + startSync: true, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: (options: LoadSubsetOptions) => { + loads.push(options) + const isFullSource = + options.where === undefined && + options.limit === undefined && + options.cursor === undefined + const isOrderedRepair = + options.where === undefined && + options.orderBy !== undefined && + options.limit === 1 && + options.cursor === undefined + const applyRows = () => { + const rows = rowsForLoadSubset( + [...authoritativeRows.values()].sort( + (left, right) => + left.rank - right.rank || left.id - right.id, + ), + options, + ) + begin() + for (const row of rows) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + commit() + } + if (!recovering || (!isFullSource && !isOrderedRepair)) { + applyRows() + return true + } + return recovery.promise.then(() => { + if (settlement === `reject`) throw recoveryError + applyRows() + }) + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `asc`) + .limit(1) + .select(({ row }) => ({ + id: row.id, + rank: row.rank, + keep: row.keep, + })), + ) + const publications: Array> = [] + const subscription = live.subscribeChanges( + () => publications.push(live.toArray.map(projectPageRow)), + { includeInitialState: false }, + ) + + try { + await live.preload() + expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) + publications.length = 0 + const loadsBeforeMutation = loads.length + + recovering = true + const moved = { id: 1, rank: 10, keep: true } + authoritativeRows.set(1, moved) + begin() + write({ type: `update`, value: { ...moved } }) + commit() + await flushPromises() + + const recoveryLoads = loads.slice(loadsBeforeMutation) + expect(recoveryLoads).toHaveLength(1) + expect(recoveryLoads[0]?.where).toBeUndefined() + expect(recoveryLoads[0]?.orderBy).toHaveLength(1) + expect(recoveryLoads[0]?.limit).toBe(1) + expect(recoveryLoads[0]?.cursor).toBeUndefined() + expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) + expect(publications).toEqual([]) + + recovery.resolve() + await flushPromises() + + if (settlement === `resolve`) { + expect(live.toArray.map(projectPageRow)).toEqual([{ id: 2, rank: 1 }]) + expect(publications).toEqual([[{ id: 2, rank: 1 }]]) + expect(live.utils.lastSubsetError).toBeUndefined() + } else { + expect(live.toArray.map(projectPageRow)).toEqual([{ id: 1, rank: 0 }]) + expect(publications).toEqual([]) + expect(live.utils.lastSubsetError).toBe(recoveryError) + } + } finally { + recovery.resolve() + subscription.unsubscribe() + await cleanupAll(live, source) + } + }, + ) + + it(`uses the leading source custom collation when ordering a later source (C-08 multi-source inheritance)`, async () => { + type ScopeRow = { id: number; groupId: number } + const scope = createCollection( + mockSyncCollectionOptions({ + id: `pagination-custom-collation-scope-${collectionSequence++}`, + initialData: [{ id: 1, groupId: 1 }] satisfies Array, + getKey: (row: ScopeRow) => row.id, + defaultStringCollation: { + stringSort: `custom`, + compare: compareCustomCollationStrings, + }, + }), + ) + const loads: Array = [] + const delivered = new Set() + const source = createCollection({ + id: `pagination-custom-collation-joined-${collectionSequence++}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + defaultStringCollation: { stringSort: `lexical` }, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: (loadOptions: LoadSubsetOptions) => { + loads.push(loadOptions) + const matchingRows = loadOptions.where + ? customCollationProviderRows.filter( + (row) => + evaluateReferenceExpression(loadOptions.where!, row) === + true, + ) + : customCollationProviderRows + begin() + for (const row of matchingRows) { + if (delivered.has(row.id)) continue + delivered.add(row.id) + write({ type: `insert`, value: { ...row } }) + } + const receipt = commit() + return receipt === true ? Promise.resolve() : receipt + }, + } + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ scope }) + .innerJoin({ row: source }, ({ scope: outer, row }) => + eq(outer.groupId, row.groupId), + ) + .where(({ row }) => eq(row.keep, true)) + .orderBy(({ row }) => row.label, { + direction: `asc`, + nulls: `first`, + }) + .select(({ row }) => ({ id: row.id, label: row.label })), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([2, 3, 4, 1]) + expect(loads).toHaveLength(1) + expect(loads[0]?.where).toBeDefined() + expect(loads[0]?.orderBy).toBeUndefined() + expect(loads[0]?.limit).toBeUndefined() + expect(loads[0]?.offset).toBeUndefined() + expect(loads[0]?.cursor).toBeUndefined() + } finally { + await cleanupAll(live, source, scope) + } + }) + + it(`uses an ascending index for a bounded descending demand`, async () => { + const rows: Array = [ + { id: 3, rank: 1 }, + { id: 1, rank: 0 }, + { id: 2, rank: 0 }, + ] + const loads: Array = [] + const loaded = new Set() + let begin!: () => void + let write!: (message: { type: `insert`; value: PageRow }) => void + let commit!: () => void + const source = createCollection({ + id: `pagination-reversed-index-ties-${collectionSequence++}`, + getKey: (row: PageRow) => row.id, + syncMode: `on-demand`, + startSync: true, + sync: { + sync: (operations) => { + begin = operations.begin + write = operations.write + commit = operations.commit + operations.markReady() + return { + loadSubset: (options) => { + loads.push(options) + begin() + for (const row of rowsForLoadSubset(rows, options)) { + if (loaded.has(row.id)) continue + loaded.add(row.id) + write({ type: `insert`, value: row }) + } + commit() + return true + }, + } + }, + }, + }) + source.createIndex((row) => row.rank, { + indexType: BTreeIndex, + options: { + compareOptions: { + direction: `asc`, + nulls: `first`, + stringSort: `locale`, + }, + }, + }) + const live = createLiveQueryCollection((query) => + query + .from({ row: source }) + .orderBy(({ row }) => row.rank, `desc`) + .limit(2), + ) + + try { + await live.preload() + expect(Array.from(live.values(), ({ id }) => id)).toEqual([3, 1]) + expect(loads.length).toBeGreaterThan(0) + expect( + loads.every( + ({ limit, where }) => limit !== undefined || where !== undefined, + ), + JSON.stringify( + loads.map(({ limit, offset, cursor, orderBy, where }) => ({ + limit, + offset, + cursor: cursor !== undefined, + orderBy: orderBy !== undefined, + where: where !== undefined, + })), + ), + ).toBe(true) + } finally { + await cleanupAll(live, source) + } + }) + + it.each([`forward`, `reverse`] as const)( + `keeps concurrent on-demand windows correct under %s completion`, + async (scenario) => { + await expectOnDemandWindowsAreCompletionOrderIndependent(scenario) + }, + ) +}) From 091a969be54952985b6c672e9e7be152832b4602 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:09:44 -0700 Subject: [PATCH 066/116] docs: oracle files register only through oracleProperty; owns goes on the registering call declareOracle is deleted from @tanstack/oracles (maintainer decision, 2026-11-11). AGENTS.md, the rubric, the glossary, and the coverage map now state the D2 law: an oracle file registers its tests only through oracleProperty, with fixed regressions, cases, calibrations, and reach in the property's histories, calibrations, and declared states, and every other test in an ordinary file. owns goes on the oracleProperty call or on the options literal of a wrapper that forwards it. Co-authored-by: Isaac --- AGENTS.md | 39 +++++++++------ docs/contributing/glossary.md | 2 +- docs/contributing/oracle-coverage.md | 8 +-- docs/contributing/oracle-rubric.md | 73 +++++++++++++++------------- 4 files changed, 66 insertions(+), 56 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 82b719efce..cc698689ac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -747,15 +747,20 @@ entry, contract comment, or table of fixtures does not turn examples into an oracle. Keep a fixed regression as input to the same model, production driver, and -comparison that the generated campaign uses. It must not carry a separate -expected answer or its own assertions. Checker calibration may supply a wrong -observation to that same comparison. Put supplemental unit tests in ordinary -test files; they do not discharge an oracle-owned coverage obligation. +comparison that the generated campaign uses: a fixed history +(`histories.fixed`) of that `oracleProperty`, or an enumerated property with +the same driver, model, and check when the grammar cannot produce it. It must +not carry a separate expected answer or its own assertions. A checker +calibration is a `calibrations` entry that supplies a wrong observation or a +mutant driver to that same comparison. Put supplemental unit tests, and +expectations that step only the model, in ordinary test files; they do not +discharge an oracle-owned coverage obligation. For compile-time contracts, generate legal TypeScript inputs and check their -public types against a reusable independent type law. A list of -`expectTypeOf` calls or `@ts-expect-error` examples is not an oracle. Negative -checks derived by the shared law from generated inputs are permitted. +public types against a reusable independent type law, with an `oracleProperty` +whose driver runs the type checker. A list of `expectTypeOf` calls or +`@ts-expect-error` examples is not an oracle. Negative checks derived by the +shared law from generated inputs are permitted. ### Name Oracle Files for Discovery @@ -765,17 +770,19 @@ 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 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. +An oracle file holds only oracle tests, and it registers them only through +`oracleProperty`, which takes the check, model, history grammar, and driver. +Its fixed regressions and cases are its histories, its calibrations are its +`calibrations`, and its reach checks are its declared states. Every other +test belongs in an ordinary test file, including a test that compares +production with a hand-written value and a test that steps only the model. +The `oracle/no-unit-test` lint rule reports every other registration in a file +whose name contains `oracle` (maintainer decision, 2026-11-11). Name the production code that an oracle file judges in the `owns` list of each -`oracleProperty` call, written inline as a literal array. A law has no owner +`oracleProperty` call, written inline as a literal array. A file that registers +through a wrapper passes the list in the wrapper call's options literal, and +the wrapper forwards it to `oracleProperty`. A law has no owner field; code ownership belongs to CODEOWNERS. The oracle rubric's [naming section](docs/contributing/oracle-rubric.md#name-oracle-files-consistently) states the form that the gap report reads. diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index aa25c29859..81ad7d94ad 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -236,7 +236,7 @@ crossing subsystem boundaries; these comparisons are not interchangeable. | 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. It is not a field of the law: an `OracleLaw` has no owner, and code ownership by people and teams belongs to CODEOWNERS. | -| owned code | The production code that an oracle file judges in the gap report, named by the `owns` list of its `oracleProperty` calls: a source file, or a function with its static call tree. A file with no inline `oracleProperty` call names it in `declareOracle({ owns })` during the migration. | +| owned code | The production code that an oracle file judges in the gap report, named by the `owns` lists that it passes to its calls: its `oracleProperty` calls, or a wrapper's call that forwards the list to `oracleProperty`. An entry is a source file, or a function with its static call tree. | | 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 reach gap closes when generated histories meet the declared state's enforced minimum under ORC-015. A mutant gap in a changed function closes with an oracle kill or an explicit maintainer allowance for its anchored gaps. Recording either as a limit does not close it. Qualify it; a sequence gap is a product term. | diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 402b788455..e8ef612325 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4325,10 +4325,10 @@ The gap report (`pnpm gap-report`) is the `oracle-gaps` CLI of 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`. The report reads each oracle file's owned -code from the `owns` list of its `oracleProperty` calls. A file with no inline -`oracleProperty` call, such as one that registers through a production driver, -keeps the list in `declareOracle({ owns })` until the oracles package removes -that API. The report reads both. +code from every literal `owns` list that it passes to a call: its +`oracleProperty` calls, or the call of a wrapper that forwards the list to +`oracleProperty`, such as a shared conformance suite's registration +function. `scripts/lint-scope-oracle.property.test.js` owns lint selection after an edit (`scripts.lint-scope`): the changed scope, the named (pre-commit) scope, diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index 50a85f129b..2c0d0d2777 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -421,35 +421,36 @@ state model, differential or metamorphic comparison, or reusable law checker. A separate module that defines that model, checker, or its history grammar also qualifies. Identify the mechanism in the code before applying the name. -An oracle file holds only oracle tests. It names its parts with -`declareOracle({ check: [...], model: [...], grammar: [...] })` from -[`oracle-declaration.ts`](../../packages/db/tests/oracle-declaration.ts), which -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 production driver: - -- A generated property or a fixed history runs through the check. -- 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). +An oracle file holds only oracle tests, and it registers them only through +`oracleProperty` (maintainer decision, 2026-11-11). The property takes the +parts: the check compares production observations with the model or with a +second formulation, and the grammar is the generators that produce legal +histories. Each kind of oracle test has its place in the property: + +- A generated history runs through the check in the property's campaigns. +- A fixed history, such as a fixed regression, is a `histories.fixed` entry. + When the grammar cannot produce it, it is a history of an enumerated + property with the same driver, model, and check, and that property's + limits say so. +- A case of a finite table is an enumerated history. +- A calibration shows that the check rejects a wrong answer (ORC-006). It is + a `calibrations` entry: a corrupted trace or a mutant driver. - A grammar calibration shows that the generators reach the states that the - laws need (ORC-004, ORC-015). -- A generated property can state its law inline: its predicate relates - production to inputs that nobody wrote down. A property whose arbitraries - are all constants, or whose predicate ignores its input, checks written - values instead. -- A type-level test compares an inferred type with a type model. A type has no - value to pass, so the file names its model types in - `declareOracle({ typeModel: [...] })`. Each name is a type alias or - interface in the same file, and a type built from one counts as using it. - -A test that uses none of them compares production with a hand-written value. It -is a unit test, even when it asserts a contract, and it belongs in an -ordinary test file beside the oracle. A finite table of hand-written expected -values selected by a test's parameters is not a model. The `oracle/no-unit-test` lint rule -reports such a test. Files named `oracle-*`, such as `oracle-config.ts`, are -the shared oracle machinery and declare no parts. + laws need (ORC-004, ORC-015). It is a declared state, in `states` with a + constructed history, or in `measured`. +- A type-level law runs the type checker in the property's driver, over + generated TypeScript inputs. + +Any other test is an ordinary test, even when it asserts a contract, and it +belongs in an ordinary test file beside the oracle. That includes a test that +compares production with a hand-written value, and a model-rule expectation +that steps the model and checks one rule against its authority: it runs no +production, so it can never kill a production mutant. A finite table of +hand-written expected values selected by a test's parameters is not a model. +The `oracle/no-unit-test` lint rule reports every registration in an oracle +file other than a complete `oracleProperty` call. Files named `oracle-*`, +such as `oracle-config.ts`, are the shared oracle machinery and register no +oracle. An oracle file also registers no test through a helper from an ordinary module. The rule reports a call to such a helper, because the helper's tests then appear @@ -464,13 +465,15 @@ each `oracleProperty` call: `oracleProperty({ id, law, owns: [...], ... })`. An entry is a source path relative to the package (`src/x.ts`), or one of its functions with its static call tree (`src/x.ts#fn`, `src/x.ts#Class.method`). The gap report (`pnpm gap-report`) reads the list without running the file, so -write it in the call as a non-empty array of string or template literals: no -spread, variable, computed list, or quoted key, and call the function by the -name `oracleProperty`, not an alias. A file with several `oracleProperty` calls -repeats the list on each call; the gap report judges ownership per file. A file -that registers only through a production driver or wrapper, with no inline -`oracleProperty` call, keeps its list in `declareOracle({ owns: [...] })` -while the migration lasts. The gap report reads both. +write it in the call as a non-empty array of string or template literals, +under the key `owns`: no spread, variable, computed list, or quoted key. A +file with several calls repeats the +list on each call; the gap report judges ownership per file, as the union of +its lists. A file that registers through a wrapper, such as a shared +conformance suite, passes the list in the wrapper call's options literal +(`registerX({ property, owns: [...], ... })`). The gap report reads `owns` +from an object-literal argument of any call, and the wrapper forwards it +unchanged to `oracleProperty`. A law has no owner field. Code ownership by people and teams belongs to CODEOWNERS (maintainer decision, 2026-11-10). The coverage map's oracle owner From f7fcf2ccd715769704cc610d50fc6427bbae2c08 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:10:27 -0700 Subject: [PATCH 067/116] test(db): register BTree Map cases and calibrations through oracleProperty The fixed comparator, depth and dense-cut cases become enumerated properties. The spy calibrations replay their old bodies as mutant drivers of btree-map.history; calibration-rejection.ts holds each accepted rejection so every clean check in the body still runs. Co-authored-by: Isaac --- packages/db/tests/btree-map-oracle.test.ts | 473 +++++++++++------- packages/db/tests/calibration-rejection.ts | 56 +++ ....ts => indexing-reuse-calibration.test.ts} | 0 .../db/tests/converted-properties/db-core.ts | 9 + 4 files changed, 350 insertions(+), 188 deletions(-) create mode 100644 packages/db/tests/calibration-rejection.ts rename packages/db/tests/conformance/{indexing-reuse-oracle.test.ts => indexing-reuse-calibration.test.ts} (100%) diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index e23a4cdc70..eb2798c416 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -1,13 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { BTree } from '../src/utils/btree.js' -import { oracleProperty, oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' +import { clean, rejection } from './calibration-rejection.js' import { applyAction, @@ -16,7 +17,6 @@ import { expectRefinement, expectValue, runMapHistory, - stepModel, } from './btree-map-oracle.js' import type { Action, Payload, ReferenceModel } from './btree-map-oracle.js' @@ -57,34 +57,51 @@ import type { Action, Payload, ReferenceModel } from './btree-map-oracle.js' // fixture returns NaN only for the pair such a comparison would use, so the // split must succeed without consulting it. Observe inserted-key reads and // whole ranges; the invalid pair is not a lookup order. -it.each([ - [16, 14], - [32, 46], -])( - `preserves the Map across a split after %s accepted entries`, - (size, rightKey) => { - oracleRoute(`case`) - const insertedKey = size * 2 - const tree = new BTree( - (left, right) => - left === insertedKey && right === rightKey ? NaN : left - right, - 4, - ) - const model: ReferenceModel = new Map() - for (let key = 0; key < insertedKey; key += 2) - applyAction(tree, model, { type: `put`, key, v: key }) - for (const key of [insertedKey, insertedKey + 2]) { - applyAction(tree, model, { type: `put`, key, v: key }) - const sorted = [...model.keys()].sort((left, right) => left - right) - expectRange(tree, model, sorted, -Infinity, Infinity, true) - expect(tree.minKey()).toBe(sorted[0]) - expect(tree.maxKey()).toBe(sorted.at(-1)) - } +function runSplitComparator([size, rightKey]: [number, number]): void { + const insertedKey = size * 2 + const tree = new BTree( + (left, right) => + left === insertedKey && right === rightKey ? NaN : left - right, + 4, + ) + const model: ReferenceModel = new Map() + for (let key = 0; key < insertedKey; key += 2) + applyAction(tree, model, { type: `put`, key, v: key }) + for (const key of [insertedKey, insertedKey + 2]) { + applyAction(tree, model, { type: `put`, key, v: key }) + const sorted = [...model.keys()].sort((left, right) => left - right) + expectRange(tree, model, sorted, -Infinity, Infinity, true) + expect(tree.minKey()).toBe(sorted[0]) + expect(tree.maxKey()).toBe(sorted.at(-1)) + } +} + +const btreeAuthority = `BTree public API numeric Map contract stated above` + +oracleProperty({ + id: `btree-map.split-comparator`, + law: { + statement: `A split determines the new child's position from the insertion index, so the tree stays a sorted Map without consulting the comparator pair that a redundant comparison would use.`, + authority: btreeAuthority, + limits: `Node size 4 after 16 and 32 accepted entries, with NaN returned only for the pair such a comparison would use.`, }, -) + owns: [`src/utils/btree.ts`], + histories: [ + [16, 14], + [32, 46], + ] as Array<[number, number]>, + label: ([size]) => + `preserves the Map across a split after ${size} accepted entries`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`btree-map.split-comparator`, plan, observe, () => + runSplitComparator(plan.history), + ), + check: forwardAssertionJudgment, +}) -it(`accepts infinite comparison results that still provide an order`, () => { - oracleRoute(`fixed history`) +function runInfiniteComparator(): void { const tree = new BTree((left, right) => left === right ? 0 : left < right ? -Infinity : Infinity, ) @@ -93,64 +110,96 @@ it(`accepts infinite comparison results that still provide an order`, () => { applyAction(tree, model, { type: `put`, key, v: key }) expectRefinement(tree, model, key) } -}) +} -declareOracle({ - check: [expectRefinement], - model: [stepModel], +oracleProperty({ + id: `btree-map.infinite-comparator`, + law: { + statement: `Numeric BTree observations agree with the sorted Map when the comparator returns infinite results that still provide an order.`, + authority: btreeAuthority, + limits: `One fixed history of three puts under a comparator that returns -Infinity, 0 or Infinity.`, + }, + owns: [`src/utils/btree.ts`], + histories: [ + `accepts infinite comparison results that still provide an order`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `btree-map.infinite-comparator`, + plan, + observe, + () => runInfiniteComparator(), + ), + check: forwardAssertionJudgment, }) describe(`BTree Map oracle`, () => { // The 120,000-operation corpus takes about nine seconds with CI coverage. // 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 - for (let round = 0; round < 40; round++) { - const nodeSize = 4 + Math.floor(rnd() * 5) - const tree = new BTree((a, b) => a - b, nodeSize) - const model: ReferenceModel = new Map() - const history: Array = [] - try { - expectRefinement(tree, model, 0) - for (let step = 0; step < 3000; step++) { - const key = Math.floor(rnd() * 200) - const op = rnd() - const action: Action = - op < 0.5 - ? { type: `put`, key, v: step } - : { - type: op < 0.85 ? `delete` : op < 0.9 ? `clear` : `read`, - key, - } - history.push(action) - applyAction(tree, model, action) - if (step % 97 === 0) - expectRefinement(tree, model, Math.floor(rnd() * 200)) + oracleProperty({ + id: `btree-map.long-campaign`, + law: { + statement: `Numeric BTree point, range, fallback and neighbor observations agree with the independent sorted Map after every action.`, + authority: btreeAuthority, + limits: `One deterministic depth campaign: 40 rounds of 3,000 random puts, deletes, clears and reads over 200 keys with node sizes 4 through 8, from seed 12345.`, + }, + owns: [`src/utils/btree.ts`], + histories: [ + `matches a Map oracle under random insert/delete/overwrite with small nodes`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`BTree Map refinement`, plan, observe, () => { + let seed = 12345 + const rnd = () => + (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff + for (let round = 0; round < 40; round++) { + const nodeSize = 4 + Math.floor(rnd() * 5) + const tree = new BTree((a, b) => a - b, nodeSize) + const model: ReferenceModel = new Map() + const history: Array = [] + try { + expectRefinement(tree, model, 0) + for (let step = 0; step < 3000; step++) { + const key = Math.floor(rnd() * 200) + const op = rnd() + const action: Action = + op < 0.5 + ? { type: `put`, key, v: step } + : { + type: op < 0.85 ? `delete` : op < 0.9 ? `clear` : `read`, + key, + } + history.push(action) + applyAction(tree, model, action) + if (step % 97 === 0) + expectRefinement(tree, model, Math.floor(rnd() * 200)) + } + expectRefinement(tree, model, 100) + } catch (cause) { + throw new Error( + JSON.stringify({ + law: `BT-map`, + initialSeed: 12345, + seed, + round, + nodeSize, + history, + }), + { cause }, + ) } - expectRefinement(tree, model, 100) - } catch (cause) { - throw new Error( - JSON.stringify({ - law: `BT-map`, - initialSeed: 12345, - seed, - round, - nodeSize, - history, - }), - { cause }, - ) } - } - }, - ) + }), + check: forwardAssertionJudgment, + timeout: 30_000, + }) oracleProperty({ id: `btree-map.history`, @@ -178,120 +227,168 @@ describe(`BTree Map oracle`, () => { runMapHistory(plan.nodeSize, plan.actions), ), check: forwardAssertionJudgment, + // Each calibration replays its old body. The refinement check must reject + // every wrong answer with an error that the old matcher accepted, and + // every clean check in the body must still pass. + calibrations: [ + { + name: `rejects wrong pair payloads, shared payload mutation and transient stale state`, + law: `BTree Map refinement`, + driver: (plan, observe) => + recordAssertionJudgment(`BTree Map refinement`, plan, observe, () => { + const expected = (error: Error) => /expected/.test(error.message) + const rejections: Array = [] + const tree = new BTree((a, b) => a - b, 4) + const value = { v: 1 } + const model: ReferenceModel = new Map([ + [1, Object.freeze({ reference: value, v: 1 })], + ]) + tree.set(1, value) + clean(() => expectRefinement(tree, model, 0)) + const wrongPair = vi + .spyOn(tree, `nextHigherPair`) + .mockReturnValue([1, { v: 2 }]) + try { + rejections.push( + rejection(expected, () => expectRefinement(tree, model, 0)), + ) + } finally { + wrongPair.mockRestore() + } + value.v = 2 + expect(tree.get(1)).toBe(value) // The former identity-only check stays green. + rejections.push( + rejection(expected, () => expectRefinement(tree, model, 0)), + ) + value.v = 1 + clean(() => expectRefinement(tree, model, 0)) + // A stale delete result can vanish at a later clear. Judge the earlier cut. + rejections.push( + rejection(expected, () => expectRefinement(tree, new Map(), 0)), + ) + tree.clear() + clean(() => expectRefinement(tree, new Map(), 0)) + const staleScan = vi + .spyOn(tree, `forRange`) + .mockImplementation((_low, _high, _inclusive, callback) => { + callback(1, value) + }) + try { + rejections.push( + rejection(expected, () => expectRefinement(tree, new Map(), 0)), + ) + } finally { + staleScan.mockRestore() + } + clean(() => expectRefinement(tree, new Map(), 0)) + rejectAt( + `calibration: wrong pair payloads, shared payload mutation and transient stale state`, + rejections[0]!, + ) + }), + }, + // Law 2 calibration. Each wrapper keeps the full scan correct, so only + // the partial and boundary range checks can reject it. + ...[ + { + design: `ignores includeHigh`, + wrap: (scan: BTree[`forRange`]) => + ((low, high, _includeHigh, onFound) => + scan(low, high, true, onFound)) as typeof scan, + }, + { + design: `ignores the low bound`, + wrap: (scan: BTree[`forRange`]) => + ((_low, high, includeHigh, onFound) => + scan(-Infinity, high, includeHigh, onFound)) as typeof scan, + }, + ].map(({ design, wrap }) => ({ + name: `rejects a range scan that ${design}`, + law: `BTree Map refinement`, + driver: ( + plan: { nodeSize: number; actions: Array }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`BTree Map refinement`, plan, observe, () => { + const tree = new BTree((a, b) => a - b, 4) + const model: ReferenceModel = new Map() + for (let key = 0; key < 40; key += 2) + applyAction(tree, model, { type: `put`, key, v: key }) + clean(() => expectRefinement(tree, model, 21)) + const scan = tree.forRange.bind(tree) + const wrongScan = vi + .spyOn(tree, `forRange`) + .mockImplementation(wrap(scan)) + let rejected: Error + try { + rejected = rejection( + (error) => /forRange\(/.test(error.message), + () => expectRefinement(tree, model, 21), + ) + } finally { + wrongScan.mockRestore() + } + clean(() => expectRefinement(tree, model, 21)) + rejectAt(`calibration: range scan that ${design}`, rejected) + }), + })), + ], }) - 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) - // 64 entries exceed every selected leaf capacity. Deleting all then - // reinserting exercises collapse/reuse without asserting private shape. - const keys = Array.from({ length: 64 }, (_, key) => key) - for (const key of keys) { - const value = { v: key } - expect(tree.set(key, value)).toBe(true) - model.set(key, Object.freeze({ reference: value, v: key })) - expectRefinement(tree, model, key) - } - for (const key of [...keys].reverse()) { - expect(tree.delete(key)).toBe(true) - model.delete(key) - expectValue(tree.get(key), undefined) - expectRefinement(tree, model, key) - } - for (const key of keys) { - const value = { v: key + 100 } - expect(tree.set(key, value)).toBe(true) - model.set(key, Object.freeze({ reference: value, v: key + 100 })) - expectRefinement(tree, model, key) - const replacement = { v: key + 200 } - expect(tree.set(key, replacement)).toBe(false) - model.set(key, Object.freeze({ reference: replacement, v: key + 200 })) - expectRefinement(tree, model, key) - } - tree.clear() - model.clear() - expectRefinement(tree, model, 0) - const value = { v: 500 } - tree.set(0, value) - model.set(0, Object.freeze({ reference: value, v: 500 })) - expectRefinement(tree, model, 0) - }, - ) - - 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([ - [1, Object.freeze({ reference: value, v: 1 })], - ]) - tree.set(1, value) + const runDenseCuts = (nodeSize: number) => { + const tree = new BTree((a, b) => a - b, nodeSize) + const model: ReferenceModel = new Map() expectRefinement(tree, model, 0) - const wrongPair = vi - .spyOn(tree, `nextHigherPair`) - .mockReturnValue([1, { v: 2 }]) - try { - expect(() => expectRefinement(tree, model, 0)).toThrowError(/expected/) - } finally { - wrongPair.mockRestore() + // 64 entries exceed every selected leaf capacity. Deleting all then + // reinserting exercises collapse/reuse without asserting private shape. + const keys = Array.from({ length: 64 }, (_, key) => key) + for (const key of keys) { + const value = { v: key } + expect(tree.set(key, value)).toBe(true) + model.set(key, Object.freeze({ reference: value, v: key })) + expectRefinement(tree, model, key) } - value.v = 2 - expect(tree.get(1)).toBe(value) // The former identity-only check stays green. - expect(() => expectRefinement(tree, model, 0)).toThrowError(/expected/) - value.v = 1 - expectRefinement(tree, model, 0) - // A stale delete result can vanish at a later clear. Judge the earlier cut. - expect(() => expectRefinement(tree, new Map(), 0)).toThrowError(/expected/) - tree.clear() - expectRefinement(tree, new Map(), 0) - const staleScan = vi - .spyOn(tree, `forRange`) - .mockImplementation((_low, _high, _inclusive, callback) => { - callback(1, value) - }) - try { - expect(() => expectRefinement(tree, new Map(), 0)).toThrowError( - /expected/, - ) - } finally { - staleScan.mockRestore() + for (const key of [...keys].reverse()) { + expect(tree.delete(key)).toBe(true) + model.delete(key) + expectValue(tree.get(key), undefined) + expectRefinement(tree, model, key) } - expectRefinement(tree, new Map(), 0) - }) - - // Law 2 calibration. Each wrapper keeps the full scan correct, so only the - // partial and boundary range checks can reject it. - it.each([ - { - design: `ignores includeHigh`, - wrap: (scan: BTree[`forRange`]) => - ((low, high, _includeHigh, onFound) => - scan(low, high, true, onFound)) as typeof scan, - }, - { - design: `ignores the low bound`, - wrap: (scan: BTree[`forRange`]) => - ((_low, high, includeHigh, onFound) => - 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) - applyAction(tree, model, { type: `put`, key, v: key }) - expectRefinement(tree, model, 21) - const scan = tree.forRange.bind(tree) - const wrongScan = vi.spyOn(tree, `forRange`).mockImplementation(wrap(scan)) - try { - expect(() => expectRefinement(tree, model, 21)).toThrowError(/forRange\(/) - } finally { - wrongScan.mockRestore() + for (const key of keys) { + const value = { v: key + 100 } + expect(tree.set(key, value)).toBe(true) + model.set(key, Object.freeze({ reference: value, v: key + 100 })) + expectRefinement(tree, model, key) + const replacement = { v: key + 200 } + expect(tree.set(key, replacement)).toBe(false) + model.set(key, Object.freeze({ reference: replacement, v: key + 200 })) + expectRefinement(tree, model, key) } - expectRefinement(tree, model, 21) + tree.clear() + model.clear() + expectRefinement(tree, model, 0) + const value = { v: 500 } + tree.set(0, value) + model.set(0, Object.freeze({ reference: value, v: 500 })) + expectRefinement(tree, model, 0) + } + oracleProperty({ + id: `btree-map.dense-cuts`, + law: { + statement: `Numeric BTree point, range, fallback and neighbor observations agree with the independent sorted Map after every action.`, + authority: btreeAuthority, + limits: `64 dense puts, reverse deletes, reinsertion with overwrite, clear and one put, at node sizes 4 through 8.`, + }, + owns: [`src/utils/btree.ts`], + histories: [4, 5, 6, 7, 8], + label: (nodeSize) => + `preserves every cut through dense growth, retirement and reuse with node size ${nodeSize}`, + resolve: (nodeSize) => ({ nodeSize }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`BTree Map refinement`, plan, observe, () => + runDenseCuts(plan.nodeSize), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/db/tests/calibration-rejection.ts b/packages/db/tests/calibration-rejection.ts new file mode 100644 index 0000000000..acac383a15 --- /dev/null +++ b/packages/db/tests/calibration-rejection.ts @@ -0,0 +1,56 @@ +// Support for calibrations whose mutant driver replays an existing calibration +// body. The body's shared check must reject each wrong answer with an error +// that the old matcher accepted; the driver then marks one held rejection with +// rejectAt, so the property reports the check's law. Neither helper decides a +// product result: the shared check still does. + +/** + * Runs one wrong answer through the shared check and returns the rejection + * when the old matcher accepts it. Any other outcome throws an unmarked error, + * so the calibration fails as the old test did. + */ +export function rejection( + accepts: (error: Error) => boolean, + wrong: () => unknown, +): Error { + try { + wrong() + } catch (error) { + if (error instanceof Error && accepts(error)) return error + throw new Error( + `the check rejected the wrong answer with an error that the calibration does not accept: ${String(error)}`, + ) + } + throw new Error(`the check accepted the wrong answer`) +} + +/** The asynchronous form of `rejection`. */ +export async function asyncRejection( + accepts: (error: Error) => boolean, + wrong: () => Promise, +): Promise { + try { + await wrong() + } catch (error) { + if (error instanceof Error && accepts(error)) return error + throw new Error( + `the check rejected the wrong answer with an error that the calibration does not accept: ${String(error)}`, + ) + } + throw new Error(`the check accepted the wrong answer`) +} + +/** + * Runs a clean check inside a calibration. Its failure is a broken + * calibration, not the check rejecting a wrong answer, so it throws an + * unmarked error that carries the message but not the marked cause. + */ +export function clean(check: () => void): void { + try { + check() + } catch (error) { + throw new Error( + `a clean check in the calibration failed: ${error instanceof Error ? error.message : String(error)}`, + ) + } +} diff --git a/packages/db/tests/conformance/indexing-reuse-oracle.test.ts b/packages/db/tests/conformance/indexing-reuse-calibration.test.ts similarity index 100% rename from packages/db/tests/conformance/indexing-reuse-oracle.test.ts rename to packages/db/tests/conformance/indexing-reuse-calibration.test.ts diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index a75e3edbe2..94477a6b2b 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -33,6 +33,10 @@ export const properties: ReadonlyArray = [ `duration-query-equality.calendar-indexing`, `index-suggestion.collection-size`, `index-suggestion.ordered-diagnostic`, + `btree-map.split-comparator`, + `btree-map.infinite-comparator`, + `btree-map.long-campaign`, + `btree-map.dense-cuts`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -76,6 +80,11 @@ export const owners: ReadonlyArray = [ `index-suggestion`, `collection-size ordered-diagnostic`, ], + [ + `db/tests/btree-map-oracle.test.ts`, + `btree-map`, + `split-comparator infinite-comparator long-campaign dense-cuts`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance From 03ea3f6163b5a88553e0dc95f720b203a2612543 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:11:43 -0700 Subject: [PATCH 068/116] test(db): register db-collection oracle tests only through oracleProperty Remove declareOracle from the collection, subscription, source-health, sync, change-event, cleanup-queue, d2, local-storage, truncate, effect-disposal, notification-laws, and db-client hydration oracles (maintainer decision D2, 2026-11-11). Fixed histories, cases, and calibrations now run as enumerated oracleProperty sources through the same drivers, models, and checks. Grammar-size checks, model and checker calibrations, and literal-expectation tests move to ordinary test files. Their grammar and model code moves unchanged into oracle-named companion modules. The new property ids and replay owners are in converted-properties/db-collection.ts. Six fixed-seed censuses that run production stay as plain tests, because their chained or mapped arbitraries cannot take constructed histories. Co-authored-by: Isaac --- .../change-event-history-model-oracle.ts | 120 + .../tests/change-event-history-oracle.test.ts | 640 +++-- .../db/tests/change-event-history.test.ts | 47 + .../cleanup-queue-oracle.property.test.ts | 79 +- ...llection-cleanup-restart-grammar-oracle.ts | 51 + .../collection-cleanup-restart-oracle.test.ts | 1844 +++++++------- .../tests/collection-cleanup-restart.test.ts | 44 +- ...ection-gc-mutation-oracle.property.test.ts | 52 +- ...llection-gc-retention-calibration.test.ts} | 52 +- ...ction-gc-retention-oracle.property.test.ts | 40 +- .../tests/collection-gc-retention-oracle.ts | 9 - ...ion-gc-transaction-oracle.property.test.ts | 5 - .../collection-index-value-oracle.test.ts | 57 +- ...tadata-publication-oracle.property.test.ts | 527 ++-- .../collection-metadata-publication-oracle.ts | 10 - ...n-row-metadata-composition-model-oracle.ts | 357 +++ ...on-row-metadata-composition-oracle.test.ts | 796 ++----- ...ollection-row-metadata-composition.test.ts | 169 ++ ...on-state-retention-oracle.property.test.ts | 1175 ++++++--- ...ion-subscription-lifecycle-cell-support.ts | 4 - ...ction-subscription-lifecycle-cells.test.ts | 80 + ...n-subscription-lifecycle-grammar-oracle.ts | 15 - ...ion-subscription-lifecycle-grammar.test.ts | 25 + ...-lifecycle-history-oracle.property.test.ts | 1857 ++++++++------- ...tion-subscription-lifecycle-oracle.test.ts | 2122 +++++++++-------- ...ecycle-publication-oracle.property.test.ts | 1823 +++++++------- ...on-lifecycle-publication-product-oracle.ts | 226 ++ ...subscription-lifecycle-publication.test.ts | 29 + ...ion-subscription-reentrancy-oracle.test.ts | 249 +- ...tion-subscription-replay-grammar-oracle.ts | 165 ++ ...ubscription-replay-oracle.property.test.ts | 786 +++--- .../collection-subscription-replay.test.ts | 64 + .../collection-sync-reentrancy-oracle.test.ts | 462 ++-- ...on-sync-reentrancy-ready-grammar-oracle.ts | 80 + .../tests/collection-sync-reentrancy.test.ts | 25 + .../converted-properties/db-collection.ts | 206 +- .../d2-source-reconciliation-model-oracle.ts | 255 ++ ...rce-reconciliation-oracle.property.test.ts | 427 +--- .../db/tests/d2-source-reconciliation.test.ts | 168 ++ ...client-hydration-authority-model-oracle.ts | 234 ++ ...-client-hydration-authority-oracle.test.ts | 735 ++---- .../db-client-hydration-authority.test.ts | 260 ++ .../db/tests/effect-disposal-oracle.test.ts | 271 ++- .../local-only-direct-write-oracle.test.ts | 62 +- .../tests/local-storage-order-oracle.test.ts | 69 +- ...age-value-fidelity-oracle.property.test.ts | 152 +- .../notification-laws-calibration.test.ts | 42 + .../db/tests/notification-laws-oracle.test.ts | 39 +- .../db/tests/source-health-oracle.test.ts | 2041 ++++++++-------- .../db/tests/sync-metadata-recovery-oracle.ts | 10 - ...c-metadata-session-oracle.property.test.ts | 538 +---- .../db/tests/sync-metadata-session-oracle.ts | 33 - ...metadata-session-witness-grammar-oracle.ts | 323 +++ .../db/tests/sync-metadata-session.test.ts | 215 +- .../tests/truncate-readiness-oracle.test.ts | 283 ++- 55 files changed, 11306 insertions(+), 9143 deletions(-) create mode 100644 packages/db/tests/change-event-history-model-oracle.ts create mode 100644 packages/db/tests/collection-cleanup-restart-grammar-oracle.ts rename packages/db/tests/{collection-gc-retention-oracle.test.ts => collection-gc-retention-calibration.test.ts} (69%) create mode 100644 packages/db/tests/collection-row-metadata-composition-model-oracle.ts create mode 100644 packages/db/tests/collection-row-metadata-composition.test.ts create mode 100644 packages/db/tests/collection-subscription-lifecycle-grammar.test.ts create mode 100644 packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts create mode 100644 packages/db/tests/collection-subscription-replay-grammar-oracle.ts create mode 100644 packages/db/tests/collection-sync-reentrancy-ready-grammar-oracle.ts create mode 100644 packages/db/tests/d2-source-reconciliation-model-oracle.ts create mode 100644 packages/db/tests/db-client-hydration-authority-model-oracle.ts create mode 100644 packages/db/tests/db-client-hydration-authority.test.ts create mode 100644 packages/db/tests/notification-laws-calibration.test.ts create mode 100644 packages/db/tests/sync-metadata-session-witness-grammar-oracle.ts diff --git a/packages/db/tests/change-event-history-model-oracle.ts b/packages/db/tests/change-event-history-model-oracle.ts new file mode 100644 index 0000000000..0dac68d888 --- /dev/null +++ b/packages/db/tests/change-event-history-model-oracle.ts @@ -0,0 +1,120 @@ +// The model and checker helpers of the change-event history oracle, shared +// with the ordinary calibration tests in change-event-history.test.ts. The +// oracle that uses them is change-event-history-oracle.test.ts, whose opening +// comment states the law; these helpers moved here unchanged. +import { expect } from 'vitest' +import { oracleAssertion } from './oracle-assertion-adapter' +import { fileIdForKey } from './change-event-history-fixture.js' +import type { + Key, + Op, + OpKind, + TestItem, +} from './change-event-history-fixture.js' +import type { BaseIndex } from '../src/indexes/base-index' +import type { ChangeMessage } from '../src/types' + +export type EventProjection = { + type: OpKind + value: TestItem + previousValue?: TestItem +} + +export function applyModelOp(rows: Map, op: Op): void { + if (op.kind === `delete`) rows.delete(op.key) + else + rows.set(op.key, { + id: op.key, + name: `v${op.step}`, + fileId: fileIdForKey(op.key), + }) +} + +export function expectedRowsAfter( + initialKeys: ReadonlyArray, + sequence: ReadonlyArray, +): Map { + const rows = new Map( + initialKeys.map((key) => [ + key, + { id: key, name: `initial-${key}`, fileId: fileIdForKey(key) }, + ]), + ) + for (const op of sequence) applyModelOp(rows, op) + return rows +} + +// A batch permits any interleaving of distinct keys. Each key's trace keeps +// message order and multiplicity, which the Collection mirror depends on. +export function eventRow(row: TestItem): TestItem { + return { id: row.id, name: row.name, fileId: row.fileId } +} + +export function perKeyEventTrace( + changes: ReadonlyArray>, +): Array]> { + const byKey = new Map>() + for (const change of changes) { + const trace = byKey.get(change.key) ?? [] + trace.push({ + type: change.type, + value: eventRow(change.value), + ...(change.previousValue === undefined + ? {} + : { previousValue: eventRow(change.previousValue) }), + }) + byKey.set(change.key, trace) + } + return [...byKey].sort(([left], [right]) => left - right) +} + +export function expectedEventTrace( + sequence: ReadonlyArray, +): Array]> { + const rows = new Map() + const byKey = new Map>() + for (const op of sequence) { + const before = rows.get(op.key) + applyModelOp(rows, op) + const after = rows.get(op.key) + let event: EventProjection + if (op.kind === `delete`) { + if (!before || after) throw new Error(`model delete requires a row`) + event = { type: `delete`, value: before } + } else if (op.kind === `insert`) { + if (before || !after) + throw new Error(`model insert requires an absent key`) + event = { type: `insert`, value: after } + } else { + if (!before || !after) throw new Error(`model update requires a row`) + event = { + type: `update`, + value: after, + previousValue: before, + } + } + const trace = byKey.get(op.key) ?? [] + trace.push(event) + byKey.set(op.key, trace) + } + return [...byKey].sort(([left], [right]) => left - right) +} + +export function assertIndexAgreement( + index: BaseIndex, + expected: ReadonlyMap, +): void { + oracleAssertion('assertIndexAgreement', () => + expect(index.keyCount).toBe(expected.size), + ) + for (const fileId of [`f1`, `f2`] as const) { + const expectedKeys = new Set( + [...expected] + .filter(([, row]) => row.fileId === fileId) + .map(([key]) => key), + ) + oracleAssertion('assertIndexAgreement', () => + expect(index.lookup(`eq`, fileId)).toEqual(expectedKeys), + ) + } +} diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 776f4e235d..0b524fe104 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -1,6 +1,6 @@ import { fc } from '@fast-check/vitest' import { Temporal } from 'temporal-polyfill' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { BTreeIndex, BasicIndex, @@ -9,9 +9,7 @@ import { eq, } from '../src/index' import { localOnlyCollectionOptions } from '../src/local-only' -import { PropRef } from '../src/query/ir' -import { oracleProperty, oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' +import { oracleProperty } from './oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -26,12 +24,13 @@ import { mirrorRows, replacement, } from './change-event-history-fixture.js' -import type { - Key, - Op, - OpKind, - TestItem, -} from './change-event-history-fixture.js' +import { + assertIndexAgreement, + expectedEventTrace, + expectedRowsAfter, + perKeyEventTrace, +} from './change-event-history-model-oracle.js' +import type { Key, Op, TestItem } from './change-event-history-fixture.js' import type { LocalOnlyCollectionUtils } from '../src/local-only' import type { Collection } from '../src/index' import type { BaseIndex } from '../src/indexes/base-index' @@ -96,12 +95,6 @@ import type { ChangeMessage, SyncConfig } from '../src/types' type IndexType = typeof BasicIndex | typeof BTreeIndex type SyncActions = Parameters[`sync`]>[0] -type EventProjection = { - type: OpKind - value: TestItem - previousValue?: TestItem -} - type GeneratedHistory = { initialKeys: ReadonlyArray sequence: ReadonlyArray @@ -132,86 +125,6 @@ function opsFromPresence( }) } -function applyModelOp(rows: Map, op: Op): void { - if (op.kind === `delete`) rows.delete(op.key) - else - rows.set(op.key, { - id: op.key, - name: `v${op.step}`, - fileId: fileIdForKey(op.key), - }) -} - -function expectedRowsAfter( - initialKeys: ReadonlyArray, - sequence: ReadonlyArray, -): Map { - const rows = new Map( - initialKeys.map((key) => [ - key, - { id: key, name: `initial-${key}`, fileId: fileIdForKey(key) }, - ]), - ) - for (const op of sequence) applyModelOp(rows, op) - return rows -} - -// A batch permits any interleaving of distinct keys. Each key's trace keeps -// message order and multiplicity, which the Collection mirror depends on. -function eventRow(row: TestItem): TestItem { - return { id: row.id, name: row.name, fileId: row.fileId } -} - -function perKeyEventTrace( - changes: ReadonlyArray>, -): Array]> { - const byKey = new Map>() - for (const change of changes) { - const trace = byKey.get(change.key) ?? [] - trace.push({ - type: change.type, - value: eventRow(change.value), - ...(change.previousValue === undefined - ? {} - : { previousValue: eventRow(change.previousValue) }), - }) - byKey.set(change.key, trace) - } - return [...byKey].sort(([left], [right]) => left - right) -} - -function expectedEventTrace( - sequence: ReadonlyArray, -): Array]> { - const rows = new Map() - const byKey = new Map>() - for (const op of sequence) { - const before = rows.get(op.key) - applyModelOp(rows, op) - const after = rows.get(op.key) - let event: EventProjection - if (op.kind === `delete`) { - if (!before || after) throw new Error(`model delete requires a row`) - event = { type: `delete`, value: before } - } else if (op.kind === `insert`) { - if (before || !after) - throw new Error(`model insert requires an absent key`) - event = { type: `insert`, value: after } - } else { - if (!before || !after) throw new Error(`model update requires a row`) - event = { - type: `update`, - value: after, - previousValue: before, - } - } - const trace = byKey.get(op.key) ?? [] - trace.push(event) - byKey.set(op.key, trace) - } - return [...byKey].sort(([left], [right]) => left - right) -} - function applyOp( collection: Collection, op: Op, @@ -260,25 +173,6 @@ function assertFinalAgreement( ) } -function assertIndexAgreement( - index: BaseIndex, - expected: ReadonlyMap, -): void { - oracleAssertion('assertIndexAgreement', () => - expect(index.keyCount).toBe(expected.size), - ) - for (const fileId of [`f1`, `f2`] as const) { - const expectedKeys = new Set( - [...expected] - .filter(([, row]) => row.fileId === fileId) - .map(([key]) => key), - ) - oracleAssertion('assertIndexAgreement', () => - expect(index.lookup(`eq`, fileId)).toEqual(expectedKeys), - ) - } -} - async function runWithCleanup( check: () => Promise, cleanup: () => Promise, @@ -627,15 +521,149 @@ function expectDurationFieldPublication( expect(fields(mirror.get(1))).toEqual(expectedFields) } -declareOracle({ - check: [ - assertFinalAgreement, - assertIndexAgreement, - runHistory, - expectDurationFieldPublication, - ], - model: [applyModelOp, expectedRowsAfter, expectedEventTrace], -}) +async function runDurationFieldChangeHistory(): Promise { + let sync!: Parameters[`sync`]>[0] + const collection = createCollection({ + id: `duration-field-change-history`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (actions) => { + sync = actions + actions.markReady() + }, + }, + }) + + await runWithCleanup( + async () => { + await collection.stateWhenReady() + const first = { + id: 1, + duration: Temporal.Duration.from({ seconds: 1 }), + } + const second = { + id: 1, + duration: Temporal.Duration.from({ milliseconds: 1000 }), + } + expect(first.duration.toString()).toBe(second.duration.toString()) + sync.begin() + sync.write({ type: `insert`, value: first }) + expect(sync.commit()).toBe(true) + + // The model applies each valid source write to a plain keyed Map. + // A subscriber maintains an independent mirror from public messages. + const expected = new Map([[1, first]]) + const mirror = new Map(expected) + const received: Array> = [] + const subscription = collection.subscribeChanges( + (changes) => { + received.push(...changes) + for (const change of changes) { + if (change.type === `delete`) mirror.delete(change.key) + else mirror.set(change.key, change.value) + } + }, + { includeInitialState: false }, + ) + try { + expected.set(1, second) + sync.begin() + sync.write({ type: `update`, value: second }) + expect(sync.commit()).toBe(true) + + expectDurationFieldPublication( + expected, + collection.state, + received, + mirror, + ) + } finally { + subscription.unsubscribe() + } + }, + () => collection.cleanup(), + ) +} + +async function runDeferredCausalHistory( + rounds: ReadonlyArray>, +): Promise { + const sequence = rounds.flat() + let sync!: SyncActions + const collection = createCollection({ + id: `deferred-causal-change-history`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (actions) => { + sync = actions + actions.markReady() + }, + }, + }) + const callbacks: Array>> = [] + const subscription = collection.subscribeChanges( + (changes) => callbacks.push(changes), + { includeInitialState: false }, + ) + const publication = collection._deferPublication() + await runWithCleanup( + async () => { + await collection.stateWhenReady() + for (const round of rounds) { + sync.begin() + for (const op of round) { + if (op.kind === `delete`) { + sync.write({ type: `delete`, key: op.key }) + } else { + sync.write({ + type: op.kind, + value: { + id: op.key, + name: `v${op.step}`, + fileId: fileIdForKey(op.key), + }, + }) + } + } + expect(sync.commit()).toBe(true) + } + expect(callbacks).toEqual([]) + publication.publish() + expect(callbacks).toHaveLength(1) + expect(perKeyEventTrace(callbacks[0]!)).toEqual( + expectedEventTrace(sequence), + ) + expect( + [...collection.state].map(([key, row]) => [key, row.name]), + ).toEqual([[2, `v6`]]) + }, + async () => { + const cleanupFailures: Array = [] + for (const release of [ + () => publication.discard(), + () => subscription.unsubscribe(), + () => collection.cleanup(), + ]) { + try { + await release() + } catch (error) { + cleanupFailures.push(error) + } + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + cleanupFailures, + `Causal oracle cleanup failed`, + { + cause: cleanupFailures[0], + }, + ) + } + }, + ) +} describe(`change-event history oracle`, () => { // Published #1519 keeps equivalent Duration forms distinct for query `eq`. @@ -647,162 +675,69 @@ describe(`change-event history oracle`, () => { // public observations must reflect the second value. // 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`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: (actions) => { - sync = actions - actions.markReady() - }, - }, - }) - - await runWithCleanup( - async () => { - await collection.stateWhenReady() - const first = { - id: 1, - duration: Temporal.Duration.from({ seconds: 1 }), - } - const second = { - id: 1, - duration: Temporal.Duration.from({ milliseconds: 1000 }), - } - expect(first.duration.toString()).toBe(second.duration.toString()) - sync.begin() - sync.write({ type: `insert`, value: first }) - expect(sync.commit()).toBe(true) - - // The model applies each valid source write to a plain keyed Map. - // A subscriber maintains an independent mirror from public messages. - const expected = new Map([[1, first]]) - const mirror = new Map(expected) - const received: Array> = [] - const subscription = collection.subscribeChanges( - (changes) => { - received.push(...changes) - for (const change of changes) { - if (change.type === `delete`) mirror.delete(change.key) - else mirror.set(change.key, change.value) - } - }, - { includeInitialState: false }, - ) - try { - expected.set(1, second) - sync.begin() - sync.write({ type: `update`, value: second }) - expect(sync.commit()).toBe(true) - - expectDurationFieldPublication( - expected, - collection.state, - received, - mirror, - ) - } finally { - subscription.unsubscribe() - } - }, - () => collection.cleanup(), - ) + const durationHistories = [ + { + name: `publishes a Duration field change to the Collection and change-message consumer`, + }, + ] + oracleProperty({ + id: `collection-state.duration-field-change`, + law: { + statement: `A source replacement that changes a Duration's intrinsic fields reaches the public row and the change-message mirror, as the reference Map predicts.`, + authority: `Collection change-message contract (#1901) and the published Duration equality of #1519, stated above`, + limits: `One two-step same-realm Duration history; no altered objects and no suppression of events for an equal copy.`, + }, + owns: [`src/collection/changes.ts`, `src/local-only.ts`], + histories: durationHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`duration field change`, plan, observe, () => + runDurationFieldChangeHistory(), + ), + check: forwardAssertionJudgment, }) - it(`preserves each key's causal messages within a deferred callback`, async () => { - oracleRoute(`fixed history`) - const rounds: ReadonlyArray> = [ - [ - { kind: `insert`, key: 1, step: 1 }, - { kind: `insert`, key: 2, step: 2 }, + const deferredCausalHistories: Array<{ + name: string + rounds: ReadonlyArray> + }> = [ + { + name: `preserves each key's causal messages within a deferred callback`, + rounds: [ + [ + { kind: `insert`, key: 1, step: 1 }, + { kind: `insert`, key: 2, step: 2 }, + ], + [ + { kind: `update`, key: 1, step: 3 }, + { kind: `update`, key: 2, step: 4 }, + ], + [ + { kind: `delete`, key: 1, step: 5 }, + { kind: `update`, key: 2, step: 6 }, + ], ], - [ - { kind: `update`, key: 1, step: 3 }, - { kind: `update`, key: 2, step: 4 }, - ], - [ - { kind: `delete`, key: 1, step: 5 }, - { kind: `update`, key: 2, step: 6 }, - ], - ] - const sequence = rounds.flat() - let sync!: SyncActions - const collection = createCollection({ - id: `deferred-causal-change-history`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: (actions) => { - sync = actions - actions.markReady() - }, - }, - }) - const callbacks: Array>> = [] - const subscription = collection.subscribeChanges( - (changes) => callbacks.push(changes), - { includeInitialState: false }, - ) - const publication = collection._deferPublication() - await runWithCleanup( - async () => { - await collection.stateWhenReady() - for (const round of rounds) { - sync.begin() - for (const op of round) { - if (op.kind === `delete`) { - sync.write({ type: `delete`, key: op.key }) - } else { - sync.write({ - type: op.kind, - value: { - id: op.key, - name: `v${op.step}`, - fileId: fileIdForKey(op.key), - }, - }) - } - } - expect(sync.commit()).toBe(true) - } - expect(callbacks).toEqual([]) - publication.publish() - expect(callbacks).toHaveLength(1) - expect(perKeyEventTrace(callbacks[0]!)).toEqual( - expectedEventTrace(sequence), - ) - expect( - [...collection.state].map(([key, row]) => [key, row.name]), - ).toEqual([[2, `v6`]]) - }, - async () => { - const cleanupFailures: Array = [] - for (const release of [ - () => publication.discard(), - () => subscription.unsubscribe(), - () => collection.cleanup(), - ]) { - try { - await release() - } catch (error) { - cleanupFailures.push(error) - } - } - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `Causal oracle cleanup failed`, - { - cause: cleanupFailures[0], - }, - ) - } - }, - ) + }, + ] + oracleProperty({ + id: `collection-state.deferred-causal-messages`, + law: { + statement: `A deferred publication delivers each key's messages in causal order with complete row values, matching the model's per-key event trace; cross-key interleaving is unconstrained.`, + authority: `Collection change-message contract and the maintainer cross-key ordering decision stated above`, + limits: `One deferred sync history of three rounds over two keys.`, + }, + owns: [`src/collection/changes.ts`, `src/local-only.ts`], + histories: deferredCausalHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`deferred causal messages`, plan, observe, () => + runDeferredCausalHistory(plan.history.rounds), + ), + check: forwardAssertionJudgment, }) const indexTypes: Array = [ @@ -849,61 +784,38 @@ describe(`change-event history oracle`, () => { return { initialKeys: [1, 2], sequence: [...replacement, ...tail], mode } }) - 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 }, - ] - const expected = expectedEventTrace(sequence) - expect( - perKeyEventTrace([ - { - type: `insert`, - key: 1, - value: { id: 1, name: `v1`, fileId: `f2` }, - }, - { - type: `update`, - key: 1, - value: { id: 1, name: `v2`, fileId: `f1` }, - previousValue: { id: 1, name: `v1`, fileId: `f2` }, - }, - ]), - ).not.toEqual(expected) - }) - - it(`rejects a missing live key in the index checker`, () => { - oracleRoute(`calibration`) - expect(replacement.map(({ kind, key }) => [kind, key])).toEqual([ - [`delete`, 1], - [`delete`, 2], - [`insert`, 1], - [`insert`, 2], - ]) - const expected = expectedRowsAfter([1, 2], replacement) - const index = new BasicIndex(1, new PropRef([`fileId`])) - index.build([...expected]) - assertIndexAgreement(index, expected) - index.remove(1, expected.get(1)) - expect(() => assertIndexAgreement(index, expected)).toThrowError(/expected/) - }) - - for (const [name, IndexType] of indexTypes) { - it.each([`batched`, `sequential`] as const)( - `keeps ${name} and indexed queries aligned after %s replacement`, - async (mode) => { - oracleRoute(`fixed history`) - await runHistory( + const indexReplacementHistories = indexTypes.flatMap(([index]) => + ([`batched`, `sequential`] as const).map((mode) => ({ index, mode })), + ) + const indexTypesByName = new Map(indexTypes) + oracleProperty({ + id: `collection-state.eager-index-replacement`, + law: { + statement: `Eager index buckets and indexed query rows agree with the independent authored Map at every settled prefix and final query checkpoint.`, + authority: `Collection change-message and eager-index contracts stated above`, + limits: `The fixed replacement history (delete then reinsert keys 1 and 2) for BasicIndex and BTreeIndex in batched and sequential modes; the generated indexed grammar cannot take it back.`, + }, + owns: [`src/collection/changes.ts`, `src/local-only.ts`], + histories: indexReplacementHistories, + label: ({ index, mode }) => + `keeps ${index} and indexed queries aligned after ${mode} replacement`, + resolve: (history) => ({ + history, + IndexType: indexTypesByName.get(history.index)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`eager index replacement`, plan, observe, () => + runHistory( replacement, [1, 2], - mode, - `indexed-${name}-${mode}`, - IndexType, - ) - }, - ) - } + plan.history.mode, + `indexed-${plan.history.index}-${plan.history.mode}`, + plan.IndexType, + ), + ), + check: forwardAssertionJudgment, + }) const verifyIndexedHistory = async ({ initialKeys, @@ -942,31 +854,59 @@ describe(`change-event history oracle`, () => { check: forwardAssertionJudgment, }) - it.each(cases)( - `$id: $label reconstructs every public row`, - async ({ mode, sequence, initialKeys, id }) => { - oracleRoute(`case`) - await runHistory(sequence, initialKeys, mode, `history-oracle-${id}`) + oracleProperty({ + id: `collection-state.change-event-cases`, + law: { + statement: `Each settled change-message history reconstructs the Collection's public rows through valid per-key messages.`, + authority: `Collection change-message contract stated above`, + limits: `The bounded enumeration: one-key histories through length four from an absent row and two-key histories through length three from every initial presence state, batched and sequential.`, }, - ) + owns: [`src/collection/changes.ts`, `src/local-only.ts`], + histories: cases, + label: ({ id, label }) => `${id}: ${label} reconstructs every public row`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`change-event case`, plan, observe, () => + runHistory( + plan.history.sequence, + plan.history.initialKeys, + plan.history.mode, + `history-oracle-${plan.history.id}`, + ), + ), + check: forwardAssertionJudgment, + }) // Every bounded history again, ending with a reload. It includes the // reported trace: initialData [1], update 1, reload. - it.each(cases)( - `$id: $label then reload reconstructs every public row`, - async ({ mode, sequence, initialKeys, id }) => { - oracleRoute(`case`) - await runHistory( - sequence, - initialKeys, - mode, - `history-oracle-reload-${id}`, - undefined, - undefined, - true, - ) + oracleProperty({ + id: `collection-state.change-event-reload-cases`, + law: { + statement: `Each settled change-message history that ends with a reload keeps the local-only rows, and a later write reaches the rows and the mirror.`, + authority: `Collection change-message contract and the maintainer reload decision stated above`, + limits: `The same bounded enumeration as collection-state.change-event-cases, each history ending with a reload.`, }, - ) + owns: [`src/collection/changes.ts`, `src/local-only.ts`], + histories: cases, + label: ({ id, label }) => + `${id}: ${label} then reload reconstructs every public row`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`change-event reload case`, plan, observe, () => + runHistory( + plan.history.sequence, + plan.history.initialKeys, + plan.history.mode, + `history-oracle-reload-${plan.history.id}`, + undefined, + undefined, + true, + ), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: `collection-state.change-event-history`, diff --git a/packages/db/tests/change-event-history.test.ts b/packages/db/tests/change-event-history.test.ts index 876b3d12c9..0147623ffb 100644 --- a/packages/db/tests/change-event-history.test.ts +++ b/packages/db/tests/change-event-history.test.ts @@ -5,11 +5,20 @@ */ import { describe, expect, it } from 'vitest' +import { BasicIndex } from '../src/index' +import { PropRef } from '../src/query/ir' +import { + assertIndexAgreement, + expectedEventTrace, + expectedRowsAfter, + perKeyEventTrace, +} from './change-event-history-model-oracle.js' import { cases, generateSequences, mirrorRows, oneKeyCases, + replacement, twoKeyCases, } from './change-event-history-fixture.js' import type { Key, Op } from './change-event-history-fixture.js' @@ -47,4 +56,42 @@ describe(`change-event history oracle`, () => { ), ).not.toEqual(expected) }) + + it(`rejects a deferred message with the right name but wrong row fields`, () => { + const sequence: Array = [ + { kind: `insert`, key: 1, step: 1 }, + { kind: `update`, key: 1, step: 2 }, + ] + const expected = expectedEventTrace(sequence) + expect( + perKeyEventTrace([ + { + type: `insert`, + key: 1, + value: { id: 1, name: `v1`, fileId: `f2` }, + }, + { + type: `update`, + key: 1, + value: { id: 1, name: `v2`, fileId: `f1` }, + previousValue: { id: 1, name: `v1`, fileId: `f2` }, + }, + ]), + ).not.toEqual(expected) + }) + + it(`rejects a missing live key in the index checker`, () => { + expect(replacement.map(({ kind, key }) => [kind, key])).toEqual([ + [`delete`, 1], + [`delete`, 2], + [`insert`, 1], + [`insert`, 2], + ]) + const expected = expectedRowsAfter([1, 2], replacement) + const index = new BasicIndex(1, new PropRef([`fileId`])) + index.build([...expected]) + assertIndexAgreement(index, expected) + index.remove(1, expected.get(1)) + expect(() => assertIndexAgreement(index, expected)).toThrowError(/expected/) + }) }) diff --git a/packages/db/tests/cleanup-queue-oracle.property.test.ts b/packages/db/tests/cleanup-queue-oracle.property.test.ts index 20f7c30885..db19a194ba 100644 --- a/packages/db/tests/cleanup-queue-oracle.property.test.ts +++ b/packages/db/tests/cleanup-queue-oracle.property.test.ts @@ -1,8 +1,7 @@ import fc from 'fast-check' -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { CleanupQueue } from '../src/collection/cleanup-queue' -import { oracleProperty, oracleRoute, pass, violation } from './oracle-config' -import { declareOracle } from './oracle-declaration' +import { oracleProperty, pass, violation } from './oracle-config' import { resetCleanupQueue } from './utils' import type { Observation, Verdict } from './oracle-config' @@ -125,11 +124,6 @@ function expectQueueState( ).toEqual([...model.errors].sort()) } -declareOracle({ - check: [expectQueueState], - model: [stepModel], -}) - type QueueObservation = { deliveries: Array errors: Array> @@ -286,19 +280,6 @@ async function driveHistory( if (failed) throw primaryFailure } -// Fixed wrong-driver controls retain their original assertion boundary. -async function runHistory(actions: Array, fault: Fault = `none`) { - const expected = modelHistory(actions) - let cut = 0 - await driveHistory( - actions, - (_checkpoint, actual) => { - expectCut(actual, expected[cut++]!) - }, - fault, - ) -} - const actionArbitrary: fc.Arbitrary = fc.oneof( fc.record({ kind: fc.constant(`schedule` as const), @@ -335,6 +316,16 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( // 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 grammar. +// The faulty-queue controls below run this history: a replacement, a +// cancellation, and one advance past both deadlines. +const faultControlHistory: Array = [ + { kind: `schedule`, key: 0, delay: 1, throws: false }, + { kind: `schedule`, key: 0, delay: 3, throws: false }, + { kind: `schedule`, key: 1, delay: 2, throws: false }, + { kind: `cancel`, key: 1 }, + { kind: `advance`, elapsed: 3 }, +] + const fixedHistories: Array<[Array]> = [ [ [ @@ -396,32 +387,30 @@ oracleProperty({ arbitrary: fc.array(actionArbitrary, { maxLength: 50 }), runs: 100, fixedSeeds: [20260913], - fixed: fixedHistories.map(([history], index) => ({ - name: `appointment regression ${index + 1}`, - history, - })), + // The control history comes first, so each faulty-queue calibration + // tries it before any other candidate. + fixed: [ + { name: `faulty queue control`, history: faultControlHistory }, + ...fixedHistories.map(([history], index) => ({ + name: `appointment regression ${index + 1}`, + history, + })), + ], }, model: modelHistory, driver: (actions, observe) => driveHistory(actions, observe), check: checkHistory, + // These controls prove that the oracle rejects four plausible wrong queues. + // The check reports law `appointment histories` only for an AssertionError + // at a recorded cut, the rejection that each original control required. + calibrations: ( + [`ignore-cancel`, `lose-replacement`, `duplicate`, `late`] as const + ).map((fault) => ({ + name: `rejects the ${fault} faulty queue`, + law: `appointment histories`, + driver: ( + actions: Array, + observe: (checkpoint: string, value: unknown) => void, + ) => driveHistory(actions, observe, fault), + })), }) - -// These controls prove that the oracle rejects four plausible wrong queues. -it.each([`ignore-cancel`, `lose-replacement`, `duplicate`, `late`] as const)( - `rejects the %s faulty queue`, - async (fault) => { - oracleRoute(`calibration`) - await expect( - runHistory( - [ - { kind: `schedule`, key: 0, delay: 1, throws: false }, - { kind: `schedule`, key: 0, delay: 3, throws: false }, - { kind: `schedule`, key: 1, delay: 2, throws: false }, - { kind: `cancel`, key: 1 }, - { kind: `advance`, elapsed: 3 }, - ], - fault, - ), - ).rejects.toMatchObject({ name: `AssertionError` }) - }, -) diff --git a/packages/db/tests/collection-cleanup-restart-grammar-oracle.ts b/packages/db/tests/collection-cleanup-restart-grammar-oracle.ts new file mode 100644 index 0000000000..214a1edad1 --- /dev/null +++ b/packages/db/tests/collection-cleanup-restart-grammar-oracle.ts @@ -0,0 +1,51 @@ +// The failing-dependents history grammar of the Collection cleanup oracle, +// moved unchanged from collection-cleanup-restart-oracle.test.ts so that the +// ordinary grammar test in collection-cleanup-restart.test.ts can import it. +// The oracle's "Cleanup with failing dependents" section describes it. + +export type DependentEnd = + | `resolve` + | `throw` + | `reject` + | `held-resolve` + | `held-reject` + | `pooled-throw` +export type AdapterCleanup = `sync` | `held-resolve` | `held-reject` +export type DependentCleanupCell = { + id: string + ends: readonly [DependentEnd, DependentEnd] + adapter: AdapterCleanup + // Which side the driver releases first when both hold. + adapterFirst: boolean +} + +export const dependentEnds: ReadonlyArray = [ + `resolve`, + `throw`, + `reject`, + `held-resolve`, + `held-reject`, + `pooled-throw`, +] +export const adapterCleanups: ReadonlyArray = [ + `sync`, + `held-resolve`, + `held-reject`, +] +export const holds = (behavior: string) => behavior.startsWith(`held`) +export const dependentCleanupCells: ReadonlyArray = + dependentEnds.flatMap((first) => + dependentEnds.flatMap((second) => + adapterCleanups.flatMap((adapter) => + (holds(adapter) && (holds(first) || holds(second)) + ? [false, true] + : [false] + ).map((adapterFirst) => ({ + id: `${first}+${second}:${adapter}${adapterFirst ? `:adapter-first` : ``}`, + ends: [first, second] as const, + adapter, + adapterFirst, + })), + ), + ), + ) diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index c0b971bd30..cad82d3d60 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -1,4 +1,4 @@ -import { afterAll, describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { BasicIndex, createCollection, @@ -9,15 +9,25 @@ import { import { createDeferred } from '../src/deferred' import { Query } from '../src/query/builder/index.js' import { createPooledLiveQuery } from '../src/query/pooled-live-query.js' -import { oracleReplayRequested, oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' +import { + dependentCleanupCells, + holds, +} from './collection-cleanup-restart-grammar-oracle.js' import { flushPromises } from './utils' import { cleanupError, - completedBoundaryScenarios, - reentryScenarios, withOracleCleanup, } from './collection-cleanup-restart-support.js' +import type { + DependentCleanupCell, + DependentEnd, +} from './collection-cleanup-restart-grammar-oracle.js' import type { CollectionDependent } from '../src/types' import type { Row } from './collection-cleanup-restart-support.js' @@ -153,15 +163,6 @@ function observeDependent(active: boolean): `active` | `terminal` { return active ? `active` : `terminal` } -function expectExactCaseIds( - actual: ReadonlyArray<{ id: string }>, - expected: ReadonlyArray, -): void { - const ids = actual.map(({ id }) => id) - expect(ids).toEqual(expected) - expect(new Set(ids).size).toBe(ids.length) -} - /** * ## Cleanup with failing dependents * @@ -209,53 +210,9 @@ function expectExactCaseIds( * Limits: one Collection with two dependents; Effects and nested live-query * chains are covered only by the admission history above. */ -type DependentEnd = - | `resolve` - | `throw` - | `reject` - | `held-resolve` - | `held-reject` - | `pooled-throw` -type AdapterCleanup = `sync` | `held-resolve` | `held-reject` -type DependentCleanupCell = { - id: string - ends: readonly [DependentEnd, DependentEnd] - adapter: AdapterCleanup - // Which side the driver releases first when both hold. - adapterFirst: boolean -} - -const dependentEnds: ReadonlyArray = [ - `resolve`, - `throw`, - `reject`, - `held-resolve`, - `held-reject`, - `pooled-throw`, -] -const adapterCleanups: ReadonlyArray = [ - `sync`, - `held-resolve`, - `held-reject`, -] -const holds = (behavior: string) => behavior.startsWith(`held`) -const dependentCleanupCells: ReadonlyArray = - dependentEnds.flatMap((first) => - dependentEnds.flatMap((second) => - adapterCleanups.flatMap((adapter) => - (holds(adapter) && (holds(first) || holds(second)) - ? [false, true] - : [false] - ).map((adapterFirst) => ({ - id: `${first}+${second}:${adapter}${adapterFirst ? `:adapter-first` : ``}`, - ends: [first, second] as const, - adapter, - adapterFirst, - })), - ), - ), - ) - +// The grammar's cells, `dependentCleanupCells`, are defined in +// collection-cleanup-restart-grammar-oracle.ts, so that the ordinary grammar +// test can import them. type Gate = `dependent-0` | `dependent-1` | `adapter` type DependentCleanupExpectation = { // Gates in release order. @@ -290,26 +247,6 @@ 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, - completedBoundaryScenarios, - dependentCleanupCells, - ], -}) - const dependentCleanupReachStates = [ `dependent-end-threw`, `dependent-failed-while-adapter-held`, @@ -317,739 +254,1002 @@ const dependentCleanupReachStates = [ `adapter-rejected-after-dependent-failure`, ] as const -describe(`Collection cleanup with failing dependents oracle`, () => { - // The fixed product retains its original one-history minimum for each - // state, with each cell counted only once however often it enters one. - const counts = new Map<(typeof dependentCleanupReachStates)[number], number>( - dependentCleanupReachStates.map((state) => [state, 0]), - ) - afterAll(() => { - if (!oracleReplayRequested()) - for (const state of dependentCleanupReachStates) - expect( - counts.get(state), - `${state} history minimum`, - ).toBeGreaterThanOrEqual(1) - }) - - 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 - // with a held dependent under each of the 2 held adapters. - expect(ids.length).toBe(6 * 6 * 3 + 20 * 2) - }) - - it.each(dependentCleanupCells)( - `settles after every dependent and the source's own cleanup: $id`, - async (cell) => { - oracleRoute(`case`) - const entered = new Set<(typeof dependentCleanupReachStates)[number]>() - const enter = (state: (typeof dependentCleanupReachStates)[number]) => { - if (!entered.has(state)) { - entered.add(state) - counts.set(state, counts.get(state)! + 1) - } - } - const expected = expectedDependentCleanup(cell) - const gates = new Map>>() - // Teardown's own cleanup must not wait on a gate nobody releases. - let tearingDown = false - const gate = (name: Gate) => { - const deferred = createDeferred() - gates.set(name, deferred) - if (tearingDown) deferred.resolve() - return deferred.promise - } - const failures = new Map() - const failure = (name: Gate) => { - const error = new Error(`${name} failed exactly`) - failures.set(name, error) - return error - } - let adapterCalls = 0 - let adapterSettled = false - // A listener's throw the pooled view reports uncaught. - const uncaught: Array = [] - const queueMicrotask = globalThis.queueMicrotask - globalThis.queueMicrotask = (callback) => - queueMicrotask(() => { - try { - callback() - } catch (error) { - if ([...failures.values()].includes(error as Error)) - uncaught.push(error) - else throw error - } - }) - const source = createCollection({ - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ type: `insert`, value: { id: 1, rank: 1 } }) - commit() - markReady() - return { - cleanup: () => { - adapterCalls++ - if (cell.adapter === `sync`) { - adapterSettled = true - return undefined - } - return gate(`adapter`).finally(() => { - adapterSettled = true - if (cell.adapter === `held-reject`) { - failures.set(`adapter-cause`, failure(`adapter`)) - throw failures.get(`adapter`) - } - }) - }, - } - }, - }, - }) - const started = [false, false] - const settled = [false, false] - const teardown: Array<() => void> = [] - const markFailedWhileHeld = () => { - if (holds(cell.adapter) && !adapterSettled) { - enter(`dependent-failed-while-adapter-held`) - } +// The cell's driver reports each declared state it enters through `enter`; +// the property counts each cell once per state (see `states` below). +async function runDependentCleanupCell( + cell: DependentCleanupCell, + enter: (state: (typeof dependentCleanupReachStates)[number]) => void, +): Promise { + const expected = expectedDependentCleanup(cell) + const gates = new Map>>() + // Teardown's own cleanup must not wait on a gate nobody releases. + let tearingDown = false + const gate = (name: Gate) => { + const deferred = createDeferred() + gates.set(name, deferred) + if (tearingDown) deferred.resolve() + return deferred.promise + } + const failures = new Map() + const failure = (name: Gate) => { + const error = new Error(`${name} failed exactly`) + failures.set(name, error) + return error + } + let adapterCalls = 0 + let adapterSettled = false + // A listener's throw the pooled view reports uncaught. + const uncaught: Array = [] + const queueMicrotask = globalThis.queueMicrotask + globalThis.queueMicrotask = (callback) => + queueMicrotask(() => { + try { + callback() + } catch (error) { + if ([...failures.values()].includes(error as Error)) + uncaught.push(error) + else throw error } - const register = (behavior: DependentEnd, index: 0 | 1) => { - const name = `dependent-${index}` as Gate - if (behavior === `pooled-throw`) { - // A pooled view on its own field, so each is its own dependent. - const query = new Query() - .from({ r: source }) - .where(({ r }) => eq(index === 0 ? r.rank : r.id, 1)) - // The builder's public type hides the members the pool reads. - const view = createPooledLiveQuery( - query as unknown as Parameters[0], - ) as unknown as { - on: (event: `status:change`, listener: () => void) => () => void - } - let thrown = false - teardown.push( - view.on(`status:change`, () => { - if (thrown) return - thrown = true - started[index] = true - settled[index] = true - enter(`dependent-end-threw`) - markFailedWhileHeld() - throw failure(name) - }), - ) - return - } - const owner: CollectionDependent = { - kind: `live-query`, - active: true, - end: () => { - started[index] = true - subscription.unsubscribe() - const finish = () => { - settled[index] = true - } - if (behavior === `resolve`) { - finish() - return Promise.resolve() - } - if (behavior === `throw`) { - finish() - enter(`dependent-end-threw`) - throw failure(name) - } - if (behavior === `reject`) { - finish() - markFailedWhileHeld() - return Promise.reject(failure(name)) + }) + const source = createCollection({ + getKey: (row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: 1, rank: 1 } }) + commit() + markReady() + return { + cleanup: () => { + adapterCalls++ + if (cell.adapter === `sync`) { + adapterSettled = true + return undefined } - return gate(name).then(() => { - finish() - if (behavior === `held-reject`) { - markFailedWhileHeld() - throw failure(name) + return gate(`adapter`).finally(() => { + adapterSettled = true + if (cell.adapter === `held-reject`) { + failures.set(`adapter-cause`, failure(`adapter`)) + throw failures.get(`adapter`) } }) }, } - const subscription = source.subscribeChanges(() => {}, { owner }) + }, + }, + }) + const started = [false, false] + const settled = [false, false] + const teardown: Array<() => void> = [] + const markFailedWhileHeld = () => { + if (holds(cell.adapter) && !adapterSettled) { + enter(`dependent-failed-while-adapter-held`) + } + } + const register = (behavior: DependentEnd, index: 0 | 1) => { + const name = `dependent-${index}` as Gate + if (behavior === `pooled-throw`) { + // A pooled view on its own field, so each is its own dependent. + const query = new Query() + .from({ r: source }) + .where(({ r }) => eq(index === 0 ? r.rank : r.id, 1)) + // The builder's public type hides the members the pool reads. + const view = createPooledLiveQuery( + query as unknown as Parameters[0], + ) as unknown as { + on: (event: `status:change`, listener: () => void) => () => void } - - try { - await source.preload() - register(cell.ends[0], 0) - register(cell.ends[1], 1) - expect(source.subscribers).toHaveLength(2) - - let call: Promise - try { - call = source.cleanup({ dependents: true }) - } catch (error) { - throw new Error(`cleanup threw synchronously instead of rejecting`, { - cause: error, - }) + let thrown = false + teardown.push( + view.on(`status:change`, () => { + if (thrown) return + thrown = true + started[index] = true + settled[index] = true + enter(`dependent-end-threw`) + markFailedWhileHeld() + throw failure(name) + }), + ) + return + } + const owner: CollectionDependent = { + kind: `live-query`, + active: true, + end: () => { + started[index] = true + subscription.unsubscribe() + const finish = () => { + settled[index] = true } - let atSettlement: - { status: string; ended: boolean; adapter: boolean } | undefined - const outcome = call.then( - () => `resolved` as const, - (error: unknown) => ({ error }), - ) - void outcome.then(() => { - atSettlement = { - status: source.status, - ended: settled.every(Boolean), - adapter: adapterSettled, - } - }) - // The call ends both dependents and starts the source's cleanup. - expect(started, `dependents ended in the call`).toEqual([true, true]) - expect(adapterCalls, `adapter cleanup started in the call`).toBe(1) - - const unreleased = new Set(expected.releases) - const checkpoint = async (label: string) => { - await flushPromises() - expect(atSettlement === undefined, `${label}: pending`).toBe( - expected.pendingWhile(unreleased), - ) - expect(source.status, `${label}: status`).toBe( - expected.statusWhile(unreleased), - ) + if (behavior === `resolve`) { + finish() + return Promise.resolve() } - await checkpoint(`after the call`) - for (const name of expected.releases) { - if (name === `adapter` && unreleased.size > 1) { - enter(`held-dependent-outlasted-adapter`) - } - if ( - name === `adapter` && - cell.adapter === `held-reject` && - failures.size > 0 - ) { - enter(`adapter-rejected-after-dependent-failure`) - } - gates.get(name)!.resolve() - unreleased.delete(name) - await checkpoint(`after releasing ${name}`) + if (behavior === `throw`) { + finish() + enter(`dependent-end-threw`) + throw failure(name) } - - const result = await outcome - expect(atSettlement, `at settlement`).toEqual({ - status: `cleaned-up`, - ended: true, - adapter: true, - }) - expect(result === `resolved` ? `resolved` : `rejected`).toBe( - expected.outcome, - ) - if (result !== `resolved`) { - const reason = result.error as { cause?: unknown } - const dependentFailures = [ - failures.get(`dependent-0`), - failures.get(`dependent-1`), - ].filter(Boolean) - expect( - dependentFailures.includes(reason as Error) || - (failures.has(`adapter-cause`) && - reason.cause === failures.get(`adapter-cause`)), - `rejects with one of this cell's failures`, - ).toBe(true) + if (behavior === `reject`) { + finish() + markFailedWhileHeld() + return Promise.reject(failure(name)) } - expect(source.status).toBe(`cleaned-up`) - await flushPromises() - cell.ends.forEach((behavior, index) => { - if (behavior === `pooled-throw`) - expect( - uncaught, - `dependent-${index}'s listener throw is reported uncaught`, - ).toContain(failures.get(`dependent-${index}` as Gate)) + return gate(name).then(() => { + finish() + if (behavior === `held-reject`) { + markFailedWhileHeld() + throw failure(name) + } }) - } finally { - globalThis.queueMicrotask = queueMicrotask - tearingDown = true - for (const deferred of gates.values()) deferred.resolve() - for (const stop of teardown) stop() - await flushPromises() - await source.cleanup({ dependents: true }).catch(() => undefined) - } - }, - ) -}) + }, + } + const subscription = source.subscribeChanges(() => {}, { owner }) + } -describe(`Collection cleanup admission oracle`, () => { - // These paths share the three-cut model above. The held adapter promise - // reaches cleanup start without confusing the retained public status with - // permission to start another sync run. No automatic retry is requested. - const subscriberAdmissionCases = ( - [`loading`, `ready`, `error`] as const - ).flatMap((priorStatus) => - ([`eager`, `on-demand`] as const).flatMap((syncMode) => - ([`subscribe`, `snapshot`, `resume`] as const).map((admission) => ({ - priorStatus, - syncMode, - admission, - })), - ), - ) + try { + await source.preload() + register(cell.ends[0], 0) + register(cell.ends[1], 1) + expect(source.subscribers).toHaveLength(2) - 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 - let callbackCount = 0 - const subscriberCounts: Array = [] - const rows = new Map() - let publish = () => {} - const collection = createCollection({ - getKey: (row) => row.id, - syncMode, - sync: { - sync: ({ begin, write, commit, markReady, fail }) => { - starts++ - publish = () => { - begin() - write({ type: `insert`, value: { id: 1, rank: starts } }) - commit() - } - if (priorStatus === `ready` || starts > 1) markReady() - if (priorStatus === `error` && starts === 1) { - fail(new Error(`initial source error`)) - } - return { - cleanup: () => cleanupGate.promise, - loadSubset: () => { - acquisitions++ - return true - }, - } - }, - }, + let call: Promise + try { + call = source.cleanup({ dependents: true }) + } catch (error) { + throw new Error(`cleanup threw synchronously instead of rejecting`, { + cause: error, }) - let subscription: - ReturnType | undefined - const subscribe = () => { - subscription = collection.subscribeChanges( - (changes) => { - callbackCount++ - for (const change of changes) { - if (change.type === `delete`) rows.delete(change.key) - else { - const { id, rank } = change.value - rows.set(change.key, { id, rank }) - } - } - }, - { - includeInitialState: admission !== `subscribe`, - deferAcquisition: admission === `resume`, - }, - ) + } + let atSettlement: + { status: string; ended: boolean; adapter: boolean } | undefined + const outcome = call.then( + () => `resolved` as const, + (error: unknown) => ({ error }), + ) + void outcome.then(() => { + atSettlement = { + status: source.status, + ended: settled.every(Boolean), + adapter: adapterSettled, + } + }) + // The call ends both dependents and starts the source's cleanup. + expect(started, `dependents ended in the call`).toEqual([true, true]) + expect(adapterCalls, `adapter cleanup started in the call`).toBe(1) + + const unreleased = new Set(expected.releases) + const checkpoint = async (label: string) => { + await flushPromises() + expect(atSettlement === undefined, `${label}: pending`).toBe( + expected.pendingWhile(unreleased), + ) + expect(source.status, `${label}: status`).toBe( + expected.statusWhile(unreleased), + ) + } + await checkpoint(`after the call`) + for (const name of expected.releases) { + if (name === `adapter` && unreleased.size > 1) { + enter(`held-dependent-outlasted-adapter`) } - const admit = () => { - if (admission === `resume`) subscription!.resumeDeferredAcquisition() - else subscribe() + if ( + name === `adapter` && + cell.adapter === `held-reject` && + failures.size > 0 + ) { + enter(`adapter-rejected-after-dependent-failure`) } - const off = collection.on(`subscribers:change`, ({ subscriberCount }) => { - subscriberCounts.push(subscriberCount) - }) + gates.get(name)!.resolve() + unreleased.delete(name) + await checkpoint(`after releasing ${name}`) + } - await withOracleCleanup(async () => { - collection.startSyncImmediate() - const cleanup = collection.cleanup() - // A deferring subscription starts no source work and may keep its - // logical demand. Only its attempted resumption must be rejected. - if (admission === `resume`) subscribe() - // Deferred admission may report its local snapshot. The rejected - // resumption must add no callback to that already-observed prefix. - const callbacksBeforeAdmission = callbackCount - const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) - expect(collection.status).toBe(atStart.collectionStatus) - expect(observeRestartAdmission(admit)).toBe(atStart.restartAdmission) - expect(collection.subscriberCount).toBe(0) - expect(subscriberCounts).toEqual([]) - expect(callbackCount).toBe(callbacksBeforeAdmission) - expect(acquisitions).toBe(0) - expect(starts).toBe(1) - if (admission === `resume`) { - expect(subscription!.isDeferringAcquisition()).toBe(true) - } + const result = await outcome + expect(atSettlement, `at settlement`).toEqual({ + status: `cleaned-up`, + ended: true, + adapter: true, + }) + expect(result === `resolved` ? `resolved` : `rejected`).toBe( + expected.outcome, + ) + if (result !== `resolved`) { + const reason = result.error as { cause?: unknown } + const dependentFailures = [ + failures.get(`dependent-0`), + failures.get(`dependent-1`), + ].filter(Boolean) + expect( + dependentFailures.includes(reason as Error) || + (failures.has(`adapter-cause`) && + reason.cause === failures.get(`adapter-cause`)), + `rejects with one of this cell's failures`, + ).toBe(true) + } + expect(source.status).toBe(`cleaned-up`) + await flushPromises() + cell.ends.forEach((behavior, index) => { + if (behavior === `pooled-throw`) + expect( + uncaught, + `dependent-${index}'s listener throw is reported uncaught`, + ).toContain(failures.get(`dependent-${index}` as Gate)) + }) + } finally { + globalThis.queueMicrotask = queueMicrotask + tearingDown = true + for (const deferred of gates.values()) deferred.resolve() + for (const stop of teardown) stop() + await flushPromises() + await source.cleanup({ dependents: true }).catch(() => undefined) + } +} - cleanupGate.resolve() - await cleanup - const settled = expectedCleanupBoundary( - priorStatus, - `cleanup-settlement`, - ) - expect(collection.status).toBe(settled.collectionStatus) - expect(starts).toBe(1) - expect(collection.subscriberCount).toBe(0) - expect(callbackCount).toBe(callbacksBeforeAdmission) - expect(observeRestartAdmission(admit)).toBe(settled.restartAdmission) - expect(collection.subscriberCount).toBe(1) - expect(starts).toBe(2) - publish() - expect([...rows.values()]).toEqual([{ id: 1, rank: 2 }]) - }, [ - () => cleanupGate.resolve(), - () => subscription?.unsubscribe(), - off, - () => collection.cleanup(), - ]) +async function runSubscriberAdmissionCase({ + priorStatus, + syncMode, + admission, +}: { + priorStatus: `loading` | `ready` | `error` + syncMode: `eager` | `on-demand` + admission: `subscribe` | `snapshot` | `resume` +}): Promise { + const cleanupGate = createDeferred() + let starts = 0 + let acquisitions = 0 + let callbackCount = 0 + const subscriberCounts: Array = [] + const rows = new Map() + let publish = () => {} + const collection = createCollection({ + getKey: (row) => row.id, + syncMode, + sync: { + sync: ({ begin, write, commit, markReady, fail }) => { + starts++ + publish = () => { + begin() + write({ type: `insert`, value: { id: 1, rank: starts } }) + commit() + } + if (priorStatus === `ready` || starts > 1) markReady() + if (priorStatus === `error` && starts === 1) { + fail(new Error(`initial source error`)) + } + return { + cleanup: () => cleanupGate.promise, + loadSubset: () => { + acquisitions++ + return true + }, + } + }, }, - ) - - it(`calibrates every declared bounded-product cell exactly once`, () => { - oracleRoute(`case`) - expectExactCaseIds(reentryScenarios, [ - `abort:plain:1`, - `abort:plain:2`, - `abort:nested:1`, - `abort:nested:2`, - `release:plain:1`, - `release:plain:2`, - `release:nested:1`, - `release:nested:2`, - ]) - expectExactCaseIds(completedBoundaryScenarios, [ - `event:source`, - `event:live-query`, - `await:source`, - `await:live-query`, - ]) }) - - it(`waits for source cleanup before publishing the restart boundary`, async () => { - oracleRoute(`fixed history`) - const cleanupGate = createDeferred() - let starts = 0 - let cleanups = 0 - let sourceCleanupSettled = false - const statuses: Array = [] - const settlementOrder: Array = [] - let publicCleanupSettled = false - let atTerminalPublication: - | Pick< - CleanupBoundaryObservation<`ready`>, - `collectionStatus` | `cleanup` - > - | undefined - const collection = createCollection({ - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - starts++ - markReady() - return { - cleanup: () => { - cleanups++ - return cleanupGate.promise.then(() => { - sourceCleanupSettled = true - settlementOrder.push(`adapter-cleanup-settled`) - }) - }, + let subscription: ReturnType | undefined + const subscribe = () => { + subscription = collection.subscribeChanges( + (changes) => { + callbackCount++ + for (const change of changes) { + if (change.type === `delete`) rows.delete(change.key) + else { + const { id, rank } = change.value + rows.set(change.key, { id, rank }) } - }, + } }, - }) - const off = collection.on(`status:change`, ({ status }) => { - statuses.push(status) - if (status === `cleaned-up`) { - atTerminalPublication = { - collectionStatus: status, - cleanup: observeCleanupSettlement(publicCleanupSettled), + { + includeInitialState: admission !== `subscribe`, + deferAcquisition: admission === `resume`, + }, + ) + } + const admit = () => { + if (admission === `resume`) subscription!.resumeDeferredAcquisition() + else subscribe() + } + const off = collection.on(`subscribers:change`, ({ subscriberCount }) => { + subscriberCounts.push(subscriberCount) + }) + + await withOracleCleanup(async () => { + collection.startSyncImmediate() + const cleanup = collection.cleanup() + // A deferring subscription starts no source work and may keep its + // logical demand. Only its attempted resumption must be rejected. + if (admission === `resume`) subscribe() + // Deferred admission may report its local snapshot. The rejected + // resumption must add no callback to that already-observed prefix. + const callbacksBeforeAdmission = callbackCount + const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) + expect(collection.status).toBe(atStart.collectionStatus) + expect(observeRestartAdmission(admit)).toBe(atStart.restartAdmission) + expect(collection.subscriberCount).toBe(0) + expect(subscriberCounts).toEqual([]) + expect(callbackCount).toBe(callbacksBeforeAdmission) + expect(acquisitions).toBe(0) + expect(starts).toBe(1) + if (admission === `resume`) { + expect(subscription!.isDeferringAcquisition()).toBe(true) + } + + cleanupGate.resolve() + await cleanup + const settled = expectedCleanupBoundary(priorStatus, `cleanup-settlement`) + expect(collection.status).toBe(settled.collectionStatus) + expect(starts).toBe(1) + expect(collection.subscriberCount).toBe(0) + expect(callbackCount).toBe(callbacksBeforeAdmission) + expect(observeRestartAdmission(admit)).toBe(settled.restartAdmission) + expect(collection.subscriberCount).toBe(1) + expect(starts).toBe(2) + publish() + expect([...rows.values()]).toEqual([{ id: 1, rank: 2 }]) + }, [ + () => cleanupGate.resolve(), + () => subscription?.unsubscribe(), + off, + () => collection.cleanup(), + ]) +} + +async function runRestartBoundaryHistory(): Promise { + const cleanupGate = createDeferred() + let starts = 0 + let cleanups = 0 + let sourceCleanupSettled = false + const statuses: Array = [] + const settlementOrder: Array = [] + let publicCleanupSettled = false + let atTerminalPublication: + | Pick, `collectionStatus` | `cleanup`> + | undefined + const collection = createCollection({ + getKey: (row) => row.id, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { + cleanup: () => { + cleanups++ + return cleanupGate.promise.then(() => { + sourceCleanupSettled = true + settlementOrder.push(`adapter-cleanup-settled`) + }) + }, } - settlementOrder.push(`cleaned-up-event`) + }, + }, + }) + const off = collection.on(`status:change`, ({ status }) => { + statuses.push(status) + if (status === `cleaned-up`) { + atTerminalPublication = { + collectionStatus: status, + cleanup: observeCleanupSettlement(publicCleanupSettled), } - }) + settlementOrder.push(`cleaned-up-event`) + } + }) - try { - await collection.preload() - statuses.length = 0 + try { + await collection.preload() + statuses.length = 0 - const firstCleanup = collection.cleanup() - const concurrentCleanup = collection.cleanup() - void firstCleanup.then(() => { - publicCleanupSettled = true - settlementOrder.push(`public-cleanup-promise-continuation`) - }) + const firstCleanup = collection.cleanup() + const concurrentCleanup = collection.cleanup() + void firstCleanup.then(() => { + publicCleanupSettled = true + settlementOrder.push(`public-cleanup-promise-continuation`) + }) - const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) - expect(sourceCleanupSettled).toBe(false) - expect(collection.status).toBe(atCleanupStart.collectionStatus) - expect(statuses).toEqual([]) - expect(cleanups).toBe(1) - expect(concurrentCleanup).toBe(firstCleanup) - expect( - observeRestartAdmission(() => collection.startSyncImmediate()), - ).toBe(atCleanupStart.restartAdmission) + const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) + expect(sourceCleanupSettled).toBe(false) + expect(collection.status).toBe(atCleanupStart.collectionStatus) + expect(statuses).toEqual([]) + expect(cleanups).toBe(1) + expect(concurrentCleanup).toBe(firstCleanup) + expect(observeRestartAdmission(() => collection.startSyncImmediate())).toBe( + atCleanupStart.restartAdmission, + ) - cleanupGate.resolve() - await Promise.all([firstCleanup, concurrentCleanup]) + cleanupGate.resolve() + await Promise.all([firstCleanup, concurrentCleanup]) - const atCleanupSettlement = expectedCleanupBoundary( - `ready`, - `cleanup-settlement`, - ) - expect(sourceCleanupSettled).toBe(true) - const terminalPublication = expectedCleanupBoundary( - `ready`, - `terminal-publication`, - ) - expect(atTerminalPublication).toEqual({ - collectionStatus: terminalPublication.collectionStatus, - cleanup: terminalPublication.cleanup, - }) - expect(collection.status).toBe(atCleanupSettlement.collectionStatus) - expect(statuses).toEqual([`cleaned-up`]) - expect(settlementOrder).toEqual([ - `adapter-cleanup-settled`, - `cleaned-up-event`, - `public-cleanup-promise-continuation`, - ]) + const atCleanupSettlement = expectedCleanupBoundary( + `ready`, + `cleanup-settlement`, + ) + expect(sourceCleanupSettled).toBe(true) + const terminalPublication = expectedCleanupBoundary( + `ready`, + `terminal-publication`, + ) + expect(atTerminalPublication).toEqual({ + collectionStatus: terminalPublication.collectionStatus, + cleanup: terminalPublication.cleanup, + }) + expect(collection.status).toBe(atCleanupSettlement.collectionStatus) + expect(statuses).toEqual([`cleaned-up`]) + expect(settlementOrder).toEqual([ + `adapter-cleanup-settled`, + `cleaned-up-event`, + `public-cleanup-promise-continuation`, + ]) - await expect(collection.cleanup()).resolves.toBeUndefined() - expect(cleanups).toBe(1) + await expect(collection.cleanup()).resolves.toBeUndefined() + expect(cleanups).toBe(1) - expect( - observeRestartAdmission(() => collection.startSyncImmediate()), - ).toBe(atCleanupSettlement.restartAdmission) - expect(starts).toBe(2) - expect(collection.status).toBe(`ready`) - } finally { - cleanupGate.resolve() - off() - await collection.cleanup() - } - }) + expect(observeRestartAdmission(() => collection.startSyncImmediate())).toBe( + atCleanupSettlement.restartAdmission, + ) + expect(starts).toBe(2) + expect(collection.status).toBe(`ready`) + } finally { + cleanupGate.resolve() + off() + await collection.cleanup() + } +} - it(`retains loading status at cleanup start, then admits restart`, async () => { - oracleRoute(`fixed history`) - const cleanupGate = createDeferred() - const statuses: Array = [] - let starts = 0 - const collection = createCollection({ - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - starts++ - if (starts > 1) markReady() - return { cleanup: () => cleanupGate.promise } - }, +async function runLoadingCleanupHistory(): Promise { + const cleanupGate = createDeferred() + const statuses: Array = [] + let starts = 0 + const collection = createCollection({ + getKey: (row) => row.id, + sync: { + sync: ({ markReady }) => { + starts++ + if (starts > 1) markReady() + return { cleanup: () => cleanupGate.promise } }, - }) - const off = collection.on(`status:change`, ({ status }) => { - statuses.push(status) - }) + }, + }) + const off = collection.on(`status:change`, ({ status }) => { + statuses.push(status) + }) - try { - collection.startSyncImmediate() - expect(collection.status).toBe(`loading`) - statuses.length = 0 + try { + collection.startSyncImmediate() + expect(collection.status).toBe(`loading`) + statuses.length = 0 - let cleanupSettled = false - const cleanup = collection.cleanup().then(() => { - cleanupSettled = true - }) - const atCleanupStart = expectedCleanupBoundary(`loading`, `cleanup-start`) + let cleanupSettled = false + const cleanup = collection.cleanup().then(() => { + cleanupSettled = true + }) + const atCleanupStart = expectedCleanupBoundary(`loading`, `cleanup-start`) - // A rule that forces ready at cleanup start fails at this held cut. - expect(collection.status).toBe(atCleanupStart.collectionStatus) - expect(statuses).toEqual([]) - expect(observeCleanupSettlement(cleanupSettled)).toBe( - atCleanupStart.cleanup, - ) - expect( - observeRestartAdmission(() => collection.startSyncImmediate()), - ).toBe(atCleanupStart.restartAdmission) + // A rule that forces ready at cleanup start fails at this held cut. + expect(collection.status).toBe(atCleanupStart.collectionStatus) + expect(statuses).toEqual([]) + expect(observeCleanupSettlement(cleanupSettled)).toBe( + atCleanupStart.cleanup, + ) + expect(observeRestartAdmission(() => collection.startSyncImmediate())).toBe( + atCleanupStart.restartAdmission, + ) - cleanupGate.resolve() - await cleanup - const atCleanupSettlement = expectedCleanupBoundary( - `loading`, - `cleanup-settlement`, - ) - expect(collection.status).toBe(atCleanupSettlement.collectionStatus) - expect(statuses).toEqual([`cleaned-up`]) - expect(observeCleanupSettlement(cleanupSettled)).toBe( - atCleanupSettlement.cleanup, - ) + cleanupGate.resolve() + await cleanup + const atCleanupSettlement = expectedCleanupBoundary( + `loading`, + `cleanup-settlement`, + ) + expect(collection.status).toBe(atCleanupSettlement.collectionStatus) + expect(statuses).toEqual([`cleaned-up`]) + expect(observeCleanupSettlement(cleanupSettled)).toBe( + atCleanupSettlement.cleanup, + ) - collection.startSyncImmediate() - expect(starts).toBe(2) - expect(collection.status).toBe(`ready`) - } finally { - cleanupGate.resolve() - off() - await collection.cleanup() - } + collection.startSyncImmediate() + expect(starts).toBe(2) + expect(collection.status).toBe(`ready`) + } finally { + cleanupGate.resolve() + off() + await collection.cleanup() + } +} + +async function runDependentPreloadHistory(): Promise { + const cleanupGate = createDeferred() + const loadGate = createDeferred() + const loadEntered = createDeferred() + const source = createCollection({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: () => { + loadEntered.resolve() + return loadGate.promise + }, + cleanup: () => cleanupGate.promise, + } + }, + }, }) + const live = createLiveQueryCollection((q) => q.from({ row: source })) + const message = + `Source collection '${source.id}' was manually cleaned up while live query '${live.id}' depends on it. ` + + `Live queries prevent automatic GC, so this was likely a manual cleanup() call.` + const reports = vi.spyOn(console, `error`).mockImplementation(() => {}) + const preload = live.preload().then( + () => ({ status: `fulfilled` as const }), + (error: unknown) => ({ status: `rejected` as const, error }), + ) - it(`rejects dependent preload when cleanup starts before terminal settlement`, async () => { - oracleRoute(`fixed history`) - const cleanupGate = createDeferred() - const loadGate = createDeferred() - const loadEntered = createDeferred() - const source = createCollection({ - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: () => { - loadEntered.resolve() - return loadGate.promise - }, - cleanup: () => cleanupGate.promise, - } - }, + try { + await loadEntered.promise + // Internal teardown that bypasses cleanup admission, such as a retiring + // facade, still crashes the dependent at cleanup start. + let cleanupSettled = false + const cleanup = source._lifecycle.cleanup().then(() => { + cleanupSettled = true + }) + + const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) + expect(source.status).toBe(atCleanupStart.collectionStatus) + expect(observeCleanupSettlement(cleanupSettled)).toBe( + atCleanupStart.cleanup, + ) + expect(observeDependent(live.status !== `crashed`)).toBe( + atCleanupStart.dependent, + ) + expect(await preload).toMatchObject({ + status: `rejected`, + error: { message }, + }) + + cleanupGate.resolve() + await cleanup + expect(source.status).toBe(`cleaned-up`) + expect(reports.mock.calls).toEqual([[`[Live Query Error] ${message}`]]) + } finally { + cleanupGate.resolve() + loadGate.resolve() + reports.mockRestore() + await live.cleanup() + await source.cleanup() + } +} + +async function runDependentEffectHistory(): Promise { + const cleanupGate = createDeferred() + const handlerGate = createDeferred() + const handlerEntered = createDeferred() + const sourceErrors: Array = [] + let adapterCleanupStarted = 0 + const source = createCollection({ + getKey: (row) => row.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: 1, rank: 1 } }) + commit() + markReady() + return { + cleanup: () => { + adapterCleanupStarted++ + return cleanupGate.promise + }, + } }, + }, + }) + const effect = createEffect({ + query: (q) => q.from({ row: source }), + onEnter: () => { + handlerEntered.resolve() + return handlerGate.promise + }, + onSourceError: (error) => sourceErrors.push(error), + }) + + try { + await handlerEntered.promise + expect(effect.disposed).toBe(false) + expect(source.subscriberCount).toBe(1) + + // The public cleanup refuses while the Effect reads the source. + // Internal teardown that bypasses it disposes the Effect at cleanup + // start. + await expect(source.cleanup()).rejects.toThrow(/dependent/) + let cleanupSettled = false + const cleanup = source._lifecycle.cleanup().then(() => { + cleanupSettled = true }) - const live = createLiveQueryCollection((q) => q.from({ row: source })) - const message = - `Source collection '${source.id}' was manually cleaned up while live query '${live.id}' depends on it. ` + - `Live queries prevent automatic GC, so this was likely a manual cleanup() call.` - const reports = vi.spyOn(console, `error`).mockImplementation(() => {}) - const preload = live.preload().then( - () => ({ status: `fulfilled` as const }), - (error: unknown) => ({ status: `rejected` as const, error }), + const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) + + expect(adapterCleanupStarted).toBe(1) + expect(source.status).toBe(atCleanupStart.collectionStatus) + expect(observeCleanupSettlement(cleanupSettled)).toBe( + atCleanupStart.cleanup, ) + expect(observeDependent(!effect.disposed)).toBe(atCleanupStart.dependent) + expect(source.subscriberCount).toBe(0) + expect(sourceErrors).toEqual([ + expect.objectContaining({ + message: `Source collection '${source.id}' was cleaned up while effect depends on it`, + }), + ]) + let effectDisposalSettled = false + const effectDisposal = effect.dispose().then(() => { + effectDisposalSettled = true + }) + expect(effectDisposalSettled).toBe(false) - try { - await loadEntered.promise - // Internal teardown that bypasses cleanup admission, such as a retiring - // facade, still crashes the dependent at cleanup start. - let cleanupSettled = false - const cleanup = source._lifecycle.cleanup().then(() => { - cleanupSettled = true - }) + handlerGate.resolve() + await effectDisposal + expect(effectDisposalSettled).toBe(true) + expect(cleanupSettled).toBe(false) - const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) - expect(source.status).toBe(atCleanupStart.collectionStatus) - expect(observeCleanupSettlement(cleanupSettled)).toBe( - atCleanupStart.cleanup, - ) - expect(observeDependent(live.status !== `crashed`)).toBe( - atCleanupStart.dependent, - ) - expect(await preload).toMatchObject({ - status: `rejected`, - error: { message }, - }) + cleanupGate.resolve() + await cleanup + const atCleanupSettlement = expectedCleanupBoundary( + `ready`, + `cleanup-settlement`, + ) - cleanupGate.resolve() - await cleanup - expect(source.status).toBe(`cleaned-up`) - expect(reports.mock.calls).toEqual([[`[Live Query Error] ${message}`]]) - } finally { - cleanupGate.resolve() - loadGate.resolve() - reports.mockRestore() - await live.cleanup() - await source.cleanup() - } + expect(observeCleanupSettlement(cleanupSettled)).toBe( + atCleanupSettlement.cleanup, + ) + expect(source.status).toBe(atCleanupSettlement.collectionStatus) + expect(observeDependent(!effect.disposed)).toBe( + atCleanupSettlement.dependent, + ) + expect(sourceErrors).toHaveLength(1) + } finally { + cleanupGate.resolve() + handlerGate.resolve() + await effect.dispose() + await source.cleanup() + } +} + +async function runLateEffectCase({ + priorStatus, + syncMode, + cleaningPosition, +}: { + priorStatus: `loading` | `ready` | `error` + syncMode: `eager` | `on-demand` + cleaningPosition: `first` | `second` +}): Promise { + const cleanupGate = createDeferred() + let starts = 0 + const source = createCollection({ + getKey: (row) => row.id, + syncMode, + sync: { + sync: ({ markReady, fail }) => { + starts++ + if (priorStatus === `ready` || starts > 1) markReady() + if (priorStatus === `error` && starts === 1) { + fail(new Error(`initial source error`)) + } + return { + cleanup: () => cleanupGate.promise, + loadSubset: () => true, + } + }, + }, + }) + const healthySource = createCollection({ + getKey: (row) => row.id, + sync: { sync: ({ markReady }) => markReady() }, }) + source.createIndex((row) => row.id, { indexType: BasicIndex }) + healthySource.createIndex((row) => row.id, { indexType: BasicIndex }) + const registerCleanupStart = source._onCleanupStart.bind(source) + let activeCleanupStartObservers = 0 + source._onCleanupStart = (callback) => { + activeCleanupStartObservers++ + const unsubscribe = registerCleanupStart(callback) + let active = true + return () => { + if (!active) return + active = false + activeCleanupStartObservers-- + unsubscribe() + } + } - it(`disposes a dependent Effect when cleanup starts before terminal settlement`, async () => { - oracleRoute(`fixed history`) - const cleanupGate = createDeferred() - const handlerGate = createDeferred() - const handlerEntered = createDeferred() - const sourceErrors: Array = [] - let adapterCleanupStarted = 0 - const source = createCollection({ - getKey: (row) => row.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - begin() - write({ type: `insert`, value: { id: 1, rank: 1 } }) - commit() - markReady() - return { - cleanup: () => { - adapterCleanupStarted++ - return cleanupGate.promise - }, - } - }, + const firstSource = cleaningPosition === `first` ? source : healthySource + const secondSource = cleaningPosition === `second` ? source : healthySource + const errors: Array = [] + let batches = 0 + const create = () => + createEffect({ + query: (q) => + q + .from({ first: firstSource }) + .join( + { second: secondSource }, + ({ first, second }) => eq(first.id, second.id), + `inner`, + ), + onBatch: () => { + batches++ }, - }) - const effect = createEffect({ - query: (q) => q.from({ row: source }), - onEnter: () => { - handlerEntered.resolve() - return handlerGate.promise + onSourceError: (error) => { + errors.push(error.message) }, - onSourceError: (error) => sourceErrors.push(error), }) + let effect: ReturnType | undefined + await withOracleCleanup(async () => { + source.startSyncImmediate() + const cleanup = source.cleanup() + effect = create() + const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) + expect(observeDependent(!effect.disposed)).toBe(atStart.dependent) + expect(source.status).toBe(atStart.collectionStatus) + expect(source.subscriberCount).toBe(0) + expect(healthySource.subscriberCount).toBe(0) + expect(activeCleanupStartObservers).toBe(0) + expect(batches).toBe(0) + expect(errors).toEqual([ + `Source collection '${source.id}' was cleaned up while effect depends on it`, + ]) - try { - await handlerEntered.promise - expect(effect.disposed).toBe(false) - expect(source.subscriberCount).toBe(1) + cleanupGate.resolve() + await cleanup + expect(effect.disposed).toBe(true) + expect(source.subscriberCount).toBe(0) + expect(healthySource.subscriberCount).toBe(0) + // A later, explicit Effect construction is allowed. The retired Effect + // stays disposed; neither cleanup settlement nor restart revives it. + effect = create() + expect(effect.disposed).toBe(false) + expect(source.subscriberCount).toBe(1) + expect(healthySource.subscriberCount).toBe(1) + expect(activeCleanupStartObservers).toBeGreaterThan(0) + expect(errors).toHaveLength(1) + }, [ + () => cleanupGate.resolve(), + () => effect?.dispose(), + () => source.cleanup(), + () => healthySource.cleanup(), + ]) + expect(activeCleanupStartObservers).toBe(0) +} - // The public cleanup refuses while the Effect reads the source. - // Internal teardown that bypasses it disposes the Effect at cleanup - // start. - await expect(source.cleanup()).rejects.toThrow(/dependent/) - let cleanupSettled = false - const cleanup = source._lifecycle.cleanup().then(() => { - cleanupSettled = true - }) - const atCleanupStart = expectedCleanupBoundary(`ready`, `cleanup-start`) +describe(`Collection cleanup with failing dependents oracle`, () => { + oracleProperty({ + id: `collection-cleanup.dependents`, + law: { + statement: `\`cleanup({ dependents: true })\` ends each dependent and starts the Collection's own cleanup in the same call; its promise settles only once every end and the Collection's cleanup have settled, when the Collection is \`cleaned-up\`, and rejects with one of the failures if any failed.`, + authority: `The \`Collection.cleanup()\` JSDoc, the glossary's cleanup entry, and ARCHITECTURE law 14, as stated above`, + limits: `One Collection with two dependents; Effects and nested live-query chains are covered only by the admission histories.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: { + enumerated: dependentCleanupCells, + // The fixed product retains its original one-history minimum for each + // state, with each cell counted only once however often it enters one. + states: dependentCleanupReachStates, + }, + label: ({ id }) => + `settles after every dependent and the source's own cleanup: ${id}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.dependents`, + plan, + observe, + () => + runDependentCleanupCell(plan.history, (state) => + observe(`reach`, state), + ), + ), + check: forwardAssertionJudgment, + }) +}) - expect(adapterCleanupStarted).toBe(1) - expect(source.status).toBe(atCleanupStart.collectionStatus) - expect(observeCleanupSettlement(cleanupSettled)).toBe( - atCleanupStart.cleanup, - ) - expect(observeDependent(!effect.disposed)).toBe(atCleanupStart.dependent) - expect(source.subscriberCount).toBe(0) - expect(sourceErrors).toEqual([ - expect.objectContaining({ - message: `Source collection '${source.id}' was cleaned up while effect depends on it`, - }), - ]) - let effectDisposalSettled = false - const effectDisposal = effect.dispose().then(() => { - effectDisposalSettled = true - }) - expect(effectDisposalSettled).toBe(false) +describe(`Collection cleanup admission oracle`, () => { + // These paths share the three-cut model above. The held adapter promise + // reaches cleanup start without confusing the retained public status with + // permission to start another sync run. No automatic retry is requested. + const subscriberAdmissionCases = ( + [`loading`, `ready`, `error`] as const + ).flatMap((priorStatus) => + ([`eager`, `on-demand`] as const).flatMap((syncMode) => + ([`subscribe`, `snapshot`, `resume`] as const).map((admission) => ({ + priorStatus, + syncMode, + admission, + })), + ), + ) - handlerGate.resolve() - await effectDisposal - expect(effectDisposalSettled).toBe(true) - expect(cleanupSettled).toBe(false) + oracleProperty({ + id: `collection-cleanup.subscriber-admission`, + law: { + statement: `Cleanup start closes subscriber admission, deferred resumption, and acquisition while the prior status is retained; after cleanup settlement an explicit admission starts the replacement sync run, as the three-cut model predicts.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `Eighteen histories crossing loading, ready, and error cleanup, eager and on-demand sources, and subscribe, snapshot, and resume admission.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: subscriberAdmissionCases, + label: ({ admission, priorStatus, syncMode }) => + `closes ${admission} admission during ${priorStatus} ${syncMode} cleanup`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.subscriber-admission`, + plan, + observe, + () => runSubscriberAdmissionCase(plan.history), + ), + check: forwardAssertionJudgment, + }) - cleanupGate.resolve() - await cleanup - const atCleanupSettlement = expectedCleanupBoundary( - `ready`, - `cleanup-settlement`, - ) + const restartBoundaryHistorys = [ + { name: `waits for source cleanup before publishing the restart boundary` }, + ] + oracleProperty({ + id: `collection-cleanup.restart-boundary`, + law: { + statement: `Restart admission stays closed until adapter cleanup settles, \`cleaned-up\` is published at terminal publication, and the cleanup promise settles afterward, as the three-cut model predicts.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `One fixed history outside the generated grammar.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: restartBoundaryHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.restart-boundary`, + plan, + observe, + () => runRestartBoundaryHistory(), + ), + check: forwardAssertionJudgment, + }) - expect(observeCleanupSettlement(cleanupSettled)).toBe( - atCleanupSettlement.cleanup, - ) - expect(source.status).toBe(atCleanupSettlement.collectionStatus) - expect(observeDependent(!effect.disposed)).toBe( - atCleanupSettlement.dependent, - ) - expect(sourceErrors).toHaveLength(1) - } finally { - cleanupGate.resolve() - handlerGate.resolve() - await effect.dispose() - await source.cleanup() - } + const loadingCleanupHistorys = [ + { name: `retains loading status at cleanup start, then admits restart` }, + ] + oracleProperty({ + id: `collection-cleanup.loading-restart`, + law: { + statement: `A loading Collection keeps its loading status at cleanup start and admits a restart only after cleanup settles, as the three-cut model predicts.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `One fixed history outside the generated grammar.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: loadingCleanupHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.loading-restart`, + plan, + observe, + () => runLoadingCleanupHistory(), + ), + check: forwardAssertionJudgment, + }) + + const dependentPreloadHistorys = [ + { + name: `rejects dependent preload when cleanup starts before terminal settlement`, + }, + ] + oracleProperty({ + id: `collection-cleanup.dependent-preload`, + law: { + statement: `A dependent live query enters terminal error at cleanup start, before terminal settlement, as the three-cut model predicts.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `One fixed history outside the generated grammar.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: dependentPreloadHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.dependent-preload`, + plan, + observe, + () => runDependentPreloadHistory(), + ), + check: forwardAssertionJudgment, + }) + + const dependentEffectHistorys = [ + { + name: `disposes a dependent Effect when cleanup starts before terminal settlement`, + }, + ] + oracleProperty({ + id: `collection-cleanup.dependent-effect`, + law: { + statement: `A dependent Effect is disposed at cleanup start, before terminal settlement, as the three-cut model predicts.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `One fixed history outside the generated grammar.`, + }, + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: dependentEffectHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.dependent-effect`, + plan, + observe, + () => runDependentEffectHistory(), + ), + check: forwardAssertionJudgment, }) const lateEffectCases = ([`loading`, `ready`, `error`] as const).flatMap( @@ -1066,107 +1266,35 @@ describe(`Collection cleanup admission oracle`, () => { // Effects must retire at cleanup start, including when construction reaches // a source whose adapter cleanup is already pending. The same boundary model // requires a terminal dependent without partial subscription ownership. - 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({ - getKey: (row) => row.id, - syncMode, - sync: { - sync: ({ markReady, fail }) => { - starts++ - if (priorStatus === `ready` || starts > 1) markReady() - if (priorStatus === `error` && starts === 1) { - fail(new Error(`initial source error`)) - } - return { - cleanup: () => cleanupGate.promise, - loadSubset: () => true, - } - }, - }, - }) - const healthySource = createCollection({ - getKey: (row) => row.id, - sync: { sync: ({ markReady }) => markReady() }, - }) - source.createIndex((row) => row.id, { indexType: BasicIndex }) - healthySource.createIndex((row) => row.id, { indexType: BasicIndex }) - const registerCleanupStart = source._onCleanupStart.bind(source) - let activeCleanupStartObservers = 0 - source._onCleanupStart = (callback) => { - activeCleanupStartObservers++ - const unsubscribe = registerCleanupStart(callback) - let active = true - return () => { - if (!active) return - active = false - activeCleanupStartObservers-- - unsubscribe() - } - } - - const firstSource = cleaningPosition === `first` ? source : healthySource - const secondSource = - cleaningPosition === `second` ? source : healthySource - const errors: Array = [] - let batches = 0 - const create = () => - createEffect({ - query: (q) => - q - .from({ first: firstSource }) - .join( - { second: secondSource }, - ({ first, second }) => eq(first.id, second.id), - `inner`, - ), - onBatch: () => { - batches++ - }, - onSourceError: (error) => { - errors.push(error.message) - }, - }) - let effect: ReturnType | undefined - await withOracleCleanup(async () => { - source.startSyncImmediate() - const cleanup = source.cleanup() - effect = create() - const atStart = expectedCleanupBoundary(priorStatus, `cleanup-start`) - expect(observeDependent(!effect.disposed)).toBe(atStart.dependent) - expect(source.status).toBe(atStart.collectionStatus) - expect(source.subscriberCount).toBe(0) - expect(healthySource.subscriberCount).toBe(0) - expect(activeCleanupStartObservers).toBe(0) - expect(batches).toBe(0) - expect(errors).toEqual([ - `Source collection '${source.id}' was cleaned up while effect depends on it`, - ]) - - cleanupGate.resolve() - await cleanup - expect(effect.disposed).toBe(true) - expect(source.subscriberCount).toBe(0) - expect(healthySource.subscriberCount).toBe(0) - // A later, explicit Effect construction is allowed. The retired Effect - // stays disposed; neither cleanup settlement nor restart revives it. - effect = create() - expect(effect.disposed).toBe(false) - expect(source.subscriberCount).toBe(1) - expect(healthySource.subscriberCount).toBe(1) - expect(activeCleanupStartObservers).toBeGreaterThan(0) - expect(errors).toHaveLength(1) - }, [ - () => cleanupGate.resolve(), - () => effect?.dispose(), - () => source.cleanup(), - () => healthySource.cleanup(), - ]) - expect(activeCleanupStartObservers).toBe(0) + oracleProperty({ + id: `collection-cleanup.late-effect`, + law: { + statement: `An Effect constructed against a Collection whose cleanup has started is a terminal dependent with no partial subscription ownership; a later explicit construction after cleanup settlement is admitted.`, + authority: `The public \`Collection.cleanup()\` contract and the glossary's cleanup and cleanup-start terms, as stated above`, + limits: `Twelve histories crossing loading, ready, and error cleanup, eager and on-demand sources, and the cleaning source's join position.`, }, - ) + // 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. + owns: [ + `src/collection/index.ts#CollectionImpl.cleanup`, + `src/collection/lifecycle.ts`, + `src/query/pooled-live-query.ts`, + `src/query/effect.ts#createEffect`, + ], + histories: lateEffectCases, + label: ({ priorStatus, syncMode, cleaningPosition }) => + `releases a late Effect during ${priorStatus} ${syncMode} cleanup of its ${cleaningPosition} source`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-cleanup.late-effect`, + plan, + observe, + () => runLateEffectCase(plan.history), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/collection-cleanup-restart.test.ts b/packages/db/tests/collection-cleanup-restart.test.ts index 5089be8ad1..7ef78a3dd4 100644 --- a/packages/db/tests/collection-cleanup-restart.test.ts +++ b/packages/db/tests/collection-cleanup-restart.test.ts @@ -1,6 +1,7 @@ // Ordinary tests moved out of collection-cleanup-restart-oracle.test.ts. They // keep literal expectations for cleanup, restart, and observer behavior; the -// three-cut laws and the grammar calibration stay in the oracle file. +// three-cut laws stay in the oracle file. The grammar checks at the end check +// the oracle's bounded grammars. import { describe, expect, it, vi } from 'vitest' import { BasicIndex, @@ -17,6 +18,7 @@ import { reentryScenarios, withOracleCleanup, } from './collection-cleanup-restart-support.js' +import { dependentCleanupCells } from './collection-cleanup-restart-grammar-oracle.js' import type { SyncConfig } from '../src/types' import type { Row } from './collection-cleanup-restart-support.js' @@ -1320,3 +1322,43 @@ describe(`Collection cleanup admission oracle`, () => { expect(collection.isLoadingSubset).toBe(false) }) }) + +function expectExactCaseIds( + actual: ReadonlyArray<{ id: string }>, + expected: ReadonlyArray, +): void { + const ids = actual.map(({ id }) => id) + expect(ids).toEqual(expected) + expect(new Set(ids).size).toBe(ids.length) +} + +// The bounded grammars of the cleanup oracle in +// collection-cleanup-restart-oracle.test.ts: each declared cell exactly once. +describe(`Collection cleanup oracle grammar`, () => { + it(`declares each cell of the dependents product once`, () => { + 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 + // with a held dependent under each of the 2 held adapters. + expect(ids.length).toBe(6 * 6 * 3 + 20 * 2) + }) + + it(`calibrates every declared bounded-product cell exactly once`, () => { + expectExactCaseIds(reentryScenarios, [ + `abort:plain:1`, + `abort:plain:2`, + `abort:nested:1`, + `abort:nested:2`, + `release:plain:1`, + `release:plain:2`, + `release:nested:1`, + `release:nested:2`, + ]) + expectExactCaseIds(completedBoundaryScenarios, [ + `event:source`, + `event:live-query`, + `await:source`, + `await:live-query`, + ]) + }) +}) 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 3b503e3070..101697aaee 100644 --- a/packages/db/tests/collection-gc-mutation-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-mutation-oracle.property.test.ts @@ -1,5 +1,5 @@ import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' @@ -9,12 +9,7 @@ import { gcDeadline, stepGcModel, } from './collection-gc-retention-oracle.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty, oracleReplayRequested } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -383,9 +378,6 @@ const historyArbitrary = fc.record({ age: fc.integer({ min: 0, max: 80 }), sourceValue: fc.integer({ min: -3, max: 3 }), }) -declareOracle({ - grammar: [historyArbitrary], -}) // Keep the original per-history dedupe, campaign reset and one-history // minimum for every declared state. Native measured reach reports the same @@ -450,43 +442,3 @@ describe(`mutation retention campaign reach`, () => { check: forwardAssertionJudgment, }) }) - -// An independently supplied projection is still an exact membership obligation. -// It cannot waive missing, stale or extra keys, even when default insert-slot -// keys would differ from the update/delete driver's expected public rows. -it.skipIf(oracleReplayRequested())( - `rejects corruption of a supplied mutation projection`, - () => { - oracleRoute(`calibration`) - const model = createGcModel(100) - stepGcModel(model, { kind: `mutate`, commit: true }) - const observation = { - retired: false, - visibleKeys: [7], - transactionStates: [`persisting`], - } - expect(() => - checkGcObservation(model, observation, `valid projection`, [], [7]), - ).not.toThrow() - for (const visibleKeys of [[], [1], [7, 8], [7, 7]]) - expect(() => - checkGcObservation( - model, - { ...observation, visibleKeys }, - `corrupt projection`, - [], - [7], - ), - ).toThrow(`visible keys expected [7]`) - stepGcModel(model, { kind: `cleanup` }) - expect(() => - checkGcObservation( - model, - { ...observation, retired: true }, - `cleanup projection`, - [], - [7], - ), - ).toThrow(`cleanup left visible rows`) - }, -) diff --git a/packages/db/tests/collection-gc-retention-oracle.test.ts b/packages/db/tests/collection-gc-retention-calibration.test.ts similarity index 69% rename from packages/db/tests/collection-gc-retention-oracle.test.ts rename to packages/db/tests/collection-gc-retention-calibration.test.ts index 0540cb3512..e7f3e4c530 100644 --- a/packages/db/tests/collection-gc-retention-oracle.test.ts +++ b/packages/db/tests/collection-gc-retention-calibration.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { oracleReplayRequested, oracleRoute } from './oracle-config.js' +import { oracleReplayRequested } from './oracle-config.js' import { checkGcObservation, createGcModel, @@ -9,8 +9,10 @@ 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 +// defines the check and the model. These ordinary tests moved here from +// collection-gc-retention-oracle.test.ts: they step the model and feed the +// check hand-built observations, so they run no production. 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 @@ -38,7 +40,6 @@ 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) @@ -65,7 +66,6 @@ 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 }) @@ -100,7 +100,6 @@ 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 }) @@ -122,3 +121,44 @@ it.skipIf(oracleReplayRequested())( ).toThrow(`visible keys expected [1,2]`) }, ) + +// Moved from collection-gc-mutation-oracle.property.test.ts: a calibration of +// the shared check's supplied-projection argument, with no production path. +// An independently supplied projection is still an exact membership obligation. +// It cannot waive missing, stale or extra keys, even when default insert-slot +// keys would differ from the update/delete driver's expected public rows. +it.skipIf(oracleReplayRequested())( + `rejects corruption of a supplied mutation projection`, + () => { + const model = createGcModel(100) + stepGcModel(model, { kind: `mutate`, commit: true }) + const observation = { + retired: false, + visibleKeys: [7], + transactionStates: [`persisting`], + } + expect(() => + checkGcObservation(model, observation, `valid projection`, [], [7]), + ).not.toThrow() + for (const visibleKeys of [[], [1], [7, 8], [7, 7]]) + expect(() => + checkGcObservation( + model, + { ...observation, visibleKeys }, + `corrupt projection`, + [], + [7], + ), + ).toThrow(`visible keys expected [7]`) + stepGcModel(model, { kind: `cleanup` }) + expect(() => + checkGcObservation( + model, + { ...observation, retired: true }, + `cleanup projection`, + [], + [7], + ), + ).toThrow(`cleanup left visible rows`) + }, +) 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 68bc5ae51c..d1454edc56 100644 --- a/packages/db/tests/collection-gc-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.property.test.ts @@ -1,14 +1,9 @@ import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty, oracleReplayRequested } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -888,10 +883,6 @@ const boundaryHistoryArbitrary: fc.Arbitrary = fc.oneof( { arbitrary: automaticCleanupHistoryArbitrary, weight: 1 }, ) -declareOracle({ - grammar: [boundaryHistoryArbitrary], -}) - // Preserve the original campaign-local counts: one hit per history and // every one of the 33 existing states reached at least once per campaign. describe(`GC boundary campaign reach`, () => { @@ -1078,10 +1069,25 @@ const boundaryExamples: Array<{ name: string; history: BoundaryHistory }> = [ }, }, ] -it.skipIf(oracleReplayRequested()).each(boundaryExamples)( - `reconstructs $name`, - async ({ history }) => { - oracleRoute(`fixed history`) - await runBoundaryHistory(history) +// The generated grammar builds its histories with `.map`, so it cannot take +// these traces back as fixed histories. They run as one enumerated property +// through the same driver and comparison; it stands aside during a replay. +const boundaryFixedPropertyName = `collection-lifecycle.gc-retention-boundaries-fixed` +oracleProperty({ + id: boundaryFixedPropertyName, + law: { + statement: `Collection retention follows independently modeled logical holds, release and handoff deadlines, and controlled host idle opportunities across transaction and cleanup phases.`, + authority: `BaseCollectionConfig.gcTime and the shared GC retention oracle's maintainer decisions`, + limits: `The named review traces that the generated grammar cannot take back; each is replayed through the same driver and comparison.`, }, -) + owns: [`src/collection/lifecycle.ts`], + histories: boundaryExamples, + label: ({ name }) => `reconstructs ${name}`, + resolve: (example) => ({ history: example.history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`Collection GC retention`, plan, observe, () => + runBoundaryHistory(plan.history), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/collection-gc-retention-oracle.ts b/packages/db/tests/collection-gc-retention-oracle.ts index 77f6e75290..3a21c86db7 100644 --- a/packages/db/tests/collection-gc-retention-oracle.ts +++ b/packages/db/tests/collection-gc-retention-oracle.ts @@ -1,5 +1,3 @@ -import { declareOracle } from './oracle-declaration.js' - /** * A Collection may retire only after every subscriber, preload and persisting * transaction has released it for gcTime. A startup without subscribers, or a @@ -264,10 +262,3 @@ export function checkGcObservation( `visible keys expected [${[...expected].join()}], got [${actual.visibleKeys.join()}]`, ) } - -// The model is the reducer and its derived deadline. The check compares the -// public observation at one cut with that model's prediction for the cut. -declareOracle({ - check: [checkGcObservation], - model: [createGcModel, stepGcModel, gcDeadline], -}) 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 84f2282ce7..15371326f3 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,6 @@ import { vi } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' -import { declareOracle } from './oracle-declaration.js' import { differs, oracleProperty, pass, violation } from './oracle-property.js' import { resetCleanupQueue, withOracleCleanup } from './utils.js' import type { Verdict } from './oracle-property.js' @@ -639,10 +638,6 @@ const advanceStep = (elapsedMs: 1 | 20 | 60 | 120): Step => ({ }) const cleanupStep = (side: Side): Step => ({ kind: `cleanup`, side }) -declareOracle({ - grammar: [stepArbitrary], -}) - // Replay one history with TANSTACK_DB_ORACLE_SEED, TANSTACK_DB_ORACLE_PATH, // and TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-persisting-transaction. // Each constructed history names the states it enters, with the steps that diff --git a/packages/db/tests/collection-index-value-oracle.test.ts b/packages/db/tests/collection-index-value-oracle.test.ts index d0599d0d1b..e94e585d06 100644 --- a/packages/db/tests/collection-index-value-oracle.test.ts +++ b/packages/db/tests/collection-index-value-oracle.test.ts @@ -15,12 +15,16 @@ * Genuine immutable native values retain their type and value. Production * serializers and comparators do not supply the model's expected classes. */ -import { afterEach, expect, it } from 'vitest' +import { afterEach, expect } 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' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' afterEach(async () => { await Promise.all( @@ -44,16 +48,9 @@ function 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`) +// The original case, unchanged: it creates one index per fixture and compares +// the signatures with the fixtures' classes. +function runSignatureClassCase(): void { 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) } @@ -83,4 +80,38 @@ it(`distinguishes native literals from ordinary tags in index signatures`, () => coalesce(row.stamp, new IR.Value(fixture.value)), ) expectSignatureClasses(fixtures, source.getIndexMetadata()) +} + +// The fixtures hold Temporal values, which are not history data, so the one +// enumerated history is the case's name. +const signatureCases = new Map([ + [ + `distinguishes native literals from ordinary tags in index signatures`, + runSignatureClassCase, + ], +]) + +// 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. +oracleProperty({ + id: `collection-index.signature-classes`, + law: { + statement: `Index signatures from getIndexMetadata are equal exactly when the indexed literals belong to the same independently assigned equivalence class.`, + authority: `The public createIndex/getIndexMetadata contract stated above`, + limits: `The bounded fixture classes above: native Temporal values, ordinary records shaped like native tags, nested combinations, and reordered record keys.`, + }, + owns: [`src/collection/indexes.ts`], + histories: [...signatureCases.keys()], + label: (name) => name, + resolve: (name) => ({ name, run: signatureCases.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-index.signature-classes`, + plan, + observe, + () => plan.run(), + ), + check: forwardAssertionJudgment, }) 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 831f400f1e..f8c2e1d3ed 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts @@ -1,14 +1,13 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' -import { oracleProperty, oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { describe, expect } from 'vitest' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import { - applyRound, expectMetadataCancellationOwnership, metadataCancellationArbitrary, mutateDriverMetadata, @@ -16,184 +15,13 @@ import { runPublicationHistory, unchangedMetadata, } from './collection-metadata-publication-oracle.js' -import type { - MetadataDriver, - MetadataEntryState, - MetadataOperation, - PublicationRound, -} from './collection-metadata-publication-oracle.js' +import type { PublicationRound } from './collection-metadata-publication-oracle.js' // The named companion states the metadata publication law, independent row // and metadata models, bounded grammar, production path and comparison cuts. const metadataOnlyProperty = `collection-publication.metadata-only` const metadataCancellationProperty = `collection-publication.metadata-cancellation` -it(`detects driver mutation of committed metadata without rewriting its authority`, async () => { - oracleRoute(`calibration`) - const rounds: Array = [ - { - key: 1, - delta: 1, - metadata: [{ key: 1, type: `set`, value: { nested: 1 } }], - outcome: `commit`, - }, - ] - const original = structuredClone(rounds) - await expect( - runPublicationHistory(rounds, mutateDriverMetadata), - ).rejects.toMatchObject({ name: `AssertionError` }) - expect(rounds).toEqual(original) - await runPublicationHistory(rounds) - await runPublicationHistory( - rounds.map((round) => ({ ...round, outcome: `abort` })), - mutateDriverMetadata, - ) -}) - -it(`rejects a torn callback-time metadata cut even when settled metadata is correct`, async () => { - oracleRoute(`calibration`) - const rounds: Array = [ - { - key: 1, - delta: 1, - metadata: [{ key: 1, type: `set`, value: false }], - outcome: `commit`, - }, - ] - await expect( - runPublicationHistory(rounds, unchangedMetadata, (cuts) => { - cuts[1]!.metadata.set(1, undefined) - }), - ).rejects.toMatchObject({ name: `AssertionError` }) - await runPublicationHistory(rounds) -}) - -it.each( - ([`initial`, `retained`] as const).flatMap((placement) => - [true, false].map((canceledFirst) => ({ placement, canceledFirst })), - ), -)( - `detects $placement metadata mutation with canceledFirst=$canceledFirst`, - async ({ placement, canceledFirst }) => { - oracleRoute(`calibration`) - const initial: Array = - placement === `initial` - ? [ - { present: true, value: { nested: 1 } }, - { present: false }, - { present: false }, - ] - : [{ present: false }, { present: false }, { present: false }] - const retained: MetadataOperation = { - type: `set`, - value: placement === `retained` ? { nested: 2 } : false, - } - const original = structuredClone({ initial, retained }) - const run = (driver: MetadataDriver) => - expectMetadataCancellationOwnership( - [1], - [2], - { type: `delete` }, - retained, - canceledFirst, - initial, - driver, - ) - await expect(run(mutateDriverMetadata)).rejects.toMatchObject({ - name: `AssertionError`, - }) - expect({ initial, retained }).toEqual(original) - await run(unchangedMetadata) - }, -) - -it(`publishes one event per key when metadata-only sync retires optimistic work`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { - key: 1, - delta: 1, - metadata: [{ key: 1, type: `set`, value: false }], - outcome: `commit`, - }, - { - key: 1, - delta: 1, - metadata: [{ key: 1, type: `delete` }], - outcome: `commit`, - }, - ]) -}) - -it(`applies the last committed metadata write and discards aborted writes`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { - key: 1, - delta: 2, - metadata: [ - { key: 1, type: `set`, value: `superseded` }, - { key: 0, type: `set`, value: Number.NaN }, - { key: 1, type: `set`, value: false }, - ], - outcome: `commit`, - }, - { - key: 1, - delta: -1, - metadata: [ - { key: 1, type: `delete` }, - { key: 2, type: `set`, value: `canceled` }, - ], - outcome: `abort`, - }, - ]) -}) - -it(`releases only canceled metadata keys while another sync remains pending`, async () => { - oracleRoute(`fixed history`) - await expectMetadataCancellationOwnership( - [0, 1], - [1, 2], - { type: `delete` }, - { type: `set`, value: false }, - true, - [ - { present: true, value: undefined }, - { present: true, value: false }, - { present: true, value: null }, - ], - ) -}) - -it(`does not apply canceled metadata to an absent base key`, async () => { - oracleRoute(`fixed history`) - await expectMetadataCancellationOwnership( - [0, 1], - [1, 2], - { type: `set`, value: `canceled` }, - { type: `set`, value: `retained` }, - true, - [{ present: false }, { present: false }, { present: false }], - ) -}) - -it(`settles an older metadata owner after canceling the newer owner`, async () => { - oracleRoute(`fixed history`) - await expectMetadataCancellationOwnership( - [0, 1], - [1, 2], - { type: `delete` }, - { type: `set`, value: `retained` }, - false, - [ - { present: true, value: undefined }, - { present: false }, - { present: true, value: false }, - ], - ) -}) - const publicationHistoryArbitrary = fc.array(publicationRoundArbitrary, { minLength: 1, maxLength: 8, @@ -221,10 +49,238 @@ const runCancellationHistory = ({ initialMetadata, ) -declareOracle({ - check: [applyRound, expectMetadataCancellationOwnership], +// A calibration's own precondition, such as an input the mutant must not +// rewrite, is not the check rejecting the wrong answer. A failure here loses +// any comparison marking, so it fails the calibration instead of passing it. +async function calibrationPrecondition( + name: string, + run: () => void | Promise, +): Promise { + try { + await run() + } catch (error) { + throw new Error( + `${name}: ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, + ) + } +} + +// Exactly the original calibrations' matcher: only an AssertionError counts +// as the check rejecting the wrong answer. A run that resolves records a +// pass, so the calibration finds no rejecting history and fails. +async function rejectsWithAssertion( + checkpoint: string, + run: () => Promise, + afterRejection: () => void | Promise, +): Promise { + try { + await run() + } catch (error) { + if (error instanceof Error && error.name === `AssertionError`) { + await calibrationPrecondition(checkpoint, afterRejection) + rejectAt(checkpoint, error) + } + throw new Error( + `${checkpoint}: expected an AssertionError, got ${error instanceof Error ? (error.stack ?? error.message) : String(error)}`, + ) + } +} + +// The metadata-only rounds below are named regressions and the histories of +// the two driver calibrations. publicationRoundArbitrary builds its rounds +// with `.map`, so it cannot take them back as fixed histories; they run as one +// enumerated property through the same driver and comparison. +const metadataOnlyFixedProperty = `collection-publication.metadata-only-fixed` +const driverMutationRounds = `one committed nested metadata write` +const tornCutRounds = `one committed false metadata write` +const metadataOnlyFixedHistories: Array<{ + name: string + rounds: Array +}> = [ + { + name: driverMutationRounds, + rounds: [ + { + key: 1, + delta: 1, + metadata: [{ key: 1, type: `set`, value: { nested: 1 } }], + outcome: `commit`, + }, + ], + }, + { + name: tornCutRounds, + rounds: [ + { + key: 1, + delta: 1, + metadata: [{ key: 1, type: `set`, value: false }], + outcome: `commit`, + }, + ], + }, + { + name: `publishes one event per key when metadata-only sync retires optimistic work`, + rounds: [ + { + key: 1, + delta: 1, + metadata: [{ key: 1, type: `set`, value: false }], + outcome: `commit`, + }, + { + key: 1, + delta: 1, + metadata: [{ key: 1, type: `delete` }], + outcome: `commit`, + }, + ], + }, + { + name: `applies the last committed metadata write and discards aborted writes`, + rounds: [ + { + key: 1, + delta: 2, + metadata: [ + { key: 1, type: `set`, value: `superseded` }, + { key: 0, type: `set`, value: Number.NaN }, + { key: 1, type: `set`, value: false }, + ], + outcome: `commit`, + }, + { + key: 1, + delta: -1, + metadata: [ + { key: 1, type: `delete` }, + { key: 2, type: `set`, value: `canceled` }, + ], + outcome: `abort`, + }, + ], + }, +] +type MetadataOnlyFixedPlan = { + history: (typeof metadataOnlyFixedHistories)[number] +} +const runMetadataOnlyFixed = ( + plan: MetadataOnlyFixedPlan, + observe: (checkpoint: string, value: unknown) => void, +) => + recordAssertionJudgment(metadataOnlyFixedProperty, plan, observe, () => + runPublicationHistory(plan.history.rounds), + ) +// A calibration applies its wrong answer only to the history that its +// original test named; every other candidate runs the unchanged comparison, +// passes, and is passed over. +const metadataOnlyCalibration = ( + name: string, + target: string, + mutant: (rounds: Array) => Promise, +) => ({ + name, + law: metadataOnlyFixedProperty, + driver: ( + plan: MetadataOnlyFixedPlan, + observe: (checkpoint: string, value: unknown) => void, + ) => + plan.history.name === target + ? recordAssertionJudgment(metadataOnlyFixedProperty, plan, observe, () => + mutant(plan.history.rounds), + ) + : runMetadataOnlyFixed(plan, observe), }) +// The cancellation histories: named regressions and the histories of the +// four placement calibrations, in the generated cancellation grammar's shape. +const metadataCancellationFixedProperty = `collection-publication.metadata-cancellation-fixed` +const placementHistoryName = ( + placement: `initial` | `retained`, + canceledFirst: boolean, +) => `${placement} metadata with canceledFirst=${canceledFirst}` +const placementHistories = ([`initial`, `retained`] as const).flatMap( + (placement) => + [true, false].map((canceledFirst): MetadataCancellationFixedHistory => ({ + name: placementHistoryName(placement, canceledFirst), + canceledKeys: [1], + retainedKeys: [2], + canceledOperation: { type: `delete` }, + retainedOperation: { + type: `set`, + value: placement === `retained` ? { nested: 2 } : false, + }, + canceledFirst, + initialMetadata: + placement === `initial` + ? [ + { present: true, value: { nested: 1 } }, + { present: false }, + { present: false }, + ] + : [{ present: false }, { present: false }, { present: false }], + })), +) +type MetadataCancellationFixedHistory = MetadataCancellationHistory & { + name: string +} +const metadataCancellationFixedHistories: Array = + [ + ...placementHistories, + { + name: `releases only canceled metadata keys while another sync remains pending`, + canceledKeys: [0, 1], + retainedKeys: [1, 2], + canceledOperation: { type: `delete` }, + retainedOperation: { type: `set`, value: false }, + canceledFirst: true, + initialMetadata: [ + { present: true, value: undefined }, + { present: true, value: false }, + { present: true, value: null }, + ], + }, + { + name: `does not apply canceled metadata to an absent base key`, + canceledKeys: [0, 1], + retainedKeys: [1, 2], + canceledOperation: { type: `set`, value: `canceled` }, + retainedOperation: { type: `set`, value: `retained` }, + canceledFirst: true, + initialMetadata: [ + { present: false }, + { present: false }, + { present: false }, + ], + }, + { + name: `settles an older metadata owner after canceling the newer owner`, + canceledKeys: [0, 1], + retainedKeys: [1, 2], + canceledOperation: { type: `delete` }, + retainedOperation: { type: `set`, value: `retained` }, + canceledFirst: false, + initialMetadata: [ + { present: true, value: undefined }, + { present: false }, + { present: true, value: false }, + ], + }, + ] +type MetadataCancellationFixedPlan = { + history: MetadataCancellationFixedHistory +} +const runCancellationFixed = ( + plan: MetadataCancellationFixedPlan, + observe: (checkpoint: string, value: unknown) => void, +) => + recordAssertionJudgment( + metadataCancellationFixedProperty, + plan, + observe, + () => runCancellationHistory(plan.history), + ) + describe(`generated metadata publication histories`, () => { oracleProperty({ id: metadataOnlyProperty, @@ -271,4 +327,119 @@ describe(`generated metadata publication histories`, () => { ), check: forwardAssertionJudgment, }) + oracleProperty({ + id: metadataOnlyFixedProperty, + law: { + statement: `Metadata-only optimistic settlement publishes one coherent keyed row and metadata world.`, + authority: `The established Collection sync metadata and change subscription contracts stated above`, + limits: `Named metadata-only histories that the generated round grammar cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + histories: metadataOnlyFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: runMetadataOnlyFixed, + check: forwardAssertionJudgment, + calibrations: [ + metadataOnlyCalibration( + `detects driver mutation of committed metadata without rewriting its authority`, + driverMutationRounds, + async (rounds) => { + const original = structuredClone(rounds) + await rejectsWithAssertion( + `driver metadata mutation`, + () => runPublicationHistory(rounds, mutateDriverMetadata), + async () => { + expect(rounds).toEqual(original) + await runPublicationHistory( + rounds.map((round) => ({ ...round, outcome: `abort` })), + mutateDriverMetadata, + ) + }, + ) + }, + ), + metadataOnlyCalibration( + `rejects a torn callback-time metadata cut even when settled metadata is correct`, + tornCutRounds, + (rounds) => + rejectsWithAssertion( + `torn callback-time metadata cut`, + () => + runPublicationHistory(rounds, unchangedMetadata, (cuts) => { + cuts[1]!.metadata.set(1, undefined) + }), + () => {}, + ), + ), + ], + }) + oracleProperty({ + id: metadataCancellationFixedProperty, + law: { + statement: `Canceling a pending metadata transaction releases only its keys and preserves the other pending transaction's metadata.`, + authority: `The established Collection sync metadata and change subscription contracts stated above`, + limits: `Named cancellation histories that the generated cancellation grammar cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + histories: metadataCancellationFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: runCancellationFixed, + check: forwardAssertionJudgment, + calibrations: ([`initial`, `retained`] as const).flatMap((placement) => + [true, false].map((canceledFirst) => { + const target = placementHistoryName(placement, canceledFirst) + return { + name: `detects ${placement} metadata mutation with canceledFirst=${canceledFirst}`, + law: metadataCancellationFixedProperty, + driver: ( + plan: MetadataCancellationFixedPlan, + observe: (checkpoint: string, value: unknown) => void, + ) => + plan.history.name === target + ? recordAssertionJudgment( + metadataCancellationFixedProperty, + plan, + observe, + async () => { + const { + canceledKeys, + retainedKeys, + canceledOperation, + retainedOperation, + initialMetadata, + } = plan.history + const original = structuredClone({ + initial: initialMetadata, + retained: retainedOperation, + }) + await rejectsWithAssertion( + `${placement} metadata mutation`, + () => + expectMetadataCancellationOwnership( + canceledKeys, + retainedKeys, + canceledOperation, + retainedOperation, + canceledFirst, + initialMetadata, + mutateDriverMetadata, + ), + () => { + expect({ + initial: initialMetadata, + retained: retainedOperation, + }).toEqual(original) + }, + ) + }, + ) + : runCancellationFixed(plan, observe), + } + }), + ), + }) }) diff --git a/packages/db/tests/collection-metadata-publication-oracle.ts b/packages/db/tests/collection-metadata-publication-oracle.ts index c79281ff85..fb7172096e 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.ts @@ -5,7 +5,6 @@ import { createDeferred } from '../src/deferred.js' import { SyncTransactionAbortedError } from '../src/errors.js' import { createLiveQueryCollection } from '../src/query/index.js' import { createTransaction } from '../src/transactions.js' -import { declareOracle } from './oracle-declaration.js' import { oracleAssertion } from './oracle-assertion-adapter.js' import { withHistoryCleanup } from './optimistic-history-oracle.js' import { metadataWorlds } from './metadata-publication-worlds.js' @@ -601,16 +600,7 @@ const mutateDriverMetadata: MetadataDriver = (value) => { return value } -// Both the generated campaign and its shrink/replay calibration use these -// unchanged independent comparisons and production histories. -declareOracle({ - owns: [`src/collection/sync.ts`, `src/collection/state.ts`], - check: [applyRound, expectMetadataCancellationOwnership], - grammar: [publicationRoundArbitrary, metadataCancellationArbitrary], -}) - export { - applyRound, expectMetadataCancellationOwnership, metadataCancellationArbitrary, mutateDriverMetadata, diff --git a/packages/db/tests/collection-row-metadata-composition-model-oracle.ts b/packages/db/tests/collection-row-metadata-composition-model-oracle.ts new file mode 100644 index 0000000000..878864fdf3 --- /dev/null +++ b/packages/db/tests/collection-row-metadata-composition-model-oracle.ts @@ -0,0 +1,357 @@ +// The model and history grammar of the row metadata composition oracle, +// moved unchanged from collection-row-metadata-composition-oracle.test.ts so +// that the ordinary grammar and model tests in +// collection-row-metadata-composition.test.ts can import them. The oracle's +// opening comment states the contract that this model enacts. + +export type Write = + | { kind: `set` } + | { kind: `unset` } + | { kind: `truncate` } + | { + kind: `insert` | `reinsert` | `update` | `delete` + metadata: boolean + } +export type Start = + `absent` | `present-with-metadata` | `present-without-metadata` +export type ProductionPath = `immediate` | `held` | `rebuilt` + +export const writes: ReadonlyArray = [ + { kind: `set` }, + { kind: `unset` }, + { kind: `truncate` }, + ...([`insert`, `reinsert`, `update`, `delete`] as const).flatMap((kind) => + [false, true].map((metadata) => ({ kind, metadata })), + ), +] + +export const startMetadata = { m: `start` } +export const writeMetadata = (step: number) => ({ m: `w${step}` }) + +/** + * The model folds the writes in order over the starting value. It keeps no + * staging state: each write replaces, keeps, or clears the one current value, + * as the contract above states. + */ +export function expectedMetadata(start: Start, sequence: ReadonlyArray) { + return foldMetadata( + start === `present-with-metadata` ? startMetadata : undefined, + sequence, + ) +} + +export function foldMetadata(initial: unknown, sequence: ReadonlyArray) { + let current = initial + sequence.forEach((write, step) => { + if (write.kind === `set`) current = writeMetadata(step) + else if ( + write.kind === `unset` || + write.kind === `truncate` || + write.kind === `delete` + ) + current = undefined + else if (write.metadata) current = writeMetadata(step) + else if (write.kind === `insert`) current = undefined + // A re-insert or an update without metadata keeps the current value. + }) + return current +} + +/** + * Legal histories follow the change-message protocol for the row: an insert + * names an absent key, and an update or delete names a present one. A + * truncate leaves the row absent. Metadata API calls are legal in any row + * state. + */ +export function isLegal(start: Start, sequence: ReadonlyArray): boolean { + let present = start !== `absent` + for (const write of sequence) { + if (write.kind === `truncate`) present = false + else if (write.kind === `insert`) { + if (present) return false + present = true + } else if ( + write.kind === `reinsert` || + write.kind === `update` || + write.kind === `delete` + ) { + if (!present) return false + present = write.kind !== `delete` + } + } + return true +} + +export const starts: ReadonlyArray = [ + `absent`, + `present-with-metadata`, + `present-without-metadata`, +] +export const histories = starts.flatMap((start) => { + const legal: Array<{ start: Start; sequence: Array }> = [] + const extend = (sequence: Array) => { + if (sequence.length > 0 && isLegal(start, sequence)) + legal.push({ start, sequence }) + if (sequence.length < 3) + for (const write of writes) extend([...sequence, write]) + } + extend([]) + return legal +}) + +/** + * 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 variant. + */ +export type EarlierWrite = { key: 1 | 2; kind: `insert` | `update` | `delete` } +export const shortSequences = writes.flatMap((first) => [ + [first], + ...writes.map((second) => [first, second]), +]) +export const asWrite = (earlier: EarlierWrite): Write => ({ + kind: earlier.kind, + metadata: false, +}) +export const rebuiltHistories = starts.flatMap((start) => { + const present = start !== `absent` + const earlierWrites: Array = [ + { key: 2, kind: `update` }, + ...(present + ? ([ + { key: 1, kind: `update` }, + { key: 1, kind: `delete` }, + ] as const) + : ([{ key: 1, kind: `insert` }] as const)), + ] + return earlierWrites.flatMap((earlier) => + earlier.key === 2 + ? histories + .filter((history) => history.start === start) + .map((history) => ({ ...history, earlier })) + : shortSequences + .filter((sequence) => isLegal(start, [asWrite(earlier), ...sequence])) + .map((sequence) => ({ start, sequence, earlier })), + ) +}) + +/** + * 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. + * - `insert`: a transaction begun inside T1 inserts the absent row with its + * own metadata. + * - `seed`: a hydration seed inserts the absent row with its own metadata. + * The Collection places a seed ahead of the first open transaction. + * + * A nested transaction can also call `metadata.row.set` after its row write. + * + * A transaction begun inside T1 is independent and commits first, so it + * applies first: sync transactions apply in commit order (maintainer + * decision of 2026-10-23, recorded in + * `docs/contributing/oracle-reviews/2026-10-22-sync-metadata-session.md`). + * 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 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. + * + * A seed is older than every source write (decisions D4 and D5 of + * 2026-10-22). It sits beneath T1, and T1's first insert names the whole + * row, so it is an insert that replaces the seeded row, not an equal + * re-insert of it. + * + * A history is legal when T1's writes are legal both where T1 writes them and + * where they apply. An insert is legal in either place. An update or a delete + * needs the row present in both. A truncate leaves the row absent in both. + */ +export type NestedWrite = `delete` | `insert` | `seed` +export type NestedHistory = { + start: Start + nested: NestedWrite + nestedSet: boolean + sequence: Array +} +export const nestedMetadata = { m: `nested` } +export const nestedSetMetadata = { m: `nested-set` } +// The model decides which inserts are re-inserts, so T1 writes only inserts. +export const nestedCandidates = writes.filter( + (write) => write.kind !== `reinsert`, +) +export function isNestedLegal( + writePresent: boolean, + applyPresent: boolean, + sequence: ReadonlyArray, +): boolean { + for (const write of sequence) { + if (write.kind === `truncate`) writePresent = applyPresent = false + else if (write.kind === `insert`) writePresent = applyPresent = true + else if (write.kind === `update` || write.kind === `delete`) { + if (!writePresent || !applyPresent) return false + if (write.kind === `delete`) writePresent = applyPresent = false + } + } + return true +} +export const nestedVariants: ReadonlyArray> = [ + ...([`present-with-metadata`, `present-without-metadata`] as const).flatMap( + (start) => + [false, true].map((nestedSet) => ({ + start, + nested: `delete` as const, + nestedSet, + })), + ), + { start: `absent`, nested: `insert`, nestedSet: false }, + { start: `absent`, nested: `insert`, nestedSet: true }, + { start: `absent`, nested: `seed`, nestedSet: false }, +] +export const nestedHistories: Array = nestedVariants.flatMap( + (variant) => { + const writePresent = variant.start !== `absent` + const applyPresent = variant.nested !== `delete` + const sequences: Array> = [] + const extend = (sequence: Array) => { + if ( + sequence.length > 0 && + isNestedLegal(writePresent, applyPresent, sequence) + ) + sequences.push(sequence) + if (sequence.length < 3) + for (const write of nestedCandidates) extend([...sequence, write]) + } + extend([]) + return sequences.map((sequence) => ({ ...variant, sequence })) + }, +) +/** + * An insert of the row is a re-insert exactly when the row is present. A + * delete or a truncate leaves the row absent. + */ +export const reclassify = ( + present: boolean, + sequence: ReadonlyArray, +): Array => + sequence.map((write) => { + if (write.kind === `delete` || write.kind === `truncate`) present = false + if (write.kind !== `insert`) return write + const kind = present ? `reinsert` : `insert` + present = true + return { kind, metadata: write.metadata } + }) +/** + * The model's expected value: T1's writes fold over the nested write's. A + * nested insert leaves the row present for T1's inserts; a seed does not, + * because T1's first insert replaces it. + */ +export function expectedNestedMetadata(history: NestedHistory): unknown { + const metadata = history.nestedSet + ? nestedSetMetadata + : history.nested === `delete` + ? undefined + : nestedMetadata + return foldMetadata( + metadata, + reclassify(history.nested === `insert`, history.sequence), + ) +} +export const describeNested = ({ + start, + nested, + nestedSet, + sequence, +}: NestedHistory) => + `${start}, nested ${nested}${nestedSet ? `+set` : ``}: ${sequence.map(describeWrite).join(`, `)}` + +export const describeWrite = (write: Write) => + write.kind === `set` || write.kind === `unset` || write.kind === `truncate` + ? write.kind + : `${write.kind}${write.metadata ? `+metadata` : ``}` + +/** + * 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 + * 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 + * settles. A transaction with a truncate applies at once instead. + * - `rebuilt`: an earlier held transaction applies while this one is still + * open, so the Collection rebuilds this transaction's automatic metadata + * writes before it commits. + */ +// 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. +export function expectedProductionPathMetadata( + start: Start, + sequence: ReadonlyArray, + earlier: EarlierWrite | undefined, +): unknown { + return earlier?.key === 1 + ? foldMetadata(expectedMetadata(start, [asWrite(earlier)]), sequence) + : expectedMetadata(start, sequence) +} + +// The observed metadata must equal the model's value, compared as JSON. A +// mismatch is recorded with its history label instead of failing at once. +export function compareObservedMetadata( + mismatches: Array, + label: string, + actual: unknown, + expected: unknown, +): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) + mismatches.push( + `${label} read ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, + ) +} + +/** + * The held-window model folds collection metadata separately. An explicit + * `set` names the cursor's value and an `unset` deletes it. Row writes and + * truncates leave it alone, because collection metadata is not row state. + * T2 always writes the `later` key, so a correct `list()` contains it. + */ +export const cursorKey = `cursor` +export const laterKey = `later` +export const laterMetadata = { m: `later` } +export type HeldOpen = `empty` | `truncated` +export type HeldReads = { + row: unknown + cursor: unknown + list: Array<{ key: string; value: unknown }> +} + +export function expectedCursor(start: Start, sequence: ReadonlyArray) { + let current: unknown = + start === `present-with-metadata` ? startMetadata : undefined + sequence.forEach((write, step) => { + if (write.kind === `set`) current = writeMetadata(step) + else if (write.kind === `unset`) current = undefined + }) + return current +} + +export function expectedHeldReads( + start: Start, + sequence: ReadonlyArray, + open: HeldOpen | `applied`, +): HeldReads { + const cursor = expectedCursor(start, sequence) + return { + // An open truncate hides every older row metadata value. + row: open === `truncated` ? undefined : expectedMetadata(start, sequence), + cursor, + list: [ + ...(cursor === undefined ? [] : [{ key: cursorKey, value: cursor }]), + { key: laterKey, value: laterMetadata }, + ], + } +} 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 4cbbaaed1c..6e26c41bb6 100644 --- a/packages/db/tests/collection-row-metadata-composition-oracle.test.ts +++ b/packages/db/tests/collection-row-metadata-composition-oracle.test.ts @@ -1,8 +1,39 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } 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 { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' +import { + compareObservedMetadata, + cursorKey, + describeNested, + describeWrite, + expectedHeldReads, + expectedNestedMetadata, + expectedProductionPathMetadata, + histories, + laterKey, + laterMetadata, + nestedHistories, + nestedMetadata, + nestedSetMetadata, + rebuiltHistories, + startMetadata, + writeMetadata, +} from './collection-row-metadata-composition-model-oracle.js' +import type { + EarlierWrite, + HeldOpen, + HeldReads, + NestedHistory, + ProductionPath, + Start, + Write, +} from './collection-row-metadata-composition-model-oracle.js' import type { SyncConfig } from '../src/types.js' /** @@ -58,326 +89,6 @@ import type { SyncConfig } from '../src/types.js' type Row = { id: number; v: number } type SyncActions = Parameters[`sync`]>[0] -type Write = - | { kind: `set` } - | { kind: `unset` } - | { kind: `truncate` } - | { - kind: `insert` | `reinsert` | `update` | `delete` - metadata: boolean - } -type Start = `absent` | `present-with-metadata` | `present-without-metadata` -type ProductionPath = `immediate` | `held` | `rebuilt` - -const writes: ReadonlyArray = [ - { kind: `set` }, - { kind: `unset` }, - { kind: `truncate` }, - ...([`insert`, `reinsert`, `update`, `delete`] as const).flatMap((kind) => - [false, true].map((metadata) => ({ kind, metadata })), - ), -] - -const startMetadata = { m: `start` } -const writeMetadata = (step: number) => ({ m: `w${step}` }) - -/** - * The model folds the writes in order over the starting value. It keeps no - * staging state: each write replaces, keeps, or clears the one current value, - * as the contract above states. - */ -function expectedMetadata(start: Start, sequence: ReadonlyArray) { - return foldMetadata( - start === `present-with-metadata` ? startMetadata : undefined, - sequence, - ) -} - -function foldMetadata(initial: unknown, sequence: ReadonlyArray) { - let current = initial - sequence.forEach((write, step) => { - if (write.kind === `set`) current = writeMetadata(step) - else if ( - write.kind === `unset` || - write.kind === `truncate` || - write.kind === `delete` - ) - current = undefined - else if (write.metadata) current = writeMetadata(step) - else if (write.kind === `insert`) current = undefined - // A re-insert or an update without metadata keeps the current value. - }) - return current -} - -/** - * Legal histories follow the change-message protocol for the row: an insert - * names an absent key, and an update or delete names a present one. A - * truncate leaves the row absent. Metadata API calls are legal in any row - * state. - */ -function isLegal(start: Start, sequence: ReadonlyArray): boolean { - let present = start !== `absent` - for (const write of sequence) { - if (write.kind === `truncate`) present = false - else if (write.kind === `insert`) { - if (present) return false - present = true - } else if ( - write.kind === `reinsert` || - write.kind === `update` || - write.kind === `delete` - ) { - if (!present) return false - present = write.kind !== `delete` - } - } - return true -} - -const starts: ReadonlyArray = [ - `absent`, - `present-with-metadata`, - `present-without-metadata`, -] -const histories = starts.flatMap((start) => { - const legal: Array<{ start: Start; sequence: Array }> = [] - const extend = (sequence: Array) => { - if (sequence.length > 0 && isLegal(start, sequence)) - legal.push({ start, sequence }) - if (sequence.length < 3) - for (const write of writes) extend([...sequence, write]) - } - extend([]) - return legal -}) - -/** - * 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 variant. - */ -type EarlierWrite = { key: 1 | 2; kind: `insert` | `update` | `delete` } -const shortSequences = writes.flatMap((first) => [ - [first], - ...writes.map((second) => [first, second]), -]) -const asWrite = (earlier: EarlierWrite): Write => ({ - kind: earlier.kind, - metadata: false, -}) -const rebuiltHistories = starts.flatMap((start) => { - const present = start !== `absent` - const earlierWrites: Array = [ - { key: 2, kind: `update` }, - ...(present - ? ([ - { key: 1, kind: `update` }, - { key: 1, kind: `delete` }, - ] as const) - : ([{ key: 1, kind: `insert` }] as const)), - ] - return earlierWrites.flatMap((earlier) => - earlier.key === 2 - ? histories - .filter((history) => history.start === start) - .map((history) => ({ ...history, earlier })) - : shortSequences - .filter((sequence) => isLegal(start, [asWrite(earlier), ...sequence])) - .map((sequence) => ({ start, sequence, earlier })), - ) -}) - -/** - * 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. - * - `insert`: a transaction begun inside T1 inserts the absent row with its - * own metadata. - * - `seed`: a hydration seed inserts the absent row with its own metadata. - * The Collection places a seed ahead of the first open transaction. - * - * A nested transaction can also call `metadata.row.set` after its row write. - * - * A transaction begun inside T1 is independent and commits first, so it - * applies first: sync transactions apply in commit order (maintainer - * decision of 2026-10-23, recorded in - * `docs/contributing/oracle-reviews/2026-10-22-sync-metadata-session.md`). - * 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 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. - * - * A seed is older than every source write (decisions D4 and D5 of - * 2026-10-22). It sits beneath T1, and T1's first insert names the whole - * row, so it is an insert that replaces the seeded row, not an equal - * re-insert of it. - * - * A history is legal when T1's writes are legal both where T1 writes them and - * where they apply. An insert is legal in either place. An update or a delete - * needs the row present in both. A truncate leaves the row absent in both. - */ -type NestedWrite = `delete` | `insert` | `seed` -type NestedHistory = { - start: Start - nested: NestedWrite - nestedSet: boolean - sequence: Array -} -const nestedMetadata = { m: `nested` } -const nestedSetMetadata = { m: `nested-set` } -// The model decides which inserts are re-inserts, so T1 writes only inserts. -const nestedCandidates = writes.filter((write) => write.kind !== `reinsert`) -function isNestedLegal( - writePresent: boolean, - applyPresent: boolean, - sequence: ReadonlyArray, -): boolean { - for (const write of sequence) { - if (write.kind === `truncate`) writePresent = applyPresent = false - else if (write.kind === `insert`) writePresent = applyPresent = true - else if (write.kind === `update` || write.kind === `delete`) { - if (!writePresent || !applyPresent) return false - if (write.kind === `delete`) writePresent = applyPresent = false - } - } - return true -} -const nestedVariants: ReadonlyArray> = [ - ...([`present-with-metadata`, `present-without-metadata`] as const).flatMap( - (start) => - [false, true].map((nestedSet) => ({ - start, - nested: `delete` as const, - nestedSet, - })), - ), - { start: `absent`, nested: `insert`, nestedSet: false }, - { start: `absent`, nested: `insert`, nestedSet: true }, - { start: `absent`, nested: `seed`, nestedSet: false }, -] -const nestedHistories: Array = nestedVariants.flatMap( - (variant) => { - const writePresent = variant.start !== `absent` - const applyPresent = variant.nested !== `delete` - const sequences: Array> = [] - const extend = (sequence: Array) => { - if ( - sequence.length > 0 && - isNestedLegal(writePresent, applyPresent, sequence) - ) - sequences.push(sequence) - if (sequence.length < 3) - for (const write of nestedCandidates) extend([...sequence, write]) - } - extend([]) - return sequences.map((sequence) => ({ ...variant, sequence })) - }, -) -/** - * An insert of the row is a re-insert exactly when the row is present. A - * delete or a truncate leaves the row absent. - */ -const reclassify = ( - present: boolean, - sequence: ReadonlyArray, -): Array => - sequence.map((write) => { - if (write.kind === `delete` || write.kind === `truncate`) present = false - if (write.kind !== `insert`) return write - const kind = present ? `reinsert` : `insert` - present = true - return { kind, metadata: write.metadata } - }) -/** - * The model's expected value: T1's writes fold over the nested write's. A - * nested insert leaves the row present for T1's inserts; a seed does not, - * because T1's first insert replaces it. - */ -function expectedNestedMetadata(history: NestedHistory): unknown { - const metadata = history.nestedSet - ? nestedSetMetadata - : history.nested === `delete` - ? undefined - : nestedMetadata - return foldMetadata( - metadata, - reclassify(history.nested === `insert`, history.sequence), - ) -} -const describeNested = ({ - start, - nested, - nestedSet, - sequence, -}: NestedHistory) => - `${start}, nested ${nested}${nestedSet ? `+set` : ``}: ${sequence.map(describeWrite).join(`, `)}` - -const describeWrite = (write: Write) => - write.kind === `set` || write.kind === `unset` || write.kind === `truncate` - ? write.kind - : `${write.kind}${write.metadata ? `+metadata` : ``}` - -/** - * 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 - * 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 - * settles. A transaction with a truncate applies at once instead. - * - `rebuilt`: an earlier held transaction applies while this one is still - * open, so the Collection rebuilds this transaction's automatic metadata - * writes before it commits. - */ -// 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 expectedProductionPathMetadata( - start: Start, - sequence: ReadonlyArray, - earlier: EarlierWrite | undefined, -): unknown { - return earlier?.key === 1 - ? foldMetadata(expectedMetadata(start, [asWrite(earlier)]), sequence) - : expectedMetadata(start, sequence) -} - -// The observed metadata must equal the model's value, compared as JSON. A -// mismatch is recorded with its history label instead of failing at once. -function compareObservedMetadata( - mismatches: Array, - label: string, - actual: unknown, - expected: unknown, -): void { - if (JSON.stringify(actual) !== JSON.stringify(expected)) - mismatches.push( - `${label} read ${JSON.stringify(actual)}, expected ${JSON.stringify(expected)}`, - ) -} - -// 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, - expectedProductionPathMetadata, - expectedNestedMetadata, - ], - grammar: [histories, rebuiltHistories, nestedHistories], -}) async function observeMetadata( productionPath: ProductionPath, @@ -486,49 +197,6 @@ async function observeMetadata( } } -/** - * The held-window model folds collection metadata separately. An explicit - * `set` names the cursor's value and an `unset` deletes it. Row writes and - * truncates leave it alone, because collection metadata is not row state. - * T2 always writes the `later` key, so a correct `list()` contains it. - */ -const cursorKey = `cursor` -const laterKey = `later` -const laterMetadata = { m: `later` } -type HeldOpen = `empty` | `truncated` -type HeldReads = { - row: unknown - cursor: unknown - list: Array<{ key: string; value: unknown }> -} - -function expectedCursor(start: Start, sequence: ReadonlyArray) { - let current: unknown = - start === `present-with-metadata` ? startMetadata : undefined - sequence.forEach((write, step) => { - if (write.kind === `set`) current = writeMetadata(step) - else if (write.kind === `unset`) current = undefined - }) - return current -} - -function expectedHeldReads( - start: Start, - sequence: ReadonlyArray, - open: HeldOpen | `applied`, -): HeldReads { - const cursor = expectedCursor(start, sequence) - return { - // An open truncate hides every older row metadata value. - row: open === `truncated` ? undefined : expectedMetadata(start, sequence), - cursor, - list: [ - ...(cursor === undefined ? [] : [{ key: cursorKey, value: cursor }]), - { key: laterKey, value: laterMetadata }, - ], - } -} - const readHeld = (sync: SyncActions): HeldReads => ({ row: sync.metadata!.row.get(1), cursor: sync.metadata!.collection.get(cursorKey), @@ -750,256 +418,170 @@ 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( - ({ start, sequence }) => - `${start}: ${sequence.map(describeWrite).join(`, `)}`, - ), +// Each production path's driver runs the whole bounded grammar and compares +// every observation with the model, recording mismatches by history label. +async function runNestedPathCase(path: `immediate` | `held`): Promise { + const mismatches: Array = [] + for (const [index, history] of nestedHistories.entries()) { + const label = describeNested(history) + let actual: unknown + try { + actual = await observeNested( + history, + `row-metadata-composition-nested-${path}-${index}`, + path === `held`, + ) + } catch (error) { + mismatches.push(`${label} threw ${String(error)}`) + continue + } + compareObservedMetadata( + mismatches, + label, + actual, + expectedNestedMetadata(history), ) - expect(labels.size).toBe(histories.length) - // Named witnesses for each write rule must remain in the grammar. - for (const witness of [ - `present-with-metadata: delete+metadata`, - `absent: set, insert`, - `absent: insert+metadata, set`, - `present-with-metadata: update`, - `present-without-metadata: delete, set`, - `present-with-metadata: reinsert`, - `absent: insert+metadata, reinsert`, - ]) - expect(labels).toContain(witness) - expect(labels).not.toContain(`absent: update`) - expect(labels).not.toContain(`absent: reinsert`) - expect(labels).not.toContain(`present-with-metadata: insert`) - }) - - it(`rejects named wrong answers in the model`, () => { - oracleRoute(`calibration`) - // A delete that carried metadata keeps none. - expect( - expectedMetadata(`present-with-metadata`, [ - { kind: `delete`, metadata: true }, - ]), - ).toBeUndefined() - // A later insert without metadata clears an explicit set. - expect( - expectedMetadata(`absent`, [ - { kind: `set` }, - { kind: `insert`, metadata: false }, - ]), - ).toBeUndefined() - // An equal re-insert without metadata keeps the current value. - expect( - expectedMetadata(`present-with-metadata`, [ - { kind: `reinsert`, metadata: false }, - ]), - ).toEqual(startMetadata) - // An update without metadata keeps the current value. - expect( - expectedMetadata(`present-with-metadata`, [ - { kind: `update`, metadata: false }, - ]), - ).toEqual(startMetadata) - }) + } + expect(mismatches).toEqual([]) +} - 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( - rebuiltHistories.some( - ({ earlier, sequence }) => - earlier.key === 1 && - earlier.kind === `delete` && - sequence[0]?.kind === `insert`, - ), - ).toBe(true) - }) +async function runHeldWindowCase(open: HeldOpen): Promise { + const mismatches: Array = [] + for (const [index, { start, sequence }] of histories.entries()) { + const label = `${start}: ${sequence.map(describeWrite).join(`, `)}` + let actual: Awaited> + try { + actual = await observeHeldReads( + start, + sequence, + open, + `row-metadata-composition-held-window-${open}-${index}`, + ) + } catch (error) { + mismatches.push(`${label} threw ${String(error)}`) + continue + } + for (const checkpoint of [`committed`, `open`, `applied`] as const) { + const expected = expectedHeldReads( + start, + sequence, + checkpoint === `open` ? open : `applied`, + ) + if (JSON.stringify(actual[checkpoint]) !== JSON.stringify(expected)) + mismatches.push( + `${label} at ${checkpoint} read ${JSON.stringify(actual[checkpoint])}, expected ${JSON.stringify(expected)}`, + ) + } + } + expect(mismatches).toEqual([]) +} - 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) - expect(labels.size).toBe(nestedHistories.length) - // Each variant must keep the writes that reclassify or reorder positions. - for (const witness of [ - `present-with-metadata, nested delete: set, insert`, - `absent, nested insert: set, insert`, - `absent, nested seed: set, insert`, - `absent, nested insert+set: insert+metadata`, - `present-without-metadata, nested delete: insert, set, delete`, - `absent, nested insert: set, truncate, insert`, - `absent, nested seed: insert, update, set`, - ]) - expect(labels).toContain(witness) - // An update needs the row present where T1 writes it and where it applies. - expect(labels).not.toContain(`absent, nested insert: update`) - expect(labels).not.toContain(`present-with-metadata, nested delete: update`) - // A set, then an insert that is now an insert: the insert clears it. - expect( - expectedNestedMetadata({ - start: `present-with-metadata`, - nested: `delete`, - nestedSet: false, - sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], - }), - ).toBeUndefined() - // A set, then an insert that is now an equal re-insert: the set holds. - expect( - expectedNestedMetadata({ - start: `absent`, - nested: `insert`, - nestedSet: false, - sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], - }), - ).toEqual(writeMetadata(0)) - // T1's insert replaces the seeded row, so it clears T1's earlier set. - expect( - expectedNestedMetadata({ - start: `absent`, - nested: `seed`, - nestedSet: false, - sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], - }), - ).toBeUndefined() - // T1 applies after the nested set, so T1's later write wins. - expect( - expectedNestedMetadata({ - start: `absent`, - nested: `insert`, - nestedSet: true, - sequence: [{ kind: `insert`, metadata: true }], - }), - ).toEqual(writeMetadata(0)) - }) +async function runProductionPathCase( + productionPath: ProductionPath, +): Promise { + const cases = + productionPath === `rebuilt` + ? rebuiltHistories + : histories.map((history) => ({ ...history, earlier: undefined })) + const mismatches: Array = [] + for (const [index, { start, sequence, earlier }] of cases.entries()) { + const label = `${start}${earlier ? ` after ${earlier.kind} ${earlier.key}` : ``}: ${sequence.map(describeWrite).join(`, `)}` + let actual: unknown + try { + actual = await observeMetadata( + productionPath, + start, + sequence, + `row-metadata-composition-${productionPath}-${index}`, + earlier, + ) + } catch (error) { + mismatches.push(`${label} threw ${String(error)}`) + continue + } + compareObservedMetadata( + mismatches, + label, + actual, + expectedProductionPathMetadata(start, sequence, earlier), + ) + } + expect(mismatches).toEqual([]) +} - it.each([`immediate`, `held`] as const)( - `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) - let actual: unknown - try { - actual = await observeNested( - history, - `row-metadata-composition-nested-${path}-${index}`, - path === `held`, - ) - } catch (error) { - mismatches.push(`${label} threw ${String(error)}`) - continue - } - compareObservedMetadata( - mismatches, - label, - actual, - expectedNestedMetadata(history), - ) - } - expect(mismatches).toEqual([]) +describe(`row metadata composition oracle`, () => { + oracleProperty({ + id: `row-metadata-composition.nested`, + law: { + statement: `After a nested transaction or hydration seed, the open transaction's row metadata follows last-write-wins as the model judges the reclassified sequence.`, + authority: `The maintainer decision of 2026-10-05 and the glossary's accepted sync transaction, as stated above`, + limits: `The 1925 nested histories, one key, on the immediate and held paths.`, }, - ) - - it(`rejects named wrong answers in the held-window model`, () => { - oracleRoute(`calibration`) - // Truncate keeps collection metadata, and an earlier set survives T2. - expect( - expectedHeldReads( - `present-with-metadata`, - [{ kind: `set` }, { kind: `truncate` }], - `empty`, + // 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, across the + // immediate, held, and rebuilt production paths. + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + histories: [`immediate`, `held`] as const, + label: (path) => + `matches last-write-wins metadata after a nested write (${path})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`row metadata nested write`, plan, observe, () => + runNestedPathCase(plan.history), ), - ).toEqual({ - row: undefined, - cursor: writeMetadata(0), - list: [ - { key: cursorKey, value: writeMetadata(0) }, - { key: laterKey, value: laterMetadata }, - ], - }) - // An open truncate hides row metadata, not the cursor. - expect( - expectedHeldReads(`absent`, [{ kind: `set` }], `truncated`), - ).toMatchObject({ row: undefined, cursor: writeMetadata(0) }) + check: forwardAssertionJudgment, + timeout: 120_000, }) - it.each([`empty`, `truncated`] as const)( - `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(`, `)}` - let actual: Awaited> - try { - actual = await observeHeldReads( - start, - sequence, - open, - `row-metadata-composition-held-window-${open}-${index}`, - ) - } catch (error) { - mismatches.push(`${label} threw ${String(error)}`) - continue - } - for (const checkpoint of [`committed`, `open`, `applied`] as const) { - const expected = expectedHeldReads( - start, - sequence, - checkpoint === `open` ? open : `applied`, - ) - if (JSON.stringify(actual[checkpoint]) !== JSON.stringify(expected)) - mismatches.push( - `${label} at ${checkpoint} read ${JSON.stringify(actual[checkpoint])}, expected ${JSON.stringify(expected)}`, - ) - } - } - expect(mismatches).toEqual([]) + oracleProperty({ + id: `row-metadata-composition.held-window`, + law: { + statement: `A sync-side metadata read returns the value that the accepted writes and the open transaction determine, whether or not a persisting optimistic transaction holds them.`, + authority: `The maintainer decision of 2026-10-05 and the glossary's accepted sync transaction, as stated above`, + limits: `The 1457 one-key histories, read at the committed, open, and applied checkpoints, with an open transaction that is empty or truncates; no read between writes of one open transaction.`, }, - ) + // 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, across the + // immediate, held, and rebuilt production paths. + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + histories: [`empty`, `truncated`] as const, + label: (open) => + `reads accepted metadata during a hold, then in an open transaction that is ${open}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`row metadata held window`, plan, observe, () => + runHeldWindowCase(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 120_000, + }) - it.each([`immediate`, `held`, `rebuilt`] as const)( - `matches last-write-wins metadata on the %s path`, - { timeout: 120_000 }, - async (productionPath) => { - oracleRoute(`case`) - const cases = - productionPath === `rebuilt` - ? rebuiltHistories - : histories.map((history) => ({ ...history, earlier: undefined })) - const mismatches: Array = [] - for (const [index, { start, sequence, earlier }] of cases.entries()) { - const label = `${start}${earlier ? ` after ${earlier.kind} ${earlier.key}` : ``}: ${sequence.map(describeWrite).join(`, `)}` - let actual: unknown - try { - actual = await observeMetadata( - productionPath, - start, - sequence, - `row-metadata-composition-${productionPath}-${index}`, - earlier, - ) - } catch (error) { - mismatches.push(`${label} threw ${String(error)}`) - continue - } - compareObservedMetadata( - mismatches, - label, - actual, - expectedProductionPathMetadata(start, sequence, earlier), - ) - } - expect(mismatches).toEqual([]) + oracleProperty({ + id: `row-metadata-composition.production-path`, + law: { + statement: `One sync transaction's row metadata follows last-write-wins, with the maintainer's rules for writes that carry no metadata, on every production path.`, + authority: `The maintainer decision of 2026-10-05 and the glossary's accepted sync transaction, as stated above`, + limits: `The 1457 one-key histories on the immediate and held paths and the 1767 rebuilt histories; no publication batches or other keys.`, }, - ) + // 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, across the + // immediate, held, and rebuilt production paths. + owns: [`src/collection/sync.ts`, `src/collection/state.ts`], + histories: [`immediate`, `held`, `rebuilt`] as const, + label: (productionPath) => + `matches last-write-wins metadata on the ${productionPath} path`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `row metadata production path`, + plan, + observe, + () => runProductionPathCase(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 120_000, + }) }) diff --git a/packages/db/tests/collection-row-metadata-composition.test.ts b/packages/db/tests/collection-row-metadata-composition.test.ts new file mode 100644 index 0000000000..b7354046de --- /dev/null +++ b/packages/db/tests/collection-row-metadata-composition.test.ts @@ -0,0 +1,169 @@ +/** + * Ordinary grammar and model tests for the row metadata composition oracle. + * They check the history grammar's size and named witnesses, and named wrong + * answers that the model must not give. The law and its production + * comparison live in collection-row-metadata-composition-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { + cursorKey, + describeNested, + describeWrite, + expectedHeldReads, + expectedMetadata, + expectedNestedMetadata, + histories, + laterKey, + laterMetadata, + nestedHistories, + rebuiltHistories, + startMetadata, + writeMetadata, +} from './collection-row-metadata-composition-model-oracle.js' + +describe(`row metadata composition oracle`, () => { + it(`enumerates every legal one-key history of up to three writes`, () => { + expect(histories).toHaveLength(1457) + const labels = new Set( + histories.map( + ({ start, sequence }) => + `${start}: ${sequence.map(describeWrite).join(`, `)}`, + ), + ) + expect(labels.size).toBe(histories.length) + // Named witnesses for each write rule must remain in the grammar. + for (const witness of [ + `present-with-metadata: delete+metadata`, + `absent: set, insert`, + `absent: insert+metadata, set`, + `present-with-metadata: update`, + `present-without-metadata: delete, set`, + `present-with-metadata: reinsert`, + `absent: insert+metadata, reinsert`, + ]) + expect(labels).toContain(witness) + expect(labels).not.toContain(`absent: update`) + expect(labels).not.toContain(`absent: reinsert`) + expect(labels).not.toContain(`present-with-metadata: insert`) + }) + + it(`rejects named wrong answers in the model`, () => { + // A delete that carried metadata keeps none. + expect( + expectedMetadata(`present-with-metadata`, [ + { kind: `delete`, metadata: true }, + ]), + ).toBeUndefined() + // A later insert without metadata clears an explicit set. + expect( + expectedMetadata(`absent`, [ + { kind: `set` }, + { kind: `insert`, metadata: false }, + ]), + ).toBeUndefined() + // An equal re-insert without metadata keeps the current value. + expect( + expectedMetadata(`present-with-metadata`, [ + { kind: `reinsert`, metadata: false }, + ]), + ).toEqual(startMetadata) + // An update without metadata keeps the current value. + expect( + expectedMetadata(`present-with-metadata`, [ + { kind: `update`, metadata: false }, + ]), + ).toEqual(startMetadata) + }) + + it(`enumerates rebuilt histories against every earlier write`, () => { + // 1457 after a key-2 update, plus 310 after a key-1 write. + expect(rebuiltHistories).toHaveLength(1767) + expect( + rebuiltHistories.some( + ({ earlier, sequence }) => + earlier.key === 1 && + earlier.kind === `delete` && + sequence[0]?.kind === `insert`, + ), + ).toBe(true) + }) + + it(`enumerates nested histories for every nested write`, () => { + const labels = new Set(nestedHistories.map(describeNested)) + // 275 sequences for each of the seven nested variants. + expect(nestedHistories).toHaveLength(1925) + expect(labels.size).toBe(nestedHistories.length) + // Each variant must keep the writes that reclassify or reorder positions. + for (const witness of [ + `present-with-metadata, nested delete: set, insert`, + `absent, nested insert: set, insert`, + `absent, nested seed: set, insert`, + `absent, nested insert+set: insert+metadata`, + `present-without-metadata, nested delete: insert, set, delete`, + `absent, nested insert: set, truncate, insert`, + `absent, nested seed: insert, update, set`, + ]) + expect(labels).toContain(witness) + // An update needs the row present where T1 writes it and where it applies. + expect(labels).not.toContain(`absent, nested insert: update`) + expect(labels).not.toContain(`present-with-metadata, nested delete: update`) + // A set, then an insert that is now an insert: the insert clears it. + expect( + expectedNestedMetadata({ + start: `present-with-metadata`, + nested: `delete`, + nestedSet: false, + sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], + }), + ).toBeUndefined() + // A set, then an insert that is now an equal re-insert: the set holds. + expect( + expectedNestedMetadata({ + start: `absent`, + nested: `insert`, + nestedSet: false, + sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], + }), + ).toEqual(writeMetadata(0)) + // T1's insert replaces the seeded row, so it clears T1's earlier set. + expect( + expectedNestedMetadata({ + start: `absent`, + nested: `seed`, + nestedSet: false, + sequence: [{ kind: `set` }, { kind: `insert`, metadata: false }], + }), + ).toBeUndefined() + // T1 applies after the nested set, so T1's later write wins. + expect( + expectedNestedMetadata({ + start: `absent`, + nested: `insert`, + nestedSet: true, + sequence: [{ kind: `insert`, metadata: true }], + }), + ).toEqual(writeMetadata(0)) + }) + + it(`rejects named wrong answers in the held-window model`, () => { + // Truncate keeps collection metadata, and an earlier set survives T2. + expect( + expectedHeldReads( + `present-with-metadata`, + [{ kind: `set` }, { kind: `truncate` }], + `empty`, + ), + ).toEqual({ + row: undefined, + cursor: writeMetadata(0), + list: [ + { key: cursorKey, value: writeMetadata(0) }, + { key: laterKey, value: laterMetadata }, + ], + }) + // An open truncate hides row metadata, not the cursor. + expect( + expectedHeldReads(`absent`, [{ kind: `set` }], `truncated`), + ).toMatchObject({ row: undefined, cursor: writeMetadata(0) }) + }) +}) 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 9b5ad3efc6..c70f4d6af6 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -7,12 +7,12 @@ import { oracleReplayRequested, oracleRoute, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import { historyRow, @@ -252,10 +252,6 @@ function expectRetainedState( ) } -declareOracle({ - check: [expectRetainedState], -}) - async function runRetentionHistory( actions: ReadonlyArray, enter: (state: RetentionState) => void = () => {}, @@ -446,56 +442,106 @@ async function runRetentionHistory( if (failed) throw primaryFailure } -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 } }, - { type: `delete`, key: 1 }, - { type: `update`, row: { id: 1, value: -1 } }, - { type: `replace`, rows: [{ id: 3, value: 0 }] }, - { type: `delete`, key: 3 }, - ]) -}) - -it(`retains a missing row introduced by a sync update`, async () => { - oracleRoute(`fixed history`) - await runRetentionHistory([{ type: `update`, row: { id: 1, value: 1 } }]) -}) - -it.each( - ([`insert`, `update`] as const).flatMap((triggerType) => - ([`insideListener`, `afterOldReturn`] as const).map( - (commitPhase) => [triggerType, commitPhase] as const, - ), - ), -)( - `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) - : []), - { - type: `reentrantRestart`, - row: { id: 1, value: 1 }, - commitPhase, - }, - ]) - }, -) - -it(`releases retained keys after long unique-key churn`, async () => { - oracleRoute(`fixed history`) +// The long churn history: a thousand unique keys, each inserted and deleted. +function uniqueKeyChurn(): Array { const keyCount = 1_000 const actions: Array = [] for (let key = 0; key < keyCount; key++) { actions.push({ type: `insert`, row: { id: key, value: key } }) actions.push({ type: `delete`, key }) } + return actions +} +// Named regressions through the generated retention property's driver. The +// generated grammar maps its elements, so it cannot take these histories back. +const retentionFixedHistories: Array<{ + name: string + actions: ReadonlyArray +}> = [ + { + name: `retains only keys in the authoritative synced state`, + actions: [ + { type: `insert`, row: { id: 1, value: 1 } }, + { type: `insert`, row: { id: 2, value: 2 } }, + { type: `delete`, key: 1 }, + { type: `update`, row: { id: 1, value: -1 } }, + { type: `replace`, rows: [{ id: 3, value: 0 }] }, + { type: `delete`, key: 3 }, + ], + }, + { + name: `retains a missing row introduced by a sync update`, + actions: [{ type: `update`, row: { id: 1, value: 1 } }], + }, + { + name: `releases retained keys after long unique-key churn`, + actions: uniqueKeyChurn(), + }, +] +oracleProperty({ + id: `collection-state.retention-fixed`, + law: { + statement: `Retained Collection state follows the independent source Map across source changes and sync-run cleanup and restart.`, + authority: `The Collection sync and cleanup contracts, including the signed-zero reinsertion decision stated above`, + limits: `Named regressions that the generated retention grammar cannot produce; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: retentionFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.retention-fixed`, + plan, + observe, + () => runRetentionHistory(plan.history.actions), + ), + check: forwardAssertionJudgment, +}) - await runRetentionHistory(actions) +const reentrantRestartCases = ([`insert`, `update`] as const).flatMap( + (triggerType) => + ([`insideListener`, `afterOldReturn`] as const).map( + (commitPhase) => [triggerType, commitPhase] as const, + ), +) +async function runReentrantRestartCase([ + triggerType, + commitPhase, +]: (typeof reentrantRestartCases)[number]): Promise { + await runRetentionHistory([ + ...(triggerType === `update` + ? ([{ type: `insert`, row: { id: 1, value: 1 } }] as const) + : []), + { + type: `reentrantRestart`, + row: { id: 1, value: 1 }, + commitPhase, + }, + ]) +} +oracleProperty({ + id: `collection-state.retention-reentrant-restart`, + law: { + statement: `Retained Collection state follows the independent source Map when a listener restarts the sync run inside an old-sync-run commit.`, + authority: `The Collection sync and cleanup contracts, including the signed-zero reinsertion decision stated above`, + limits: `An insert or update trigger, with the restarted row committed inside the listener or after the old commit returns.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: reentrantRestartCases, + label: ([triggerType, commitPhase]) => + `retains an old-sync-run ${triggerType} and a restarted row committed ${commitPhase}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.retention-reentrant-restart`, + plan, + observe, + () => runReentrantRestartCase(plan.history), + ), + check: forwardAssertionJudgment, }) // One element in ten writes a zero to a key, then inserts the other zero: @@ -594,108 +640,156 @@ const partialHistory = fc // 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) => - [false, true].flatMap((replacementHasKey) => - [false, true].map((reinsert) => ({ - acceptBeforeTruncate, - replacementHasKey, - reinsert, - })), - ), +const directDeletionDropCases = [false, true].flatMap((acceptBeforeTruncate) => + [false, true].flatMap((replacementHasKey) => + [false, true].map((reinsert) => ({ + acceptBeforeTruncate, + replacementHasKey, + reinsert, + })), ), -)( - `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 }, - ...(acceptBeforeTruncate - ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] - : []), - ...(reinsert - ? [ - { - type: `edit`, - key: 1, - fields: { a: 4 }, - optimistic: true, - } as const, - ] - : []), - { - type: `sync`, - rows: replacementHasKey ? [row] : [], - truncate: true, - copies: 1, - }, - ...(!acceptBeforeTruncate - ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] - : []), - ...(reinsert - ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] - : []), - { - type: `sync`, - rows: [{ id: 2, a: 2, b: 2, c: 2 }], - truncate: false, - copies: 1, - }, - ] - const counts = await runOptimisticHistory([row], steps) - expect(counts.deletes).toBe(1) - expect(counts.settlements).toBe(reinsert ? 2 : 1) - }, ) +async function runDirectDeletionDropCase({ + acceptBeforeTruncate, + replacementHasKey, + reinsert, +}: (typeof directDeletionDropCases)[number]): Promise { + const row = { id: 1, a: 1, b: 2, c: 3 } + const steps: Array = [ + { type: `delete`, key: 1, optimistic: true }, + ...(acceptBeforeTruncate + ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] + : []), + ...(reinsert + ? [ + { + type: `edit`, + key: 1, + fields: { a: 4 }, + optimistic: true, + } as const, + ] + : []), + { + type: `sync`, + rows: replacementHasKey ? [row] : [], + truncate: true, + copies: 1, + }, + ...(!acceptBeforeTruncate + ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] + : []), + ...(reinsert + ? [{ type: `settle`, slot: 0, success: true, cascade: false } as const] + : []), + { + type: `sync`, + rows: [{ id: 2, a: 2, b: 2, c: 2 }], + truncate: false, + copies: 1, + }, + ] + const counts = await runOptimisticHistory([row], steps) + expect(counts.deletes).toBe(1) + expect(counts.settlements).toBe(reinsert ? 2 : 1) +} +oracleProperty({ + id: `collection-state.direct-deletion-drop`, + law: { + statement: `A direct deletion's optimistic state drops at settlement across a source replacement and a later sync, as the history model predicts.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `The eight orderings of acceptance before or after a truncate, a replacement with or without the key, and an optional re-insert.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: directDeletionDropCases, + label: (row) => + `drops a direct deletion at settlement across replacement and later sync: ${JSON.stringify(row)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.direct-deletion-drop`, + plan, + observe, + () => runDirectDeletionDropCase(plan.history), + ), + check: forwardAssertionJudgment, +}) // 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. -it.each( - [false, true].flatMap((optimistic) => - [false, true].map((commit) => ({ optimistic, commit })), - ), -)( - `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 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic }, - { - type: `open`, - batch: { - type: `sync`, - rows: [{ id: 1, a: 2, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `close`, commit }, - { +const openSyncAttributionCases = [false, true].flatMap((optimistic) => + [false, true].map((commit) => ({ optimistic, commit })), +) +async function runOpenSyncAttributionCase({ + optimistic, + commit, +}: (typeof openSyncAttributionCases)[number]): Promise { + const counts = await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 0 }], + [ + { type: `edit`, key: 1, fields: { a: 1 }, optimistic }, + { + type: `open`, + batch: { type: `sync`, - rows: [{ id: 1, a: 3, b: 0, c: 0 }], + rows: [{ id: 1, a: 2, b: 0, c: 0 }], truncate: false, copies: 1, }, - ], - ) - expect(counts.openBatches).toBe(1) - expect(counts.abortedBatches).toBe(Number(!commit)) + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `close`, commit }, + { + type: `sync`, + rows: [{ id: 1, a: 3, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + ], + ) + expect(counts.openBatches).toBe(1) + expect(counts.abortedBatches).toBe(Number(!commit)) +} +oracleProperty({ + id: `collection-state.open-sync-attribution`, + law: { + statement: `A sync transaction still open when a mutation settles is not accepted, holds nothing, and attributes nothing; a later remote write is remote.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `Fixed review-probe histories: an optimistic or non-optimistic edit with the open transaction committed or aborted.`, }, -) -it(`keeps an unrelated completed key remote while another key's sync is held`, async () => { - oracleRoute(`fixed history`) - await runOptimisticHistory( - [ + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: openSyncAttributionCases, + label: (row) => + `attributes nothing to a sync transaction open at settlement: ${JSON.stringify(row)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.open-sync-attribution`, + plan, + observe, + () => runOpenSyncAttributionCase(plan.history), + ), + check: forwardAssertionJudgment, +}) +// Named regressions through the generated optimistic-history property's +// driver. The generated grammar maps its elements, so it cannot take these +// histories back. +const optimisticFixedHistories: Array<{ + name: string + initial: Array + steps: Array +}> = [ + { + name: `keeps an unrelated completed key remote while another key's sync is held`, + initial: [ { id: 1, a: 0, b: 0, c: 0 }, { id: 2, a: 0, b: 0, c: 0 }, ], - [ + steps: [ { type: `edit`, key: 2, fields: { a: 2 }, optimistic: true }, { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { @@ -713,16 +807,14 @@ it(`keeps an unrelated completed key remote while another key's sync is held`, a copies: 1, }, ], - ) -}) -// 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( - [], - [ + }, + // 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. + { + name: `publishes a rollback while a sync transaction on its key is open`, + initial: [], + steps: [ { type: `open`, batch: { @@ -748,13 +840,11 @@ it(`publishes a rollback while a sync transaction on its key is open`, async () { type: `settle`, slot: 0, success: false, cascade: false }, { type: `sync`, rows: [], deletes: [1], truncate: false, copies: 1 }, ], - ) -}) -it(`shows the newer of two held completed rows on one key`, async () => { - oracleRoute(`fixed history`) - await runOptimisticHistory( - [], - [ + }, + { + name: `shows the newer of two held completed rows on one key`, + initial: [], + steps: [ { type: `edit`, key: 1, @@ -792,13 +882,44 @@ it(`shows the newer of two held completed rows on one key`, async () => { { type: `settle`, slot: 0, success: true, cascade: false }, { type: `delete`, key: 1, optimistic: false }, ], - ) + }, + { + name: `publishes prior optimistic ownership when rollback reveals an identical authoritative row`, + initial: [{ id: 3, a: 0, b: 0, c: 0 }], + steps: [ + { type: `delete`, key: 3, optimistic: true }, + { type: `edit`, key: 3, fields: { c: 0 }, optimistic: true }, + { type: `sync`, rows: [], truncate: false, copies: 1 }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `settle`, slot: 0, success: false, cascade: false }, + ], + }, +] +oracleProperty({ + id: `collection-state.optimistic-fixed`, + law: { + statement: `Optimistic ownership, row publication and request settlement follow the independent history model.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `Named regressions that the generated optimistic-history grammar cannot produce; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: optimisticFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.optimistic-fixed`, + plan, + observe, + () => runOptimisticHistory(plan.history.initial, plan.history.steps), + ), + check: forwardAssertionJudgment, }) // 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`) +async function runSourceReinsertAttributionHistory(): Promise { const row = { id: 2, a: 0, b: 0, c: 0 } const counts = await runOptimisticHistory( [], @@ -836,6 +957,32 @@ it(`attributes a source reinsert after a confirmed delete to the source`, async ], ) expect(counts.sourceDeletes).toBe(1) +} +const sourceReinsertAttributionHistorys = [ + { + name: `attributes a source reinsert after a confirmed delete to the source`, + }, +] +oracleProperty({ + id: `collection-state.source-reinsert-attribution`, + law: { + statement: `A confirmed delete clears the key's local attribution, so a later source reinsert is remote.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `One fixed regression of a random-campaign counterexample.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: sourceReinsertAttributionHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.source-reinsert-attribution`, + plan, + observe, + () => runSourceReinsertAttributionHistory(), + ), + check: forwardAssertionJudgment, }) it(`writes source inserts and deletes in the fixed campaign`, async () => { @@ -901,35 +1048,58 @@ function absentKeyDelete(truncate = false) { // Committed while the insert persists, or inside its handler, the delete is // queued. The settled insert's row is held with it, and the drop and the // delete publish together, so the row is gone after settlement. -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 }], - [ - { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - ...(inHandler ? { inHandler: absentKeyDelete() } : {}), - }, - ...(inHandler ? [] : [absentKeyDelete()]), - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) - expect(counts.absentSourceDeletes).toBe(1) - expect(counts.queued).toBe(1) - expect(counts.handlerBatches).toBe(inHandler ? 1 : 0) +const absentKeyDeleteDropCases = [ + `while persisting`, + `inside the handler`, +] as const +async function runAbsentKeyDeleteDropCase( + delivery: (typeof absentKeyDeleteDropCases)[number], +): Promise { + const inHandler = delivery === `inside the handler` + const counts = await runOptimisticHistory( + [{ id: 2, a: 0, b: 0, c: 0 }], + [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic: true, + ...(inHandler ? { inHandler: absentKeyDelete() } : {}), + }, + ...(inHandler ? [] : [absentKeyDelete()]), + { type: `settle`, slot: 0, success: true, cascade: false }, + ], + ) + expect(counts.absentSourceDeletes).toBe(1) + expect(counts.queued).toBe(1) + expect(counts.handlerBatches).toBe(inHandler ? 1 : 0) +} +oracleProperty({ + id: `collection-state.absent-key-delete-drop`, + law: { + statement: `A queued source delete of a never-held key publishes with the settled insert's drop, so the row is gone after settlement.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `The delete committed while the insert persists or inside its handler.`, }, -) + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: absentKeyDeleteDropCases, + label: (delivery) => + `removes a settled insert in its drop publication when the source deletes a key it never held ${String(delivery)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.absent-key-delete-drop`, + plan, + observe, + () => runAbsentKeyDeleteDropCase(plan.history), + ), + check: forwardAssertionJudgment, +}) // 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`) +async function runAbsentKeyDeleteAfterDropHistory(): Promise { const counts = await runOptimisticHistory( [{ id: 2, a: 0, b: 0, c: 0 }], [ @@ -940,73 +1110,140 @@ it(`leaves the visible rows unchanged when the source deletes a never-held key a ) expect(counts.absentSourceDeletes).toBe(1) expect(counts.queued).toBe(0) +} +const absentKeyDeleteAfterDropHistorys = [ + { + name: `leaves the visible rows unchanged when the source deletes a never-held key after the drop`, + }, +] +oracleProperty({ + id: `collection-state.absent-key-delete-after-drop`, + law: { + statement: `Once the optimistic state has dropped, a source delete of a never-held key changes nothing a reader can see.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `One fixed history.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: absentKeyDeleteAfterDropHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.absent-key-delete-after-drop`, + plan, + observe, + () => runAbsentKeyDeleteAfterDropHistory(), + ), + check: forwardAssertionJudgment, }) // The nearby boundary: a persisting request keeps its optimistic row. A // queued delete waits for settlement. A truncate that carries the delete // applies at once, and the persisting insert overlays the replacement. -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 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, - absentKeyDelete(truncate), - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) - expect(counts.absentSourceDeletes).toBe(1) - expect(truncate ? counts.snapshotOverrides : counts.queued).toBe(1) +const absentKeyDeletePersistingCases = [false, true] +async function runAbsentKeyDeletePersistingCase( + truncate: (typeof absentKeyDeletePersistingCases)[number], +): Promise { + const counts = await runOptimisticHistory( + [{ id: 2, a: 0, b: 0, c: 0 }], + [ + { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, + absentKeyDelete(truncate), + { type: `settle`, slot: 0, success: true, cascade: false }, + ], + ) + expect(counts.absentSourceDeletes).toBe(1) + expect(truncate ? counts.snapshotOverrides : counts.queued).toBe(1) +} +oracleProperty({ + id: `collection-state.absent-key-delete-persisting`, + law: { + statement: `A persisting insert keeps its optimistic row when the source deletes a key it never held: a queued delete waits for settlement, and a truncate that carries the delete applies at once under the overlay.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `The delete as a queued batch or carried by a truncate.`, }, -) + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: absentKeyDeletePersistingCases, + label: (truncate) => + `keeps a persisting insert's optimistic row when the source deletes a key it never held, truncate=${String(truncate)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.absent-key-delete-persisting`, + plan, + observe, + () => runAbsentKeyDeletePersistingCase(plan.history), + ), + check: forwardAssertionJudgment, +}) // A handler that confirms its own request through sync, before it returns, // optionally awaiting the commit receipt. The batch waits for settlement and // publishes together with the drop of the request's optimistic state. -it.each( - [`insert`, `update`, `delete`].flatMap((kind) => - [false, true].map((awaitReceipt) => ({ kind, awaitReceipt })), - ), -)( - `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 = { - type: `sync`, - rows: kind === `delete` ? [] : [confirmed], - truncate: false, - copies: 1, - awaitReceipt, - } as const - const counts = await runOptimisticHistory( - kind === `insert` ? [] : [existing], - [ - kind === `delete` - ? { type: `delete`, key: 1, optimistic: true, inHandler: batch } - : { - type: `edit`, - key: 1, - fields: { a: 1, b: 1, c: 1 }, - optimistic: true, - inHandler: batch, - }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { - type: `sync`, - rows: [{ id: 1, a: 2, b: 2, c: 2 }], - truncate: false, - copies: 1, - }, - ], - ) - expect(counts.handlerBatches).toBe(1) - expect(counts.awaitedReceipts).toBe(Number(awaitReceipt)) - }, +const handlerConfirmationCases = [`insert`, `update`, `delete`].flatMap( + (kind) => [false, true].map((awaitReceipt) => ({ kind, awaitReceipt })), ) +async function runHandlerConfirmationCase({ + kind, + awaitReceipt, +}: (typeof handlerConfirmationCases)[number]): Promise { + const existing = { id: 1, a: 0, b: 0, c: 0 } + const confirmed = { id: 1, a: 1, b: 1, c: 1 } + const batch = { + type: `sync`, + rows: kind === `delete` ? [] : [confirmed], + truncate: false, + copies: 1, + awaitReceipt, + } as const + const counts = await runOptimisticHistory( + kind === `insert` ? [] : [existing], + [ + kind === `delete` + ? { type: `delete`, key: 1, optimistic: true, inHandler: batch } + : { + type: `edit`, + key: 1, + fields: { a: 1, b: 1, c: 1 }, + optimistic: true, + inHandler: batch, + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { + type: `sync`, + rows: [{ id: 1, a: 2, b: 2, c: 2 }], + truncate: false, + copies: 1, + }, + ], + ) + expect(counts.handlerBatches).toBe(1) + expect(counts.awaitedReceipts).toBe(Number(awaitReceipt)) +} +oracleProperty({ + id: `collection-state.handler-confirmation`, + law: { + statement: `A request its own handler confirms through sync publishes with the drop of its optimistic state at settlement.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `Insert, update, and delete requests, with or without awaiting the commit receipt.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: handlerConfirmationCases, + label: (row) => + `publishes a request confirmed inside its own handler at settlement: ${JSON.stringify(row)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.handler-confirmation`, + plan, + observe, + () => runHandlerConfirmationCase(plan.history), + ), + check: forwardAssertionJudgment, +}) const defaultHistory = ( truncate: boolean, @@ -1018,23 +1255,73 @@ const defaultHistory = ( { type: `sync`, rows: [], truncate, copies: 1 }, { type: `settle`, slot: 0, success, cascade: false }, ] -it.each( - [false, true].flatMap((truncate) => - [false, true].map((success) => ({ truncate, success })), - ), -)( - `keeps validated defaults with authored fields until settlement: %j`, - async ({ truncate, success }) => { - oracleRoute(`case`) - for (const insertDefault of [3, 11]) - await runOptimisticHistory( - [], - defaultHistory(truncate, success), - undefined, - { insertDefault }, - ) - }, +const validatedDefaultsCases = [false, true].flatMap((truncate) => + [false, true].map((success) => ({ truncate, success })), ) +async function runValidatedDefaultsCase({ + truncate, + success, +}: (typeof validatedDefaultsCases)[number]): Promise { + for (const insertDefault of [3, 11]) + await runOptimisticHistory( + [], + defaultHistory(truncate, success), + undefined, + { insertDefault }, + ) +} +oracleProperty({ + id: `collection-state.validated-defaults`, + law: { + statement: `Validated schema defaults stay with the authored fields until settlement, across a sync with or without truncate and an accepted or rejected request.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `Two insert defaults (3 and 11) over the four truncate and settlement outcomes.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: validatedDefaultsCases, + label: (row) => + `keeps validated defaults with authored fields until settlement: ${JSON.stringify(row)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.validated-defaults`, + plan, + observe, + () => runValidatedDefaultsCase(plan.history), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a default lost only after settlement`, + law: `collection-state.validated-defaults`, + // The mutant runs on the history the old calibration used; any other + // history reports no rejection, so the framework moves on to the next. + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.validated-defaults`, + plan, + observe, + async () => { + if (!(plan.history.truncate && plan.history.success)) return + const steps = defaultHistory(true, true) + try { + await runOptimisticHistory([], steps, `retained-default`, { + insertDefault: 3, + }) + } catch (error) { + // Exactly the old matcher: an AssertionError rejects the mutant. + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`calibration retained-default`, error) + throw new Error( + `retained-default was not rejected by an AssertionError: ${String(error)}`, + ) + } + }, + ), + }, + ], +}) // A partial source update merges into the source row, not into an optimistic // row. Here the source holds key 1 with c = 7. An optimistic delete and a // re-insert that took the schema default for `c` both persist when the partial @@ -1056,46 +1343,79 @@ const partialOverPersistingDefault = ( { type: `settle`, slot: 0, success: true, cascade: false }, { type: `settle`, slot: 0, success: true, cascade: false }, ] -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), - undefined, - { insertDefault, partialUpdates: true }, - ) - expect(counts.distinguishingPartialUpdates).toBe(1) - expect(counts.queued).toBe(1) +const partialOverDefaultCases = [3, 11] +async function runPartialOverDefaultCase( + insertDefault: (typeof partialOverDefaultCases)[number], +): Promise { + const counts = await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 7 }], + partialOverPersistingDefault(insertDefault), + undefined, + { insertDefault, partialUpdates: true }, + ) + expect(counts.distinguishingPartialUpdates).toBe(1) + expect(counts.queued).toBe(1) +} +oracleProperty({ + id: `collection-state.partial-over-default`, + law: { + statement: `A held partial source update merges into the source row, not the optimistic row, under a persisting schema default.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `Two insert defaults (3 and 11) with one held partial update.`, }, -) -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, - partialUpdates: true, - }) - await expect( - runOptimisticHistory( - [{ id: 1, a: 0, b: 0, c: 7 }], - steps, - `partial-as-full`, - { insertDefault: 3, partialUpdates: true }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: partialOverDefaultCases, + label: (insertDefault) => + `merges a held partial update into the source row under a persisting schema default (${insertDefault})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.partial-over-default`, + plan, + observe, + () => runPartialOverDefaultCase(plan.history), ), - // A row observation must reject the replaced row, not the mutant checkpoint. - ).rejects.toThrow( - /whole forward publication|: reads .* expected |rows visible when settlement completed/, - ) -}) -it(`rejects a default lost only after settlement`, async () => { - oracleRoute(`calibration`) - const steps = defaultHistory(true, true) - await runOptimisticHistory([], steps, undefined, { insertDefault: 3 }) - await expect( - runOptimisticHistory([], steps, `retained-default`, { insertDefault: 3 }), - ).rejects.toMatchObject({ name: `AssertionError` }) + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a partial update applied as a full replacement`, + law: `collection-state.partial-over-default`, + // The mutant runs on the history the old calibration used; any other + // history reports no rejection, so the framework moves on to the next. + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.partial-over-default`, + plan, + observe, + async () => { + if (plan.history !== 3) return + const steps = partialOverPersistingDefault(3) + try { + await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 7 }], + steps, + `partial-as-full`, + { insertDefault: 3, partialUpdates: true }, + ) + } catch (error) { + // Exactly the old matcher: a row observation must reject the + // replaced row, not the mutant checkpoint. + if ( + error instanceof Error && + /whole forward publication|: reads .* expected |rows visible when settlement completed/.test( + error.message, + ) + ) + rejectAt(`calibration partial-as-full`, error) + throw new Error( + `partial-as-full was not rejected at a row observation: ${String(error)}`, + ) + } + }, + ), + }, + ], }) const insertionPrefix: Array = [ @@ -1196,53 +1516,78 @@ for (const timing of [ }) } -it.each([true, false])( - `replays an insert and its update across settlement, accepted=%s`, - async (success) => { - oracleRoute(`case`) - await runOptimisticHistory( - [], - [ - ...insertionPrefix, - { type: `settle`, slot: 0, success, cascade: 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 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, - { - type: `sync`, - rows: [{ id: 1, a: 0, b: 2, c: 3 }], - truncate, - copies: 1, - }, - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) +const insertUpdateReplayCases = [true, false] +async function runInsertUpdateReplayCase( + success: (typeof insertUpdateReplayCases)[number], +): Promise { + await runOptimisticHistory( + [], + [...insertionPrefix, { type: `settle`, slot: 0, success, cascade: false }], + ) +} +oracleProperty({ + id: `collection-state.insert-update-replay`, + law: { + statement: `An insert and its update replay across settlement, as the history model predicts.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `The insertion prefix with the insert accepted or rejected.`, }, -) -it(`publishes prior optimistic ownership when rollback reveals an identical authoritative row`, async () => { - oracleRoute(`fixed history`) + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: insertUpdateReplayCases, + label: (success) => + `replays an insert and its update across settlement, accepted=${String(success)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.insert-update-replay`, + plan, + observe, + () => runInsertUpdateReplayCase(plan.history), + ), + check: forwardAssertionJudgment, +}) +const syncRowOverlayCases = [true, false] +async function runSyncRowOverlayCase( + truncate: (typeof syncRowOverlayCases)[number], +): Promise { await runOptimisticHistory( - [{ id: 3, a: 0, b: 0, c: 0 }], + [{ id: 1, a: 0, b: 0, c: 0 }], [ - { type: `delete`, key: 3, optimistic: true }, - { type: `edit`, key: 3, fields: { c: 0 }, optimistic: true }, - { type: `sync`, rows: [], truncate: false, copies: 1 }, + { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, + { + type: `sync`, + rows: [{ id: 1, a: 0, b: 2, c: 3 }], + truncate, + copies: 1, + }, { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `settle`, slot: 0, success: false, cascade: false }, ], ) +} +oracleProperty({ + id: `collection-state.sync-row-overlay`, + law: { + statement: `A sync row stays behind an optimistic row until settlement, with or without truncate.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `One key with a sync batch that does or does not truncate.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: syncRowOverlayCases, + label: (truncate) => + `hides a sync row behind an optimistic row until settlement, truncate=${String(truncate)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.sync-row-overlay`, + plan, + observe, + () => runSyncRowOverlayCase(plan.history), + ), + check: forwardAssertionJudgment, }) -it(`publishes the subscriber-visible row when a buffered optimistic update becomes a delete`, async () => { - oracleRoute(`fixed history`) +async function runBufferedUpdateDeleteHistory(): Promise { const counts = await runOptimisticHistory( [], [ @@ -1259,58 +1604,105 @@ it(`publishes the subscriber-visible row when a buffered optimistic update becom failures: 1, queued: 1, }) +} +const bufferedUpdateDeleteHistorys = [ + { + name: `publishes the subscriber-visible row when a buffered optimistic update becomes a delete`, + }, +] +oracleProperty({ + id: `collection-state.buffered-update-delete`, + law: { + statement: `When a buffered optimistic update becomes a delete, the subscriber-visible row is published, as the history model predicts.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `One fixed history.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: bufferedUpdateDeleteHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.buffered-update-delete`, + plan, + observe, + () => runBufferedUpdateDeleteHistory(), + ), + check: forwardAssertionJudgment, }) // Each settled delete or edit drops its optimistic state, whatever order the // requests settle in. A truncate between settlements does not revive them. -it.each( - [false, true].flatMap((truncate) => - [false, true].flatMap((editSettlesFirst) => - [false, true].map((insertAccepted) => ({ - truncate, - editSettlesFirst, - insertAccepted, - })), - ), +const settlementOrdersCases = [false, true].flatMap((truncate) => + [false, true].flatMap((editSettlesFirst) => + [false, true].map((insertAccepted) => ({ + truncate, + editSettlesFirst, + insertAccepted, + })), ), -)( - `drops each settled delete and edit across settlement orders: %j`, - async ({ truncate, editSettlesFirst, insertAccepted }) => { - oracleRoute(`case`) - const replacement: OptimisticStep = { - type: `sync`, - rows: [], - truncate: true, - copies: 1, - } - const acceptedSettlements: Array = editSettlesFirst - ? [ - { type: `settle`, slot: 2, success: true, cascade: false }, - { type: `settle`, slot: 0, success: true, cascade: false }, - ] - : [ - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `settle`, slot: 1, success: true, cascade: false }, - ] - const counts = await runOptimisticHistory( - [{ id: 1, a: 0, b: 0, c: 0 }], - [ - { type: `delete`, key: 1, optimistic: true }, - { type: `edit`, key: 1, fields: { c: 0 }, optimistic: true }, - { type: `edit`, key: 1, fields: { c: 1 }, optimistic: true }, - ...acceptedSettlements, - ...(truncate ? [replacement] : []), - { type: `settle`, slot: 0, success: insertAccepted, cascade: false }, - ], - ) - expect(counts).toMatchObject({ - edits: 3, - deletes: 1, - settlements: 3, - failures: Number(!insertAccepted), - replacements: Number(truncate), - }) - }, ) +async function runSettlementOrdersCase({ + truncate, + editSettlesFirst, + insertAccepted, +}: (typeof settlementOrdersCases)[number]): Promise { + const replacement: OptimisticStep = { + type: `sync`, + rows: [], + truncate: true, + copies: 1, + } + const acceptedSettlements: Array = editSettlesFirst + ? [ + { type: `settle`, slot: 2, success: true, cascade: false }, + { type: `settle`, slot: 0, success: true, cascade: false }, + ] + : [ + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `settle`, slot: 1, success: true, cascade: false }, + ] + const counts = await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 0 }], + [ + { type: `delete`, key: 1, optimistic: true }, + { type: `edit`, key: 1, fields: { c: 0 }, optimistic: true }, + { type: `edit`, key: 1, fields: { c: 1 }, optimistic: true }, + ...acceptedSettlements, + ...(truncate ? [replacement] : []), + { type: `settle`, slot: 0, success: insertAccepted, cascade: false }, + ], + ) + expect(counts).toMatchObject({ + edits: 3, + deletes: 1, + settlements: 3, + failures: Number(!insertAccepted), + replacements: Number(truncate), + }) +} +oracleProperty({ + id: `collection-state.settlement-orders`, + law: { + statement: `Each settled delete or edit drops its optimistic state, whatever order the requests settle in; a truncate between settlements does not revive them.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `The eight orderings of an optional truncate, edit-first settlement, and an accepted or rejected insert.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: settlementOrdersCases, + label: (row) => + `drops each settled delete and edit across settlement orders: ${JSON.stringify(row)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.settlement-orders`, + plan, + observe, + () => runSettlementOrdersCase(plan.history), + ), + check: forwardAssertionJudgment, +}) oracleProperty({ id: `collection-state.optimistic-history`, law: { @@ -1341,8 +1733,7 @@ oracleProperty({ // An aborted open batch never applies, so the source's own rows must return // 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`) +async function runAbortedDeletePartialHistory(): Promise { const counts = await runOptimisticHistory( [{ id: 1, a: 0, b: 0, c: 0 }], [ @@ -1374,6 +1765,32 @@ it(`merges a partial update after an aborted source delete`, async () => { abortedBatches: 1, distinguishingPartialUpdates: 1, }) +} +const abortedDeletePartialHistorys = [ + { + name: `merges a partial update after an aborted source delete`, + }, +] +oracleProperty({ + id: `collection-state.aborted-delete-partial`, + law: { + statement: `An aborted open batch never applies, so a later partial update merges over the source's prior row and keeps the held field.`, + authority: `The optimistic snapshot and settlement laws in optimistic-history-oracle.ts`, + limits: `One fixed history shrunk from seed -526998278.`, + }, + owns: [`src/collection/state.ts`, `src/collection/sync.ts`], + histories: abortedDeletePartialHistorys, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.aborted-delete-partial`, + plan, + observe, + () => runAbortedDeletePartialHistory(), + ), + check: forwardAssertionJudgment, }) oracleProperty({ id: `collection-state.optimistic-history-partial`, diff --git a/packages/db/tests/collection-subscription-lifecycle-cell-support.ts b/packages/db/tests/collection-subscription-lifecycle-cell-support.ts index f383168b58..0d75a58b33 100644 --- a/packages/db/tests/collection-subscription-lifecycle-cell-support.ts +++ b/packages/db/tests/collection-subscription-lifecycle-cell-support.ts @@ -1,5 +1,3 @@ -import { declareOracle } from './oracle-declaration.js' - /** * Acquisition-boundary cells and scenario grammar for the demand lifecycle * oracle. The explicit cell table names every legal phase/entry pair and gives @@ -136,8 +134,6 @@ export const acquisitionCellDefinitions = { * The cell table is the grammar of acquisition coverage. Its calibration test * in the oracle file reaches it through the cell sets derived below. */ -declareOracle({ grammar: [acquisitionCellDefinitions] }) - export const legalAcquisitionCells = new Set( Object.entries(acquisitionCellDefinitions) .filter(([, definition]) => definition.kind === `covered`) diff --git a/packages/db/tests/collection-subscription-lifecycle-cells.test.ts b/packages/db/tests/collection-subscription-lifecycle-cells.test.ts index 34e5205193..12bd5e3f91 100644 --- a/packages/db/tests/collection-subscription-lifecycle-cells.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-cells.test.ts @@ -8,12 +8,20 @@ import { flushPromises, } from './utils.js' import { oracleReplayRequested } from './oracle-config.js' +import { oracleAssertion } from './oracle-assertion-adapter.js' import { + acquisitionEntries, + acquisitionInteractionCauses, acquisitionInteractionCellDefinitions, + acquisitionLifecycleStates, + acquisitionPhases, + excludedAcquisitionCells, failureScenarios, legalAcquisitionCells, releaseScenarios, restartScenarios, + startOutcomes, + startReentries, startScenarios, } from './collection-subscription-lifecycle-cell-support.js' import type { @@ -2019,3 +2027,75 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() }) }) + +/** + * Grammar-size checks for the acquisition cell table and scenario grammar in + * collection-subscription-lifecycle-cell-support.ts. They test test code, so + * they moved here from collection-subscription-lifecycle-oracle.test.ts. + */ +describe(`CollectionSubscription demand lifecycle cell grammar`, () => { + it(`covers every finite start, failure-delivery, and release cell`, () => { + oracleAssertion('existing comparison at 823', () => + expect( + new Set( + startScenarios.map(({ outcome, reentry }) => `${outcome}:${reentry}`), + ), + ).toHaveLength(startOutcomes.length * startReentries.length), + ) + oracleAssertion('existing comparison at 828', () => + expect( + new Set( + failureScenarios.map( + ({ outcome, reentry }) => `${outcome}:${reentry}`, + ), + ), + ).toHaveLength(2 * startReentries.length), + ) + oracleAssertion('existing comparison at 833', () => + expect( + new Set( + releaseScenarios.map( + ({ outcome, reentry }) => `${outcome}:${reentry}`, + ), + ), + ).toHaveLength(2 * 4), + ) + oracleAssertion('existing comparison at 838', () => + expect( + new Set( + restartScenarios.map( + ({ outcome, reentry }) => `${outcome}:${reentry}`, + ), + ), + ).toHaveLength(4 * 5), + ) + }) + + it(`accounts for every acquisition phase and entry pair`, () => { + const allCells = new Set( + acquisitionPhases.flatMap((phase) => + acquisitionEntries.map((entry) => `${phase}:${entry}` as const), + ), + ) + oracleAssertion('existing comparison at 852', () => + expect( + new Set([...legalAcquisitionCells, ...excludedAcquisitionCells.keys()]), + ).toEqual(allCells), + ) + }) + + it(`accounts for every acquisition lifecycle state and interaction cause`, () => { + const allCells = new Set( + acquisitionLifecycleStates.flatMap((state) => + acquisitionInteractionCauses.map( + (cause) => `${state}:${cause}` as const, + ), + ), + ) + oracleAssertion('existing comparison at 866', () => + expect( + new Set(Object.keys(acquisitionInteractionCellDefinitions)), + ).toEqual(allCells), + ) + }) +}) diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts index 09a7237541..f8f3c46acc 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar-oracle.ts @@ -1,6 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from './oracle-declaration.js' - /** * # Which subscription histories are legal? * @@ -1916,16 +1914,3 @@ export function expectedStartHandoff(history: StartHandoffHistory) { starts: 1, } as const } - -declareOracle({ - model: [ - createLifecycleModel, - reduceLifecycle, - expectedCleanedRestartDemand, - expectedParkedReloadDemand, - expectedDeferralBatches, - expectedStartHandoff, - modelSubscriberStartCleanup, - ], - grammar: [parkedReloadDemandCases], -}) diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar.test.ts b/packages/db/tests/collection-subscription-lifecycle-grammar.test.ts new file mode 100644 index 0000000000..a66b21bf24 --- /dev/null +++ b/packages/db/tests/collection-subscription-lifecycle-grammar.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest' +import { oracleAssertion } from './oracle-assertion-adapter.js' +import { + createLifecycleModel, + pendingSupersessionHistory, + reduceLifecycle, +} from './collection-subscription-lifecycle-grammar-oracle.js' + +/** + * Model-rule checks of the shared lifecycle grammar in + * collection-subscription-lifecycle-grammar-oracle.ts. They step the + * independent model without production, so they moved here from + * collection-subscription-lifecycle-history-oracle.property.test.ts. + */ +describe(`CollectionSubscription async lifecycle history oracle`, () => { + it(`names the overlapping replay transition in the model`, () => { + const model = createLifecycleModel() + for (const command of pendingSupersessionHistory) { + reduceLifecycle(model, command) + } + oracleAssertion(`lifecycle history comparison at 1374`, () => + expect(model.reach).toContain(`overlapping-replay`), + ) + }) +}) 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 522432057e..f621290f7e 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 @@ -1,5 +1,5 @@ import { fc } from '@fast-check/vitest' -import { afterAll, beforeEach, describe, expect, it as vitestIt } from 'vitest' +import { afterAll, beforeEach, describe, expect } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { Func, PropRef, Value } from '../src/query/ir.js' @@ -31,15 +31,14 @@ import { subscriptionComparison } from './collection-subscription-property-regis import { forwardAssertionJudgment, oracleAssertion, + recordAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import { oraclePropertyOptions, oracleReplayRequested, - oracleReplayTest, - oracleRoute, readOracleRunConfig, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' import type { DemandName, @@ -52,11 +51,6 @@ import type { } from './collection-subscription-lifecycle-grammar-oracle.js' const { multiplier } = readOracleRunConfig() -const it = oracleReplayTest(vitestIt) - -declareOracle({ - check: [runHistory], -}) /** * # What settles a demand created while a Collection is cleaned up? @@ -78,157 +72,173 @@ declareOracle({ * and subscriber-applied rows must match the model. Public preload failure and * demands first admitted after restart remain outside this focused matrix. */ -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 - let releaseEagerReady: (() => void) | undefined - let operations!: Parameters[`sync`]>[0] +async function runCleanedRestartDemandCase( + history: (typeof cleanedRestartDemandCases)[number], +): Promise { + const expected = expectedCleanedRestartDemand(history) + let syncRuns = 0 + let physicalAcquisitions = 0 + let releaseEagerReady: (() => void) | undefined + let operations!: Parameters[`sync`]>[0] - const publishRow = (id: string) => { - operations.begin() - operations.write({ type: `insert`, value: { id } }) - operations.commit() - } - const publishReplacement = () => { - if (history.replacement === `row`) publishRow(`replacement`) - } - const collection = createCollection<{ id: string }>({ - id: `cleaned-restart-${history.syncMode}-${history.loader}-${history.replacement}`, - getKey: ({ id }) => id, - ...(history.syncMode === `default-eager` - ? {} - : { syncMode: history.syncMode }), - sync: { - sync: (next) => { - const syncRun = syncRuns++ - // Keep the current run's public adapter boundary for the history - // commands below; the model does not read this object. - operations = next - if (syncRun === 0) { - next.markReady() - } else if (history.syncMode !== `on-demand`) { - releaseEagerReady = () => { - publishReplacement() - next.markReady() - } - } else { - next.markReady() - } - if (history.loader === `absent`) return - return { - loadSubset: () => { - physicalAcquisitions++ - publishReplacement() - return true - }, - } - }, - }, - }) - const visible = new Map() - const subscription = collection.subscribeChanges( - (changes) => { - for (const change of changes) { - if (change.type === `delete`) visible.delete(change.key) - else visible.set(change.key, { id: change.value.id }) + const publishRow = (id: string) => { + operations.begin() + operations.write({ type: `insert`, value: { id } }) + operations.commit() + } + const publishReplacement = () => { + if (history.replacement === `row`) publishRow(`replacement`) + } + const collection = createCollection<{ id: string }>({ + id: `cleaned-restart-${history.syncMode}-${history.loader}-${history.replacement}`, + getKey: ({ id }) => id, + ...(history.syncMode === `default-eager` + ? {} + : { syncMode: history.syncMode }), + sync: { + sync: (next) => { + const syncRun = syncRuns++ + // Keep the current run's public adapter boundary for the history + // commands below; the model does not read this object. + operations = next + if (syncRun === 0) { + next.markReady() + } else if (history.syncMode !== `on-demand`) { + releaseEagerReady = () => { + publishReplacement() + next.markReady() } - }, - { includeInitialState: false }, - ) - - await withOracleCleanup(async () => { - publishRow(`old`) - oracleAssertion(`lifecycle history comparison at 148`, () => - expect([...visible.values()]).toEqual([{ id: `old` }]), - ) - await collection.cleanup() - let resultState: `pending` | `fulfilled` | `rejected` = `pending` - let result: unknown - subscription.requestSnapshot({ - onLoadSubsetResult: (returned) => { - result = returned - if (returned instanceof Promise) { - void returned.then( - () => { - resultState = `fulfilled` - }, - () => { - resultState = `rejected` - }, - ) - } else { - resultState = `fulfilled` - } + } else { + next.markReady() + } + if (history.loader === `absent`) return + return { + loadSubset: () => { + physicalAcquisitions++ + publishReplacement() + return true }, - }) - - oracleAssertion(`lifecycle history comparison at 170`, () => - expect(result).toBeInstanceOf(Promise), - ) - oracleAssertion(`lifecycle history comparison at 171`, () => - expect(resultState).toBe(`pending`), - ) - - collection.startSyncImmediate() - await flushPromises() + } + }, + }, + }) + const visible = new Map() + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else visible.set(change.key, { id: change.value.id }) + } + }, + { includeInitialState: false }, + ) - if (expected.beforeEagerReady) { - oracleAssertion(`lifecycle history comparison at 177`, () => - expect({ - physicalAcquisitions, - result: resultState, - status: subscription.status, - collectionStatus: collection.status, - }).toEqual(expected.beforeEagerReady), + await withOracleCleanup(async () => { + publishRow(`old`) + oracleAssertion(`lifecycle history comparison at 148`, () => + expect([...visible.values()]).toEqual([{ id: `old` }]), + ) + await collection.cleanup() + let resultState: `pending` | `fulfilled` | `rejected` = `pending` + let result: unknown + subscription.requestSnapshot({ + onLoadSubsetResult: (returned) => { + result = returned + if (returned instanceof Promise) { + void returned.then( + () => { + resultState = `fulfilled` + }, + () => { + resultState = `rejected` + }, ) + } else { + resultState = `fulfilled` + } + }, + }) - if (history.interruption === `truncate`) { - operations.begin() - operations.truncate({ markReady: false }) - const receipt = operations.commit() - if (receipt !== true) await receipt - } else if (history.interruption === `failed-then-reload`) { - operations.fail(new Error(`restart failure`)) - } - if (expected.afterInterruption) { - await flushPromises() - oracleAssertion(`lifecycle history comparison at 194`, () => - expect({ - physicalAcquisitions, - result: resultState, - status: subscription.status, - collectionStatus: collection.status, - }).toEqual(expected.afterInterruption), - ) - } + oracleAssertion(`lifecycle history comparison at 170`, () => + expect(result).toBeInstanceOf(Promise), + ) + oracleAssertion(`lifecycle history comparison at 171`, () => + expect(resultState).toBe(`pending`), + ) - // A failed first load ends its sync run; only a reload starts the - // run that can serve the waiting demand. - if (history.interruption === `failed-then-reload`) { - void collection.reload().catch(() => undefined) - await flushPromises() - } - releaseEagerReady!() - await flushPromises() - } + collection.startSyncImmediate() + await flushPromises() - oracleAssertion(`lifecycle history comparison at 212`, () => + if (expected.beforeEagerReady) { + oracleAssertion(`lifecycle history comparison at 177`, () => + expect({ + physicalAcquisitions, + result: resultState, + status: subscription.status, + collectionStatus: collection.status, + }).toEqual(expected.beforeEagerReady), + ) + + if (history.interruption === `truncate`) { + operations.begin() + operations.truncate({ markReady: false }) + const receipt = operations.commit() + if (receipt !== true) await receipt + } else if (history.interruption === `failed-then-reload`) { + operations.fail(new Error(`restart failure`)) + } + if (expected.afterInterruption) { + await flushPromises() + oracleAssertion(`lifecycle history comparison at 194`, () => expect({ physicalAcquisitions, result: resultState, status: subscription.status, - rows: [...visible.values()], collectionStatus: collection.status, - }).toEqual(expected.settled), + }).toEqual(expected.afterInterruption), ) - }, [() => subscription.unsubscribe(), () => collection.cleanup()]) + } + + // A failed first load ends its sync run; only a reload starts the + // run that can serve the waiting demand. + if (history.interruption === `failed-then-reload`) { + void collection.reload().catch(() => undefined) + await flushPromises() + } + releaseEagerReady!() + await flushPromises() + } + + oracleAssertion(`lifecycle history comparison at 212`, () => + expect({ + physicalAcquisitions, + result: resultState, + status: subscription.status, + rows: [...visible.values()], + collectionStatus: collection.status, + }).toEqual(expected.settled), + ) + }, [() => subscription.unsubscribe(), () => collection.cleanup()]) +} + +describe(`demand created while its Collection is cleaned up`, () => { + oracleProperty({ + id: `subscription-lifecycle.cleaned-restart-demand`, + law: { + statement: `A demand created while its Collection is cleaned up settles from the next sync run's evidence, as the independent cleaned-restart model predicts at every cut.`, + authority: `Collection lifecycle and the live-query architecture, as the contract above and the lifecycle grammar's cleanedRestartDemandCases model state them.`, + limits: `The finite cleaned-restart matrix; public preload failure and demands first admitted after restart remain outside it.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: cleanedRestartDemandCases, + label: ({ syncMode, loader, replacement, interruption }) => + `${syncMode} source with ${loader} loader, ${replacement} replacement, and ${interruption}`, + ...subscriptionComparison<(typeof cleanedRestartDemandCases)[number]>( + `subscription-lifecycle.cleaned-restart-demand`, + runCleanedRestartDemandCase, + ), + check: forwardAssertionJudgment, + }) }) /** @@ -240,82 +250,100 @@ describe(`demand created while its Collection is cleaned up`, () => { * a later microtask. After the history settles, the counts, the parked * request's promise, and both statuses must match the model. */ -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 - let unloads = 0 - let failFirstRun!: () => void - let laterSnapshot: (() => void) | undefined - const collection = createCollection<{ id: string }>({ - id: `parked-reload-${history.newRun}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - sync: { - sync: (next) => { - const syncRun = syncRuns++ - const snapshot = () => { - next.begin({ snapshot: true }) - next.commit() - } - if (syncRun === 0) { - failFirstRun = () => next.fail(new Error(`first load failed`)) - } else if (history.newRun === `snapshot-then-ready`) { - snapshot() - next.markReady() - } else { - next.markReady() - if (history.newRun === `ready-then-snapshot`) snapshot() - if (history.newRun === `ready-then-later-snapshot`) - laterSnapshot = snapshot - } - return { - loadSubset: () => { - loads++ - return Promise.resolve() - }, - unloadSubset: () => { - unloads++ - }, - } - }, +async function runParkedReloadDemandCase( + history: (typeof parkedReloadDemandCases)[number], +): Promise { + const expected = expectedParkedReloadDemand(history) + let syncRuns = 0 + let loads = 0 + let unloads = 0 + let failFirstRun!: () => void + let laterSnapshot: (() => void) | undefined + const collection = createCollection<{ id: string }>({ + id: `parked-reload-${history.newRun}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (next) => { + const syncRun = syncRuns++ + const snapshot = () => { + next.begin({ snapshot: true }) + next.commit() + } + if (syncRun === 0) { + failFirstRun = () => next.fail(new Error(`first load failed`)) + } else if (history.newRun === `snapshot-then-ready`) { + snapshot() + next.markReady() + } else { + next.markReady() + if (history.newRun === `ready-then-snapshot`) snapshot() + if (history.newRun === `ready-then-later-snapshot`) + laterSnapshot = snapshot + } + return { + loadSubset: () => { + loads++ + return Promise.resolve() + }, + unloadSubset: () => { + unloads++ + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + await withOracleCleanup(async () => { + collection.startSyncImmediate() + failFirstRun() + await flushPromises() + let result: `pending` | `fulfilled` | `rejected` = `pending` + subscription.requestSnapshot({ + onLoadSubsetResult: (returned) => { + void Promise.resolve(returned).then( + () => (result = `fulfilled`), + () => (result = `rejected`), + ) }, }) - const subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - await withOracleCleanup(async () => { - collection.startSyncImmediate() - failFirstRun() - await flushPromises() - let result: `pending` | `fulfilled` | `rejected` = `pending` - subscription.requestSnapshot({ - onLoadSubsetResult: (returned) => { - void Promise.resolve(returned).then( - () => (result = `fulfilled`), - () => (result = `rejected`), - ) - }, - }) + await flushPromises() + void collection.reload().catch(() => undefined) + await flushPromises() + if (laterSnapshot) { + laterSnapshot() await flushPromises() - void collection.reload().catch(() => undefined) - await flushPromises() - if (laterSnapshot) { - laterSnapshot() - await flushPromises() - } - oracleAssertion(`lifecycle history comparison at 299`, () => - expect({ - loads, - unloads, - result, - status: subscription.status, - collectionStatus: collection.status, - }).toEqual(expected), - ) - }, [() => subscription.unsubscribe(), () => collection.cleanup()]) + } + oracleAssertion(`lifecycle history comparison at 299`, () => + expect({ + loads, + unloads, + result, + status: subscription.status, + collectionStatus: collection.status, + }).toEqual(expected), + ) + }, [() => subscription.unsubscribe(), () => collection.cleanup()]) +} + +describe(`demand parked behind a failed first load across a reload`, () => { + oracleProperty({ + id: `subscription-lifecycle.parked-reload-demand`, + law: { + statement: `A demand parked behind a failed first load is served by the reload's sync run exactly as the independent parked-reload model predicts.`, + authority: `Collection lifecycle and the lifecycle grammar's parkedReloadDemandCases model.`, + limits: `The four bounded new-run shapes after one failed first load.`, + }, + owns: [`src/collection/subscription.ts`], + histories: parkedReloadDemandCases, + label: ({ newRun }) => `new run: ${newRun}`, + ...subscriptionComparison<(typeof parkedReloadDemandCases)[number]>( + `subscription-lifecycle.parked-reload-demand`, + runParkedReloadDemandCase, + ), + check: forwardAssertionJudgment, }) }) @@ -1210,89 +1238,48 @@ if ( ) } -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` }, - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `restart` }, - settle(`a`, `current`, `oldest`, `reject`), - { type: `abort`, demand: `a` }, - settle(`b`, `current`, `oldest`, outcome), - ], - { cancellation: `reject` }, - ) - }, - ) - - it(`shares one normalized replay rejection among surviving detached callers`, async () => { - oracleRoute(`fixed history`) - await runHistory( - [ - { type: `cleanup` }, - { type: `request`, demand: `a` }, - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `restart` }, - settle(`a`, `current`, `oldest`, `resolve`), - settle(`a`, `current`, `newest`, `resolve`), - { type: `abort`, demand: `b` }, - ], - { cancellation: `reject` }, - ) - }) +// The fixed and enumerated histories below replay the shared driver +// `runHistory` and its independent lifecycle model. Each entry is data: its +// commands and the data options of each `runHistory` call, in order. An entry +// without `runs` makes one call with the default options. +type HistoryRunOptions = { + acquisitionMode?: `async-pending` | `sync-success` + cancellation?: `manual` | `reject` + continueAfterMismatch?: boolean +} +type NamedLifecycleHistory = { + name: string + history: ReadonlyArray + runs?: ReadonlyArray +} - it.each([ - `resolve`, - `reject`, - `release`, - `abort`, - `cleanup`, - `unsubscribe`, - ] as const)( - `holds detached callers for their peer until %s`, - async (finish) => { - oracleRoute(`case`) - const history: Array = [ - { type: `cleanup` }, - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `restart` }, - settle(`a`, `current`, `oldest`, `resolve`), - ...(finish === `resolve` || finish === `reject` - ? [settle(`b`, `current`, `oldest`, finish)] - : finish === `release` || finish === `abort` - ? [{ type: finish, demand: `b` } as const] - : [{ type: finish } as const]), - ] - let checkpoints = 0 - await runHistory(history, { - cancellation: `reject`, - onResultCheckpoint: (_command, callers) => { - if (checkpoints++ === 4) { - oracleAssertion(`lifecycle history comparison at 1174`, () => - expect(callers.map(({ outcome }) => outcome.status)).toEqual([ - `pending`, - `pending`, - ]), - ) - } - }, - }) - oracleAssertion(`lifecycle history comparison at 1181`, () => - expect(checkpoints).toBe(6), - ) - }, - ) +async function runNamedHistory({ + history, + runs = [{}], +}: NamedLifecycleHistory): Promise { + for (const options of runs) await runHistory(history, options) +} - it(`does not revive a discarded caller when its logical demand restarts`, async () => { - oracleRoute(`fixed history`) - await runHistory([ +// Named regressions that the generated command grammars cannot take back: +// their arbitraries map and chain without an unmapper. +const lifecycleFixedHistories: ReadonlyArray = [ + { + name: `shares one normalized replay rejection among surviving detached callers`, + history: [ + { type: `cleanup` }, + { type: `request`, demand: `a` }, + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `restart` }, + settle(`a`, `current`, `oldest`, `resolve`), + settle(`a`, `current`, `newest`, `resolve`), + { type: `abort`, demand: `b` }, + ], + runs: [{ cancellation: `reject` }], + }, + { + name: `does not revive a discarded caller when its logical demand restarts`, + history: [ { type: `cleanup` }, { type: `request`, demand: `a` }, { type: `restart` }, @@ -1302,218 +1289,213 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { settle(`a`, `current`, `oldest`, `resolve`), { type: `request`, demand: `b` }, settle(`b`, `current`, `oldest`, `resolve`), - ]) - }) - - it.each( - ([`initial`, `detached`] as const).flatMap((origin) => - ([`early`, `pending`, `wrong-value`, `wrong-error`] as const).map( - (fault) => ({ origin, fault }), - ), - ), - )(`checks corrupted caller outcomes: %j`, async ({ origin, fault }) => { - oracleRoute(`calibration`) - const history: Array = [ - ...(origin === `detached` ? [{ type: `cleanup` } as const] : []), + ], + }, + { + name: `releases exact ownership while older replay work is pending`, + history: [ + ...pendingSupersessionHistory, + { type: `cleanup` }, + { type: `restart` }, + { type: `release`, demand: `a` }, + { type: `unsubscribe` }, + ], + runs: [{ continueAfterMismatch: true }], + }, + { + name: `does not release an unacquired replacement after an aborted demand replays`, + history: [ + ...abortReplayHistory, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, + ], + runs: [{ continueAfterMismatch: true }], + }, + { + name: `replays a live peer without reacquiring an aborted demand`, + history: [ { type: `request`, demand: `a` }, - ...(origin === `detached` ? [{ type: `restart` } as const] : []), - settle( - `a`, - `current`, - `oldest`, - fault === `wrong-error` ? `reject` : `resolve`, - ), - ] - await oracleAssertion(`lifecycle history comparison at 1219`, () => - expect(runHistory(history, { callerFault: fault })).rejects.toThrow( - `caller outcome`, - ), - ) - }) - - it(`retains the caller mismatch and a separate cleanup failure`, async () => { - oracleRoute(`calibration`) - const cleanupFailure = new Error(`cleanup sentinel`) - let reported: unknown - try { - await runHistory( - [ - { type: `request`, demand: `a` }, - settle(`a`, `current`, `oldest`, `resolve`), - ], - { - callerFault: `wrong-value`, - cleanupFault: () => { - throw cleanupFailure - }, - }, - ) - } catch (error) { - reported = error - } - oracleAssertion(`lifecycle history comparison at 1244`, () => - expect(reported).toBeInstanceOf(AggregateError), - ) - const combined = reported as AggregateError - oracleAssertion(`lifecycle history comparison at 1246`, () => - expect((combined.cause as Error).message).toContain(`caller outcome`), - ) - oracleAssertion(`lifecycle history comparison at 1247`, () => - expect(combined.errors).toHaveLength(1), - ) - oracleAssertion(`lifecycle history comparison at 1248`, () => - expect((combined.errors[0] as Error).cause).toBe(cleanupFailure), - ) - }) - - 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 }, - ) - oracleAssertion(`lifecycle history comparison at 1258`, () => - expect(reach).toContain(`retired-attempt-delivery:${outcome}`), - ) - }) - } - - 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` }, - { type: `request`, demand: `b` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: firstOutcome, - }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: secondOutcome, - }, - ] - const expectedStatuses = [ - [`pending`], - [`pending`, `pending`], - [firstOutcome === `resolve` ? `fulfilled` : `rejected`, `pending`], - [ - firstOutcome === `resolve` ? `fulfilled` : `rejected`, - secondOutcome === `resolve` ? `fulfilled` : `rejected`, - ], - ] - const identities: Array = [] - let cut = 0 - await runHistory(history, { - onResultCheckpoint: (_command, results, failures) => { - oracleAssertion(`lifecycle history comparison at 1297`, () => - expect(results.map(({ outcome }) => outcome.status)).toEqual( - expectedStatuses[cut++], - ), - ) - for (const [index, returned] of results.entries()) { - oracleAssertion(`lifecycle history comparison at 1301`, () => - expect(returned.attemptId).toBe(index), - ) - oracleAssertion(`lifecycle history comparison at 1302`, () => - expect(returned.result).toBeInstanceOf(Promise), - ) - if (identities.length === index) identities.push(returned.result) - oracleAssertion(`lifecycle history comparison at 1304`, () => - expect(returned.result).toBe(identities[index]), - ) - const outcome = returned.outcome - if (outcome.status === `fulfilled`) { - oracleAssertion(`lifecycle history comparison at 1306`, () => - expect(outcome.value).toBeUndefined(), - ) - } else if (outcome.status === `rejected`) { - oracleAssertion(`lifecycle history comparison at 1308`, () => - expect(outcome.error).toBe(failures.get(index)), - ) - } - } - }, - }) - oracleAssertion(`lifecycle history comparison at 1313`, () => - expect(cut).toBe(4), - ) - }) - } - - it(`covers every required command and cross-phase transition`, async () => { - oracleRoute(`case`) - const reach = new Set() - for (const history of [ - ...greenLifecycleHistories, - ...compoundLifecycleCoverageHistories, - ]) { - for (const label of await runHistory(history)) reach.add(label) - } - const commands = [ - `request`, - `abort`, - `release`, - `settle`, - `truncate`, - `cleanup`, - `restart`, - `unsubscribe`, - ] - const required = new Set([ - ...commands.map((type) => `command:${type}`), - ...commands.map((type) => `effective:${type}`), - ...commands.map((type) => `noop:${type}`), - `settle-scope:current`, - `settle-scope:obsolete`, - `settle-age:oldest`, - `settle-age:newest`, - `settle-outcome:resolve`, - `settle-outcome:reject`, - ...([`current`, `obsolete`] as const).flatMap((scope) => - ([`oldest`, `newest`] as const).flatMap((age) => - ([`resolve`, `reject`] as const).map( - (outcome) => `settle:${scope}:${age}:${outcome}`, - ), - ), - ), - `attempt-sync-run:initial`, - `attempt-sync-run:restarted`, - `attempt-replay:initial`, - `attempt-replay:replayed`, - `attempt-location:initial:initial`, - `attempt-location:initial:replayed`, - `attempt-location:restarted:initial`, - `attempt-location:restarted:replayed`, - `duplicate-owner`, - `request-while-cleaned`, - `partial-generation-supersession`, - ]) - oracleAssertion(`lifecycle history comparison at 1365`, () => - expect([...required].filter((label) => !reach.has(label))).toEqual([]), - ) - }) + { type: `request`, demand: `b` }, + { type: `abort`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `truncate` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `a` }, + { type: `release`, demand: `b` }, + ], + }, + { + name: `restarts a live peer without reacquiring an aborted demand and completes teardown`, + history: mixedAbortedRestartHistory, + runs: [{ continueAfterMismatch: true }], + }, + { + name: `preserves acquisition lease ownership across queued synchronous replay`, + history: syncLifecycleHistory, + runs: [{ acquisitionMode: `sync-success`, continueAfterMismatch: true }], + }, + { + // Seed 1413322355, path 757:13:15:15:9:9:9. This checks an empty snapshot + // notification: initial readiness is not a replacement publication gate. + name: `publishes a new snapshot while canceled initial work still holds readiness`, + history: [ + { type: `request`, demand: `b` }, + { type: `truncate` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { + type: `settle`, + demand: `b`, + scope: `obsolete`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `a` }, + { type: `release`, demand: `b` }, + { type: `unsubscribe` }, + ], + runs: [{ continueAfterMismatch: true }], + }, + { + // Minimized from seed 317005625 at 100×. The mismatch is an empty + // notification, not lost rows: failed replacement must keep reads private. + name: `keeps a new snapshot private after failed restart`, + history: [ + { type: `request`, demand: `b` }, + { type: `cleanup` }, + { type: `restart` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `a` }, + { type: `release`, demand: `b` }, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, + ], + runs: [{ continueAfterMismatch: true }], + }, + // Two owners across a truncate: each replacement load judges the attempts + // this command's replay replaces. These replay the shared driver and model. + ...( + [ + { + name: `an aborted peer and a pending owner`, + history: [ + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `abort`, demand: `a` }, + { type: `truncate` }, + ], + }, + { + name: `an abort after the first replay`, + history: [ + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `truncate` }, + ], + }, + ] satisfies Array<{ name: string; history: Array }> + ).map(({ name, history }) => ({ + name: `replaces each owner's pending attempt before its replacement loads for ${name}`, + history, + runs: [{}, { cancellation: `reject` as const }], + })), + // Prompt cancellation of the replaced owner's replay load is a settlement: + // the held replacement covers it, so the replay publishes. + { + name: `publishes a replay when a covered owner's replay load is canceled`, + history: [ + { type: `request`, demand: `c` }, + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `replace`, demand: `b`, replaced: `a` }, + { type: `truncate` }, + settle(`b`, `current`, `oldest`, `resolve`), + settle(`c`, `current`, `oldest`, `resolve`), + { type: `abort`, demand: `a` }, + { type: `request`, demand: `a` }, + ], + runs: [{ cancellation: `reject` }], + }, +] - it(`names the overlapping replay transition in the model`, () => { - oracleRoute(`case`) - const model = createLifecycleModel() - for (const command of pendingSupersessionHistory) { - reduceLifecycle(model, command) - } - oracleAssertion(`lifecycle history comparison at 1374`, () => - expect(model.reach).toContain(`overlapping-replay`), - ) - }) +const syncReplayScenarios = ([`truncate`, `restart`] as const).flatMap( + (transition) => + ([1, 2] as const).flatMap((ownerCount) => + ([false, true] as const).map((abortFirst) => { + const history: Array = [ + { type: `request`, demand: `a` }, + ...(ownerCount === 2 + ? ([{ type: `request`, demand: `b` }] as const) + : []), + ...(abortFirst ? ([{ type: `abort`, demand: `a` }] as const) : []), + ...(transition === `truncate` + ? ([{ type: `truncate` }] as const) + : ([{ type: `cleanup` }, { type: `restart` }] as const)), + ] + return { transition, ownerCount, abortFirst, history } + }), + ), +) - it.each([ +// Bounded case tables over the same driver and model. +const lifecycleCaseHistories: ReadonlyArray = [ + ...([`resolve`, `reject`] as const).map((outcome): NamedLifecycleHistory => ({ + name: `retains an earlier peer failure after aborting it while another peer will ${outcome}`, + history: [ + { type: `cleanup` }, + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `restart` }, + settle(`a`, `current`, `oldest`, `reject`), + { type: `abort`, demand: `a` }, + settle(`b`, `current`, `oldest`, outcome), + ], + runs: [{ cancellation: `reject` }], + })), + ...[ { name: `one demand after one replay`, history: [ @@ -1532,296 +1514,536 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { name: `duplicate owners after overlapping replay`, history: pendingSupersessionHistory, }, - ])( - `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, - { type: `cleanup` }, - { type: `restart` }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ], - { continueAfterMismatch: true }, - ) - }) - - it.each( - ([`manual`, `reject`] as const).flatMap((cancellation) => - ([1, 2] as const).flatMap((replays) => - ([`resolve`, `reject`] as const).map((outcome) => ({ - cancellation, - replays, - outcome, - })), + ].map(({ name, history }) => ({ + name: `does not wait for an aborted load to settle for ${name}`, + history, + })), + ...([`manual`, `reject`] as const).flatMap((cancellation) => + ([1, 2] as const).flatMap((replays) => + ([`resolve`, `reject`] as const).map( + (outcome): NamedLifecycleHistory => ({ + name: `tracks ${cancellation} cancellation across ${replays} replay(s) ending in ${outcome}`, + history: [ + { type: `request`, demand: `a` }, + ...Array.from({ length: replays }, (): LifecycleCommand => ({ + type: `truncate`, + })), + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome, + }, + ...Array.from( + { length: replays }, + (_, index): LifecycleCommand => ({ + type: `settle`, + demand: `a`, + scope: `obsolete`, + age: `oldest`, + outcome: index % 2 === 0 ? `reject` : `resolve`, + }), + ), + { type: `release`, demand: `a` }, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, + ], + runs: [{ cancellation }], + }), ), ), - )( - `tracks $cancellation cancellation across $replays replay(s) ending in $outcome`, - async ({ cancellation, replays, outcome }) => { - oracleRoute(`case`) - await runHistory( - [ + ), + ...[ + { name: `truncate replay`, history: abortReplayHistory }, + { name: `cleanup restart`, history: abortedRestartHistory }, + ].map(({ name, history }) => ({ + name: `queues replay without reacquiring an aborted demand on ${name}`, + history, + })), + ...syncReplayScenarios.map( + ({ + transition, + ownerCount, + abortFirst, + history, + }): NamedLifecycleHistory => ({ + name: `settles queued synchronous ${transition} with ${ownerCount} owner(s), abort=${abortFirst}`, + history, + runs: [{ acquisitionMode: `sync-success` }], + }), + ), + ...( + [ + { + name: `same-key owners across truncate`, + history: [ { type: `request`, demand: `a` }, - ...Array.from({ length: replays }, (): LifecycleCommand => ({ - type: `truncate`, - })), - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome, - }, - ...Array.from({ length: replays }, (_, index): LifecycleCommand => ({ - type: `settle`, - demand: `a`, - scope: `obsolete`, - age: `oldest`, - outcome: index % 2 === 0 ? `reject` : `resolve`, - })), + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `release`, demand: `a` }, { type: `release`, demand: `a` }, { type: `cleanup` }, { type: `restart` }, { type: `unsubscribe` }, ], - { cancellation }, - ) - }, - ) + }, + { + name: `same-key owners across restart`, + history: [ + { type: `request`, demand: `a` }, + { type: `request`, demand: `a` }, + { type: `cleanup` }, + { type: `restart` }, + { type: `release`, demand: `a` }, + { type: `release`, demand: `a` }, + { type: `unsubscribe` }, + ], + }, + { + name: `aborted owner across repeated truncate`, + history: [ + { type: `request`, demand: `a` }, + { type: `abort`, demand: `a` }, + { type: `truncate` }, + { type: `truncate` }, + { type: `release`, demand: `a` }, + { type: `unsubscribe` }, + ], + }, + { + name: `detached last-owner abort across restart`, + history: [ + { type: `request`, demand: `a` }, + { type: `cleanup` }, + { type: `abort`, demand: `a` }, + { type: `restart` }, + { type: `release`, demand: `a` }, + { type: `unsubscribe` }, + ], + }, + ] satisfies ReadonlyArray<{ + name: string + history: ReadonlyArray + }> + ).map(({ name, history }) => ({ + name: `continues through the full synchronous replay suffix for ${name}`, + history, + runs: [ + { acquisitionMode: `sync-success` as const, continueAfterMismatch: true }, + ], + })), +] - it.each([ - { name: `truncate replay`, history: abortReplayHistory }, - { name: `cleanup restart`, history: abortedRestartHistory }, - ])( - `queues replay without reacquiring an aborted demand on $name`, - async ({ history }) => { - oracleRoute(`case`) - await runHistory(history) - }, - ) +// The uncorrupted caller-outcome histories that the calibrations below +// corrupt: an initial or cleanup-detached request whose acquisition resolves +// or rejects. Each runs clean through the shared driver first. +const callerOutcomeHistories: ReadonlyArray = ( + [`initial`, `detached`] as const +).flatMap((origin) => + ([`resolve`, `reject`] as const).map((outcome) => ({ + name: `delivers the caller outcome: ${JSON.stringify({ origin, outcome })}`, + history: [ + ...(origin === `detached` ? [{ type: `cleanup` } as const] : []), + { type: `request`, demand: `a` }, + ...(origin === `detached` ? [{ type: `restart` } as const] : []), + settle(`a`, `current`, `oldest`, outcome), + ], + })), +) +const callerOutcomeLaw = `subscription-lifecycle.caller-outcome` +const callerOutcomeHistoryName = ( + origin: `initial` | `detached`, + outcome: `resolve` | `reject`, +) => `delivers the caller outcome: ${JSON.stringify({ origin, outcome })}` - it(`does not release an unacquired replacement after an aborted demand replays`, async () => { - oracleRoute(`fixed history`) - await runHistory( - [ - ...abortReplayHistory, - { type: `cleanup` }, - { type: `restart` }, - { type: `unsubscribe` }, - ], - { continueAfterMismatch: true }, +// A corrupted caller outcome must fail the shared driver's caller comparison. +// The mutant applies to the history the old witness ran; it records no +// judgment change for any other candidate. Only a rejection that names the +// caller outcome counts as the check rejecting the wrong answer. +const corruptedCallerOutcomeCalibrations = ([`initial`, `detached`] as const) + .flatMap((origin) => + ([`early`, `pending`, `wrong-value`, `wrong-error`] as const).map( + (fault) => ({ origin, fault }), + ), + ) + .map(({ origin, fault }) => { + const target = callerOutcomeHistoryName( + origin, + fault === `wrong-error` ? `reject` : `resolve`, ) + return { + name: `checks corrupted caller outcomes: ${JSON.stringify({ origin, fault })}`, + law: callerOutcomeLaw, + driver: async ( + plan: { history: NamedLifecycleHistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(callerOutcomeLaw, plan, observe, async () => { + if (plan.history.name !== target) return + try { + await runHistory(plan.history.history, { callerFault: fault }) + } catch (error) { + if ( + error instanceof Error && + error.message.includes(`caller outcome`) + ) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `calibration ${fault} rejected without naming the caller outcome: ${String(error)}`, + ) + } + }), + } }) - it(`replays a live peer without reacquiring an aborted demand`, async () => { - oracleRoute(`fixed history`) - await runHistory([ - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `abort`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, - { type: `truncate` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `release`, demand: `a` }, - { type: `release`, demand: `b` }, - ]) +// A caller mismatch and a separate cleanup failure must both survive: the +// driver reports an AggregateError whose cause is the caller mismatch and +// whose one error carries the cleanup failure. +const cleanupFailureCalibration = { + name: `retains the caller mismatch and a separate cleanup failure`, + law: callerOutcomeLaw, + driver: async ( + plan: { history: NamedLifecycleHistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(callerOutcomeLaw, plan, observe, async () => { + if (plan.history.name !== callerOutcomeHistoryName(`initial`, `resolve`)) + return + const cleanupFailure = new Error(`cleanup sentinel`) + let reported: unknown + try { + await runHistory(plan.history.history, { + callerFault: `wrong-value`, + cleanupFault: () => { + throw cleanupFailure + }, + }) + } catch (error) { + reported = error + } + if ( + reported instanceof AggregateError && + reported.cause instanceof Error && + reported.cause.message.includes(`caller outcome`) && + reported.errors.length === 1 && + (reported.errors[0] as Error | undefined)?.cause === cleanupFailure + ) + rejectAt(`calibration cleanup failure`, reported) + throw new Error( + `the driver did not retain the caller mismatch and the cleanup failure: ${String(reported)}`, + ) + }), +} + +async function runDetachedCallerCase( + finish: + `resolve` | `reject` | `release` | `abort` | `cleanup` | `unsubscribe`, +): Promise { + const history: Array = [ + { type: `cleanup` }, + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { type: `restart` }, + settle(`a`, `current`, `oldest`, `resolve`), + ...(finish === `resolve` || finish === `reject` + ? [settle(`b`, `current`, `oldest`, finish)] + : finish === `release` || finish === `abort` + ? [{ type: finish, demand: `b` } as const] + : [{ type: finish } as const]), + ] + let checkpoints = 0 + await runHistory(history, { + cancellation: `reject`, + onResultCheckpoint: (_command, callers) => { + if (checkpoints++ === 4) { + oracleAssertion(`lifecycle history comparison at 1174`, () => + expect(callers.map(({ outcome }) => outcome.status)).toEqual([ + `pending`, + `pending`, + ]), + ) + } + }, }) + oracleAssertion(`lifecycle history comparison at 1181`, () => + expect(checkpoints).toBe(6), + ) +} - it(`restarts a live peer without reacquiring an aborted demand and completes teardown`, async () => { - oracleRoute(`fixed history`) - await runHistory(mixedAbortedRestartHistory, { - continueAfterMismatch: true, - }) +async function runRetiredDeliveryCase( + outcome: `resolve` | `reject`, +): Promise { + const reach = await runHistory( + [{ type: `request`, demand: `a` }, { type: `unsubscribe` }], + { retiredDelivery: outcome }, + ) + oracleAssertion(`lifecycle history comparison at 1258`, () => + expect(reach).toContain(`retired-attempt-delivery:${outcome}`), + ) +} + +async function runReturnedResultsCase( + firstOutcome: `resolve` | `reject`, +): Promise { + const secondOutcome = firstOutcome === `resolve` ? `reject` : `resolve` + const history: Array = [ + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: firstOutcome, + }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: secondOutcome, + }, + ] + const expectedStatuses = [ + [`pending`], + [`pending`, `pending`], + [firstOutcome === `resolve` ? `fulfilled` : `rejected`, `pending`], + [ + firstOutcome === `resolve` ? `fulfilled` : `rejected`, + secondOutcome === `resolve` ? `fulfilled` : `rejected`, + ], + ] + const identities: Array = [] + let cut = 0 + await runHistory(history, { + onResultCheckpoint: (_command, results, failures) => { + oracleAssertion(`lifecycle history comparison at 1297`, () => + expect(results.map(({ outcome }) => outcome.status)).toEqual( + expectedStatuses[cut++], + ), + ) + for (const [index, returned] of results.entries()) { + oracleAssertion(`lifecycle history comparison at 1301`, () => + expect(returned.attemptId).toBe(index), + ) + oracleAssertion(`lifecycle history comparison at 1302`, () => + expect(returned.result).toBeInstanceOf(Promise), + ) + if (identities.length === index) identities.push(returned.result) + oracleAssertion(`lifecycle history comparison at 1304`, () => + expect(returned.result).toBe(identities[index]), + ) + const outcome = returned.outcome + if (outcome.status === `fulfilled`) { + oracleAssertion(`lifecycle history comparison at 1306`, () => + expect(outcome.value).toBeUndefined(), + ) + } else if (outcome.status === `rejected`) { + oracleAssertion(`lifecycle history comparison at 1308`, () => + expect(outcome.error).toBe(failures.get(index)), + ) + } + } + }, }) + oracleAssertion(`lifecycle history comparison at 1313`, () => + expect(cut).toBe(4), + ) +} - const syncReplayScenarios = ([`truncate`, `restart`] as const).flatMap( - (transition) => - ([1, 2] as const).flatMap((ownerCount) => - ([false, true] as const).map((abortFirst) => { - const history: Array = [ - { type: `request`, demand: `a` }, - ...(ownerCount === 2 - ? ([{ type: `request`, demand: `b` }] as const) - : []), - ...(abortFirst ? ([{ type: `abort`, demand: `a` }] as const) : []), - ...(transition === `truncate` - ? ([{ type: `truncate` }] as const) - : ([{ type: `cleanup` }, { type: `restart` }] as const)), - ] - return { transition, ownerCount, abortFirst, history } - }), +async function runRequiredCoverage(): Promise { + const reach = new Set() + for (const history of [ + ...greenLifecycleHistories, + ...compoundLifecycleCoverageHistories, + ]) { + for (const label of await runHistory(history)) reach.add(label) + } + const commands = [ + `request`, + `abort`, + `release`, + `settle`, + `truncate`, + `cleanup`, + `restart`, + `unsubscribe`, + ] + const required = new Set([ + ...commands.map((type) => `command:${type}`), + ...commands.map((type) => `effective:${type}`), + ...commands.map((type) => `noop:${type}`), + `settle-scope:current`, + `settle-scope:obsolete`, + `settle-age:oldest`, + `settle-age:newest`, + `settle-outcome:resolve`, + `settle-outcome:reject`, + ...([`current`, `obsolete`] as const).flatMap((scope) => + ([`oldest`, `newest`] as const).flatMap((age) => + ([`resolve`, `reject`] as const).map( + (outcome) => `settle:${scope}:${age}:${outcome}`, + ), ), + ), + `attempt-sync-run:initial`, + `attempt-sync-run:restarted`, + `attempt-replay:initial`, + `attempt-replay:replayed`, + `attempt-location:initial:initial`, + `attempt-location:initial:replayed`, + `attempt-location:restarted:initial`, + `attempt-location:restarted:replayed`, + `duplicate-owner`, + `request-while-cleaned`, + `partial-generation-supersession`, + ]) + oracleAssertion(`lifecycle history comparison at 1365`, () => + expect([...required].filter((label) => !reach.has(label))).toEqual([]), ) +} - it.each(syncReplayScenarios)( - `settles queued synchronous $transition with $ownerCount owner(s), abort=$abortFirst`, - async ({ history }) => { - oracleRoute(`case`) - await runHistory(history, { acquisitionMode: `sync-success` }) - }, +async function runChangeRetirementCase(): Promise { + const reached = await runHistory(changeRetiresReplacementHistory) + oracleAssertion(`lifecycle history comparison at 1724`, () => + expect(reached).toContain(`change:between-releases-would-publish`), ) +} - it(`preserves acquisition lease ownership across queued synchronous replay`, async () => { - oracleRoute(`fixed history`) - await runHistory(syncLifecycleHistory, { - acquisitionMode: `sync-success`, - continueAfterMismatch: true, - }) +describe(`CollectionSubscription async lifecycle history oracle`, () => { + oracleProperty({ + id: `subscription-lifecycle.history-fixed`, + law: { + statement: `Logical demand, physical acquisition, caller outcomes, and replacement authority follow the independent lifecycle history.`, + authority: `The shared lifecycle grammar and this file's contract prose; Collection lifecycle and ARCHITECTURE laws 9 and 10.`, + limits: `Named regressions that the generated command grammars cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/subscription.ts`], + histories: lifecycleFixedHistories, + label: ({ name }) => name, + ...subscriptionComparison( + `subscription-lifecycle.history-fixed`, + runNamedHistory, + ), + check: forwardAssertionJudgment, }) - it.each([ - { - name: `same-key owners across truncate`, - history: [ - { type: `request`, demand: `a` }, - { type: `request`, demand: `a` }, - { type: `truncate` }, - { type: `release`, demand: `a` }, - { type: `release`, demand: `a` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `unsubscribe` }, - ], - }, - { - name: `same-key owners across restart`, - history: [ - { type: `request`, demand: `a` }, - { type: `request`, demand: `a` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `release`, demand: `a` }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ], + oracleProperty({ + id: `subscription-lifecycle.history-cases`, + law: { + statement: `Logical demand, physical acquisition, caller outcomes, and replacement authority follow the independent lifecycle history.`, + authority: `The shared lifecycle grammar and this file's contract prose; Collection lifecycle and ARCHITECTURE laws 9 and 10.`, + limits: `The bounded peer-failure, aborted-load, cancellation, aborted-replay, and synchronous-replay case tables; each is replayed through the same driver and comparison.`, }, - { - name: `aborted owner across repeated truncate`, - history: [ - { type: `request`, demand: `a` }, - { type: `abort`, demand: `a` }, - { type: `truncate` }, - { type: `truncate` }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ], + owns: [`src/collection/subscription.ts`], + histories: lifecycleCaseHistories, + label: ({ name }) => name, + ...subscriptionComparison( + `subscription-lifecycle.history-cases`, + runNamedHistory, + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subscription-lifecycle.caller-outcome`, + law: { + statement: `A caller receives the outcome of the acquisition attempt or publication wait it owns, as the independent lifecycle history predicts.`, + authority: `The shared lifecycle grammar and this file's caller-outcome contract.`, + limits: `Initial and cleanup-detached requests whose acquisition resolves or rejects; the calibrations corrupt the caller outcome and the cleanup path.`, }, - { - name: `detached last-owner abort across restart`, - history: [ - { type: `request`, demand: `a` }, - { type: `cleanup` }, - { type: `abort`, demand: `a` }, - { type: `restart` }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ], + owns: [`src/collection/subscription.ts`], + histories: callerOutcomeHistories, + label: ({ name }) => name, + ...subscriptionComparison( + callerOutcomeLaw, + runNamedHistory, + ), + check: forwardAssertionJudgment, + calibrations: [ + ...corruptedCallerOutcomeCalibrations, + cleanupFailureCalibration, + ], + }) + + oracleProperty({ + id: `subscription-lifecycle.detached-callers`, + law: { + statement: `Detached callers wait for their peer's replay until it finishes, as the independent lifecycle history predicts.`, + authority: `The shared lifecycle grammar and this file's caller-outcome contract.`, + limits: `The six ways the peer's replay finishes after one detached caller resolves.`, }, - ] satisfies ReadonlyArray<{ - name: string - history: ReadonlyArray - }>)( - `continues through the full synchronous replay suffix for $name`, - async ({ history }) => { - oracleRoute(`case`) - await runHistory(history, { - acquisitionMode: `sync-success`, - continueAfterMismatch: true, - }) + owns: [`src/collection/subscription.ts`], + histories: [ + `resolve`, + `reject`, + `release`, + `abort`, + `cleanup`, + `unsubscribe`, + ] as const, + label: (finish) => `holds detached callers for their peer until ${finish}`, + ...subscriptionComparison< + `resolve` | `reject` | `release` | `abort` | `cleanup` | `unsubscribe` + >(`subscription-lifecycle.detached-callers`, runDetachedCallerCase), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `subscription-lifecycle.retired-delivery`, + law: { + statement: `A retired acquisition attempt that settles after unsubscribe leaves terminal observations unchanged and delivers its own outcome.`, + authority: `The shared lifecycle grammar and this file's returned-promise contract.`, + limits: `The retired delivery cases that the generated grammar cannot select after unsubscribe.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: [`resolve`, `reject`] as const, + label: (outcome) => + `keeps terminal observations unchanged when a retired acquisition attempt ${outcome}s`, + ...subscriptionComparison<`resolve` | `reject`>( + `subscription-lifecycle.retired-delivery`, + runRetiredDeliveryCase, + ), + check: forwardAssertionJudgment, + }) - 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( - [ - { type: `request`, demand: `b` }, - { type: `truncate` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `request`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { - type: `settle`, - demand: `b`, - scope: `obsolete`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `release`, demand: `a` }, - { type: `release`, demand: `b` }, - { type: `unsubscribe` }, - ], - { continueAfterMismatch: true }, - ) + oracleProperty({ + id: `subscription-lifecycle.returned-results`, + law: { + statement: `Each returned acquisition promise settles only with its own attempt's outcome and keeps its identity.`, + authority: `The shared lifecycle grammar and this file's returned-promise contract.`, + limits: `Two concurrent demands whose first acquisition resolves or rejects.`, + }, + owns: [`src/collection/subscription.ts`], + histories: [`resolve`, `reject`] as const, + label: (firstOutcome) => + `settles only the corresponding returned result when the first acquisition ${firstOutcome}s`, + ...subscriptionComparison<`resolve` | `reject`>( + `subscription-lifecycle.returned-results`, + runReturnedResultsCase, + ), + check: forwardAssertionJudgment, }) - 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( - [ - { type: `request`, demand: `b` }, - { type: `cleanup` }, - { type: `restart` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, - { type: `request`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `release`, demand: `a` }, - { type: `release`, demand: `b` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `unsubscribe` }, - ], - { continueAfterMismatch: true }, - ) + oracleProperty({ + id: `subscription-lifecycle.required-coverage`, + law: { + statement: `The canonical green and compound lifecycle histories refine the independent model and together reach every required command and cross-phase transition.`, + authority: `The shared lifecycle grammar and this file's contract prose.`, + limits: `The canonical green and compound coverage histories, replayed in order through the shared driver.`, + }, + owns: [`src/collection/subscription.ts`], + histories: [ + `covers every required command and cross-phase transition`, + ] as const, + label: (name) => name, + ...subscriptionComparison( + `subscription-lifecycle.required-coverage`, + runRequiredCoverage, + ), + check: forwardAssertionJudgment, }) // Law 9: a demand change is one step. The states below are where judging @@ -1837,13 +2059,16 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { const demandChangeReached = new Set() // Preserve the old aggregate minimum-one census across the same campaigns. // Native determinism probes and the added constructed random campaign do - // not supply reach to the original campaigns' aggregate gate. + // not supply reach to the original campaigns' aggregate gate. The named + // replay-failed histories counted as plain tests before they became an + // enumerated property, so their property still counts. let countingCampaign = true beforeEach(({ task }) => { countingCampaign = (task.name === `random seed` && task.suite?.name !== `subscription-lifecycle.constructed-history`) || task.name.startsWith(`fixed seed `) || + task.suite?.name === `subscription-lifecycle.replay-failed` || !task.suite?.name.startsWith(`subscription-lifecycle.`) }) let demandChangeHistoriesRan = false @@ -1868,63 +2093,23 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { if (reached.has(label)) demandChangeReached.add(state) } - 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) - oracleAssertion(`lifecycle history comparison at 1724`, () => - expect(reached).toContain(`change:between-releases-would-publish`), - ) - }) - - // Two owners across a truncate: each replacement load judges the attempts - // this command's replay replaces. These replay the shared driver and model. - it.each([ - { - name: `an aborted peer and a pending owner`, - history: [ - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `abort`, demand: `a` }, - { type: `truncate` }, - ], - }, - { - name: `an abort after the first replay`, - history: [ - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `truncate` }, - { type: `abort`, demand: `a` }, - { type: `truncate` }, - ], - }, - ] satisfies Array<{ name: string; history: Array }>)( - `replaces each owner's pending attempt before its replacement loads for $name`, - async ({ history }) => { - oracleRoute(`fixed history`) - await runHistory(history) - await runHistory(history, { cancellation: `reject` }) + oracleProperty({ + id: `subscription-lifecycle.change-retirement`, + law: { + statement: `A demand change is one step: the replay is judged once, after the change that retires a replacement and a pending owner.`, + authority: `ARCHITECTURE law 9 and the shared lifecycle grammar.`, + limits: `The one named change that retires a held replacement and a pending owner.`, }, - ) - - // 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` }, - { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, - { type: `replace`, demand: `b`, replaced: `a` }, - { type: `truncate` }, - settle(`b`, `current`, `oldest`, `resolve`), - settle(`c`, `current`, `oldest`, `resolve`), - { type: `abort`, demand: `a` }, - { type: `request`, demand: `a` }, - ], - { cancellation: `reject` }, - ) + owns: [`src/collection/subscription.ts`], + histories: [ + `judges a replay once after a change that retires a replacement and a pending owner`, + ] as const, + label: (name) => name, + ...subscriptionComparison( + `subscription-lifecycle.change-retirement`, + runChangeRetirementCase, + ), + check: forwardAssertionJudgment, }) // An abort ends the load (maintainer decision), so an aborted @@ -1933,7 +2118,7 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { // retire established coverage). The replaced owner's failure stays, and // the replay keeps its publication private. 30-times counterexamples of // production, which counted the aborted replacement as covering. - it.each([ + const replayFailedHistories = [ { name: `a replacement aborted after its replaced owner failed`, history: [ @@ -1962,13 +2147,23 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { { type: `request`, demand: `a` }, ], }, - ] satisfies Array<{ name: string; history: Array }>)( - `keeps a replay failed when $name`, - async ({ history }) => { - oracleRoute(`case`) - await runDemandChangeHistory(history) + ] satisfies Array<{ name: string; history: Array }> + oracleProperty({ + id: `subscription-lifecycle.replay-failed`, + law: { + statement: `An aborted replacement covers none of the owners it replaces, so the replaced owner's failure keeps the replay failed.`, + authority: `ARCHITECTURE law 10, the maintainer decision that an abort ends the load, and the shared lifecycle grammar.`, + limits: `The two named aborted-replacement histories; they also feed the demand-change and lifecycle-row reach census above.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: replayFailedHistories, + label: ({ name }) => `keeps a replay failed when ${name}`, + ...subscriptionComparison<(typeof replayFailedHistories)[number]>( + `subscription-lifecycle.replay-failed`, + ({ history }) => runDemandChangeHistory(history), + ), + check: forwardAssertionJudgment, + }) const cancellationArbitrary = fc.constantFrom( `manual` as const, diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index d526f504b1..d14c7824ab 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -1,5 +1,5 @@ import { fc } from '@fast-check/vitest' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { BTreeIndex } from '../src/indexes/btree-index.js' @@ -12,9 +12,7 @@ import { import { oraclePropertyOptions, oracleReplayRequested, - oracleRoute, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, @@ -28,26 +26,6 @@ import { subscriberResumeRetirementHistories, subscriberStartCleanupHistories, } from './collection-subscription-lifecycle-grammar-oracle.js' -import { - acquisitionCellDefinitions, - acquisitionEntries, - acquisitionInteractionCauses, - acquisitionInteractionCellDefinitions, - acquisitionLifecycleStates, - acquisitionPhases, - excludedAcquisitionCells, - failureScenarios, - legalAcquisitionCells, - releaseScenarios, - restartScenarios, - startOutcomes, - startReentries, - startScenarios, -} from './collection-subscription-lifecycle-cell-support.js' -import type { - AcquisitionCell, - AcquisitionInteractionCell, -} from './collection-subscription-lifecycle-cell-support.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' /** @@ -61,9 +39,9 @@ import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' * * The cell witnesses live in collection-subscription-lifecycle-cells.test.ts. * Each case there declares the cells it may reach, and the coverage audit in - * that file fails if a legal cell was never observed. This file keeps the - * grammar calibrations, the model checks, and the generated properties that - * run the scenario drivers. Counts, signals, errors, readiness, owners, and + * that file fails if a legal cell was never observed, and that file also + * checks the size of the cell table and scenario grammar. This file keeps the + * model checks and the properties that run the scenario drivers. Counts, signals, errors, readiness, owners, and * rows are checked separately. * * Release law: an `unloadSubset` that throws is an adapter bug. The @@ -812,37 +790,13 @@ function expectedThreeGenerationSettlement( } } -declareOracle({ - check: [runAsyncRestartScenario], - model: [ - modelSubscriberResumeRetirement, - startupRetirementModel, - expectedUnloadsAfterUnavailableWait, - expectedLoadsAfterCleanupEndsWait, - expectedStatusAfterQueuedCancel, - expectedUncaughtReleaseErrors, - expectedSubscriberCountAfterRelease, - expectedThreeGenerationSettlement, - ], - grammar: [ - acquisitionCellDefinitions, - acquisitionInteractionCellDefinitions, - startScenarios, - failureScenarios, - releaseScenarios, - restartScenarios, - asyncRestartScenarioArbitrary, - ], -}) - /** * Exhaust the synchronous adapter-start boundary before adding more runtime * special cases. Logical demand is visible during this callback, but a * physical acquisition and its lease exist only if the callback returns. */ describe(`CollectionSubscription demand lifecycle oracle`, () => { - it(`executes every required async restart regime`, async () => { - oracleRoute(`case`) + const runRequiredAsyncRestartRegimes = async () => { const reach = new Set() for (const scenario of asyncRestartCoverageScenarios) { for (const label of await runAsyncRestartScenario(scenario)) { @@ -867,212 +821,185 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { oracleAssertion('existing comparison at 818', () => expect(required.filter((label) => !reach.has(label))).toEqual([]), ) - }) - - it(`covers every finite start, failure-delivery, and release cell`, () => { - oracleRoute(`case`) - oracleAssertion('existing comparison at 823', () => - expect( - new Set( - startScenarios.map(({ outcome, reentry }) => `${outcome}:${reentry}`), - ), - ).toHaveLength(startOutcomes.length * startReentries.length), - ) - oracleAssertion('existing comparison at 828', () => - expect( - new Set( - failureScenarios.map( - ({ outcome, reentry }) => `${outcome}:${reentry}`, - ), - ), - ).toHaveLength(2 * startReentries.length), - ) - oracleAssertion('existing comparison at 833', () => - expect( - new Set( - releaseScenarios.map( - ({ outcome, reentry }) => `${outcome}:${reentry}`, - ), - ), - ).toHaveLength(2 * 4), - ) - oracleAssertion('existing comparison at 838', () => - expect( - new Set( - restartScenarios.map( - ({ outcome, reentry }) => `${outcome}:${reentry}`, - ), - ), - ).toHaveLength(4 * 5), - ) - }) - - 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), - ), - ) - oracleAssertion('existing comparison at 852', () => - expect( - new Set([...legalAcquisitionCells, ...excludedAcquisitionCells.keys()]), - ).toEqual(allCells), - ) - }) - - it(`accounts for every acquisition lifecycle state and interaction cause`, () => { - oracleRoute(`case`) - const allCells = new Set( - acquisitionLifecycleStates.flatMap((state) => - acquisitionInteractionCauses.map( - (cause) => `${state}:${cause}` as const, - ), + } + oracleProperty({ + id: `subscription-lifecycle.async-restart-regimes`, + law: { + statement: `Demand settlements are fenced by the sync run that owns their physical acquisition, and the async restart coverage scenarios together execute every required regime.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the lifecycle grammar's existing model`, + limits: `The four async restart coverage scenarios, replayed in order through the shared driver.`, + }, + owns: [`src/collection/subscription.ts`], + histories: [`executes every required async restart regime`] as const, + label: (name) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.async-restart-regimes`, + plan, + observe, + runRequiredAsyncRestartRegimes, ), - ) - oracleAssertion('existing comparison at 866', () => - expect( - new Set(Object.keys(acquisitionInteractionCellDefinitions)), - ).toEqual(allCells), - ) + check: forwardAssertionJudgment, }) - it.each( - ([false, true] as const).flatMap((restart) => - ( - [`loading`, `ready`, `adapter-throw`, `ready-effect-throw`] as const - ).map((entry) => ({ restart, entry })), + const startupRetirementCases = ([false, true] as const).flatMap((restart) => + ([`loading`, `ready`, `adapter-throw`, `ready-effect-throw`] as const).map( + (entry) => ({ restart, entry }), ), - )( - `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 = [] - const unloads: Array = [] - let syncRunGeneration = -1 - let retire = false - let nestedRestartError: unknown - const collection = createOnDemandCollection<{ id: string }>({ - sync: { - sync: ({ markReady }) => { - const ownSyncRunGeneration = ++syncRunGeneration - markReady() - if (ownSyncRunGeneration === 1 && entry === `adapter-throw`) { - throw failure - } - return { - loadSubset: () => { - loads.push(ownSyncRunGeneration) - return true - }, - unloadSubset: () => unloads.push(ownSyncRunGeneration), - cleanup: () => { - cleanups.push(ownSyncRunGeneration) - }, - } - }, + ) + const runStartupRetirementCase = async ({ + entry, + restart, + }: (typeof startupRetirementCases)[number]) => { + const failure = new Error(`obsolete startup failed`) + const cleanups: Array = [] + const loads: Array = [] + const unloads: Array = [] + let syncRunGeneration = -1 + let retire = false + let nestedRestartError: unknown + const collection = createOnDemandCollection<{ id: string }>({ + sync: { + sync: ({ markReady }) => { + const ownSyncRunGeneration = ++syncRunGeneration + markReady() + if (ownSyncRunGeneration === 1 && entry === `adapter-throw`) { + throw failure + } + return { + loadSubset: () => { + loads.push(ownSyncRunGeneration) + return true + }, + unloadSubset: () => unloads.push(ownSyncRunGeneration), + cleanup: () => { + cleanups.push(ownSyncRunGeneration) + }, + } }, - }) - const subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - let removeListener = () => {} - try { - await collection.cleanup() - const retireSyncRun = () => { - if (!retire) return - retire = false - void collection.cleanup() - if (restart) { - try { - collection.startSyncImmediate() - } catch (error) { - nestedRestartError = error - } + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + let removeListener = () => {} + try { + await collection.cleanup() + const retireSyncRun = () => { + if (!retire) return + retire = false + void collection.cleanup() + if (restart) { + try { + collection.startSyncImmediate() + } catch (error) { + nestedRestartError = error } - if (entry === `ready-effect-throw`) throw failure - } - removeListener = - entry === `ready-effect-throw` - ? collection.onFirstReady(retireSyncRun) - : collection.on( - entry === `loading` ? `status:loading` : `status:ready`, - retireSyncRun, - ) - retire = true - if (entry === `adapter-throw` || entry === `ready-effect-throw`) { - oracleAssertion('existing comparison at 937', () => - expect(() => collection.startSyncImmediate()).toThrow(failure), - ) - } else { - collection.startSyncImmediate() - } - await flushPromises() - - const { - cleanupStartedDuringSyncEntry, - restartSucceeded, - returnsObsoleteCleanup, - } = startupRetirementModel(entry, restart) - if (restart && cleanupStartedDuringSyncEntry) { - oracleAssertion('existing comparison at 949', () => - expect(nestedRestartError).toMatchObject({ - name: `CollectionStateError`, - message: expect.stringContaining(`after cleanup() completes`), - }), - ) - } else { - oracleAssertion('existing comparison at 954', () => - expect(nestedRestartError).toBeUndefined(), - ) } - oracleAssertion('existing comparison at 956', () => - expect(collection.status).toBe( - restartSucceeded ? `ready` : `cleaned-up`, - ), + if (entry === `ready-effect-throw`) throw failure + } + removeListener = + entry === `ready-effect-throw` + ? collection.onFirstReady(retireSyncRun) + : collection.on( + entry === `loading` ? `status:loading` : `status:ready`, + retireSyncRun, + ) + retire = true + if (entry === `adapter-throw` || entry === `ready-effect-throw`) { + oracleAssertion('existing comparison at 937', () => + expect(() => collection.startSyncImmediate()).toThrow(failure), ) - oracleAssertion('existing comparison at 959', () => - expect(cleanups).toEqual(returnsObsoleteCleanup ? [0, 1] : [0]), + } else { + collection.startSyncImmediate() + } + await flushPromises() + + const { + cleanupStartedDuringSyncEntry, + restartSucceeded, + returnsObsoleteCleanup, + } = startupRetirementModel(entry, restart) + if (restart && cleanupStartedDuringSyncEntry) { + oracleAssertion('existing comparison at 949', () => + expect(nestedRestartError).toMatchObject({ + name: `CollectionStateError`, + message: expect.stringContaining(`after cleanup() completes`), + }), ) - oracleAssertion('existing comparison at 960', () => - expect(syncRunGeneration).toBe( - entry === `loading` ? (restart ? 1 : 0) : 1, - ), + } else { + oracleAssertion('existing comparison at 954', () => + expect(nestedRestartError).toBeUndefined(), ) + } + oracleAssertion('existing comparison at 956', () => + expect(collection.status).toBe( + restartSucceeded ? `ready` : `cleaned-up`, + ), + ) + oracleAssertion('existing comparison at 959', () => + expect(cleanups).toEqual(returnsObsoleteCleanup ? [0, 1] : [0]), + ) + oracleAssertion('existing comparison at 960', () => + expect(syncRunGeneration).toBe( + entry === `loading` ? (restart ? 1 : 0) : 1, + ), + ) - if (restartSucceeded) { - subscription.requestSnapshot({ - where: new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]), - }) - oracleAssertion('existing comparison at 968', () => - expect(loads).toEqual([syncRunGeneration]), - ) - subscription.unsubscribe() - oracleAssertion('existing comparison at 970', () => - expect(unloads).toEqual([syncRunGeneration]), - ) - } else { - oracleAssertion('existing comparison at 972', () => - expect(loads).toEqual([]), - ) - oracleAssertion('existing comparison at 973', () => - expect(unloads).toEqual([]), - ) - } - } finally { - removeListener() + if (restartSucceeded) { + subscription.requestSnapshot({ + where: new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]), + }) + oracleAssertion('existing comparison at 968', () => + expect(loads).toEqual([syncRunGeneration]), + ) subscription.unsubscribe() - await collection.cleanup() + oracleAssertion('existing comparison at 970', () => + expect(unloads).toEqual([syncRunGeneration]), + ) + } else { + oracleAssertion('existing comparison at 972', () => + expect(loads).toEqual([]), + ) + oracleAssertion('existing comparison at 973', () => + expect(unloads).toEqual([]), + ) } + } finally { + removeListener() + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.startup-retirement`, + law: { + statement: `Startup retirement follows the independent startup model: a nested restart succeeds only when no cleanup started during the sync entry, and a ready entry returns the obsolete sync run's cleanup resource.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and startupRetirementModel.`, + limits: `The four sync entry points, with and without a nested restart.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: startupRetirementCases, + label: (row) => + `retires startup at ${row.entry} with nested restart=${row.restart}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.startup-retirement`, + plan, + observe, + () => runStartupRetirementCase(plan.history), + ), + check: forwardAssertionJudgment, + }) // A cleaned-up attempt recovers with a restart. An errored attempt cannot // recover in place; the test above owns its demand. - it.each( - ([`cleaned-up`] as const).flatMap((unavailable) => + const unavailableDemandCases = ([`cleaned-up`] as const).flatMap( + (unavailable) => ( [ `return`, @@ -1099,640 +1026,743 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ).map((entry) => ({ unavailable, outcome, phase, entry })), ), ), - ), - )( - `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`) - const signal = new AbortController() - const loads: Array = [] - const unloads: Array = [] - const rows = new Map() - let operations!: Parameters[`sync`]>[0] - const collection = createOnDemandCollection<{ id: string }>({ - sync: { - sync: (next) => { - operations = next - return { - loadSubset: (options) => { - loads.push(options) - if (outcome === `throw`) throw failure - operations.begin() - operations.write({ type: `insert`, value: { id: `row` } }) - operations.commit() - return outcome === `return` ? true : transport.promise - }, - unloadSubset: (options) => unloads.push(options), - } - }, - }, - }) - const subscription = collection.subscribeChanges( - (changes) => { - for (const change of changes) { - if (change.type === `delete`) rows.delete(change.key) - else rows.set(change.key, change.value) + ) + const runUnavailableDemandCase = async ({ + outcome, + phase, + entry, + }: (typeof unavailableDemandCases)[number]) => { + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) + const transport = createDeferred() + const failure = new Error(`recovery acquisition failed`) + const signal = new AbortController() + const loads: Array = [] + const unloads: Array = [] + const rows = new Map() + let operations!: Parameters[`sync`]>[0] + const collection = createOnDemandCollection<{ id: string }>({ + sync: { + sync: (next) => { + operations = next + return { + loadSubset: (options) => { + loads.push(options) + if (outcome === `throw`) throw failure + operations.begin() + operations.write({ type: `insert`, value: { id: `row` } }) + operations.commit() + return outcome === `return` ? true : transport.promise + }, + unloadSubset: (options) => unloads.push(options), } }, - { includeInitialState: false }, + }, + }) + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) rows.delete(change.key) + else rows.set(change.key, change.value) + } + }, + { includeInitialState: false }, + ) + let result: true | Promise | undefined + let release: (() => void) | undefined + const settlements: Array = [] + const visibleOnSuccess: Array> = [] + let callbacks = 0 + try { + if (entry === `limited`) { + subscription.setOrderByIndex( + collection.createIndex((row) => row.id, { indexType: BTreeIndex }), + ) + } + await collection.cleanup() + const onLoadSubsetResult = ( + value: true | Promise, + _options: LoadSubsetOptions, + releaseDemand?: () => void, + ) => { + callbacks++ + result = value + release = releaseDemand + if (value instanceof Promise) + void value.then( + () => { + visibleOnSuccess.push([...rows.values()].map(({ id }) => id)) + settlements.push(`success`) + }, + (error: unknown) => settlements.push(error), + ) + } + if (entry === `snapshot`) { + subscription.requestSnapshot({ + where, + signal: signal.signal, + onLoadSubsetResult, + }) + } else { + subscription.requestLimitedSnapshot({ + orderBy: [ + { + expression: new PropRef([`id`]), + compareOptions: { direction: `asc`, nulls: `first` }, + }, + ], + limit: 1, + onLoadSubsetResult, + }) + } + // Production callers copy the result as soon as requestSnapshot returns. + oracleAssertion('existing comparison at 1101', () => + expect(callbacks).toBe(1), ) - let result: true | Promise | undefined - let release: (() => void) | undefined - const settlements: Array = [] - const visibleOnSuccess: Array> = [] - let callbacks = 0 - try { - if (entry === `limited`) { - subscription.setOrderByIndex( - collection.createIndex((row) => row.id, { indexType: BTreeIndex }), + oracleAssertion('existing comparison at 1102', () => + expect(result).toBeInstanceOf(Promise), + ) + await flushPromises() + oracleAssertion('existing comparison at 1104', () => + expect(settlements).toEqual([]), + ) + oracleAssertion('existing comparison at 1105', () => + expect(loads).toEqual([]), + ) + const recover = () => { + collection.startSyncImmediate() + operations.markReady() + } + if (phase === `during`) { + recover() + await flushPromises() + oracleAssertion('existing comparison at 1113', () => + expect(loads).toHaveLength(1), + ) + if (outcome !== `return` && outcome !== `throw`) { + oracleAssertion('existing comparison at 1115', () => + expect(settlements).toEqual([]), + ) + oracleAssertion('existing comparison at 1116', () => + expect([...rows.values()]).toEqual([]), ) } - await collection.cleanup() - const onLoadSubsetResult = ( - value: true | Promise, - _options: LoadSubsetOptions, - releaseDemand?: () => void, - ) => { - callbacks++ - result = value - release = releaseDemand - if (value instanceof Promise) - void value.then( - () => { - visibleOnSuccess.push([...rows.values()].map(({ id }) => id)) - settlements.push(`success`) - }, - (error: unknown) => settlements.push(error), - ) - } - if (entry === `snapshot`) { - subscription.requestSnapshot({ - where, - signal: signal.signal, - onLoadSubsetResult, - }) - } else { - subscription.requestLimitedSnapshot({ - orderBy: [ - { - expression: new PropRef([`id`]), - compareOptions: { direction: `asc`, nulls: `first` }, - }, - ], - limit: 1, - onLoadSubsetResult, - }) - } - // Production callers copy the result as soon as requestSnapshot returns. - oracleAssertion('existing comparison at 1101', () => - expect(callbacks).toBe(1), + } + if (outcome === `release`) release!() + else if (outcome === `unsubscribe`) subscription.unsubscribe() + else if (outcome === `cleanup`) await collection.cleanup() + else if (outcome === `abort`) signal.abort() + else if (outcome === `reject`) transport.reject(failure) + else if (outcome === `resolve`) transport.resolve() + await flushPromises() + if (outcome === `return` || outcome === `resolve`) { + oracleAssertion('existing comparison at 1127', () => + expect(settlements).toEqual([`success`]), ) - oracleAssertion('existing comparison at 1102', () => - expect(result).toBeInstanceOf(Promise), + oracleAssertion('existing comparison at 1128', () => + expect(visibleOnSuccess).toEqual([[`row`]]), ) - await flushPromises() - oracleAssertion('existing comparison at 1104', () => - expect(settlements).toEqual([]), + oracleAssertion('existing comparison at 1129', () => + expect([...rows.values()].map(({ id }) => id)).toEqual([`row`]), ) - oracleAssertion('existing comparison at 1105', () => - expect(loads).toEqual([]), + } else if (outcome === `throw` || outcome === `reject`) { + oracleAssertion('existing comparison at 1131', () => + expect(settlements).toHaveLength(1), ) - const recover = () => { - collection.startSyncImmediate() - operations.markReady() - } - if (phase === `during`) { + oracleAssertion('existing comparison at 1132', () => + expect(settlements[0]).toBe(failure), + ) + oracleAssertion('existing comparison at 1133', () => + expect([...rows.values()]).toEqual([]), + ) + } else { + oracleAssertion('existing comparison at 1135', () => + expect(settlements).toEqual([ + expect.objectContaining({ name: `AbortError` }), + ]), + ) + oracleAssertion('existing comparison at 1138', () => + expect(unloads).toEqual( + expectedUnloadsAfterUnavailableWait(phase, outcome, loads), + ), + ) + if (phase === `before` && outcome !== `cleanup`) { recover() await flushPromises() - oracleAssertion('existing comparison at 1113', () => - expect(loads).toHaveLength(1), + oracleAssertion('existing comparison at 1144', () => + expect(loads).toEqual([]), ) - if (outcome !== `return` && outcome !== `throw`) { - oracleAssertion('existing comparison at 1115', () => - expect(settlements).toEqual([]), - ) - oracleAssertion('existing comparison at 1116', () => - expect([...rows.values()]).toEqual([]), - ) - } } - if (outcome === `release`) release!() - else if (outcome === `unsubscribe`) subscription.unsubscribe() - else if (outcome === `cleanup`) await collection.cleanup() - else if (outcome === `abort`) signal.abort() - else if (outcome === `reject`) transport.reject(failure) - else if (outcome === `resolve`) transport.resolve() - await flushPromises() - if (outcome === `return` || outcome === `resolve`) { - oracleAssertion('existing comparison at 1127', () => - expect(settlements).toEqual([`success`]), - ) - oracleAssertion('existing comparison at 1128', () => - expect(visibleOnSuccess).toEqual([[`row`]]), - ) - oracleAssertion('existing comparison at 1129', () => - expect([...rows.values()].map(({ id }) => id)).toEqual([`row`]), - ) - } else if (outcome === `throw` || outcome === `reject`) { - oracleAssertion('existing comparison at 1131', () => - expect(settlements).toHaveLength(1), - ) - oracleAssertion('existing comparison at 1132', () => - expect(settlements[0]).toBe(failure), - ) - oracleAssertion('existing comparison at 1133', () => - expect([...rows.values()]).toEqual([]), - ) - } else { - oracleAssertion('existing comparison at 1135', () => - expect(settlements).toEqual([ - expect.objectContaining({ name: `AbortError` }), - ]), - ) - oracleAssertion('existing comparison at 1138', () => - expect(unloads).toEqual( - expectedUnloadsAfterUnavailableWait(phase, outcome, loads), - ), - ) - if (phase === `before` && outcome !== `cleanup`) { - recover() - await flushPromises() - oracleAssertion('existing comparison at 1144', () => - expect(loads).toEqual([]), - ) - } - if (outcome === `cleanup`) { - // Cleanup ends this wait, not the surviving subscription's demand. - collection.startSyncImmediate() - operations.markReady() - await flushPromises() - oracleAssertion('existing comparison at 1151', () => - expect(loads).toHaveLength( - expectedLoadsAfterCleanupEndsWait(phase), - ), - ) - } - // Non-cooperative late settlement cannot rewrite the observed outcome. - transport.resolve() + if (outcome === `cleanup`) { + // Cleanup ends this wait, not the surviving subscription's demand. + collection.startSyncImmediate() + operations.markReady() await flushPromises() - oracleAssertion('existing comparison at 1156', () => - expect(settlements).toEqual([ - expect.objectContaining({ name: `AbortError` }), - ]), + oracleAssertion('existing comparison at 1151', () => + expect(loads).toHaveLength( + expectedLoadsAfterCleanupEndsWait(phase), + ), ) } - oracleAssertion('existing comparison at 1160', () => - expect(callbacks).toBe(1), - ) - } finally { + // Non-cooperative late settlement cannot rewrite the observed outcome. transport.resolve() - subscription.unsubscribe() - await collection.cleanup() - } - }, - ) - - it.each( - ([`loading`, `ready`] as const).flatMap((phase) => - ([`cleanup`, `release`, `unsubscribe`] as const).map((action) => ({ - phase, - action, - })), - ), - )( - `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`)]) - let cancelOnEntry = false - const observed: Array> = [] - const collection = createOnDemandCollection<{ id: string }>({ - id: `deferred-resume-cleanup`, - startSync: false, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: (options) => { - loads.push(options) - return true - }, - unloadSubset: (options) => unloads.push(options), - } - }, - }, - }) - oracleAssertion('existing comparison at 1201', () => - expect(collection._deferSyncStart()).toBe(true), - ) - const subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - deferAcquisition: true, - }) - const removeReadyListener = collection.on(`status:${phase}`, () => { - if (!cancelOnEntry) return - cancelOnEntry = false - if (action === `cleanup`) void collection.cleanup() - else if (action === `release`) subscription.releaseSnapshot(where) - else subscription.unsubscribe() - }) - subscription.requestSnapshot({ - where, - onLoadSubsetResult: (result) => observed.push(result), - }) - - try { - cancelOnEntry = true - subscription.resumeDeferredAcquisition() await flushPromises() - - oracleAssertion('existing comparison at 1223', () => - expect(collection.status).toBe( - expectedStatusAfterQueuedCancel(action), - ), - ) - oracleAssertion('existing comparison at 1224', () => - expect(loads).toHaveLength(0), - ) - oracleAssertion('existing comparison at 1225', () => - expect(unloads).toHaveLength(0), - ) - oracleAssertion('existing comparison at 1226', () => - expect(observed).toHaveLength(1), + oracleAssertion('existing comparison at 1156', () => + expect(settlements).toEqual([ + expect.objectContaining({ name: `AbortError` }), + ]), ) - await oracleAssertion('existing comparison at 1227', () => - expect(observed[0]).rejects.toMatchObject({ name: `AbortError` }), - ) - } finally { - removeReadyListener() - subscription.unsubscribe() - await collection.cleanup() } + oracleAssertion('existing comparison at 1160', () => + expect(callbacks).toBe(1), + ) + } finally { + transport.resolve() + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.unavailable-demand`, + law: { + statement: `Demand that waits on an unavailable sync run is observed synchronously, and only a release or unsubscribe during recovery retires the physical acquisition that recovery started.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and the unavailable-wait model.`, + limits: `A cleaned-up sync run, the bounded recovery outcomes and phases, and the snapshot and limited entries.`, }, - ) - - it.each( - ([`return`, `throw`] as const).flatMap((outcome) => - [false, true].map((releaseSelf) => ({ outcome, releaseSelf })), - ), - )( - `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`) - const loads: Array = [] - const unloads: Array = [] - const errors: Array = [] - const uncaught = captureMicrotaskErrors() - let releaseOwner = () => {} - let operations!: Parameters[`sync`]>[0] - const collection = createCollection<{ id: string }, string>({ - id: `aborted-replay-release`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - sync: { - sync: (nextOperations) => { - operations = nextOperations - operations.markReady() - return { - loadSubset: (options) => { - loads.push(options) - return true - }, - unloadSubset: (options) => { - unloads.push(options) - if (unloads.length === 1) { - if (releaseSelf) releaseOwner() - if (outcome === `throw`) throw failure - } - }, - } - }, + owns: [`src/collection/subscription.ts`], + histories: unavailableDemandCases, + label: (row) => + `observes unavailable demand synchronously: ${row.entry} / ${row.unavailable} / ${row.outcome} / ${row.phase}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.unavailable-demand`, + plan, + observe, + () => runUnavailableDemandCase(plan.history), + ), + check: forwardAssertionJudgment, + }) + + const queuedCancelCases = ([`loading`, `ready`] as const).flatMap((phase) => + ([`cleanup`, `release`, `unsubscribe`] as const).map((action) => ({ + phase, + action, + })), + ) + const runQueuedCancelCase = async ({ + phase, + action, + }: (typeof queuedCancelCases)[number]) => { + const loads: Array = [] + const unloads: Array = [] + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) + let cancelOnEntry = false + const observed: Array> = [] + const collection = createOnDemandCollection<{ id: string }>({ + id: `deferred-resume-cleanup`, + startSync: false, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => unloads.push(options), + } }, - }) - const subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - releaseOwner = () => subscription.releaseSnapshot(where) - subscription.on(`loadSubset:error`, ({ error }) => errors.push(error)) - try { - subscription.requestSnapshot({ where, signal: controller.signal }) - controller.abort() - operations.begin() - operations.truncate() - const receipt = operations.commit() - if (receipt !== true) await receipt - await flushPromises() + }, + }) + oracleAssertion('existing comparison at 1201', () => + expect(collection._deferSyncStart()).toBe(true), + ) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + deferAcquisition: true, + }) + const removeReadyListener = collection.on(`status:${phase}`, () => { + if (!cancelOnEntry) return + cancelOnEntry = false + if (action === `cleanup`) void collection.cleanup() + else if (action === `release`) subscription.releaseSnapshot(where) + else subscription.unsubscribe() + }) + subscription.requestSnapshot({ + where, + onLoadSubsetResult: (result) => observed.push(result), + }) - oracleAssertion('existing comparison at 1291', () => - expect(loads).toHaveLength(1), - ) - oracleAssertion('existing comparison at 1292', () => - expect(unloads).toHaveLength(1), - ) - oracleAssertion('existing comparison at 1293', () => - expect(unloads[0]).toBe(loads[0]), - ) - oracleAssertion('existing comparison at 1294', () => - expect(loads[0]!.signal!.aborted).toBe(true), - ) - oracleAssertion('existing comparison at 1295', () => - expect(subscription.status).toBe(`ready`), - ) - // The release error is reported uncaught, not as a load error. - oracleAssertion('existing comparison at 1297', () => - expect(errors).toEqual([]), - ) - oracleAssertion('existing comparison at 1298', () => - expect(uncaught.errors).toEqual( - expectedUncaughtReleaseErrors(outcome, failure), - ), - ) + try { + cancelOnEntry = true + subscription.resumeDeferredAcquisition() + await flushPromises() - releaseOwner() - oracleAssertion('existing comparison at 1303', () => - expect(unloads).toHaveLength(1), - ) - subscription.unsubscribe() - oracleAssertion('existing comparison at 1305', () => - expect(unloads).toHaveLength(1), - ) - for (const options of unloads) - oracleAssertion('existing comparison at 1306', () => - expect(options).toBe(loads[0]), - ) - oracleAssertion('existing comparison at 1307', () => - expect(loads).toHaveLength(1), - ) - await Promise.resolve() - oracleAssertion('existing comparison at 1309', () => - expect(uncaught.errors).toEqual( - expectedUncaughtReleaseErrors(outcome, failure), - ), - ) - } finally { - uncaught.restore() - subscription.unsubscribe() - await collection.cleanup() - } + oracleAssertion('existing comparison at 1223', () => + expect(collection.status).toBe(expectedStatusAfterQueuedCancel(action)), + ) + oracleAssertion('existing comparison at 1224', () => + expect(loads).toHaveLength(0), + ) + oracleAssertion('existing comparison at 1225', () => + expect(unloads).toHaveLength(0), + ) + oracleAssertion('existing comparison at 1226', () => + expect(observed).toHaveLength(1), + ) + await oracleAssertion('existing comparison at 1227', () => + expect(observed[0]).rejects.toMatchObject({ name: `AbortError` }), + ) + } finally { + removeReadyListener() + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.queued-cancel`, + law: { + statement: `Cancelling a queued acquisition starts no physical acquisition; cleanup leaves the Collection cleaned up, and release or unsubscribe leaves it ready.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and the queued-cancel model.`, + limits: `Loading and ready phases crossed with cleanup, release, and unsubscribe.`, }, + owns: [`src/collection/subscription.ts`], + histories: queuedCancelCases, + label: (row) => + `cancels queued acquisition during ${row.phase} via ${row.action}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.queued-cancel`, + plan, + observe, + () => runQueuedCancelCase(plan.history), + ), + check: forwardAssertionJudgment, + }) + + const abortedReplayReleaseCases = ([`return`, `throw`] as const).flatMap( + (outcome) => [false, true].map((releaseSelf) => ({ outcome, releaseSelf })), ) + const runAbortedReplayReleaseCase = async ({ + outcome, + releaseSelf, + }: (typeof abortedReplayReleaseCases)[number]) => { + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) + const controller = new AbortController() + const failure = new Error(`release failed`) + const loads: Array = [] + const unloads: Array = [] + const errors: Array = [] + const uncaught = captureMicrotaskErrors() + let releaseOwner = () => {} + let operations!: Parameters[`sync`]>[0] + const collection = createCollection<{ id: string }, string>({ + id: `aborted-replay-release`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (nextOperations) => { + operations = nextOperations + operations.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (unloads.length === 1) { + if (releaseSelf) releaseOwner() + if (outcome === `throw`) throw failure + } + }, + } + }, + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + releaseOwner = () => subscription.releaseSnapshot(where) + subscription.on(`loadSubset:error`, ({ error }) => errors.push(error)) + try { + subscription.requestSnapshot({ where, signal: controller.signal }) + controller.abort() + operations.begin() + operations.truncate() + const receipt = operations.commit() + if (receipt !== true) await receipt + await flushPromises() - it.each( - ([`adapter`, `error-listener`] as const).flatMap((reentry) => - [1, 2].map((failures) => ({ reentry, failures })), - ), - )( - `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 = [] - const unloads: Array = [] - const errors: Array = [] - const nestedFailures: Array = [] - const uncaught = captureMicrotaskErrors() - let releaseOwner = () => {} - const collection = createOnDemandCollection<{ id: string }>({ - id: `release-reentry-${reentry}-${failures}`, - sync: { - sync: ({ markReady }) => { - markReady() - return { - loadSubset: (options) => { - loads.push(options) - return true - }, - unloadSubset: (options) => { - unloads.push(options) - if (unloads.length === 1 && reentry === `adapter`) { - releaseOwner() - } - if (unloads.length <= failures) throw failure - }, - } - }, + oracleAssertion('existing comparison at 1291', () => + expect(loads).toHaveLength(1), + ) + oracleAssertion('existing comparison at 1292', () => + expect(unloads).toHaveLength(1), + ) + oracleAssertion('existing comparison at 1293', () => + expect(unloads[0]).toBe(loads[0]), + ) + oracleAssertion('existing comparison at 1294', () => + expect(loads[0]!.signal!.aborted).toBe(true), + ) + oracleAssertion('existing comparison at 1295', () => + expect(subscription.status).toBe(`ready`), + ) + // The release error is reported uncaught, not as a load error. + oracleAssertion('existing comparison at 1297', () => + expect(errors).toEqual([]), + ) + oracleAssertion('existing comparison at 1298', () => + expect(uncaught.errors).toEqual( + expectedUncaughtReleaseErrors(outcome, failure), + ), + ) + + releaseOwner() + oracleAssertion('existing comparison at 1303', () => + expect(unloads).toHaveLength(1), + ) + subscription.unsubscribe() + oracleAssertion('existing comparison at 1305', () => + expect(unloads).toHaveLength(1), + ) + for (const options of unloads) + oracleAssertion('existing comparison at 1306', () => + expect(options).toBe(loads[0]), + ) + oracleAssertion('existing comparison at 1307', () => + expect(loads).toHaveLength(1), + ) + await Promise.resolve() + oracleAssertion('existing comparison at 1309', () => + expect(uncaught.errors).toEqual( + expectedUncaughtReleaseErrors(outcome, failure), + ), + ) + } finally { + uncaught.restore() + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.aborted-replay-release`, + law: { + statement: `An aborted replay retires only the acquisition lease it acquired, and a throwing physical release is reported once, uncaught.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and the release-error model.`, + limits: `Returning and throwing releases, with and without a self release.`, + }, + owns: [`src/collection/subscription.ts`], + histories: abortedReplayReleaseCases, + label: (row) => + `retires only the acquired lease for aborted replay with unload=${row.outcome}, releaseSelf=${row.releaseSelf}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.aborted-replay-release`, + plan, + observe, + () => runAbortedReplayReleaseCase(plan.history), + ), + check: forwardAssertionJudgment, + }) + + const releaseReentryCases = ([`adapter`, `error-listener`] as const).flatMap( + (reentry) => [1, 2].map((failures) => ({ reentry, failures })), + ) + const runReleaseReentryCase = async ({ + reentry, + failures, + }: (typeof releaseReentryCases)[number]) => { + const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) + const failure = new Error(`physical release failed`) + const loads: Array = [] + const unloads: Array = [] + const errors: Array = [] + const nestedFailures: Array = [] + const uncaught = captureMicrotaskErrors() + let releaseOwner = () => {} + const collection = createOnDemandCollection<{ id: string }>({ + id: `release-reentry-${reentry}-${failures}`, + sync: { + sync: ({ markReady }) => { + markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: (options) => { + unloads.push(options) + if (unloads.length === 1 && reentry === `adapter`) { + releaseOwner() + } + if (unloads.length <= failures) throw failure + }, + } }, - }) - const subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - releaseOwner = () => { - try { - subscription.unsubscribe() - } catch (error) { - nestedFailures.push(error) - } - } - subscription.on(`loadSubset:error`, ({ error }) => { - errors.push(error) - if (errors.length === 1 && reentry === `error-listener`) releaseOwner() - }) + }, + }) + const subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + releaseOwner = () => { try { - subscription.requestSnapshot({ where }) - let releaseError: unknown - try { - subscription.releaseSnapshot(where) - } catch (error) { - releaseError = error - } - oracleAssertion('existing comparison at 1379', () => - expect(releaseError).toBe(undefined), - ) - // Adapter reentry sees a retired acquisition, including after throw. - // A release error reaches no error listener, so that listener cannot - // reenter teardown. - oracleAssertion('existing comparison at 1383', () => - expect(unloads).toHaveLength(1), - ) - // Only the adapter path reentered and unsubscribed. - oracleAssertion('existing comparison at 1385', () => - expect(collection.subscriberCount).toBe( - expectedSubscriberCountAfterRelease(reentry), - ), - ) - oracleAssertion('existing comparison at 1388', () => - expect(errors).toEqual([]), - ) - oracleAssertion('existing comparison at 1389', () => - expect(nestedFailures).toEqual([]), - ) - oracleAssertion('existing comparison at 1390', () => - expect(() => subscription.unsubscribe()).not.toThrow(), - ) - oracleAssertion('existing comparison at 1391', () => - expect(unloads).toHaveLength(1), - ) - subscription.unsubscribe() - oracleAssertion('existing comparison at 1393', () => - expect(unloads).toHaveLength(1), - ) - oracleAssertion('existing comparison at 1394', () => - expect(loads).toHaveLength(1), - ) - for (const options of unloads) - oracleAssertion('existing comparison at 1395', () => - expect(options).toBe(loads[0]), - ) - await Promise.resolve() - // One attempt, one report, whatever the adapter would throw later. - oracleAssertion('existing comparison at 1398', () => - expect(uncaught.errors).toEqual([failure]), - ) - } finally { - uncaught.restore() - await collection.cleanup() subscription.unsubscribe() + } catch (error) { + nestedFailures.push(error) + } + } + subscription.on(`loadSubset:error`, ({ error }) => { + errors.push(error) + if (errors.length === 1 && reentry === `error-listener`) releaseOwner() + }) + try { + subscription.requestSnapshot({ where }) + let releaseError: unknown + try { + subscription.releaseSnapshot(where) + } catch (error) { + releaseError = error } + oracleAssertion('existing comparison at 1379', () => + expect(releaseError).toBe(undefined), + ) + // Adapter reentry sees a retired acquisition, including after throw. + // A release error reaches no error listener, so that listener cannot + // reenter teardown. + oracleAssertion('existing comparison at 1383', () => + expect(unloads).toHaveLength(1), + ) + // Only the adapter path reentered and unsubscribed. + oracleAssertion('existing comparison at 1385', () => + expect(collection.subscriberCount).toBe( + expectedSubscriberCountAfterRelease(reentry), + ), + ) + oracleAssertion('existing comparison at 1388', () => + expect(errors).toEqual([]), + ) + oracleAssertion('existing comparison at 1389', () => + expect(nestedFailures).toEqual([]), + ) + oracleAssertion('existing comparison at 1390', () => + expect(() => subscription.unsubscribe()).not.toThrow(), + ) + oracleAssertion('existing comparison at 1391', () => + expect(unloads).toHaveLength(1), + ) + subscription.unsubscribe() + oracleAssertion('existing comparison at 1393', () => + expect(unloads).toHaveLength(1), + ) + oracleAssertion('existing comparison at 1394', () => + expect(loads).toHaveLength(1), + ) + for (const options of unloads) + oracleAssertion('existing comparison at 1395', () => + expect(options).toBe(loads[0]), + ) + await Promise.resolve() + // One attempt, one report, whatever the adapter would throw later. + oracleAssertion('existing comparison at 1398', () => + expect(uncaught.errors).toEqual([failure]), + ) + } finally { + uncaught.restore() + await collection.cleanup() + subscription.unsubscribe() + } + } + oracleProperty({ + id: `subscription-lifecycle.release-reentry`, + law: { + statement: `A physical release is attempted once across reentry, and its error reaches no error listener.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and the reentry model.`, + limits: `Adapter and error-listener reentry with one or two configured failures.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: releaseReentryCases, + label: (row) => + `attempts release once across ${row.reentry} reentry with ${row.failures} configured failures`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.release-reentry`, + plan, + observe, + () => runReleaseReentryCase(plan.history), + ), + check: forwardAssertionJudgment, + }) - 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() - void obsolete.promise.catch(() => {}) - void current.promise.catch(() => {}) - const obsoleteFailure = new Error(`obsolete sync run failed`) - const currentFailure = new Error(`current sync run failed`) - const visible = new Map() - const errors: Array = [] - const unloadSyncRunGenerations: Array = [] - let syncRunGeneration = -1 + const threeGenerationCases = threeGenerationScenarios + const runThreeGenerationCase = async ({ + obsoleteOutcome, + currentOutcome, + settlementOrder, + }: (typeof threeGenerationCases)[number]) => { + type Row = { id: string; version: number } + const obsolete = createDeferred() + const current = createDeferred() + void obsolete.promise.catch(() => {}) + void current.promise.catch(() => {}) + const obsoleteFailure = new Error(`obsolete sync run failed`) + const currentFailure = new Error(`current sync run failed`) + const visible = new Map() + const errors: Array = [] + const unloadSyncRunGenerations: Array = [] + let syncRunGeneration = -1 - const collection = createOnDemandCollection({ - id: `three-sync-run-generations-${obsoleteOutcome}-${currentOutcome}-${settlementOrder}`, - sync: { - sync: (operations) => { - syncRunGeneration++ - const ownSyncRunGeneration = syncRunGeneration - operations.markReady() - return { - loadSubset: (options) => { - if (ownSyncRunGeneration === 0) { - operations.begin() - operations.write({ - type: `insert`, - value: { id: `row`, version: 1 }, - }) - operations.commit(options.signal) - return true - } - const gate = ownSyncRunGeneration === 1 ? obsolete : current - const outcome = - ownSyncRunGeneration === 1 ? obsoleteOutcome : currentOutcome - const failure = - ownSyncRunGeneration === 1 ? obsoleteFailure : currentFailure - return gate.promise.then(() => { - if (outcome === `reject`) throw failure - operations.begin() - operations.write({ - type: `insert`, - value: { - id: `row`, - version: ownSyncRunGeneration + 1, - }, - }) - const receipt = operations.commit(options.signal) - if (receipt !== true) return receipt - return undefined + const collection = createOnDemandCollection({ + id: `three-sync-run-generations-${obsoleteOutcome}-${currentOutcome}-${settlementOrder}`, + sync: { + sync: (operations) => { + syncRunGeneration++ + const ownSyncRunGeneration = syncRunGeneration + operations.markReady() + return { + loadSubset: (options) => { + if (ownSyncRunGeneration === 0) { + operations.begin() + operations.write({ + type: `insert`, + value: { id: `row`, version: 1 }, }) - }, - unloadSubset: () => - unloadSyncRunGenerations.push(ownSyncRunGeneration), - } - }, - }, - }) - const subscription = collection.subscribeChanges( - (changes) => { - for (const change of changes) { - if (change.type === `delete`) visible.delete(change.key) - else { - visible.set(change.key, { - id: change.value.id, - version: change.value.version, + operations.commit(options.signal) + return true + } + const gate = ownSyncRunGeneration === 1 ? obsolete : current + const outcome = + ownSyncRunGeneration === 1 ? obsoleteOutcome : currentOutcome + const failure = + ownSyncRunGeneration === 1 ? obsoleteFailure : currentFailure + return gate.promise.then(() => { + if (outcome === `reject`) throw failure + operations.begin() + operations.write({ + type: `insert`, + value: { + id: `row`, + version: ownSyncRunGeneration + 1, + }, + }) + const receipt = operations.commit(options.signal) + if (receipt !== true) return receipt + return undefined }) - } + }, + unloadSubset: () => + unloadSyncRunGenerations.push(ownSyncRunGeneration), } }, - { includeInitialState: false }, - ) - subscription.on(`loadSubset:error`, ({ error }) => errors.push(error)) - subscription.requestSnapshot() - oracleAssertion('existing comparison at 1483', () => - expect([...visible.values()]).toEqual([{ id: `row`, version: 1 }]), - ) + }, + }) + const subscription = collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) visible.delete(change.key) + else { + visible.set(change.key, { + id: change.value.id, + version: change.value.version, + }) + } + } + }, + { includeInitialState: false }, + ) + subscription.on(`loadSubset:error`, ({ error }) => errors.push(error)) + subscription.requestSnapshot() + oracleAssertion('existing comparison at 1483', () => + expect([...visible.values()]).toEqual([{ id: `row`, version: 1 }]), + ) - await collection.cleanup() - collection.startSyncImmediate() - await flushPromises() - await collection.cleanup() - collection.startSyncImmediate() + await collection.cleanup() + collection.startSyncImmediate() + await flushPromises() + await collection.cleanup() + collection.startSyncImmediate() + await flushPromises() + oracleAssertion('existing comparison at 1491', () => + expect(subscription.status).toBe(`loadingSubset`), + ) + + const settleObsolete = () => + obsoleteOutcome === `resolve` + ? obsolete.resolve() + : obsolete.reject(obsoleteFailure) + const settleCurrent = () => + currentOutcome === `resolve` + ? current.resolve() + : current.reject(currentFailure) + if (settlementOrder === `obsolete-first`) { + settleObsolete() await flushPromises() - oracleAssertion('existing comparison at 1491', () => + oracleAssertion('existing comparison at 1504', () => expect(subscription.status).toBe(`loadingSubset`), ) - - const settleObsolete = () => - obsoleteOutcome === `resolve` - ? obsolete.resolve() - : obsolete.reject(obsoleteFailure) - const settleCurrent = () => - currentOutcome === `resolve` - ? current.resolve() - : current.reject(currentFailure) - if (settlementOrder === `obsolete-first`) { - settleObsolete() - await flushPromises() - oracleAssertion('existing comparison at 1504', () => - expect(subscription.status).toBe(`loadingSubset`), - ) - settleCurrent() - } else { - settleCurrent() - await flushPromises() - settleObsolete() - } + settleCurrent() + } else { + settleCurrent() await flushPromises() + settleObsolete() + } + await flushPromises() - const settled = expectedThreeGenerationSettlement( - currentOutcome, - currentFailure, - ) - oracleAssertion('existing comparison at 1517', () => - expect([...visible.values()]).toEqual([ - { id: `row`, version: settled.version }, - ]), - ) - oracleAssertion('existing comparison at 1520', () => - expect(errors).toEqual(settled.errors), - ) - oracleAssertion('existing comparison at 1521', () => - expect(subscription.lastError).toBe(settled.lastError), - ) - oracleAssertion('existing comparison at 1522', () => - expect(subscription.status).toBe(`ready`), - ) + const settled = expectedThreeGenerationSettlement( + currentOutcome, + currentFailure, + ) + oracleAssertion('existing comparison at 1517', () => + expect([...visible.values()]).toEqual([ + { id: `row`, version: settled.version }, + ]), + ) + oracleAssertion('existing comparison at 1520', () => + expect(errors).toEqual(settled.errors), + ) + oracleAssertion('existing comparison at 1521', () => + expect(subscription.lastError).toBe(settled.lastError), + ) + oracleAssertion('existing comparison at 1522', () => + expect(subscription.status).toBe(`ready`), + ) - subscription.unsubscribe() - oracleAssertion('existing comparison at 1525', () => - expect(unloadSyncRunGenerations).toEqual([2]), - ) - await collection.cleanup() + subscription.unsubscribe() + oracleAssertion('existing comparison at 1525', () => + expect(unloadSyncRunGenerations).toEqual([2]), + ) + await collection.cleanup() + } + oracleProperty({ + id: `subscription-lifecycle.three-generations`, + law: { + statement: `The current sync run decides the published row and the delivered error; the obsolete sync run's settlement never publishes.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary; the release law and boundary matrix stated at the top of this file, and the three-generation model.`, + limits: `Every obsolete and current outcome in both settlement orders.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: threeGenerationCases, + label: (row) => + `fences three sync-run generations for ${row.obsoleteOutcome}/${row.currentOutcome} settled ${row.settlementOrder}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.three-generations`, + plan, + observe, + () => runThreeGenerationCase(plan.history), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: `subscription-lifecycle.async-restart`, @@ -1956,128 +1986,144 @@ describe(`resumption notification error and surviving demand`, () => { // gate with no request, and a held requested start with both source modes. // It records the complete public status trace at subscribeChanges return // and cleanup settlement. A later ordinary reader is fresh restart demand. - it.each( - subscriberStartCleanupHistories.flatMap((history) => - ([`none`, `unrequested`, `requested`] as const).flatMap((gate) => - ([`eager`, `on-demand`] as const).map((syncMode) => ({ - ...history, - gate, - syncMode, - })), - ), + const startCleanupCases = subscriberStartCleanupHistories.flatMap((history) => + ([`none`, `unrequested`, `requested`] as const).flatMap((gate) => + ([`eager`, `on-demand`] as const).map((syncMode) => ({ + ...history, + gate, + syncMode, + })), ), - )( - `ordinary admission preserves $cleanupAt cleanup (pending=$cleanupPending, $gate start, $syncMode)`, - async (history) => { - oracleRoute(`case`) - const expected = modelSubscriberStartCleanup(history) - const pendingCleanup = createDeferred() - let starts = 0 - let cleanups = 0 - let cleanupRequested = false - let cleanup: Promise | undefined - const statuses: Array = [] - const collection = createCollection({ - id: `ordinary-admission-cleanup`, - startSync: false, - syncMode: history.syncMode, - getKey: (row: { id: string }) => row.id, - sync: { - sync: ({ markReady }) => { - starts++ - markReady() - return { - loadSubset: () => true, - cleanup: () => { - cleanups++ - return history.cleanupPending - ? pendingCleanup.promise - : undefined - }, - } - }, - }, - }) - if (history.gate !== `none`) { - oracleAssertion('existing comparison at 1727', () => - expect(collection._deferSyncStart()).toBe(true), - ) - if (history.gate === `requested`) collection.startSyncImmediate() - } - const removeStatusListener = collection.on( - `status:change`, - ({ status }) => { - statuses.push(status) - if (!cleanupRequested && status === history.cleanupAt) { - cleanupRequested = true - cleanup = collection.cleanup() + ) + const runStartCleanupCase = async ( + history: (typeof startCleanupCases)[number], + ) => { + const expected = modelSubscriberStartCleanup(history) + const pendingCleanup = createDeferred() + let starts = 0 + let cleanups = 0 + let cleanupRequested = false + let cleanup: Promise | undefined + const statuses: Array = [] + const collection = createCollection({ + id: `ordinary-admission-cleanup`, + startSync: false, + syncMode: history.syncMode, + getKey: (row: { id: string }) => row.id, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { + loadSubset: () => true, + cleanup: () => { + cleanups++ + return history.cleanupPending ? pendingCleanup.promise : undefined + }, } }, + }, + }) + if (history.gate !== `none`) { + oracleAssertion('existing comparison at 1727', () => + expect(collection._deferSyncStart()).toBe(true), + ) + if (history.gate === `requested`) collection.startSyncImmediate() + } + const removeStatusListener = collection.on( + `status:change`, + ({ status }) => { + statuses.push(status) + if (!cleanupRequested && status === history.cleanupAt) { + cleanupRequested = true + cleanup = collection.cleanup() + } + }, + ) + let subscription: ReturnType | undefined + let peer: ReturnType | undefined + try { + subscription = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + oracleAssertion('existing comparison at 1747', () => + expect(starts, `adapter entries at admission return`).toBe( + expected.starts, + ), + ) + oracleAssertion('existing comparison at 1750', () => + expect(cleanups).toBe(expected.cleanups), + ) + oracleAssertion('existing comparison at 1751', () => + expect(collection.status).toBe(expected.statusOnReturn), + ) + oracleAssertion('existing comparison at 1752', () => + expect(statuses).toEqual(expected.statusesOnReturn), ) - let subscription: - ReturnType | undefined - let peer: ReturnType | undefined - try { - subscription = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - oracleAssertion('existing comparison at 1747', () => - expect(starts, `adapter entries at admission return`).toBe( - expected.starts, - ), - ) - oracleAssertion('existing comparison at 1750', () => - expect(cleanups).toBe(expected.cleanups), - ) - oracleAssertion('existing comparison at 1751', () => - expect(collection.status).toBe(expected.statusOnReturn), - ) - oracleAssertion('existing comparison at 1752', () => - expect(statuses).toEqual(expected.statusesOnReturn), - ) - pendingCleanup.resolve() - await cleanup - oracleAssertion('existing comparison at 1756', () => - expect(starts, `cleanup settlement must not restart`).toBe( - expected.starts, - ), - ) - oracleAssertion('existing comparison at 1759', () => - expect(collection.status).toBe(expected.statusAfterCleanup), - ) - oracleAssertion('existing comparison at 1760', () => - expect(statuses).toEqual(expected.statusesAfterCleanup), - ) + pendingCleanup.resolve() + await cleanup + oracleAssertion('existing comparison at 1756', () => + expect(starts, `cleanup settlement must not restart`).toBe( + expected.starts, + ), + ) + oracleAssertion('existing comparison at 1759', () => + expect(collection.status).toBe(expected.statusAfterCleanup), + ) + oracleAssertion('existing comparison at 1760', () => + expect(statuses).toEqual(expected.statusesAfterCleanup), + ) - removeStatusListener() - peer = collection.subscribeChanges(() => {}, { - includeInitialState: false, - }) - oracleAssertion('existing comparison at 1766', () => - expect(starts, `later demand may restart a retired run`).toBe( - expected.starts + (history.cleanupAt === `none` ? 0 : 1), - ), - ) - oracleAssertion('existing comparison at 1769', () => - expect(collection.status).toBe(`ready`), - ) - } finally { - removeStatusListener() - pendingCleanup.resolve() - await cleanup - peer?.unsubscribe() - subscription?.unsubscribe() - await collection.cleanup() - } + removeStatusListener() + peer = collection.subscribeChanges(() => {}, { + includeInitialState: false, + }) + oracleAssertion('existing comparison at 1766', () => + expect(starts, `later demand may restart a retired run`).toBe( + expected.starts + (history.cleanupAt === `none` ? 0 : 1), + ), + ) + oracleAssertion('existing comparison at 1769', () => + expect(collection.status).toBe(`ready`), + ) + } finally { + removeStatusListener() + pendingCleanup.resolve() + await cleanup + peer?.unsubscribe() + subscription?.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.start-cleanup`, + law: { + statement: `Ordinary subscriber admission treats a cleanup as retiring this start, not as permission to retry it, as the grammar's independent start-cleanup judgment predicts.`, + authority: `Collection cleanup/restart in the glossary and the lifecycle grammar's modelSubscriberStartCleanup.`, + limits: `The grammar's start-cleanup histories crossed with three render gates and both source modes.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: startCleanupCases, + label: (history) => + `ordinary admission preserves ${history.cleanupAt} cleanup (pending=${history.cleanupPending}, ${history.gate} start, ${history.syncMode})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.start-cleanup`, + plan, + observe, + () => runStartCleanupCase(plan.history), + ), + check: forwardAssertionJudgment, + }) // The callback can release this owner before resumption commits, or from // its acquiring notification. Both cuts must leave balanced public counts // and no later acquisition. The no-retirement neighbor must acquire normally. - it.each( - subscriberResumeRetirementHistories.flatMap((history) => + const resumeRetirementCases = subscriberResumeRetirementHistories.flatMap( + (history) => ([`eager`, `on-demand`] as const).flatMap((syncMode) => [false, true].map((renderGate) => ({ ...history, @@ -2085,96 +2131,116 @@ describe(`resumption notification error and surviving demand`, () => { renderGate, })), ), - ), - )( - `balances resume at $retireAt (cleanup=$cleanup, $syncMode, gate=$renderGate)`, - async (history) => { - oracleRoute(`case`) - const expected = modelSubscriberResumeRetirement(history) - let starts = 0 - let loads = 0 - const counts: Array = [] - const collection = createCollection<{ id: string }>({ - getKey: (row) => row.id, - gcTime: 0, - syncMode: history.syncMode, - sync: { - sync: ({ markReady }) => { - starts++ - markReady() - return { - loadSubset: () => { - loads++ - return true - }, - } - }, - }, - }) - if (history.renderGate) collection._deferSyncStart() - const subscription = collection.subscribeChanges(() => {}, { - deferAcquisition: true, - includeInitialState: true, - }) - let cleanup: Promise | undefined - let retired = false - const retire = () => { - if (retired) return - retired = true - subscription.unsubscribe() - if (history.cleanup) cleanup = collection.cleanup() - } - const offStatus = collection.on(`status:change`, ({ status }) => { - if (status === history.retireAt) retire() - }) - const offCounts = collection.on( - `subscribers:change`, - ({ subscriberCount }) => { - counts.push(subscriberCount) - if (history.retireAt === `subscribers` && subscriberCount === 1) - retire() + ) + const runResumeRetirementCase = async ( + history: (typeof resumeRetirementCases)[number], + ) => { + const expected = modelSubscriberResumeRetirement(history) + let starts = 0 + let loads = 0 + const counts: Array = [] + const collection = createCollection<{ id: string }>({ + getKey: (row) => row.id, + gcTime: 0, + syncMode: history.syncMode, + sync: { + sync: ({ markReady }) => { + starts++ + markReady() + return { + loadSubset: () => { + loads++ + return true + }, + } }, + }, + }) + if (history.renderGate) collection._deferSyncStart() + const subscription = collection.subscribeChanges(() => {}, { + deferAcquisition: true, + includeInitialState: true, + }) + let cleanup: Promise | undefined + let retired = false + const retire = () => { + if (retired) return + retired = true + subscription.unsubscribe() + if (history.cleanup) cleanup = collection.cleanup() + } + const offStatus = collection.on(`status:change`, ({ status }) => { + if (status === history.retireAt) retire() + }) + const offCounts = collection.on( + `subscribers:change`, + ({ subscriberCount }) => { + counts.push(subscriberCount) + if (history.retireAt === `subscribers` && subscriberCount === 1) + retire() + }, + ) + try { + subscription.resumeDeferredAcquisition() + oracleAssertion('existing comparison at 1845', () => + expect(starts).toBe(expected.starts), ) - try { - subscription.resumeDeferredAcquisition() - oracleAssertion('existing comparison at 1845', () => - expect(starts).toBe(expected.starts), - ) - oracleAssertion('existing comparison at 1846', () => - expect(collection.subscriberCount).toBe(expected.acquiring), - ) - oracleAssertion('existing comparison at 1847', () => - expect(counts).toEqual(expected.counts), - ) - oracleAssertion('existing comparison at 1848', () => - expect(collection._hasSubscriberOrPreload()).toBe( - expected.subscriberOrPreload, - ), - ) - await cleanup - await flushPromises() - oracleAssertion('existing comparison at 1853', () => - expect(collection.subscriberCount).toBe(expected.acquiring), - ) - oracleAssertion('existing comparison at 1854', () => - expect(loads).toBe( - history.syncMode === `on-demand` && history.retireAt === `none` - ? 1 - : 0, - ), - ) - offStatus() - offCounts() - subscription.unsubscribe() - oracleAssertion('existing comparison at 1862', () => - expect(collection.subscriberCount).toBe(0), - ) - } finally { - offStatus() - offCounts() - subscription.unsubscribe() - await collection.cleanup() - } + oracleAssertion('existing comparison at 1846', () => + expect(collection.subscriberCount).toBe(expected.acquiring), + ) + oracleAssertion('existing comparison at 1847', () => + expect(counts).toEqual(expected.counts), + ) + oracleAssertion('existing comparison at 1848', () => + expect(collection._hasSubscriberOrPreload()).toBe( + expected.subscriberOrPreload, + ), + ) + await cleanup + await flushPromises() + oracleAssertion('existing comparison at 1853', () => + expect(collection.subscriberCount).toBe(expected.acquiring), + ) + oracleAssertion('existing comparison at 1854', () => + expect(loads).toBe( + history.syncMode === `on-demand` && history.retireAt === `none` + ? 1 + : 0, + ), + ) + offStatus() + offCounts() + subscription.unsubscribe() + oracleAssertion('existing comparison at 1862', () => + expect(collection.subscriberCount).toBe(0), + ) + } finally { + offStatus() + offCounts() + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.resume-retirement`, + law: { + statement: `A resumption whose owner retires before it commits, or from its acquiring notification, leaves balanced public counts and no later acquisition; the no-retirement neighbor acquires normally.`, + authority: `Collection cleanup/restart and logical demand ownership in the glossary, and the lifecycle grammar's modelSubscriberResumeRetirement.`, + limits: `The grammar's resume-retirement histories crossed with both source modes and the render gate.`, }, - ) + owns: [`src/collection/subscription.ts`], + histories: resumeRetirementCases, + label: (history) => + `balances resume at ${history.retireAt} (cleanup=${history.cleanup}, ${history.syncMode}, gate=${history.renderGate})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.resume-retirement`, + plan, + observe, + () => runResumeRetirementCase(plan.history), + ), + check: forwardAssertionJudgment, + }) }) 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 83e6f1b9be..a80d8c78ad 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 @@ -19,7 +19,6 @@ import { oracleRoute, readOracleRunConfig, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { oracleProperty } from './oracle-property.js' import { subscriptionComparison } from './collection-subscription-property-registration.js' import { @@ -33,6 +32,14 @@ import { cloneRow, normalizePublicationOrder, } from './collection-subscription-lifecycle-publication-support' +// The publication command types and the finite row-publication product live +// in this module, unchanged, so the ordinary grammar-size test can import them. +import { + publicationControlSlices, + replacementOrderingCases, + replacementRetirementCases, + successfulReplacementCases, +} from './collection-subscription-lifecycle-publication-product-oracle.js' import type { PublicationChange, PublicationPhase, @@ -41,6 +48,12 @@ import type { SyncOperations, TerminalPublicationSnapshot, } from './collection-subscription-lifecycle-publication-support' +import type { + PublicationCommand, + PublicationProductCase, + SourceEffect, + SourceMutation, +} from './collection-subscription-lifecycle-publication-product-oracle.js' import type { DeferralLifecycleCommand, DemandName, @@ -105,16 +118,6 @@ import type { * still exercises their real dependency edge. */ -type SourceMutation = { - type: `source` - key: RowKey - action: `upsert` | `delete` - value: number - // Upsert the visible row's zero with the other sign; a key whose visible - // value is not a zero upserts `value`. - flip?: boolean -} - /** Resolves a `flip` mutation against the model's visible rows. */ function withFlippedZero( command: PublicationCommand, @@ -130,12 +133,6 @@ function withFlippedZero( // Reach: source writes that change a public row only by the sign of a zero. let zeroSignWrites = 0 -type PublicationCommand = - | Exclude - | (Extract & { value?: number }) - | { type: `truncate`; replacement?: Row } - | SourceMutation - type RuntimeAttempt = { id: number ownerId: number @@ -177,8 +174,6 @@ type PublicationModel = { sentKeys: Set } -type SourceEffect = `insert` | `update` | `delete` - type PublicationObservation = { index: number command: PublicationCommand[`type`] @@ -1511,196 +1506,6 @@ async function runPublicationHistory( return observations } -type ProductSettlement = `none` | `resolve` | `reject` - -type ProductSuffix = `release` | `cleanup` | `restart` | `unsubscribe` - -type PriorIndependentRow = `absent` | `present` - -type PublicationProductCase = { - name: string - phase: PublicationPhase - sourceEffect: SourceEffect - settlement: ProductSettlement - suffix: ProductSuffix - priorIndependentRow: PriorIndependentRow - history: Array - focalSourceIndex: number - settlementIndex?: number - pendingProbeIndex: number - suffixIndex: number - postUnsubscribeProbeIndex: number -} - -const settleCurrent = ( - demand: DemandName, - outcome: `resolve` | `reject`, -): LifecycleCommand => ({ - type: `settle`, - demand, - scope: `current`, - age: `oldest`, - outcome, -}) - -function createPublicationProductCase( - phase: PublicationPhase, - sourceEffect: SourceEffect, - settlement: ProductSettlement, - suffix: ProductSuffix, - priorIndependentRow: PriorIndependentRow, -): PublicationProductCase { - const history: Array = [] - const push = (command: PublicationCommand): number => - history.push(command) - 1 - const hasPeer = phase === `private-settling` || phase === `private-failed` - - if (priorIndependentRow === `present`) { - push({ type: `source`, key: `d`, action: `upsert`, value: 30 }) - } - push({ type: `request`, demand: `a` }) - if (hasPeer) push({ type: `request`, demand: `b` }) - if (phase !== `public`) { - push(settleCurrent(`a`, `resolve`)) - if (hasPeer) push(settleCurrent(`b`, `resolve`)) - push({ type: `truncate` }) - if (phase === `private-settling`) { - push(settleCurrent(`b`, `resolve`)) - } else if (phase === `private-failed`) { - push(settleCurrent(`b`, `reject`)) - } - } - - if (sourceEffect !== `insert`) { - push({ type: `source`, key: `c`, action: `upsert`, value: 40 }) - } - const focalSourceIndex = push({ - type: `source`, - key: `c`, - action: sourceEffect === `delete` ? `delete` : `upsert`, - value: 41, - }) - const settlementIndex = - settlement === `none` ? undefined : push(settleCurrent(`a`, settlement)) - const pendingProbeIndex = history.length - - let suffixIndex: number - if (suffix === `release`) { - suffixIndex = push({ type: `release`, demand: `a` }) - if (hasPeer) suffixIndex = push({ type: `release`, demand: `b` }) - push({ type: `unsubscribe` }) - } else if (suffix === `cleanup`) { - suffixIndex = push({ type: `cleanup` }) - push({ type: `unsubscribe` }) - } else if (suffix === `restart`) { - push({ type: `cleanup` }) - suffixIndex = push({ type: `restart` }) - push({ type: `unsubscribe` }) - } else { - suffixIndex = push({ type: `unsubscribe` }) - } - if (suffix === `cleanup`) push({ type: `restart` }) - const postUnsubscribeProbeIndex = push({ - type: `source`, - key: `d`, - action: `upsert`, - value: 99, - }) - - return { - name: `${phase}:${sourceEffect}:${settlement}:${suffix}:prior-${priorIndependentRow}`, - phase, - sourceEffect, - settlement, - suffix, - priorIndependentRow, - history, - focalSourceIndex, - ...(settlementIndex === undefined ? {} : { settlementIndex }), - pendingProbeIndex, - suffixIndex, - postUnsubscribeProbeIndex, - } -} - -const publicationPhases = [ - `public`, - `private-pending`, - `private-settling`, - `private-failed`, -] as const - -const sourceEffects = [`insert`, `update`, `delete`] as const - -const productSettlements = [`none`, `resolve`, `reject`] as const - -const productSuffixes = [ - `release`, - `cleanup`, - `restart`, - `unsubscribe`, -] as const - -const priorIndependentRows = [`absent`, `present`] as const - -const publicationProductCases = publicationPhases.flatMap((phase) => - sourceEffects.flatMap((sourceEffect) => - productSettlements.flatMap((settlement) => - productSuffixes.flatMap((suffix) => - priorIndependentRows.map((priorIndependentRow) => - createPublicationProductCase( - phase, - sourceEffect, - settlement, - suffix, - priorIndependentRow, - ), - ), - ), - ), - ), -) - -const successfulReplacementCases = publicationProductCases.filter( - ({ phase, sourceEffect, settlement }) => - (phase === `private-pending` || phase === `private-settling`) && - sourceEffect !== `delete` && - settlement === `resolve`, -) - -const replacementOrderingCases = publicationProductCases.filter( - ({ phase, sourceEffect, settlement, suffix, priorIndependentRow }) => - (phase === `private-pending` || phase === `private-settling`) && - sourceEffect === `delete` && - settlement === `resolve` && - suffix !== `release` && - priorIndependentRow === `present`, -) - -const replacementRetirementCases = publicationProductCases.filter( - (scenario) => - scenario.phase !== `public` && - scenario.suffix === `release` && - !successfulReplacementCases.includes(scenario) && - !replacementOrderingCases.includes(scenario), -) - -const publicationControlCases = publicationProductCases.filter( - (scenario) => - !successfulReplacementCases.includes(scenario) && - !replacementOrderingCases.includes(scenario) && - !replacementRetirementCases.includes(scenario), -) - -// Keep each semantic cell intact while giving the aggregate campaign several -// scheduler cuts. One 204-history test can exceed Vitest's per-test deadline -// when the full oracle portfolio runs concurrently, even though no history is -// slow or stuck. -const publicationControlSlices = Array.from({ length: 4 }, (_, index) => ({ - name: `${index * 51 + 1}-${(index + 1) * 51}`, - scenarios: publicationControlCases.slice(index * 51, (index + 1) * 51), -})) - async function runPublicationProduct( scenarios: ReadonlyArray, ): Promise> { @@ -1957,29 +1762,9 @@ 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({ - check: [runPublicationHistory, runPublicationProduct, runRestartReceiver], - model: [ - observeRestartRow, - observeRestartOwner, - projectPublication, - publicationDiff, - publishIfChanged, - recordSourceWrite, - finishReplacement, - expectedSelection, - ], - grammar: [ - deferralCleanupHistories, - publicationProductCases, - restartPublicationInputArbitrary, - restartPublicationHistory, - ], -}) - +// Each property below names, in `owns`, what the gap report judges: which +// rows a subscription publishes to subscribers, in subscription.ts and in the +// Collection state that holds the rows, across replay, restart, and cleanup. describe(`CollectionSubscription lifecycle publication oracle`, () => { // A local snapshot selects source rows for this subscriber. Selection and // publication are distinct: replay can hold a selection privately, while @@ -2014,495 +1799,695 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { first: FirstSnapshot reenter: boolean }> - 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( - first, - snapshotRows, - ) + async function runSelectionCase({ + includeInitialState, + first, + reenter, + }: (typeof selectionCases)[number]): Promise { + // Local selections are modeled by expectedSelection; visible is the + // independent callback consumer's row set. + const { batches: expectedBatches, selected } = expectedSelection( + first, + snapshotRows, + ) - const observedBatches: Array> = [] - const visible = new Set() - const invalidChanges: Array = [] - const loads: Array<{ cursor?: { lastKey?: string | number } }> = [] - const collection = createCollection({ - id: `snapshot-selection-${first}-${includeInitialState}-${reenter}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - sync: { - sync: (operations) => { - operations.begin() - for (const value of snapshotRows) - operations.write({ type: `insert`, value }) - operations.commit() - operations.markReady() - return { - loadSubset: (options) => { - loads.push(options) - return true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const index = collection.createIndex((row) => row.value, { - indexType: BTreeIndex, - }) - const orderBy = valueOrderBy() - let inFirstCallback = false - let nested = false - const subscription = collection.subscribeChanges( - (changes) => { - const batch: Array = [] - for (const change of changes) { - batch.push(`${change.type}:${change.key}`) - if (change.type === `insert`) { - if (visible.has(String(change.key))) - invalidChanges.push(`duplicate insert:${change.key}`) - visible.add(String(change.key)) - } else if (change.type === `delete`) { - visible.delete(String(change.key)) - } - } - if (batch.length > 0) observedBatches.push(batch) - if (reenter && inFirstCallback && !nested) { - nested = true - subscription.requestLimitedSnapshot({ - orderBy, - limit: 1, - minValues: [1], - }) + const observedBatches: Array> = [] + const visible = new Set() + const invalidChanges: Array = [] + const loads: Array<{ cursor?: { lastKey?: string | number } }> = [] + const collection = createCollection({ + id: `snapshot-selection-${first}-${includeInitialState}-${reenter}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (operations) => { + operations.begin() + for (const value of snapshotRows) + operations.write({ type: `insert`, value }) + operations.commit() + operations.markReady() + return { + loadSubset: (options) => { + loads.push(options) + return true + }, + unloadSubset: () => {}, } }, - { includeInitialState }, - ) - subscription.setOrderByIndex(index) - try { - inFirstCallback = true - if (first === `full`) subscription.requestSnapshot() - else if (first === `explicit`) subscription.requestSnapshot({}) - else - subscription.requestSnapshot({ - where: new Func(`eq`, [ - new PropRef([`id`]), - new Value(first === `one` ? `one` : `missing`), - ]), - }) - inFirstCallback = false - if (!reenter) + }, + }) + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const orderBy = valueOrderBy() + let inFirstCallback = false + let nested = false + const subscription = collection.subscribeChanges( + (changes) => { + const batch: Array = [] + for (const change of changes) { + batch.push(`${change.type}:${change.key}`) + if (change.type === `insert`) { + if (visible.has(String(change.key))) + invalidChanges.push(`duplicate insert:${change.key}`) + visible.add(String(change.key)) + } else if (change.type === `delete`) { + visible.delete(String(change.key)) + } + } + if (batch.length > 0) observedBatches.push(batch) + if (reenter && inFirstCallback && !nested) { + nested = true subscription.requestLimitedSnapshot({ orderBy, limit: 1, minValues: [1], }) + } + }, + { includeInitialState }, + ) + subscription.setOrderByIndex(index) + try { + inFirstCallback = true + if (first === `full`) subscription.requestSnapshot() + else if (first === `explicit`) subscription.requestSnapshot({}) + else + subscription.requestSnapshot({ + where: new Func(`eq`, [ + new PropRef([`id`]), + new Value(first === `one` ? `one` : `missing`), + ]), + }) + inFirstCallback = false + if (!reenter) subscription.requestLimitedSnapshot({ orderBy, limit: 1, minValues: [1], }) + subscription.requestLimitedSnapshot({ + orderBy, + limit: 1, + minValues: [1], + }) - // Snapshot enumeration order is not part of the key-selection law. - oracleAssertion(`lifecycle publication comparison at 2015`, () => - expect(observedBatches.map((batch) => [...batch].sort())).toEqual( - expectedBatches.map((batch) => [...batch].sort()), - ), - ) - oracleAssertion(`lifecycle publication comparison at 2018`, () => - expect(invalidChanges).toEqual([]), - ) - oracleAssertion(`lifecycle publication comparison at 2019`, () => - expect([...visible].sort()).toEqual([...selected].sort()), - ) - // Local selections do not certify a provider prefix. Without an - // explicit offset, the adapter request begins at the source prefix - // and carries no key-based cursor hint, including after a full read. - oracleAssertion(`lifecycle publication comparison at 2023`, () => - expect(loads.at(-1)).toMatchObject({ offset: 0 }), - ) - oracleAssertion(`lifecycle publication comparison at 2024`, () => - expect(loads.at(-1)?.cursor?.lastKey).toBeUndefined(), - ) - } finally { - subscription.unsubscribe() - await collection.cleanup() - } + // Snapshot enumeration order is not part of the key-selection law. + oracleAssertion(`lifecycle publication comparison at 2015`, () => + expect(observedBatches.map((batch) => [...batch].sort())).toEqual( + expectedBatches.map((batch) => [...batch].sort()), + ), + ) + oracleAssertion(`lifecycle publication comparison at 2018`, () => + expect(invalidChanges).toEqual([]), + ) + oracleAssertion(`lifecycle publication comparison at 2019`, () => + expect([...visible].sort()).toEqual([...selected].sort()), + ) + // Local selections do not certify a provider prefix. Without an + // explicit offset, the adapter request begins at the source prefix + // and carries no key-based cursor hint, including after a full read. + oracleAssertion(`lifecycle publication comparison at 2023`, () => + expect(loads.at(-1)).toMatchObject({ offset: 0 }), + ) + oracleAssertion(`lifecycle publication comparison at 2024`, () => + expect(loads.at(-1)?.cursor?.lastKey).toBeUndefined(), + ) + } finally { + subscription.unsubscribe() + await collection.cleanup() + } + } + oracleProperty({ + id: `subscription-lifecycle.publication-selection`, + law: { + statement: `Across a local snapshot and later ordered snapshots, a callback consumer receives each locally selected key once, the independent selection model predicts the selected keys and batches, and the adapter request starts at the source prefix without a key cursor.`, + authority: `Collection.subscribeChanges and requestSnapshot / requestLimitedSnapshot, as the local-selection paragraph above states.`, + limits: `The 14 enumerated first-snapshot, includeInitialState, and re-entry cases over three static distinct-ranked rows.`, }, - ) - it(`reconstructs every bounded deferral cleanup history`, () => { - oracleRoute(`case`) - oracleAssertion(`lifecycle publication comparison at 2033`, () => - expect(deferralCleanupHistories).toHaveLength(32), - ) - oracleAssertion(`lifecycle publication comparison at 2034`, () => - expect( - new Set(deferralCleanupHistories.map(({ name }) => name)).size, - ).toBe(32), - ) + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: selectionCases, + label: ({ includeInitialState, first, reenter }) => + `selects each local key once across ${first} then ordered, includeInitialState=${includeInitialState}, reenter=${reenter}`, + ...subscriptionComparison<(typeof selectionCases)[number]>( + `subscription-lifecycle.publication-selection`, + runSelectionCase, + ), + check: forwardAssertionJudgment, }) - it.each(deferralCleanupHistories)( - `publishes independent batches after deferral cleanup: $name`, - async ({ commands }) => { - oracleRoute(`case`) - await runDeferralCleanupHistory(commands) + oracleProperty({ + id: `subscription-lifecycle.publication-deferral-cleanup`, + law: { + statement: `After each bounded deferral cleanup history, independent source batches publish exactly as the deferral model predicts.`, + authority: `The deferral cleanup law in the opening contract above and the shared lifecycle grammar's deferral histories.`, + limits: `The 32 bounded deferral cleanup histories.`, }, - ) - it.each( - ([`initial`, `replay`, `restart`] as const).flatMap((phase) => - [1, 2].flatMap((demandCount) => - ([`resolve`, `reject`] as const).flatMap((outcome) => - ([`oldest-first`, `newest-first`] as const).map((order) => ({ - phase, - demandCount, - outcome, - order, - })), - ), - ), + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: deferralCleanupHistories, + label: ({ name }) => + `publishes independent batches after deferral cleanup: ${name}`, + ...subscriptionComparison<(typeof deferralCleanupHistories)[number]>( + `subscription-lifecycle.publication-deferral-cleanup`, + ({ commands }) => runDeferralCleanupHistory(commands), ), - )( - `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` }, - ...(demandCount === 2 - ? [{ type: `request` as const, demand: `b` as const }] - : []), - ...(phase === `replay` - ? [{ type: `truncate` as const }] - : phase === `restart` - ? [{ type: `cleanup` as const }, { type: `restart` as const }] - : []), - ] - const reached: Array = [] - await runPublicationHistory(history, { - terminal: { - order, + check: forwardAssertionJudgment, + }) + const retiredTransportCases = ( + [`initial`, `replay`, `restart`] as const + ).flatMap((phase) => + [1, 2].flatMap((demandCount) => + ([`resolve`, `reject`] as const).flatMap((outcome) => + ([`oldest-first`, `newest-first`] as const).map((order) => ({ + phase, + demandCount, outcome, - expectedPending: demandCount * (phase === `initial` ? 1 : 2), - reached, - }, - }) - oracleAssertion(`lifecycle publication comparison at 2083`, () => - expect(new Set(reached).size).toBe( - demandCount * (phase === `initial` ? 1 : 2), - ), - ) - }, + order, + })), + ), + ), ) - it(`keeps raw source updates after retiring the final replay owner`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { type: `cleanup` }, - { type: `release`, demand: `b` }, - { type: `request`, demand: `b` }, - { type: `restart` }, - { type: `source`, key: `d`, action: `upsert`, value: 0 }, - { type: `abort`, demand: `b` }, - { type: `release`, demand: `b` }, - { type: `abort`, demand: `b` }, - { type: `source`, key: `d`, action: `upsert`, value: 4 }, - { type: `release`, demand: `b` }, - { type: `restart` }, - { type: `unsubscribe` }, - ]) - }) - it(`keeps raw truncate deletes after retiring the final replay owner`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { - type: `settle`, - demand: `a`, - scope: `obsolete`, - age: `oldest`, - outcome: `reject`, - }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, + async function runRetiredTransportCase({ + phase, + demandCount, + outcome, + order, + }: (typeof retiredTransportCases)[number]): Promise { + const history: Array = [ + { type: `source`, key: `c`, action: `upsert`, value: 42 }, { type: `request`, demand: `a` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `source`, key: `a`, action: `upsert`, value: 5 }, - { type: `release`, demand: `a` }, - { type: `restart` }, - { type: `truncate` }, - ]) - }) - it(`records requested snapshot rows before a canceled reset`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { type: `request`, demand: `b` }, - { type: `truncate` }, - { type: `source`, key: `b`, action: `upsert`, value: 0 }, - { type: `release`, demand: `b` }, - { type: `request`, demand: `b` }, - { type: `restart` }, - { type: `abort`, demand: `a` }, - { type: `abort`, demand: `b` }, - { type: `restart` }, - { type: `truncate` }, - { type: `restart` }, - { type: `unsubscribe` }, - { type: `release`, demand: `b` }, - ]) + ...(demandCount === 2 + ? [{ type: `request` as const, demand: `b` as const }] + : []), + ...(phase === `replay` + ? [{ type: `truncate` as const }] + : phase === `restart` + ? [{ type: `cleanup` as const }, { type: `restart` as const }] + : []), + ] + const reached: Array = [] + await runPublicationHistory(history, { + terminal: { + order, + outcome, + expectedPending: demandCount * (phase === `initial` ? 1 : 2), + reached, + }, + }) + oracleAssertion(`lifecycle publication comparison at 2083`, () => + expect(new Set(reached).size).toBe( + demandCount * (phase === `initial` ? 1 : 2), + ), + ) + } + oracleProperty({ + id: `subscription-lifecycle.publication-retired-transports`, + law: { + statement: `Settling a retired transport, in either order and with either outcome, publishes nothing to the subscriber and reaches every pending attempt, as the publication model predicts.`, + authority: `Collection.subscribeChanges and the terminal-publication law of the lifecycle/publication contract above.`, + limits: `The 24 enumerated phase, demand-count, outcome, and settlement-order cases.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: retiredTransportCases, + label: (row) => + `observes retired transports without subscriber activity: ${JSON.stringify(row)}`, + ...subscriptionComparison<(typeof retiredTransportCases)[number]>( + `subscription-lifecycle.publication-retired-transports`, + runRetiredTransportCase, + ), + check: forwardAssertionJudgment, }) - it(`does not invent a publication for an unchanged private row`, async () => { - oracleRoute(`fixed history`) + const rawResetCases = ( + [`none`, `retained`, `refreshed`, `new`] as const + ).flatMap((baseline) => + ([`delete`, `empty`, `same`, `other`] as const).map((reset) => ({ + baseline, + reset, + })), + ) + async function runRawResetCase({ + baseline, + reset, + }: (typeof rawResetCases)[number]): Promise { await runPublicationHistory([ + ...(baseline === `retained` || baseline === `refreshed` + ? [{ type: `source`, key: `d`, action: `upsert`, value: 0 } as const] + : []), { type: `request`, demand: `a` }, { type: `cleanup` }, { type: `restart` }, { type: `source`, key: `a`, action: `upsert`, value: 5 }, { type: `release`, demand: `a` }, - { type: `source`, key: `a`, action: `upsert`, value: 5 }, + ...(baseline === `refreshed` || baseline === `new` + ? [{ type: `source`, key: `d`, action: `upsert`, value: 0 } as const] + : []), + reset === `delete` + ? { type: `source`, key: `a`, action: `delete`, value: 0 } + : { + type: `truncate`, + ...(reset === `same` + ? { replacement: { id: `a`, value: 5 } as const } + : reset === `other` + ? { replacement: { id: `c`, value: 6 } as const } + : {}), + }, { type: `unsubscribe` }, ]) - }) - it.each( - ([`none`, `retained`, `refreshed`, `new`] as const).flatMap((baseline) => - ([`delete`, `empty`, `same`, `other`] as const).map((reset) => ({ - baseline, - reset, - })), + } + oracleProperty({ + id: `subscription-lifecycle.publication-raw-reset`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `The 16 enumerated baseline and reset cases after a cleanup and restart.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: rawResetCases, + label: ({ baseline, reset }) => + `distinguishes raw source resets from retained replacements: ${baseline}/${reset}`, + ...subscriptionComparison<(typeof rawResetCases)[number]>( + `subscription-lifecycle.publication-raw-reset`, + runRawResetCase, ), - )( - `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] - : []), - { type: `request`, demand: `a` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `source`, key: `a`, action: `upsert`, value: 5 }, - { type: `release`, demand: `a` }, - ...(baseline === `refreshed` || baseline === `new` - ? [{ type: `source`, key: `d`, action: `upsert`, value: 0 } as const] - : []), - reset === `delete` - ? { type: `source`, key: `a`, action: `delete`, value: 0 } - : { - type: `truncate`, - ...(reset === `same` - ? { replacement: { id: `a`, value: 5 } as const } - : reset === `other` - ? { replacement: { id: `c`, value: 6 } as const } - : {}), - }, - { type: `unsubscribe` }, - ]) + check: forwardAssertionJudgment, + }) + // The finite row-publication product, run in its four control slices and + // its three replacement families. Each history replays its scenarios through + // runPublicationProduct and the shared publication comparison. + const publicationProductHistories = [ + ...publicationControlSlices.map(({ name, scenarios }) => ({ + name: `matches row publications for lifecycle control cells ${name}`, + scenarios, + })), + { + name: `preserves independent source work when a successful replay publishes`, + scenarios: successfulReplacementCases, }, - ) - 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` }, - { type: `truncate` }, - { type: `abort`, demand: `a` }, - { type: `truncate` }, - { - type: `settle`, - demand: `a`, - scope: `obsolete`, - age: `newest`, - outcome: `resolve`, + { + name: `publishes complete replacement batches regardless of independent key order`, + scenarios: replacementOrderingCases, + }, + { + name: `retires incomplete or failed replacement without changing independent public rows`, + scenarios: replacementRetirementCases, + }, + ] satisfies Array<{ + name: string + scenarios: ReadonlyArray + }> + oracleProperty({ + id: `subscription-lifecycle.publication-product`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `The 288-cell publication phase, source effect, settlement, suffix, and prior-row product, in four control slices and three replacement families.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: publicationProductHistories, + label: ({ name }) => name, + ...subscriptionComparison<(typeof publicationProductHistories)[number]>( + `subscription-lifecycle.publication-product`, + async ({ scenarios }) => { + expectNoPublicationMismatches(await runPublicationProduct(scenarios)) }, - { type: `cleanup` }, - ]) + ), + check: forwardAssertionJudgment, }) - 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` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, - { type: `truncate` }, - { type: `source`, key: `b`, action: `upsert`, value: 1 }, - { type: `restart` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `newest`, - outcome: `reject`, - }, - { type: `restart` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `truncate` }, - { type: `request`, demand: `b` }, - { type: `cleanup` }, - ]) - }) - it(`defines all 288 unique row-publication lifecycle cells`, () => { - oracleRoute(`case`) - oracleAssertion(`lifecycle publication comparison at 2253`, () => - expect(publicationProductCases).toHaveLength(288), - ) - oracleAssertion(`lifecycle publication comparison at 2254`, () => - expect( - new Set(publicationProductCases.map(({ name }) => name)).size, - ).toBe(288), - ) - oracleAssertion(`lifecycle publication comparison at 2257`, () => - expect(successfulReplacementCases).toHaveLength(32), - ) - oracleAssertion(`lifecycle publication comparison at 2258`, () => - expect(replacementOrderingCases).toHaveLength(6), - ) - oracleAssertion(`lifecycle publication comparison at 2259`, () => - expect(replacementRetirementCases).toHaveLength(46), - ) - oracleAssertion(`lifecycle publication comparison at 2260`, () => - expect(publicationControlCases).toHaveLength(204), - ) - }) - 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( + // Releasing the final owner retires a replay that has not failed; it + // publishes the rows it holds, so none of these rows stays stale. + const staleRowHistories = [ + [ + `a value written during the replay`, [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, { type: `request`, demand: `a` }, - { type: `request`, demand: `b` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, + ], + ], + [ + `a value written again after the replay retired`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 2 }, + { type: `release`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 2 }, + ], + ], + [ + `a row no demand names`, + [ + { type: `source`, key: `c`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `c`, action: `upsert`, value: 2 }, + { type: `release`, demand: `a` }, + ], + ], + [ + `a row deleted during the replay`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { type: `truncate` }, + { type: `abort`, demand: `a` }, + { type: `source`, key: `a`, action: `delete`, value: 0 }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, + ], + ], + [ + `a row retained by a failed replay and deleted before the next one`, + [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, { type: `settle`, demand: `a`, - scope: `obsolete`, + scope: `current`, age: `oldest`, outcome: `resolve`, }, + { type: `truncate` }, + { type: `source`, key: `a`, action: `delete`, value: 0 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, { type: `release`, demand: `b` }, - { type: `cleanup` }, - { type: `restart` }, - { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it(`publishes an authoritative truncate after the final demand is released`, async () => { - oracleRoute(`fixed history`) + ], + [ + `an undemanded row retained by a failed replay and deleted before the next one`, + [ + { type: `source`, key: `c`, action: `upsert`, value: 1 }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `source`, key: `c`, action: `delete`, value: 0 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `truncate` }, + { type: `release`, demand: `b` }, + ], + ], + ] as Array<[string, Array]> + const emptyResetCases = [false, true].flatMap((restart) => + [false, true].map((canceledOwner) => ({ restart, canceledOwner })), + ) + async function runEmptyResetCase({ + restart, + canceledOwner, + }: (typeof emptyResetCases)[number]): Promise { await runPublicationHistory([ - { type: `request`, demand: `b` }, + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + ...(restart + ? [{ type: `cleanup` } as const, { type: `restart` } as const] + : []), + ...(canceledOwner + ? [ + { type: `request`, demand: `b` } as const, + { type: `abort`, demand: `b` } as const, + ] + : []), + { type: `truncate` }, + { type: `truncate` }, + { type: `request`, demand: `a` }, { type: `settle`, - demand: `b`, + demand: `a`, scope: `current`, age: `oldest`, outcome: `resolve`, }, + { type: `release`, demand: `a` }, + { type: `release`, demand: `b` }, + { type: `unsubscribe` }, + ]) + } + oracleProperty({ + id: `subscription-lifecycle.publication-empty-reset`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `The 4 enumerated restart and canceled-owner cases around a repeated authoritative empty truncate.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: emptyResetCases, + label: (row) => + `publishes an authoritative empty reset: ${JSON.stringify(row)}`, + ...subscriptionComparison<(typeof emptyResetCases)[number]>( + `subscription-lifecycle.publication-empty-reset`, + runEmptyResetCase, + ), + check: forwardAssertionJudgment, + }) + const retainedReplacementCases = [false, true].flatMap((canceledOwner) => + ( + [ + { id: `a`, value: 0 }, + { id: `a`, value: 1 }, + { id: `c`, value: 2 }, + ] satisfies Array + ).map((replacement) => ({ canceledOwner, replacement })), + ) + async function runRetainedReplacementCase({ + canceledOwner, + replacement, + }: (typeof retainedReplacementCases)[number]): Promise { + await runPublicationHistory([ + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `cleanup` }, + { type: `restart` }, + ...(canceledOwner + ? [ + { type: `request`, demand: `b` } as const, + { type: `abort`, demand: `b` } as const, + ] + : []), + { type: `truncate`, replacement }, + { type: `source`, key: replacement.id, action: `upsert`, value: 3 }, { type: `release`, demand: `b` }, - { type: `truncate` }, { type: `unsubscribe` }, ]) + } + oracleProperty({ + id: `subscription-lifecycle.publication-retained-replacement`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `The 6 enumerated canceled-owner and replacement-row cases after a cleanup and restart.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: retainedReplacementCases, + label: (row) => + `installs a retained-row replacement atomically: ${JSON.stringify(row)}`, + ...subscriptionComparison<(typeof retainedReplacementCases)[number]>( + `subscription-lifecycle.publication-retained-replacement`, + runRetainedReplacementCase, + ), + check: forwardAssertionJudgment, }) - it(`publishes independent source changes with a successful replay`, async () => { - oracleRoute(`fixed history`) + const eagerRestartReplacements = [ + undefined, + { id: `a`, value: 0 }, + { id: `a`, value: 1 }, + { id: `c`, value: 2 }, + ] satisfies Array + async function runEagerRestartCase( + replacement: (typeof eagerRestartReplacements)[number], + ): Promise { await runPublicationHistory( [ - { type: `request`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `cleanup` }, + { type: `restart` }, + { type: `truncate`, replacement }, + { type: `source`, key: `a`, action: `upsert`, value: 3 }, + { type: `unsubscribe` }, + ], + { withoutLoader: true }, + ) + } + oracleProperty({ + id: `subscription-lifecycle.publication-eager-restart`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `The 4 enumerated truncate replacements of an eager restart without a loader.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: eagerRestartReplacements, + label: (replacement) => + `publishes eager restart before a later source reset: ${JSON.stringify(replacement)}`, + ...subscriptionComparison<(typeof eagerRestartReplacements)[number]>( + `subscription-lifecycle.publication-eager-restart`, + runEagerRestartCase, + ), + check: forwardAssertionJudgment, + }) + // Named regressions and the canonical green and stale-row histories. The + // publication command grammar draws its histories through `chain`, so it + // cannot take these back as fixed histories; each one replays through the + // same driver and comparison as the generated publication-history campaign. + const publicationFixedHistories: Array<{ + name: string + commands: ReadonlyArray + options?: { continueAfterMismatch: boolean } + }> = [ + { + name: `keeps raw source updates after retiring the final replay owner`, + commands: [ + { type: `cleanup` }, + { type: `release`, demand: `b` }, + { type: `request`, demand: `b` }, + { type: `restart` }, + { type: `source`, key: `d`, action: `upsert`, value: 0 }, + { type: `abort`, demand: `b` }, + { type: `release`, demand: `b` }, + { type: `abort`, demand: `b` }, + { type: `source`, key: `d`, action: `upsert`, value: 4 }, + { type: `release`, demand: `b` }, + { type: `restart` }, + { type: `unsubscribe` }, + ], + }, + { + name: `keeps raw truncate deletes after retiring the final replay owner`, + commands: [ { type: `settle`, demand: `a`, - scope: `current`, + scope: `obsolete`, age: `oldest`, - outcome: `resolve`, + outcome: `reject`, }, - { type: `truncate` }, - { type: `source`, key: `b`, action: `upsert`, value: 50 }, { type: `settle`, demand: `a`, scope: `current`, age: `oldest`, - outcome: `resolve`, + outcome: `reject`, }, - { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, { type: `cleanup` }, { type: `restart` }, - { type: `unsubscribe` }, + { type: `source`, key: `a`, action: `upsert`, value: 5 }, + { type: `release`, demand: `a` }, + { type: `restart` }, + { type: `truncate` }, ], - { continueAfterMismatch: true }, - ) - }) - 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 }, - { type: `request`, demand: `b` }, - { type: `source`, key: `b`, action: `upsert`, value: 1 }, + }, + { + name: `records requested snapshot rows before a canceled reset`, + commands: [ { type: `request`, demand: `b` }, + { type: `truncate` }, + { type: `source`, key: `b`, action: `upsert`, value: 0 }, { type: `release`, demand: `b` }, + { type: `request`, demand: `b` }, + { type: `restart` }, + { type: `abort`, demand: `a` }, + { type: `abort`, demand: `b` }, + { type: `restart` }, + { type: `truncate` }, + { type: `restart` }, + { type: `unsubscribe` }, { type: `release`, demand: `b` }, + ], + }, + { + name: `does not invent a publication for an unchanged private row`, + commands: [ + { type: `request`, demand: `a` }, { type: `cleanup` }, { type: `restart` }, + { type: `source`, key: `a`, action: `upsert`, value: 5 }, + { type: `release`, demand: `a` }, + { type: `source`, key: `a`, action: `upsert`, value: 5 }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it(`suppresses canceled source writes when an aborted acquisition settles`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory( - [ + }, + { + name: `reconciles a repeated reset after the last replay owner aborts`, + commands: [ + { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `request`, demand: `a` }, + { type: `truncate` }, { type: `abort`, demand: `a` }, + { type: `truncate` }, + { + type: `settle`, + demand: `a`, + scope: `obsolete`, + age: `newest`, + outcome: `resolve`, + }, + { type: `cleanup` }, + ], + }, + { + name: `keeps independent source rows when a replay settles after redundant restart calls`, + commands: [ + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `truncate` }, + { type: `source`, key: `b`, action: `upsert`, value: 1 }, + { type: `restart` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `newest`, + outcome: `reject`, + }, + { type: `restart` }, { type: `settle`, demand: `a`, @@ -2510,90 +2495,111 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { age: `oldest`, outcome: `resolve`, }, + { type: `truncate` }, + { type: `request`, demand: `b` }, + { type: `cleanup` }, + ], + }, + { + name: `suppresses canceled source writes when a released acquisition settles`, + commands: [ + { type: `request`, demand: `a` }, + { type: `request`, demand: `b` }, { type: `release`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `obsolete`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `b` }, { type: `cleanup` }, { type: `restart` }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it(`keeps later source changes private after a failed replay`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory([ - { type: `request`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `truncate` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, - { type: `source`, key: `b`, action: `upsert`, value: 51 }, - ]) - }) - // Releasing the final owner retires a replay that has not failed; it - // publishes the rows it holds, so none of these rows stays stale. - it.each([ - [ - `a value written during the replay`, - [ - { type: `source`, key: `a`, action: `upsert`, value: 1 }, - { type: `request`, demand: `a` }, + options: { continueAfterMismatch: true }, + }, + { + name: `publishes an authoritative truncate after the final demand is released`, + commands: [ + { type: `request`, demand: `b` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `b` }, { type: `truncate` }, - { type: `abort`, demand: `a` }, - { type: `source`, key: `a`, action: `upsert`, value: 0 }, - { type: `release`, demand: `a` }, - { type: `request`, demand: `a` }, + { type: `unsubscribe` }, ], - ], - [ - `a value written again after the replay retired`, - [ - { type: `source`, key: `a`, action: `upsert`, value: 1 }, + }, + { + name: `publishes independent source changes with a successful replay`, + commands: [ { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, { type: `truncate` }, - { type: `abort`, demand: `a` }, - { type: `source`, key: `a`, action: `upsert`, value: 2 }, + { type: `source`, key: `b`, action: `upsert`, value: 50 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, { type: `release`, demand: `a` }, - { type: `source`, key: `a`, action: `upsert`, value: 2 }, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, ], - ], - [ - `a row no demand names`, - [ - { type: `source`, key: `c`, action: `upsert`, value: 1 }, - { type: `request`, demand: `a` }, - { type: `truncate` }, - { type: `abort`, demand: `a` }, - { type: `source`, key: `c`, action: `upsert`, value: 2 }, - { type: `release`, demand: `a` }, + options: { continueAfterMismatch: true }, + }, + { + name: `does not republish a row already delivered by a live change`, + commands: [ + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `request`, demand: `b` }, + { type: `source`, key: `b`, action: `upsert`, value: 1 }, + { type: `request`, demand: `b` }, + { type: `release`, demand: `b` }, + { type: `release`, demand: `b` }, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, ], - ], - [ - `a row deleted during the replay`, - [ - { type: `source`, key: `a`, action: `upsert`, value: 1 }, + options: { continueAfterMismatch: true }, + }, + { + name: `suppresses canceled source writes when an aborted acquisition settles`, + commands: [ { type: `request`, demand: `a` }, - { type: `truncate` }, { type: `abort`, demand: `a` }, - { type: `source`, key: `a`, action: `delete`, value: 0 }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, { type: `release`, demand: `a` }, - { type: `request`, demand: `a` }, - ], - ], - [ - `a row retained by a failed replay and deleted before the next one`, - [ - { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `cleanup` }, + { type: `restart` }, + { type: `unsubscribe` }, + ], + options: { continueAfterMismatch: true }, + }, + { + name: `keeps later source changes private after a failed replay`, + commands: [ { type: `request`, demand: `a` }, { type: `settle`, @@ -2603,7 +2609,6 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { outcome: `resolve`, }, { type: `truncate` }, - { type: `source`, key: `a`, action: `delete`, value: 0 }, { type: `settle`, demand: `a`, @@ -2611,23 +2616,15 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { age: `oldest`, outcome: `reject`, }, - { type: `release`, demand: `a` }, - { type: `request`, demand: `b` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `truncate` }, - { type: `release`, demand: `b` }, + { type: `source`, key: `b`, action: `upsert`, value: 51 }, ], - ], - [ - `an undemanded row retained by a failed replay and deleted before the next one`, - [ - { type: `source`, key: `c`, action: `upsert`, value: 1 }, + }, + // Law 9: a failed replay stays private until an authoritative replay + // succeeds, even after its failed owner is released and nothing remains. + { + name: `keeps a failed replay private when its final owner releases`, + commands: [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, { type: `request`, demand: `a` }, { type: `settle`, @@ -2637,7 +2634,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { outcome: `resolve`, }, { type: `truncate` }, - { type: `source`, key: `c`, action: `delete`, value: 0 }, + { type: `source`, key: `a`, action: `upsert`, value: 5 }, { type: `settle`, demand: `a`, @@ -2646,55 +2643,11 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { outcome: `reject`, }, { type: `release`, demand: `a` }, - { type: `request`, demand: `b` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `truncate` }, - { type: `release`, demand: `b` }, ], - ], - ] 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` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `truncate` }, - { type: `source`, key: `a`, action: `upsert`, value: 5 }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `reject`, - }, - { type: `release`, demand: `a` }, - ]) - }) - it(`retires failed private replacement rows when its final owner releases`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory( - [ + { + name: `retires failed private replacement rows when its final owner releases`, + commands: [ { type: `source`, key: `b`, action: `upsert`, value: 7 }, { type: `request`, demand: `a` }, { @@ -2719,16 +2672,14 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { { type: `restart` }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - // The history above with zeros: the failed replay retains b as -0, and the - // 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( - [ + options: { continueAfterMismatch: true }, + }, + // The history above with zeros: the failed replay retains b as -0, and the + // later source write of 0 must reconcile as an update, since -0 and 0 are + // distinct row values (maintainer decision, 2026-10-29). + { + name: `reconciles a retained -0 row with a later source write of 0`, + commands: [ { type: `source`, key: `b`, action: `upsert`, value: -0 }, { type: `request`, demand: `a` }, { @@ -2753,13 +2704,11 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { { type: `restart` }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it(`does not delete an independent public row when releasing a restarted demand`, async () => { - oracleRoute(`fixed history`) - await runPublicationHistory( - [ + options: { continueAfterMismatch: true }, + }, + { + name: `does not delete an independent public row when releasing a restarted demand`, + commands: [ { type: `source`, key: `b`, action: `upsert`, value: 0 }, { type: `request`, demand: `a` }, { type: `cleanup` }, @@ -2768,15 +2717,13 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { { type: `unsubscribe` }, { type: `source`, key: `b`, action: `upsert`, value: 1 }, ], - { continueAfterMismatch: true }, - ) - }) - it(`keeps a restarted snapshot private while canceled replay work settles`, async () => { - oracleRoute(`fixed history`) + options: { continueAfterMismatch: true }, + }, // 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( - [ + { + name: `keeps a restarted snapshot private while canceled replay work settles`, + commands: [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `cleanup` }, { type: `request`, demand: `b` }, @@ -2802,15 +2749,13 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { { type: `release`, demand: `b` }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it(`matches retained rows across an empty restart and canceled-only truncates`, async () => { - oracleRoute(`fixed history`) + options: { continueAfterMismatch: true }, + }, // 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( - [ + { + name: `matches retained rows across an empty restart and canceled-only truncates`, + commands: [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, { type: `release`, demand: `a` }, { type: `cleanup` }, @@ -2840,29 +2785,42 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { { type: `release`, demand: `b` }, { type: `unsubscribe` }, ], - { continueAfterMismatch: true }, - ) - }) - it.each( - [false, true].flatMap((restart) => - [false, true].map((canceledOwner) => ({ restart, canceledOwner })), - ), - )( - `publishes an authoritative empty reset: %j`, - async ({ restart, canceledOwner }) => { - oracleRoute(`case`) - await runPublicationHistory([ + options: { continueAfterMismatch: true }, + }, + // Seed 1657005, path 164:18 after removing the visible-row request omission. + { + name: `resets retained rows after no-op cleanup and release commands`, + commands: [ + { type: `source`, key: `a`, action: `upsert`, value: 1 }, + { type: `cleanup` }, + { type: `cleanup` }, + { type: `release`, demand: `b` }, + { type: `restart` }, + { type: `truncate` }, + { type: `request`, demand: `a` }, + { type: `release`, demand: `a` }, + { type: `request`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `release`, demand: `a` }, + { type: `unsubscribe` }, + ], + }, + // Seed 333468655, path 59:13:0:0:0. + { + name: `resets retained rows after the last replay owner retires`, + commands: [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, - ...(restart - ? [{ type: `cleanup` } as const, { type: `restart` } as const] - : []), - ...(canceledOwner - ? [ - { type: `request`, demand: `b` } as const, - { type: `abort`, demand: `b` } as const, - ] - : []), + { type: `request`, demand: `b` }, + { type: `truncate` }, + { type: `release`, demand: `b` }, { type: `truncate` }, + { type: `release`, demand: `a` }, { type: `truncate` }, { type: `request`, demand: `a` }, { @@ -2873,159 +2831,84 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { outcome: `resolve`, }, { type: `release`, demand: `a` }, - { type: `release`, demand: `b` }, { type: `unsubscribe` }, - ]) + ], }, - ) - it.each( - [false, true].flatMap((canceledOwner) => - ( - [ - { id: `a`, value: 0 }, - { id: `a`, value: 1 }, - { id: `c`, value: 2 }, - ] satisfies Array - ).map((replacement) => ({ canceledOwner, replacement })), - ), - )( - `installs a retained-row replacement atomically: %j`, - async ({ canceledOwner, replacement }) => { - oracleRoute(`case`) - await runPublicationHistory([ + // Seed 1337491191, path 591:20:1:8:8:8:7:7. + { + name: `does not restore source rows when the final replay owner retires`, + commands: [ { type: `source`, key: `a`, action: `upsert`, value: 0 }, - { type: `cleanup` }, { type: `restart` }, - ...(canceledOwner - ? [ - { type: `request`, demand: `b` } as const, - { type: `abort`, demand: `b` } as const, - ] - : []), - { type: `truncate`, replacement }, - { type: `source`, key: replacement.id, action: `upsert`, value: 3 }, + { type: `truncate` }, + { type: `source`, key: `a`, action: `upsert`, value: 0 }, + { type: `request`, demand: `b` }, + { type: `truncate` }, { type: `release`, demand: `b` }, + { type: `source`, key: `a`, action: `delete`, value: 0 }, + { type: `source`, key: `a`, action: `upsert`, value: 1 }, { type: `unsubscribe` }, - ]) - }, - ) - it.each([ - undefined, - { id: `a`, value: 0 }, - { id: `a`, value: 1 }, - { id: `c`, value: 2 }, - ] 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 }, - { type: `cleanup` }, - { type: `restart` }, - { type: `truncate`, replacement }, - { type: `source`, key: `a`, action: `upsert`, value: 3 }, - { type: `unsubscribe` }, - ], - { withoutLoader: true }, - ) + ], }, - ) - 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 }, - { type: `cleanup` }, - { type: `cleanup` }, - { type: `release`, demand: `b` }, - { type: `restart` }, - { type: `truncate` }, - { type: `request`, demand: `a` }, - { type: `release`, demand: `a` }, - { type: `request`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ]) - }) - 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 }, - { type: `request`, demand: `b` }, - { type: `truncate` }, - { type: `release`, demand: `b` }, - { type: `truncate` }, - { type: `release`, demand: `a` }, - { type: `truncate` }, - { type: `request`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `release`, demand: `a` }, - { type: `unsubscribe` }, - ]) - }) - 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 }, - { type: `restart` }, - { type: `truncate` }, - { type: `source`, key: `a`, action: `upsert`, value: 0 }, - { type: `request`, demand: `b` }, - { type: `truncate` }, - { type: `release`, demand: `b` }, - { type: `source`, key: `a`, action: `delete`, value: 0 }, - { type: `source`, key: `a`, action: `upsert`, value: 1 }, - { type: `unsubscribe` }, - ]) - }) - 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 }, - { type: `request`, demand: `b` }, - { type: `cleanup` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `resolve`, - }, - { type: `restart` }, - { - type: `settle`, - demand: `b`, - scope: `current`, - age: `newest`, - outcome: `resolve`, - }, - { type: `abort`, demand: `a` }, - { - type: `settle`, - demand: `a`, - scope: `current`, - age: `oldest`, - outcome: `reject`, + { + name: `keeps an equal row restored by the restarted acquisition`, + commands: [ + { type: `source`, key: `b`, action: `upsert`, value: 1 }, + { type: `request`, demand: `b` }, + { type: `cleanup` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `resolve`, + }, + { type: `restart` }, + { + type: `settle`, + demand: `b`, + scope: `current`, + age: `newest`, + outcome: `resolve`, + }, + { type: `abort`, demand: `a` }, + { + type: `settle`, + demand: `a`, + scope: `current`, + age: `oldest`, + outcome: `reject`, + }, + { type: `release`, demand: `b` }, + ], + }, + ...greenLifecycleHistories.map((commands, index) => ({ + name: `maps every canonical green lifecycle history to public rows: ${index}`, + commands, + })), + ...staleRowHistories.map(([name, commands]) => ({ + name: `leaves no stale row after a retired replay: ${name}`, + commands, + })), + ] + oracleProperty({ + id: `subscription-lifecycle.publication-fixed`, + law: { + statement: `Each command publishes the exact coherent callback batches and public rows predicted by the independent publication model.`, + authority: `Collection.subscribeChanges, public Collection lifecycle, and the shared lifecycle grammar as stated above.`, + limits: `Named regressions and canonical histories that the generated publication command grammar cannot take back; each is replayed through the same driver and comparison.`, + }, + owns: [`src/collection/subscription.ts`, `src/collection/state.ts`], + histories: publicationFixedHistories, + label: ({ name }) => name, + ...subscriptionComparison<(typeof publicationFixedHistories)[number]>( + `subscription-lifecycle.publication-fixed`, + async ({ commands, options }) => { + await runPublicationHistory(commands, options) }, - { type: `release`, demand: `b` }, - ]) + ), + check: forwardAssertionJudgment, }) const { multiplier } = readOracleRunConfig() diff --git a/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts new file mode 100644 index 0000000000..980956f922 --- /dev/null +++ b/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts @@ -0,0 +1,226 @@ +/** + * The row-publication product grammar, shared by the row-publication oracle + * and its ordinary grammar-size test: the publication command types and the + * finite product of publication phase, source effect, settlement, suffix, and + * prior independent row. The law and its comparison live in + * collection-subscription-lifecycle-publication-oracle.property.test.ts. + * These definitions moved here unchanged so that the ordinary test can check + * the product's size without importing the oracle test file. + */ +import type { LifecycleCommand } from './collection-subscription-lifecycle-grammar-oracle.js' +import type { + PublicationPhase, + Row, + RowKey, +} from './collection-subscription-lifecycle-publication-support' + +export type SourceMutation = { + type: `source` + key: RowKey + action: `upsert` | `delete` + value: number + // Upsert the visible row's zero with the other sign; a key whose visible + // value is not a zero upserts `value`. + flip?: boolean +} + +export type PublicationCommand = + | Exclude + | (Extract & { value?: number }) + | { type: `truncate`; replacement?: Row } + | SourceMutation + +export type SourceEffect = `insert` | `update` | `delete` + +type ProductSettlement = `none` | `resolve` | `reject` + +type ProductSuffix = `release` | `cleanup` | `restart` | `unsubscribe` + +type PriorIndependentRow = `absent` | `present` + +export type PublicationProductCase = { + name: string + phase: PublicationPhase + sourceEffect: SourceEffect + settlement: ProductSettlement + suffix: ProductSuffix + priorIndependentRow: PriorIndependentRow + history: Array + focalSourceIndex: number + settlementIndex?: number + pendingProbeIndex: number + suffixIndex: number + postUnsubscribeProbeIndex: number +} + +const settleCurrent = ( + demand: DemandName, + outcome: `resolve` | `reject`, +): LifecycleCommand => ({ + type: `settle`, + demand, + scope: `current`, + age: `oldest`, + outcome, +}) + +function createPublicationProductCase( + phase: PublicationPhase, + sourceEffect: SourceEffect, + settlement: ProductSettlement, + suffix: ProductSuffix, + priorIndependentRow: PriorIndependentRow, +): PublicationProductCase { + const history: Array = [] + const push = (command: PublicationCommand): number => + history.push(command) - 1 + const hasPeer = phase === `private-settling` || phase === `private-failed` + + if (priorIndependentRow === `present`) { + push({ type: `source`, key: `d`, action: `upsert`, value: 30 }) + } + push({ type: `request`, demand: `a` }) + if (hasPeer) push({ type: `request`, demand: `b` }) + if (phase !== `public`) { + push(settleCurrent(`a`, `resolve`)) + if (hasPeer) push(settleCurrent(`b`, `resolve`)) + push({ type: `truncate` }) + if (phase === `private-settling`) { + push(settleCurrent(`b`, `resolve`)) + } else if (phase === `private-failed`) { + push(settleCurrent(`b`, `reject`)) + } + } + + if (sourceEffect !== `insert`) { + push({ type: `source`, key: `c`, action: `upsert`, value: 40 }) + } + const focalSourceIndex = push({ + type: `source`, + key: `c`, + action: sourceEffect === `delete` ? `delete` : `upsert`, + value: 41, + }) + const settlementIndex = + settlement === `none` ? undefined : push(settleCurrent(`a`, settlement)) + const pendingProbeIndex = history.length + + let suffixIndex: number + if (suffix === `release`) { + suffixIndex = push({ type: `release`, demand: `a` }) + if (hasPeer) suffixIndex = push({ type: `release`, demand: `b` }) + push({ type: `unsubscribe` }) + } else if (suffix === `cleanup`) { + suffixIndex = push({ type: `cleanup` }) + push({ type: `unsubscribe` }) + } else if (suffix === `restart`) { + push({ type: `cleanup` }) + suffixIndex = push({ type: `restart` }) + push({ type: `unsubscribe` }) + } else { + suffixIndex = push({ type: `unsubscribe` }) + } + if (suffix === `cleanup`) push({ type: `restart` }) + const postUnsubscribeProbeIndex = push({ + type: `source`, + key: `d`, + action: `upsert`, + value: 99, + }) + + return { + name: `${phase}:${sourceEffect}:${settlement}:${suffix}:prior-${priorIndependentRow}`, + phase, + sourceEffect, + settlement, + suffix, + priorIndependentRow, + history, + focalSourceIndex, + ...(settlementIndex === undefined ? {} : { settlementIndex }), + pendingProbeIndex, + suffixIndex, + postUnsubscribeProbeIndex, + } +} + +const publicationPhases = [ + `public`, + `private-pending`, + `private-settling`, + `private-failed`, +] as const + +const sourceEffects = [`insert`, `update`, `delete`] as const + +const productSettlements = [`none`, `resolve`, `reject`] as const + +const productSuffixes = [ + `release`, + `cleanup`, + `restart`, + `unsubscribe`, +] as const + +const priorIndependentRows = [`absent`, `present`] as const + +export const publicationProductCases = publicationPhases.flatMap((phase) => + sourceEffects.flatMap((sourceEffect) => + productSettlements.flatMap((settlement) => + productSuffixes.flatMap((suffix) => + priorIndependentRows.map((priorIndependentRow) => + createPublicationProductCase( + phase, + sourceEffect, + settlement, + suffix, + priorIndependentRow, + ), + ), + ), + ), + ), +) + +export const successfulReplacementCases = publicationProductCases.filter( + ({ phase, sourceEffect, settlement }) => + (phase === `private-pending` || phase === `private-settling`) && + sourceEffect !== `delete` && + settlement === `resolve`, +) + +export const replacementOrderingCases = publicationProductCases.filter( + ({ phase, sourceEffect, settlement, suffix, priorIndependentRow }) => + (phase === `private-pending` || phase === `private-settling`) && + sourceEffect === `delete` && + settlement === `resolve` && + suffix !== `release` && + priorIndependentRow === `present`, +) + +export const replacementRetirementCases = publicationProductCases.filter( + (scenario) => + scenario.phase !== `public` && + scenario.suffix === `release` && + !successfulReplacementCases.includes(scenario) && + !replacementOrderingCases.includes(scenario), +) + +export const publicationControlCases = publicationProductCases.filter( + (scenario) => + !successfulReplacementCases.includes(scenario) && + !replacementOrderingCases.includes(scenario) && + !replacementRetirementCases.includes(scenario), +) + +// Keep each semantic cell intact while giving the aggregate campaign several +// scheduler cuts. One 204-history test can exceed Vitest's per-test deadline +// when the full oracle portfolio runs concurrently, even though no history is +// slow or stuck. +export const publicationControlSlices = Array.from( + { length: 4 }, + (_, index) => ({ + name: `${index * 51 + 1}-${(index + 1) * 51}`, + scenarios: publicationControlCases.slice(index * 51, (index + 1) * 51), + }), +) diff --git a/packages/db/tests/collection-subscription-lifecycle-publication.test.ts b/packages/db/tests/collection-subscription-lifecycle-publication.test.ts index 289b1b5de7..0aa07dbc8e 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication.test.ts @@ -16,6 +16,14 @@ import { cloneRow, normalizePublicationOrder, } from './collection-subscription-lifecycle-publication-support' +import { deferralCleanupHistories } from './collection-subscription-lifecycle-grammar-oracle.js' +import { + publicationControlCases, + publicationProductCases, + replacementOrderingCases, + replacementRetirementCases, + successfulReplacementCases, +} from './collection-subscription-lifecycle-publication-product-oracle.js' import type { PublicationChange, Row, @@ -866,3 +874,24 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { }, ) }) + +// Grammar-size checks of the histories that the row-publication oracle +// replays. They test test code, so they live outside the oracle file. +describe(`CollectionSubscription lifecycle publication grammar`, () => { + it(`reconstructs every bounded deferral cleanup history`, () => { + expect(deferralCleanupHistories).toHaveLength(32) + expect(new Set(deferralCleanupHistories.map(({ name }) => name)).size).toBe( + 32, + ) + }) + it(`defines all 288 unique row-publication lifecycle cells`, () => { + expect(publicationProductCases).toHaveLength(288) + expect(new Set(publicationProductCases.map(({ name }) => name)).size).toBe( + 288, + ) + expect(successfulReplacementCases).toHaveLength(32) + expect(replacementOrderingCases).toHaveLength(6) + expect(replacementRetirementCases).toHaveLength(46) + expect(publicationControlCases).toHaveLength(204) + }) +}) diff --git a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts index 1f01026916..5f99bf97a6 100644 --- a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts @@ -1,11 +1,15 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createCollection } from '../src/collection/index.js' 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 { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import type { LoadSubsetOptions } from '../src/types.js' /** @@ -56,122 +60,137 @@ function expectSnapshotReentryOutcome(observed: { ) } -// 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 = { - [Symbol.toPrimitive]: () => { - if (armSnapshotReentry) { - armSnapshotReentry = false - reentries++ - if (action === `release`) - subscription.releaseSnapshot(requestedWhere) - else subscription.unsubscribe() - } - return `a` - }, +// The original case, unchanged: it drives a direct snapshot whose callback +// reenters with the history's action and checks the outcome at its return. +async function runSnapshotReentry({ + phase, + action, +}: (typeof reentrantSnapshotHistories)[number]): Promise { + let reentries = 0 + let armSnapshotReentry = false + const threshold = { + [Symbol.toPrimitive]: () => { + if (armSnapshotReentry) { + armSnapshotReentry = false + reentries++ + if (action === `release`) subscription.releaseSnapshot(requestedWhere) + else subscription.unsubscribe() } - const requestedWhere = - phase === `snapshotEvaluation` - ? new Func(`gt`, [new PropRef([`id`]), new Value(threshold)]) - : where() - const loads: Array = [] - const unloads: Array = [] - const publications: Array> = [] - let resultHooks = 0 - const transport = createDeferred() - const collection = createCollection<{ id: string }>({ - id: `snapshot-reentry-${phase}-${action}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - sync: { - sync: (operations) => { - operations.begin() - operations.write({ type: `insert`, value: { id: `row` } }) - operations.commit() - operations.markReady() - return { - loadSubset: (options) => { - loads.push(options) - if (phase === `loadSubset`) { - reentries++ - if (action === `release`) - subscription.releaseSnapshot(requestedWhere) - else subscription.unsubscribe() - } - return phase === `statusLoadingSubset` - ? transport.promise - : true - }, - unloadSubset: (options) => unloads.push(options), + return `a` + }, + } + const requestedWhere = + phase === `snapshotEvaluation` + ? new Func(`gt`, [new PropRef([`id`]), new Value(threshold)]) + : where() + const loads: Array = [] + const unloads: Array = [] + const publications: Array> = [] + let resultHooks = 0 + const transport = createDeferred() + const collection = createCollection<{ id: string }>({ + id: `snapshot-reentry-${phase}-${action}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (operations) => { + operations.begin() + operations.write({ type: `insert`, value: { id: `row` } }) + operations.commit() + operations.markReady() + return { + loadSubset: (options) => { + loads.push(options) + if (phase === `loadSubset`) { + reentries++ + if (action === `release`) + subscription.releaseSnapshot(requestedWhere) + else subscription.unsubscribe() } + return phase === `statusLoadingSubset` ? transport.promise : true }, - }, - }) - const subscription = collection.subscribeChanges( - (changes) => publications.push(changes.map(({ value }) => value.id)), - { includeInitialState: false }, - ) - if (phase === `statusLoadingSubset`) { - subscription.on(`status:loadingSubset`, () => { - reentries++ - if (action === `release`) subscription.releaseSnapshot(requestedWhere) - else subscription.unsubscribe() - }) - } - const reads = vi.spyOn(collection, `currentStateAsChanges`) - armSnapshotReentry = phase === `snapshotEvaluation` + unloadSubset: (options) => unloads.push(options), + } + }, + }, + }) + const subscription = collection.subscribeChanges( + (changes) => publications.push(changes.map(({ value }) => value.id)), + { includeInitialState: false }, + ) + if (phase === `statusLoadingSubset`) { + subscription.on(`status:loadingSubset`, () => { + reentries++ + if (action === `release`) subscription.releaseSnapshot(requestedWhere) + else subscription.unsubscribe() + }) + } + const reads = vi.spyOn(collection, `currentStateAsChanges`) + armSnapshotReentry = phase === `snapshotEvaluation` - try { - const accepted = subscription.requestSnapshot({ - where: requestedWhere, - onLoadSubsetResult: (_result, _options, release) => { - resultHooks++ - if (phase !== `onLoadSubsetResult`) return - reentries++ - if (action === `release`) release() - else subscription.unsubscribe() - }, - ...(phase === `onUnoptimized` - ? { - onUnoptimized: () => { - reentries++ - if (action === `release`) - subscription.releaseSnapshot(requestedWhere) - else subscription.unsubscribe() - }, - } - : {}), - }) + try { + const accepted = subscription.requestSnapshot({ + where: requestedWhere, + onLoadSubsetResult: (_result, _options, release) => { + resultHooks++ + if (phase !== `onLoadSubsetResult`) return + reentries++ + if (action === `release`) release() + else subscription.unsubscribe() + }, + ...(phase === `onUnoptimized` + ? { + onUnoptimized: () => { + reentries++ + if (action === `release`) + subscription.releaseSnapshot(requestedWhere) + else subscription.unsubscribe() + }, + } + : {}), + }) - expectSnapshotReentryOutcome({ - phase, - reentries, - resultHooks, - accepted, - publications, - loads, - unloads, - reads, - }) - } finally { - transport.resolve() - reads.mockRestore() - subscription.unsubscribe() - await collection.cleanup() - } + expectSnapshotReentryOutcome({ + phase, + reentries, + resultHooks, + accepted, + publications, + loads, + unloads, + reads, + }) + } finally { + transport.resolve() + reads.mockRestore() + subscription.unsubscribe() + await collection.cleanup() + } +} + +describe(`Collection subscription callback reentry oracle`, () => { + oracleProperty({ + id: `subscription-reentrancy.direct-snapshot`, + law: { + statement: `Once a callback that interrupts a direct snapshot retires its demand or subscription, the request returns false, publishes no local snapshot, releases an accepted acquisition once, and starts no fallback read.`, + authority: `The subscription ownership law stated above`, + limits: `The bounded reentrant phases and demand-retiring actions of reentrantSnapshotHistories, observed at the synchronous return checkpoint.`, }, - ) + // 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. + owns: [`src/collection/subscription.ts`], + histories: reentrantSnapshotHistories, + label: ({ phase, action }) => + `stops a direct snapshot after ${phase} calls ${action}`, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-reentrancy.direct-snapshot`, + plan, + observe, + () => runSnapshotReentry(plan), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/collection-subscription-replay-grammar-oracle.ts b/packages/db/tests/collection-subscription-replay-grammar-oracle.ts new file mode 100644 index 0000000000..c3d096c12a --- /dev/null +++ b/packages/db/tests/collection-subscription-replay-grammar-oracle.ts @@ -0,0 +1,165 @@ +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed } from './oracle-config.js' +import type { ReplayRow } from './collection-subscription-replay-support.js' + +/** + * The replay scenario grammar of + * collection-subscription-replay-oracle.property.test.ts: source rows, + * logical demands, replay attempts, settlement order, release points, and + * later source actions. That file holds the law, the reducers that compute + * the expected publications, and the production driver. This module holds + * only the scenario records and their arbitraries, so the grammar census in + * collection-subscription-replay.test.ts samples the same grammar. It + * registers no tests. + */ + +export type ReplayDemandId = ReplayRow[`id`] + +export type ReplayLoad = { + demandId: ReplayDemandId + rows: ReadonlyArray + outcome: `resolve` | `reject` + writeBeforeSettlement?: boolean +} + +type ReplayAttempt = { + loads: ReadonlyArray +} + +export type SourceAction = + | { type: `put`; row: ReplayRow } + | { type: `delete`; id: ReplayRow[`id`] } + | { type: `request`; demandId: ReplayDemandId } + +export type ReplayScenario = { + initialRows: ReadonlyArray + demandIds: ReadonlyArray + attempts: ReadonlyArray + settlementOrder: ReadonlyArray + settlementPhases: ReadonlyArray + releaseOnLastAttempt?: ReplayDemandId + afterSettlement: ReadonlyArray +} + +// Values include -0, a distinct row value from 0 (maintainer decision, +// 2026-10-29), so a replay can replace a row that differs only by a zero's +// sign. +const replayValue = fc.oneof(fc.integer({ min: -2, max: 2 }), fc.constant(-0)) + +export const rowArbitrary: fc.Arbitrary = fc.record({ + id: fc.constantFrom(`one` as const, `two` as const), + value: replayValue, +}) + +export const rowsArbitrary = fc.uniqueArray(rowArbitrary, { + minLength: 0, + maxLength: 2, + selector: ({ id }) => id, +}) + +function replayLoadArbitrary( + demandId: ReplayDemandId, +): fc.Arbitrary { + return fc.record({ + demandId: fc.constant(demandId), + rows: fc + .option(replayValue, { nil: undefined }) + .map((value) => (value === undefined ? [] : [{ id: demandId, value }])), + outcome: fc.constantFrom(`resolve` as const, `reject` as const), + writeBeforeSettlement: fc.boolean(), + }) +} + +function sourceActionArbitrary( + demandIds: ReadonlyArray, +): fc.Arbitrary { + return fc.oneof( + fc + .tuple(fc.constantFrom(...demandIds), replayValue) + .map(([id, value]) => ({ type: `put` as const, row: { id, value } })), + fc + .constantFrom(...demandIds) + .map((id) => ({ type: `delete` as const, id })), + ) +} + +export const replayScenarioArbitrary: fc.Arbitrary = fc + .uniqueArray(fc.constantFrom(`one`, `two`), { + minLength: 1, + maxLength: 2, + }) + .chain((demandIds) => + fc + .record({ + initialRows: rowsArbitrary, + attempts: fc.array( + fc + .tuple( + ...demandIds.map((demandId) => replayLoadArbitrary(demandId)), + ) + .map((loads) => ({ loads })), + { minLength: 1, maxLength: 3 }, + ), + releaseOnLastAttempt: fc.option(fc.constantFrom(...demandIds), { + nil: undefined, + }), + }) + .chain(({ initialRows, attempts, releaseOnLastAttempt }) => { + const replayCount = attempts.length * demandIds.length + const lastAttemptIndex = attempts.length - 1 + return fc + .record({ + settlementOrder: fc.shuffledSubarray( + Array.from({ length: replayCount }, (_, index) => index), + { minLength: replayCount, maxLength: replayCount }, + ), + rawSettlementPhases: fc.array( + fc.integer({ min: 0, max: lastAttemptIndex }), + { minLength: replayCount, maxLength: replayCount }, + ), + afterSettlement: + releaseOnLastAttempt === undefined + ? fc.array(sourceActionArbitrary(demandIds), { + minLength: 0, + maxLength: 3, + }) + : fc + .tuple( + fc.constant({ + type: `request`, + demandId: releaseOnLastAttempt, + }), + fc.array(sourceActionArbitrary(demandIds), { + minLength: 0, + maxLength: 2, + }), + ) + .map(([request, actions]) => [request, ...actions]), + }) + .map(({ settlementOrder, rawSettlementPhases, afterSettlement }) => ({ + initialRows, + demandIds, + attempts, + settlementOrder, + settlementPhases: rawSettlementPhases.map((phase, replayIndex) => + Math.max(phase, Math.floor(replayIndex / demandIds.length)), + ), + releaseOnLastAttempt, + afterSettlement, + })) + }), + ) + +/** + * The fixed-seed sample of the grammar census in + * collection-subscription-replay.test.ts. The census asserts that this sample + * holds shared, failed, stale, released, and post-replay histories. Its seed + * and run budget stay here, in a module that the oracle imports, so the run + * budget check still tracks them. + */ +export function sampleReplayGrammarCensus(): Array { + return fc.sample( + replayScenarioArbitrary, + oracleFixedSeed(1755, { numRuns: 300 }), + ) +} 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 3a73f4f324..f029bbbb13 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -12,7 +12,6 @@ import { oracleRoute, readOracleRunConfig, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, @@ -27,10 +26,20 @@ import { sortedChanges, sortedRows, } from './collection-subscription-replay-support.js' +import { + replayScenarioArbitrary, + rowsArbitrary, +} from './collection-subscription-replay-grammar-oracle.js' import type { ReplayChange, ReplayRow, } from './collection-subscription-replay-support.js' +import type { + ReplayDemandId, + ReplayLoad, + ReplayScenario, + SourceAction, +} from './collection-subscription-replay-grammar-oracle.js' import type { Collection } from '../src/collection/index.js' import type { ChangeMessageOrDeleteKeyMessage, @@ -56,7 +65,8 @@ import type { Scheduler } from 'fast-check' * rows, including during subscriber callback reentry. * * Small scenario records describe source rows, logical demands, replay - * attempts, settlement order, release points, and later source actions. Plain + * attempts, settlement order, release points, and later source actions; their + * grammar lives in collection-subscription-replay-grammar-oracle.ts. Plain * Map-based reducers compute the expected snapshot and event batches. The * production driver uses real Collection subscriptions, indexes, transactions, * and callback reentry. It compares every intermediate publication, not only @@ -71,24 +81,6 @@ import type { Scheduler } from 'fast-check' * oracle. */ -type ReplayDemandId = ReplayRow[`id`] - -type ReplayLoad = { - demandId: ReplayDemandId - rows: ReadonlyArray - outcome: `resolve` | `reject` - writeBeforeSettlement?: boolean -} - -type ReplayAttempt = { - loads: ReadonlyArray -} - -type SourceAction = - | { type: `put`; row: ReplayRow } - | { type: `delete`; id: ReplayRow[`id`] } - | { type: `request`; demandId: ReplayDemandId } - type SourceWriteOrigin = | { type: `initial`; demandId: ReplayDemandId } | { type: `replay`; demandId: ReplayDemandId; attemptIndex: number } @@ -100,16 +92,6 @@ type SourceWrite = { rows: ReadonlyArray } -type ReplayScenario = { - initialRows: ReadonlyArray - demandIds: ReadonlyArray - attempts: ReadonlyArray - settlementOrder: ReadonlyArray - settlementPhases: ReadonlyArray - releaseOnLastAttempt?: ReplayDemandId - afterSettlement: ReadonlyArray -} - type SequentialReplayLoad = { rows: ReadonlyArray outcome: `return` | `throw` | `resolve` | `reject` @@ -150,115 +132,6 @@ type PendingReplay = { settled: boolean } -// Values include -0, a distinct row value from 0 (maintainer decision, -// 2026-10-29), so a replay can replace a row that differs only by a zero's -// sign. -const replayValue = fc.oneof(fc.integer({ min: -2, max: 2 }), fc.constant(-0)) - -const rowArbitrary: fc.Arbitrary = fc.record({ - id: fc.constantFrom(`one` as const, `two` as const), - value: replayValue, -}) - -const rowsArbitrary = fc.uniqueArray(rowArbitrary, { - minLength: 0, - maxLength: 2, - selector: ({ id }) => id, -}) - -function replayLoadArbitrary( - demandId: ReplayDemandId, -): fc.Arbitrary { - return fc.record({ - demandId: fc.constant(demandId), - rows: fc - .option(replayValue, { nil: undefined }) - .map((value) => (value === undefined ? [] : [{ id: demandId, value }])), - outcome: fc.constantFrom(`resolve` as const, `reject` as const), - writeBeforeSettlement: fc.boolean(), - }) -} - -function sourceActionArbitrary( - demandIds: ReadonlyArray, -): fc.Arbitrary { - return fc.oneof( - fc - .tuple(fc.constantFrom(...demandIds), replayValue) - .map(([id, value]) => ({ type: `put` as const, row: { id, value } })), - fc - .constantFrom(...demandIds) - .map((id) => ({ type: `delete` as const, id })), - ) -} - -const replayScenarioArbitrary: fc.Arbitrary = fc - .uniqueArray(fc.constantFrom(`one`, `two`), { - minLength: 1, - maxLength: 2, - }) - .chain((demandIds) => - fc - .record({ - initialRows: rowsArbitrary, - attempts: fc.array( - fc - .tuple( - ...demandIds.map((demandId) => replayLoadArbitrary(demandId)), - ) - .map((loads) => ({ loads })), - { minLength: 1, maxLength: 3 }, - ), - releaseOnLastAttempt: fc.option(fc.constantFrom(...demandIds), { - nil: undefined, - }), - }) - .chain(({ initialRows, attempts, releaseOnLastAttempt }) => { - const replayCount = attempts.length * demandIds.length - const lastAttemptIndex = attempts.length - 1 - return fc - .record({ - settlementOrder: fc.shuffledSubarray( - Array.from({ length: replayCount }, (_, index) => index), - { minLength: replayCount, maxLength: replayCount }, - ), - rawSettlementPhases: fc.array( - fc.integer({ min: 0, max: lastAttemptIndex }), - { minLength: replayCount, maxLength: replayCount }, - ), - afterSettlement: - releaseOnLastAttempt === undefined - ? fc.array(sourceActionArbitrary(demandIds), { - minLength: 0, - maxLength: 3, - }) - : fc - .tuple( - fc.constant({ - type: `request`, - demandId: releaseOnLastAttempt, - }), - fc.array(sourceActionArbitrary(demandIds), { - minLength: 0, - maxLength: 2, - }), - ) - .map(([request, actions]) => [request, ...actions]), - }) - .map(({ settlementOrder, rawSettlementPhases, afterSettlement }) => ({ - initialRows, - demandIds, - attempts, - settlementOrder, - settlementPhases: rawSettlementPhases.map((phase, replayIndex) => - Math.max(phase, Math.floor(replayIndex / demandIds.length)), - ), - releaseOnLastAttempt, - afterSettlement, - })) - }), - ) - const sequentialReplayScenarioArbitrary: fc.Arbitrary = fc.record({ initialRows: rowsArbitrary, @@ -1672,258 +1545,259 @@ function predictFilteredReplay( return { nextOrdered, replacement } } -declareOracle({ - check: [ - runReplayScenario, - runSequentialReplayScenario, - runCleanupRestartScenario, - expectScheduledReplaySettlementIsGenerationSafe, - runSharedSubscriptionScenario, - runOptimisticReplayScenario, - ], - model: [publicationDiff, predictFilteredReplay], - grammar: [ - replayScenarioArbitrary, - sequentialReplayScenarioArbitrary, - cleanupRestartScenarioArbitrary, - sharedSubscriptionScenarioArbitrary, - optimisticReplayScenarioArbitrary, - ], -}) +// A snapshot chosen while a direct truncate replay is pending enters the +// private replacement, not the callback mirror. Its key still excludes that +// row from the next ordered local selection. An earlier subscriber's callback +// is a legal reentry cut: it can read the source replacement before this +// subscriber receives the same source transaction. +async function runFilteredSnapshotReentry(): Promise { + type Row = { id: string; value: number } + const rows: Array = [ + { id: `one`, value: 1 }, + { id: `two`, value: 2 }, + { id: `three`, value: 3 }, + ] + // The model separates a callback-visible row from rows selected locally + // during replay. Neither all source rows nor the callback mirror determines + // the next choice; only the filtered snapshot selected `one` here. + const privatelySelected = new Set( + rows.filter(({ id }) => id === `one`).map(({ id }) => id), + ) + const initialPublicRows = new Map([[`one`, rows[0]!]]) + const { nextOrdered, replacement: expectedReplacement } = + predictFilteredReplay(rows, initialPublicRows, privatelySelected) + const publicRows = new Map() -describe(`CollectionSubscription replay oracle`, () => { - it(`generates shared, failed, stale, released, and post-replay histories`, () => { - oracleRoute(`fixed seed`) - const scenarios = fc.sample( - replayScenarioArbitrary, - oracleFixedSeed(1755, { numRuns: 300 }), + let begin!: () => void + let write!: (message: { type: `insert`; value: Row }) => void + let commit!: () => void + let truncate!: () => void + const heldReplay = createDeferred() + const loadOptions: Array = [] + let holdReplayRequest = false + let heldRequestCount = 0 + const collection = createCollection({ + id: `ordered-replay-mixed-snapshot-reentry`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + begin = params.begin + write = params.write + commit = params.commit + truncate = params.truncate + begin() + for (const value of rows) write({ type: `insert`, value }) + commit() + params.markReady() + return { + loadSubset: (options) => { + loadOptions.push(options) + if ( + holdReplayRequest && + options.orderBy !== undefined && + options.offset === 0 && + options.cursor === undefined + ) { + holdReplayRequest = false + heldRequestCount++ + return heldReplay.promise + } + return true + }, + unloadSubset: () => {}, + } + }, + }, + }) + const index = collection.createIndex((row) => row.value, { + indexType: BTreeIndex, + }) + const orderBy = valueOrderBy() + const whereOne = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) + const batches: Array> = [] + const invalidChanges: Array = [] + let armed = false + let reentered = false + const trigger = collection.subscribeChanges( + (changes) => { + if (!armed || !changes.some(({ type }) => type === `insert`)) return + armed = false + reentered = true + target.requestSnapshot({ where: whereOne }) + target.requestLimitedSnapshot({ orderBy, limit: 1 }) + }, + { includeInitialState: false }, + ) + const target = collection.subscribeChanges((changes) => { + if (changes.length === 0) return + const batch: Array = [] + for (const change of changes) { + batch.push(`${change.type}:${change.key}`) + if (change.type === `insert`) { + if (publicRows.has(String(change.key))) + invalidChanges.push(`duplicate insert:${change.key}`) + publicRows.set(String(change.key), change.value) + } else if (change.type === `delete`) { + publicRows.delete(String(change.key)) + } else { + publicRows.set(String(change.key), change.value) + } + } + batches.push(batch) + }) + target.setOrderByIndex(index) + try { + target.requestLimitedSnapshot({ orderBy, limit: 1 }) + oracleAssertion('existing comparison at 1702', () => + expect(batches).toEqual([[`insert:one`]]), ) + batches.length = 0 - oracleAssertion('existing comparison at 1561', () => - expect(scenarios.some(({ demandIds }) => demandIds.length > 1)).toBe( - true, - ), + target.requestSnapshot({ + where: new Func(`eq`, [new PropRef([`id`]), new Value(`missing`)]), + }) + holdReplayRequest = true + begin() + truncate() + commit() + await flushPromises() + oracleAssertion('existing comparison at 1713', () => + expect(heldRequestCount).toBe(1), ) - oracleAssertion('existing comparison at 1562', () => - expect(scenarios.some(({ attempts }) => attempts.length > 1)).toBe(true), + + armed = true + begin() + for (const value of rows) write({ type: `insert`, value }) + commit() + await flushPromises() + + oracleAssertion('existing comparison at 1721', () => + expect(reentered).toBe(true), ) - oracleAssertion('existing comparison at 1563', () => - expect( - scenarios.some(({ attempts }) => - attempts.some(({ loads }) => - loads.some(({ outcome }) => outcome === `reject`), - ), - ), - ).toBe(true), + oracleAssertion('existing comparison at 1722', () => + expect(batches).toEqual([]), ) - oracleAssertion('existing comparison at 1570', () => - expect( - scenarios.some(({ attempts }) => - attempts.some(({ loads }) => - loads.some(({ writeBeforeSettlement }) => writeBeforeSettlement), - ), - ), - ).toBe(true), + oracleAssertion('existing comparison at 1723', () => + expect(loadOptions.at(-1)).toMatchObject({ offset: 0 }), ) - oracleAssertion('existing comparison at 1577', () => - expect( - scenarios.some(({ settlementOrder }) => - settlementOrder.some((value, index) => value !== index), - ), - ).toBe(true), + oracleAssertion('existing comparison at 1724', () => + expect(loadOptions.at(-1)?.limit).toBeGreaterThanOrEqual( + 1 + privatelySelected.size + Number(nextOrdered !== undefined), + ), ) - oracleAssertion('existing comparison at 1582', () => - expect( - scenarios.some(({ releaseOnLastAttempt }) => - Boolean(releaseOnLastAttempt), - ), - ).toBe(true), + oracleAssertion('existing comparison at 1727', () => + expect(loadOptions.at(-1)?.cursor?.lastKey).toBeUndefined(), ) - oracleAssertion('existing comparison at 1587', () => - expect( - scenarios.some(({ afterSettlement }) => afterSettlement.length > 0), - ).toBe(true), + heldReplay.resolve() + await flushPromises() + oracleAssertion('existing comparison at 1730', () => + expect(batches.map((batch) => [...batch].sort())).toEqual([ + expectedReplacement.sort(), + ]), ) - oracleAssertion('existing comparison at 1590', () => - expect( - scenarios.some(({ afterSettlement }) => - afterSettlement.some(({ type }) => type === `request`), - ), - ).toBe(true), + oracleAssertion('existing comparison at 1733', () => + expect(invalidChanges).toEqual([]), ) - }) + oracleAssertion('existing comparison at 1734', () => + expect([...publicRows.keys()].sort()).toEqual( + rows.map(({ id }) => id).sort(), + ), + ) + } finally { + heldReplay.resolve() + trigger.unsubscribe() + target.unsubscribe() + await collection.cleanup() + } +} +describe(`CollectionSubscription replay oracle`, () => { // A snapshot chosen while a direct truncate replay is pending enters the // private replacement, not the callback mirror. Its key still excludes that // row from the next ordered local selection. An earlier subscriber's callback // 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 }, - { id: `two`, value: 2 }, - { id: `three`, value: 3 }, - ] - // The model separates a callback-visible row from rows selected locally - // during replay. Neither all source rows nor the callback mirror determines - // the next choice; only the filtered snapshot selected `one` here. - const privatelySelected = new Set( - rows.filter(({ id }) => id === `one`).map(({ id }) => id), - ) - const initialPublicRows = new Map([[`one`, rows[0]!]]) - const { nextOrdered, replacement: expectedReplacement } = - predictFilteredReplay(rows, initialPublicRows, privatelySelected) - const publicRows = new Map() - - let begin!: () => void - let write!: (message: { type: `insert`; value: Row }) => void - let commit!: () => void - let truncate!: () => void - const heldReplay = createDeferred() - const loadOptions: Array = [] - let holdReplayRequest = false - let heldRequestCount = 0 - const collection = createCollection({ - id: `ordered-replay-mixed-snapshot-reentry`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - sync: { - sync: (params) => { - begin = params.begin - write = params.write - commit = params.commit - truncate = params.truncate - begin() - for (const value of rows) write({ type: `insert`, value }) - commit() - params.markReady() - return { - loadSubset: (options) => { - loadOptions.push(options) - if ( - holdReplayRequest && - options.orderBy !== undefined && - options.offset === 0 && - options.cursor === undefined - ) { - holdReplayRequest = false - heldRequestCount++ - return heldReplay.promise - } - return true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const index = collection.createIndex((row) => row.value, { - indexType: BTreeIndex, - }) - const orderBy = valueOrderBy() - const whereOne = new Func(`eq`, [new PropRef([`id`]), new Value(`one`)]) - const batches: Array> = [] - const invalidChanges: Array = [] - let armed = false - let reentered = false - const trigger = collection.subscribeChanges( - (changes) => { - if (!armed || !changes.some(({ type }) => type === `insert`)) return - armed = false - reentered = true - target.requestSnapshot({ where: whereOne }) - target.requestLimitedSnapshot({ orderBy, limit: 1 }) - }, - { includeInitialState: false }, - ) - const target = collection.subscribeChanges((changes) => { - if (changes.length === 0) return - const batch: Array = [] - for (const change of changes) { - batch.push(`${change.type}:${change.key}`) - if (change.type === `insert`) { - if (publicRows.has(String(change.key))) - invalidChanges.push(`duplicate insert:${change.key}`) - publicRows.set(String(change.key), change.value) - } else if (change.type === `delete`) { - publicRows.delete(String(change.key)) - } else { - publicRows.set(String(change.key), change.value) - } - } - batches.push(batch) - }) - target.setOrderByIndex(index) - try { - target.requestLimitedSnapshot({ orderBy, limit: 1 }) - oracleAssertion('existing comparison at 1702', () => - expect(batches).toEqual([[`insert:one`]]), - ) - batches.length = 0 - - target.requestSnapshot({ - where: new Func(`eq`, [new PropRef([`id`]), new Value(`missing`)]), - }) - holdReplayRequest = true - begin() - truncate() - commit() - await flushPromises() - oracleAssertion('existing comparison at 1713', () => - expect(heldRequestCount).toBe(1), - ) - - armed = true - begin() - for (const value of rows) write({ type: `insert`, value }) - commit() - await flushPromises() + oracleProperty({ + id: `subscription-replay.filtered-reentry`, + law: { + statement: `A snapshot selected privately during a pending replay excludes its key from the next ordered local selection, and the replay then publishes the filtered-replay model's replacement batch.`, + authority: `Collection subscribeChanges contract; the replay laws stated above and the filtered-replay model.`, + limits: `The one callback-reentry history with three static rows and one held replay request.`, + }, + owns: [`src/collection/subscription.ts`], + histories: [ + `continues after a privately selected filtered snapshot during callback reentry`, + ] as const, + label: (name) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-replay.filtered-reentry`, + plan, + observe, + runFilteredSnapshotReentry, + ), + check: forwardAssertionJudgment, + }) - oracleAssertion('existing comparison at 1721', () => - expect(reentered).toBe(true), - ) - oracleAssertion('existing comparison at 1722', () => - expect(batches).toEqual([]), - ) - oracleAssertion('existing comparison at 1723', () => - expect(loadOptions.at(-1)).toMatchObject({ offset: 0 }), - ) - oracleAssertion('existing comparison at 1724', () => - expect(loadOptions.at(-1)?.limit).toBeGreaterThanOrEqual( - 1 + privatelySelected.size + Number(nextOrdered !== undefined), - ), - ) - oracleAssertion('existing comparison at 1727', () => - expect(loadOptions.at(-1)?.cursor?.lastKey).toBeUndefined(), - ) - heldReplay.resolve() - await flushPromises() - oracleAssertion('existing comparison at 1730', () => - expect(batches.map((batch) => [...batch].sort())).toEqual([ - expectedReplacement.sort(), - ]), - ) - oracleAssertion('existing comparison at 1733', () => - expect(invalidChanges).toEqual([]), - ) - oracleAssertion('existing comparison at 1734', () => - expect([...publicRows.keys()].sort()).toEqual( - rows.map(({ id }) => id).sort(), - ), - ) - } finally { - heldReplay.resolve() - trigger.unsubscribe() - target.unsubscribe() - await collection.cleanup() + // 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, + oracleFixedSeed(1756, { numRuns: generatedRuns }), + )) { + await runReplayScenario(scenario) } + oracleAssertion('existing comparison at 2105', () => + expect(zeroSignReplacements).toBeGreaterThan(0), + ) }) +}) - it(`keeps private row tracking through consecutive failed replays`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ +// The existing independent helper retains its model and comparison checkpoints. +oracleProperty({ + id: `subscription-replay.completion`, + law: { + statement: `Replay publication is atomic and obsolete work cannot publish.`, + authority: `Collection subscribeChanges contract; the replay and cleanup laws stated above`, + limits: `The existing bounded scenario grammar and its public-row, error, readiness, and event checkpoints; no additional histories are claimed.`, + }, + // What this oracle owns: the gap report judges every function in + // collection/subscription.ts by this file's replay histories. + owns: [`src/collection/subscription.ts`], + histories: { + arbitrary: replayScenarioArbitrary, + runs: 30, + fixedSeeds: [1756], + }, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `Replay publication is atomic and obsolete work cannot publish.`, + plan, + observe, + () => runReplayScenario(plan.history), + ), + check: forwardAssertionJudgment, +}) + +// Named regressions that the replay scenario grammar cannot take back: its +// arbitrary chains without an unmapper. Each replays the same driver and +// comparison as the generated completion campaign. +const replayFixedScenarios: ReadonlyArray<{ + name: string + history: ReplayScenario +}> = [ + { + name: `keeps private row tracking through consecutive failed replays`, + history: { initialRows: [ { id: `one`, value: -2 }, { id: `two`, value: 0 }, @@ -1964,12 +1838,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [0, 2, 1], settlementPhases: [0, 1, 2], afterSettlement: [], - }) - }) - - it(`retains a successful retry after retiring its failed peer`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `retains a successful retry after retiring its failed peer`, + history: { initialRows: [{ id: `two`, value: 0 }], demandIds: [`one`, `two`], attempts: [ @@ -2010,23 +1883,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementPhases: [0, 0, 1, 1], releaseOnLastAttempt: `one`, afterSettlement: [{ type: `request`, demandId: `one` }], - }) - }) - - it(`does not republish an identical snapshot after a synchronous replay failure`, async () => { - oracleRoute(`fixed history`) - await runSequentialReplayScenario({ - initialRows: [{ id: `one`, value: 0 }], - loads: [ - { rows: [], outcome: `throw` }, - { rows: [{ id: `one`, value: 0 }], outcome: `return` }, - ], - }) - }) - - it(`keeps a same-key source replacement private after a failed replay`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `keeps a same-key source replacement private after a failed replay`, + history: { initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], attempts: [ @@ -2037,12 +1898,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [0], settlementPhases: [0], afterSettlement: [{ type: `put`, row: { id: `one`, value: 2 } }], - }) - }) - - it(`does not let an unpublished truncate delete suppress a later insert`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `does not let an unpublished truncate delete suppress a later insert`, + history: { initialRows: [], demandIds: [`two`, `one`], attempts: [ @@ -2070,12 +1930,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [0, 1, 2, 3], settlementPhases: [0, 0, 1, 1], afterSettlement: [{ type: `put`, row: { id: `two`, value: 1 } }], - }) - }) - - it(`lets the newest successful replay replace an older failed replay`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `lets the newest successful replay replace an older failed replay`, + history: { initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], attempts: [ @@ -2095,12 +1954,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [1, 0], settlementPhases: [1, 1], afterSettlement: [], - }) - }) - - it(`ignores an obsolete replay that settles after the newest replay`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `ignores an obsolete replay that settles after the newest replay`, + history: { initialRows: [{ id: `one`, value: 0 }], demandIds: [`one`], attempts: [ @@ -2126,12 +1984,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [1, 0], settlementPhases: [1, 1], afterSettlement: [], - }) - }) - - it(`releases every successful overlapping replay acquisition`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `releases every successful overlapping replay acquisition`, + history: { initialRows: [], demandIds: [`one`], attempts: [ @@ -2145,12 +2002,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [1, 0], settlementPhases: [1, 1], afterSettlement: [], - }) - }) - - it(`uses the newest complete multi-demand replay`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `uses the newest complete multi-demand replay`, + history: { initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`, `two`], attempts: [ @@ -2182,12 +2038,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [2, 3, 0, 1], settlementPhases: [1, 1, 1, 1], afterSettlement: [], - }) - }) - - it(`retains applied rows after their replay demand is released`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `retains applied rows after their replay demand is released`, + history: { initialRows: [{ id: `two`, value: 0 }], demandIds: [`one`, `two`], attempts: [ @@ -2211,13 +2066,12 @@ describe(`CollectionSubscription replay oracle`, () => { settlementPhases: [0, 0], releaseOnLastAttempt: `one`, afterSettlement: [{ type: `request`, demandId: `one` }], - }) - }) - - 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({ + name: `refreshes a retained row when its final released demand is reacquired`, + history: { initialRows: [{ id: `one`, value: 0 }], demandIds: [`one`], attempts: [ @@ -2236,12 +2090,11 @@ describe(`CollectionSubscription replay oracle`, () => { settlementPhases: [0], releaseOnLastAttempt: `one`, afterSettlement: [{ type: `request`, demandId: `one` }], - }) - }) - - it(`replaces a retained snapshot with a later empty replay`, async () => { - oracleRoute(`fixed history`) - await runReplayScenario({ + }, + }, + { + name: `replaces a retained snapshot with a later empty replay`, + history: { initialRows: [{ id: `one`, value: 1 }], demandIds: [`one`], attempts: [ @@ -2255,44 +2108,21 @@ describe(`CollectionSubscription replay oracle`, () => { settlementOrder: [0, 1], settlementPhases: [0, 1], afterSettlement: [], - }) - }) - - // 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, - oracleFixedSeed(1756, { numRuns: generatedRuns }), - )) { - await runReplayScenario(scenario) - } - oracleAssertion('existing comparison at 2105', () => - expect(zeroSignReplacements).toBeGreaterThan(0), - ) - }) -}) + }, + }, +] -// The existing independent helper retains its model and comparison checkpoints. oracleProperty({ - id: `subscription-replay.completion`, + id: `subscription-replay.completion-fixed`, law: { statement: `Replay publication is atomic and obsolete work cannot publish.`, authority: `Collection subscribeChanges contract; the replay and cleanup laws stated above`, - limits: `The existing bounded scenario grammar and its public-row, error, readiness, and event checkpoints; no additional histories are claimed.`, + limits: `Named regressions that the generated scenario grammar cannot take back; each is replayed through the same driver and comparison.`, }, - // What this oracle owns: the gap report judges every function in - // collection/subscription.ts by this file's replay histories. owns: [`src/collection/subscription.ts`], - histories: { - arbitrary: replayScenarioArbitrary, - runs: 30, - fixedSeeds: [1756], - }, - resolve: (history) => ({ history }), + histories: replayFixedScenarios, + label: ({ name }) => name, + resolve: ({ history }) => ({ history }), model: recordedAssertionJudgment, driver: (plan, observe) => recordAssertionJudgment( @@ -2317,6 +2147,18 @@ oracleProperty({ arbitrary: sequentialReplayScenarioArbitrary, runs: 30, fixedSeeds: [1761], + fixed: [ + { + name: `does not republish an identical snapshot after a synchronous replay failure`, + history: { + initialRows: [{ id: `one`, value: 0 }], + loads: [ + { rows: [], outcome: `throw` }, + { rows: [{ id: `one`, value: 0 }], outcome: `return` }, + ], + }, + }, + ], }, resolve: (history) => ({ history }), model: recordedAssertionJudgment, diff --git a/packages/db/tests/collection-subscription-replay.test.ts b/packages/db/tests/collection-subscription-replay.test.ts index ad5847453a..da0d95faba 100644 --- a/packages/db/tests/collection-subscription-replay.test.ts +++ b/packages/db/tests/collection-subscription-replay.test.ts @@ -15,6 +15,8 @@ import { sortedChanges, sortedRows, } from './collection-subscription-replay-support.js' +import { oracleAssertion } from './oracle-assertion-adapter.js' +import { sampleReplayGrammarCensus } from './collection-subscription-replay-grammar-oracle.js' import type { ReplayChange, ReplayRow, @@ -3038,3 +3040,65 @@ describe(`CollectionSubscription replay oracle`, () => { }, ) }) + +// A grammar census of the replay scenario grammar: a fixed-seed sample must +// contain shared, failed, stale, released, and post-replay histories. It runs +// no production, so it moved here from the oracle file. +describe(`CollectionSubscription replay scenario grammar`, () => { + it(`generates shared, failed, stale, released, and post-replay histories`, () => { + const scenarios = sampleReplayGrammarCensus() + + oracleAssertion('existing comparison at 1561', () => + expect(scenarios.some(({ demandIds }) => demandIds.length > 1)).toBe( + true, + ), + ) + oracleAssertion('existing comparison at 1562', () => + expect(scenarios.some(({ attempts }) => attempts.length > 1)).toBe(true), + ) + oracleAssertion('existing comparison at 1563', () => + expect( + scenarios.some(({ attempts }) => + attempts.some(({ loads }) => + loads.some(({ outcome }) => outcome === `reject`), + ), + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1570', () => + expect( + scenarios.some(({ attempts }) => + attempts.some(({ loads }) => + loads.some(({ writeBeforeSettlement }) => writeBeforeSettlement), + ), + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1577', () => + expect( + scenarios.some(({ settlementOrder }) => + settlementOrder.some((value, index) => value !== index), + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1582', () => + expect( + scenarios.some(({ releaseOnLastAttempt }) => + Boolean(releaseOnLastAttempt), + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1587', () => + expect( + scenarios.some(({ afterSettlement }) => afterSettlement.length > 0), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1590', () => + expect( + scenarios.some(({ afterSettlement }) => + afterSettlement.some(({ type }) => type === `request`), + ), + ).toBe(true), + ) + }) +}) diff --git a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts index 05b6109fcf..2c5ff4d514 100644 --- a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts @@ -30,23 +30,34 @@ * `optimistic-history-oracle.ts`. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, 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 { oracleProperty, pass, violation } from './oracle-config.js' import { - oracleProperty, - oracleRoute, - pass, - violation, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import { flushPromises } from './utils.js' import { createSyncHarness, stageInsert, } from './collection-sync-reentrancy-support.js' +import { + describeIntent, + overlay, + readyCases, + replacement, + sourceBefore, + sourceValues, +} from './collection-sync-reentrancy-ready-grammar-oracle.js' import type { Row, SyncOps } from './collection-sync-reentrancy-support.js' +import type { + ReadyCase, + ReadyIntent, +} from './collection-sync-reentrancy-ready-grammar-oracle.js' import type { Observation, Verdict } from './oracle-config.js' type ListenerAction = `commit` | `abort` @@ -298,276 +309,201 @@ async function runListenerScenario( } } -declareOracle({ - check: [runListenerScenario], - grammar: [listenerScenarioArbitrary, exhaustiveListenerScenarios], -}) - describe(`sync publication reentrancy`, () => { - // A truncate marks the Collection ready before it publishes its batch, and a - // ready callback can write in that window. The callback's messages and the - // truncate batch together must be valid for every subscriber, and must end - // at the Collection's rows, whatever optimistic requests were already - // active. A subscriber without initial state has no sent-key filter to hide - // a repeated message. A filtered subscriber keeps only rows whose value is - // `one`, so a removal must reach it with the row it holds. Subscribers - // receive the truncate's messages only after the Collection is ready. A - // message that carries a source row is synced and remote unless a prior - // request owns its key. A subscriber that the callback creates starts from - // the rows it sees, and a live query becomes ready showing replaced rows. - // - // The model overlays active intents, in order, over source rows. Before the - // truncate, the source holds keys 1 and 4. The replacement holds 1 and 2, so - // key 4's prefix delete must remain. A prior request is active before the - // truncate. The callback then writes once against the rows it can see. - type ReadyIntent = - | { type: `update` | `insert`; key: number; value: string } - | { type: `delete`; key: number } - const overlay = ( - rows: ReadonlyArray, - intents: ReadonlyArray, - ) => { - const visible = new Map(rows) - for (const intent of intents) { - if (!intent) continue - if (intent.type === `delete`) visible.delete(intent.key) - else visible.set(intent.key, intent.value) + // The original ready-callback case, unchanged: its comparisons stay at their + // cuts, and the enumerated property below runs it once per case. + async function runReadyTruncateCase({ + hook, + prior, + callback, + subscriber, + }: ReadyCase): Promise { + const persistence = createDeferred() + let sync!: SyncOps + const collection = createCollection({ + id: `truncate-ready-${hook}-${describeIntent(prior)}-${describeIntent(callback)}-${subscriber}`, + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (ops) => { + sync = ops + ops.begin() + for (const [id, value] of sourceBefore) + ops.write({ type: `insert`, value: { id, value } }) + ops.commit() + }, + }, + onInsert: () => persistence.promise, + onUpdate: () => persistence.promise, + onDelete: () => persistence.promise, + }) + const requests: Array<{ when: (state: 'settled') => Promise }> = [] + const apply = (intent: ReadyIntent) => { + requests.push( + intent.type === `delete` + ? collection.delete(intent.key) + : intent.type === `insert` + ? collection.insert({ id: intent.key, value: intent.value }) + : collection.update(intent.key, (draft) => { + draft.value = intent.value + }), + ) } - return visible - } - const sourceBefore = [ - [1, `one`], - [4, `four`], - ] as const - const replacement = [ - [1, `one-again`], - [2, `two`], - ] as const - const priorIntents: Record = { - none: undefined, - 'update 1': { type: `update`, key: 1, value: `prior-one` }, - 'delete 1': { type: `delete`, key: 1 }, - 'insert 3': { type: `insert`, key: 3, value: `prior-three` }, - } - const callbackIntents = (visible: ReadonlyMap) => - [1, 2, 3, 4].flatMap((key): Array => - visible.has(key) - ? [ - { type: `update`, key, value: `callback-${key}` }, - { type: `delete`, key }, - ] - : [{ type: `insert`, key, value: `callback-${key}` }], + await flushPromises() + const keeps = (value: string) => + subscriber !== `filtered` || value === `one` + // A subscriber without initial state starts from the rows it can see. + const mirror = new Map( + subscriber === `initial` + ? [] + : [...collection.state] + .map(([key, row]) => [key, row.value] as const) + .filter(([, value]) => keeps(value)), ) - const sourceValues = new Set( - [...sourceBefore, ...replacement].map(([, value]) => value), - ) - const describeIntent = (intent: ReadyIntent | undefined) => - intent ? `${intent.type} ${intent.key}` : `none` - const readyCases = ([`onFirstReady`, `status:change`] as const).flatMap( - (hook) => - Object.entries(priorIntents).flatMap(([priorName, prior]) => - callbackIntents(overlay(replacement, [prior])).flatMap((callback) => - ([`initial`, `raw`, `filtered`] as const).map((subscriber) => ({ - hook, - priorName, - prior, - callback, - callbackName: describeIntent(callback), - subscriber, - })), - ), - ), - ) - - declareOracle({ - model: [overlay], - grammar: [readyCases], - }) - - 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( - readyCases.map( - ({ priorName, callbackName }) => `${priorName} / ${callbackName}`, - ), + const violations: Array = [] + let truncating = false + const subscription = collection.subscribeChanges( + (changes) => { + if (truncating && collection.status !== `ready`) + violations.push(`delivered while ${collection.status}`) + for (const change of changes) { + if (mirror.has(change.key) === (change.type === `insert`)) + violations.push(`${change.type} ${change.key}`) + if ( + change.type !== `delete` && + sourceValues.has(change.value.value) && + prior?.key !== change.key && + (change.value.$hasPendingWrites || + change.value.$origin !== `remote`) + ) + violations.push(`source row ${change.key} published as local`) + if (change.type === `delete`) mirror.delete(change.key) + else mirror.set(change.key, change.value.value) + } + }, + subscriber === `filtered` + ? { + includeInitialState: false, + where: (row) => eq(row.value, `one`), + } + : { includeInitialState: subscriber === `initial` }, ) - for (const witness of [ - `none / delete 1`, - `none / delete 2`, - `none / insert 4`, - `update 1 / delete 1`, - `none / insert 3`, - `insert 3 / delete 3`, - `delete 1 / insert 1`, - ]) - expect(labels).toContain(witness) - expect(labels).not.toContain(`delete 1 / delete 1`) - expect(labels).not.toContain(`insert 3 / insert 3`) - }) - - 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({ - id: `truncate-ready-${hook}-${describeIntent(prior)}-${describeIntent(callback)}-${subscriber}`, - getKey: (row) => row.id, - startSync: true, - sync: { - sync: (ops) => { - sync = ops - ops.begin() - for (const [id, value] of sourceBefore) - ops.write({ type: `insert`, value: { id, value } }) - ops.commit() - }, - }, - onInsert: () => persistence.promise, - onUpdate: () => persistence.promise, - onDelete: () => persistence.promise, - }) - const requests: Array<{ when: (state: 'settled') => Promise }> = - [] - const apply = (intent: ReadyIntent) => { - requests.push( - intent.type === `delete` - ? collection.delete(intent.key) - : intent.type === `insert` - ? collection.insert({ id: intent.key, value: intent.value }) - : collection.update(intent.key, (draft) => { - draft.value = intent.value - }), - ) - } - await flushPromises() - const keeps = (value: string) => - subscriber !== `filtered` || value === `one` - // A subscriber without initial state starts from the rows it can see. - const mirror = new Map( - subscriber === `initial` - ? [] - : [...collection.state] - .map(([key, row]) => [key, row.value] as const) - .filter(([, value]) => keeps(value)), - ) - const violations: Array = [] - let truncating = false - const subscription = collection.subscribeChanges( + let callbackRan = false + let callbackSubscription: { unsubscribe: () => void } | undefined + const write = () => { + if (callbackRan) return + callbackRan = true + apply(callback) + const seen = new Set(collection.state.keys()) + callbackSubscription = collection.subscribeChanges( (changes) => { - if (truncating && collection.status !== `ready`) - violations.push(`delivered while ${collection.status}`) for (const change of changes) { - if (mirror.has(change.key) === (change.type === `insert`)) - violations.push(`${change.type} ${change.key}`) - if ( - change.type !== `delete` && - sourceValues.has(change.value.value) && - prior?.key !== change.key && - (change.value.$hasPendingWrites || - change.value.$origin !== `remote`) - ) - violations.push(`source row ${change.key} published as local`) - if (change.type === `delete`) mirror.delete(change.key) - else mirror.set(change.key, change.value.value) + if (seen.has(change.key) === (change.type === `insert`)) + violations.push( + `callback subscriber ${change.type} ${change.key}`, + ) + if (change.type === `delete`) seen.delete(change.key) + else seen.add(change.key) } }, - subscriber === `filtered` - ? { - includeInitialState: false, - where: (row) => eq(row.value, `one`), - } - : { includeInitialState: subscriber === `initial` }, + { includeInitialState: false }, ) - let callbackRan = false - let callbackSubscription: { unsubscribe: () => void } | undefined - const write = () => { - if (callbackRan) return - callbackRan = true - apply(callback) - const seen = new Set(collection.state.keys()) - callbackSubscription = collection.subscribeChanges( - (changes) => { - for (const change of changes) { - if (seen.has(change.key) === (change.type === `insert`)) - violations.push( - `callback subscriber ${change.type} ${change.key}`, - ) - if (change.type === `delete`) seen.delete(change.key) - else seen.add(change.key) - } - }, - { includeInitialState: false }, - ) - } - const live = createLiveQueryCollection((q) => q.from({ row: collection })) - let liveAtReady: Array | undefined - live.onFirstReady(() => { - liveAtReady = [...live.state] - .map(([key, row]) => [key as number, row.value] as const) - .sort(([a], [b]) => a - b) + } + const live = createLiveQueryCollection((q) => q.from({ row: collection })) + let liveAtReady: Array | undefined + live.onFirstReady(() => { + liveAtReady = [...live.state] + .map(([key, row]) => [key as number, row.value] as const) + .sort(([a], [b]) => a - b) + }) + void live.preload() + if (hook === `onFirstReady`) collection.onFirstReady(write) + else + collection.on(`status:change`, ({ status }) => { + if (status === `ready`) write() }) - void live.preload() - if (hook === `onFirstReady`) collection.onFirstReady(write) - else - collection.on(`status:change`, ({ status }) => { - if (status === `ready`) write() - }) - - try { - if (prior) apply(prior) - expect( - [...collection.state].map(([key, row]) => [key, row.value]), - ).toEqual([...overlay(sourceBefore, [prior])]) - sync.begin() - sync.truncate() - for (const [id, value] of replacement) - sync.write({ type: `insert`, value: { id, value } }) - truncating = true - expect(sync.commit()).toBe(true) + try { + if (prior) apply(prior) + expect( + [...collection.state].map(([key, row]) => [key, row.value]), + ).toEqual([...overlay(sourceBefore, [prior])]) + + sync.begin() + sync.truncate() + for (const [id, value] of replacement) + sync.write({ type: `insert`, value: { id, value } }) + truncating = true + expect(sync.commit()).toBe(true) + + expect(callbackRan).toBe(true) + await flushPromises() + expect(violations).toEqual([]) + const expected = [...overlay(replacement, [prior, callback])].sort( + ([a], [b]) => a - b, + ) + const replaced = [...overlay(replacement, [prior])].sort( + ([a], [b]) => a - b, + ) + expect([replaced, expected]).toContainEqual(liveAtReady) + expect([...mirror].sort(([a], [b]) => a - b)).toEqual( + expected.filter(([, value]) => keeps(value)), + ) + expect( + [...collection.state] + .map(([key, row]) => [key, row.value] as const) + .sort(([a], [b]) => a - b), + ).toEqual(expected) + } finally { + subscription.unsubscribe() + callbackSubscription?.unsubscribe() + await live.cleanup() + persistence.resolve() + await Promise.all( + requests.map((request) => + request.when(`settled`).catch(() => undefined), + ), + ) + await collection.cleanup() + } + } - expect(callbackRan).toBe(true) - await flushPromises() - expect(violations).toEqual([]) - const expected = [...overlay(replacement, [prior, callback])].sort( - ([a], [b]) => a - b, - ) - const replaced = [...overlay(replacement, [prior])].sort( - ([a], [b]) => a - b, - ) - expect([replaced, expected]).toContainEqual(liveAtReady) - expect([...mirror].sort(([a], [b]) => a - b)).toEqual( - expected.filter(([, value]) => keeps(value)), - ) - expect( - [...collection.state] - .map(([key, row]) => [key, row.value] as const) - .sort(([a], [b]) => a - b), - ).toEqual(expected) - } finally { - subscription.unsubscribe() - callbackSubscription?.unsubscribe() - await live.cleanup() - persistence.resolve() - await Promise.all( - requests.map((request) => - request.when(`settled`).catch(() => undefined), - ), - ) - await collection.cleanup() - } + oracleProperty({ + id: `collection-sync.ready-truncate`, + law: { + statement: `A ready callback that writes during a truncate publishes, with the truncate batch, valid messages for every subscriber after the Collection is ready, ending at the overlay of active intents on the replacement rows.`, + authority: `Collection sync truncate and ready-callback contracts stated above`, + limits: `The ${readyCases.length} enumerated hook, prior-request, callback-write and subscriber cases over the fixed two-row source and replacement.`, }, - ) + owns: [`src/collection/sync.ts`], + histories: readyCases, + label: ({ hook, callbackName, priorName, subscriber }) => + `publishes valid truncate messages when a ${hook} callback runs ${callbackName} after prior ${priorName} (${subscriber} subscriber)`, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-sync.ready-truncate`, + plan, + observe, + () => runReadyTruncateCase(plan), + ), + check: forwardAssertionJudgment, + }) - it(`matches every bounded reentrant listener history`, async () => { - oracleRoute(`case`) - for (const scenario of exhaustiveListenerScenarios) { - await runListenerScenario(scenario) - } + oracleProperty({ + id: `collection-sync.reentrant-drain-bounded`, + law: { + statement: `Listener commits apply after the current publication, aborted commits apply nothing, and an open transaction publishes only after committing.`, + authority: `Collection sync publication and aborted commit contracts stated above`, + limits: `Every bounded listener history: up to two commit or abort actions on either side of one optional open transaction.`, + }, + owns: [`src/collection/sync.ts`], + histories: exhaustiveListenerScenarios, + label: (scenario) => + `matches every bounded reentrant listener history: ${JSON.stringify(scenario)}`, + model: modelListenerScenario, + driver: (scenario, observe) => runListenerScenario(scenario, observe), + check: checkListenerScenario, }) oracleProperty({ diff --git a/packages/db/tests/collection-sync-reentrancy-ready-grammar-oracle.ts b/packages/db/tests/collection-sync-reentrancy-ready-grammar-oracle.ts new file mode 100644 index 0000000000..ac3bd1d32d --- /dev/null +++ b/packages/db/tests/collection-sync-reentrancy-ready-grammar-oracle.ts @@ -0,0 +1,80 @@ +// The ready-callback truncate grammar and its row-overlay model, moved +// unchanged out of collection-sync-reentrancy-oracle.test.ts so that the +// grammar-size check in collection-sync-reentrancy.test.ts can import it +// without importing the oracle file. It registers no tests. +// A truncate marks the Collection ready before it publishes its batch, and a +// ready callback can write in that window. The callback's messages and the +// truncate batch together must be valid for every subscriber, and must end +// at the Collection's rows, whatever optimistic requests were already +// active. A subscriber without initial state has no sent-key filter to hide +// a repeated message. A filtered subscriber keeps only rows whose value is +// `one`, so a removal must reach it with the row it holds. Subscribers +// receive the truncate's messages only after the Collection is ready. A +// message that carries a source row is synced and remote unless a prior +// request owns its key. A subscriber that the callback creates starts from +// the rows it sees, and a live query becomes ready showing replaced rows. +// +// The model overlays active intents, in order, over source rows. Before the +// truncate, the source holds keys 1 and 4. The replacement holds 1 and 2, so +// key 4's prefix delete must remain. A prior request is active before the +// truncate. The callback then writes once against the rows it can see. +export type ReadyIntent = + | { type: `update` | `insert`; key: number; value: string } + | { type: `delete`; key: number } +export const overlay = ( + rows: ReadonlyArray, + intents: ReadonlyArray, +) => { + const visible = new Map(rows) + for (const intent of intents) { + if (!intent) continue + if (intent.type === `delete`) visible.delete(intent.key) + else visible.set(intent.key, intent.value) + } + return visible +} +export const sourceBefore = [ + [1, `one`], + [4, `four`], +] as const +export const replacement = [ + [1, `one-again`], + [2, `two`], +] as const +const priorIntents: Record = { + none: undefined, + 'update 1': { type: `update`, key: 1, value: `prior-one` }, + 'delete 1': { type: `delete`, key: 1 }, + 'insert 3': { type: `insert`, key: 3, value: `prior-three` }, +} +const callbackIntents = (visible: ReadonlyMap) => + [1, 2, 3, 4].flatMap((key): Array => + visible.has(key) + ? [ + { type: `update`, key, value: `callback-${key}` }, + { type: `delete`, key }, + ] + : [{ type: `insert`, key, value: `callback-${key}` }], + ) +export const sourceValues = new Set( + [...sourceBefore, ...replacement].map(([, value]) => value), +) +export const describeIntent = (intent: ReadyIntent | undefined) => + intent ? `${intent.type} ${intent.key}` : `none` +export const readyCases = ([`onFirstReady`, `status:change`] as const).flatMap( + (hook) => + Object.entries(priorIntents).flatMap(([priorName, prior]) => + callbackIntents(overlay(replacement, [prior])).flatMap((callback) => + ([`initial`, `raw`, `filtered`] as const).map((subscriber) => ({ + hook, + priorName, + prior, + callback, + callbackName: describeIntent(callback), + subscriber, + })), + ), + ), +) + +export type ReadyCase = (typeof readyCases)[number] diff --git a/packages/db/tests/collection-sync-reentrancy.test.ts b/packages/db/tests/collection-sync-reentrancy.test.ts index 90cd199db6..9c4788c3b5 100644 --- a/packages/db/tests/collection-sync-reentrancy.test.ts +++ b/packages/db/tests/collection-sync-reentrancy.test.ts @@ -11,6 +11,7 @@ import { installInitialOrderedRows, stageInsert, } from './collection-sync-reentrancy-support.js' +import { readyCases } from './collection-sync-reentrancy-ready-grammar-oracle.js' import type { LayoutCallback, OrderedRow, @@ -20,6 +21,30 @@ import type { } from './collection-sync-reentrancy-support.js' describe(`sync publication reentrancy`, () => { + // Moved from collection-sync-reentrancy-oracle.test.ts: a size check of the + // ready-callback truncate grammar, which runs no production. + it(`enumerates prior requests and legal ready-callback writes`, () => { + // 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( + readyCases.map( + ({ priorName, callbackName }) => `${priorName} / ${callbackName}`, + ), + ) + for (const witness of [ + `none / delete 1`, + `none / delete 2`, + `none / insert 4`, + `update 1 / delete 1`, + `none / insert 3`, + `insert 3 / delete 3`, + `delete 1 / insert 1`, + ]) + expect(labels).toContain(witness) + expect(labels).not.toContain(`delete 1 / delete 1`) + expect(labels).not.toContain(`insert 3 / insert 3`) + }) + it.each([`cleanup`, `discard`] as const)( `releases queued messages when %s retires a deferral`, (ending) => { diff --git a/packages/db/tests/converted-properties/db-collection.ts b/packages/db/tests/converted-properties/db-collection.ts index e47c21a45d..f6dc919552 100644 --- a/packages/db/tests/converted-properties/db-collection.ts +++ b/packages/db/tests/converted-properties/db-collection.ts @@ -5,6 +5,208 @@ // files and deletes this directory. import type { ConvertedOwnerGroup } from './index.js' -export const properties: ReadonlyArray = [] +export const properties: ReadonlyArray = [ + // section: publication + `subscription-lifecycle.publication-selection`, + `subscription-lifecycle.publication-deferral-cleanup`, + `subscription-lifecycle.publication-retired-transports`, + `subscription-lifecycle.publication-raw-reset`, + `subscription-lifecycle.publication-product`, + `subscription-lifecycle.publication-empty-reset`, + `subscription-lifecycle.publication-retained-replacement`, + `subscription-lifecycle.publication-eager-restart`, + `subscription-lifecycle.publication-fixed`, + // section: source-health + `source-health.history-fixed`, + `source-health.overlap`, + `source-health.on-demand-reload`, + `source-health.on-demand-fixed`, + `source-health.lazy-demand-fixed`, + // section: lifecycle + `subscription-lifecycle.cleaned-restart-demand`, + `subscription-lifecycle.parked-reload-demand`, + `subscription-lifecycle.history-fixed`, + `subscription-lifecycle.history-cases`, + `subscription-lifecycle.caller-outcome`, + `subscription-lifecycle.detached-callers`, + `subscription-lifecycle.retired-delivery`, + `subscription-lifecycle.returned-results`, + `subscription-lifecycle.required-coverage`, + `subscription-lifecycle.change-retirement`, + `subscription-lifecycle.replay-failed`, + `subscription-lifecycle.async-restart-regimes`, + `subscription-lifecycle.startup-retirement`, + `subscription-lifecycle.unavailable-demand`, + `subscription-lifecycle.queued-cancel`, + `subscription-lifecycle.aborted-replay-release`, + `subscription-lifecycle.release-reentry`, + `subscription-lifecycle.three-generations`, + `subscription-lifecycle.start-cleanup`, + `subscription-lifecycle.resume-retirement`, + `subscription-replay.filtered-reentry`, + `subscription-replay.completion-fixed`, + // section: state + `collection-state.duration-field-change`, + `collection-state.deferred-causal-messages`, + `collection-state.eager-index-replacement`, + `collection-state.change-event-cases`, + `collection-state.change-event-reload-cases`, + `row-metadata-composition.nested`, + `row-metadata-composition.held-window`, + `row-metadata-composition.production-path`, + `collection-cleanup.dependents`, + `collection-cleanup.subscriber-admission`, + `collection-cleanup.restart-boundary`, + `collection-cleanup.loading-restart`, + `collection-cleanup.dependent-preload`, + `collection-cleanup.dependent-effect`, + `collection-cleanup.late-effect`, + `db-client-hydration.authority`, + `db-client-hydration.absent-seed-metadata`, + `db-client-hydration.interleaving`, + `collection-state.retention-fixed`, + `collection-state.retention-reentrant-restart`, + `collection-state.direct-deletion-drop`, + `collection-state.open-sync-attribution`, + `collection-state.optimistic-fixed`, + `collection-state.source-reinsert-attribution`, + `collection-state.absent-key-delete-drop`, + `collection-state.absent-key-delete-after-drop`, + `collection-state.absent-key-delete-persisting`, + `collection-state.handler-confirmation`, + `collection-state.validated-defaults`, + `collection-state.partial-over-default`, + `collection-state.insert-update-replay`, + `collection-state.sync-row-overlay`, + `collection-state.buffered-update-delete`, + `collection-state.settlement-orders`, + `collection-state.aborted-delete-partial`, + // section: misc + `collection-publication.metadata-only-fixed`, + `collection-publication.metadata-cancellation-fixed`, + `collection-lifecycle.gc-retention-boundaries-fixed`, + `collection-sync.ready-truncate`, + `collection-sync.reentrant-drain-bounded`, + `local-storage.order-fixed`, + `local-storage.value-fidelity-fixed`, + `sync-metadata-session.witnesses`, + `truncate-readiness.replacement-intent`, + `truncate-readiness.reentrant-batches`, + `collection-index.signature-classes`, + `subscription-reentrancy.direct-snapshot`, + `effect-disposal.joined-callers`, + `local-only.direct-write-fixed`, +] -export const owners: ReadonlyArray = [] +export const owners: ReadonlyArray = [ + // section: publication + [ + `db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts`, + `subscription-lifecycle`, + `publication-selection publication-deferral-cleanup publication-retired-transports publication-raw-reset publication-product publication-empty-reset publication-retained-replacement publication-eager-restart publication-fixed`, + ], + // section: source-health + [ + `db/tests/source-health-oracle.test.ts`, + `source-health`, + `history-fixed overlap on-demand-reload on-demand-fixed lazy-demand-fixed`, + ], + // section: lifecycle + [ + `db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts`, + `subscription-lifecycle`, + `cleaned-restart-demand parked-reload-demand history-fixed history-cases caller-outcome detached-callers retired-delivery returned-results required-coverage change-retirement replay-failed`, + ], + [ + `db/tests/collection-subscription-lifecycle-oracle.test.ts`, + `subscription-lifecycle`, + `async-restart-regimes startup-retirement unavailable-demand queued-cancel aborted-replay-release release-reentry three-generations start-cleanup resume-retirement`, + ], + [ + `db/tests/collection-subscription-replay-oracle.property.test.ts`, + `subscription-replay`, + `filtered-reentry completion-fixed`, + ], + // section: state + [ + `db/tests/change-event-history-oracle.test.ts`, + `collection-state`, + `duration-field-change deferred-causal-messages eager-index-replacement change-event-cases change-event-reload-cases`, + ], + [ + `db/tests/collection-row-metadata-composition-oracle.test.ts`, + `row-metadata-composition`, + `nested held-window production-path`, + ], + [ + `db/tests/collection-cleanup-restart-oracle.test.ts`, + `collection-cleanup`, + `dependents subscriber-admission restart-boundary loading-restart dependent-preload dependent-effect late-effect`, + ], + [ + `db/tests/db-client-hydration-authority-oracle.test.ts`, + `db-client-hydration`, + `authority absent-seed-metadata interleaving`, + ], + [ + `db/tests/collection-state-retention-oracle.property.test.ts`, + `collection-state`, + `retention-fixed retention-reentrant-restart direct-deletion-drop open-sync-attribution optimistic-fixed source-reinsert-attribution absent-key-delete-drop absent-key-delete-after-drop absent-key-delete-persisting handler-confirmation validated-defaults partial-over-default insert-update-replay sync-row-overlay buffered-update-delete settlement-orders aborted-delete-partial`, + ], + // section: misc + [ + `db/tests/collection-metadata-publication-oracle.property.test.ts`, + `collection-publication`, + `metadata-only-fixed metadata-cancellation-fixed`, + ], + [ + `db/tests/collection-gc-retention-oracle.property.test.ts`, + `collection-lifecycle`, + `gc-retention-boundaries-fixed`, + ], + [ + `db/tests/collection-sync-reentrancy-oracle.test.ts`, + `collection-sync`, + `ready-truncate reentrant-drain-bounded`, + ], + [ + `db/tests/local-storage-order-oracle.test.ts`, + `local-storage`, + `order-fixed`, + ], + [ + `db/tests/local-storage-value-fidelity-oracle.property.test.ts`, + `local-storage`, + `value-fidelity-fixed`, + ], + [ + `db/tests/sync-metadata-session-oracle.property.test.ts`, + `sync-metadata-session`, + `witnesses`, + ], + [ + `db/tests/truncate-readiness-oracle.test.ts`, + `truncate-readiness`, + `replacement-intent reentrant-batches`, + ], + [ + `db/tests/collection-index-value-oracle.test.ts`, + `collection-index`, + `signature-classes`, + ], + [ + `db/tests/collection-subscription-reentrancy-oracle.test.ts`, + `subscription-reentrancy`, + `direct-snapshot`, + ], + [ + `db/tests/effect-disposal-oracle.test.ts`, + `effect-disposal`, + `joined-callers`, + ], + [ + `db/tests/local-only-direct-write-oracle.test.ts`, + `local-only`, + `direct-write-fixed`, + ], +] diff --git a/packages/db/tests/d2-source-reconciliation-model-oracle.ts b/packages/db/tests/d2-source-reconciliation-model-oracle.ts new file mode 100644 index 0000000000..cecb5f5457 --- /dev/null +++ b/packages/db/tests/d2-source-reconciliation-model-oracle.ts @@ -0,0 +1,255 @@ +// The D2 source reconciliation model and its per-step check, moved unchanged +// out of d2-source-reconciliation-oracle.property.test.ts so that the ordinary +// tests in d2-source-reconciliation.test.ts can import them without importing +// the oracle file. The oracle file states the law and runs its generated +// histories through these functions. This module registers no tests. +import { expect } from 'vitest' +import { reconcileChangesForD2 } from '../src/query/live/utils.js' +import { oracleAssertion } from './oracle-assertion-adapter.js' +import { + addWeight, + expectedWeightedRowIdentity, +} from './d2-source-reconciliation-support.js' +import type { + SourceKey, + SourceRow, +} from './d2-source-reconciliation-support.js' +import type { ChangeMessage } from '../src/types.js' + +export type SourceOperation = + | { + type: `upsert` + key: SourceKey + row: SourceRow + reportedPreviousValue: SourceRow + } + | { + type: `rawUpdate` + key: SourceKey + row: SourceRow + reportedPreviousValue: SourceRow + } + | { type: `replay`; key: SourceKey } + | { type: `delete`; key: SourceKey; reportedValue: SourceRow } + +export type ReconciliationStep = + | { type: `batch`; operations: ReadonlyArray } + | { type: `truncate` } + | { type: `teardown` } + | { type: `restart` } + +export type ReconciliationModel = { + // Only sourceRows is expected state. sentRows belongs to the real helper; + // relation independently integrates that helper's raw weighted output. + sourceRows: Map + sentRows: Map + relation: Map + // Model-only switch for helper invocation, not a production graph state. + graphActive: boolean +} + +export type Reconciler = ( + changes: Array>, + sentRows: Map, +) => Array> + +export function applyToRelation( + relation: Map, + changes: ReadonlyArray>, +): void { + for (const change of changes) { + if (change.type === `insert`) { + addWeight(relation, change.key, change.value, 1) + } else if (change.type === `update`) { + addWeight(relation, change.key, change.previousValue!, -1) + addWeight(relation, change.key, change.value, 1) + } else { + addWeight(relation, change.key, change.value, -1) + } + } +} + +export function sourceChangesFor( + operations: ReadonlyArray, + sourceRows: Map, +): Array> { + const changes: Array> = [] + for (const operation of operations) { + if (operation.type === `upsert`) { + const previousValue = sourceRows.get(operation.key) + changes.push( + previousValue === undefined + ? { type: `insert`, key: operation.key, value: { ...operation.row } } + : { + type: `update`, + key: operation.key, + value: { ...operation.row }, + previousValue: { ...operation.reportedPreviousValue }, + }, + ) + sourceRows.set(operation.key, { ...operation.row }) + } else if (operation.type === `rawUpdate`) { + changes.push({ + type: `update`, + key: operation.key, + value: { ...operation.row }, + previousValue: { ...operation.reportedPreviousValue }, + }) + sourceRows.set(operation.key, { ...operation.row }) + } else if (operation.type === `replay`) { + const row = sourceRows.get(operation.key) + if (row !== undefined) { + changes.push({ type: `insert`, key: operation.key, value: { ...row } }) + } + } else { + changes.push({ + type: `delete`, + key: operation.key, + value: { ...operation.reportedValue }, + }) + sourceRows.delete(operation.key) + } + } + return changes +} + +export function expectTrackerMatchesSource( + sourceRows: ReadonlyMap, + sentRows: ReadonlyMap, +): void { + const compareEntries = ( + [a]: readonly [SourceKey, SourceRow], + [b]: readonly [SourceKey, SourceRow], + ) => `${typeof a}:${String(a)}`.localeCompare(`${typeof b}:${String(b)}`) + oracleAssertion('expectTrackerMatchesSource', () => + expect([...sentRows.entries()].sort(compareEntries)).toEqual( + [...sourceRows.entries()].sort(compareEntries), + ), + ) +} + +export function expectWeightedRelationMatchesSource( + sourceRows: ReadonlyMap, + relation: ReadonlyMap, +): void { + oracleAssertion('expectWeightedRelationMatchesSource', () => + expect( + [...relation.entries()].sort(([a], [b]) => a.localeCompare(b)), + ).toEqual( + [...sourceRows.entries()] + .map( + ([key, row]) => [expectedWeightedRowIdentity(key, row), 1] as const, + ) + .sort(([a], [b]) => a.localeCompare(b)), + ), + ) +} + +export function createReconciliationModel(): ReconciliationModel { + return { + sourceRows: new Map(), + sentRows: new Map(), + relation: new Map(), + graphActive: true, + } +} + +export function snapshotModel(model: ReconciliationModel): { + source: Array + sent: Array + relation: Array +} { + const rows = (entries: ReadonlyMap) => + [...entries] + .map( + ([key, row]) => + `${typeof key}:${String(key)}|${row.id}:${row.revision}:${row.value}`, + ) + .sort() + return { + source: rows(model.sourceRows), + sent: rows(model.sentRows), + relation: [...model.relation].sort(([left], [right]) => + left.localeCompare(right), + ), + } +} + +export function applyReconciliationStep( + model: ReconciliationModel, + step: ReconciliationStep, + reconcile: Reconciler = reconcileChangesForD2, +): void { + // This is a helper/session simulation, not real graph restart wiring. + // The separate Effect/live tests below inject events at a real consumer. + if (step.type === `truncate`) { + // Truncate is only an early lifecycle signal. Its later source batch + // still needs the retained exact rows to retract the active graph. + } else if (step.type === `teardown`) { + model.sentRows.clear() + model.relation.clear() + model.graphActive = false + } else if (step.type === `restart`) { + if (!model.graphActive) { + const replay = [...model.sourceRows].map(([key, value]) => ({ + type: `insert` as const, + key, + value: { ...value }, + })) + applyToRelation(model.relation, reconcile(replay, model.sentRows)) + model.graphActive = true + } + } else { + const changes = sourceChangesFor(step.operations, model.sourceRows) + if (model.graphActive) { + const reconciled = reconcile(changes, model.sentRows) + applyToRelation(model.relation, reconciled) + } + } + + if (model.graphActive) { + expectTrackerMatchesSource(model.sourceRows, model.sentRows) + expectWeightedRelationMatchesSource(model.sourceRows, model.relation) + } else { + oracleAssertion('applyReconciliationStep', () => + expect(model.sentRows.size).toBe(0), + ) + oracleAssertion('applyReconciliationStep', () => + expect(model.relation.size).toBe(0), + ) + } +} + +export function upsert( + key: SourceKey, + row: SourceRow, + reportedPreviousValue: SourceRow = row, +): ReconciliationStep { + return { + type: `batch`, + operations: [{ type: `upsert`, key, row, reportedPreviousValue }], + } +} + +export function executedReplayReach( + steps: ReadonlyArray, +): number { + const model = createReconciliationModel() + let sourceAtTeardown: Array = [] + let completedChangedRestarts = 0 + for (const step of steps) { + const activeBefore = model.graphActive + if (step.type === `teardown` && activeBefore) + sourceAtTeardown = snapshotModel(model).source + applyReconciliationStep(model, step) + if (step.type === `restart` && !activeBefore) { + const replayed = snapshotModel(model).source + if ( + sourceAtTeardown.length !== replayed.length || + sourceAtTeardown.some((value, index) => value !== replayed[index]) + ) + completedChangedRestarts++ + } + } + return completedChangedRestarts +} 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 8768dfca25..6f92621915 100644 --- a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts +++ b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts @@ -1,12 +1,10 @@ import { fc } from '@fast-check/vitest' import { expect, it as vitestIt } from 'vitest' -import { reconcileChangesForD2 } from '../src/query/live/utils.js' import { oracleProperty, oracleReplayTest, oracleRoute, } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { forwardAssertionJudgment, oracleAssertion, @@ -14,14 +12,20 @@ import { recordedAssertionJudgment, } from './oracle-assertion-adapter.js' import { - addWeight, - expectedWeightedRowIdentity, -} from './d2-source-reconciliation-support.js' + applyReconciliationStep, + createReconciliationModel, + executedReplayReach, + snapshotModel, + upsert, +} from './d2-source-reconciliation-model-oracle.js' import type { SourceKey, SourceRow, } from './d2-source-reconciliation-support.js' -import type { ChangeMessage } from '../src/types.js' +import type { + ReconciliationStep, + SourceOperation, +} from './d2-source-reconciliation-model-oracle.js' const it = oracleReplayTest(vitestIt) @@ -32,7 +36,9 @@ const it = oracleReplayTest(vitestIt) * result; the `reconcileChangesForD2` boundary owns the exact sent-row value * used for each retraction. * - * The model has three separate nodes: authoritative source rows, production's + * The model and its per-step check live in + * `d2-source-reconciliation-model-oracle.ts`. The model has three separate + * nodes: authoritative source rows, production's * sent-row memory, and an independently integrated weighted relation. Generated * batches may lie about previous values, reuse equal keys of different JS * types, replay rows, delete, truncate, tear down, and restart. The reference @@ -49,43 +55,6 @@ const it = oracleReplayTest(vitestIt) * not establish every graph shape or publication schedule. */ -type SourceOperation = - | { - type: `upsert` - key: SourceKey - row: SourceRow - reportedPreviousValue: SourceRow - } - | { - type: `rawUpdate` - key: SourceKey - row: SourceRow - reportedPreviousValue: SourceRow - } - | { type: `replay`; key: SourceKey } - | { type: `delete`; key: SourceKey; reportedValue: SourceRow } - -type ReconciliationStep = - | { type: `batch`; operations: ReadonlyArray } - | { type: `truncate` } - | { type: `teardown` } - | { type: `restart` } - -type ReconciliationModel = { - // Only sourceRows is expected state. sentRows belongs to the real helper; - // relation independently integrates that helper's raw weighted output. - sourceRows: Map - sentRows: Map - relation: Map - // Model-only switch for helper invocation, not a production graph state. - graphActive: boolean -} - -type Reconciler = ( - changes: Array>, - sentRows: Map, -) => Array> - const sourceRowArbitrary = fc.record({ id: fc.integer({ min: 0, max: 3 }), revision: fc.integer({ min: 0, max: 4 }), @@ -196,293 +165,6 @@ const disjointHistoriesArbitrary = fc.tuple( }), ) -function applyToRelation( - relation: Map, - changes: ReadonlyArray>, -): void { - for (const change of changes) { - if (change.type === `insert`) { - addWeight(relation, change.key, change.value, 1) - } else if (change.type === `update`) { - addWeight(relation, change.key, change.previousValue!, -1) - addWeight(relation, change.key, change.value, 1) - } else { - addWeight(relation, change.key, change.value, -1) - } - } -} - -function sourceChangesFor( - operations: ReadonlyArray, - sourceRows: Map, -): Array> { - const changes: Array> = [] - for (const operation of operations) { - if (operation.type === `upsert`) { - const previousValue = sourceRows.get(operation.key) - changes.push( - previousValue === undefined - ? { type: `insert`, key: operation.key, value: { ...operation.row } } - : { - type: `update`, - key: operation.key, - value: { ...operation.row }, - previousValue: { ...operation.reportedPreviousValue }, - }, - ) - sourceRows.set(operation.key, { ...operation.row }) - } else if (operation.type === `rawUpdate`) { - changes.push({ - type: `update`, - key: operation.key, - value: { ...operation.row }, - previousValue: { ...operation.reportedPreviousValue }, - }) - sourceRows.set(operation.key, { ...operation.row }) - } else if (operation.type === `replay`) { - const row = sourceRows.get(operation.key) - if (row !== undefined) { - changes.push({ type: `insert`, key: operation.key, value: { ...row } }) - } - } else { - changes.push({ - type: `delete`, - key: operation.key, - value: { ...operation.reportedValue }, - }) - sourceRows.delete(operation.key) - } - } - return changes -} - -function expectTrackerMatchesSource( - sourceRows: ReadonlyMap, - sentRows: ReadonlyMap, -): void { - const compareEntries = ( - [a]: readonly [SourceKey, SourceRow], - [b]: readonly [SourceKey, SourceRow], - ) => `${typeof a}:${String(a)}`.localeCompare(`${typeof b}:${String(b)}`) - oracleAssertion('expectTrackerMatchesSource', () => - expect([...sentRows.entries()].sort(compareEntries)).toEqual( - [...sourceRows.entries()].sort(compareEntries), - ), - ) -} - -function expectWeightedRelationMatchesSource( - sourceRows: ReadonlyMap, - relation: ReadonlyMap, -): void { - oracleAssertion('expectWeightedRelationMatchesSource', () => - expect( - [...relation.entries()].sort(([a], [b]) => a.localeCompare(b)), - ).toEqual( - [...sourceRows.entries()] - .map( - ([key, row]) => [expectedWeightedRowIdentity(key, row), 1] as const, - ) - .sort(([a], [b]) => a.localeCompare(b)), - ), - ) -} - -function createReconciliationModel(): ReconciliationModel { - return { - sourceRows: new Map(), - sentRows: new Map(), - relation: new Map(), - graphActive: true, - } -} - -function snapshotModel(model: ReconciliationModel): { - source: Array - sent: Array - relation: Array -} { - const rows = (entries: ReadonlyMap) => - [...entries] - .map( - ([key, row]) => - `${typeof key}:${String(key)}|${row.id}:${row.revision}:${row.value}`, - ) - .sort() - return { - source: rows(model.sourceRows), - sent: rows(model.sentRows), - relation: [...model.relation].sort(([left], [right]) => - left.localeCompare(right), - ), - } -} - -function applyReconciliationStep( - model: ReconciliationModel, - step: ReconciliationStep, - reconcile: Reconciler = reconcileChangesForD2, -): void { - // This is a helper/session simulation, not real graph restart wiring. - // The separate Effect/live tests below inject events at a real consumer. - if (step.type === `truncate`) { - // Truncate is only an early lifecycle signal. Its later source batch - // still needs the retained exact rows to retract the active graph. - } else if (step.type === `teardown`) { - model.sentRows.clear() - model.relation.clear() - model.graphActive = false - } else if (step.type === `restart`) { - if (!model.graphActive) { - const replay = [...model.sourceRows].map(([key, value]) => ({ - type: `insert` as const, - key, - value: { ...value }, - })) - applyToRelation(model.relation, reconcile(replay, model.sentRows)) - model.graphActive = true - } - } else { - const changes = sourceChangesFor(step.operations, model.sourceRows) - if (model.graphActive) { - const reconciled = reconcile(changes, model.sentRows) - applyToRelation(model.relation, reconciled) - } - } - - if (model.graphActive) { - expectTrackerMatchesSource(model.sourceRows, model.sentRows) - expectWeightedRelationMatchesSource(model.sourceRows, model.relation) - } else { - oracleAssertion('applyReconciliationStep', () => - expect(model.sentRows.size).toBe(0), - ) - oracleAssertion('applyReconciliationStep', () => - expect(model.relation.size).toBe(0), - ) - } -} - -function upsert( - key: SourceKey, - row: SourceRow, - reportedPreviousValue: SourceRow = row, -): ReconciliationStep { - return { - type: `batch`, - operations: [{ type: `upsert`, key, row, reportedPreviousValue }], - } -} - -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 - - applyReconciliationStep(model, { - type: `batch`, - operations: keys.map((key) => ({ - type: `upsert` as const, - key, - row, - reportedPreviousValue: row, - })), - }) - expect(model.sourceRows).toEqual( - new Map([ - [0, row], - [`0`, row], - ]), - ) - expect(model.sentRows).toEqual(model.sourceRows) - expect(model.relation).toEqual( - new Map([ - [expectedWeightedRowIdentity(0, row), 1], - [expectedWeightedRowIdentity(`0`, row), 1], - ]), - ) - - applyReconciliationStep(model, { type: `teardown` }) - applyReconciliationStep(model, { type: `restart` }) - expect(model.sourceRows).toEqual( - new Map([ - [0, row], - [`0`, row], - ]), - ) - expect(model.sentRows).toEqual(model.sourceRows) - expect(model.relation).toEqual( - new Map([ - [expectedWeightedRowIdentity(0, row), 1], - [expectedWeightedRowIdentity(`0`, row), 1], - ]), - ) -}) - -it(`preserves external source rows across graph teardown and restart`, () => { - oracleRoute(`fixed history`) - const model = createReconciliationModel() - const row = { id: 1, revision: 1, value: 1 } - - applyReconciliationStep(model, upsert(`row`, row)) - applyReconciliationStep(model, { type: `teardown` }) - expect(model.graphActive).toBe(false) - expect(model.sourceRows).toEqual(new Map([[`row`, row]])) - expect(model.sentRows).toEqual(new Map()) - expect(model.relation).toEqual(new Map()) - - applyReconciliationStep(model, { type: `restart` }) - expect(model.graphActive).toBe(true) - expect(model.sourceRows).toEqual(new Map([[`row`, row]])) - expect(model.sentRows).toEqual(new Map([[`row`, row]])) - expect(model.relation).toEqual( - new Map([[expectedWeightedRowIdentity(`row`, row), 1]]), - ) -}) - -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 } - - applyReconciliationStep(model, upsert(`row`, first)) - applyReconciliationStep(model, { type: `teardown` }) - applyReconciliationStep(model, upsert(`row`, replacement, first)) - expect(model.sourceRows).toEqual(new Map([[`row`, replacement]])) - expect(model.sentRows).toEqual(new Map()) - expect(model.relation).toEqual(new Map()) - - applyReconciliationStep(model, { type: `restart` }) - expect(model.sourceRows).toEqual(new Map([[`row`, replacement]])) - expect(model.sentRows).toEqual(new Map([[`row`, replacement]])) - expect(model.relation).toEqual( - new Map([[expectedWeightedRowIdentity(`row`, replacement), 1]]), - ) -}) - -function executedReplayReach(steps: ReadonlyArray): number { - const model = createReconciliationModel() - let sourceAtTeardown: Array = [] - let completedChangedRestarts = 0 - for (const step of steps) { - const activeBefore = model.graphActive - if (step.type === `teardown` && activeBefore) - sourceAtTeardown = snapshotModel(model).source - applyReconciliationStep(model, step) - if (step.type === `restart` && !activeBefore) { - const replayed = snapshotModel(model).source - if ( - sourceAtTeardown.length !== replayed.length || - sourceAtTeardown.some((value, index) => value !== replayed[index]) - ) - completedChangedRestarts++ - } - } - return completedChangedRestarts -} - it(`executes changed down-state sources and checks their restart replay`, () => { oracleRoute(`fixed seed`) const histories = fc.sample(reconciliationHistoryArbitrary, { @@ -502,30 +184,6 @@ it(`executes changed down-state sources and checks their restart replay`, () => expect(counts.reduce((sum, count) => sum + count, 0)).toBeGreaterThan(0) }) -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` }], - } - expect( - executedReplayReach([{ type: `teardown` }, absent, { type: `restart` }]), - ).toBe(0) - const change = upsert(`row`, { id: 1, revision: 1, value: 1 }) - expect(executedReplayReach([{ type: `teardown` }, change])).toBe(0) - expect( - executedReplayReach([{ type: `teardown` }, change, { type: `restart` }]), - ).toBe(1) - expect( - executedReplayReach([ - change, - { type: `teardown` }, - change, - { type: `restart` }, - ]), - ).toBe(0) -}) - const assertChangedSourceReplay = (row: SourceRow, key: SourceKey) => { const replacement = { ...row, revision: row.revision + 1 } oracleAssertion('assertChangedSourceReplay', () => @@ -564,52 +222,6 @@ oracleProperty({ check: forwardAssertionJudgment, }) -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 = - entry === `restart` - ? { type: `restart` } - : entry === `replay` - ? { type: `batch`, operations: [{ type: `replay`, key: `row` }] } - : { - type: `batch`, - operations: [ - { type: entry, key: `row`, row, reportedPreviousValue: row }, - ], - } - if (entry === `restart` || entry === `replay`) { - applyReconciliationStep(model, upsert(`row`, row)) - applyReconciliationStep(model, { type: `teardown` }) - if (entry === `replay`) model.graphActive = true - } - const corrupt: Reconciler = (changes, sent) => { - for (const change of changes) change.value.value++ - return reconcileChangesForD2(changes, sent) - } - expect(() => applyReconciliationStep(model, step, corrupt)).toThrowError( - expect.objectContaining({ name: `AssertionError` }), - ) - expect(model.sourceRows.get(`row`)).toEqual({ - id: 1, - revision: 1, - value: 1, - }) - expect(row).toEqual({ id: 1, revision: 1, value: 1 }) - // Fresh real helper control at the same entry, including restart hydration. - const valid = createReconciliationModel() - if (entry === `restart` || entry === `replay`) { - applyReconciliationStep(valid, upsert(`row`, row)) - applyReconciliationStep(valid, { type: `teardown` }) - if (entry === `replay`) valid.graphActive = true - } - applyReconciliationStep(valid, step) - }, -) - const assertExactSourceContributions = ( steps: ReadonlyArray, ) => { @@ -682,16 +294,3 @@ oracleProperty({ ), check: forwardAssertionJudgment, }) - -declareOracle({ - check: [ - applyReconciliationStep, - expectTrackerMatchesSource, - expectWeightedRelationMatchesSource, - assertExactSourceContributions, - assertChangedSourceReplay, - assertDisjointHistoriesCommute, - ], - model: [applyToRelation], - grammar: [reconciliationHistoryArbitrary], -}) diff --git a/packages/db/tests/d2-source-reconciliation.test.ts b/packages/db/tests/d2-source-reconciliation.test.ts index a39033d3f8..fed2e603bb 100644 --- a/packages/db/tests/d2-source-reconciliation.test.ts +++ b/packages/db/tests/d2-source-reconciliation.test.ts @@ -12,10 +12,20 @@ import { expectedWeightedRowIdentity, runWithCleanup, } from './d2-source-reconciliation-support.js' +import { + applyReconciliationStep, + createReconciliationModel, + executedReplayReach, + upsert, +} from './d2-source-reconciliation-model-oracle.js' import type { SourceKey, SourceRow, } from './d2-source-reconciliation-support.js' +import type { + Reconciler, + ReconciliationStep, +} from './d2-source-reconciliation-model-oracle.js' import type { ChangeMessage } from '../src/types.js' it(`retains a D2 mismatch and later cleanup diagnostics while releasing every resource`, async () => { @@ -526,3 +536,161 @@ it(`keeps revision and value in weighted row identity`, () => { ]), ) }) + +// Moved from d2-source-reconciliation-oracle.property.test.ts. The first three +// step the model and its per-step check through fixed histories and compare +// the model's state with literal rows; the next tests the reach measurement +// that the fixed-seed census uses; the last calibrates the per-step check +// against a corrupted reconciler on a hand-built model state. +it(`keeps numeric and string source keys distinct across restart`, () => { + const model = createReconciliationModel() + const row = { id: 1, revision: 1, value: 1 } + const keys = [0, `0`] as const + + applyReconciliationStep(model, { + type: `batch`, + operations: keys.map((key) => ({ + type: `upsert` as const, + key, + row, + reportedPreviousValue: row, + })), + }) + expect(model.sourceRows).toEqual( + new Map([ + [0, row], + [`0`, row], + ]), + ) + expect(model.sentRows).toEqual(model.sourceRows) + expect(model.relation).toEqual( + new Map([ + [expectedWeightedRowIdentity(0, row), 1], + [expectedWeightedRowIdentity(`0`, row), 1], + ]), + ) + + applyReconciliationStep(model, { type: `teardown` }) + applyReconciliationStep(model, { type: `restart` }) + expect(model.sourceRows).toEqual( + new Map([ + [0, row], + [`0`, row], + ]), + ) + expect(model.sentRows).toEqual(model.sourceRows) + expect(model.relation).toEqual( + new Map([ + [expectedWeightedRowIdentity(0, row), 1], + [expectedWeightedRowIdentity(`0`, row), 1], + ]), + ) +}) + +it(`preserves external source rows across graph teardown and restart`, () => { + const model = createReconciliationModel() + const row = { id: 1, revision: 1, value: 1 } + + applyReconciliationStep(model, upsert(`row`, row)) + applyReconciliationStep(model, { type: `teardown` }) + expect(model.graphActive).toBe(false) + expect(model.sourceRows).toEqual(new Map([[`row`, row]])) + expect(model.sentRows).toEqual(new Map()) + expect(model.relation).toEqual(new Map()) + + applyReconciliationStep(model, { type: `restart` }) + expect(model.graphActive).toBe(true) + expect(model.sourceRows).toEqual(new Map([[`row`, row]])) + expect(model.sentRows).toEqual(new Map([[`row`, row]])) + expect(model.relation).toEqual( + new Map([[expectedWeightedRowIdentity(`row`, row), 1]]), + ) +}) + +it(`replays external source changes made while the graph is down`, () => { + const model = createReconciliationModel() + const first = { id: 1, revision: 1, value: 1 } + const replacement = { id: 1, revision: 2, value: 2 } + + applyReconciliationStep(model, upsert(`row`, first)) + applyReconciliationStep(model, { type: `teardown` }) + applyReconciliationStep(model, upsert(`row`, replacement, first)) + expect(model.sourceRows).toEqual(new Map([[`row`, replacement]])) + expect(model.sentRows).toEqual(new Map()) + expect(model.relation).toEqual(new Map()) + + applyReconciliationStep(model, { type: `restart` }) + expect(model.sourceRows).toEqual(new Map([[`row`, replacement]])) + expect(model.sentRows).toEqual(new Map([[`row`, replacement]])) + expect(model.relation).toEqual( + new Map([[expectedWeightedRowIdentity(`row`, replacement), 1]]), + ) +}) + +it(`does not count absent replay or an unfinished downtime as replay coverage`, () => { + const absent: ReconciliationStep = { + type: `batch`, + operations: [{ type: `replay`, key: `missing` }], + } + expect( + executedReplayReach([{ type: `teardown` }, absent, { type: `restart` }]), + ).toBe(0) + const change = upsert(`row`, { id: 1, revision: 1, value: 1 }) + expect(executedReplayReach([{ type: `teardown` }, change])).toBe(0) + expect( + executedReplayReach([{ type: `teardown` }, change, { type: `restart` }]), + ).toBe(1) + expect( + executedReplayReach([ + change, + { type: `teardown` }, + change, + { type: `restart` }, + ]), + ).toBe(0) +}) + +it.each([`upsert`, `rawUpdate`, `replay`, `restart`] as const)( + `detects driver mutation at %s without changing expected source rows`, + (entry) => { + const model = createReconciliationModel() + const row = { id: 1, revision: 1, value: 1 } + const step: ReconciliationStep = + entry === `restart` + ? { type: `restart` } + : entry === `replay` + ? { type: `batch`, operations: [{ type: `replay`, key: `row` }] } + : { + type: `batch`, + operations: [ + { type: entry, key: `row`, row, reportedPreviousValue: row }, + ], + } + if (entry === `restart` || entry === `replay`) { + applyReconciliationStep(model, upsert(`row`, row)) + applyReconciliationStep(model, { type: `teardown` }) + if (entry === `replay`) model.graphActive = true + } + const corrupt: Reconciler = (changes, sent) => { + for (const change of changes) change.value.value++ + return reconcileChangesForD2(changes, sent) + } + expect(() => applyReconciliationStep(model, step, corrupt)).toThrowError( + expect.objectContaining({ name: `AssertionError` }), + ) + expect(model.sourceRows.get(`row`)).toEqual({ + id: 1, + revision: 1, + value: 1, + }) + expect(row).toEqual({ id: 1, revision: 1, value: 1 }) + // Fresh real helper control at the same entry, including restart hydration. + const valid = createReconciliationModel() + if (entry === `restart` || entry === `replay`) { + applyReconciliationStep(valid, upsert(`row`, row)) + applyReconciliationStep(valid, { type: `teardown` }) + if (entry === `replay`) valid.graphActive = true + } + applyReconciliationStep(valid, step) + }, +) diff --git a/packages/db/tests/db-client-hydration-authority-model-oracle.ts b/packages/db/tests/db-client-hydration-authority-model-oracle.ts new file mode 100644 index 0000000000..ef734a0ad3 --- /dev/null +++ b/packages/db/tests/db-client-hydration-authority-model-oracle.ts @@ -0,0 +1,234 @@ +// The reference model and history grammar of the DbClient hydration +// authority oracle, moved unchanged from +// db-client-hydration-authority-oracle.test.ts so that the ordinary reference +// tests in db-client-hydration-authority.test.ts can import them. The oracle's +// opening comment states the law that `referenceRows` folds. + +export type Row = { id: string; name: string; seeded?: string } +export type Metadata = { source: string } +export type RowUpdateMode = `partial` | `full` +export type SeedRow = { value: Row; metadata?: Metadata } +export type HydrationOrder = `before` | `after` | `both` +export type HydrationStage = `before` | `after` + +/** Each adapter decision names its operation and whether it names metadata. */ +export const adapterDecisions = { + insert: { op: `insert`, metadata: false }, + 'insert+metadata': { op: `insert`, metadata: true }, + update: { op: `update`, metadata: false }, + 'update+metadata': { op: `update`, metadata: true }, + metadata: { op: `metadata`, metadata: true }, + delete: { op: `delete`, metadata: false }, + truncate: { op: `truncate`, metadata: false }, +} as const +export type AdapterDecision = keyof typeof adapterDecisions +export type AdapterAction = + | { kind: `adapter`; op: `insert` | `update`; row: Row; metadata?: Metadata } + | { kind: `adapter`; op: `metadata`; metadata: Metadata } + | { kind: `adapter`; op: `delete` } + | { kind: `adapter`; op: `truncate` } +export type HistoryAction = + { kind: `hydrate`; rows: ReadonlyArray } | AdapterAction + +export const target = `1` +export const peer = `2` +export const newKey = `3` + +/** + * `initialData` is the first seed. It carries no metadata, and the target's + * `seeded` field is one that no adapter write names. + */ +export const initialRows: ReadonlyArray = [ + { value: { id: peer, name: `initial-peer` } }, + { value: { id: target, name: `initial-target`, seeded: `initial` } }, +] + +/** + * The early chunk carries `seeded`, a field no adapter write names. The late + * chunk omits it, so a merge of the late chunk over the early one is visible. + */ +export function hydrationRows(stage: HydrationStage): Array { + const seeded: { seeded?: string } = + stage === `before` ? { seeded: stage } : {} + return [ + { id: target, name: `${stage}-target` }, + { id: peer, name: `${stage}-peer` }, + { id: newKey, name: `${stage}-new` }, + ].map((value) => ({ + value: { ...value, ...seeded }, + metadata: { source: `${stage}-hydration` }, + })) +} + +export function adapterAction(decision: AdapterDecision): AdapterAction { + const { op, metadata } = adapterDecisions[decision] + if (op === `delete` || op === `truncate`) return { kind: `adapter`, op } + if (op === `metadata`) + return { kind: `adapter`, op, metadata: { source: `adapter-metadata` } } + return { + kind: `adapter`, + op, + row: { id: target, name: `adapter-${op}` }, + ...(metadata ? { metadata: { source: `adapter-${op}` } } : {}), + } +} + +export type ObservedRow = { + id: string + row: Row + metadata: Metadata | undefined +} +export type Slot = { + row: Row | undefined + rowWritten: boolean + metadata: Metadata | undefined + metadataWritten: boolean +} + +export const sameRow = (left: Row, right: Row): boolean => + left.id === right.id && + left.name === right.name && + left.seeded === right.seeded + +/** + * Forward fold, key by key. A source write sets the written flag of each object + * it touches. An insert names the row and its metadata: over a row that only a + * seed supplied it replaces both, and an insert without metadata clears the + * metadata. Over a row the source wrote, an equal insert keeps metadata unless + * it names some. An update merges over the current row under `partial` mode, + * and fixes metadata only when it names it. A metadata write fixes metadata + * alone. A delete fixes both objects as absent. A truncate fixes every key, + * including keys that a later seed names. A seed fills each object whose flag + * is clear, so a later seed replaces an earlier seed's object whole. + */ +export function referenceRows( + initial: ReadonlyArray, + history: ReadonlyArray, + rowUpdateMode: RowUpdateMode, +): Array { + const slots = new Map() + let truncated = false + const slotOf = (key: string): Slot => { + let slot = slots.get(key) + if (!slot) { + slot = { + row: undefined, + rowWritten: truncated, + metadata: undefined, + metadataWritten: truncated, + } + slots.set(key, slot) + } + return slot + } + const fixMetadata = (slot: Slot, metadata: Metadata | undefined): void => { + slot.metadata = metadata + slot.metadataWritten = true + } + const clear = (slot: Slot): void => { + slot.row = undefined + slot.rowWritten = true + fixMetadata(slot, undefined) + } + const insertRow = (slot: Slot, row: Row, metadata?: Metadata): void => { + if (slot.rowWritten && slot.row !== undefined) { + if (!sameRow(slot.row, row)) + throw new Error(`insert over a source-written row must re-insert it`) + if (metadata !== undefined) fixMetadata(slot, metadata) + return + } + slot.row = row + slot.rowWritten = true + fixMetadata(slot, metadata) + } + const seed = (rows: ReadonlyArray): void => { + for (const { value, metadata } of rows) { + const slot = slotOf(value.id) + if (!slot.rowWritten) slot.row = value + if (!slot.metadataWritten) slot.metadata = metadata + } + } + const write = (action: AdapterAction): void => { + if (action.op === `truncate`) { + truncated = true + for (const slot of slots.values()) clear(slot) + return + } + if (action.op === `delete`) { + clear(slotOf(target)) + return + } + if (action.op === `metadata`) { + fixMetadata(slotOf(target), action.metadata) + return + } + const slot = slotOf(action.row.id) + if (action.op === `insert`) { + insertRow(slot, action.row, action.metadata) + return + } + slot.row = + rowUpdateMode === `partial` ? { ...slot.row, ...action.row } : action.row + slot.rowWritten = true + if (action.metadata !== undefined) fixMetadata(slot, action.metadata) + } + + seed(initial) + for (const action of history) { + if (action.kind === `hydrate`) seed(action.rows) + else write(action) + } + return [...slots] + .flatMap(([id, slot]) => + slot.row === undefined + ? [] + : [{ id, row: slot.row, metadata: slot.metadata }], + ) + .sort((left, right) => left.id.localeCompare(right.id)) +} + +export type Case = { + decisions: ReadonlyArray + label: string + order: HydrationOrder + rowUpdateMode: RowUpdateMode +} +export const orders: ReadonlyArray = [`before`, `after`, `both`] +export const singles: ReadonlyArray = [ + `insert`, + `insert+metadata`, + `update`, + `update+metadata`, + `metadata`, + `delete`, + `truncate`, +] +// A row an update brings back after a delete keeps the metadata the delete +// cleared: neither write leaves the metadata for a later seed. +export const pairs = [ + `delete,insert`, + `delete,insert+metadata`, + `delete,update`, + `delete,update+metadata`, + `insert,delete`, + `truncate,insert`, + `truncate,insert+metadata`, +] +export const cases: Array = ([`full`, `partial`] as const).flatMap( + (rowUpdateMode) => [ + ...singles.flatMap((decision) => + orders.map((order) => ({ + decisions: [decision], + label: decision, + order, + rowUpdateMode, + })), + ), + ...pairs.map((label) => ({ + decisions: label.split(`,`) as Array, + label, + order: `after` as const, + rowUpdateMode, + })), + ], +) 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 96155fd51d..5e67a293a6 100644 --- a/packages/db/tests/db-client-hydration-authority-oracle.test.ts +++ b/packages/db/tests/db-client-hydration-authority-oracle.test.ts @@ -1,9 +1,33 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { DbClient, collectionOptions } from '../src' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' +import { + adapterAction, + cases, + hydrationRows, + initialRows, + referenceRows, + target, +} from './db-client-hydration-authority-model-oracle.js' import type { Collection, SyncConfig } from '../src' import type { ChangeMessage } from '../src/types' +import type { + AdapterAction, + AdapterDecision, + HistoryAction, + HydrationOrder, + HydrationStage, + Metadata, + ObservedRow, + Row, + RowUpdateMode, + SeedRow, +} from './db-client-hydration-authority-model-oracle.js' /** * # Can a hydration seed supersede applied adapter work? @@ -77,187 +101,13 @@ import type { ChangeMessage } from '../src/types' * 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. + * + * The reference fold `referenceRows`, its seeds and adapter actions, and the + * bounded `cases` grammar are in db-client-hydration-authority-model-oracle.ts, + * which the ordinary reference tests also import. */ -type Row = { id: string; name: string; seeded?: string } -type Metadata = { source: string } -type RowUpdateMode = `partial` | `full` -type SeedRow = { value: Row; metadata?: Metadata } type SyncActions = Parameters[`sync`]>[0] -type HydrationOrder = `before` | `after` | `both` -type HydrationStage = `before` | `after` - -/** Each adapter decision names its operation and whether it names metadata. */ -const adapterDecisions = { - insert: { op: `insert`, metadata: false }, - 'insert+metadata': { op: `insert`, metadata: true }, - update: { op: `update`, metadata: false }, - 'update+metadata': { op: `update`, metadata: true }, - metadata: { op: `metadata`, metadata: true }, - delete: { op: `delete`, metadata: false }, - truncate: { op: `truncate`, metadata: false }, -} as const -type AdapterDecision = keyof typeof adapterDecisions -type AdapterAction = - | { kind: `adapter`; op: `insert` | `update`; row: Row; metadata?: Metadata } - | { kind: `adapter`; op: `metadata`; metadata: Metadata } - | { kind: `adapter`; op: `delete` } - | { kind: `adapter`; op: `truncate` } -type HistoryAction = - { kind: `hydrate`; rows: ReadonlyArray } | AdapterAction - -const target = `1` -const peer = `2` -const newKey = `3` - -/** - * `initialData` is the first seed. It carries no metadata, and the target's - * `seeded` field is one that no adapter write names. - */ -const initialRows: ReadonlyArray = [ - { value: { id: peer, name: `initial-peer` } }, - { value: { id: target, name: `initial-target`, seeded: `initial` } }, -] - -/** - * The early chunk carries `seeded`, a field no adapter write names. The late - * chunk omits it, so a merge of the late chunk over the early one is visible. - */ -function hydrationRows(stage: HydrationStage): Array { - const seeded: { seeded?: string } = - stage === `before` ? { seeded: stage } : {} - return [ - { id: target, name: `${stage}-target` }, - { id: peer, name: `${stage}-peer` }, - { id: newKey, name: `${stage}-new` }, - ].map((value) => ({ - value: { ...value, ...seeded }, - metadata: { source: `${stage}-hydration` }, - })) -} - -function adapterAction(decision: AdapterDecision): AdapterAction { - const { op, metadata } = adapterDecisions[decision] - if (op === `delete` || op === `truncate`) return { kind: `adapter`, op } - if (op === `metadata`) - return { kind: `adapter`, op, metadata: { source: `adapter-metadata` } } - return { - kind: `adapter`, - op, - row: { id: target, name: `adapter-${op}` }, - ...(metadata ? { metadata: { source: `adapter-${op}` } } : {}), - } -} - -type ObservedRow = { id: string; row: Row; metadata: Metadata | undefined } -type Slot = { - row: Row | undefined - rowWritten: boolean - metadata: Metadata | undefined - metadataWritten: boolean -} - -const sameRow = (left: Row, right: Row): boolean => - left.id === right.id && - left.name === right.name && - left.seeded === right.seeded - -/** - * Forward fold, key by key. A source write sets the written flag of each object - * it touches. An insert names the row and its metadata: over a row that only a - * seed supplied it replaces both, and an insert without metadata clears the - * metadata. Over a row the source wrote, an equal insert keeps metadata unless - * it names some. An update merges over the current row under `partial` mode, - * and fixes metadata only when it names it. A metadata write fixes metadata - * alone. A delete fixes both objects as absent. A truncate fixes every key, - * including keys that a later seed names. A seed fills each object whose flag - * is clear, so a later seed replaces an earlier seed's object whole. - */ -function referenceRows( - initial: ReadonlyArray, - history: ReadonlyArray, - rowUpdateMode: RowUpdateMode, -): Array { - const slots = new Map() - let truncated = false - const slotOf = (key: string): Slot => { - let slot = slots.get(key) - if (!slot) { - slot = { - row: undefined, - rowWritten: truncated, - metadata: undefined, - metadataWritten: truncated, - } - slots.set(key, slot) - } - return slot - } - const fixMetadata = (slot: Slot, metadata: Metadata | undefined): void => { - slot.metadata = metadata - slot.metadataWritten = true - } - const clear = (slot: Slot): void => { - slot.row = undefined - slot.rowWritten = true - fixMetadata(slot, undefined) - } - const insertRow = (slot: Slot, row: Row, metadata?: Metadata): void => { - if (slot.rowWritten && slot.row !== undefined) { - if (!sameRow(slot.row, row)) - throw new Error(`insert over a source-written row must re-insert it`) - if (metadata !== undefined) fixMetadata(slot, metadata) - return - } - slot.row = row - slot.rowWritten = true - fixMetadata(slot, metadata) - } - const seed = (rows: ReadonlyArray): void => { - for (const { value, metadata } of rows) { - const slot = slotOf(value.id) - if (!slot.rowWritten) slot.row = value - if (!slot.metadataWritten) slot.metadata = metadata - } - } - const write = (action: AdapterAction): void => { - if (action.op === `truncate`) { - truncated = true - for (const slot of slots.values()) clear(slot) - return - } - if (action.op === `delete`) { - clear(slotOf(target)) - return - } - if (action.op === `metadata`) { - fixMetadata(slotOf(target), action.metadata) - return - } - const slot = slotOf(action.row.id) - if (action.op === `insert`) { - insertRow(slot, action.row, action.metadata) - return - } - slot.row = - rowUpdateMode === `partial` ? { ...slot.row, ...action.row } : action.row - slot.rowWritten = true - if (action.metadata !== undefined) fixMetadata(slot, action.metadata) - } - - seed(initial) - for (const action of history) { - if (action.kind === `hydrate`) seed(action.rows) - else write(action) - } - return [...slots] - .flatMap(([id, slot]) => - slot.row === undefined - ? [] - : [{ id, row: slot.row, metadata: slot.metadata }], - ) - .sort((left, right) => left.id.localeCompare(right.id)) -} /** Row data fields only; collection virtual props such as `$hasPendingWrites` are not data. */ function rowData(row: Row): Row { @@ -430,326 +280,72 @@ async function runHistory( }) } -// 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], -}) +// Both seeds carry the same row fields, so the row check cannot tell a whole +// replacement from a merge; only the metadata distinguishes them. +function runAbsentLatestSeedMetadataHistory(): Promise { + const row = { id: target, name: `seed`, seeded: `initial` } + return runHistory([], `both`, `partial`, (stage) => + stage === `before` + ? [{ value: row, metadata: { source: `early-seed` } }] + : [{ value: row }], + ) +} describe(`DbClient hydration authority oracle`, () => { - type Case = { - decisions: ReadonlyArray - label: string - order: HydrationOrder - rowUpdateMode: RowUpdateMode - } - const orders: ReadonlyArray = [`before`, `after`, `both`] - const singles: ReadonlyArray = [ - `insert`, - `insert+metadata`, - `update`, - `update+metadata`, - `metadata`, - `delete`, - `truncate`, - ] - // A row an update brings back after a delete keeps the metadata the delete - // cleared: neither write leaves the metadata for a later seed. - const pairs = [ - `delete,insert`, - `delete,insert+metadata`, - `delete,update`, - `delete,update+metadata`, - `insert,delete`, - `truncate,insert`, - `truncate,insert+metadata`, - ] - const cases: Array = ([`full`, `partial`] as const).flatMap( - (rowUpdateMode) => [ - ...singles.flatMap((decision) => - orders.map((order) => ({ - decisions: [decision], - label: decision, - order, - rowUpdateMode, - })), - ), - ...pairs.map((label) => ({ - decisions: label.split(`,`) as Array, - label, - order: `after` as const, - rowUpdateMode, - })), + // What this oracle owns: the gap report judges DbClient.collection and + // DbClient.applyCollectionChunk by this file's hydration histories. + oracleProperty({ + id: `db-client-hydration.authority`, + law: { + statement: `An adapter write is authoritative over hydration in either arrival order: at each completed action the synced store, the public rows, the change-message mirror, and the dehydrated row metadata equal the reference fold.`, + authority: `The D5 hydration authority rules stated above`, + limits: `One or two immediately applied adapter transactions on the target key, three keys, and at most two hydration chunks, in full and partial row-update modes.`, + }, + owns: [ + `src/client.ts#DbClient.collection`, + `src/client.ts#DbClient.applyCollectionChunk`, ], - ) - - // Seeds for the metadata law. Seed A carries `seeded` and metadata; seed B - // carries neither `seeded` nor metadata unless a test says otherwise. - const seedA: SeedRow = { - value: { id: target, name: `seed-a`, seeded: `x` }, - metadata: { source: `seed-a` }, - } - const seedB: SeedRow = { - value: { id: target, name: `seed-b` }, - metadata: { source: `seed-b` }, - } - const targetOf = ( - rows: ReadonlyArray, - ): ObservedRow | undefined => rows.find((entry) => entry.id === target) - const hydrate = (rows: ReadonlyArray) => ({ - kind: `hydrate` as const, - rows, - }) - - it(`reaches applied delete and truncate with late hydration`, () => { - oracleRoute(`case`) - expect(cases).toContainEqual({ - decisions: [`delete`], - label: `delete`, - order: `after`, - rowUpdateMode: `partial`, - }) - expect(cases).toContainEqual({ - decisions: [`truncate`], - label: `truncate`, - order: `after`, - rowUpdateMode: `partial`, - }) - expect( - referenceRows( - initialRows, - [adapterAction(`delete`), hydrate(hydrationRows(`after`))], - `partial`, - ), - ).toEqual([ - { - id: peer, - row: { id: peer, name: `after-peer` }, - metadata: { source: `after-hydration` }, - }, - { - id: newKey, - row: { id: newKey, name: `after-new` }, - metadata: { source: `after-hydration` }, - }, - ]) - }) - - it(`reference folds the D5 rules over fixed histories`, () => { - oracleRoute(`case`) - const before = hydrate(hydrationRows(`before`)) - const after = hydrate(hydrationRows(`after`)) - - // An insert replaces the seed's row and clears its metadata. - expect( - targetOf( - referenceRows( - initialRows, - [before, adapterAction(`insert`)], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-insert` }, - metadata: undefined, - }) - // A partial update merges over the seed and keeps its unnamed field and metadata. - expect( - targetOf( - referenceRows( - initialRows, - [before, adapterAction(`update`)], - `partial`, + histories: cases, + label: ({ label, order, rowUpdateMode }) => + `keeps ${label} authoritative with hydration ${order} its receipt in ${rowUpdateMode} mode`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`hydration authority`, plan, observe, () => + runHistory( + plan.history.decisions, + plan.history.order, + plan.history.rowUpdateMode, ), ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update`, seeded: `before` }, - metadata: { source: `before-hydration` }, - }) - // An update that does not name metadata leaves the metadata open to a later seed. - expect( - targetOf( - referenceRows(initialRows, [adapterAction(`update`), after], `partial`), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update`, seeded: `initial` }, - metadata: { source: `after-hydration` }, - }) - // An update that names metadata fixes it against a later seed. - expect( - targetOf( - referenceRows( - initialRows, - [adapterAction(`update+metadata`), after], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update`, seeded: `initial` }, - metadata: { source: `adapter-update` }, - }) - // A metadata-only write fixes the metadata and leaves the row to a later seed. - expect( - targetOf( - referenceRows( - initialRows, - [adapterAction(`metadata`), after], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `after-target` }, - metadata: { source: `adapter-metadata` }, - }) - // The latest seed wins for a key no adapter write has written. - expect( - referenceRows(initialRows, [before, after], `partial`).find( - (entry) => entry.id === peer, - ), - ).toEqual({ - id: peer, - row: { id: peer, name: `after-peer` }, - metadata: { source: `after-hydration` }, - }) - }) - - it(`reference keeps the metadata of an equal re-insert of a source-written row`, () => { - oracleRoute(`case`) - expect( - targetOf( - referenceRows( - [seedA], - [adapterAction(`insert+metadata`), adapterAction(`insert`)], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-insert` }, - metadata: { source: `adapter-insert` }, - }) - // The clear applies only over seed-only rows, so an unequal re-insert is - // outside the grammar and the reference refuses it. - expect(() => - referenceRows( - [seedA], - [ - adapterAction(`insert`), - { kind: `adapter`, op: `insert`, row: { id: target, name: `other` } }, - ], - `partial`, - ), - ).toThrow(`insert over a source-written row must re-insert it`) - }) - - 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( - referenceRows( - [seedA], - [adapterAction(`update`), hydrate([seedB])], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update`, seeded: `x` }, - metadata: { source: `seed-b` }, - }) - // The update's metadata is the source's, so B's metadata is ignored. - expect( - targetOf( - referenceRows( - [seedA], - [adapterAction(`update+metadata`), hydrate([seedB])], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update`, seeded: `x` }, - metadata: { source: `adapter-update` }, - }) + check: forwardAssertionJudgment, }) - 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( - referenceRows( - [seedA], - [hydrate([seedB]), adapterAction(`update`)], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `adapter-update` }, - metadata: { source: `seed-b` }, - }) - }) - - it(`reference leaves metadata absent when the latest seed names none`, () => { - oracleRoute(`case`) - expect( - targetOf( - referenceRows([seedA], [hydrate([{ value: seedB.value }])], `partial`), - ), - ).toEqual({ - id: target, - row: { id: target, name: `seed-b` }, - metadata: undefined, - }) - }) - - it(`reference holds a metadata-only write against a later seed`, () => { - oracleRoute(`case`) - expect( - targetOf( - referenceRows( - [seedA], - [adapterAction(`metadata`), hydrate([seedB])], - `partial`, - ), - ), - ).toEqual({ - id: target, - row: { id: target, name: `seed-b` }, - metadata: { source: `adapter-metadata` }, - }) - }) - - it.each(cases)( - `keeps $label authoritative with hydration $order its receipt in $rowUpdateMode mode`, - async ({ decisions, order, rowUpdateMode }) => { - oracleRoute(`case`) - return runHistory(decisions, order, rowUpdateMode) + const absentLatestSeedMetadataHistories = [ + { + name: `keeps metadata absent when the latest seed names none, in production`, }, - ) - - // 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` - ? [{ value: row, metadata: { source: `early-seed` } }] - : [{ value: row }], - ) + ] + oracleProperty({ + id: `db-client-hydration.absent-seed-metadata`, + law: { + statement: `When the latest seed names no metadata, the row keeps no metadata, so a whole replacement is distinguished from a merge.`, + authority: `The D5 hydration authority rules stated above`, + limits: `One fixed history with two seeds of the same row fields and no adapter write; the generated grammar's hydration chunks always carry metadata.`, + }, + owns: [ + `src/client.ts#DbClient.collection`, + `src/client.ts#DbClient.applyCollectionChunk`, + ], + histories: absentLatestSeedMetadataHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`absent seed metadata`, plan, observe, () => + runAbsentLatestSeedMetadataHistory(), + ), + check: forwardAssertionJudgment, }) }) @@ -809,10 +405,67 @@ function expectInterleavedRows( expect(rows).toEqual(expectedInterleavedRows(history)) } -declareOracle({ - check: [expectAcceptedSeedRow, expectInterleavedRows], - model: [expectedInterleavedRows, expectedAcceptedSeedRow], -}) +async function runInterleavingCase(history: Interleaving): Promise { + let sync!: SyncActions + let release!: () => void + const held = new Promise((resolve) => { + release = resolve + }) + const descriptor = collectionOptions({ + id: `db-client-interleaving-${history.persisting}-${history.source}-${history.sourceKey}`, + getKey: (row) => row.id, + sync: { + sync: (params) => { + sync = params + params.markReady() + }, + }, + onInsert: () => held, + }) + const client = new DbClient() + const collection = client.collection(descriptor) as Collection + try { + await collection.preload() + const mutation = history.persisting + ? collection.insert({ id: `local`, name: `local` }) + : undefined + const sourceRow = { + id: history.sourceKey, + name: `source`, + } + if (history.source === `open-before-chunk`) { + sync.begin() + sync.write({ type: `insert`, value: sourceRow }) + } + client.applyCollectionChunk({ + collectionId: collection.id, + rows: [{ key: `seed`, value: { id: `seed`, name: `seed` } }], + }) + // The accepted-row view also includes an open transaction's staged + // writes, which follow the chunk in commit order. + expectAcceptedSeedRow( + collection._state.getAcceptedSyncedRow(`seed`), + history, + ) + if (history.source === `after-chunk`) { + sync.begin() + sync.write({ type: `insert`, value: sourceRow }) + } + if (history.source !== `none`) { + const receipt = sync.commit() + void Promise.resolve(receipt).catch(() => undefined) + } + release() + await mutation?.when('settled').catch(() => undefined) + expectInterleavedRows( + observedRows(collection.values()).filter((row) => row.id !== `local`), + history, + ) + } finally { + release() + await collection.cleanup() + } +} describe(`DbClient hydration interleaving oracle`, () => { const interleavings: Array = [false, true].flatMap( @@ -827,72 +480,26 @@ 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) => { - release = resolve - }) - const descriptor = collectionOptions({ - id: `db-client-interleaving-${history.persisting}-${history.source}-${history.sourceKey}`, - getKey: (row) => row.id, - sync: { - sync: (params) => { - sync = params - params.markReady() - }, - }, - onInsert: () => held, - }) - const client = new DbClient() - const collection = client.collection(descriptor) as Collection< - Row, - string - > - try { - await collection.preload() - const mutation = history.persisting - ? collection.insert({ id: `local`, name: `local` }) - : undefined - const sourceRow = { - id: history.sourceKey, - name: `source`, - } - if (history.source === `open-before-chunk`) { - sync.begin() - sync.write({ type: `insert`, value: sourceRow }) - } - client.applyCollectionChunk({ - collectionId: collection.id, - rows: [{ key: `seed`, value: { id: `seed`, name: `seed` } }], - }) - // The accepted-row view also includes an open transaction's staged - // writes, which follow the chunk in commit order. - expectAcceptedSeedRow( - collection._state.getAcceptedSyncedRow(`seed`), - history, - ) - if (history.source === `after-chunk`) { - sync.begin() - sync.write({ type: `insert`, value: sourceRow }) - } - if (history.source !== `none`) { - const receipt = sync.commit() - void Promise.resolve(receipt).catch(() => undefined) - } - release() - await mutation?.when('settled').catch(() => undefined) - expectInterleavedRows( - observedRows(collection.values()).filter((row) => row.id !== `local`), - history, - ) - } finally { - release() - await collection.cleanup() - } + oracleProperty({ + id: `db-client-hydration.interleaving`, + law: { + statement: `A hydration chunk is accepted at once while other work is pending: accepted readers see the seed row or an earlier open source transaction's staged write, and the settled rows are the union of seed and source rows in acceptance order.`, + authority: `The hydration chunk acceptance contract stated above`, + limits: `The bounded grammar of an optional persisting mutation crossed with a source transaction that is absent, opened before the chunk, or begun after it, writing the seeded key or another key.`, }, - ) + owns: [ + `src/client.ts#DbClient.collection`, + `src/client.ts#DbClient.applyCollectionChunk`, + ], + histories: interleavings, + label: ({ persisting, source, sourceKey }) => + `accepts a chunk with persisting=${persisting} and source ${source} writing ${sourceKey}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`hydration interleaving`, plan, observe, () => + runInterleavingCase(plan.history), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/db-client-hydration-authority.test.ts b/packages/db/tests/db-client-hydration-authority.test.ts new file mode 100644 index 0000000000..3a0c5a9d84 --- /dev/null +++ b/packages/db/tests/db-client-hydration-authority.test.ts @@ -0,0 +1,260 @@ +/** + * Ordinary reference-model tests for the DbClient hydration authority oracle. + * They fold fixed histories through the reference and do not run production; + * the law and its production comparison live in + * db-client-hydration-authority-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { + adapterAction, + cases, + hydrationRows, + initialRows, + newKey, + peer, + referenceRows, + target, +} from './db-client-hydration-authority-model-oracle.js' +import type { + ObservedRow, + SeedRow, +} from './db-client-hydration-authority-model-oracle.js' + +describe(`DbClient hydration authority reference`, () => { + // Seeds for the metadata law. Seed A carries `seeded` and metadata; seed B + // carries neither `seeded` nor metadata unless a test says otherwise. + const seedA: SeedRow = { + value: { id: target, name: `seed-a`, seeded: `x` }, + metadata: { source: `seed-a` }, + } + const seedB: SeedRow = { + value: { id: target, name: `seed-b` }, + metadata: { source: `seed-b` }, + } + const targetOf = ( + rows: ReadonlyArray, + ): ObservedRow | undefined => rows.find((entry) => entry.id === target) + const hydrate = (rows: ReadonlyArray) => ({ + kind: `hydrate` as const, + rows, + }) + + it(`reaches applied delete and truncate with late hydration`, () => { + expect(cases).toContainEqual({ + decisions: [`delete`], + label: `delete`, + order: `after`, + rowUpdateMode: `partial`, + }) + expect(cases).toContainEqual({ + decisions: [`truncate`], + label: `truncate`, + order: `after`, + rowUpdateMode: `partial`, + }) + expect( + referenceRows( + initialRows, + [adapterAction(`delete`), hydrate(hydrationRows(`after`))], + `partial`, + ), + ).toEqual([ + { + id: peer, + row: { id: peer, name: `after-peer` }, + metadata: { source: `after-hydration` }, + }, + { + id: newKey, + row: { id: newKey, name: `after-new` }, + metadata: { source: `after-hydration` }, + }, + ]) + }) + + it(`reference folds the D5 rules over fixed histories`, () => { + const before = hydrate(hydrationRows(`before`)) + const after = hydrate(hydrationRows(`after`)) + + // An insert replaces the seed's row and clears its metadata. + expect( + targetOf( + referenceRows( + initialRows, + [before, adapterAction(`insert`)], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-insert` }, + metadata: undefined, + }) + // A partial update merges over the seed and keeps its unnamed field and metadata. + expect( + targetOf( + referenceRows( + initialRows, + [before, adapterAction(`update`)], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update`, seeded: `before` }, + metadata: { source: `before-hydration` }, + }) + // An update that does not name metadata leaves the metadata open to a later seed. + expect( + targetOf( + referenceRows(initialRows, [adapterAction(`update`), after], `partial`), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update`, seeded: `initial` }, + metadata: { source: `after-hydration` }, + }) + // An update that names metadata fixes it against a later seed. + expect( + targetOf( + referenceRows( + initialRows, + [adapterAction(`update+metadata`), after], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update`, seeded: `initial` }, + metadata: { source: `adapter-update` }, + }) + // A metadata-only write fixes the metadata and leaves the row to a later seed. + expect( + targetOf( + referenceRows( + initialRows, + [adapterAction(`metadata`), after], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `after-target` }, + metadata: { source: `adapter-metadata` }, + }) + // The latest seed wins for a key no adapter write has written. + expect( + referenceRows(initialRows, [before, after], `partial`).find( + (entry) => entry.id === peer, + ), + ).toEqual({ + id: peer, + row: { id: peer, name: `after-peer` }, + metadata: { source: `after-hydration` }, + }) + }) + + it(`reference keeps the metadata of an equal re-insert of a source-written row`, () => { + expect( + targetOf( + referenceRows( + [seedA], + [adapterAction(`insert+metadata`), adapterAction(`insert`)], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-insert` }, + metadata: { source: `adapter-insert` }, + }) + // The clear applies only over seed-only rows, so an unequal re-insert is + // outside the grammar and the reference refuses it. + expect(() => + referenceRows( + [seedA], + [ + adapterAction(`insert`), + { kind: `adapter`, op: `insert`, row: { id: target, name: `other` } }, + ], + `partial`, + ), + ).toThrow(`insert over a source-written row must re-insert it`) + }) + + it(`reference lets seed B supply metadata when a partial update wrote only row data`, () => { + // Seed A's `seeded` survives the partial merge; B supplies only the metadata. + expect( + targetOf( + referenceRows( + [seedA], + [adapterAction(`update`), hydrate([seedB])], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update`, seeded: `x` }, + metadata: { source: `seed-b` }, + }) + // The update's metadata is the source's, so B's metadata is ignored. + expect( + targetOf( + referenceRows( + [seedA], + [adapterAction(`update+metadata`), hydrate([seedB])], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update`, seeded: `x` }, + metadata: { source: `adapter-update` }, + }) + }) + + it(`reference merges a partial update over the later seed when both seeds arrive first`, () => { + // Seed B replaces A's row whole, so the update's merge base has no `seeded`. + expect( + targetOf( + referenceRows( + [seedA], + [hydrate([seedB]), adapterAction(`update`)], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `adapter-update` }, + metadata: { source: `seed-b` }, + }) + }) + + it(`reference leaves metadata absent when the latest seed names none`, () => { + expect( + targetOf( + referenceRows([seedA], [hydrate([{ value: seedB.value }])], `partial`), + ), + ).toEqual({ + id: target, + row: { id: target, name: `seed-b` }, + metadata: undefined, + }) + }) + + it(`reference holds a metadata-only write against a later seed`, () => { + expect( + targetOf( + referenceRows( + [seedA], + [adapterAction(`metadata`), hydrate([seedB])], + `partial`, + ), + ), + ).toEqual({ + id: target, + row: { id: target, name: `seed-b` }, + metadata: { source: `adapter-metadata` }, + }) + }) +}) diff --git a/packages/db/tests/effect-disposal-oracle.test.ts b/packages/db/tests/effect-disposal-oracle.test.ts index b39b74e8e2..94fbdb0382 100644 --- a/packages/db/tests/effect-disposal-oracle.test.ts +++ b/packages/db/tests/effect-disposal-oracle.test.ts @@ -1,9 +1,13 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } 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' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' /** * # What does concurrent effect disposal mean? @@ -75,129 +79,148 @@ 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], -}) - -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) - const uncaught = captureMicrotaskErrors() - const handler = createDeferred() - let nested: Promise | undefined - let batches = 0 - let releases = 0 - const source = createCollection<{ id: number }>({ - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: ({ begin, write, commit, markReady }) => { - markReady() - return { - loadSubset: () => { - begin() - write({ type: `insert`, value: { id: 1 } }) - commit() - return true - }, - unloadSubset: () => { - releases++ - if (reentry === `release`) nested = effect.dispose() - if (outcome === `error`) throw failure - if (outcome === `undefined`) throw undefined - }, - } +// The original disposal case, unchanged: it drives one scenario through a +// real Effect and its source and compares every caller and report with the +// model above. +async function runDisposalScenario({ + reentry, + pendingHandler, + outcome, +}: (typeof scenarios)[number]): Promise { + const failure = new Error(`release failed`) + // The thrown value the model expects to see reported, if any. + const reported = expectedUncaughtReports(outcome, failure) + const uncaught = captureMicrotaskErrors() + const handler = createDeferred() + let nested: Promise | undefined + let batches = 0 + let releases = 0 + const source = createCollection<{ id: number }>({ + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: ({ begin, write, commit, markReady }) => { + markReady() + return { + loadSubset: () => { + begin() + write({ type: `insert`, value: { id: 1 } }) + commit() + return true + }, + unloadSubset: () => { + releases++ + if (reentry === `release`) nested = effect.dispose() + if (outcome === `error`) throw failure + if (outcome === `undefined`) throw undefined }, - }, - }) - const effect: ReturnType = createEffect({ - query: (q) => q.from({ row: source }), - onBatch: (_events, { signal }) => { - batches++ - if (reentry === `abort`) - signal.addEventListener( - `abort`, - () => { - nested = effect.dispose() - }, - { once: true }, - ) - return pendingHandler ? handler.promise : undefined - }, - }) - let primaryFailure: { error: unknown } | undefined - try { - await flushPromises() - expect(batches).toBeGreaterThan(0) - const outer = effect.dispose() - const joined = effect.dispose() - // Attach rejection observers before an assertion can interrupt the case. - const results = Promise.allSettled([outer, nested, joined]) - expect(nested).toBeDefined() - const callers = [outer, nested!, joined] - // Record each caller separately: an aggregate can stay pending after - // one caller settles too soon. - const settled = callers.map(() => false) - callers.forEach((caller, index) => { - void caller.then( - () => { - settled[index] = true - }, - () => { - settled[index] = true - }, - ) - }) - expect(effect.disposed).toBe(true) - expect(source.subscriberCount).toBe(0) - expect(releases).toBe(1) - if (pendingHandler) { - await flushPromises() - expect([...settled]).toEqual([false, false, false]) } - handler.resolve() - const observed = await results - expectCallerOutcomes(observed) - await flushPromises() - expectUncaughtReports(uncaught.errors, reported) - // A finished attempt does not make the acquisition lease retryable. - await effect.dispose() - await flushPromises() - expect(releases).toBe(1) - expect(uncaught.errors).toHaveLength(reported.length) - } catch (error) { - primaryFailure = { error } - } - handler.resolve() - uncaught.restore() - const cleanupFailures: Array = [] - try { - await Promise.allSettled([nested, effect.dispose()]) - } catch (error) { - cleanupFailures.push(error) - } - try { - await source.cleanup() - } catch (error) { - cleanupFailures.push(error) - } - if (cleanupFailures.length > 0) { - throw new AggregateError( - cleanupFailures, - `Effect disposal oracle cleanup failed`, - { cause: primaryFailure?.error }, + }, + }, + }) + const effect: ReturnType = createEffect({ + query: (q) => q.from({ row: source }), + onBatch: (_events, { signal }) => { + batches++ + if (reentry === `abort`) + signal.addEventListener( + `abort`, + () => { + nested = effect.dispose() + }, + { once: true }, ) - } - if (primaryFailure) throw primaryFailure.error + return pendingHandler ? handler.promise : undefined }, - ) + }) + let primaryFailure: { error: unknown } | undefined + try { + await flushPromises() + expect(batches).toBeGreaterThan(0) + const outer = effect.dispose() + const joined = effect.dispose() + // Attach rejection observers before an assertion can interrupt the case. + const results = Promise.allSettled([outer, nested, joined]) + expect(nested).toBeDefined() + const callers = [outer, nested!, joined] + // Record each caller separately: an aggregate can stay pending after + // one caller settles too soon. + const settled = callers.map(() => false) + callers.forEach((caller, index) => { + void caller.then( + () => { + settled[index] = true + }, + () => { + settled[index] = true + }, + ) + }) + expect(effect.disposed).toBe(true) + expect(source.subscriberCount).toBe(0) + expect(releases).toBe(1) + if (pendingHandler) { + await flushPromises() + expect([...settled]).toEqual([false, false, false]) + } + handler.resolve() + const observed = await results + expectCallerOutcomes(observed) + await flushPromises() + expectUncaughtReports(uncaught.errors, reported) + // A finished attempt does not make the acquisition lease retryable. + await effect.dispose() + await flushPromises() + expect(releases).toBe(1) + expect(uncaught.errors).toHaveLength(reported.length) + } catch (error) { + primaryFailure = { error } + } + handler.resolve() + uncaught.restore() + const cleanupFailures: Array = [] + try { + await Promise.allSettled([nested, effect.dispose()]) + } catch (error) { + cleanupFailures.push(error) + } + try { + await source.cleanup() + } catch (error) { + cleanupFailures.push(error) + } + if (cleanupFailures.length > 0) { + throw new AggregateError( + cleanupFailures, + `Effect disposal oracle cleanup failed`, + { cause: primaryFailure?.error }, + ) + } + if (primaryFailure) throw primaryFailure.error +} + +describe(`Effect disposal outcome oracle`, () => { + oracleProperty({ + id: `effect-disposal.joined-callers`, + law: { + statement: `Every dispose() call during one cleanup attempt stays pending until the handler settles, then fulfills with the same outcome; the source is released once, and a release that throws is reported uncaught once with its own identity.`, + authority: `The public Effect.dispose contract in src/query/effect.ts and the Collection acquisition lease contract, as stated above`, + limits: `The finite matrix of two reentry sites, a pending or synchronous batch handler, and a release that succeeds, throws an Error, or throws undefined.`, + }, + // What this oracle owns: the gap report judges every function in + // query/effect.ts by this file's disposal matrix. + owns: [`src/query/effect.ts`], + histories: scenarios, + label: (scenario) => + `joins all callers to one attempt: ${JSON.stringify(scenario)}`, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `effect-disposal.joined-callers`, + plan, + observe, + () => runDisposalScenario(plan), + ), + check: forwardAssertionJudgment, + }) }) 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 0ee0996096..f4e5ebdec4 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' +import { describe, expect } from 'vitest' +import { oracleProperty } from './oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter' import { confirmEverything, createOrders, @@ -80,17 +84,6 @@ function expectDirectMatchesConfirmed( expect(direct.remaining).toBe(0) } -// 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> = { 'a mixed multi-key batch': [ @@ -144,13 +137,38 @@ 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) + // A history's writes are functions, so each enumerated history is its name; + // resolution maps it to those writes. + oracleProperty({ + id: `local-only.direct-write-fixed`, + law: { + statement: `A local-only direct write leaves the same rows, publishes the same batches, throws the same errors, and ends with the same completed transactions as the confirmed optimistic path, with no transaction left on the Collection.`, + authority: `The local-only direct-write law stated above`, + limits: `The named guard fallback histories above, which the change-event history oracle does not generate.`, + }, + // 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. + owns: [ + `src/local-only.ts`, + `src/collection/mutations.ts#CollectionMutationsManager.commitLocalOnlyDirect`, + ], + histories: Object.keys(histories), + label: (name) => `for ${name}`, + resolve: (name) => ({ name, writes: histories[name]! }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `local-only.direct-write-fixed`, + plan, + record, + async () => { + const direct = await observe({}, plan.writes) + const reference = await observe(confirmEverything, plan.writes) - expectDirectMatchesConfirmed(direct, reference) - }) - } + expectDirectMatchesConfirmed(direct, reference) + }, + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/local-storage-order-oracle.test.ts b/packages/db/tests/local-storage-order-oracle.test.ts index f4dcb17fed..a24d2b5226 100644 --- a/packages/db/tests/local-storage-order-oracle.test.ts +++ b/packages/db/tests/local-storage-order-oracle.test.ts @@ -32,14 +32,18 @@ * covers direct and queued success and Storage failure; it does not make * several distinct storage keys one atomic transaction. */ -import { expect, it } from 'vitest' +import { expect } 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 { oracleProperty } from './oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter' import { observeHistoryPromise, withHistoryCleanup, @@ -67,18 +71,10 @@ function expectedRows( return [...rows.values()].sort((a, b) => a.id.localeCompare(b.id)) } -// 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`) +async function persistNestedFireAndForgetWrite(): Promise { const storage = makeSeededStorage() const snapshots: Array> = [] const setItem = storage.setItem @@ -129,7 +125,7 @@ it('persists a nested fire-and-forget mutation after its handler returns', async }, () => [() => release.resolve(), () => collection.cleanup()], ) -}) +} /** A DbClient descriptor materializes a fresh Collection from module-level * LocalStorage options. The authored order is automatic update 1, then manual @@ -137,8 +133,7 @@ it('persists a nested fire-and-forget mutation after its handler returns', async * Collection's storage order: while the first handler is held, durable state * 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`) +async function orderModuleLevelManualAcceptance(): Promise { const storage = makeSeededStorage() const entered = createDeferred() const release = createDeferred() @@ -227,15 +222,14 @@ it('orders module-level manual acceptance with a DbClient Collection', async () () => client.cleanup(), ], ) -}) +} // A synchronous handler rejection has already decided that its mutation will // not write. The independent authored fold therefore excludes that update and // includes the next handler-free insert at the insert method's return cut. // 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`) +async function persistDirectWriteAfterSyncFailure(): Promise { const storage = makeSeededStorage() const failure = new Error('update rejected before writing') const collection = createCollection( @@ -284,4 +278,43 @@ it('persists a direct write at return after a synchronous handler failure', asyn }, () => [() => collection.cleanup()], ) +} + +// The three fixed histories above, each with its original comparisons against +// the authored fold at its original cuts. A history is the witness's name; +// resolution maps it to the witness that drives production for it. +const orderWitnesses = new Map Promise>([ + [ + `persists a nested fire-and-forget mutation after its handler returns`, + persistNestedFireAndForgetWrite, + ], + [ + `orders module-level manual acceptance with a DbClient Collection`, + orderModuleLevelManualAcceptance, + ], + [ + `persists a direct write at return after a synchronous handler failure`, + persistDirectWriteAfterSyncFailure, + ], +]) + +oracleProperty({ + id: `local-storage.order-fixed`, + law: { + statement: `LocalStorage automatic and manual writes preserve accepted mutation order within one Collection: durable storage and public rows follow the authored fold of accepted updates, and a fulfilled settled receipt reflects its mutation.`, + authority: `The public LocalStorage guide's direct persisting mutations and Collection settlement, as stated above`, + limits: `The three fixed histories that local-storage.write-order excludes: a nested fire-and-forget write, module-level manual acceptance through a DbClient Collection, and a direct write after a synchronous handler failure.`, + }, + // What this oracle owns: the gap report judges every function in + // src/local-storage.ts by this file's fixed LocalStorage write-order histories. + owns: [`src/local-storage.ts`], + histories: [...orderWitnesses.keys()], + label: (name) => name, + resolve: (name) => ({ name, witness: orderWitnesses.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`local-storage.order-fixed`, plan, observe, () => + plan.witness(), + ), + check: forwardAssertionJudgment, }) 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 d141eec407..06e3dc1c56 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 @@ -27,17 +27,12 @@ * peers, and Storage failures belong to the order and peer owners. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../src/collection/index' import { SerializationError } from '../src/errors' import { localStorageCollectionOptions } from '../src/local-storage' import { createTransaction } from '../src/transactions' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -401,80 +396,101 @@ async function runHistory({ entry, handler, payload }: History) { ) } -declareOracle({ - check: [runHistory], - model: [restoredArray, restoredObject], -}) - describe(`LocalStorage value fidelity oracle`, () => { - if (!oracleReplayRequested()) { - // 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 fixedPayloads: Array = [ - ...lostLeaves.map((make) => make()), - lost(`nested function`, { a: { b: () => 1 } }), - lost(`function in array`, [1, () => 1]), - lost(`undefined entry`, [1, undefined]), - faithful(`Date`, new Date(0), new Date(0).toISOString()), - faithful(`toJSON object`, { toJSON: () => `x` }, `x`), - faithful(`undefined property`, { a: undefined, b: 1 }, { b: 1 }), - faithful(`negative zero`, -0, 0), - faithful(`empty containers`, { a: [], b: {} }), - faithful( - `toJSON function`, - Object.assign(() => 1, { toJSON: () => `x` }), - `x`, - ), - faithful(`boxed number`, new Number(2), 2), + // 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 fixedPayloads: Array = [ + ...lostLeaves.map((make) => make()), + lost(`nested function`, { a: { b: () => 1 } }), + lost(`function in array`, [1, () => 1]), + lost(`undefined entry`, [1, undefined]), + faithful(`Date`, new Date(0), new Date(0).toISOString()), + faithful(`toJSON object`, { toJSON: () => `x` }, `x`), + faithful(`undefined property`, { a: undefined, b: 1 }, { b: 1 }), + faithful(`negative zero`, -0, 0), + faithful(`empty containers`, { a: [], b: {} }), + faithful( + `toJSON function`, + Object.assign(() => 1, { toJSON: () => `x` }), + `x`, + ), + faithful(`boxed number`, new Number(2), 2), + faithful( + `toJSON undefined property`, + { a: { toJSON: () => undefined }, b: 1 }, + { b: 1 }, + ), + lost(`toJSON undefined entry`, [{ toJSON: () => undefined }]), + onlyInserted( faithful( - `toJSON undefined property`, - { a: { toJSON: () => undefined }, b: 1 }, - { b: 1 }, + `hidden symbol key`, + Object.defineProperty({ a: 1 }, Symbol(`s`), { value: 1 }), + { a: 1 }, ), - lost(`toJSON undefined entry`, [{ toJSON: () => undefined }]), - onlyInserted( - faithful( - `hidden symbol key`, - Object.defineProperty({ a: 1 }, Symbol(`s`), { value: 1 }), - { a: 1 }, - ), - ), - ] - const entries = [ - `insert`, - `update`, - `manual insert`, - `manual update`, - ] as const - for (const entry of entries) { - for (const handler of [true, false]) { - 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`}`, () => { - oracleRoute(`fixed history`) - return runHistory({ entry, handler, payload }) - }) - } + ), + ] + const entries = [ + `insert`, + `update`, + `manual insert`, + `manual update`, + ] as const + // A payload holds functions and class instances, which are not history + // data, so each enumerated history is its test name; resolution builds the + // history that the shared driver runs. + const fixedHistories = new Map History>() + for (const entry of entries) { + for (const handler of [true, false]) { + for (const payload of fixedPayloads) { + if (!isLegal({ entry, handler, payload })) continue + fixedHistories.set( + `${entry} ${handler ? `with` : `without`} a handler of ${payload.label} ${payload.restored === LOST ? `rejects` : `persists its JSON form`}`, + () => ({ 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`) + } + // 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. + fixedHistories.set( + `${entry} stores the JSON that its check accepted`, + () => { let calls = 0 const payload = { toJSON: () => (calls++ === 0 ? `checked` : () => `later`), } - return runHistory({ + return { entry, handler: true, payload: faithful(`toJSON that changes`, payload, `checked`), - }) - }) - } + } + }, + ) } + oracleProperty({ + id: `${property}-fixed`, + law: { + statement: `The default JSON storage boundary rejects lossy payloads without durable writes or handler calls, and restores accepted payloads in their independently described JSON form.`, + authority: `LocalStorage guide acceptance and restore contract stated above`, + limits: `The named payloads above on every legal entry path, with and without a handler; each is replayed through the same driver and comparison as the generated histories.`, + }, + owns: [`src/local-storage.ts`], + histories: [...fixedHistories.keys()], + label: (name) => name, + resolve: (name) => ({ history: fixedHistories.get(name)!() }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `local-storage value fidelity`, + plan, + observe, + () => runHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ id: property, law: { diff --git a/packages/db/tests/notification-laws-calibration.test.ts b/packages/db/tests/notification-laws-calibration.test.ts new file mode 100644 index 0000000000..6903be0a56 --- /dev/null +++ b/packages/db/tests/notification-laws-calibration.test.ts @@ -0,0 +1,42 @@ +// Ordinary tests moved out of notification-laws-oracle.test.ts. Each one feeds +// the shared notification-history checker a hand-built publication mutant and +// requires it to reject the mutant; none runs production. +import { expect, it } from 'vitest' +import { expectNotificationHistory } from '../../db-collection-e2e/src/utils/notification-laws-oracle' +import type { Notification } from '../../db-collection-e2e/src/utils/notification-laws-oracle' + +type Row = { id: string; count: number } + +it.each([ + 'duplicate', + 'spurious-delete', + 'wrong-key', + 'wrong-payload', + 'missing-event', + 'absent-update', + 'absent-delete', + 'unknown-type', +])(`rejects the %s publication mutant`, (fault) => { + const peer = { id: 'peer', count: 2 } + const row = { id: 'owned', count: 1 } + const insert = { type: 'insert', key: row.id, value: row } + const healthy: Array> = [ + { events: [], rows: [peer] }, + { events: [insert], rows: [peer, row] }, + ] + expectNotificationHistory([peer], healthy, [peer, row]) + const callbacks = structuredClone(healthy) + const batch = callbacks[1]! + if (fault === 'duplicate') callbacks.push(structuredClone(batch)) + if (fault === 'spurious-delete') + batch.events.push({ ...insert, type: 'delete' }) + if (fault === 'wrong-key') batch.events[0]!.key = 'wrong' + if (fault === 'wrong-payload') batch.events[0]!.value.count++ + if (fault === 'missing-event') batch.events = [] + if (fault === 'absent-update') batch.events[0]!.type = 'update' + if (fault === 'absent-delete') batch.events[0]!.type = 'delete' + if (fault === 'unknown-type') batch.events[0]!.type = 'upsert' + expect(() => + expectNotificationHistory([peer], callbacks, [peer, row]), + ).toThrow() +}) diff --git a/packages/db/tests/notification-laws-oracle.test.ts b/packages/db/tests/notification-laws-oracle.test.ts index ed1a9f4a92..de16ec8222 100644 --- a/packages/db/tests/notification-laws-oracle.test.ts +++ b/packages/db/tests/notification-laws-oracle.test.ts @@ -15,7 +15,7 @@ * production batches, and asynchronous providers are outside this owner. * `change-event-history-oracle.test.ts` owns direct source Collection events. */ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import fc from 'fast-check' import { createCollection, createLiveQueryCollection } from '../src' import { @@ -23,7 +23,7 @@ import { expectNotificationsStopped, } from '../../db-collection-e2e/src/utils/notification-laws-oracle' import { ScenarioLifetime } from './conformance/scenario-lifetime' -import { oracleProperty, oracleRoute, pass, violation } from './oracle-config' +import { oracleProperty, pass, violation } from './oracle-config' import type { Notification } from '../../db-collection-e2e/src/utils/notification-laws-oracle' import type { Verdict } from './oracle-config' @@ -257,41 +257,6 @@ oracleProperty({ }, }) -it.each([ - 'duplicate', - 'spurious-delete', - 'wrong-key', - 'wrong-payload', - 'missing-event', - 'absent-update', - '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 } - const healthy: Array> = [ - { events: [], rows: [peer] }, - { events: [insert], rows: [peer, row] }, - ] - expectNotificationHistory([peer], healthy, [peer, row]) - const callbacks = structuredClone(healthy) - const batch = callbacks[1]! - if (fault === 'duplicate') callbacks.push(structuredClone(batch)) - if (fault === 'spurious-delete') - batch.events.push({ ...insert, type: 'delete' }) - if (fault === 'wrong-key') batch.events[0]!.key = 'wrong' - if (fault === 'wrong-payload') batch.events[0]!.value.count++ - if (fault === 'missing-event') batch.events = [] - if (fault === 'absent-update') batch.events[0]!.type = 'update' - if (fault === 'absent-delete') batch.events[0]!.type = 'delete' - if (fault === 'unknown-type') batch.events[0]!.type = 'upsert' - expect(() => - expectNotificationHistory([peer], callbacks, [peer, row]), - ).toThrow() -}) - oracleProperty({ id: `notification-laws.corrupt-prior-values`, law: { diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index d9bc320695..aaf00b192f 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -221,14 +221,7 @@ * started after the failure. */ import { fc } from '@fast-check/vitest' -import { - afterAll, - afterEach, - beforeEach, - describe, - expect, - it as vitestIt, -} from 'vitest' +import { afterAll, afterEach, beforeEach, describe, expect } from 'vitest' import { createCollection, findProblems } from '../src/collection/index.js' import { createLiveQueryCollection } from '../src/query/live-query-collection.js' import { createEffect } from '../src/query/effect.js' @@ -242,11 +235,7 @@ import { } from '../src/errors.js' import { RequestErrors } from '../src/collection/lifecycle.js' import { createDeferred } from '../src/deferred.js' -import { - oracleReplayRequested, - oracleReplayTest, - oracleRoute, -} from './oracle-config.js' +import { oracleReplayRequested } from './oracle-config.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, @@ -254,7 +243,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter.js' -import { declareOracle } from './oracle-declaration.js' import { flushPromises } from './utils.js' import { createSource } from './source-health-support.js' import type { Verdict } from './oracle-property.js' @@ -272,27 +260,6 @@ import type { LoadSubsetOptions, } from '../src/types.js' -// 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({ - check: [expectIdentity, expectRequestErrors, expectHeldDemandCovered], - model: [ - applyModel, - applySourceStep, - expectedTree, - expectedQuerySize, - applyWindowStep, - settleWindow, - applyLazyStep, - expectedRequestErrors, - expectedOverlap, - expectedOnDemandReload, - ], -}) - type Shape = `direct` | `join` | `self-join` | `nested` | `include` | `pooled-nested` // A source step that takes no other argument. @@ -3232,18 +3199,42 @@ const namedHistories: Record> = { ], } -// Named path replay executes its generated property without the fixed examples. -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}`, () => { - oracleRoute(`fixed history`) - return runHistory(shape, steps) - }) - } - } + // Every named history runs in every shape through the shared driver and + // its independent source and query models. The generated arbitrary maps + // its histories without an unmapper, so it cannot take these back; they + // register as one enumerated property. + const namedShapeHistories = shapes.flatMap((shape) => + Object.entries(namedHistories).map(([name, steps]) => ({ + shape, + name, + steps, + })), + ) + oracleProperty<(typeof namedShapeHistories)[number], Verdict>({ + id: `source-health.history-fixed`, + law: { + statement: `Every generated source step preserves the independently derived status, rows, health tree and event identities.`, + authority: `The source-health laws and independent source and query models stated above.`, + limits: `The named histories in each of the six shapes, which the generated arbitrary cannot take back; each is replayed through the same driver and models.`, + }, + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], + histories: namedShapeHistories, + label: ({ shape, name }) => `${shape}: ${name}`, + model: recordedAssertionJudgment, + driver: (history, observe) => + recordAssertionJudgment( + `source-health.history-fixed`, + history, + observe, + () => runHistory(history.shape, history.steps), + ), + check: forwardAssertionJudgment, + }) const stepArb: fc.Arbitrary = fc.oneof( { @@ -3625,6 +3616,96 @@ function expectedOverlap({ outcomes, first }: OverlapHistory) { }) } +async function runOverlapHistory(history: OverlapHistory): Promise { + const loads: Array>> = [] + let deferLoads = false + let params!: SyncParams + const source = createCollection({ + id: `health-overlap-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (next) => { + params = next + next.markReady() + return { + loadSubset: () => { + if (!deferLoads) return Promise.resolve() + const load = createDeferred() + loads.push(load) + return load.promise + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.id) + .limit(2), + ) + const consoleError = console.error + console.error = () => {} + try { + await query.preload() + params.begin() + for (const id of [1, 2, 3, 4]) + params.write({ type: `insert`, value: { id, parentId: id } }) + params.commit() + await flushPromises() + deferLoads = true + void Promise.resolve(query.utils.setWindow({ offset: 2, limit: 2 })).catch( + () => undefined, + ) + await flushPromises() + params.begin() + params.write({ type: `delete`, value: { id: 3, parentId: 3 } }) + params.commit() + await flushPromises() + // Premise: both loads are in flight at once. + expect(loads).toHaveLength(2) + const model = expectedOverlap(history) + const order = history.first === 0 ? [0, 1] : [1, 0] + for (const [step, load] of order.entries()) { + if (history.outcomes[load] === `ok`) loads[load]!.resolve() + else loads[load]!.reject(new Error(`load ${load} failed`)) + await flushPromises() + expect( + query.health.error?.message ?? null, + `after load ${load} settles`, + ).toBe(model[step]) + expect(query.status).toBe(`ready`) + } + // A load started after the failure clears it when it succeeds. + if (model.at(-1) !== null) { + void Promise.resolve( + query.utils.setWindow({ offset: 6, limit: 2 }), + ).catch(() => undefined) + await flushPromises() + // The loader may have started more loads while the two settled; a + // load started before the failure clears nothing, so settle every + // load until the one the window move started has succeeded. + const started = loads.length + for (let index = 2; index < loads.length; index++) { + loads[index]!.resolve() + await flushPromises() + } + expect(loads.length, `the window move started a load`).toBeGreaterThan(2) + expect(started).toBeGreaterThan(2) + expect( + query.health.error?.message ?? null, + `after a later load succeeds`, + ).toBeNull() + } + } finally { + console.error = consoleError + await query.cleanup() + await source.cleanup() + } +} + describe(`overlapping root loads`, () => { const histories: Array = [] for (const a of [`ok`, `fail`] as const) @@ -3632,102 +3713,28 @@ describe(`overlapping root loads`, () => { for (const first of [0, 1] as const) histories.push({ outcomes: [a, b], first }) - it.each(histories)( - `outcomes $outcomes, load $first settles first`, - async (history) => { - oracleRoute(`case`) - const loads: Array>> = [] - let deferLoads = false - let params!: SyncParams - const source = createCollection({ - id: `health-overlap-${Math.random().toString(36).slice(2)}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - gcTime: 0, - sync: { - sync: (next) => { - params = next - next.markReady() - return { - loadSubset: () => { - if (!deferLoads) return Promise.resolve() - const load = createDeferred() - loads.push(load) - return load.promise - }, - } - }, - }, - }) - const query = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.id) - .limit(2), - ) - const consoleError = console.error - console.error = () => {} - try { - await query.preload() - params.begin() - for (const id of [1, 2, 3, 4]) - params.write({ type: `insert`, value: { id, parentId: id } }) - params.commit() - await flushPromises() - deferLoads = true - void Promise.resolve( - query.utils.setWindow({ offset: 2, limit: 2 }), - ).catch(() => undefined) - await flushPromises() - params.begin() - params.write({ type: `delete`, value: { id: 3, parentId: 3 } }) - params.commit() - await flushPromises() - // Premise: both loads are in flight at once. - expect(loads).toHaveLength(2) - const model = expectedOverlap(history) - const order = history.first === 0 ? [0, 1] : [1, 0] - for (const [step, load] of order.entries()) { - if (history.outcomes[load] === `ok`) loads[load]!.resolve() - else loads[load]!.reject(new Error(`load ${load} failed`)) - await flushPromises() - expect( - query.health.error?.message ?? null, - `after load ${load} settles`, - ).toBe(model[step]) - expect(query.status).toBe(`ready`) - } - // A load started after the failure clears it when it succeeds. - if (model.at(-1) !== null) { - void Promise.resolve( - query.utils.setWindow({ offset: 6, limit: 2 }), - ).catch(() => undefined) - await flushPromises() - // The loader may have started more loads while the two settled; a - // load started before the failure clears nothing, so settle every - // load until the one the window move started has succeeded. - const started = loads.length - for (let index = 2; index < loads.length; index++) { - loads[index]!.resolve() - await flushPromises() - } - expect( - loads.length, - `the window move started a load`, - ).toBeGreaterThan(2) - expect(started).toBeGreaterThan(2) - expect( - query.health.error?.message ?? null, - `after a later load succeeds`, - ).toBeNull() - } - } finally { - console.error = consoleError - await query.cleanup() - await source.cleanup() - } + oracleProperty({ + id: `source-health.overlap`, + law: { + statement: `A request error clears only when a load started after the failure succeeds; overlapping loads that started before it keep the latest failure.`, + authority: `The overlapping root loads law stated above and its independent expectedOverlap model.`, + limits: `The eight outcome and settlement orders of two overlapping root loads of one ordered, limited query.`, }, - ) + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], + histories, + label: ({ outcomes, first }) => + `outcomes ${outcomes.join(`,`)}, load ${first} settles first`, + model: recordedAssertionJudgment, + driver: (history, observe) => + recordAssertionJudgment(`source-health.overlap`, history, observe, () => + runOverlapHistory(history), + ), + check: forwardAssertionJudgment, + }) }) // --------------------------------------------------------------------------- @@ -3783,6 +3790,112 @@ function expectedOnDemandReload( ) } +async function runOnDemandReloadHistory( + history: OnDemandReloadHistory, +): Promise { + let runs = 0 + const runLoads: Array void>> = [[], [], []] + const newRunReady = createDeferred() + const source = createCollection({ + id: `health-on-demand-reload-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + syncMode: `on-demand`, + gcTime: 0, + sync: { + sync: (params) => { + const run = ++runs + if (run === 1 || history.firstCommit === `empty-inside-sync`) { + params.markReady() + } else if (history.firstCommit === `row-inside-sync`) { + params.begin() + params.write({ type: `insert`, value: { id: 5, parentId: 15 } }) + params.commit() + params.markReady() + } else { + void newRunReady.promise.then(() => params.markReady()) + } + return { + loadSubset: () => { + const serve = () => { + params.begin() + for (const row of providerRows(run)) + params.write({ + type: + params.collection._state.getAcceptedSyncedRow(row.id) === + undefined + ? `insert` + : `update`, + value: row, + }) + params.commit() + } + if (run === 1) { + serve() + return true + } + const load = createDeferred() + runLoads[run]!.push(() => { + serve() + load.resolve() + }) + return load.promise + }, + } + }, + }, + }) + const query = createLiveQueryCollection((q) => { + const rows = q.from({ row: source }) + return history.query === `all` + ? rows + : rows.where(({ row }) => eq(row.id, 1)) + }) + const sorted = (rows: Array) => + [...rows] + .map(({ id, parentId }) => ({ id, parentId })) + .sort((a, b) => a.id - b.id) + try { + await query.preload() + expect(sorted(query.toArray)).toEqual(expectedOnDemandReload(history, 1)) + + let reloaded = false + const reload = source.reload().then(() => { + reloaded = true + }) + newRunReady.resolve() + await flushPromises() + expect( + runLoads[2]!.length, + `the new run reloads the query's subset`, + ).toBeGreaterThan(0) + expect(sorted(query.toArray), `while the loads are in flight`).toEqual( + expectedOnDemandReload(history, 1), + ) + expect( + sorted(source.toArray), + `a direct read while the loads are in flight`, + ).toEqual([ + ...providerRows(1), + ...(history.firstCommit === `row-inside-sync` + ? [{ id: 5, parentId: 15 }] + : []), + ]) + + for (const serve of runLoads[2]!.splice(0)) serve() + await reload + await flushPromises() + expect(reloaded).toBe(true) + expect(sorted(query.toArray), `after the loads settle`).toEqual( + expectedOnDemandReload(history, 2), + ) + expect(query.status).toBe(`ready`) + expect(source.health.hasErrors).toBe(false) + } finally { + await query.cleanup() + await source.cleanup() + } +} + describe(`on-demand reload`, () => { const histories: Array = [] for (const firstCommit of [ @@ -3793,116 +3906,31 @@ describe(`on-demand reload`, () => { for (const query of [`all`, `row-1`] as const) histories.push({ firstCommit, query }) - it.each(histories)( - `first commit $firstCommit, query $query`, - async (history) => { - oracleRoute(`case`) - let runs = 0 - const runLoads: Array void>> = [[], [], []] - const newRunReady = createDeferred() - const source = createCollection({ - id: `health-on-demand-reload-${Math.random().toString(36).slice(2)}`, - getKey: (row) => row.id, - syncMode: `on-demand`, - gcTime: 0, - sync: { - sync: (params) => { - const run = ++runs - if (run === 1 || history.firstCommit === `empty-inside-sync`) { - params.markReady() - } else if (history.firstCommit === `row-inside-sync`) { - params.begin() - params.write({ type: `insert`, value: { id: 5, parentId: 15 } }) - params.commit() - params.markReady() - } else { - void newRunReady.promise.then(() => params.markReady()) - } - return { - loadSubset: () => { - const serve = () => { - params.begin() - for (const row of providerRows(run)) - params.write({ - type: - params.collection._state.getAcceptedSyncedRow( - row.id, - ) === undefined - ? `insert` - : `update`, - value: row, - }) - params.commit() - } - if (run === 1) { - serve() - return true - } - const load = createDeferred() - runLoads[run]!.push(() => { - serve() - load.resolve() - }) - return load.promise - }, - } - }, - }, - }) - const query = createLiveQueryCollection((q) => { - const rows = q.from({ row: source }) - return history.query === `all` - ? rows - : rows.where(({ row }) => eq(row.id, 1)) - }) - const sorted = (rows: Array) => - [...rows] - .map(({ id, parentId }) => ({ id, parentId })) - .sort((a, b) => a.id - b.id) - try { - await query.preload() - expect(sorted(query.toArray)).toEqual( - expectedOnDemandReload(history, 1), - ) - - let reloaded = false - const reload = source.reload().then(() => { - reloaded = true - }) - newRunReady.resolve() - await flushPromises() - expect( - runLoads[2]!.length, - `the new run reloads the query's subset`, - ).toBeGreaterThan(0) - expect(sorted(query.toArray), `while the loads are in flight`).toEqual( - expectedOnDemandReload(history, 1), - ) - expect( - sorted(source.toArray), - `a direct read while the loads are in flight`, - ).toEqual([ - ...providerRows(1), - ...(history.firstCommit === `row-inside-sync` - ? [{ id: 5, parentId: 15 }] - : []), - ]) - - for (const serve of runLoads[2]!.splice(0)) serve() - await reload - await flushPromises() - expect(reloaded).toBe(true) - expect(sorted(query.toArray), `after the loads settle`).toEqual( - expectedOnDemandReload(history, 2), - ) - expect(query.status).toBe(`ready`) - expect(source.health.hasErrors).toBe(false) - } finally { - await query.cleanup() - await source.cleanup() - } + oracleProperty({ + id: `source-health.on-demand-reload`, + law: { + statement: `A live query over an on-demand Collection keeps its rows until a reload's new run has reloaded its subset, and then shows the new run's rows.`, + authority: `The on-demand reload law stated above and its independent expectedOnDemandReload model.`, + limits: `The three first-commit forms of the new run crossed with an unfiltered query and a query for row 1.`, }, - ) + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], + histories, + label: ({ firstCommit, query }) => + `first commit ${firstCommit}, query ${query}`, + model: recordedAssertionJudgment, + driver: (history, observe) => + recordAssertionJudgment( + `source-health.on-demand-reload`, + history, + observe, + () => runOnDemandReloadHistory(history), + ), + check: forwardAssertionJudgment, + }) }) // --------------------------------------------------------------------------- @@ -5409,11 +5437,38 @@ describe(`on-demand demand grammar`, () => { `restart`, `settle-ok`, ] - for (const [name, steps] of Object.entries(named)) - it(name, () => { - oracleRoute(`fixed history`) - return runDemandHistory(steps) - }) + // Each named history replays through the demand driver and its + // independent model, as the generated property's histories do. The + // generated arbitrary maps its steps without an unmapper, so it cannot + // take these back; they register as one enumerated property. + const namedDemandHistories = Object.entries(named).map(([name, steps]) => ({ + name, + steps, + })) + oracleProperty<(typeof namedDemandHistories)[number], Verdict>({ + id: `source-health.on-demand-fixed`, + law: { + statement: `Window demand, replay, source health and atomic publication refine the shared independent demand model.`, + authority: `The demand and window laws and source model stated above.`, + limits: `The named histories, which the generated arbitrary cannot take back; each is replayed through the same demand driver and model.`, + }, + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], + histories: namedDemandHistories, + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (history, observe) => + recordAssertionJudgment( + `source-health.on-demand-fixed`, + history, + observe, + () => runDemandHistory(history.steps), + ), + check: forwardAssertionJudgment, + }) const step = fc.constantFrom( `window`, @@ -6554,118 +6609,116 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 3 }, ], } - for (const [name, steps] of Object.entries(named)) - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `parent`, key: 3 }, - { kind: `truncate` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `unparent`, key: 3 }, - { kind: `settle-last-fail` }, - { kind: `settle-first-ok` }, - { kind: `reload` }, - { 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`, () => { - 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`, () => { - 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`, () => { - 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`, () => { - 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + // A named lazy history: its steps and the driver options it runs under. + type LazyFixedHistory = { + name: string + steps: ReadonlyArray + options?: Omit[1]>, `reach`> + } + const lazyFixedHistories: Array = [ + ...Object.entries(named).map(([name, steps]) => ({ name, 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. + { + name: `a failed replay does not list a load the query released before its verdict`, + steps: [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, - { kind: `settle-fail` }, - { kind: `unparent`, key: 1 }, - { kind: `fail` }, + { kind: `parent`, key: 3 }, { kind: `truncate` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `settle-fail` }, - { kind: `unparent`, key: 1 }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `settle-first-ok` }, + { kind: `reload` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-fail` }, + ], + }, + { + name: `a demand change that asks for a failed key again keeps its replay open`, + steps: [ + { kind: `reload` }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + ], + }, + { + name: `a first load waits for a replay load that no demand change follows`, + steps: [ + { kind: `parent`, key: 3 }, + { kind: `settle-first-ok` }, + { kind: `reload` }, + { kind: `unparent`, key: 3 }, + ], + }, + { + name: `a demand change after a failed replay load requests its key again`, + steps: [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + ], + }, + { + name: `a reload's retired load decides nothing when its abort rejects it`, + steps: [ + { kind: `reload` }, + { kind: `settle-last-ok` }, + { kind: `settle-first-fail` }, + ], + }, + { + name: `a later load of the failed key clears its error while another load is pending`, + steps: [ + { kind: `unparent`, key: 1 }, + { kind: `parent`, key: 1 }, + { kind: `parent`, key: 2 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 3 }, + { kind: `settle-last-ok` }, + ], + }, + { + name: `a demand change aborts a replay load and must request its still-held key again`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 1 }, + ], + options: { 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. + { + name: `a parked demand loads when its source restarts, and the replay then settles`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, { kind: `fail` }, { kind: `truncate` }, { kind: `parent`, key: 1 }, { kind: `reload` }, { kind: `settle-ok` }, ], - { holdChildReady: true }, - ) - }) - it(`a parked replay ends when its last demand retires`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + options: { holdChildReady: true }, + }, + { + name: `a parked replay ends when its last demand retires`, + steps: [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, @@ -6676,601 +6729,578 @@ describe(`lazy join demand grammar`, () => { { kind: `unparent`, key: 1 }, { kind: `unparent`, key: 2 }, ], - { holdChildReady: true }, - ) - }) - 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`, () => { - 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `parent`, key: 2 }, - { kind: `reload` }, - { kind: `reload` }, - { kind: `unparent`, key: 1 }, - { 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`, () => { - 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 3 }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + options: { holdChildReady: true }, + }, + { + name: `a load that fails during a replay is decided by the replay's verdict`, + steps: [ + { kind: `settle-ok` }, { kind: `reload` }, - { kind: `truncate` }, { kind: `parent`, key: 3 }, - { kind: `parent`, key: 2 }, - { kind: `settle-first-fail` }, { kind: `settle-last-fail` }, + { kind: `settle-first-fail` }, { kind: `unparent`, key: 3 }, ], - { holdChildReady: true }, - ) - }) - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ - { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, + }, + { + name: `a reload during the first load fails it only for a demand still held`, + steps: [ + { kind: `parent`, key: 3 }, { kind: `reload` }, + { kind: `settle-first-fail` }, { kind: `parent`, key: 2 }, - { kind: `child-ready` }, - { kind: `settle-fail` }, + { kind: `unparent`, key: 3 }, + { kind: `unparent`, key: 2 }, ], - { holdChildReady: true }, - ) - }) - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 3 }, - { kind: `unparent`, key: 1 }, - { kind: `throw-next` }, - { kind: `settle-last-fail` }, - { kind: `parent`, key: 2 }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 3 }, - { kind: `parent`, key: 2 }, - { 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 3 }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + { + name: `a second reload replaces a load that failed during the first replay`, + steps: [ + { kind: `reload` }, + { kind: `parent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `reload` }, { kind: `settle-ok` }, - { kind: `fail` }, + ], + }, + { + name: `a second reload's replay replaces the first replay's loads`, + steps: [ + { kind: `parent`, key: 2 }, + { kind: `reload` }, + { kind: `reload` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-first-ok` }, + { kind: `settle-last-ok` }, + { kind: `settle-first-fail` }, + ], + }, + { + name: `a reload after a truncate keeps the first load waiting for its replay`, + steps: [ + { kind: `parent`, key: 3 }, + { kind: `settle-last-ok` }, { kind: `truncate` }, - { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `reload` }, ], - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + { + name: `a truncate replay's success clears the error of a key whose retry it replaced`, + steps: [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `truncate` }, + { kind: `settle-last-ok` }, + ], + }, + { + name: `a failed replay succeeds for the demand left after its failed demand retires`, + steps: [ + { kind: `settle-ok` }, { kind: `reload` }, - { kind: `fail` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, { kind: `unparent`, key: 1 }, + { kind: `settle-ok` }, + { kind: `settle-fail` }, ], - { 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + { + name: `a demand change's replacement keeps a reload's replay open in the first load`, + steps: [ { kind: `reload` }, { kind: `truncate` }, { kind: `parent`, key: 3 }, - { kind: `settle-first-fail` }, { kind: `parent`, key: 2 }, + { kind: `settle-first-fail` }, + { kind: `settle-last-fail` }, { kind: `unparent`, key: 3 }, + ], + options: { holdChildReady: true }, + }, + { + name: `a first load waits for a source reload's whole replay`, + steps: [ + { 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. + { + name: `a reload after the source stopped replays its demand and fails with every load`, + steps: [ { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `reload` }, + { kind: `parent`, key: 2 }, + { kind: `child-ready` }, + { kind: `settle-fail` }, ], - { 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + options: { holdChildReady: true }, + }, + { + name: `a reload during the first load with a late ready does not fail the query`, + steps: [{ kind: `reload` }, { kind: `settle-ok` }], + options: { + 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. + { + name: `a failed replay's error shows again when the newer errors over it retire`, + steps: [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `unparent`, key: 1 }, + { kind: `throw-next` }, + { kind: `settle-last-fail` }, + { kind: `parent`, key: 2 }, + { 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. + { + name: `a load that throws after a failed replay shows its own error`, + steps: [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `parent`, key: 2 }, + { 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. + { + name: `a failed replay succeeds when its failed demand retires after the rest loaded`, + steps: [ + { 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. + { + name: `a replacement that loads a failed replay key again lets the replay succeed`, + steps: [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { 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. + { + name: `a replay load fails the replay while its child shows a failed first load`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `settle-fail` }, + ], + options: { 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. + { + name: `a parked demand that replaces an aborted replay load holds the replay`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `reload` }, + { kind: `fail` }, + { kind: `unparent`, key: 1 }, + ], + options: { 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. + { + name: `a load the truncate aborted does not clear a request error`, + steps: [ + { 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. + { + name: `a demand change retries a load that failed during a replay in the first load`, + steps: [ + { kind: `reload` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 3 }, + { kind: `settle-ok` }, + ], + options: { 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. + { + name: `a demand change retries a key whose truncate replay load failed in the first load`, + steps: [ + { 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. + { + name: `a truncate replay re-requests parked demand and then publishes it`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-ok` }, + ], + options: { holdChildReady: true }, + }, + // Releasing a parked demand removes its parked acquisition, so a demand + // for the same key parks again. + { + name: `a parked demand released and asked for again parks again`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `parent`, key: 2 }, + ], + options: { 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. + { + name: `a request error frame after a thrown request and a reload`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `settle-first-ok` }, + { kind: `parent`, key: 3 }, + { kind: `unparent`, key: 1 }, + { 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 + // request error stays (law 16; a failed replay stays open while the query + // holds demand). + // A parked demand's acquisition never loaded, so the truncate replay's + // request of it is its own first load: when it fails, it covers nothing, + // 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). + { + name: `a demand change retries a parked key whose replay request failed`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `reload` }, + { kind: `child-ready` }, + { kind: `settle-ok` }, + ], + options: { holdChildReady: true }, + }, + { + name: `a failed replay's error stays when parked demand retires after the child restarts`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, + { kind: `child-ready` }, + { kind: `unparent`, key: 2 }, + ], + options: { 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. + { + name: `a failed replay request still covers its key when a parked key retires`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-fail` }, + { kind: `unparent`, key: 3 }, + ], + options: { 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. + { + name: `a demand change drops a parked replacement that no longer matches`, + steps: [ + { kind: `settle-ok` }, + { kind: `fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-first-fail` }, + { kind: `parent`, key: 2 }, + { kind: `unparent`, key: 2 }, + { kind: `unparent`, key: 3 }, + ], + options: { 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 + // fragmented-churn replacement and another segment while the replaced + // 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). + { + name: `a demand change that retires a replacement and another segment judges the replay once`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `parent`, key: 3 }, + { kind: `settle-ok` }, + { kind: `unparent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-last-ok` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + ], + }, + { + name: `a reload from inside loadSubset rejects and leaves the query running`, + steps: [ + { 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 + // 3's error is the newest open one. + // A 30-times counterexample: key 2's load failed, so its request error + // 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. + { + name: `a failed replay's verdict replaces an earlier open failure`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `reload` }, + { kind: `settle-fail` }, + ], + }, + { + name: `a failed truncate replay's verdict replaces an earlier open failure`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-fail` }, + ], + }, + { + name: `a replay load replaced by a later load of its key leaves the replay's list`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-last-fail` }, + ], + }, + { + name: `the node shows the newest open request error`, + steps: [ + { 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. + { + name: `a replay's success clears every request error of its demand`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, + { kind: `parent`, key: 3 }, + { kind: `settle-ok` }, + { kind: `throw-next` }, + { 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. + { + name: `a replay's success clears the error of a failed request whose demand retired`, + steps: [ { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, + { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, { kind: `truncate` }, + { kind: `throw-next` }, + { kind: `unparent`, key: 3 }, + { kind: `unparent`, key: 2 }, { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + // 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. + { + name: `a failed replay succeeds when a failed replacement retires after the rest loaded`, + steps: [ { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 3 }, { kind: `parent`, key: 2 }, + { kind: `settle-ok` }, { kind: `unparent`, key: 2 }, - { kind: `parent`, key: 2 }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, ], - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `parent`, key: 3 }, - { kind: `truncate` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `unparent`, key: 3 }, - { kind: `settle-last-fail` }, - { kind: `settle-first-ok` }, - { kind: `parent`, key: 3 }, - { kind: `unparent`, key: 1 }, - { 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 - // request error stays (law 16; a failed replay stays open while the query - // holds demand). - // A parked demand's acquisition never loaded, so the truncate replay's - // request of it is its own first load: when it fails, it covers nothing, - // 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + // 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. + { + name: `a load that joins a failed replay shows its own error when it fails`, + steps: [ { kind: `settle-ok` }, - { kind: `fail` }, + { kind: `parent`, key: 2 }, { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, { kind: `parent`, key: 3 }, - { kind: `truncate` }, - { kind: `settle-fail` }, { kind: `unparent`, key: 1 }, - { kind: `reload` }, - { kind: `child-ready` }, - { kind: `settle-ok` }, + { kind: `settle-last-fail` }, ], - { holdChildReady: true }, - ) - }) - it(`a failed replay's error stays when parked demand retires after the child restarts`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + // 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. + { + name: `a failed replay lists the loads that failed during it in failure order`, + steps: [ { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, { kind: `reload` }, - { kind: `settle-fail` }, { kind: `parent`, key: 2 }, - { kind: `child-ready` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ - { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, { kind: `parent`, key: 3 }, { kind: `settle-fail` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + }, + // 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. + { + name: `an older load's failure decides nothing while its key's replacement is pending`, + steps: [ { kind: `settle-ok` }, - { kind: `fail` }, - { kind: `truncate` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, + { kind: `unparent`, key: 1 }, { kind: `settle-first-fail` }, - { kind: `parent`, key: 2 }, - { kind: `unparent`, key: 2 }, - { 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 - // fragmented-churn replacement and another segment while the replaced - // 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `settle-ok` }, - { kind: `parent`, key: 3 }, - { kind: `settle-ok` }, - { kind: `unparent`, key: 3 }, - { kind: `truncate` }, - { kind: `settle-last-ok` }, - { kind: `settle-last-fail` }, - { kind: `unparent`, key: 1 }, - ]) - }) - 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 - // 3's error is the newest open one. - // A 30-times counterexample: key 2's load failed, so its request error - // 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`, () => { - 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `settle-fail` }, - { kind: `truncate` }, - { kind: `settle-last-fail` }, - { kind: `unparent`, key: 1 }, - { kind: `settle-last-fail` }, - ]) - }) - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `settle-ok` }, - { kind: `parent`, key: 3 }, - { kind: `settle-ok` }, - { kind: `throw-next` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `parent`, key: 3 }, - { kind: `truncate` }, - { kind: `throw-next` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 3 }, - { kind: `parent`, key: 2 }, - { kind: `settle-ok` }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `truncate` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { 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`, () => { - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, - { kind: `parent`, key: 2 }, - { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ + }, + // 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. + ...([`throws`, `rejects`] as const).map((replacement) => ({ + name: `a churn replacement that ${replacement} leaves its key to the older load after the next demand change`, + steps: [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, @@ -7285,80 +7315,80 @@ 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory([ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `parent`, key: 3 }, - { kind: `truncate` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `settle-last-fail` }, - { kind: `unparent`, key: 3 }, - { kind: `settle-last-fail` }, - { kind: `settle-first-ok` }, - { kind: `truncate` }, - { kind: `settle-last-ok` }, - { 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 - // replacement fails too (maintainer decision; 30-times counterexamples of - // production, which listed every replay load whose replacement failed). - for (const [name, steps] of [ - [ - `a contraction`, - [ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `settle-fail` }, - { kind: `truncate` }, - { kind: `parent`, key: 3 }, - { kind: `settle-first-fail` }, - { kind: `unparent`, key: 3 }, - { kind: `settle-fail` }, ], - ], - [ - `a churn after a failed reload`, - [ + })), + // 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). + { + name: `a released replay load's success does not count for the replay's verdict`, + steps: [ { kind: `settle-ok` }, - { kind: `reload` }, - { kind: `settle-fail` }, { kind: `parent`, key: 2 }, { kind: `parent`, key: 3 }, { kind: `truncate` }, { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 3 }, + { kind: `settle-last-fail` }, + { kind: `settle-first-ok` }, + { kind: `truncate` }, + { kind: `settle-last-ok` }, + { kind: `settle-last-fail` }, { kind: `unparent`, key: 1 }, - { kind: `settle-fail` }, + { kind: `settle-ok` }, ], - ], - ] as const) - 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( + }, + // 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 + // replacement fails too (maintainer decision; 30-times counterexamples of + // production, which listed every replay load whose replacement failed). + ...( [ + [ + `a contraction`, + [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `settle-fail` }, + { kind: `truncate` }, + { kind: `parent`, key: 3 }, + { kind: `settle-first-fail` }, + { kind: `unparent`, key: 3 }, + { kind: `settle-fail` }, + ], + ], + [ + `a churn after a failed reload`, + [ + { kind: `settle-ok` }, + { kind: `reload` }, + { kind: `settle-fail` }, + { kind: `parent`, key: 2 }, + { kind: `parent`, key: 3 }, + { kind: `truncate` }, + { kind: `settle-last-fail` }, + { kind: `unparent`, key: 1 }, + { kind: `settle-fail` }, + ], + ], + ] as const + ).map(([name, steps]) => ({ + name: `a failed replay does not list a failed load that ${name} asked for again`, + 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). + { + name: `a parked growth asks again for a key whose released replay request failed`, + steps: [ { kind: `settle-ok` }, { kind: `fail` }, { kind: `truncate` }, @@ -7368,37 +7398,60 @@ describe(`lazy join demand grammar`, () => { { kind: `parent`, key: 1 }, { 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`, () => { - oracleRoute(`fixed history`) - return runLazyHistory( - [ + options: { 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). + { + name: `a failed lazy load under an ordered window still publishes the window`, + steps: [ { kind: `settle-ok` }, { kind: `parent`, key: 2 }, { kind: `settle-fail` }, { kind: `parent`, key: 3 }, { 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) => { - oracleRoute(`case`) - return runLazyHistory([{ kind: `settle-ok` }], { - ending: { at: `subscription-status`, dependents }, - }) + options: { 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. + ...[false, true].map((dependents) => ({ + name: `a cleanup from a subscription's status listener during a reload leaves no replacement armed (dependents: ${String(dependents)})`, + steps: [{ kind: `settle-ok` }] as ReadonlyArray, + options: { ending: { at: `subscription-status`, dependents } } as const, + })), + ] + // Each named history, fixed regression, and cleanup case replays through + // the lazy driver and its independent demand model, as the generated + // property's histories do. The generated arbitrary maps its steps without + // an unmapper, so it cannot take these histories back; they register as + // one enumerated property. + oracleProperty({ + id: `source-health.lazy-demand-fixed`, + law: { + statement: `Lazy parent demand, request failures, replay and health observations refine the shared demand and source models.`, + authority: `The source-health and lazy-demand laws and independent models stated above.`, + limits: `Named histories and fixed regressions that the generated arbitrary cannot take back; each is replayed through the same lazy driver and model.`, + }, + owns: [ + `src/collection/health-tree.ts`, + `src/collection/lifecycle.ts`, + `src/collection/subscription.ts`, + ], + histories: lazyFixedHistories, + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (history, observe) => + recordAssertionJudgment( + `source-health.lazy-demand-fixed`, + history, + observe, + () => runLazyHistory(history.steps, history.options), + ), + check: forwardAssertionJudgment, + }) // A load, then a reload whose load fails. const failedReload: { steps: ReadonlyArray } = { steps: [{ kind: `settle-ok` }, { kind: `reload` }, { kind: `settle-fail` }], diff --git a/packages/db/tests/sync-metadata-recovery-oracle.ts b/packages/db/tests/sync-metadata-recovery-oracle.ts index 3ead1444fc..797729828b 100644 --- a/packages/db/tests/sync-metadata-recovery-oracle.ts +++ b/packages/db/tests/sync-metadata-recovery-oracle.ts @@ -1,5 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from './oracle-declaration.js' import { compareSource, createSessionModel, @@ -275,12 +274,3 @@ export async function runRecoverySchedule( if (primary !== undefined) throw primary return outcome! } - -declareOracle({ - grammar: [ - recoveryValueArbitrary, - recoverySchedulesOf, - recoverySchedulesArbitrary, - ], - check: [runRecoverySchedule], -}) 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 f023de38bf..5d2882de9e 100644 --- a/packages/db/tests/sync-metadata-session-oracle.property.test.ts +++ b/packages/db/tests/sync-metadata-session-oracle.property.test.ts @@ -1,8 +1,7 @@ -import { fc } from '@fast-check/vitest' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect } from 'vitest' import { DbClient, collectionOptions } from '../src/client.js' import { DuplicateKeySyncError } from '../src/errors.js' -import { oracleReplayRequested, oracleRoute } from './oracle-config.js' +import { oracleReplayRequested } from './oracle-config.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, @@ -15,7 +14,6 @@ import { describeMismatch, flatGrammar, historyRegimes, - modelCheckpoints, nestedConflictGrammar, nestedGrammar, nestedSeedGrammar, @@ -28,13 +26,13 @@ import { sessionCollectionKeys, sessionHistoryArbitrary, } from './sync-metadata-session-oracle.js' +import { witnesses } from './sync-metadata-session-witness-grammar-oracle.js' import type { Verdict } from './oracle-property.js' import type { ReceiptState, SessionAction, SessionDriver, SessionGrammar, - SessionModel, } from './sync-metadata-session-oracle.js' import type { SyncConfig } from '../src/types.js' @@ -254,313 +252,6 @@ async function assertSessionHistory( ) } -// --------------------------------------------------------------------------- -// Named witnesses -// --------------------------------------------------------------------------- - -const a = ( - type: SessionAction[`type`], - key?: number | string, - metadata?: boolean, -) => - ({ - type, - ...(key === undefined ? {} : { key }), - ...(metadata === undefined ? {} : { metadata }), - }) as SessionAction - -/** - * Each witness reaches one law's premise. The model expectations below are - * derived by hand from the laws, so they also check the model against the - * wrong answers that production gives on `30175be48`. - */ -const witnesses = { - // A later seed beneath an open re-insert does not change its kind, but a - // source update that merges over that seed and commits ahead does: the - // re-insert then lands on a different row (decision 10). - lateSeedUnderMergedUpdate: { - grammar: nestedSeedGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`upsert`, 1, true), - a(`reinsert`, 1, false), - a(`begin`), - { type: `seed`, key: 1, bare: true }, - a(`upsert`, 1, false), - a(`commit`), - ] as Array, - }, - // The same under a hold: the inner commit stays accepted but unapplied, so - // the seed still marks the row. - heldLateSeedUnderMergedUpdate: { - grammar: nestedSeedGrammar, - history: [ - a(`hold`), - a(`seed`, 1), - a(`begin`), - a(`upsert`, 1, true), - a(`reinsert`, 1, false), - a(`begin`), - { type: `seed`, key: 1, bare: true }, - a(`upsert`, 1, false), - a(`commit`), - ] as Array, - }, - // Issue #2220: a held earlier write must stay visible after a later commit. - heldCursor: { - grammar: flatGrammar, - history: [ - a(`hold`), - a(`begin`), - { type: `collectionSet`, key: `cursor` }, - a(`commit`), - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - ] as Array, - }, - // The open transaction overlays every held one, not only the newest. - openOverHeld: { - grammar: flatGrammar, - history: [ - a(`hold`), - a(`begin`), - a(`rowSet`, 1), - { type: `collectionSet`, key: `gc:a` }, - a(`commit`), - a(`begin`), - ] as Array, - }, - // Independent transactions: an outer abort keeps an inner commit. - outerAbortKeepsInnerCommit: { - grammar: nestedGrammar, - history: [ - a(`begin`), - a(`begin`), - a(`upsert`, 1, true), - a(`commit`), - a(`cancel`), - ] as Array, - }, - // Commit order: the outer transaction commits after the inner one, so its - // earlier write wins, and a hold does not change that order. - commitOrderDuringHold: { - grammar: nestedGrammar, - history: [ - a(`hold`), - a(`begin`), - a(`rowSet`, 1), - a(`begin`), - a(`rowSet`, 1), - a(`commit`), - a(`commit`), - a(`release`), - ] as Array, - }, - // During a hold, the outer transaction can still write and commit after an - // inner one commits. - outerContinuesAfterInnerDuringHold: { - grammar: nestedGrammar, - history: [ - a(`hold`), - a(`begin`), - a(`begin`), - a(`rowSet`, 1), - a(`commit`), - a(`rowSet`, 2), - a(`commit`), - ] as Array, - }, - // An inner truncate applies at its own commit, at once despite a hold. - innerTruncateAppliesAtItsCommit: { - grammar: nestedGrammar, - history: [ - a(`begin`), - a(`upsert`, 1, true), - a(`commit`), - a(`hold`), - a(`begin`), - a(`begin`), - a(`truncate`), - a(`commit`), - a(`commit`), - ] as Array, - }, - // A seed that arrives under an open update changes the update's merge base, - // 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: { - grammar: seedGrammar, - history: [ - a(`begin`), - { type: `seed`, key: 1, narrow: true, bare: true }, - a(`upsert`, 1, false), - a(`reinsert`, 1, false), - { type: `seed`, key: 1, bare: true }, - a(`commit`), - ] as Array, - }, - // A seed never supersedes a source metadata write on a row the source did not write. - seedUnderMetadataOnlyWrite: { - grammar: seedGrammar, - history: [ - a(`begin`), - a(`rowSet`, 1), - a(`commit`), - a(`seed`, 1), - ] as Array, - }, - // A seed arriving under the source's open insert sits beneath it: the - // insert replaces the seeded row and clears its metadata. Consumers see the - // seed until the insert's transaction applies. - seedUnderOpenInsert: { - grammar: seedGrammar, - history: [ - a(`begin`), - a(`upsert`, 1, false), - a(`seed`, 1), - a(`commit`), - ] as Array, - }, - // The same history with the insert committed before the seed arrives: the - // outcome does not depend on arrival order. - seedAfterCommittedInsert: { - grammar: seedGrammar, - history: [ - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - a(`seed`, 1), - ] as Array, - }, - // 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: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`reinsert`, 1, false), - ] as Array, - }, - // 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: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - { type: `seed`, key: 1, narrow: true }, - ] as Array, - }, - // 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: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - { type: `seed`, key: 1, narrow: true }, - ] as Array, - }, - // The latest seed wins whole: a later seed without metadata leaves none. - bareLaterSeedClearsMetadata: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - { type: `seed`, key: 1, bare: true }, - ] as Array, - }, - // A fresh source insert over a row only a seed supplied replaces the row - // and, naming no metadata, clears the seed's. - insertOverSeedReplaces: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`insertOverSeed`, 1, false), - a(`commit`), - ] as Array, - }, - // A source update merges over a seeded row under partial row updates, so the - // seed's other field and its metadata stay. - updateOverSeed: { - grammar: seedGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - ] as Array, - }, - // Under full row updates the update replaces the seeded row; its metadata - // stays, because the update names none. - updateOverSeedFullRows: { - grammar: seedFullGrammar, - history: [ - a(`seed`, 1), - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - ] as Array, - }, - // An open truncate hides a held row write: a row read stops at the truncate. - openTruncateHidesHeldRow: { - grammar: flatGrammar, - history: [ - a(`hold`), - a(`begin`), - a(`rowSet`, 1), - a(`commit`), - a(`begin`), - a(`truncate`), - ] as Array, - }, - // An open delete hides a held collection entry from `list()`. - openDeleteHidesHeldListEntry: { - grammar: flatGrammar, - history: [ - a(`hold`), - a(`begin`), - a(`collectionSet`, `gc:a`), - a(`commit`), - a(`begin`), - a(`collectionDelete`, `gc:a`), - ] as Array, - }, - // `list('gc:')` includes the key equal to the prefix and excludes `gc`. - prefixBoundary: { - grammar: flatGrammar, - history: [ - a(`begin`), - a(`collectionSet`, `gc`), - a(`collectionSet`, `gc:`), - ] as Array, - }, - // Under full row updates a held update replaces the row, so a later insert - // 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: { - grammar: seedFullGrammar, - history: [ - a(`begin`), - a(`upsert`, 1, true), - a(`commit`), - a(`hold`), - a(`begin`), - a(`upsert`, 1, false), - a(`commit`), - a(`begin`), - a(`reinsert`, 1, false), - ] as Array, - }, -} satisfies Record< - string, - { grammar: SessionGrammar; history: Array } -> - /** * Each grammar's replay names: its campaign, and the paired formulations that * apply to it (key independence without full row updates, seed order with @@ -683,213 +374,36 @@ const declaredReach: Record> = { } 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 { 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) - expect(held.collection.cursor).toEqual({ s: 2 }) - expect(held.base[1]).toBe(absentMarker) - // Wrong answer: the open transaction hides every held write. - const open = await at(`openOverHeld`, 5) - expect(open.rowMetadata[1]).toEqual({ s: 2 }) - expect(open.prefixList).toEqual([[`gc:a`, { s: 3 }]]) - // Wrong answer under closed nesting: the outer abort undoes the inner - // commit, and its receipt waits for the outer commit. - const innerCommitted = await at(`outerAbortKeepsInnerCommit`, 3) - const insertedRow = { id: 1, v: { s: 2 }, m: { s: 2 } } - expect(innerCommitted.rowMetadata[1]).toEqual({ s: 2 }) - expect(innerCommitted.base[1]).toEqual(insertedRow) - expect(innerCommitted.receipts).toEqual([`applied`]) - const outerAborted = await at(`outerAbortKeepsInnerCommit`, 4) - expect(outerAborted.rowMetadata[1]).toEqual({ s: 2 }) - expect(outerAborted.base[1]).toEqual(insertedRow) - // Wrong answers: issue order (the inner write wins), and on 30175be48 a - // held inner commit read over the open outer write and applied after it. - expect((await at(`commitOrderDuringHold`, 4)).rowMetadata[1]).toEqual({ - s: 4, - }) - expect((await at(`commitOrderDuringHold`, 5)).rowMetadata[1]).toEqual({ - s: 2, - }) - const released = await at(`commitOrderDuringHold`, 7) - expect(released.rowMetadata[1]).toEqual({ s: 2 }) - expect(released.receipts).toEqual([`applied`, `applied`]) - // Wrong answer on 30175be48: the outer transaction cannot write after an - // inner one commits during a hold. - const continued = await at(`outerContinuesAfterInnerDuringHold`, 6) - expect(continued.rowMetadata).toEqual({ 1: { s: 3 }, 2: { s: 5 } }) - expect(continued.receipts).toEqual([`pending`, `pending`]) - // Wrong answer under closed nesting: the inner truncate waits for the - // outer commit. - const innerTruncated = await at(`innerTruncateAppliesAtItsCommit`, 7) - expect(innerTruncated.base[1]).toBe(absentMarker) - expect(innerTruncated.rowMetadata[1]).toBe(absentMarker) - expect(innerTruncated.receipts).toEqual([`applied`, `applied`]) - expect((await at(`innerTruncateAppliesAtItsCommit`, 8)).receipts).toEqual([ - `applied`, - `applied`, - `pending`, - ]) - // Wrong answers: replay makes the re-insert a duplicate and invalidates - // the source's transaction, so only the late seed remains; or the - // re-insert merges and keeps the late seed's field. - const reinserted = await at(`seedUnderOpenReinsert`, 5) - expect(reinserted.base[1]).toEqual({ id: 1, v: { s: 2 } }) - expect(reinserted.receipts).toEqual([`applied`]) - // Wrong answer: the seed's metadata replaces the source's metadata write. - const seeded = await at(`seedUnderMetadataOnlyWrite`, 3) - expect(seeded.rowMetadata[1]).toEqual({ s: 1 }) - expect(seeded.base[1]).toEqual({ id: 1, v: { s: 3 }, seeded: { s: 3 } }) - // Wrong answer on 30175be48: the open insert becomes an update over the - // late seed, so the seed's metadata and other field survive it. - const underOpen = await at(`seedUnderOpenInsert`, 2) - expect(underOpen.rowMetadata[1]).toBe(absentMarker) - expect(underOpen.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 2 } }) - const openCommitted = await at(`seedUnderOpenInsert`, 3) - expect(openCommitted.rowMetadata[1]).toBe(absentMarker) - expect(openCommitted.base[1]).toEqual({ id: 1, v: { s: 1 } }) - const afterCommit = await at(`seedAfterCommittedInsert`, 3) - expect(afterCommit.rowMetadata[1]).toBe(absentMarker) - expect(afterCommit.base[1]).toEqual({ id: 1, v: { s: 1 } }) - // Wrong answer on 30175be48: core counts it as an equal re-insert. - const equalInsert = await at(`equalInsertOverSeed`, 2) - expect(equalInsert.rowMetadata[1]).toBe(absentMarker) - const truncated = await at(`openTruncateHidesHeldRow`, 5) - expect(truncated.rowMetadata[1]).toBe(absentMarker) - const deleted = await at(`openDeleteHidesHeldListEntry`, 5) - expect(deleted.list).toEqual([]) - expect(deleted.prefixList).toEqual([]) - const boundary = await at(`prefixBoundary`, 2) - expect(boundary.prefixList).toEqual([[`gc:`, { s: 2 }]]) - expect(boundary.list).toEqual([ - [`gc`, { s: 1 }], - [`gc:`, { s: 2 }], - ]) - const equalAfterFullUpdate = await at( - `fullUpdateThenEqualInsertDuringHold`, - 8, - ) - expect(equalAfterFullUpdate.rowMetadata[1]).toEqual({ s: 1 }) - expect(equalAfterFullUpdate.base[1]).toEqual({ - id: 1, - v: { s: 1 }, - m: { s: 1 }, - }) - const laterSeed = await at(`laterSeedReplacesSeed`, 1) - expect(laterSeed.rowMetadata[1]).toEqual({ s: 1 }) - expect(laterSeed.base[1]).toEqual({ id: 1, v: { s: 1 } }) - // Wrong answer on 30175be48: the late seed is dropped with its metadata. - const lateSeed = await at(`partialUpdateThenLateSeed`, 4) - expect(lateSeed.rowMetadata[1]).toEqual({ s: 4 }) - expect(lateSeed.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 0 } }) - // Wrong answer on 30175be48: the earlier seed's metadata survives. - const bare = await at(`bareLaterSeedClearsMetadata`, 1) - expect(bare.rowMetadata[1]).toBe(absentMarker) - expect(bare.base[1]).toEqual({ id: 1, v: { s: 1 }, seeded: { s: 1 } }) - // Wrong answer on 30175be48: the insert merges over the seed as an update. - const replacedSeed = await at(`insertOverSeedReplaces`, 3) - expect(replacedSeed.rowMetadata[1]).toBe(absentMarker) - expect(replacedSeed.base[1]).toEqual({ id: 1, v: { s: 2 } }) - const merged = await at(`updateOverSeed`, 3) - expect(merged.rowMetadata[1]).toEqual({ s: 0 }) - expect(merged.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 0 } }) - const replaced = await at(`updateOverSeedFullRows`, 3) - expect(replaced.rowMetadata[1]).toEqual({ s: 0 }) - expect(replaced.base[1]).toEqual({ id: 1, v: { s: 2 } }) - }) - - it.each(Object.entries(witnesses))( - `keeps the session law on witness %s`, - async (_name, { grammar, history }) => { - oracleRoute(`fixed history`) - await assertSessionHistory(grammar, history) + // Each named witness, through the same driver and comparison as the + // generated campaigns. The witnesses span several grammars, so they run as + // one enumerated property, named by witness. + oracleProperty({ + id: `sync-metadata-session.witnesses`, + law: { + statement: `Source reads include accepted and open writes in the independent session order, while rows and receipts follow atomic application.`, + authority: `The borrowed consistency laws, maintainer decisions and independent model in sync-metadata-session-oracle.ts.`, + limits: `The named witnesses in sync-metadata-session-witness-grammar-oracle.ts, each in its own grammar, with the core hold and DbClient hydration boundary.`, }, - ) + owns: [`src/client.ts`, `src/collection/sync.ts`], + histories: Object.keys(witnesses) as Array, + label: (name) => `keeps the session law on witness ${name}`, + resolve: (name) => ({ name, ...witnesses[name] }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `sync-metadata-session.witnesses`, + plan, + observe, + () => assertSessionHistory(plan.grammar, plan.history), + ), + check: forwardAssertionJudgment, + }) }) // --------------------------------------------------------------------------- // Campaigns // --------------------------------------------------------------------------- -/** - * Grammar ablation: each generated axis exists for a regime. Removing the - * axis must remove its regime from the fixed campaign, or the axis adds - * nothing the laws need. - */ -describe(`sync metadata session grammar ablation`, () => { - const ablations: Array<{ - grammar: SessionGrammar - drop: (action: SessionAction) => boolean - regime: string - }> = [ - { - grammar: flatGrammar, - drop: (x) => x.type === `hold`, - regime: `read with two held transactions`, - }, - { - grammar: flatGrammar, - drop: (x) => x.type === `truncate`, - regime: `truncate during a hold`, - }, - { - grammar: flatGrammar, - drop: (x) => x.type === `reinsert`, - regime: `equal re-insert`, - }, - { - grammar: flatGrammar, - drop: (x) => x.type === `collectionSet` && x.undefinedValue === true, - regime: `undefined collection value`, - }, - { - grammar: nestedGrammar, - drop: (x) => x.type === `cancel`, - regime: `abort keeps an inner commit`, - }, - { - grammar: { ...nestedGrammar, maxDepth: 1 }, - drop: () => false, - regime: `nested commit`, - }, - { 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, { grammar, drop, regime }) => { - oracleRoute(`fixed seed`) - const count = (filter: (action: SessionAction) => boolean) => - fc - .sample(sessionHistoryArbitrary(grammar), { - seed: 2_220_100, - numRuns: 200, - }) - .filter((history) => - historyRegimes(grammar, history.filter(filter)).has(regime), - ).length - const fullGrammar = - grammar.maxDepth === 1 && regime === `nested commit` - ? nestedGrammar - : grammar - const full = fc - .sample(sessionHistoryArbitrary(fullGrammar), { - seed: 2_220_100, - numRuns: 200, - }) - .filter((history) => - historyRegimes(fullGrammar, history).has(regime), - ).length - expect(full).toBeGreaterThan(10) - expect(count((action) => !drop(action))).toBe(0) - }, - ) -}) - /** * Second formulation: production against itself, with and without holds. * It does not consult the model's reads, so it cannot share a fault in them. diff --git a/packages/db/tests/sync-metadata-session-oracle.ts b/packages/db/tests/sync-metadata-session-oracle.ts index aaece9cfee..63af279553 100644 --- a/packages/db/tests/sync-metadata-session-oracle.ts +++ b/packages/db/tests/sync-metadata-session-oracle.ts @@ -1,5 +1,4 @@ import { fc } from '@fast-check/vitest' -import { declareOracle } from './oracle-declaration.js' /** * # What does a sync source read back, and what do consumers see? @@ -3262,35 +3261,3 @@ export function describeMismatch( : []), ].join(`\n`) } - -// The check runs a history through a driver and compares each observation -// with the model; the model enacts the session laws; the grammar generates -// legal histories and names the regimes they reach. -declareOracle({ - check: [ - runSessionHistory, - runHoldInvariance, - runKeyIndependence, - runSeedOrderInvariance, - ], - model: [SessionModel, createSessionModel, modelCheckpoints], - grammar: [ - sessionActionArbitrary, - sessionHistoryArbitrary, - historyRegimes, - constructRegime, - seedReinsertReloadInputArbitrary, - seedReinsertReloadHistory, - seedReinsertReloadHistoryArbitrary, - completedSeedReinsertReloadRegime, - keptMetadataDurabilityInputArbitrary, - keptMetadataDurabilityBatch, - keptMetadataDurabilityHistoriesArbitrary, - queuedMetadataRepairInputArbitrary, - queuedMetadataRepairBatch, - queuedMetadataRepairHistoriesArbitrary, - missingRowMetadataRepeatsArbitrary, - missingRowMetadataBatch, - missingRowMetadataHistoriesArbitrary, - ], -}) diff --git a/packages/db/tests/sync-metadata-session-witness-grammar-oracle.ts b/packages/db/tests/sync-metadata-session-witness-grammar-oracle.ts new file mode 100644 index 0000000000..e672566b36 --- /dev/null +++ b/packages/db/tests/sync-metadata-session-witness-grammar-oracle.ts @@ -0,0 +1,323 @@ +// The sync metadata session's named witnesses, moved unchanged out of +// sync-metadata-session-oracle.property.test.ts so that its model-only witness +// check in sync-metadata-session.test.ts can import them without importing the +// oracle file. The oracle file replays each witness through production. It +// registers no tests. +import { + flatGrammar, + nestedGrammar, + nestedSeedGrammar, + seedFullGrammar, + seedGrammar, +} from './sync-metadata-session-oracle.js' +import type { + SessionAction, + SessionGrammar, +} from './sync-metadata-session-oracle.js' + +// --------------------------------------------------------------------------- +// Named witnesses +// --------------------------------------------------------------------------- + +const a = ( + type: SessionAction[`type`], + key?: number | string, + metadata?: boolean, +) => + ({ + type, + ...(key === undefined ? {} : { key }), + ...(metadata === undefined ? {} : { metadata }), + }) as SessionAction + +/** + * Each witness reaches one law's premise. The model expectations below are + * derived by hand from the laws, so they also check the model against the + * wrong answers that production gives on `30175be48`. + */ +export const witnesses = { + // A later seed beneath an open re-insert does not change its kind, but a + // source update that merges over that seed and commits ahead does: the + // re-insert then lands on a different row (decision 10). + lateSeedUnderMergedUpdate: { + grammar: nestedSeedGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`upsert`, 1, true), + a(`reinsert`, 1, false), + a(`begin`), + { type: `seed`, key: 1, bare: true }, + a(`upsert`, 1, false), + a(`commit`), + ] as Array, + }, + // The same under a hold: the inner commit stays accepted but unapplied, so + // the seed still marks the row. + heldLateSeedUnderMergedUpdate: { + grammar: nestedSeedGrammar, + history: [ + a(`hold`), + a(`seed`, 1), + a(`begin`), + a(`upsert`, 1, true), + a(`reinsert`, 1, false), + a(`begin`), + { type: `seed`, key: 1, bare: true }, + a(`upsert`, 1, false), + a(`commit`), + ] as Array, + }, + // Issue #2220: a held earlier write must stay visible after a later commit. + heldCursor: { + grammar: flatGrammar, + history: [ + a(`hold`), + a(`begin`), + { type: `collectionSet`, key: `cursor` }, + a(`commit`), + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + ] as Array, + }, + // The open transaction overlays every held one, not only the newest. + openOverHeld: { + grammar: flatGrammar, + history: [ + a(`hold`), + a(`begin`), + a(`rowSet`, 1), + { type: `collectionSet`, key: `gc:a` }, + a(`commit`), + a(`begin`), + ] as Array, + }, + // Independent transactions: an outer abort keeps an inner commit. + outerAbortKeepsInnerCommit: { + grammar: nestedGrammar, + history: [ + a(`begin`), + a(`begin`), + a(`upsert`, 1, true), + a(`commit`), + a(`cancel`), + ] as Array, + }, + // Commit order: the outer transaction commits after the inner one, so its + // earlier write wins, and a hold does not change that order. + commitOrderDuringHold: { + grammar: nestedGrammar, + history: [ + a(`hold`), + a(`begin`), + a(`rowSet`, 1), + a(`begin`), + a(`rowSet`, 1), + a(`commit`), + a(`commit`), + a(`release`), + ] as Array, + }, + // During a hold, the outer transaction can still write and commit after an + // inner one commits. + outerContinuesAfterInnerDuringHold: { + grammar: nestedGrammar, + history: [ + a(`hold`), + a(`begin`), + a(`begin`), + a(`rowSet`, 1), + a(`commit`), + a(`rowSet`, 2), + a(`commit`), + ] as Array, + }, + // An inner truncate applies at its own commit, at once despite a hold. + innerTruncateAppliesAtItsCommit: { + grammar: nestedGrammar, + history: [ + a(`begin`), + a(`upsert`, 1, true), + a(`commit`), + a(`hold`), + a(`begin`), + a(`begin`), + a(`truncate`), + a(`commit`), + a(`commit`), + ] as Array, + }, + // A seed that arrives under an open update changes the update's merge base, + // 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: { + grammar: seedGrammar, + history: [ + a(`begin`), + { type: `seed`, key: 1, narrow: true, bare: true }, + a(`upsert`, 1, false), + a(`reinsert`, 1, false), + { type: `seed`, key: 1, bare: true }, + a(`commit`), + ] as Array, + }, + // A seed never supersedes a source metadata write on a row the source did not write. + seedUnderMetadataOnlyWrite: { + grammar: seedGrammar, + history: [ + a(`begin`), + a(`rowSet`, 1), + a(`commit`), + a(`seed`, 1), + ] as Array, + }, + // A seed arriving under the source's open insert sits beneath it: the + // insert replaces the seeded row and clears its metadata. Consumers see the + // seed until the insert's transaction applies. + seedUnderOpenInsert: { + grammar: seedGrammar, + history: [ + a(`begin`), + a(`upsert`, 1, false), + a(`seed`, 1), + a(`commit`), + ] as Array, + }, + // The same history with the insert committed before the seed arrives: the + // outcome does not depend on arrival order. + seedAfterCommittedInsert: { + grammar: seedGrammar, + history: [ + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + a(`seed`, 1), + ] as Array, + }, + // 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: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`reinsert`, 1, false), + ] as Array, + }, + // 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: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + { type: `seed`, key: 1, narrow: true }, + ] as Array, + }, + // 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: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + { type: `seed`, key: 1, narrow: true }, + ] as Array, + }, + // The latest seed wins whole: a later seed without metadata leaves none. + bareLaterSeedClearsMetadata: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + { type: `seed`, key: 1, bare: true }, + ] as Array, + }, + // A fresh source insert over a row only a seed supplied replaces the row + // and, naming no metadata, clears the seed's. + insertOverSeedReplaces: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`insertOverSeed`, 1, false), + a(`commit`), + ] as Array, + }, + // A source update merges over a seeded row under partial row updates, so the + // seed's other field and its metadata stay. + updateOverSeed: { + grammar: seedGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + ] as Array, + }, + // Under full row updates the update replaces the seeded row; its metadata + // stays, because the update names none. + updateOverSeedFullRows: { + grammar: seedFullGrammar, + history: [ + a(`seed`, 1), + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + ] as Array, + }, + // An open truncate hides a held row write: a row read stops at the truncate. + openTruncateHidesHeldRow: { + grammar: flatGrammar, + history: [ + a(`hold`), + a(`begin`), + a(`rowSet`, 1), + a(`commit`), + a(`begin`), + a(`truncate`), + ] as Array, + }, + // An open delete hides a held collection entry from `list()`. + openDeleteHidesHeldListEntry: { + grammar: flatGrammar, + history: [ + a(`hold`), + a(`begin`), + a(`collectionSet`, `gc:a`), + a(`commit`), + a(`begin`), + a(`collectionDelete`, `gc:a`), + ] as Array, + }, + // `list('gc:')` includes the key equal to the prefix and excludes `gc`. + prefixBoundary: { + grammar: flatGrammar, + history: [ + a(`begin`), + a(`collectionSet`, `gc`), + a(`collectionSet`, `gc:`), + ] as Array, + }, + // Under full row updates a held update replaces the row, so a later insert + // 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: { + grammar: seedFullGrammar, + history: [ + a(`begin`), + a(`upsert`, 1, true), + a(`commit`), + a(`hold`), + a(`begin`), + a(`upsert`, 1, false), + a(`commit`), + a(`begin`), + a(`reinsert`, 1, false), + ] as Array, + }, +} satisfies Record< + string, + { grammar: SessionGrammar; history: Array } +> diff --git a/packages/db/tests/sync-metadata-session.test.ts b/packages/db/tests/sync-metadata-session.test.ts index c84ed6feae..d16690f7c0 100644 --- a/packages/db/tests/sync-metadata-session.test.ts +++ b/packages/db/tests/sync-metadata-session.test.ts @@ -1,12 +1,26 @@ +import { fc } from '@fast-check/vitest' import { describe, expect, it } from 'vitest' import { DbClient, collectionOptions } from '../src/client.js' import { createCollection } from '../src/collection/index.js' import { + absentMarker, createSessionModel, + flatGrammar, + historyRegimes, + modelCheckpoints, + nestedGrammar, persistedPeerGrammar, runSessionHistory, + seedGrammar, + sessionHistoryArbitrary, +} from './sync-metadata-session-oracle.js' +import { witnesses } from './sync-metadata-session-witness-grammar-oracle.js' +import type { + SessionAction, + SessionDriver, + SessionGrammar, + SessionModel, } from './sync-metadata-session-oracle.js' -import type { SessionDriver } from './sync-metadata-session-oracle.js' import type { SyncConfig } from '../src/types.js' // Fixed boundaries of the sync metadata session. The session law itself is @@ -165,3 +179,202 @@ describe(`sync metadata session boundaries`, () => { } }) }) + +// Moved from sync-metadata-session-oracle.property.test.ts: the model's +// checkpoints on the named witnesses, against expectations derived by hand +// from the laws. It runs no production. +describe(`sync metadata session oracle`, () => { + it(`derives the named witnesses from the laws`, async () => { + type Observation = ReturnType + const at = async (name: keyof typeof witnesses, step: number) => { + 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) + expect(held.collection.cursor).toEqual({ s: 2 }) + expect(held.base[1]).toBe(absentMarker) + // Wrong answer: the open transaction hides every held write. + const open = await at(`openOverHeld`, 5) + expect(open.rowMetadata[1]).toEqual({ s: 2 }) + expect(open.prefixList).toEqual([[`gc:a`, { s: 3 }]]) + // Wrong answer under closed nesting: the outer abort undoes the inner + // commit, and its receipt waits for the outer commit. + const innerCommitted = await at(`outerAbortKeepsInnerCommit`, 3) + const insertedRow = { id: 1, v: { s: 2 }, m: { s: 2 } } + expect(innerCommitted.rowMetadata[1]).toEqual({ s: 2 }) + expect(innerCommitted.base[1]).toEqual(insertedRow) + expect(innerCommitted.receipts).toEqual([`applied`]) + const outerAborted = await at(`outerAbortKeepsInnerCommit`, 4) + expect(outerAborted.rowMetadata[1]).toEqual({ s: 2 }) + expect(outerAborted.base[1]).toEqual(insertedRow) + // Wrong answers: issue order (the inner write wins), and on 30175be48 a + // held inner commit read over the open outer write and applied after it. + expect((await at(`commitOrderDuringHold`, 4)).rowMetadata[1]).toEqual({ + s: 4, + }) + expect((await at(`commitOrderDuringHold`, 5)).rowMetadata[1]).toEqual({ + s: 2, + }) + const released = await at(`commitOrderDuringHold`, 7) + expect(released.rowMetadata[1]).toEqual({ s: 2 }) + expect(released.receipts).toEqual([`applied`, `applied`]) + // Wrong answer on 30175be48: the outer transaction cannot write after an + // inner one commits during a hold. + const continued = await at(`outerContinuesAfterInnerDuringHold`, 6) + expect(continued.rowMetadata).toEqual({ 1: { s: 3 }, 2: { s: 5 } }) + expect(continued.receipts).toEqual([`pending`, `pending`]) + // Wrong answer under closed nesting: the inner truncate waits for the + // outer commit. + const innerTruncated = await at(`innerTruncateAppliesAtItsCommit`, 7) + expect(innerTruncated.base[1]).toBe(absentMarker) + expect(innerTruncated.rowMetadata[1]).toBe(absentMarker) + expect(innerTruncated.receipts).toEqual([`applied`, `applied`]) + expect((await at(`innerTruncateAppliesAtItsCommit`, 8)).receipts).toEqual([ + `applied`, + `applied`, + `pending`, + ]) + // Wrong answers: replay makes the re-insert a duplicate and invalidates + // the source's transaction, so only the late seed remains; or the + // re-insert merges and keeps the late seed's field. + const reinserted = await at(`seedUnderOpenReinsert`, 5) + expect(reinserted.base[1]).toEqual({ id: 1, v: { s: 2 } }) + expect(reinserted.receipts).toEqual([`applied`]) + // Wrong answer: the seed's metadata replaces the source's metadata write. + const seeded = await at(`seedUnderMetadataOnlyWrite`, 3) + expect(seeded.rowMetadata[1]).toEqual({ s: 1 }) + expect(seeded.base[1]).toEqual({ id: 1, v: { s: 3 }, seeded: { s: 3 } }) + // Wrong answer on 30175be48: the open insert becomes an update over the + // late seed, so the seed's metadata and other field survive it. + const underOpen = await at(`seedUnderOpenInsert`, 2) + expect(underOpen.rowMetadata[1]).toBe(absentMarker) + expect(underOpen.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 2 } }) + const openCommitted = await at(`seedUnderOpenInsert`, 3) + expect(openCommitted.rowMetadata[1]).toBe(absentMarker) + expect(openCommitted.base[1]).toEqual({ id: 1, v: { s: 1 } }) + const afterCommit = await at(`seedAfterCommittedInsert`, 3) + expect(afterCommit.rowMetadata[1]).toBe(absentMarker) + expect(afterCommit.base[1]).toEqual({ id: 1, v: { s: 1 } }) + // Wrong answer on 30175be48: core counts it as an equal re-insert. + const equalInsert = await at(`equalInsertOverSeed`, 2) + expect(equalInsert.rowMetadata[1]).toBe(absentMarker) + const truncated = await at(`openTruncateHidesHeldRow`, 5) + expect(truncated.rowMetadata[1]).toBe(absentMarker) + const deleted = await at(`openDeleteHidesHeldListEntry`, 5) + expect(deleted.list).toEqual([]) + expect(deleted.prefixList).toEqual([]) + const boundary = await at(`prefixBoundary`, 2) + expect(boundary.prefixList).toEqual([[`gc:`, { s: 2 }]]) + expect(boundary.list).toEqual([ + [`gc`, { s: 1 }], + [`gc:`, { s: 2 }], + ]) + const equalAfterFullUpdate = await at( + `fullUpdateThenEqualInsertDuringHold`, + 8, + ) + expect(equalAfterFullUpdate.rowMetadata[1]).toEqual({ s: 1 }) + expect(equalAfterFullUpdate.base[1]).toEqual({ + id: 1, + v: { s: 1 }, + m: { s: 1 }, + }) + const laterSeed = await at(`laterSeedReplacesSeed`, 1) + expect(laterSeed.rowMetadata[1]).toEqual({ s: 1 }) + expect(laterSeed.base[1]).toEqual({ id: 1, v: { s: 1 } }) + // Wrong answer on 30175be48: the late seed is dropped with its metadata. + const lateSeed = await at(`partialUpdateThenLateSeed`, 4) + expect(lateSeed.rowMetadata[1]).toEqual({ s: 4 }) + expect(lateSeed.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 0 } }) + // Wrong answer on 30175be48: the earlier seed's metadata survives. + const bare = await at(`bareLaterSeedClearsMetadata`, 1) + expect(bare.rowMetadata[1]).toBe(absentMarker) + expect(bare.base[1]).toEqual({ id: 1, v: { s: 1 }, seeded: { s: 1 } }) + // Wrong answer on 30175be48: the insert merges over the seed as an update. + const replacedSeed = await at(`insertOverSeedReplaces`, 3) + expect(replacedSeed.rowMetadata[1]).toBe(absentMarker) + expect(replacedSeed.base[1]).toEqual({ id: 1, v: { s: 2 } }) + const merged = await at(`updateOverSeed`, 3) + expect(merged.rowMetadata[1]).toEqual({ s: 0 }) + expect(merged.base[1]).toEqual({ id: 1, v: { s: 2 }, seeded: { s: 0 } }) + const replaced = await at(`updateOverSeedFullRows`, 3) + expect(replaced.rowMetadata[1]).toEqual({ s: 0 }) + expect(replaced.base[1]).toEqual({ id: 1, v: { s: 2 } }) + }) +}) + +// Moved from sync-metadata-session-oracle.property.test.ts: a fixed-seed +// census of the grammar alone, which runs no production. +/** + * Grammar ablation: each generated axis exists for a regime. Removing the + * axis must remove its regime from the fixed campaign, or the axis adds + * nothing the laws need. + */ +describe(`sync metadata session grammar ablation`, () => { + const ablations: Array<{ + grammar: SessionGrammar + drop: (action: SessionAction) => boolean + regime: string + }> = [ + { + grammar: flatGrammar, + drop: (x) => x.type === `hold`, + regime: `read with two held transactions`, + }, + { + grammar: flatGrammar, + drop: (x) => x.type === `truncate`, + regime: `truncate during a hold`, + }, + { + grammar: flatGrammar, + drop: (x) => x.type === `reinsert`, + regime: `equal re-insert`, + }, + { + grammar: flatGrammar, + drop: (x) => x.type === `collectionSet` && x.undefinedValue === true, + regime: `undefined collection value`, + }, + { + grammar: nestedGrammar, + drop: (x) => x.type === `cancel`, + regime: `abort keeps an inner commit`, + }, + { + grammar: { ...nestedGrammar, maxDepth: 1 }, + drop: () => false, + regime: `nested commit`, + }, + { 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, { grammar, drop, regime }) => { + const count = (filter: (action: SessionAction) => boolean) => + fc + .sample(sessionHistoryArbitrary(grammar), { + seed: 2_220_100, + numRuns: 200, + }) + .filter((history) => + historyRegimes(grammar, history.filter(filter)).has(regime), + ).length + const fullGrammar = + grammar.maxDepth === 1 && regime === `nested commit` + ? nestedGrammar + : grammar + const full = fc + .sample(sessionHistoryArbitrary(fullGrammar), { + seed: 2_220_100, + numRuns: 200, + }) + .filter((history) => + historyRegimes(fullGrammar, history).has(regime), + ).length + expect(full).toBeGreaterThan(10) + expect(count((action) => !drop(action))).toBe(0) + }, + ) +}) diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index a1dcc89137..7b1559ba78 100644 --- a/packages/db/tests/truncate-readiness-oracle.test.ts +++ b/packages/db/tests/truncate-readiness-oracle.test.ts @@ -15,11 +15,15 @@ * promises readiness after it is a contract violation and throws. Only a * restart begins a new attempt. */ -import { expect, it } from 'vitest' +import { expect } 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 { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import type { SyncConfig } from '../src/types.js' type Row = { id: number; value: string } @@ -68,132 +72,175 @@ function expectReentrantStatuses( expect(statuses).toEqual(last === false ? [] : ['ready']) } +// The original replacement-readiness case, unchanged: it drives production +// and compares each cut with the model above. +async function runReplacementCell({ + status, + held, + first, + last, +}: (typeof cells)[number]): Promise { + let sync!: Sync + const finish = createDeferred() + const c = createCollection({ + getKey: (row) => row.id, + startSync: true, + gcTime: Infinity, + sync: { + sync: (params) => { + sync = params + }, + }, + onInsert: () => finish.promise, + }) + const observed: Array = [] + const stop = c.subscribeChanges(() => { + observed.push(c.status) + }) + c.on('status:change', ({ status: next }) => { + observed.push(next) + }) + try { + sync.begin() + sync.write({ type: 'insert', value: { id: 1, value: 'initial' } }) + sync.commit() + if (status === 'ready') sync.markReady() + // Loading admits edits after sync entry. + const optimistic = held + ? c.insert({ id: 9, value: 'optimistic' }) + : undefined + observed.length = 0 + // Structural callable widening makes the pre-fix run reach assertions: + // an ignored option is a semantic failure, never a compiler-error witness. + const truncate: (options?: { markReady?: boolean }) => void = sync.truncate + sync.begin() + truncate(first === undefined ? undefined : { markReady: first }) + sync.write({ type: 'insert', value: { id: 2, value: 'discarded' } }) + truncate(last === undefined ? undefined : { markReady: last }) + sync.write({ type: 'insert', value: { id: 3, value: 'replacement' } }) + const receipt = sync.commit() + const expectedStatus = expectedReplacementStatus(status, last) + expectReplacementStatus(c.status, observed, expectedStatus) + expect( + [...c.base.values()].map((row) => ({ id: row.id, value: row.value })), + ).toEqual([{ id: 3, value: 'replacement' }]) + await receipt + if (held) expect(c.get(9)?.value).toBe('optimistic') + finish.resolve() + await optimistic?.when(`settled`) + expect.soft(c.status).toBe(expectedStatus) + // A later independent ordinary replacement is ready. + sync.begin() + sync.truncate() + sync.commit() + expect(c.status).toBe('ready') + expect(c.size).toBe(0) + } finally { + finish.resolve() + stop.unsubscribe() + await c.cleanup() + } +} + // 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({ +oracleProperty({ + id: `truncate-readiness.replacement-intent`, + law: { + statement: `A truncate's replacement makes the Collection ready unless its final authored intent is markReady:false, which keeps the current status, at every publication and after commit.`, + authority: `Sync truncate readiness contract stated above, the core boundary required by the IndexedDB retirement model`, + limits: `Initial loading or ready status, every pair of two truncate intents in one transaction, and an optional held optimistic transaction.`, + }, owns: [`src/collection/sync.ts`], - check: [expectReplacementStatus, expectReentrantStatuses], - model: [expectedReplacementStatus], + histories: cells, + label: (cell) => + `replacement readiness follows the final authored intent: ${JSON.stringify(cell)}`, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `truncate-readiness.replacement-intent`, + plan, + observe, + () => runReplacementCell(plan), + ), + check: forwardAssertionJudgment, }) -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({ - getKey: (row) => row.id, - startSync: true, - gcTime: Infinity, - sync: { - sync: (params) => { - sync = params - }, - }, - onInsert: () => finish.promise, - }) - const observed: Array = [] - const stop = c.subscribeChanges(() => { - observed.push(c.status) - }) - c.on('status:change', ({ status: next }) => { - observed.push(next) - }) - try { - sync.begin() - sync.write({ type: 'insert', value: { id: 1, value: 'initial' } }) - sync.commit() - if (status === 'ready') sync.markReady() - // Loading admits edits after sync entry. - const optimistic = held - ? c.insert({ id: 9, value: 'optimistic' }) - : undefined - observed.length = 0 - // Structural callable widening makes the pre-fix run reach assertions: - // an ignored option is a semantic failure, never a compiler-error witness. - const truncate: (options?: { markReady?: boolean }) => void = - sync.truncate - sync.begin() - truncate(first === undefined ? undefined : { markReady: first }) - sync.write({ type: 'insert', value: { id: 2, value: 'discarded' } }) - truncate(last === undefined ? undefined : { markReady: last }) - sync.write({ type: 'insert', value: { id: 3, value: 'replacement' } }) - const receipt = sync.commit() - const expectedStatus = expectedReplacementStatus(status, last) - expectReplacementStatus(c.status, observed, expectedStatus) - expect( - [...c.base.values()].map((row) => ({ id: row.id, value: row.value })), - ).toEqual([{ id: 3, value: 'replacement' }]) - await receipt - if (held) expect(c.get(9)?.value).toBe('optimistic') - finish.resolve() - await optimistic?.when(`settled`) - expect.soft(c.status).toBe(expectedStatus) - // A later independent ordinary replacement is ready. - sync.begin() - sync.truncate() - sync.commit() - expect(c.status).toBe('ready') - expect(c.size).toBe(0) - } finally { - finish.resolve() - stop.unsubscribe() - await c.cleanup() - } - }, -) - // Reentrant commits queue behind the publication currently notifying observers. // Their final replacement determines the next published batch, including its // readiness intent. An earlier replaced snapshot must not make that batch ready. // The Collection starts loading, so only a replacement can make it ready. -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, - startSync: true, - gcTime: Infinity, - sync: { - sync: (params) => { - sync = params - }, +const reentrantPairs = intents.flatMap((first) => + intents.map((last) => ({ first, last })), +) + +async function runReentrantPair({ + first, + last, +}: (typeof reentrantPairs)[number]): Promise { + let sync!: Sync + const c = createCollection({ + getKey: (row) => row.id, + startSync: true, + gcTime: Infinity, + sync: { + sync: (params) => { + sync = params }, - }) - let entered = false - const receipts: Array> = [] - const statuses: Array = [] - c.on('status:change', ({ status }) => { - statuses.push(status) - }) - const subscription = c.subscribeChanges(() => { - if (entered) return - entered = true - for (const [index, markReady] of [first, last].entries()) { - sync.begin() - sync.truncate(markReady === undefined ? undefined : { markReady }) - sync.write({ - type: 'insert', - value: { id: index + 2, value: 'replacement' }, - }) - receipts.push(sync.commit()) - } - }) - try { + }, + }) + let entered = false + const receipts: Array> = [] + const statuses: Array = [] + c.on('status:change', ({ status }) => { + statuses.push(status) + }) + const subscription = c.subscribeChanges(() => { + if (entered) return + entered = true + for (const [index, markReady] of [first, last].entries()) { sync.begin() - sync.write({ type: 'insert', value: { id: 1, value: 'trigger' } }) - sync.commit() - await Promise.all(receipts) - expect(entered).toBe(true) - expectReentrantStatuses(c.status, statuses, last) - expect([...c.keys()]).toEqual([3]) - } finally { - subscription.unsubscribe() - await c.cleanup() + sync.truncate(markReady === undefined ? undefined : { markReady }) + sync.write({ + type: 'insert', + value: { id: index + 2, value: 'replacement' }, + }) + receipts.push(sync.commit()) } + }) + try { + sync.begin() + sync.write({ type: 'insert', value: { id: 1, value: 'trigger' } }) + sync.commit() + await Promise.all(receipts) + expect(entered).toBe(true) + expectReentrantStatuses(c.status, statuses, last) + expect([...c.keys()]).toEqual([3]) + } finally { + subscription.unsubscribe() + await c.cleanup() + } +} + +oracleProperty({ + id: `truncate-readiness.reentrant-batches`, + law: { + statement: `Reentrant replacement commits queue behind the current publication, and the final replacement's readiness intent alone decides the next batch's status.`, + authority: `Sync truncate readiness contract stated above, the core boundary required by the IndexedDB retirement model`, + limits: `Every pair of readiness intents for two reentrant replacement transactions on a loading Collection.`, }, -) + owns: [`src/collection/sync.ts`], + histories: reentrantPairs, + label: (pair) => + `composes readiness of reentrant replacement batches: ${JSON.stringify(pair)}`, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `truncate-readiness.reentrant-batches`, + plan, + observe, + () => runReentrantPair(plan), + ), + check: forwardAssertionJudgment, +}) From 3a9cfc3eb66e968c4efe6ab565b7b9d22836641e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:12:46 -0700 Subject: [PATCH 069/116] test(db): register Effect virtual-field histories through oracleProperty Move the hostile and allowed ledger calibrations of the delta check to a new ordinary test file, with the model and check in a test-free companion module. Co-authored-by: Isaac --- .../virtual-row-fields-effect-model-oracle.ts | 228 +++ .../virtual-row-fields-effect-oracle.test.ts | 1452 +++++++---------- .../query/virtual-row-fields-effect.test.ts | 231 +++ 3 files changed, 1014 insertions(+), 897 deletions(-) create mode 100644 packages/db/tests/query/virtual-row-fields-effect-model-oracle.ts create mode 100644 packages/db/tests/query/virtual-row-fields-effect.test.ts diff --git a/packages/db/tests/query/virtual-row-fields-effect-model-oracle.ts b/packages/db/tests/query/virtual-row-fields-effect-model-oracle.ts new file mode 100644 index 0000000000..1a57dabab3 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-effect-model-oracle.ts @@ -0,0 +1,228 @@ +import { isDeepStrictEqual } from 'node:util' +import { expect } from 'vitest' +import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' +import { virtualFieldNames } from './virtual-row-fields-support.js' +import type { DeltaEvent } from '../../src/query/effect.js' + +// The source, row, event, and ledger model of the Effect virtual-field oracle +// and its delta check, moved here unchanged from +// virtual-row-fields-effect-oracle.test.ts. The oracle compares production +// Effects with them; the ordinary checker calibrations in +// virtual-row-fields-effect.test.ts feed them hand-built ledgers. This module +// registers no tests. + +export type Key = string | number +export type Side = `left` | `right` +export type Origin = `local` | `remote` +export type SourceRow = { + id: Key + parentId: string + side: Side + label: string +} +export type SourceState = { + id: string + base: Array + origins: Map + overlay?: SourceRow + confirmation?: SourceRow +} +export type Projection = `structured` | `functional` +export type Kind = `single` | `join` | `union` +export type History = { + kind: Kind + projection: Projection + reverse: boolean + confirm: ReadonlyArray + reject: ReadonlyArray + rightFirst: boolean +} +export type ProjectedRow = Record & { id: Key; side: Side } +export type ModelEvent = { + type: `enter` | `update` | `exit` + value: ProjectedRow + previousValue?: ProjectedRow +} +export type RecordedEvent = { + type: string + key: Key + value: unknown + previousValue?: unknown +} +export type NamedEvent = RecordedEvent & { route: ModelEvent[`type`] } +export type Ledger = { + batches: Array> + named: Array +} +export type KeyIntervals = Map + +export function visibleSource(source: SourceState) { + return source.base.map((base) => { + const pending = source.overlay?.id === base.id + const row = pending ? source.overlay! : base + return { + ...row, + ...expectedSourceMetadata( + row.id, + source.id, + pending, + source.origins.get(row.id) ?? `remote`, + ), + } + }) +} +export type SourceValue = ReturnType[number] + +// SELECT copies these source facts into ordinary fields. New output objects +// need no root metadata judgment to determine their explicitly selected values. +export function projectedSource(row: SourceValue): ProjectedRow { + return { + id: row.id, + side: row.side, + label: row.label, + observedKey: row.$key, + observedCollectionId: row.$collectionId, + observedOrigin: row.$origin, + observedPending: row.$hasPendingWrites, + } +} +export function expectedEffectRows( + kind: Kind, + left: SourceState, + right: SourceState, +): Array { + const a = visibleSource(left) + const b = visibleSource(right) + if (kind === `single`) return a.map(projectedSource) + if (kind === `union`) return [...a, ...b].map(projectedSource) + return a.map((row) => { + const joined = b.find((candidate) => candidate.parentId === row.id) + return { + ...projectedSource(row), + rightLabel: joined?.label, + rightKey: joined?.$key, + rightCollectionId: joined?.$collectionId, + rightOrigin: joined?.$origin, + rightPending: joined?.$hasPendingWrites, + } + }) +} +export function occurrence(value: unknown): string { + if (!value || typeof value !== `object`) return `invalid:${String(value)}` + const row = value as Record + return `${String(row.side)}:${typeof row.id}:${String(row.id)}` +} +// Independent before/after differencing retains every occurrence. Equality +// distinguishes a missing own field from a field whose value is undefined. +export function expectedEffectDelta( + before: ReadonlyArray, + after: ReadonlyArray, +): Array { + const delta: Array = [] + for (const old of before) { + if (!after.some((row) => occurrence(row) === occurrence(old))) + delta.push({ type: `exit`, value: old }) + } + for (const row of after) { + const old = before.find((prior) => occurrence(prior) === occurrence(row)) + if (!old) delta.push({ type: `enter`, value: row }) + else if (!isDeepStrictEqual(old, row)) + delta.push({ type: `update`, value: row, previousValue: old }) + } + return delta +} +export function eventOrder( + a: { value: unknown }, + b: { value: unknown }, +): number { + return occurrence(a.value).localeCompare(occurrence(b.value)) +} +export function eventValue(event: RecordedEvent) { + const { key: _key, ...selected } = event + return selected +} +// Named delivery and batch delivery each refine the independent full delta. +// Cross-channel agreement alone would allow both channels to share a defect. +export function checkEffectDelta( + ledger: Ledger, + expected: ReadonlyArray, + active: KeyIntervals, +): void { + const batches = ledger.batches.flat() + const failures: Array = [] + const judge = (law: string, compare: () => void) => { + try { + compare() + } catch (error) { + failures.push(new Error(law, { cause: error })) + } + } + judge(`batch delta and previous values`, () => { + expect(batches.map(eventValue).sort(eventOrder)).toStrictEqual( + [...expected].sort(eventOrder), + ) + }) + judge(`named delta, route and previous values`, () => { + expect(ledger.named.map(eventValue).sort(eventOrder)).toStrictEqual( + expected + .map((event) => ({ ...event, route: event.type })) + .sort(eventOrder), + ) + }) + judge(`named and batch event keys agree`, () => { + expect( + ledger.named.map(({ route: _route, ...event }) => event).sort(eventOrder), + ).toStrictEqual([...batches].sort(eventOrder)) + }) + + // Report an invalid event without adopting it as a live occurrence. Later + // lawful enters in the same batch can still establish their own intervals. + for (const event of batches) { + const id = occurrence(event.value) + judge(`opaque key interval for ${event.type} ${id}`, () => { + expect([`string`, `number`]).toContain(typeof event.key) + if (event.type === `enter`) { + expect(active.has(id), `enter starts an absent occurrence`).toBe(false) + expect( + [...active.values()], + `live occurrence keys are distinct`, + ).not.toContain(event.key) + active.set(id, event.key) + } else { + expect(active.has(id), `update/exit names a live occurrence`).toBe(true) + expect(event.key, `key remains stable within its active interval`).toBe( + active.get(id), + ) + if (event.type === `exit`) active.delete(id) + } + }) + } + if (failures.length) + throw new AggregateError(failures, `Effect delta law failed`, { + cause: failures[0], + }) +} + +// One source has two outcomes and two confirmation states. Two sources have +// four independent outcomes, four confirmation masks and two settlement orders. +// Only unions vary branch order: it has observable source-selection machinery. + +export function captureSelected(value: unknown): unknown { + if (!value || typeof value !== `object`) return value + return Object.fromEntries( + Object.entries(value).filter( + ([name]) => !virtualFieldNames.some((field) => field === name), + ), + ) +} +export function captureEvent(event: DeltaEvent): RecordedEvent { + return { + type: event.type, + key: event.key, + value: captureSelected(event.value), + ...(`previousValue` in event + ? { previousValue: captureSelected(event.previousValue) } + : {}), + } +} +// Driver projection: runtime source objects, not model source snapshots. 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 4a3e91da8d..e5dfaf61a1 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 @@ -48,221 +48,46 @@ * histories change selected source facts and retain their array-difference * judgment; they do not specify every unordered graph notification. */ -import { isDeepStrictEqual } from 'node:util' -import { afterAll, expect, test } from 'vitest' +import { afterAll, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { Query, coalesce, eq } from '../../src/query/index.js' import { createEffect } from '../../src/query/effect.js' import { createTransaction } from '../../src/transactions.js' -import { declareOracle } from '../oracle-declaration.js' import { flushPromises, mockSyncCollectionOptions } from '../utils.js' -import { oracleRoute } from '../oracle-config.js' -import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' +import { oracleProperty } from '../oracle-config.js' import { - checkWithCleanup, - virtualFieldNames, -} from './virtual-row-fields-support.js' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' +import { checkWithCleanup } from './virtual-row-fields-support.js' import { callbackHistories, callbackReach, runCallbackHistory, } from './effect-callback-oracle.js' +import { + captureEvent, + captureSelected, + checkEffectDelta, + expectedEffectDelta, + expectedEffectRows, +} from './virtual-row-fields-effect-model-oracle.js' +import type { + History, + KeyIntervals, + Ledger, + ProjectedRow, + RecordedEvent, + Side, + SourceRow, + SourceState, + SourceValue, +} from './virtual-row-fields-effect-model-oracle.js' import type { DeltaEvent, EffectConfig } from '../../src/query/effect.js' import type { Transaction } from '../../src/transactions.js' import type { CallbackOutput, CallbackRow } from './effect-callback-oracle.js' -type Key = string | number -type Side = `left` | `right` -type Origin = `local` | `remote` -type SourceRow = { - id: Key - parentId: string - side: Side - label: string -} -type SourceState = { - id: string - base: Array - origins: Map - overlay?: SourceRow - confirmation?: SourceRow -} -type Projection = `structured` | `functional` -type Kind = `single` | `join` | `union` -type History = { - kind: Kind - projection: Projection - reverse: boolean - confirm: ReadonlyArray - reject: ReadonlyArray - rightFirst: boolean -} -type ProjectedRow = Record & { id: Key; side: Side } -type ModelEvent = { - type: `enter` | `update` | `exit` - value: ProjectedRow - previousValue?: ProjectedRow -} -type RecordedEvent = { - type: string - key: Key - value: unknown - previousValue?: unknown -} -type NamedEvent = RecordedEvent & { route: ModelEvent[`type`] } -type Ledger = { - batches: Array> - named: Array -} -type KeyIntervals = Map - -function visibleSource(source: SourceState) { - return source.base.map((base) => { - const pending = source.overlay?.id === base.id - const row = pending ? source.overlay! : base - return { - ...row, - ...expectedSourceMetadata( - row.id, - source.id, - pending, - source.origins.get(row.id) ?? `remote`, - ), - } - }) -} -type SourceValue = ReturnType[number] - -// SELECT copies these source facts into ordinary fields. New output objects -// need no root metadata judgment to determine their explicitly selected values. -function projectedSource(row: SourceValue): ProjectedRow { - return { - id: row.id, - side: row.side, - label: row.label, - observedKey: row.$key, - observedCollectionId: row.$collectionId, - observedOrigin: row.$origin, - observedPending: row.$hasPendingWrites, - } -} -function expectedEffectRows( - kind: Kind, - left: SourceState, - right: SourceState, -): Array { - const a = visibleSource(left) - const b = visibleSource(right) - if (kind === `single`) return a.map(projectedSource) - if (kind === `union`) return [...a, ...b].map(projectedSource) - return a.map((row) => { - const joined = b.find((candidate) => candidate.parentId === row.id) - return { - ...projectedSource(row), - rightLabel: joined?.label, - rightKey: joined?.$key, - rightCollectionId: joined?.$collectionId, - rightOrigin: joined?.$origin, - rightPending: joined?.$hasPendingWrites, - } - }) -} -function occurrence(value: unknown): string { - if (!value || typeof value !== `object`) return `invalid:${String(value)}` - const row = value as Record - return `${String(row.side)}:${typeof row.id}:${String(row.id)}` -} -// Independent before/after differencing retains every occurrence. Equality -// distinguishes a missing own field from a field whose value is undefined. -function expectedEffectDelta( - before: ReadonlyArray, - after: ReadonlyArray, -): Array { - const delta: Array = [] - for (const old of before) { - if (!after.some((row) => occurrence(row) === occurrence(old))) - delta.push({ type: `exit`, value: old }) - } - for (const row of after) { - const old = before.find((prior) => occurrence(prior) === occurrence(row)) - if (!old) delta.push({ type: `enter`, value: row }) - else if (!isDeepStrictEqual(old, row)) - delta.push({ type: `update`, value: row, previousValue: old }) - } - return delta -} -function eventOrder(a: { value: unknown }, b: { value: unknown }): number { - return occurrence(a.value).localeCompare(occurrence(b.value)) -} -function eventValue(event: RecordedEvent) { - const { key: _key, ...selected } = event - return selected -} -// Named delivery and batch delivery each refine the independent full delta. -// Cross-channel agreement alone would allow both channels to share a defect. -function checkEffectDelta( - ledger: Ledger, - expected: ReadonlyArray, - active: KeyIntervals, -): void { - const batches = ledger.batches.flat() - const failures: Array = [] - const judge = (law: string, compare: () => void) => { - try { - compare() - } catch (error) { - failures.push(new Error(law, { cause: error })) - } - } - judge(`batch delta and previous values`, () => { - expect(batches.map(eventValue).sort(eventOrder)).toStrictEqual( - [...expected].sort(eventOrder), - ) - }) - judge(`named delta, route and previous values`, () => { - expect(ledger.named.map(eventValue).sort(eventOrder)).toStrictEqual( - expected - .map((event) => ({ ...event, route: event.type })) - .sort(eventOrder), - ) - }) - judge(`named and batch event keys agree`, () => { - expect( - ledger.named.map(({ route: _route, ...event }) => event).sort(eventOrder), - ).toStrictEqual([...batches].sort(eventOrder)) - }) - - // Report an invalid event without adopting it as a live occurrence. Later - // lawful enters in the same batch can still establish their own intervals. - for (const event of batches) { - const id = occurrence(event.value) - judge(`opaque key interval for ${event.type} ${id}`, () => { - expect([`string`, `number`]).toContain(typeof event.key) - if (event.type === `enter`) { - expect(active.has(id), `enter starts an absent occurrence`).toBe(false) - expect( - [...active.values()], - `live occurrence keys are distinct`, - ).not.toContain(event.key) - active.set(id, event.key) - } else { - expect(active.has(id), `update/exit names a live occurrence`).toBe(true) - expect(event.key, `key remains stable within its active interval`).toBe( - active.get(id), - ) - if (event.type === `exit`) active.delete(id) - } - }) - } - if (failures.length) - throw new AggregateError(failures, `Effect delta law failed`, { - cause: failures[0], - }) -} - -// One source has two outcomes and two confirmation states. Two sources have -// four independent outcomes, four confirmation masks and two settlement orders. -// Only unions vary branch order: it has observable source-selection machinery. const effectMetadataHistories: Array = ( [`structured`, `functional`] as const ).flatMap((projection) => @@ -302,62 +127,87 @@ afterAll(() => { Object.fromEntries(deliveryReach.counts()), ) }) -test.each(callbackHistories)( - `delivers current values across callback profiles: %j`, - async (history) => { - oracleRoute(`case`) - ranDelivery = true - await runCallbackHistory( - history, - { - mount: async ({ skipInitial, ordered, project, handlers }) => { - const source = createCollection( - mockSyncCollectionOptions({ - id: `callback-core`, - getKey: (row) => row.id, - initialData: [{ id: 1, version: 0 }], - }), - ) - await source.preload() - const config: EffectConfig = { - query: () => { - const rows = new Query().from({ row: source }) - return ( - ordered ? rows.orderBy(({ row }) => row.id).limit(10) : rows - ).fn.select(({ row }) => project(row)) - }, - skipInitial, - ...handlers, - } - const effect = createEffect(config) - await flushPromises() - return { - write: async (type, version) => { - source.utils.begin() - source.utils.write({ type, value: { id: 1, version } }) - source.utils.commit() - await flushPromises() - }, - replaceHandlers: async (replacement) => { - Object.assign(config, replacement) - await flushPromises() - }, - dispose: () => effect.dispose(), - cleanup: () => - checkWithCleanup(async () => { - await effect.dispose() - }, [ - async () => { - await source.cleanup() - }, - ]), - } - }, +// The former case body, unchanged. +async function judgeCallbackDelivery( + history: (typeof callbackHistories)[number], +): Promise { + ranDelivery = true + await runCallbackHistory( + history, + { + mount: async ({ skipInitial, ordered, project, handlers }) => { + const source = createCollection( + mockSyncCollectionOptions({ + id: `callback-core`, + getKey: (row) => row.id, + initialData: [{ id: 1, version: 0 }], + }), + ) + await source.preload() + const config: EffectConfig = { + query: () => { + const rows = new Query().from({ row: source }) + return ( + ordered ? rows.orderBy(({ row }) => row.id).limit(10) : rows + ).fn.select(({ row }) => project(row)) + }, + skipInitial, + ...handlers, + } + const effect = createEffect(config) + await flushPromises() + return { + write: async (type, version) => { + source.utils.begin() + source.utils.write({ type, value: { id: 1, version } }) + source.utils.commit() + await flushPromises() + }, + replaceHandlers: async (replacement) => { + Object.assign(config, replacement) + await flushPromises() + }, + dispose: () => effect.dispose(), + cleanup: () => + checkWithCleanup(async () => { + await effect.dispose() + }, [ + async () => { + await source.cleanup() + }, + ]), + } }, - deliveryReach, - ) + }, + deliveryReach, + ) +} + +const callbackDeliveryLaw = `Effect callbacks deliver current values across handler profiles at the core boundary.` + +oracleProperty({ + id: `virtual-row-fields-effect.callback-delivery`, + law: { + statement: callbackDeliveryLaw, + authority: `The Collection virtual-field boundary and boundary-only metadata decision (2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, and the Effect publication rules, stated at the start of this owner.`, + limits: `The enumerated callback representation and handler-profile histories at the core Effect boundary.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/effect.ts#createEffect`, + `src/transactions.ts#createTransaction`, + ], + histories: callbackHistories, + label: (history) => + `delivers current values across callback profiles: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(callbackDeliveryLaw, plan, observe, () => + judgeCallbackDelivery(plan.history), + ), + check: forwardAssertionJudgment, +}) function pathOf(history: Pick) { return `${history.kind}${history.kind === `union` ? (history.reverse ? `-right-first` : `-left-first`) : ``}/${history.projection}` } @@ -430,16 +280,6 @@ afterAll(() => { Object.fromEntries(effectPassedCuts), ) }) -declareOracle({ - owns: [ - 'src/collection/index.ts#createCollection', - 'src/query/effect.ts#createEffect', - 'src/transactions.ts#createTransaction', - ], - model: [visibleSource, expectedEffectRows, expectedEffectDelta], - check: [checkEffectDelta], - grammar: [effectMetadataHistories], -}) function gate() { let resolve!: () => void @@ -465,25 +305,6 @@ function createSource(state: SourceState, hold: ReturnType) { }) } type Source = ReturnType -function captureSelected(value: unknown): unknown { - if (!value || typeof value !== `object`) return value - return Object.fromEntries( - Object.entries(value).filter( - ([name]) => !virtualFieldNames.some((field) => field === name), - ), - ) -} -function captureEvent(event: DeltaEvent): RecordedEvent { - return { - type: event.type, - key: event.key, - value: captureSelected(event.value), - ...(`previousValue` in event - ? { previousValue: captureSelected(event.previousValue) } - : {}), - } -} -// Driver projection: runtime source objects, not model source snapshots. function selectSource(row: SourceValue) { return { id: row.id, @@ -585,457 +406,270 @@ function startEffect( }) } -test.each(effectMetadataHistories)( - `delivers explicitly selected source metadata through Effects: %j`, - async (history) => { - oracleRoute(`case`) - enteredEffectHistories++ - effectMetadataHistory++ - const path = pathOf(history) - const id = historyId(history) - const reach = (cut: string) => { - effectMetadataHistoriesByState - .get(`${id}/batch/${cut}`)! - .add(effectMetadataHistory) - effectMetadataHistoriesByState - .get(`${id}/named/${cut}`)! - .add(effectMetadataHistory) - } - const leftMain: SourceRow = { - id: history.kind === `union` ? 1 : `a`, - parentId: `unused`, - side: `left`, - label: `left-base`, - } - const peer: SourceRow = { - id: `anchor`, - parentId: `unused`, - side: `left`, - label: `peer`, - } - const rightMain: SourceRow = { - id: history.kind === `union` ? `1` : `r`, - parentId: `a`, - side: `right`, - label: `right-base`, - } - const states: Record = { - left: { - id: `effect-left`, - base: [{ ...leftMain }, { ...peer }], - origins: new Map(), - }, - right: { - id: `effect-right`, - base: [{ ...rightMain }], - origins: new Map(), - }, - } - const holds = { left: gate(), right: gate() } - let left: Source | undefined - let right: Source | undefined - let effect: ReturnType | undefined - const settlements: Partial>> = {} - const transactions = new Map() - const failures = { - left: new Error(`left rejection`), - right: new Error(`right rejection`), +// The former case body, unchanged. +async function judgeEffectMetadataHistory(history: History): Promise { + enteredEffectHistories++ + effectMetadataHistory++ + const path = pathOf(history) + const id = historyId(history) + const reach = (cut: string) => { + effectMetadataHistoriesByState + .get(`${id}/batch/${cut}`)! + .add(effectMetadataHistory) + effectMetadataHistoriesByState + .get(`${id}/named/${cut}`)! + .add(effectMetadataHistory) + } + const leftMain: SourceRow = { + id: history.kind === `union` ? 1 : `a`, + parentId: `unused`, + side: `left`, + label: `left-base`, + } + const peer: SourceRow = { + id: `anchor`, + parentId: `unused`, + side: `left`, + label: `peer`, + } + const rightMain: SourceRow = { + id: history.kind === `union` ? `1` : `r`, + parentId: `a`, + side: `right`, + label: `right-base`, + } + const states: Record = { + left: { + id: `effect-left`, + base: [{ ...leftMain }, { ...peer }], + origins: new Map(), + }, + right: { + id: `effect-right`, + base: [{ ...rightMain }], + origins: new Map(), + }, + } + const holds = { left: gate(), right: gate() } + let left: Source | undefined + let right: Source | undefined + let effect: ReturnType | undefined + const settlements: Partial>> = {} + const transactions = new Map() + const failures = { + left: new Error(`left rejection`), + right: new Error(`right rejection`), + } + const ledger: Ledger = { batches: [], named: [] } + const errors: Array = [] + const mismatches: Array = [] + const active: KeyIntervals = new Map() + let checkedBatches = 0 + let checkedNamed = 0 + let previous: Array = [] + const modelRows = () => + expectedEffectRows(history.kind, states.left, states.right) + const cut = async (name: string, premise?: () => void) => { + await flushPromises() + const next = modelRows() + let premiseReached = true + try { + premise?.() + } catch (error) { + premiseReached = false + mismatches.push( + new Error(`Effect metadata premise ${name}`, { cause: error }), + ) } - const ledger: Ledger = { batches: [], named: [] } - const errors: Array = [] - const mismatches: Array = [] - const active: KeyIntervals = new Map() - let checkedBatches = 0 - let checkedNamed = 0 - let previous: Array = [] - const modelRows = () => - expectedEffectRows(history.kind, states.left, states.right) - const cut = async (name: string, premise?: () => void) => { - await flushPromises() - const next = modelRows() - let premiseReached = true - try { - premise?.() - } catch (error) { - premiseReached = false - mismatches.push( - new Error(`Effect metadata premise ${name}`, { cause: error }), - ) - } - try { - checkEffectDelta( - { - batches: ledger.batches.slice(checkedBatches), - named: ledger.named.slice(checkedNamed), - }, - expectedEffectDelta(previous, next), - active, - ) - expect(errors).toStrictEqual([]) - const key = `${path}/${name}` - effectPassedCuts.set(key, (effectPassedCuts.get(key) ?? 0) + 1) - } catch (error) { - mismatches.push( - new Error(`Effect metadata cut ${name}`, { cause: error }), - ) - } - checkedBatches = ledger.batches.length - checkedNamed = ledger.named.length - previous = next - if (premiseReached) reach(name) + try { + checkEffectDelta( + { + batches: ledger.batches.slice(checkedBatches), + named: ledger.named.slice(checkedNamed), + }, + expectedEffectDelta(previous, next), + active, + ) + expect(errors).toStrictEqual([]) + const key = `${path}/${name}` + effectPassedCuts.set(key, (effectPassedCuts.get(key) ?? 0) + 1) + } catch (error) { + mismatches.push( + new Error(`Effect metadata cut ${name}`, { cause: error }), + ) } - const write = ( - side: Side, - type: `insert` | `update` | `delete`, - row: SourceRow, - ) => { - const source = side === `left` ? left! : right! - source.utils.begin() - source.utils.write({ type, value: { ...row } }) - return source.utils.commit() + checkedBatches = ledger.batches.length + checkedNamed = ledger.named.length + previous = next + if (premiseReached) reach(name) + } + const write = ( + side: Side, + type: `insert` | `update` | `delete`, + row: SourceRow, + ) => { + const source = side === `left` ? left! : right! + source.utils.begin() + source.utils.write({ type, value: { ...row } }) + return source.utils.commit() + } + const requirePersisting = (side: Side) => { + expect(holds[side].calls, `${side} handler entered once`).toBe(1) + expect(transactions.get(side)?.state, `${side} transaction persists`).toBe( + `persisting`, + ) + } + const drive = async () => { + left = createSource(states.left, holds.left) + right = createSource(states.right, holds.right) + await Promise.all([left.preload(), right.preload()]) + effect = startEffect(history, left, right, ledger, errors) + await cut(`initial`) + const sides = sidesOf(history) + for (const side of sides) { + const source = side === `left` ? left : right + const row = states[side].base[0]! + states[side].overlay = { ...row, label: `${side}-pending` } + const transaction = source.update(row.id, (draft) => { + draft.label = `${side}-pending` + }) + transactions.set(side, transaction) + settlements[side] = transaction.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + await cut(`${side}-pending`, () => requirePersisting(side)) } - const requirePersisting = (side: Side) => { - expect(holds[side].calls, `${side} handler entered once`).toBe(1) - expect( - transactions.get(side)?.state, - `${side} transaction persists`, - ).toBe(`persisting`) + if (sides.length === 2) + await cut(`two-pending`, () => { + requirePersisting(`left`) + requirePersisting(`right`) + }) + for (const side of history.confirm) { + const row = { ...states[side].base[0]!, label: `${side}-server` } + states[side].confirmation = row + // Commit accepts this row; the persisting overlay still owns visibility. + write(side, `update`, row) + await cut(`${side}-accepted-held`, () => requirePersisting(side)) } - const drive = async () => { - left = createSource(states.left, holds.left) - right = createSource(states.right, holds.right) - await Promise.all([left.preload(), right.preload()]) - effect = startEffect(history, left, right, ledger, errors) - await cut(`initial`) - const sides = sidesOf(history) - for (const side of sides) { - const source = side === `left` ? left : right - const row = states[side].base[0]! - states[side].overlay = { ...row, label: `${side}-pending` } - const transaction = source.update(row.id, (draft) => { - draft.label = `${side}-pending` - }) - transactions.set(side, transaction) - settlements[side] = transaction.when(`settled`).then( - () => undefined, - (error: unknown) => error, + const order: Array = + history.kind === `single` + ? [`left`] + : history.rightFirst + ? [`right`, `left`] + : [`left`, `right`] + for (const [index, side] of order.entries()) { + const state = states[side] + const reject = history.reject.includes(side) + state.overlay = undefined + if (state.confirmation) { + const confirmation = state.confirmation + state.base = state.base.map((row) => + row.id === confirmation.id ? { ...confirmation } : row, ) - await cut(`${side}-pending`, () => requirePersisting(side)) - } - if (sides.length === 2) - await cut(`two-pending`, () => { - requirePersisting(`left`) - requirePersisting(`right`) - }) - for (const side of history.confirm) { - const row = { ...states[side].base[0]!, label: `${side}-server` } - states[side].confirmation = row - // Commit accepts this row; the persisting overlay still owns visibility. - write(side, `update`, row) - await cut(`${side}-accepted-held`, () => requirePersisting(side)) + state.origins.set(confirmation.id, reject ? `remote` : `local`) + state.confirmation = undefined } - const order: Array = - history.kind === `single` - ? [`left`] - : history.rightFirst - ? [`right`, `left`] - : [`left`, `right`] - for (const [index, side] of order.entries()) { - const state = states[side] - const reject = history.reject.includes(side) - state.overlay = undefined - if (state.confirmation) { - const confirmation = state.confirmation - state.base = state.base.map((row) => - row.id === confirmation.id ? { ...confirmation } : row, + if (reject) holds[side].reject(failures[side]) + else holds[side].resolve() + expect(await settlements[side]).toBe(reject ? failures[side] : undefined) + await cut(`${side}-${reject ? `rejected` : `fulfilled`}`, () => { + expect(holds[side].calls, `${side} handler entered once`).toBe(1) + expect( + transactions.get(side)?.state, + `${side} transaction settled`, + ).toBe(reject ? `failed` : `completed`) + }) + if (sides.length === 2 && index === 0) { + const stillPending = order[1]! + await cut(`one-pending-${stillPending}`, () => { + requirePersisting(stillPending) + expect(transactions.get(side)?.state).toBe( + reject ? `failed` : `completed`, ) - state.origins.set(confirmation.id, reject ? `remote` : `local`) - state.confirmation = undefined - } - if (reject) holds[side].reject(failures[side]) - else holds[side].resolve() - expect(await settlements[side]).toBe( - reject ? failures[side] : undefined, - ) - await cut(`${side}-${reject ? `rejected` : `fulfilled`}`, () => { - expect(holds[side].calls, `${side} handler entered once`).toBe(1) - expect( - transactions.get(side)?.state, - `${side} transaction settled`, - ).toBe(reject ? `failed` : `completed`) - }) - if (sides.length === 2 && index === 0) { - const stillPending = order[1]! - await cut(`one-pending-${stillPending}`, () => { - requirePersisting(stillPending) - expect(transactions.get(side)?.state).toBe( - reject ? `failed` : `completed`, - ) - }) - } - } - for (const side of sides) { - const state = states[side] - const row = { ...state.base[0]!, label: `${side}-later` } - state.base[0] = row - state.origins.set(row.id, `remote`) - write(side, `update`, row) - await cut(`${side}-remote-followup`) - } - states.left.base = states.left.base.filter((row) => row.id !== peer.id) - write(`left`, `delete`, peer) - await cut(`peer-exit`) - states.left.base.push({ ...peer }) - write(`left`, `insert`, peer) - await cut(`peer-reentry`) - const survivor = { ...states.left.base[0]!, label: `survivor-next-use` } - states.left.base[0] = survivor - write(`left`, `update`, survivor) - await cut(`survivor-next-use`) - await effect.dispose() - await cut(`final-drain`) - } - await checkWithCleanup(async () => { - try { - await drive() - } catch (error) { - mismatches.push( - new Error(`Effect metadata driver ${id}`, { cause: error }), - ) - } - if (mismatches.length) - throw new AggregateError(mismatches, `Effect metadata history failed`, { - cause: mismatches[0], }) - }, [ - async () => { - holds.left.resolve() - holds.right.resolve() - await Promise.all(Object.values(settlements)) - }, - async () => { - await effect?.dispose() - }, - async () => { - await right?.cleanup() - }, - async () => { - await left?.cleanup() - }, - ]) - }, -) - -// The same delta judgment must reject wrong callback histories even when their -// final row is right. Controls seed active key intervals through initial enter -// delivery, so failures reach the update comparison rather than setup. -const effectFaults = [ - `omitted-peer`, - `duplicate-message`, - `duplicate-batch`, - `stale-pending`, - `wrong-source`, - `new-previous-value`, - `extra-undefined`, - `wrong-route`, - `missing-named`, - `delayed-extra`, - `key-collision`, - `key-change`, -] as const -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`, - label: pending ? `pending` : `base`, - observedKey: id, - observedCollectionId: `source`, - observedOrigin: pending ? `local` : `remote`, - observedPending: pending, - }) - const before = [row(1, false), row(`1`, false)] - const after = [row(1, true), row(`1`, true)] - const events = (expected: Array, prefix: string): Ledger => { - const batch = expected.map((event) => ({ - ...structuredClone(event), - key: `${prefix}/${occurrence(event.value)}`, - })) - return { - batches: [batch], - named: batch.map((event) => ({ - ...structuredClone(event), - route: event.type, - })), } } - const active: KeyIntervals = new Map() - checkEffectDelta( - events(expectedEffectDelta([], before), `opaque`), - expectedEffectDelta([], before), - active, - ) - const expected = expectedEffectDelta(before, after) - const allowed = events(expected, `opaque`) - checkEffectDelta(structuredClone(allowed), expected, new Map(active)) - const hostile = structuredClone(allowed) - const first = hostile.batches[0]![0]! - const second = hostile.batches[0]![1]! - if (fault === `omitted-peer`) hostile.batches[0]!.pop() - if (fault === `duplicate-message`) - hostile.batches[0]!.push(structuredClone(first)) - if (fault === `duplicate-batch`) - hostile.batches.push(structuredClone(hostile.batches[0]!)) - if (fault === `stale-pending`) - (first.value as Record).observedPending = false - if (fault === `wrong-source`) - (first.value as Record).observedCollectionId = - `other-source` - if (fault === `new-previous-value`) - first.previousValue = structuredClone(first.value) - if (fault === `extra-undefined`) - (first.value as Record).extra = undefined - if (fault === `wrong-route`) hostile.named[0]!.route = `enter` - if (fault === `missing-named`) hostile.named.pop() - if (fault === `delayed-extra`) - hostile.batches.push([structuredClone(first)]) - // Both channels agree on each bad key, so only interval continuity can - // reject these faults; cross-channel agreement cannot mask its removal. - if (fault === `key-collision`) { - second.key = first.key - hostile.named[1]!.key = second.key + for (const side of sides) { + const state = states[side] + const row = { ...state.base[0]!, label: `${side}-later` } + state.base[0] = row + state.origins.set(row.id, `remote`) + write(side, `update`, row) + await cut(`${side}-remote-followup`) } - if (fault === `key-change`) { - first.key = `different-opaque-key` - hostile.named[0]!.key = first.key + states.left.base = states.left.base.filter((row) => row.id !== peer.id) + write(`left`, `delete`, peer) + await cut(`peer-exit`) + states.left.base.push({ ...peer }) + write(`left`, `insert`, peer) + await cut(`peer-reentry`) + const survivor = { ...states.left.base[0]!, label: `survivor-next-use` } + states.left.base[0] = survivor + write(`left`, `update`, survivor) + await cut(`survivor-next-use`) + await effect.dispose() + await cut(`final-drain`) + } + await checkWithCleanup(async () => { + try { + await drive() + } catch (error) { + mismatches.push( + new Error(`Effect metadata driver ${id}`, { cause: error }), + ) } - expect(() => checkEffectDelta(hostile, expected, new Map(active))).toThrow() - }, -) - -// Allowed controls preserve implementation freedom: row order and opaque key -// representation do not define metadata, and an exited interval owns no key. -function calibrationRows(pending: boolean): Array { - const base: Array = [ - { id: 1, side: `left`, parentId: `unused`, label: `base` }, - { id: `1`, side: `left`, parentId: `unused`, label: `peer` }, - ] - return expectedEffectRows( - `single`, - { - id: `source`, - base, - origins: new Map(), - ...(pending ? { overlay: { ...base[0]!, label: `pending` } } : {}), + if (mismatches.length) + throw new AggregateError(mismatches, `Effect metadata history failed`, { + cause: mismatches[0], + }) + }, [ + async () => { + holds.left.resolve() + holds.right.resolve() + await Promise.all(Object.values(settlements)) }, - { id: `unused`, base: [], origins: new Map() }, - ) -} -function calibrationLedger( - delta: ReadonlyArray, - keyFor: (value: ProjectedRow) => Key, -): Ledger { - const batch = delta.map((event) => ({ - ...structuredClone(event), - key: keyFor(event.value), - })) - return { - batches: batch.length ? [batch.reverse()] : [], - named: batch.map((event) => ({ - ...structuredClone(event), - route: event.type, - })), - } + async () => { + await effect?.dispose() + }, + async () => { + await right?.cleanup() + }, + async () => { + await left?.cleanup() + }, + ]) } -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() - const oldKey = (row: ProjectedRow) => `arbitrary/${occurrence(row)}` - const initial = expectedEffectDelta([], before) - checkEffectDelta(calibrationLedger(initial, oldKey), initial, active) - const update = expectedEffectDelta(before, after) - checkEffectDelta(calibrationLedger(update, oldKey), update, active) - const remaining = [after[0]!] - const exit = expectedEffectDelta(after, remaining) - checkEffectDelta(calibrationLedger(exit, oldKey), exit, active) - const reentry = expectedEffectDelta(remaining, after) - checkEffectDelta( - calibrationLedger(reentry, (row) => `fresh/${occurrence(row)}`), - reentry, - active, - ) - const next = [before[0]!, after[1]!] - const survivor = expectedEffectDelta(after, next) - 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() - const keyFor = (row: ProjectedRow) => occurrence(row) - for (const delta of [ - expectedEffectDelta([], before), - expectedEffectDelta(before, after), - expectedEffectDelta(after, after), - ]) { - const ledger = calibrationLedger(delta, keyFor) - ledger.batches.unshift([]) - ledger.batches.push([]) - checkEffectDelta(ledger, delta, active) - } -}) -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) - const active: KeyIntervals = new Map() - const initial = expectedEffectDelta([], before) - checkEffectDelta(calibrationLedger(initial, keyFor), initial, active) - const expected = expectedEffectDelta(before, after) - const row = structuredClone(expected[0]!.value) - const previousValue = structuredClone(expected[0]!.previousValue!) - const event: DeltaEvent = { - type: `update`, - key: keyFor(row), - value: row, - previousValue, - } - const ledger: Ledger = { - batches: [[captureEvent(event)]], - named: [{ route: `update`, ...captureEvent(event) }], - } - row.observedPending = false - previousValue.label = `mutated after delivery` - 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) - const active: KeyIntervals = new Map() - const initial = expectedEffectDelta([], before) - const ledger = calibrationLedger(initial, keyFor) - const invalid = { - type: `update`, - key: `never-entered`, - value: structuredClone(before[0]!), - previousValue: structuredClone(before[0]!), - } - ledger.batches[0]!.unshift(invalid) - ledger.named.unshift({ ...structuredClone(invalid), route: `update` }) - expect(() => checkEffectDelta(ledger, initial, active)).toThrow() - const update = expectedEffectDelta(before, after) - checkEffectDelta(calibrationLedger(update, keyFor), update, active) + +const explicitMetadataLaw = `Effects deliver explicitly selected source metadata, matching the visible source rows, at every batch and named callback cut.` + +oracleProperty({ + id: `virtual-row-fields-effect.explicit-metadata`, + law: { + statement: explicitMetadataLaw, + authority: `The Collection virtual-field boundary and boundary-only metadata decision (2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, and the Effect publication rules, stated at the start of this owner.`, + limits: `The enumerated single, join, and union metadata histories over controlled sources and optimistic transactions.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/effect.ts#createEffect`, + `src/transactions.ts#createTransaction`, + ], + histories: effectMetadataHistories, + label: (history) => + `delivers explicitly selected source metadata through Effects: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(explicitMetadataLaw, plan, observe, () => + judgeEffectMetadataHistory(plan.history), + ), + check: forwardAssertionJudgment, }) +// The hostile-ledger and allowed-ledger checker calibrations of +// checkEffectDelta are ordinary tests in virtual-row-fields-effect.test.ts. + /** * Membership is independent of the selected value's comparison domain. A fresh * callable or opaque instance may compare unequal when the graph retracts a @@ -1144,190 +778,214 @@ function captureBoundaryEvent(event: DeltaEvent): RecordedEvent { : {}), } } -declareOracle({ model: [boundaryModelRows], grammar: [boundaryHistories] }) -test.each(boundaryHistories)( - `classifies callback-visible membership across coalesced source histories: %j`, - async (history) => { - oracleRoute(`case`) - enteredBoundary = true - boundaryHistory++ - const main: SourceRow = { - id: `a`, - parentId: `unused`, - side: `left`, - label: `v0`, - } - const anchor: SourceRow = { - id: `anchor`, - parentId: `unused`, - side: `left`, - label: `anchor`, - } - const joined: SourceRow = { - id: `r`, - parentId: `a`, - side: `right`, - label: `right`, - } - const state: SourceState = { - id: `boundary-left`, - base: history.present ? [main, anchor] : [anchor], - origins: new Map(), - } - const rightState: SourceState = { - id: `boundary-right`, - base: [joined], - origins: new Map(), - } - const left = createSource(state, gate()) - const right = createSource(rightState, gate()) - const ledger: Ledger = { batches: [], named: [] } - const active: KeyIntervals = new Map() - const failures: Array = [] - const callbackErrors: Array = [] - let effect: ReturnType | undefined - let before: Array = [] - let checkedBatch = 0 - let checkedNamed = 0 - const cut = async (name?: string) => { - await flushPromises() - const after = boundaryModelRows( - history.kind, - history.representation, - state, - rightState, +// The former case body, unchanged. +async function judgeBoundaryMembership( + history: (typeof boundaryHistories)[number], +): Promise { + enteredBoundary = true + boundaryHistory++ + const main: SourceRow = { + id: `a`, + parentId: `unused`, + side: `left`, + label: `v0`, + } + const anchor: SourceRow = { + id: `anchor`, + parentId: `unused`, + side: `left`, + label: `anchor`, + } + const joined: SourceRow = { + id: `r`, + parentId: `a`, + side: `right`, + label: `right`, + } + const state: SourceState = { + id: `boundary-left`, + base: history.present ? [main, anchor] : [anchor], + origins: new Map(), + } + const rightState: SourceState = { + id: `boundary-right`, + base: [joined], + origins: new Map(), + } + const left = createSource(state, gate()) + const right = createSource(rightState, gate()) + const ledger: Ledger = { batches: [], named: [] } + const active: KeyIntervals = new Map() + const failures: Array = [] + const callbackErrors: Array = [] + let effect: ReturnType | undefined + let before: Array = [] + let checkedBatch = 0 + let checkedNamed = 0 + const cut = async (name?: string) => { + await flushPromises() + const after = boundaryModelRows( + history.kind, + history.representation, + state, + rightState, + ) + try { + checkEffectDelta( + { + batches: ledger.batches.slice(checkedBatch), + named: ledger.named.slice(checkedNamed), + }, + expectedEffectDelta(before, after), + active, + ) + expect(callbackErrors).toStrictEqual([]) + } catch (error) { + failures.push( + new Error(`membership cut ${name ?? `individual action`}`, { + cause: error, + }), ) - try { - checkEffectDelta( - { - batches: ledger.batches.slice(checkedBatch), - named: ledger.named.slice(checkedNamed), - }, - expectedEffectDelta(before, after), - active, - ) - expect(callbackErrors).toStrictEqual([]) - } catch (error) { - failures.push( - new Error(`membership cut ${name ?? `individual action`}`, { - cause: error, - }), - ) - } - before = after - checkedBatch = ledger.batches.length - checkedNamed = ledger.named.length - if (name) - boundaryHistoriesByState - .get(`${boundaryId(history)}/${name}`)! - .add(boundaryHistory) - } - // The model applies the authored source operation directly. Production - // receives the same operation through public sync writes and scheduling. - const apply = (type: `insert` | `delete` | `update`, label: string) => { - const row = { ...main, label } - state.base = - type === `delete` - ? state.base.filter((value) => value.id !== main.id) - : type === `insert` - ? [...state.base, row] - : state.base.map((value) => (value.id === main.id ? row : value)) - left.utils.begin() - left.utils.write({ type, value: row }) - left.utils.commit() } - await checkWithCleanup(async () => { - await Promise.all([left.preload(), right.preload()]) - effect = createEffect({ - query: () => { - const base = new Query().from({ a: left }) - const project = (a: SourceValue, b?: SourceValue) => { - const label = a.label - const payload = - history.representation === `function` - ? () => label - : history.representation === `private` - ? new BoundaryValue(label) - : label - return { - ...selectSource(a), - ...(history.kind === `join` - ? { - rightLabel: b?.label, - rightKey: b?.$key, - rightCollectionId: b?.$collectionId, - rightOrigin: b?.$origin, - rightPending: b?.$hasPendingWrites, - } - : {}), - payload, - } + before = after + checkedBatch = ledger.batches.length + checkedNamed = ledger.named.length + if (name) + boundaryHistoriesByState + .get(`${boundaryId(history)}/${name}`)! + .add(boundaryHistory) + } + // The model applies the authored source operation directly. Production + // receives the same operation through public sync writes and scheduling. + const apply = (type: `insert` | `delete` | `update`, label: string) => { + const row = { ...main, label } + state.base = + type === `delete` + ? state.base.filter((value) => value.id !== main.id) + : type === `insert` + ? [...state.base, row] + : state.base.map((value) => (value.id === main.id ? row : value)) + left.utils.begin() + left.utils.write({ type, value: row }) + left.utils.commit() + } + await checkWithCleanup(async () => { + await Promise.all([left.preload(), right.preload()]) + effect = createEffect({ + query: () => { + const base = new Query().from({ a: left }) + const project = (a: SourceValue, b?: SourceValue) => { + const label = a.label + const payload = + history.representation === `function` + ? () => label + : history.representation === `private` + ? new BoundaryValue(label) + : label + return { + ...selectSource(a), + ...(history.kind === `join` + ? { + rightLabel: b?.label, + rightKey: b?.$key, + rightCollectionId: b?.$collectionId, + rightOrigin: b?.$origin, + rightPending: b?.$hasPendingWrites, + } + : {}), + payload, } - return history.kind === `join` - ? base - .leftJoin({ b: right }, ({ a, b }) => eq(a.id, b.parentId)) - .fn.select(({ a, b }) => project(a, b)) - : base.fn.select(({ a }) => project(a)) - }, - onBatch: (events) => { - ledger.batches.push(events.map(captureBoundaryEvent)) - }, - onEnter: (event) => { - ledger.named.push({ route: `enter`, ...captureBoundaryEvent(event) }) - }, - onUpdate: (event) => { - ledger.named.push({ route: `update`, ...captureBoundaryEvent(event) }) - }, - onExit: (event) => { - ledger.named.push({ route: `exit`, ...captureBoundaryEvent(event) }) - }, - onError: (error) => callbackErrors.push(error), - onSourceError: (error) => callbackErrors.push(error), - }) - await cut(`initial`) - const commands: Array = - history.present - ? [ - [`delete`, `v0`], - [`insert`, `v1`], - [`delete`, `v1`], - ] - : [ - [`insert`, `v0`], - [`delete`, `v0`], - [`insert`, `v1`], - ] - if (history.grouped) { - createTransaction({ mutationFn: async () => {} }).mutate(() => { - for (const [type, label] of commands) apply(type, label) - }) - } else { - for (const [type, label] of commands) { - apply(type, label) - await cut() } - } - await cut(`sequence`) - if (!history.present) apply(`delete`, `v1`) - await cut(`absent`) - apply(`insert`, `v2`) - await cut(`reentry`) - apply(`update`, `v3`) - await cut(`next-use`) - if (failures.length) - throw new AggregateError(failures, `Effect membership history failed`, { - cause: failures[0], - }) - }, [ - async () => { - await effect?.dispose() + return history.kind === `join` + ? base + .leftJoin({ b: right }, ({ a, b }) => eq(a.id, b.parentId)) + .fn.select(({ a, b }) => project(a, b)) + : base.fn.select(({ a }) => project(a)) + }, + onBatch: (events) => { + ledger.batches.push(events.map(captureBoundaryEvent)) }, - async () => { - await right.cleanup() + onEnter: (event) => { + ledger.named.push({ route: `enter`, ...captureBoundaryEvent(event) }) }, - async () => { - await left.cleanup() + onUpdate: (event) => { + ledger.named.push({ route: `update`, ...captureBoundaryEvent(event) }) }, - ]) + onExit: (event) => { + ledger.named.push({ route: `exit`, ...captureBoundaryEvent(event) }) + }, + onError: (error) => callbackErrors.push(error), + onSourceError: (error) => callbackErrors.push(error), + }) + await cut(`initial`) + const commands: Array = + history.present + ? [ + [`delete`, `v0`], + [`insert`, `v1`], + [`delete`, `v1`], + ] + : [ + [`insert`, `v0`], + [`delete`, `v0`], + [`insert`, `v1`], + ] + if (history.grouped) { + createTransaction({ mutationFn: async () => {} }).mutate(() => { + for (const [type, label] of commands) apply(type, label) + }) + } else { + for (const [type, label] of commands) { + apply(type, label) + await cut() + } + } + await cut(`sequence`) + if (!history.present) apply(`delete`, `v1`) + await cut(`absent`) + apply(`insert`, `v2`) + await cut(`reentry`) + apply(`update`, `v3`) + await cut(`next-use`) + if (failures.length) + throw new AggregateError(failures, `Effect membership history failed`, { + cause: failures[0], + }) + }, [ + async () => { + await effect?.dispose() + }, + async () => { + await right.cleanup() + }, + async () => { + await left.cleanup() + }, + ]) +} + +const boundaryMembershipLaw = `Effect callback membership follows the source snapshots, independent of the selected value's comparison domain: an absent occurrence cannot update.` + +oracleProperty({ + id: `virtual-row-fields-effect.boundary-membership`, + law: { + statement: boundaryMembershipLaw, + authority: `The Collection virtual-field boundary and boundary-only metadata decision (2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, and the Effect publication rules, stated at the start of this owner.`, + limits: `The enumerated scalar, callable, and private-value histories; no exact instance identity for a re-evaluated projection.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/effect.ts#createEffect`, + `src/transactions.ts#createTransaction`, + ], + histories: boundaryHistories, + label: (history) => + `classifies callback-visible membership across coalesced source histories: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(boundaryMembershipLaw, plan, observe, () => + judgeBoundaryMembership(plan.history), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/query/virtual-row-fields-effect.test.ts b/packages/db/tests/query/virtual-row-fields-effect.test.ts new file mode 100644 index 0000000000..50ebac4cd5 --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-effect.test.ts @@ -0,0 +1,231 @@ +// Checker calibrations for the Effect virtual-field oracle in +// virtual-row-fields-effect-oracle.test.ts. They run no production: each +// feeds a hand-built callback ledger to the oracle's delta check, which must +// reject a hostile ledger and accept the implementation freedom that the +// allowed controls preserve. +import { expect, test } from 'vitest' +import { + captureEvent, + checkEffectDelta, + expectedEffectDelta, + expectedEffectRows, + occurrence, +} from './virtual-row-fields-effect-model-oracle.js' +import type { DeltaEvent } from '../../src/query/effect.js' +import type { + Key, + KeyIntervals, + Ledger, + ModelEvent, + ProjectedRow, + SourceRow, +} from './virtual-row-fields-effect-model-oracle.js' + +// The same delta judgment must reject wrong callback histories even when their +// final row is right. Controls seed active key intervals through initial enter +// delivery, so failures reach the update comparison rather than setup. +const effectFaults = [ + `omitted-peer`, + `duplicate-message`, + `duplicate-batch`, + `stale-pending`, + `wrong-source`, + `new-previous-value`, + `extra-undefined`, + `wrong-route`, + `missing-named`, + `delayed-extra`, + `key-collision`, + `key-change`, +] as const + +// Allowed controls preserve implementation freedom: row order and opaque key +// representation do not define metadata, and an exited interval owns no key. +function calibrationRows(pending: boolean): Array { + const base: Array = [ + { id: 1, side: `left`, parentId: `unused`, label: `base` }, + { id: `1`, side: `left`, parentId: `unused`, label: `peer` }, + ] + return expectedEffectRows( + `single`, + { + id: `source`, + base, + origins: new Map(), + ...(pending ? { overlay: { ...base[0]!, label: `pending` } } : {}), + }, + { id: `unused`, base: [], origins: new Map() }, + ) +} +function calibrationLedger( + delta: ReadonlyArray, + keyFor: (value: ProjectedRow) => Key, +): Ledger { + const batch = delta.map((event) => ({ + ...structuredClone(event), + key: keyFor(event.value), + })) + return { + batches: batch.length ? [batch.reverse()] : [], + named: batch.map((event) => ({ + ...structuredClone(event), + route: event.type, + })), + } +} + +test.each(effectFaults)( + `rejects a hostile Effect metadata ledger: %s`, + (fault) => { + const row = (id: Key, pending: boolean): ProjectedRow => ({ + id, + side: `left`, + label: pending ? `pending` : `base`, + observedKey: id, + observedCollectionId: `source`, + observedOrigin: pending ? `local` : `remote`, + observedPending: pending, + }) + const before = [row(1, false), row(`1`, false)] + const after = [row(1, true), row(`1`, true)] + const events = (expected: Array, prefix: string): Ledger => { + const batch = expected.map((event) => ({ + ...structuredClone(event), + key: `${prefix}/${occurrence(event.value)}`, + })) + return { + batches: [batch], + named: batch.map((event) => ({ + ...structuredClone(event), + route: event.type, + })), + } + } + const active: KeyIntervals = new Map() + checkEffectDelta( + events(expectedEffectDelta([], before), `opaque`), + expectedEffectDelta([], before), + active, + ) + const expected = expectedEffectDelta(before, after) + const allowed = events(expected, `opaque`) + checkEffectDelta(structuredClone(allowed), expected, new Map(active)) + const hostile = structuredClone(allowed) + const first = hostile.batches[0]![0]! + const second = hostile.batches[0]![1]! + if (fault === `omitted-peer`) hostile.batches[0]!.pop() + if (fault === `duplicate-message`) + hostile.batches[0]!.push(structuredClone(first)) + if (fault === `duplicate-batch`) + hostile.batches.push(structuredClone(hostile.batches[0]!)) + if (fault === `stale-pending`) + (first.value as Record).observedPending = false + if (fault === `wrong-source`) + (first.value as Record).observedCollectionId = + `other-source` + if (fault === `new-previous-value`) + first.previousValue = structuredClone(first.value) + if (fault === `extra-undefined`) + (first.value as Record).extra = undefined + if (fault === `wrong-route`) hostile.named[0]!.route = `enter` + if (fault === `missing-named`) hostile.named.pop() + if (fault === `delayed-extra`) + hostile.batches.push([structuredClone(first)]) + // Both channels agree on each bad key, so only interval continuity can + // reject these faults; cross-channel agreement cannot mask its removal. + if (fault === `key-collision`) { + second.key = first.key + hostile.named[1]!.key = second.key + } + if (fault === `key-change`) { + first.key = `different-opaque-key` + hostile.named[0]!.key = first.key + } + expect(() => checkEffectDelta(hostile, expected, new Map(active))).toThrow() + }, +) + +test(`permits reordered delivery and fresh opaque keys after an exit`, () => { + const before = calibrationRows(false) + const after = calibrationRows(true) + const active: KeyIntervals = new Map() + const oldKey = (row: ProjectedRow) => `arbitrary/${occurrence(row)}` + const initial = expectedEffectDelta([], before) + checkEffectDelta(calibrationLedger(initial, oldKey), initial, active) + const update = expectedEffectDelta(before, after) + checkEffectDelta(calibrationLedger(update, oldKey), update, active) + const remaining = [after[0]!] + const exit = expectedEffectDelta(after, remaining) + checkEffectDelta(calibrationLedger(exit, oldKey), exit, active) + const reentry = expectedEffectDelta(remaining, after) + checkEffectDelta( + calibrationLedger(reentry, (row) => `fresh/${occurrence(row)}`), + reentry, + active, + ) + const next = [before[0]!, after[1]!] + const survivor = expectedEffectDelta(after, next) + checkEffectDelta(calibrationLedger(survivor, oldKey), survivor, active) +}) + +test(`permits empty callback partitions while preserving exact delta events`, () => { + const before = calibrationRows(false) + const after = calibrationRows(true) + const active: KeyIntervals = new Map() + const keyFor = (row: ProjectedRow) => occurrence(row) + for (const delta of [ + expectedEffectDelta([], before), + expectedEffectDelta(before, after), + expectedEffectDelta(after, after), + ]) { + const ledger = calibrationLedger(delta, keyFor) + ledger.batches.unshift([]) + ledger.batches.push([]) + checkEffectDelta(ledger, delta, active) + } +}) + +test(`records scalar callback values before later object mutation`, () => { + const before = calibrationRows(false) + const after = calibrationRows(true) + const keyFor = (row: ProjectedRow) => occurrence(row) + const active: KeyIntervals = new Map() + const initial = expectedEffectDelta([], before) + checkEffectDelta(calibrationLedger(initial, keyFor), initial, active) + const expected = expectedEffectDelta(before, after) + const row = structuredClone(expected[0]!.value) + const previousValue = structuredClone(expected[0]!.previousValue!) + const event: DeltaEvent = { + type: `update`, + key: keyFor(row), + value: row, + previousValue, + } + const ledger: Ledger = { + batches: [[captureEvent(event)]], + named: [{ route: `update`, ...captureEvent(event) }], + } + row.observedPending = false + previousValue.label = `mutated after delivery` + checkEffectDelta(ledger, expected, active) +}) + +test(`preserves later interval judgments after an invalid unknown update`, () => { + const before = calibrationRows(false) + const after = calibrationRows(true) + const keyFor = (row: ProjectedRow) => occurrence(row) + const active: KeyIntervals = new Map() + const initial = expectedEffectDelta([], before) + const ledger = calibrationLedger(initial, keyFor) + const invalid = { + type: `update`, + key: `never-entered`, + value: structuredClone(before[0]!), + previousValue: structuredClone(before[0]!), + } + ledger.batches[0]!.unshift(invalid) + ledger.named.unshift({ ...structuredClone(invalid), route: `update` }) + expect(() => checkEffectDelta(ledger, initial, active)).toThrow() + const update = expectedEffectDelta(before, after) + checkEffectDelta(calibrationLedger(update, keyFor), update, active) +}) From 1cff9ee7dfa79d4c8ee5139e20e6dfb8a495c7d2 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:13:29 -0700 Subject: [PATCH 070/116] test(sqlite): register the persisted demand coverage cells and fixed histories through oracleProperty P2 and P1b: the Part 1 cells and the Part 2 to 4 fixed histories become enumerated properties whose drivers are the unchanged test bodies. Rows that hold a driver are named, and resolve maps the name to its driver. P6: the model, check, and grammar calibrations run no production, so they move unchanged to persisted-demand-coverage-calibration.test.ts, with the Part 1 model in the companion persisted-demand-coverage-cells-model-oracle.ts. The registry also names the campaign's storage-fault and release properties. Co-authored-by: Isaac --- ...sisted-demand-coverage-calibration.test.ts | 219 ++++ ...sted-demand-coverage-cells-model-oracle.ts | 302 ++++++ .../persisted-demand-coverage-oracle.test.ts | 939 ++++++------------ .../db/tests/converted-properties/sqlite.ts | 27 + 4 files changed, 865 insertions(+), 622 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-calibration.test.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-cells-model-oracle.ts diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-calibration.test.ts new file mode 100644 index 0000000000..6e43a1b1f7 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-calibration.test.ts @@ -0,0 +1,219 @@ +/** + * Calibrations of Part 1 of the persisted demand coverage oracle: the + * refinement check rejects wrong observations, the model differs from each + * plausible wrong design at a named cell (ORC-006, ORC-013), and the cells + * enumerate every declared combination once. They judge the model, check, and + * grammar in persisted-demand-coverage-cells-model-oracle.ts and run no + * production, so they are ordinary tests. The oracle that drives production + * is persisted-demand-coverage-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { + ALL_CELLS, + COVERAGE_HISTORIES, + LAW, + SESSION_1_ROWS, + UPSTREAM_SCHEDULES, + WAITS, + cellName, + expectedTrace, + firstMismatch, + sameObservation, +} from './persisted-demand-coverage-cells-model-oracle.js' +import type { + Cell, + Design, +} from './persisted-demand-coverage-cells-model-oracle.js' + +describe(`persisted demand coverage oracle`, () => { + // ------------------------------------------------------------------------- + // Calibration: the comparison rejects wrong answers, and each block + // distinguishes its law from plausible wrong designs at a named case + // (ORC-006, ORC-013). These checks judge the models and comparisons; + // production mutants run separately against an implementation. + // ------------------------------------------------------------------------- + + it(`rejects wrong observations at a checkpoint`, () => { + const ready = { + queryStatus: `ready`, + preload: `resolved`, + rows: [`a1:v1`, `a2:v1`], + } as const + expect(sameObservation(ready, ready)).toBe(true) + expect(sameObservation(ready, { ...ready, rows: [`a1:v1`] })).toBe(false) + expect(sameObservation(ready, { ...ready, rows: [`a2:v1`, `a1:v1`] })).toBe( + false, + ) + expect(sameObservation(ready, { ...ready, rows: [`a1:v1`, `a1:v1`] })).toBe( + false, + ) + expect(sameObservation(ready, { ...ready, preload: `pending` })).toBe(false) + expect( + sameObservation(ready, { queryStatus: `loading`, preload: `pending` }), + ).toBe(false) + expect( + sameObservation( + { queryStatus: `loading`, preload: `pending` }, + { queryStatus: `ready`, preload: `resolved`, rows: [] }, + ), + ).toBe(false) + }) + + const wrongDesigns: ReadonlyArray<{ design: Design; witness: Cell }> = [ + { + design: { ...LAW, name: `any local read counts`, isCovered: () => true }, + witness: { history: `not-requested`, waitMs: 0, upstream: `pending` }, + }, + { + design: { + ...LAW, + name: `nonempty rows count`, + isCovered: (history) => SESSION_1_ROWS[history].length > 0, + }, + witness: { history: `settled-empty`, waitMs: 0, upstream: `pending` }, + }, + { + // A Collection-wide flag set by any settled load, or rows that match D. + design: { + ...LAW, + name: `coverage per Collection`, + isCovered: (history) => + history !== `not-requested` && history !== `failed`, + }, + witness: { + history: `overlapping-demand`, + waitMs: 0, + upstream: `pending`, + }, + }, + { + design: { + ...LAW, + name: `coverage keyed by filter only`, + isCovered: (history) => + LAW.isCovered(history) || + history === `other-window` || + history === `other-order`, + }, + witness: { history: `other-window`, waitMs: 0, upstream: `pending` }, + }, + { + // Honors limit but drops `orderBy` from the key. + design: { + ...LAW, + name: `coverage ignores order`, + isCovered: (history) => + LAW.isCovered(history) || history === `other-order`, + }, + witness: { history: `other-order`, waitMs: 0, upstream: `pending` }, + }, + { + design: { + ...LAW, + name: `coverage written before the rows commit`, + isCovered: (history) => + LAW.isCovered(history) || history === `durable-write-failed`, + }, + witness: { + history: `durable-write-failed`, + waitMs: 0, + upstream: `pending`, + }, + }, + { + design: { + ...LAW, + name: `coverage survives truncate`, + isCovered: (history) => + LAW.isCovered(history) || history === `truncated`, + }, + witness: { history: `truncated`, waitMs: 0, upstream: `pending` }, + }, + { + design: { + ...LAW, + name: `any settled load records coverage`, + isCovered: (history) => + LAW.isCovered(history) || + history === `truncated-in-flight` || + history === `recovered-in-flight`, + }, + witness: { + history: `truncated-in-flight`, + waitMs: 0, + upstream: `pending`, + }, + }, + { + design: { + ...LAW, + name: `coverage ignores rows deleted after it settled`, + isCovered: (history) => + LAW.isCovered(history) || history === `settled-then-row-deleted`, + }, + witness: { + history: `settled-then-row-deleted`, + waitMs: 0, + upstream: `pending`, + }, + }, + { + design: { ...LAW, name: `wait ignored`, honorsWait: false }, + witness: { history: `settled`, waitMs: 1000, upstream: `pending` }, + }, + { + design: { ...LAW, name: `half deadline`, deadlineFraction: 0.5 }, + witness: { history: `settled`, waitMs: 1000, upstream: `pending` }, + }, + { + design: { + ...LAW, + name: `failure errors a covered demand`, + failureSettlesCovered: false, + }, + witness: { history: `settled`, waitMs: 1000, upstream: `fails-early` }, + }, + { + design: { + ...LAW, + name: `upstream aborted after SQLite`, + upstreamContinues: false, + }, + witness: { history: `settled`, waitMs: 0, upstream: `answers-late` }, + }, + { + design: { + ...LAW, + name: `late failure undoes a settlement`, + settlementSurvivesFailure: false, + }, + witness: { history: `settled`, waitMs: 0, upstream: `fails-late` }, + }, + { + design: { + ...LAW, + name: `settles before restore`, + settlesBeforeRestore: true, + }, + witness: { history: `settled`, waitMs: 0, upstream: `pending` }, + }, + ] + + it.each(wrongDesigns.map((entry) => [entry.design.name, entry] as const))( + `distinguishes the law from the wrong design: %s`, + (_name, { design, witness }) => { + expect(ALL_CELLS).toContainEqual(witness) + expect( + firstMismatch(expectedTrace(witness), expectedTrace(witness, design)), + ).toBeDefined() + }, + ) + + it(`enumerates every declared cell once`, () => { + const names = ALL_CELLS.map(cellName) + expect(new Set(names).size).toBe(names.length) + expect(names).toHaveLength( + COVERAGE_HISTORIES.length * WAITS.length * UPSTREAM_SCHEDULES.length, + ) + }) +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-cells-model-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-cells-model-oracle.ts new file mode 100644 index 0000000000..80fbb45fcf --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-cells-model-oracle.ts @@ -0,0 +1,302 @@ +/** + * The Part 1 model of the persisted demand coverage oracle in + * persisted-demand-coverage-oracle.test.ts: the session-1 coverage histories, + * waits, and upstream schedules; the rows each history leaves; the observed + * checkpoints; the independent model of one demand over two sessions, with + * `Design` variants for its calibration; the refinement check; and the cells. + * Owns nothing: the oracle test drives production with them, and + * persisted-demand-coverage-calibration.test.ts calibrates the model and check. + */ +import type { Row } from './helpers/demand-coverage-driver.js' + +export const COVERAGE_HISTORIES = [ + `not-requested`, + `failed`, + `overlapping-demand`, + `other-window`, + `other-order`, + `durable-write-failed`, + `settled-empty`, + `settled`, + `truncated`, + `truncated-in-flight`, + `recovered-in-flight`, + `recovering-in-flight`, + `settled-then-row-deleted`, +] as const +export type CoverageHistory = (typeof COVERAGE_HISTORIES)[number] + +export const UPSTREAM_SCHEDULES = [ + `pending`, + `fails-at-once`, + `fails-early`, + `answers-early`, + `answers-late`, + `answers-empty-late`, + `fails-late`, +] as const +export type UpstreamSchedule = (typeof UPSTREAM_SCHEDULES)[number] + +export const WAITS = [0, 1000] as const +export type Wait = (typeof WAITS)[number] + +export type Cell = { + history: CoverageHistory + waitMs: Wait + upstream: UpstreamSchedule +} + +export const A1: Row = { id: `a1`, group: `a`, rank: 1, value: `v1` } +export const A2: Row = { id: `a2`, group: `a`, rank: 2, value: `v1` } + +export const SESSION_1_ROWS: Record> = { + 'not-requested': [], + failed: [], + 'overlapping-demand': [A1], + 'other-window': [A1], + 'other-order': [A1, A2], + // The upstream answer was A1 and A2, but no row became durable. + 'durable-write-failed': [], + 'settled-empty': [], + settled: [A1, A2], + truncated: [], + 'truncated-in-flight': [], + 'recovered-in-flight': [], + 'recovering-in-flight': [], + 'settled-then-row-deleted': [A2], +} + +// Session 2's upstream answer updates a1 and adds a3. It omits a2; an +// on-demand load reports rows, not deletions, so a2 stays. +export const SESSION_2_ANSWER: ReadonlyArray = [ + { id: `a1`, group: `a`, rank: 1, value: `v2` }, + { id: `a3`, group: `a`, rank: 3, value: `v2` }, +] + +// --------------------------------------------------------------------------- +// Observations and checkpoints +// +// The query reads only D, so its initial-query readiness is D's load +// settlement. The model therefore predicts three public facts together: +// `query.status`, the state of the session-2 `preload()` promise, and, when +// ready, the exact rows. Rows are not compared while loading; this law does +// not govern partial results before settlement. +// +// Checkpoints, in order: +// +// - `restored`: the upstream load has started and microtasks have drained, +// before fake time advances. The persisted subset restore has had every +// chance to publish. A slower publication fails the ready cells here; it +// cannot pass them. +// - `afterEarlyEvent`: after an early upstream answer or failure. +// - `beforeDeadline`: fake time is 1 ms before the wait passes. +// - `afterDeadline`: fake time has reached the wait. +// - `afterLateEvent`: after a late upstream answer or failure. +// --------------------------------------------------------------------------- + +export type Observation = { + queryStatus: `loading` | `ready` | `error` + preload: `pending` | `resolved` | `rejected` + rows?: ReadonlyArray +} + +export type Trace = { + restored: Observation + afterEarlyEvent: Observation + beforeDeadline: Observation + afterDeadline: Observation + afterLateEvent: Observation +} + +export const CHECKPOINTS = [ + `restored`, + `afterEarlyEvent`, + `beforeDeadline`, + `afterDeadline`, + `afterLateEvent`, +] as const satisfies ReadonlyArray + +export function rowLabels(rows: Iterable): Array { + return Array.from(rows, (row) => `${row.id}:${row.value}`).sort() +} + +// --------------------------------------------------------------------------- +// Independent model +// +// The model keeps four facts: whether D is covered, the rows visible to D, +// whether D's load has settled, and whether it failed. Coverage comes from +// the session-1 history label alone; the model never inspects SQLite or the +// wrapper. Each fact changes a later observation: +// +// - coverage decides whether the wait or an upstream failure settles D; +// - visible rows are the exact rows at settlement and after a later answer; +// - settlement separates `ready` from `loading`; +// - failure separates `error` from `loading` for an unsettled demand. +// +// Vocabulary (ORC-009): `covered` is persisted demand coverage; the initial +// visible rows are the persisted subset restore; `settled` is D's load +// settlement, observed through initial-query readiness and `preload()`. +// +// `Design` lets the calibration below express plausible wrong designs as +// variants of the same rule. The law is `LAW`. +// --------------------------------------------------------------------------- + +export type Design = { + name: string + isCovered: (history: CoverageHistory) => boolean + // `false` settles a covered demand at restore, ignoring the wait. + honorsWait: boolean + // Fraction of the wait after which a covered demand settles. + deadlineFraction: number + // A covered demand settles when its upstream load fails. + failureSettlesCovered: boolean + // Upstream rows still publish after a settlement from SQLite. + upstreamContinues: boolean + // A later upstream failure leaves a settled demand ready. + settlementSurvivesFailure: boolean + // Settles before the persisted subset restore publishes its rows. + settlesBeforeRestore: boolean +} + +export const LAW: Design = { + name: `persisted demand coverage`, + isCovered: (history) => history === `settled` || history === `settled-empty`, + honorsWait: true, + deadlineFraction: 1, + failureSettlesCovered: true, + upstreamContinues: true, + settlementSurvivesFailure: true, + settlesBeforeRestore: false, +} + +export function expectedTrace(cell: Cell, design: Design = LAW): Trace { + const covered = design.isCovered(cell.history) + const visible = new Map( + SESSION_1_ROWS[cell.history].map((row) => [row.id, row]), + ) + const state = { + settled: false, + settledFromSqlite: false, + failed: false, + // Set only by the wrong design that settles before the restore publishes. + rowsAtEarlySettle: undefined as Array | undefined, + } + + const observe = (): Observation => { + if (state.failed) return { queryStatus: `error`, preload: `rejected` } + if (!state.settled) return { queryStatus: `loading`, preload: `pending` } + return { + queryStatus: `ready`, + preload: `resolved`, + rows: state.rowsAtEarlySettle ?? rowLabels(visible.values()), + } + } + const settleFromSqlite = () => { + if (state.settled || state.failed) return + state.settled = true + state.settledFromSqlite = true + } + const answer = (rows: ReadonlyArray) => { + if (state.failed) return + if (state.settledFromSqlite && !design.upstreamContinues) return + for (const row of rows) visible.set(row.id, row) + state.rowsAtEarlySettle = undefined + state.settled = true + } + const fail = () => { + if (state.failed) return + if (!state.settled) { + if (covered && design.failureSettlesCovered) settleFromSqlite() + else state.failed = true + } else if (!design.settlementSurvivesFailure) { + state.failed = true + } + } + + // restored (t = 0) + if (covered && (cell.waitMs === 0 || !design.honorsWait)) settleFromSqlite() + if (cell.upstream === `fails-at-once`) fail() + if (design.settlesBeforeRestore && covered) state.rowsAtEarlySettle = [] + const restored = observe() + + // early event (t = 0) + if (cell.upstream === `answers-early`) answer(SESSION_2_ANSWER) + if (cell.upstream === `fails-early`) fail() + const afterEarlyEvent = observe() + + // t = wait - 1 + if ( + covered && + cell.waitMs > 0 && + design.deadlineFraction * cell.waitMs <= cell.waitMs - 1 + ) { + settleFromSqlite() + } + const beforeDeadline = cell.waitMs === 0 ? afterEarlyEvent : observe() + + // t = wait + if (covered) settleFromSqlite() + const afterDeadline = observe() + + // late event + if (cell.upstream === `answers-late`) answer(SESSION_2_ANSWER) + if (cell.upstream === `answers-empty-late`) answer([]) + if (cell.upstream === `fails-late`) fail() + const afterLateEvent = observe() + + return { + restored, + afterEarlyEvent, + beforeDeadline, + afterDeadline, + afterLateEvent, + } +} + +// --------------------------------------------------------------------------- +// Refinement check +// +// Production refines the model when every checkpoint agrees on status, +// preload state, and, when the model is ready, the exact sorted rows. The +// recorder keeps duplicates and order, so the comparison can reject both. +// --------------------------------------------------------------------------- + +export function sameObservation( + expected: Observation, + actual: Observation, +): boolean { + if (expected.queryStatus !== actual.queryStatus) return false + if (expected.preload !== actual.preload) return false + if (expected.rows === undefined) return true + return ( + actual.rows !== undefined && + actual.rows.length === expected.rows.length && + expected.rows.every((row, index) => actual.rows![index] === row) + ) +} + +export function firstMismatch( + expected: Trace, + actual: Partial, +): + | { checkpoint: keyof Trace; expected: Observation; actual?: Observation } + | undefined { + for (const checkpoint of CHECKPOINTS) { + const observed = actual[checkpoint] + if (!observed || !sameObservation(expected[checkpoint], observed)) { + return { checkpoint, expected: expected[checkpoint], actual: observed } + } + } + return undefined +} + +export const ALL_CELLS: ReadonlyArray = COVERAGE_HISTORIES.flatMap( + (history) => + WAITS.flatMap((waitMs) => + UPSTREAM_SCHEDULES.map((upstream) => ({ history, waitMs, upstream })), + ), +) + +export function cellName(cell: Cell): string { + return `${cell.history}, wait ${cell.waitMs} ms, upstream ${cell.upstream}` +} 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 d4d3c6e45c..784627ee29 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 @@ -1,5 +1,5 @@ import { DatabaseSync } from 'node:sqlite' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import { BTreeIndex, IR, @@ -12,8 +12,12 @@ import { SQLiteCorePersistenceAdapter, persistedCollectionOptions, } from '../src/index.js' -import { declareOracle } from '../../db/tests/oracle-declaration' -import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { createSerializedNodeSqliteDriver as createDriver } from './helpers/node-sqlite-driver.js' import { drain, @@ -27,6 +31,23 @@ import { withMacrotaskReads, } from './helpers/demand-coverage-driver.js' import { coverageViolations } from './persisted-demand-coverage-model-oracle.js' +import { + A1, + A2, + ALL_CELLS, + SESSION_1_ROWS, + SESSION_2_ANSWER, + cellName, + expectedTrace, + firstMismatch, + rowLabels, +} from './persisted-demand-coverage-cells-model-oracle.js' +import type { + Cell, + CoverageHistory, + Observation, + Trace, +} from './persisted-demand-coverage-cells-model-oracle.js' import type { Row, Upstream } from './helpers/demand-coverage-driver.js' import type { CoverageReach, @@ -198,296 +219,12 @@ import type { SQLiteDriver } from '../src/index.js' // of D hydrates and records coverage. A truncate clears hydrated demand, so // that load must hydrate, and the wrapper rejects a source transaction that // crosses a hydration cycle (`InvalidPersistedCollectionConfigError`). -// --------------------------------------------------------------------------- - -const COVERAGE_HISTORIES = [ - `not-requested`, - `failed`, - `overlapping-demand`, - `other-window`, - `other-order`, - `durable-write-failed`, - `settled-empty`, - `settled`, - `truncated`, - `truncated-in-flight`, - `recovered-in-flight`, - `recovering-in-flight`, - `settled-then-row-deleted`, -] as const -type CoverageHistory = (typeof COVERAGE_HISTORIES)[number] - -const UPSTREAM_SCHEDULES = [ - `pending`, - `fails-at-once`, - `fails-early`, - `answers-early`, - `answers-late`, - `answers-empty-late`, - `fails-late`, -] as const -type UpstreamSchedule = (typeof UPSTREAM_SCHEDULES)[number] - -const WAITS = [0, 1000] as const -type Wait = (typeof WAITS)[number] - -type Cell = { - history: CoverageHistory - waitMs: Wait - upstream: UpstreamSchedule -} - -const A1: Row = { id: `a1`, group: `a`, rank: 1, value: `v1` } -const A2: Row = { id: `a2`, group: `a`, rank: 2, value: `v1` } - -const SESSION_1_ROWS: Record> = { - 'not-requested': [], - failed: [], - 'overlapping-demand': [A1], - 'other-window': [A1], - 'other-order': [A1, A2], - // The upstream answer was A1 and A2, but no row became durable. - 'durable-write-failed': [], - 'settled-empty': [], - settled: [A1, A2], - truncated: [], - 'truncated-in-flight': [], - 'recovered-in-flight': [], - 'recovering-in-flight': [], - 'settled-then-row-deleted': [A2], -} - -// Session 2's upstream answer updates a1 and adds a3. It omits a2; an -// on-demand load reports rows, not deletions, so a2 stays. -const SESSION_2_ANSWER: ReadonlyArray = [ - { id: `a1`, group: `a`, rank: 1, value: `v2` }, - { id: `a3`, group: `a`, rank: 3, value: `v2` }, -] - -// --------------------------------------------------------------------------- -// Observations and checkpoints // -// The query reads only D, so its initial-query readiness is D's load -// settlement. The model therefore predicts three public facts together: -// `query.status`, the state of the session-2 `preload()` promise, and, when -// ready, the exact rows. Rows are not compared while loading; this law does -// not govern partial results before settlement. -// -// Checkpoints, in order: -// -// - `restored`: the upstream load has started and microtasks have drained, -// before fake time advances. The persisted subset restore has had every -// chance to publish. A slower publication fails the ready cells here; it -// cannot pass them. -// - `afterEarlyEvent`: after an early upstream answer or failure. -// - `beforeDeadline`: fake time is 1 ms before the wait passes. -// - `afterDeadline`: fake time has reached the wait. -// - `afterLateEvent`: after a late upstream answer or failure. +// The histories, rows, observations and checkpoints, independent model, +// refinement check, and cells of Part 1 live in +// persisted-demand-coverage-cells-model-oracle.ts. // --------------------------------------------------------------------------- -type Observation = { - queryStatus: `loading` | `ready` | `error` - preload: `pending` | `resolved` | `rejected` - rows?: ReadonlyArray -} - -type Trace = { - restored: Observation - afterEarlyEvent: Observation - beforeDeadline: Observation - afterDeadline: Observation - afterLateEvent: Observation -} - -const CHECKPOINTS = [ - `restored`, - `afterEarlyEvent`, - `beforeDeadline`, - `afterDeadline`, - `afterLateEvent`, -] as const satisfies ReadonlyArray - -function rowLabels(rows: Iterable): Array { - return Array.from(rows, (row) => `${row.id}:${row.value}`).sort() -} - -// --------------------------------------------------------------------------- -// Independent model -// -// The model keeps four facts: whether D is covered, the rows visible to D, -// whether D's load has settled, and whether it failed. Coverage comes from -// the session-1 history label alone; the model never inspects SQLite or the -// wrapper. Each fact changes a later observation: -// -// - coverage decides whether the wait or an upstream failure settles D; -// - visible rows are the exact rows at settlement and after a later answer; -// - settlement separates `ready` from `loading`; -// - failure separates `error` from `loading` for an unsettled demand. -// -// Vocabulary (ORC-009): `covered` is persisted demand coverage; the initial -// visible rows are the persisted subset restore; `settled` is D's load -// settlement, observed through initial-query readiness and `preload()`. -// -// `Design` lets the calibration below express plausible wrong designs as -// variants of the same rule. The law is `LAW`. -// --------------------------------------------------------------------------- - -type Design = { - name: string - isCovered: (history: CoverageHistory) => boolean - // `false` settles a covered demand at restore, ignoring the wait. - honorsWait: boolean - // Fraction of the wait after which a covered demand settles. - deadlineFraction: number - // A covered demand settles when its upstream load fails. - failureSettlesCovered: boolean - // Upstream rows still publish after a settlement from SQLite. - upstreamContinues: boolean - // A later upstream failure leaves a settled demand ready. - settlementSurvivesFailure: boolean - // Settles before the persisted subset restore publishes its rows. - settlesBeforeRestore: boolean -} - -const LAW: Design = { - name: `persisted demand coverage`, - isCovered: (history) => history === `settled` || history === `settled-empty`, - honorsWait: true, - deadlineFraction: 1, - failureSettlesCovered: true, - upstreamContinues: true, - settlementSurvivesFailure: true, - settlesBeforeRestore: false, -} - -function expectedTrace(cell: Cell, design: Design = LAW): Trace { - const covered = design.isCovered(cell.history) - const visible = new Map( - SESSION_1_ROWS[cell.history].map((row) => [row.id, row]), - ) - const state = { - settled: false, - settledFromSqlite: false, - failed: false, - // Set only by the wrong design that settles before the restore publishes. - rowsAtEarlySettle: undefined as Array | undefined, - } - - const observe = (): Observation => { - if (state.failed) return { queryStatus: `error`, preload: `rejected` } - if (!state.settled) return { queryStatus: `loading`, preload: `pending` } - return { - queryStatus: `ready`, - preload: `resolved`, - rows: state.rowsAtEarlySettle ?? rowLabels(visible.values()), - } - } - const settleFromSqlite = () => { - if (state.settled || state.failed) return - state.settled = true - state.settledFromSqlite = true - } - const answer = (rows: ReadonlyArray) => { - if (state.failed) return - if (state.settledFromSqlite && !design.upstreamContinues) return - for (const row of rows) visible.set(row.id, row) - state.rowsAtEarlySettle = undefined - state.settled = true - } - const fail = () => { - if (state.failed) return - if (!state.settled) { - if (covered && design.failureSettlesCovered) settleFromSqlite() - else state.failed = true - } else if (!design.settlementSurvivesFailure) { - state.failed = true - } - } - - // restored (t = 0) - if (covered && (cell.waitMs === 0 || !design.honorsWait)) settleFromSqlite() - if (cell.upstream === `fails-at-once`) fail() - if (design.settlesBeforeRestore && covered) state.rowsAtEarlySettle = [] - const restored = observe() - - // early event (t = 0) - if (cell.upstream === `answers-early`) answer(SESSION_2_ANSWER) - if (cell.upstream === `fails-early`) fail() - const afterEarlyEvent = observe() - - // t = wait - 1 - if ( - covered && - cell.waitMs > 0 && - design.deadlineFraction * cell.waitMs <= cell.waitMs - 1 - ) { - settleFromSqlite() - } - const beforeDeadline = cell.waitMs === 0 ? afterEarlyEvent : observe() - - // t = wait - if (covered) settleFromSqlite() - const afterDeadline = observe() - - // late event - if (cell.upstream === `answers-late`) answer(SESSION_2_ANSWER) - if (cell.upstream === `answers-empty-late`) answer([]) - if (cell.upstream === `fails-late`) fail() - const afterLateEvent = observe() - - return { - restored, - afterEarlyEvent, - beforeDeadline, - afterDeadline, - afterLateEvent, - } -} - -// --------------------------------------------------------------------------- -// Refinement check -// -// Production refines the model when every checkpoint agrees on status, -// preload state, and, when the model is ready, the exact sorted rows. The -// recorder keeps duplicates and order, so the comparison can reject both. -// --------------------------------------------------------------------------- - -function sameObservation(expected: Observation, actual: Observation): boolean { - if (expected.queryStatus !== actual.queryStatus) return false - if (expected.preload !== actual.preload) return false - if (expected.rows === undefined) return true - return ( - actual.rows !== undefined && - actual.rows.length === expected.rows.length && - expected.rows.every((row, index) => actual.rows![index] === row) - ) -} - -function firstMismatch( - expected: Trace, - actual: Partial, -): - | { checkpoint: keyof Trace; expected: Observation; actual?: Observation } - | undefined { - for (const checkpoint of CHECKPOINTS) { - const observed = actual[checkpoint] - if (!observed || !sameObservation(expected[checkpoint], observed)) { - return { checkpoint, expected: expected[checkpoint], actual: observed } - } - } - return undefined -} - -const ALL_CELLS: ReadonlyArray = COVERAGE_HISTORIES.flatMap((history) => - WAITS.flatMap((waitMs) => - UPSTREAM_SCHEDULES.map((upstream) => ({ history, waitMs, upstream })), - ), -) - -function cellName(cell: Cell): string { - return `${cell.history}, wait ${cell.waitMs} ms, upstream ${cell.upstream}` -} - // --------------------------------------------------------------------------- // Production driver // --------------------------------------------------------------------------- @@ -2379,365 +2116,323 @@ afterEach(() => { vi.restoreAllMocks() }) -declareOracle({ - owns: [`src/persisted.ts`], - check: [coverageViolations, firstMismatch, sameObservation], - model: [expectedTrace], - grammar: [ALL_CELLS], -}) - -describe(`persisted demand coverage oracle`, () => { - // ------------------------------------------------------------------------- - // Calibration: the comparison rejects wrong answers, and each block - // distinguishes its law from plausible wrong designs at a named case - // (ORC-006, ORC-013). These checks judge the models and comparisons; - // production mutants run separately against an implementation. - // ------------------------------------------------------------------------- +// Each block below runs its production driver and applies its existing +// judgment: Part 1's trace model through firstMismatch, and Parts 2 to 4's +// shared model through judge (coverageViolations). The adapter transports that +// judgment to oracleProperty. +const demandCoverageLaw = { + statement: `An opted-in persisted wrapper over an on-demand source settles a demand's load from SQLite only when its persisted subset restore has published, the exact demand has persisted demand coverage in the current cache generation with at least the rows it held then, and the wait has passed or the upstream load failed; otherwise the load waits for its upstream load.`, + authority: `Issue #2102 item 1, docs/contributing/oracle-reviews/2026-10-20-on-demand-network-first-law.md, the persisted-wrapper paragraph in packages/db/src/query/live/ARCHITECTURE.md, and the maintainer decision of 2026-10-26.`, +} - it(`rejects wrong observations at a checkpoint`, () => { - oracleRoute(`calibration`) - const ready = { - queryStatus: `ready`, - preload: `resolved`, - rows: [`a1:v1`, `a2:v1`], - } as const - expect(sameObservation(ready, ready)).toBe(true) - expect(sameObservation(ready, { ...ready, rows: [`a1:v1`] })).toBe(false) - expect(sameObservation(ready, { ...ready, rows: [`a2:v1`, `a1:v1`] })).toBe( - false, - ) - expect(sameObservation(ready, { ...ready, rows: [`a1:v1`, `a1:v1`] })).toBe( - false, - ) - expect(sameObservation(ready, { ...ready, preload: `pending` })).toBe(false) - expect( - sameObservation(ready, { queryStatus: `loading`, preload: `pending` }), - ).toBe(false) - expect( - sameObservation( - { queryStatus: `loading`, preload: `pending` }, - { queryStatus: `ready`, preload: `resolved`, rows: [] }, - ), - ).toBe(false) - }) +// Part 1: production's trace of one cell agrees with the model at every +// checkpoint. +async function refineCell(cell: Cell): Promise { + quietConsole() + const actual = await runCell(cell) + expect(firstMismatch(expectedTrace(cell), actual)).toBeUndefined() +} - const wrongDesigns: ReadonlyArray<{ design: Design; witness: Cell }> = [ - { - design: { ...LAW, name: `any local read counts`, isCovered: () => true }, - witness: { history: `not-requested`, waitMs: 0, upstream: `pending` }, - }, - { - design: { - ...LAW, - name: `nonempty rows count`, - isCovered: (history) => SESSION_1_ROWS[history].length > 0, - }, - witness: { history: `settled-empty`, waitMs: 0, upstream: `pending` }, - }, - { - // A Collection-wide flag set by any settled load, or rows that match D. - design: { - ...LAW, - name: `coverage per Collection`, - isCovered: (history) => - history !== `not-requested` && history !== `failed`, - }, - witness: { - history: `overlapping-demand`, - waitMs: 0, - upstream: `pending`, - }, - }, - { - design: { - ...LAW, - name: `coverage keyed by filter only`, - isCovered: (history) => - LAW.isCovered(history) || - history === `other-window` || - history === `other-order`, - }, - witness: { history: `other-window`, waitMs: 0, upstream: `pending` }, - }, - { - // Honors limit but drops `orderBy` from the key. - design: { - ...LAW, - name: `coverage ignores order`, - isCovered: (history) => - LAW.isCovered(history) || history === `other-order`, - }, - witness: { history: `other-order`, waitMs: 0, upstream: `pending` }, - }, - { - design: { - ...LAW, - name: `coverage written before the rows commit`, - isCovered: (history) => - LAW.isCovered(history) || history === `durable-write-failed`, - }, - witness: { - history: `durable-write-failed`, - waitMs: 0, - upstream: `pending`, - }, - }, - { - design: { - ...LAW, - name: `coverage survives truncate`, - isCovered: (history) => - LAW.isCovered(history) || history === `truncated`, - }, - witness: { history: `truncated`, waitMs: 0, upstream: `pending` }, - }, - { - design: { - ...LAW, - name: `any settled load records coverage`, - isCovered: (history) => - LAW.isCovered(history) || - history === `truncated-in-flight` || - history === `recovered-in-flight`, - }, - witness: { - history: `truncated-in-flight`, - waitMs: 0, - upstream: `pending`, - }, - }, - { - design: { - ...LAW, - name: `coverage ignores rows deleted after it settled`, - isCovered: (history) => - LAW.isCovered(history) || history === `settled-then-row-deleted`, - }, - witness: { - history: `settled-then-row-deleted`, - waitMs: 0, - upstream: `pending`, - }, - }, - { - design: { ...LAW, name: `wait ignored`, honorsWait: false }, - witness: { history: `settled`, waitMs: 1000, upstream: `pending` }, - }, - { - design: { ...LAW, name: `half deadline`, deadlineFraction: 0.5 }, - witness: { history: `settled`, waitMs: 1000, upstream: `pending` }, - }, - { - design: { - ...LAW, - name: `failure errors a covered demand`, - failureSettlesCovered: false, - }, - witness: { history: `settled`, waitMs: 1000, upstream: `fails-early` }, - }, - { - design: { - ...LAW, - name: `upstream aborted after SQLite`, - upstreamContinues: false, - }, - witness: { history: `settled`, waitMs: 0, upstream: `answers-late` }, - }, - { - design: { - ...LAW, - name: `late failure undoes a settlement`, - settlementSurvivesFailure: false, +// Part 2: each load of a covered demand gets its own wait, and coverage +// survives release. +async function judgeLifecycle(): Promise { + quietConsole() + const records = await runLifecycle() + judge(records) + // Checker calibration: a deadline measured from the sync run would show + // query 2 ready at 1000 ms. The judgment must reject that observation. + const at1000 = records[LIFECYCLE_CHECKPOINTS.indexOf(1000)]! + const early = { + ...at1000, + queries: { + ...at1000.queries, + query2: { + ...at1000.queries.query2!, + observed: { status: `ready`, rows: [`a1`, `a2`] }, }, - witness: { history: `settled`, waitMs: 0, upstream: `fails-late` }, }, - { - design: { - ...LAW, - name: `settles before restore`, - settlesBeforeRestore: true, + } satisfies CoverageRecord + expect(coverageViolations(early)).toHaveLength(1) +} + +// Part 3: joined queries, by history and shape. +const joinedCases = [ + ...([`flat`, `nested`, `two-readers`] as const).flatMap( + (shape: JoinedShape) => + ([`covered by the same joined query`, `never requested`] as const).map( + (history: ChildHistory) => [history, shape] as const, + ), + ), + // The inner-only history differs from full coverage only when the query + // reaches C through two live queries. + [`covered by the inner query only`, `two-readers`] as const, +] as const + +async function judgeJoined([ + history, + shape, +]: (typeof joinedCases)[number]): Promise { + quietConsole() + const records = await runJoined(history, shape) + judge(records) + // Checker calibration: an opt-in that permits fallback alone would + // release a never-requested demand. The judgment must reject it. + if (history === `never requested`) { + const last = records[records.length - 1]! + const released = { + ...last, + queries: { + joined: { + ...last.queries.joined!, + observed: { status: `ready`, rows: [] }, + }, }, - witness: { history: `settled`, waitMs: 0, upstream: `pending` }, - }, - ] - - 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)), - ).toBeDefined() - }, - ) + } satisfies CoverageRecord + expect(coverageViolations(released)).toHaveLength(1) + } +} - 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( - COVERAGE_HISTORIES.length * WAITS.length * UPSTREAM_SCHEDULES.length, - ) - }) +// Part 2b: resets in the same session. Each row names its driver. +const sessionResets = [ + [`a rotation`, () => runSessionReset(`rotation`)], + [`a rotation of an empty demand`, () => runSessionReset(`rotation`, [])], + [`a refetch`, () => runSessionReset(`refetch`)], + [`a refetch of an empty demand`, () => runSessionReset(`refetch`, [])], + [`a load after scoped recovery reloaded the demand`, runRecoveryReload], + [`a truncate inside the source's load call`, runTruncateInsideLoad], + [`a schema reset`, runSchemaReset], + [ + `a cache claim that expires before the coverage record`, + runClaimExpiredBeforeRecord, + ], + [ + `a cache claim that expires before the coverage check`, + () => runClaimExpiredBeforeCheck(), + ], + [ + `a cache claim that expires before an empty demand's coverage check`, + () => runClaimExpiredBeforeCheck([]), + ], +] as const +const sessionResetRuns = new Map Promise>>( + sessionResets, +) + +// Part 2c: coverage bookkeeping. Each row names its driver. +const bookkeepingCases = [ + [`a coverage read that fails after the load`, runCoverageReadFailsAfterLoad], + [`a coverage check read that fails`, runCoverageReadFailsDuringCheck], + [`a synchronous answer`, () => runSynchronousAnswer([A1, A2], `nothing`)], + [ + `a synchronous answer, then a deletion`, + () => runSynchronousAnswer([A1, A2], `delete`), + ], + [ + `an empty synchronous answer, then a truncate during its coverage read`, + () => runSynchronousAnswer([], `truncate`), + ], + [ + `an unordered demand that loses a row and gains another`, + runUnorderedRowReplaced, + ], + [ + `a truncate during an empty load's coverage read`, + runTruncateDuringCoverageRead, + ], + [ + `a coverage read that outlasts the wait`, + () => runHeldPastTheWait(`coverage read`), + ], + [ + `a cache-claim renewal that outlasts the wait`, + () => runHeldPastTheWait(`claim renewal`), + ], +] as const +const bookkeepingRuns = new Map Promise>>( + bookkeepingCases, +) + +// Part 4: the window moves offline to page 2 by whether session 1 loaded it +// and whether the rank is indexed. +const nextPageCases = [ + [true, true], + [true, false], + [false, true], + [false, false], +] as const +describe(`persisted demand coverage oracle`, () => { // ------------------------------------------------------------------------- // Refinement against production. // ------------------------------------------------------------------------- - 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() + oracleProperty({ + id: `persisted-demand-coverage.cell`, + law: { + ...demandCoverageLaw, + limits: `Part 1's bounded cells: every session-1 coverage history, wait, and upstream schedule, judged by the trace model at five checkpoints.`, }, - ) + owns: [`src/persisted.ts`], + histories: ALL_CELLS, + label: (cell) => `refines the model: ${cellName(cell)}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`demand coverage cell`, plan, observe, () => + refineCell(plan.cell), + ), + check: forwardAssertionJudgment, + }) // ------------------------------------------------------------------------- // Parts 2 to 4: fixed histories judged by the shared model. // ------------------------------------------------------------------------- - 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) - // Checker calibration: a deadline measured from the sync run would show - // query 2 ready at 1000 ms. The judgment must reject that observation. - const at1000 = records[LIFECYCLE_CHECKPOINTS.indexOf(1000)]! - const early = { - ...at1000, - queries: { - ...at1000.queries, - query2: { - ...at1000.queries.query2!, - observed: { status: `ready`, rows: [`a1`, `a2`] }, - }, + oracleProperty({ + id: `persisted-demand-coverage.lifecycle`, + law: { + ...demandCoverageLaw, + limits: `Part 2's fixed load lifecycle in one session, with its checker calibration of an early deadline.`, + }, + owns: [`src/persisted.ts`], + histories: [ + { + name: `gives each load of a covered demand its own wait and keeps coverage across release`, }, - } satisfies CoverageRecord - expect(coverageViolations(early)).toHaveLength(1) + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`demand coverage lifecycle`, plan, observe, () => + judgeLifecycle(), + ), + check: forwardAssertionJudgment, }) - it.each([ - ...([`flat`, `nested`, `two-readers`] as const).flatMap( - (shape: JoinedShape) => - ([`covered by the same joined query`, `never requested`] as const).map( - (history: ChildHistory) => [history, shape] as const, - ), - ), - // The inner-only history differs from full coverage only when the query - // reaches C through two live queries. - [`covered by the inner query only`, `two-readers`] as const, - ])( - `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) - // Checker calibration: an opt-in that permits fallback alone would - // release a never-requested demand. The judgment must reject it. - if (history === `never requested`) { - const last = records[records.length - 1]! - const released = { - ...last, - queries: { - joined: { - ...last.queries.joined!, - observed: { status: `ready`, rows: [] }, - }, - }, - } satisfies CoverageRecord - expect(coverageViolations(released)).toHaveLength(1) - } + oracleProperty({ + id: `persisted-demand-coverage.joined`, + law: { + ...demandCoverageLaw, + limits: `Part 3's joined histories and shapes, with the checker calibration of a never-requested demand released by fallback.`, }, - ) + owns: [`src/persisted.ts`], + histories: joinedCases.map(([history, shape]) => ({ history, shape })), + label: ({ history, shape }) => + `makes a joined query ready only when its on-demand demands settle: ${history}, ${shape}`, + resolve: ({ history, shape }) => ({ + joinedCase: [history, shape] as const, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`demand coverage joined`, plan, observe, () => + judgeJoined(plan.joinedCase), + ), + check: forwardAssertionJudgment, + }) - it.each([ - [`a rotation`, () => runSessionReset(`rotation`)], - [`a rotation of an empty demand`, () => runSessionReset(`rotation`, [])], - [`a refetch`, () => runSessionReset(`refetch`)], - [`a refetch of an empty demand`, () => runSessionReset(`refetch`, [])], - [`a load after scoped recovery reloaded the demand`, runRecoveryReload], - [`a truncate inside the source's load call`, runTruncateInsideLoad], - [`a schema reset`, runSchemaReset], - [ - `a cache claim that expires before the coverage record`, - runClaimExpiredBeforeRecord, - ], - [ - `a cache claim that expires before the coverage check`, - () => runClaimExpiredBeforeCheck(), - ], - [ - `a cache claim that expires before an empty demand's coverage check`, - () => runClaimExpiredBeforeCheck([]), - ], - ] as const)(`judges a reset in the same session: %s`, async (_name, run) => { - oracleRoute(`case`) - quietConsole() - judge(await run()) + oracleProperty({ + id: `persisted-demand-coverage.session-reset`, + law: { + ...demandCoverageLaw, + limits: `Part 2b's fixed resets in the same session: rotation, refetch, scoped recovery, a truncate inside the load call, a schema reset, and cache-claim expiry.`, + }, + owns: [`src/persisted.ts`], + histories: sessionResets.map(([name]) => name), + label: (name) => `judges a reset in the same session: ${name}`, + resolve: (name) => ({ run: sessionResetRuns.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `demand coverage reset`, + plan, + observe, + async () => { + quietConsole() + judge(await plan.run()) + }, + ), + check: forwardAssertionJudgment, }) - it.each([ - [ - `a coverage read that fails after the load`, - runCoverageReadFailsAfterLoad, - ], - [`a coverage check read that fails`, runCoverageReadFailsDuringCheck], - [`a synchronous answer`, () => runSynchronousAnswer([A1, A2], `nothing`)], - [ - `a synchronous answer, then a deletion`, - () => runSynchronousAnswer([A1, A2], `delete`), - ], - [ - `an empty synchronous answer, then a truncate during its coverage read`, - () => runSynchronousAnswer([], `truncate`), - ], - [ - `an unordered demand that loses a row and gains another`, - runUnorderedRowReplaced, - ], - [ - `a truncate during an empty load's coverage read`, - runTruncateDuringCoverageRead, - ], - [ - `a coverage read that outlasts the wait`, - () => runHeldPastTheWait(`coverage read`), - ], - [ - `a cache-claim renewal that outlasts the wait`, - () => runHeldPastTheWait(`claim renewal`), - ], - ] as const)(`judges coverage bookkeeping: %s`, async (_name, run) => { - oracleRoute(`case`) - quietConsole() - judge(await run()) + oracleProperty({ + id: `persisted-demand-coverage.bookkeeping`, + law: { + ...demandCoverageLaw, + limits: `Part 2c's fixed coverage bookkeeping histories: failed coverage reads, synchronous answers, row replacement, a truncate during a coverage read, and reads that outlast the wait.`, + }, + owns: [`src/persisted.ts`], + histories: bookkeepingCases.map(([name]) => name), + label: (name) => `judges coverage bookkeeping: ${name}`, + resolve: (name) => ({ run: bookkeepingRuns.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `demand coverage bookkeeping`, + plan, + observe, + async () => { + quietConsole() + judge(await plan.run()) + }, + ), + check: forwardAssertionJudgment, }) - 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)) + oracleProperty({ + id: `persisted-demand-coverage.window`, + law: { + ...demandCoverageLaw, + limits: `Part 4's fixed ordered-window histories.`, }, - ) + owns: [`src/persisted.ts`], + histories: Object.keys(WINDOW_HISTORIES) as Array, + label: (history) => + `settles an ordered window from SQLite only while its demands are covered: ${history}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `demand coverage window`, + plan, + observe, + async () => { + quietConsole() + judge(await runWindow(plan.history)) + }, + ), + check: forwardAssertionJudgment, + }) - it.each([ - [true, true], - [true, false], - [false, true], - [false, false], - ] 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)) + oracleProperty({ + id: `persisted-demand-coverage.next-page`, + law: { + ...demandCoverageLaw, + limits: `Part 4's move of a window offline to page 2, by whether session 1 loaded page 2 and whether the rank is indexed.`, }, - ) + owns: [`src/persisted.ts`], + histories: nextPageCases.map(([session1LoadsPageTwo, indexed]) => ({ + session1LoadsPageTwo, + indexed, + })), + label: ({ session1LoadsPageTwo, indexed }) => + `moves a window offline to a page only when that page is covered (session 1 loaded page 2: ${session1LoadsPageTwo}, indexed: ${indexed})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `demand coverage next page`, + plan, + observe, + async () => { + quietConsole() + judge( + await runNextPage( + plan.history.session1LoadsPageTwo, + plan.history.indexed, + ), + ) + }, + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index cefd3dc93d..960b2f7a08 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -12,6 +12,18 @@ export const properties: ReadonlyArray = [ `sqlite-reload.class.electron-app`, `sqlite-persistence.typed-value-reopen`, `sqlite-persistence.native-temporal-query`, + `persisted-demand-coverage.storage-fault`, + `persisted-demand-coverage.release`, + `persisted-demand-coverage.cell`, + `persisted-demand-coverage.lifecycle`, + `persisted-demand-coverage.joined`, + `persisted-demand-coverage.session-reset`, + `persisted-demand-coverage.bookkeeping`, + `persisted-demand-coverage.window`, + `persisted-demand-coverage.next-page`, + `browser.shared-driver-fairness.fixed`, + `browser.shared-driver-fairness.persist-first`, + `browser.shared-driver-fairness.barrier`, ] export const owners: ReadonlyArray = [ @@ -35,4 +47,19 @@ export const owners: ReadonlyArray = [ `sqlite-persistence`, `typed-value-reopen native-temporal-query`, ], + [ + `db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts`, + `persisted-demand-coverage`, + `storage-fault release`, + ], + [ + `db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`, + `persisted-demand-coverage`, + `cell lifecycle joined session-reset bookkeeping window next-page`, + ], + [ + `browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts`, + `browser.shared-driver-fairness`, + `fixed persist-first barrier`, + ], ] From bf07508f733a9316bc61ddd5d448ab5a7e73d4d4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:13:29 -0700 Subject: [PATCH 071/116] test(sqlite): register the shared-driver fairness fixed histories and mutants through oracleProperty P1b: the neutral and storm fixed histories, the persist-first storm, and the barrier history become enumerated properties. P3: the persist-first FIFO scheduling mutant and the two barrier mutants become driver calibrations that accept exactly the rejections that the old tests accepted. P6: the grammar controls and the synthetic checker calibration move unchanged to shared-driver-fairness-grammar.test.ts, with the Node driver, check, and grammar in the companion shared-driver-fairness-node-oracle.ts. M2: the browser registration wrapper forwards owns to oracleProperty. Co-authored-by: Isaac --- .../browser-property-registration-oracle.ts | 3 + .../shared-driver-fairness-grammar.test.ts | 128 +++ .../shared-driver-fairness-node-oracle.ts | 268 ++++++ .../shared-driver-fairness-oracle.test.ts | 785 +++++++----------- 4 files changed, 694 insertions(+), 490 deletions(-) create mode 100644 packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-grammar.test.ts create mode 100644 packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-node-oracle.ts diff --git a/packages/browser-db-sqlite-persistence/tests/browser-property-registration-oracle.ts b/packages/browser-db-sqlite-persistence/tests/browser-property-registration-oracle.ts index 5e2e13c606..1b62e76a7f 100644 --- a/packages/browser-db-sqlite-persistence/tests/browser-property-registration-oracle.ts +++ b/packages/browser-db-sqlite-persistence/tests/browser-property-registration-oracle.ts @@ -10,6 +10,8 @@ import type { GeneratedHistories } from '../../db/tests/oracle-config' type BrowserCampaign = { id: string + // The caller's owns list, forwarded unchanged to oracleProperty. + owns?: ReadonlyArray runs: number fixedSeed: number examples?: ReadonlyArray @@ -54,6 +56,7 @@ export function registerBrowserProperty( for (const histories of sources) oracleProperty({ id: campaign.id, + owns: campaign.owns, law: { statement, authority: `The calling owner's existing RFC #1659 and persistence contract citations.`, diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-grammar.test.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-grammar.test.ts new file mode 100644 index 0000000000..980dd87415 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-grammar.test.ts @@ -0,0 +1,128 @@ +/** + * Calibrations of the shared-driver fairness grammar and checker. The grammar + * reconstructs the known persist-first witness, covers the range marginals, + * and rejects a nearby invalid storm before any production work; removing + * tail-order variation loses every hydrate-before-later-persist history; and + * the checker rejects a synthetic persist-first observation. They run no + * production, so they are ordinary tests. The campaign that drives production + * is shared-driver-fairness-oracle.test.ts. + */ +import fc from 'fast-check' +import { describe, expect, it } from 'vitest' +import { oracleRuns } from '../../db/tests/oracle-config' +import { + createPersistFirstFaultObservation, + findSharedDriverFairnessViolation, +} from './shared-driver-fairness-oracle' +import { + GENERATED_RUNS, + buildGeneratedHistory, + createScenario, + expectFairObservation, + generatedHistoryArbitrary, + reachesHydrateBeforeLaterPersist, + tailOrderAblatedArbitrary, + withNodeScenario, +} from './shared-driver-fairness-node-oracle' +import { FIXED_SEED } from './shared-driver-fairness-config' + +describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { + it(`reconstructs the known persist-first witness, covers range marginals, and rejects a nearby invalid storm`, async () => { + const knownWitness = buildGeneratedHistory(2, 4, 1, [ + { kind: `persist`, token: `p1` }, + { kind: `persist`, token: `p2` }, + { kind: `persist`, token: `p3` }, + { kind: `hydrate`, token: `h0` }, + { kind: `hydrate`, token: `h1` }, + ]) + expect(knownWitness).toEqual({ + hydrateCount: 2, + persistCount: 4, + mutationsPerPersist: 1, + orderedKinds: [ + `persist`, + `persist`, + `persist`, + `persist`, + `hydrate`, + `hydrate`, + ], + replayHistory: `p0,p1,p2,p3,h0,h1`, + }) + + // Fixing hydrate count at two loses later completion checkpoints. Fixing + // persist count at three loses larger backlogs. Fixing mutation width at + // one loses multi-row work inside one logical persist. + const marginalHistories = [ + buildGeneratedHistory(2, 3, 1, [ + { kind: `persist`, token: `p1` }, + { kind: `persist`, token: `p2` }, + { kind: `hydrate`, token: `h0` }, + { kind: `hydrate`, token: `h1` }, + ]), + buildGeneratedHistory(7, 7, 3, [ + ...Array.from({ length: 6 }, (_, index) => ({ + kind: `persist` as const, + token: `p${index + 1}`, + })), + ...Array.from({ length: 7 }, (_, index) => ({ + kind: `hydrate` as const, + token: `h${index}`, + })), + ]), + ] + expect( + marginalHistories.map((history) => [ + history.hydrateCount, + history.persistCount, + history.mutationsPerPersist, + ]), + ).toEqual([ + [2, 3, 1], + [7, 7, 3], + ]) + + await expect( + withNodeScenario( + createScenario(`invalid-storm-boundary`, [ + `hydrate`, + `persist`, + `hydrate`, + ]), + expectFairObservation, + ), + ).rejects.toThrow(`must begin with its already-running persist`) + }) + + it(`proves tail-order ablation removes a hydrate-before-later-persist history`, () => { + const sampleOptions = { + seed: FIXED_SEED, + numRuns: oracleRuns(GENERATED_RUNS), + } + const completeGrammar = fc.sample(generatedHistoryArbitrary, sampleOptions) + const tailOrderAblatedGrammar = fc.sample( + tailOrderAblatedArbitrary, + sampleOptions, + ) + + expect(completeGrammar.some(reachesHydrateBeforeLaterPersist)).toBe(true) + expect(tailOrderAblatedGrammar.some(reachesHydrateBeforeLaterPersist)).toBe( + false, + ) + }) + + it(`calibrates the checker against a synthetic persist-first observation`, () => { + const scenario = createScenario( + `persist-first-checker-calibration`, + [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], + 1, + ) + const fault = createPersistFirstFaultObservation(scenario) + + expect(findSharedDriverFairnessViolation(fault)).toMatchObject({ + checkpoint: fault.hydrationCompletions[0], + expectedMaximumCompletedPersists: 1, + expectedMinimumPendingPersists: 3, + }) + }) +}) diff --git a/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-node-oracle.ts b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-node-oracle.ts new file mode 100644 index 0000000000..441becf6bd --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-node-oracle.ts @@ -0,0 +1,268 @@ +/** + * The Node production driver, its law check, and the generated history grammar + * of the shared-driver fairness campaign in shared-driver-fairness-oracle.test.ts. + * withNodeScenario runs one scenario through the real package path on a + * file-backed wa-sqlite test database; expectFairObservation applies the + * shared comparison of shared-driver-fairness-oracle.ts and names the first + * violated law and checkpoint; the arbitraries generate legal storms, with and + * without tail-order variation. Owns nothing and registers no tests: the + * campaign drives production with them, and shared-driver-fairness-grammar.test.ts + * calibrates the grammar with them. + */ +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import fc from 'fast-check' +import { expect } from 'vitest' +import { + oracleAssertion, + rejectAt, +} from '../../db/tests/oracle-assertion-adapter' +import { + SHARED_DRIVER_FAIRNESS_BOUND, + findSharedDriverFairnessLawViolations, + observeSharedDriverFairness, +} from './shared-driver-fairness-oracle' +import { createWASQLiteTestDatabase } from './helpers/wa-sqlite-test-db' +import type { + SharedDriverFairnessObservation, + SharedDriverFairnessOptions, + SharedDriverFairnessScenario, + SharedDriverFairnessWork, +} from './shared-driver-fairness-oracle' + +export const GENERATED_RUNS = 12 +export const HYDRATE_COUNT_RANGE = { min: 2, max: 7 } as const +export const PERSIST_COUNT_RANGE = { min: 3, max: 7 } as const +export const MUTATIONS_PER_PERSIST_RANGE = { min: 1, max: 3 } as const + +export class FairnessOracleFailure extends Error { + constructor( + readonly law: string, + readonly checkpoint: string, + cause: unknown, + ) { + super(cause instanceof Error ? cause.message : String(cause), { cause }) + } +} + +export function checkAt( + law: string, + checkpoint: string, + assertion: () => void, +): void { + try { + assertion() + } catch (error) { + throw new FairnessOracleFailure(law, checkpoint, error) + } +} + +export type WorkKind = SharedDriverFairnessWork[`kind`] + +export type GeneratedFairnessHistory = { + hydrateCount: number + persistCount: number + mutationsPerPersist: number + orderedKinds: ReadonlyArray + replayHistory: string +} + +export type GeneratedTailEntry = { + kind: WorkKind + token: string +} + +export function createScenario( + id: string, + orderedKinds: ReadonlyArray, + mutationsPerPersist = 1, +): SharedDriverFairnessScenario { + let hydrateIndex = 0 + let persistIndex = 0 + return { + id, + work: orderedKinds.map((kind) => { + if (kind === `persist`) { + const index = persistIndex++ + return { + kind, + id: `persist-${index}`, + mutationsPerPersist, + } + } + const index = hydrateIndex++ + return { + kind, + id: `hydrate-${index}`, + seededRows: [ + { id: `row-${index}-0`, value: index * 10 }, + { id: `row-${index}-1`, value: index * 10 + 1 }, + ], + } + }), + } +} + +export async function withNodeScenario( + scenario: SharedDriverFairnessScenario, + assertion: ( + observation: SharedDriverFairnessObservation, + ) => void | Promise, + options: SharedDriverFairnessOptions = {}, +): Promise { + const directory = mkdtempSync(join(tmpdir(), `db-driver-fairness-`)) + let primaryFailure: unknown + try { + const observation = await observeSharedDriverFairness( + () => + createWASQLiteTestDatabase({ + filename: join(directory, `state.sqlite`), + }), + scenario, + options, + ) + await assertion(observation) + } catch (error) { + primaryFailure = error + } + + let nodeCleanupFailure: unknown + try { + rmSync(directory, { recursive: true, force: true }) + } catch (error) { + nodeCleanupFailure = error + } + + if (primaryFailure !== undefined) { + if (nodeCleanupFailure !== undefined) { + throw new AggregateError( + [primaryFailure, nodeCleanupFailure], + `primary: ${String(primaryFailure)}; node cleanup diagnostics: ${String(nodeCleanupFailure)}`, + { cause: primaryFailure }, + ) + } + throw primaryFailure + } + if (nodeCleanupFailure !== undefined) throw nodeCleanupFailure +} + +export const REACH_LAWS = new Set([ + `hydrate admission IDs`, + `hydrate completion count`, + `hydrated public rows`, +]) + +// The shared comparison owns every law. This driver turns its first violation +// into a FairnessOracleFailure that names the law and checkpoint. +export function throwFirstViolation( + observation: SharedDriverFairnessObservation, + laws: (law: string) => boolean, +): void { + const violation = findSharedDriverFairnessLawViolations( + observation, + SHARED_DRIVER_FAIRNESS_BOUND, + ).find(({ law }) => laws(law)) + if (!violation) return + rejectAt( + violation.checkpoint, + new FairnessOracleFailure( + violation.law, + violation.checkpoint, + new Error( + `shared-driver fairness mismatch: expected ${JSON.stringify(violation.expected)}, ` + + `got ${JSON.stringify(violation.actual)}; ` + + `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, + ), + ), + ) +} + +export function expectObservationReach( + observation: SharedDriverFairnessObservation, +): void { + throwFirstViolation(observation, (law) => REACH_LAWS.has(law)) +} + +export function expectFairObservation( + observation: SharedDriverFairnessObservation, +): void { + throwFirstViolation(observation, () => true) + checkAt(`driver cleanup`, `after observation`, () => + oracleAssertion('expectFairObservation', () => + expect(observation.cleanupFailures).toEqual([]), + ), + ) +} + +export function buildGeneratedHistory( + hydrateCount: number, + persistCount: number, + mutationsPerPersist: number, + generatedTail: ReadonlyArray, +): GeneratedFairnessHistory { + return { + hydrateCount, + persistCount, + mutationsPerPersist, + orderedKinds: [ + `persist` as const, + ...generatedTail.map(({ kind }) => kind), + ], + replayHistory: `p0,${generatedTail.map(({ token }) => token).join(`,`)}`, + } +} + +export function createGeneratedHistoryArbitrary( + tailOrderAxis: `varied` | `removed`, +): fc.Arbitrary { + return fc + .record({ + hydrateCount: fc.integer(HYDRATE_COUNT_RANGE), + persistCount: fc.integer(PERSIST_COUNT_RANGE), + mutationsPerPersist: fc.integer(MUTATIONS_PER_PERSIST_RANGE), + }) + .chain(({ hydrateCount, persistCount, mutationsPerPersist }) => { + const tail = [ + ...Array.from({ length: persistCount - 1 }, (_, index) => ({ + kind: `persist` as const, + token: `p${index + 1}`, + })), + ...Array.from({ length: hydrateCount }, (_, index) => ({ + kind: `hydrate` as const, + token: `h${index}`, + })), + ] + const orderedTail = + tailOrderAxis === `varied` + ? fc.shuffledSubarray(tail, { + minLength: tail.length, + maxLength: tail.length, + }) + : fc.constant(tail) + return orderedTail.map((generatedTail) => + buildGeneratedHistory( + hydrateCount, + persistCount, + mutationsPerPersist, + generatedTail, + ), + ) + }) +} + +export function reachesHydrateBeforeLaterPersist( + history: GeneratedFairnessHistory, +): boolean { + let hydrateSeen = false + for (const kind of history.orderedKinds.slice(1)) { + if (kind === `hydrate`) hydrateSeen = true + if (kind === `persist` && hydrateSeen) return true + } + return false +} + +export const generatedHistoryArbitrary = + createGeneratedHistoryArbitrary(`varied`) +export const tailOrderAblatedArbitrary = + createGeneratedHistoryArbitrary(`removed`) 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 78ff898ab3..12f4c56f7b 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 @@ -12,6 +12,9 @@ * 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. + * Those grammar controls and the synthetic checker calibration run no + * production, so they live in `shared-driver-fairness-grammar.test.ts`; the + * Node driver, check, and grammar live in `shared-driver-fairness-node-oracle.ts`. * The shared fixture holds the first `BEGIN IMMEDIATE` while cold `preload()` * calls queue. This file supplies BetterSQLite; the receiving witness in * `../e2e/shared-driver-fairness-witness.opfs.spec.ts` uses the same fixture with a @@ -20,64 +23,43 @@ * violated laws and checkpoints; the report says whether shrinking retained * that violation. The full logical persist completion order checks lane FIFO. */ -import { mkdtempSync, rmSync } from 'node:fs' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { + forwardAssertionJudgment, oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, rejectAt, } from '../../db/tests/oracle-assertion-adapter' -import { oracleRoute, oracleRuns } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' +import { oracleProperty } from '../../db/tests/oracle-property' import { browserReplayAlias, registerBrowserProperty, } from './browser-property-registration-oracle' import { - SHARED_DRIVER_FAIRNESS_BOUND, - createPersistFirstFaultObservation, findSharedDriverFairnessLawViolations, findSharedDriverFairnessViolation, - observeSharedDriverFairness, } from './shared-driver-fairness-oracle' -import { createWASQLiteTestDatabase } from './helpers/wa-sqlite-test-db' +import { + FairnessOracleFailure, + GENERATED_RUNS, + createScenario, + expectFairObservation, + expectObservationReach, + generatedHistoryArbitrary, + withNodeScenario, +} from './shared-driver-fairness-node-oracle' import { FIXED_SEED, readFairnessCalibration, readFairnessReplayConfig, } from './shared-driver-fairness-config' +import type { GeneratedFairnessHistory } from './shared-driver-fairness-node-oracle' import type { - SharedDriverFairnessObservation, - SharedDriverFairnessOptions, SharedDriverFairnessScenario, SharedDriverFairnessWork, } from './shared-driver-fairness-oracle' -const GENERATED_RUNS = 12 -const HYDRATE_COUNT_RANGE = { min: 2, max: 7 } as const -const PERSIST_COUNT_RANGE = { min: 3, max: 7 } as const -const MUTATIONS_PER_PERSIST_RANGE = { min: 1, max: 3 } as const - -class FairnessOracleFailure extends Error { - constructor( - readonly law: string, - readonly checkpoint: string, - cause: unknown, - ) { - super(cause instanceof Error ? cause.message : String(cause), { cause }) - } -} - -function checkAt(law: string, checkpoint: string, assertion: () => void): void { - try { - assertion() - } catch (error) { - throw new FairnessOracleFailure(law, checkpoint, error) - } -} - const fairnessReplay = readFairnessReplayConfig() browserReplayAlias( fairnessReplay && { @@ -87,217 +69,6 @@ browserReplayAlias( ) const fairnessCalibration = readFairnessCalibration() -type WorkKind = SharedDriverFairnessWork[`kind`] - -type GeneratedFairnessHistory = { - hydrateCount: number - persistCount: number - mutationsPerPersist: number - orderedKinds: ReadonlyArray - replayHistory: string -} - -type GeneratedTailEntry = { - kind: WorkKind - token: string -} - -function createScenario( - id: string, - orderedKinds: ReadonlyArray, - mutationsPerPersist = 1, -): SharedDriverFairnessScenario { - let hydrateIndex = 0 - let persistIndex = 0 - return { - id, - work: orderedKinds.map((kind) => { - if (kind === `persist`) { - const index = persistIndex++ - return { - kind, - id: `persist-${index}`, - mutationsPerPersist, - } - } - const index = hydrateIndex++ - return { - kind, - id: `hydrate-${index}`, - seededRows: [ - { id: `row-${index}-0`, value: index * 10 }, - { id: `row-${index}-1`, value: index * 10 + 1 }, - ], - } - }), - } -} - -async function withNodeScenario( - scenario: SharedDriverFairnessScenario, - assertion: ( - observation: SharedDriverFairnessObservation, - ) => void | Promise, - options: SharedDriverFairnessOptions = {}, -): Promise { - const directory = mkdtempSync(join(tmpdir(), `db-driver-fairness-`)) - let primaryFailure: unknown - try { - const observation = await observeSharedDriverFairness( - () => - createWASQLiteTestDatabase({ - filename: join(directory, `state.sqlite`), - }), - scenario, - options, - ) - await assertion(observation) - } catch (error) { - primaryFailure = error - } - - let nodeCleanupFailure: unknown - try { - rmSync(directory, { recursive: true, force: true }) - } catch (error) { - nodeCleanupFailure = error - } - - if (primaryFailure !== undefined) { - if (nodeCleanupFailure !== undefined) { - throw new AggregateError( - [primaryFailure, nodeCleanupFailure], - `primary: ${String(primaryFailure)}; node cleanup diagnostics: ${String(nodeCleanupFailure)}`, - { cause: primaryFailure }, - ) - } - throw primaryFailure - } - if (nodeCleanupFailure !== undefined) throw nodeCleanupFailure -} - -const REACH_LAWS = new Set([ - `hydrate admission IDs`, - `hydrate completion count`, - `hydrated public rows`, -]) - -// The shared comparison owns every law. This driver turns its first violation -// into a FairnessOracleFailure that names the law and checkpoint. -function throwFirstViolation( - observation: SharedDriverFairnessObservation, - laws: (law: string) => boolean, -): void { - const violation = findSharedDriverFairnessLawViolations( - observation, - SHARED_DRIVER_FAIRNESS_BOUND, - ).find(({ law }) => laws(law)) - if (!violation) return - rejectAt( - violation.checkpoint, - new FairnessOracleFailure( - violation.law, - violation.checkpoint, - new Error( - `shared-driver fairness mismatch: expected ${JSON.stringify(violation.expected)}, ` + - `got ${JSON.stringify(violation.actual)}; ` + - `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, - ), - ), - ) -} - -function expectObservationReach( - observation: SharedDriverFairnessObservation, -): void { - throwFirstViolation(observation, (law) => REACH_LAWS.has(law)) -} - -function expectFairObservation( - observation: SharedDriverFairnessObservation, -): void { - throwFirstViolation(observation, () => true) - checkAt(`driver cleanup`, `after observation`, () => - oracleAssertion('expectFairObservation', () => - expect(observation.cleanupFailures).toEqual([]), - ), - ) -} - -function buildGeneratedHistory( - hydrateCount: number, - persistCount: number, - mutationsPerPersist: number, - generatedTail: ReadonlyArray, -): GeneratedFairnessHistory { - return { - hydrateCount, - persistCount, - mutationsPerPersist, - orderedKinds: [ - `persist` as const, - ...generatedTail.map(({ kind }) => kind), - ], - replayHistory: `p0,${generatedTail.map(({ token }) => token).join(`,`)}`, - } -} - -function createGeneratedHistoryArbitrary( - tailOrderAxis: `varied` | `removed`, -): fc.Arbitrary { - return fc - .record({ - hydrateCount: fc.integer(HYDRATE_COUNT_RANGE), - persistCount: fc.integer(PERSIST_COUNT_RANGE), - mutationsPerPersist: fc.integer(MUTATIONS_PER_PERSIST_RANGE), - }) - .chain(({ hydrateCount, persistCount, mutationsPerPersist }) => { - const tail = [ - ...Array.from({ length: persistCount - 1 }, (_, index) => ({ - kind: `persist` as const, - token: `p${index + 1}`, - })), - ...Array.from({ length: hydrateCount }, (_, index) => ({ - kind: `hydrate` as const, - token: `h${index}`, - })), - ] - const orderedTail = - tailOrderAxis === `varied` - ? fc.shuffledSubarray(tail, { - minLength: tail.length, - maxLength: tail.length, - }) - : fc.constant(tail) - return orderedTail.map((generatedTail) => - buildGeneratedHistory( - hydrateCount, - persistCount, - mutationsPerPersist, - generatedTail, - ), - ) - }) -} - -function reachesHydrateBeforeLaterPersist( - history: GeneratedFairnessHistory, -): boolean { - let hydrateSeen = false - for (const kind of history.orderedKinds.slice(1)) { - if (kind === `hydrate`) hydrateSeen = true - if (kind === `persist` && hydrateSeen) return true - } - return false -} - -const generatedHistoryArbitrary = createGeneratedHistoryArbitrary(`varied`) -const tailOrderAblatedArbitrary = createGeneratedHistoryArbitrary(`removed`) - -declareOracle({ - owns: [`src/wa-sqlite-driver.ts`], - grammar: [generatedHistoryArbitrary, tailOrderAblatedArbitrary], -}) let generatedPropertyExecutions = 0 type GeneratedFailureRecord = { law: string @@ -346,193 +117,225 @@ const runGeneratedFairnessHistory = async ({ } } -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` }, - { kind: `persist`, token: `p3` }, - { kind: `hydrate`, token: `h0` }, - { kind: `hydrate`, token: `h1` }, - ]) - expect(knownWitness).toEqual({ - hydrateCount: 2, - persistCount: 4, - mutationsPerPersist: 1, - orderedKinds: [ - `persist`, - `persist`, - `persist`, - `persist`, - `hydrate`, - `hydrate`, +// Each fixed history below runs through withNodeScenario and the shared +// comparison, expectFairObservation; the adapter transports that judgment. +const fairnessLaw = `shared-driver K=1 fairness` +const fairnessLawText = { + statement: `Under a persist storm, the already-running persist completes before the first cold hydrate, at most one more persist completes between consecutive hydrate completions, FIFO order holds inside each lane, and hydrated public rows equal their independently seeded rows.`, + authority: `RFC #1659's K=1 complete-logical-cold-hydrate scheduling, as shared-driver-fairness-oracle.ts states it.`, +} + +async function runNeutralReach(): Promise { + await withNodeScenario( + createScenario(`neutral-reach`, [`hydrate`, `hydrate`, `hydrate`]), + (observation) => { + expectFairObservation(observation) + expect( + observation.rawDequeues.some((entry) => + entry.sql.startsWith(`SELECT key, value, metadata, row_version FROM`), + ), + ).toBe(true) + }, + ) +} + +async function runFixedPersistStorm(): Promise { + const scenario = createScenario( + `fixed-persist-storm`, + [`persist`, `persist`, `persist`, `hydrate`, `hydrate`], + 2, + ) + await withNodeScenario(scenario, (observation) => { + expectFairObservation(observation) + + // A K=2 scheduler could finish the third persist before the second + // hydrate. K=1 must leave it pending at that logical checkpoint. + const secondHydrate = observation.hydrationCompletions[1]! + expect(secondHydrate.pendingPersistCount).toBeGreaterThan(0) + const completedPersistIds = scenario.work + .filter((work) => work.kind === `persist`) + .map((work) => `${scenario.id}-${work.id}`) + expect(observation.hydrationCompletions[0]?.completedPersistIds).toEqual( + completedPersistIds.slice(0, 1), + ) + const k2WrongAnswer = { + ...observation, + logicalCompletionOrder: [ + `persist:${completedPersistIds[0]}`, + `hydrate:${observation.hydrationCompletions[0]!.collectionId}`, + `persist:${completedPersistIds[1]}`, + `persist:${completedPersistIds[2]}`, + `hydrate:${secondHydrate.collectionId}`, + ], + hydrationCompletions: [ + observation.hydrationCompletions[0]!, + { + ...secondHydrate, + completionOrdinal: 4, + completedPersistIds, + pendingPersistCount: 0, + }, ], - replayHistory: `p0,p1,p2,p3,h0,h1`, + } + expect(findSharedDriverFairnessViolation(k2WrongAnswer)).toMatchObject({ + checkpoint: k2WrongAnswer.hydrationCompletions[1], + expectedMaximumCompletedPersists: 2, + expectedMinimumPendingPersists: 1, }) - - // Fixing hydrate count at two loses later completion checkpoints. Fixing - // persist count at three loses larger backlogs. Fixing mutation width at - // one loses multi-row work inside one logical persist. - const marginalHistories = [ - buildGeneratedHistory(2, 3, 1, [ - { kind: `persist`, token: `p1` }, - { kind: `persist`, token: `p2` }, - { kind: `hydrate`, token: `h0` }, - { kind: `hydrate`, token: `h1` }, - ]), - buildGeneratedHistory(7, 7, 3, [ - ...Array.from({ length: 6 }, (_, index) => ({ - kind: `persist` as const, - token: `p${index + 1}`, - })), - ...Array.from({ length: 7 }, (_, index) => ({ - kind: `hydrate` as const, - token: `h${index}`, - })), - ]), - ] + expect(findSharedDriverFairnessViolation(k2WrongAnswer, 2)).toBeUndefined() + + // A hydrate that preempts the running persist stays under every K=1 + // ceiling. Only the non-preemptible law rejects it. + const firstHydrate = observation.hydrationCompletions[0]! + const preemptedWrongAnswer = { + ...observation, + hydrationCompletions: [ + { + ...firstHydrate, + completedPersistIds: [], + pendingPersistCount: firstHydrate.pendingPersistCount + 1, + }, + ...observation.hydrationCompletions.slice(1), + ], + } + expect( + findSharedDriverFairnessViolation(preemptedWrongAnswer), + ).toBeUndefined() expect( - marginalHistories.map((history) => [ - history.hydrateCount, - history.persistCount, - history.mutationsPerPersist, - ]), + findSharedDriverFairnessLawViolations(preemptedWrongAnswer).map( + ({ law, checkpoint }) => ({ law, checkpoint }), + ), ).toEqual([ - [2, 3, 1], - [7, 7, 3], + { + law: `non-preemptible running persist`, + checkpoint: `hydrate completion 0`, + }, ]) - await expect( - withNodeScenario( - createScenario(`invalid-storm-boundary`, [ - `hydrate`, - `persist`, - `hydrate`, - ]), - expectFairObservation, - ), - ).rejects.toThrow(`must begin with its already-running persist`) - }) - - 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) => { - expectFairObservation(observation) - expect( - observation.rawDequeues.some((entry) => - entry.sql.startsWith( - `SELECT key, value, metadata, row_version FROM`, - ), - ), - ).toBe(true) + const swappedPersistOrder = observation.logicalCompletionOrder.map( + (entry) => { + if (entry === `persist:${completedPersistIds[1]}`) { + return `persist:${completedPersistIds[2]}` + } + if (entry === `persist:${completedPersistIds[2]}`) { + return `persist:${completedPersistIds[1]}` + } + return entry }, ) + let orderFailure: unknown + try { + expectFairObservation({ + ...observation, + logicalCompletionOrder: swappedPersistOrder, + }) + } catch (error) { + orderFailure = error + } + expect(orderFailure).toMatchObject({ + law: `persist FIFO completion order`, + checkpoint: `after all work settled`, + }) }) +} - 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`], - 2, - ) - await withNodeScenario(scenario, (observation) => { - expectFairObservation(observation) +const fixedFairnessHistories = new Map Promise>([ + [ + `reaches cold hydration and preserves independently seeded public rows without queued persists`, + runNeutralReach, + ], + [ + `completes a pending cold hydrate before an unrelated persist backlog drains and distinguishes K=1 from K=2`, + runFixedPersistStorm, + ], +]) - // A K=2 scheduler could finish the third persist before the second - // hydrate. K=1 must leave it pending at that logical checkpoint. - const secondHydrate = observation.hydrationCompletions[1]! - expect(secondHydrate.pendingPersistCount).toBeGreaterThan(0) - const completedPersistIds = scenario.work - .filter((work) => work.kind === `persist`) - .map((work) => `${scenario.id}-${work.id}`) - expect(observation.hydrationCompletions[0]?.completedPersistIds).toEqual( - completedPersistIds.slice(0, 1), - ) - const k2WrongAnswer = { - ...observation, - logicalCompletionOrder: [ - `persist:${completedPersistIds[0]}`, - `hydrate:${observation.hydrationCompletions[0]!.collectionId}`, - `persist:${completedPersistIds[1]}`, - `persist:${completedPersistIds[2]}`, - `hydrate:${secondHydrate.collectionId}`, - ], - hydrationCompletions: [ - observation.hydrationCompletions[0]!, - { - ...secondHydrate, - completionOrdinal: 4, - completedPersistIds, - pendingPersistCount: 0, - }, - ], - } - expect(findSharedDriverFairnessViolation(k2WrongAnswer)).toMatchObject({ - checkpoint: k2WrongAnswer.hydrationCompletions[1], - expectedMaximumCompletedPersists: 2, - expectedMinimumPendingPersists: 1, - }) - expect( - findSharedDriverFairnessViolation(k2WrongAnswer, 2), - ).toBeUndefined() +// The named persist-first storm: four persists, then two hydrates. The real +// scheduler must pass it; the persist-first FIFO scheduling mutant must fail +// the checker on it. +const persistFirstScenario = () => + createScenario( + `persist-first-fixture-fault`, + [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], + 1, + ) - // A hydrate that preempts the running persist stays under every K=1 - // ceiling. Only the non-preemptible law rejects it. - const firstHydrate = observation.hydrationCompletions[0]! - const preemptedWrongAnswer = { - ...observation, - hydrationCompletions: [ - { - ...firstHydrate, - completedPersistIds: [], - pendingPersistCount: firstHydrate.pendingPersistCount + 1, - }, - ...observation.hydrationCompletions.slice(1), - ], +async function rejectPersistFirstFifo(): Promise { + let rejected: ReturnType + await withNodeScenario( + createScenario( + `persist-first-fixture-fault`, + [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], + 1, + ), + (observation) => { + expectObservationReach(observation) + const violation = findSharedDriverFairnessViolation(observation) + if (!violation) { + throw new Error( + `persist-first scheduling mutant escaped the fairness checker; ` + + `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, + ) } - expect( - findSharedDriverFairnessViolation(preemptedWrongAnswer), - ).toBeUndefined() - expect( - findSharedDriverFairnessLawViolations(preemptedWrongAnswer).map( - ({ law, checkpoint }) => ({ law, checkpoint }), - ), - ).toEqual([ - { - law: `non-preemptible running persist`, - checkpoint: `hydrate completion 0`, - }, - ]) + expect(violation.checkpoint.completedPersistIds).toHaveLength(4) + expect(violation.checkpoint.pendingPersistCount).toBe(0) + expect(observation.cleanupFailures).toEqual([]) + rejected = violation + }, + { schedulingFault: `persist-first-fifo` }, + ) + // The checker found the mutant's violation at the asserted checkpoint. + if (rejected) + rejectAt( + `calibration persist-first-fifo`, + new Error( + `persist-first scheduling mutant rejected: ${JSON.stringify(rejected.checkpoint)}`, + ), + ) +} - const swappedPersistOrder = observation.logicalCompletionOrder.map( - (entry) => { - if (entry === `persist:${completedPersistIds[1]}`) { - return `persist:${completedPersistIds[2]}` - } - if (entry === `persist:${completedPersistIds[2]}`) { - return `persist:${completedPersistIds[1]}` - } - return entry - }, - ) - let orderFailure: unknown - try { - expectFairObservation({ - ...observation, - logicalCompletionOrder: swappedPersistOrder, - }) - } catch (error) { - orderFailure = error - } - expect(orderFailure).toMatchObject({ - law: `persist FIFO completion order`, - checkpoint: `after all work settled`, - }) - }) +describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { + oracleProperty({ + id: `browser.shared-driver-fairness.fixed`, + law: { + ...fairnessLawText, + limits: `The neutral hydrate-only history and the fixed persist storm, with the storm's checker calibrations of a K=2 answer, a preempted running persist, and swapped persist order.`, + }, + owns: [`src/wa-sqlite-driver.ts`], + histories: [...fixedFairnessHistories.keys()], + label: (name) => name, + resolve: (name) => ({ run: fixedFairnessHistories.get(name)! }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => plan.run()), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `browser.shared-driver-fairness.persist-first`, + law: { + ...fairnessLawText, + limits: `The named persist-first storm through the real package path; its calibration runs the persist-first FIFO scheduling mutant.`, + }, + owns: [`src/wa-sqlite-driver.ts`], + histories: [{ name: `persist-first-fixture` }], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + withNodeScenario(persistFirstScenario(), expectFairObservation), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects the named persist-first FIFO wrong answer through the real package path`, + law: fairnessLaw, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + rejectPersistFirstFifo(), + ), + }, + ], }) { @@ -541,6 +344,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { generatedHistoryArbitrary, { id: 'browser.shared-driver-fairness', + owns: [`src/wa-sqlite-driver.ts`], runs: GENERATED_RUNS, fixedSeed: FIXED_SEED, beforeCampaign: () => { @@ -612,8 +416,7 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { work: withBarrier.work.filter((work) => work.kind !== `barrier`), } - it(`keeps the K=1 interleaving and waits only for the hydrate queued before it`, async () => { - oracleRoute(`fixed history`) + async function runBarrierHistory(): Promise { let baseline: ReadonlyArray | undefined await withNodeScenario(withoutBarrier, (observation) => { expectFairObservation(observation) @@ -628,10 +431,9 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { expect(settled).toBeGreaterThan(trace.indexOf(`hydrate-scope-end:1`)) expect(settled).toBeLessThan(trace.indexOf(`hydrate-scope-end:2`)) }) - }) + } - it(`rejects an empty regular task used as the barrier`, async () => { - oracleRoute(`calibration`) + async function rejectEmptyRegularBarrier(): Promise { let baseline: ReadonlyArray | undefined await withNodeScenario(withoutBarrier, (observation) => { baseline = observation.logicalCompletionOrder @@ -650,14 +452,24 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }, { barrierFault: `empty-regular` }, ) - expect({ + const outcome = { sameInterleaving: JSON.stringify(observed) === JSON.stringify(baseline), checkerFailed: checkerFailure !== undefined, - }).not.toEqual({ sameInterleaving: true, checkerFailed: false }) - }) + } + // Exactly the old matcher, which required this outcome not to equal + // { sameInterleaving: true, checkerFailed: false }: the mutant counts as + // rejected when it changed the interleaving or failed the checker. + if (!outcome.sameInterleaving || outcome.checkerFailed) + rejectAt( + `calibration empty-regular`, + new Error( + `empty regular barrier rejected: ${JSON.stringify(outcome)}`, + { cause: checkerFailure }, + ), + ) + } - it(`rejects an empty hydrate scope used as the barrier`, async () => { - oracleRoute(`calibration`) + async function rejectEmptyHydrateBarrier(): Promise { let failure: unknown await withNodeScenario( withBarrier, @@ -670,69 +482,62 @@ describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { }, { barrierFault: `empty-hydrate` }, ) - expect(failure).toMatchObject({ - law: `K=1 completed-persist bound; K=1 pending-persist backlog; completed persist outside permitted prefix`, - checkpoint: `hydrate completion 1`, - }) - }) - }) - - it(`proves tail-order ablation removes a hydrate-before-later-persist history`, () => { - oracleRoute(`calibration`) - const sampleOptions = { - seed: FIXED_SEED, - numRuns: oracleRuns(GENERATED_RUNS), + // Exactly the old matcher: only a failure that names this law and + // checkpoint counts as the check rejecting the empty hydrate scope. + const matched = + typeof failure === `object` && + failure !== null && + `law` in failure && + failure.law === + `K=1 completed-persist bound; K=1 pending-persist backlog; completed persist outside permitted prefix` && + `checkpoint` in failure && + failure.checkpoint === `hydrate completion 1` + if (matched) + rejectAt( + `calibration empty-hydrate`, + failure instanceof Error + ? failure + : new Error(`empty hydrate barrier rejected`, { cause: failure }), + ) } - const completeGrammar = fc.sample(generatedHistoryArbitrary, sampleOptions) - const tailOrderAblatedGrammar = fc.sample( - tailOrderAblatedArbitrary, - sampleOptions, - ) - - expect(completeGrammar.some(reachesHydrateBeforeLaterPersist)).toBe(true) - expect(tailOrderAblatedGrammar.some(reachesHydrateBeforeLaterPersist)).toBe( - false, - ) - }) - it(`rejects the named persist-first FIFO wrong answer through the real package path`, async () => { - oracleRoute(`calibration`) - await withNodeScenario( - createScenario( - `persist-first-fixture-fault`, - [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], - 1, - ), - (observation) => { - expectObservationReach(observation) - const violation = findSharedDriverFairnessViolation(observation) - if (!violation) { - throw new Error( - `persist-first scheduling mutant escaped the fairness checker; ` + - `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, - ) - } - expect(violation.checkpoint.completedPersistIds).toHaveLength(4) - expect(violation.checkpoint.pendingPersistCount).toBe(0) - expect(observation.cleanupFailures).toEqual([]) + oracleProperty({ + id: `browser.shared-driver-fairness.barrier`, + law: { + ...fairnessLawText, + limits: `One election barrier between two queued hydrates, compared with the same history without it; its calibrations run an empty regular task and an empty hydrate scope as the barrier.`, }, - { schedulingFault: `persist-first-fifo` }, - ) - }) - - 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`], - 1, - ) - const fault = createPersistFirstFaultObservation(scenario) - - expect(findSharedDriverFairnessViolation(fault)).toMatchObject({ - checkpoint: fault.hydrationCompletions[0], - expectedMaximumCompletedPersists: 1, - expectedMinimumPendingPersists: 3, + owns: [`src/wa-sqlite-driver.ts`], + histories: [ + { + name: `keeps the K=1 interleaving and waits only for the hydrate queued before it`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + runBarrierHistory(), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects an empty regular task used as the barrier`, + law: fairnessLaw, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + rejectEmptyRegularBarrier(), + ), + }, + { + name: `rejects an empty hydrate scope used as the barrier`, + law: fairnessLaw, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + rejectEmptyHydrateBarrier(), + ), + }, + ], }) }) }) From b41a33bea70e611ebfebd3b4b6a35cc5af597c7f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:13:30 -0700 Subject: [PATCH 072/116] test(db): register comparison witnesses and calibrations through oracleProperty The fixed non-finite, signed-infinity and Temporal witnesses become enumerated properties, and the wrong-comparator calibrations become their mutant drivers. The comparison-laws companion's dead owns entry, src/utils/comparison.ts, moves onto this file's oracleProperty calls. Co-authored-by: Isaac --- .../tests/comparison-oracle.property.test.ts | 471 +++++++++++------- .../db/tests/converted-properties/db-core.ts | 8 + 2 files changed, 309 insertions(+), 170 deletions(-) diff --git a/packages/db/tests/comparison-oracle.property.test.ts b/packages/db/tests/comparison-oracle.property.test.ts index de91877071..63774dcb25 100644 --- a/packages/db/tests/comparison-oracle.property.test.ts +++ b/packages/db/tests/comparison-oracle.property.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it } from 'vitest' +import { beforeEach, describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { Temporal } from 'temporal-polyfill' import { @@ -10,13 +10,9 @@ import { normalizeValue, } from '../src/utils/comparison' import { DEFAULT_COMPARE_OPTIONS } from '../src/utils' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' +import { oracleProperty, oracleReplayRequested } from './oracle-config.js' import { defaultOpts, lexicalOpts } from './comparison-options.js' -import { declareOracle } from './oracle-declaration.js' +import { rejection } from './calibration-rejection.js' import { nativeCampaignReceipt } from './native-campaign-receipt' import { checkWithOriginalFailure, @@ -162,6 +158,10 @@ function comparisonProperty< limits: 'The unchanged bounded same-type and normalization grammars; unrelated object identity order, mixed-type order and query/index receiving paths remain outside this owner.', }, + // What this oracle owns: the comparison and normalization functions in + // utils/comparison.ts, which the comparison-laws-oracle companion's + // checks and models judge through this file's properties. + owns: [`src/utils/comparison.ts`], histories: { arbitrary, runs: campaignRuns, fixedSeeds: [fixedSeed] }, resolve: (history) => ({ history }), model: recordedAssertionJudgment, @@ -393,101 +393,164 @@ describe(`ascComparator property-based tests`, () => { }) }) +const comparisonAuthority = + 'OrderByOptions, CompareOptions and the comparison, binary normalization and non-finite ordering contracts stated by the named comparison-laws-oracle companion' + 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], - unorderedCount: 2, - includeNull: false, - }) - assertNonFiniteOrder({ - family: `number`, - finiteDays: [5, 1], - unorderedCount: 1, - includeNull: true, - }) - assertNonFiniteOrder({ - family: `date`, - finiteDays: [19_358], // 2023-01-01, the legacy valid-Date operand. - unorderedCount: 1, - includeNull: false, - }) + oracleProperty({ + id: `comparison.non-finite-witnesses`, + law: { + statement: `The default comparator refines the tagged finite and non-finite order: NaN and invalid Dates share one position after finite values, and null stays first.`, + authority: comparisonAuthority, + limits: `The three fixed legacy witnesses: duplicate NaN among finite numbers, NaN with null, and one invalid Date beside a valid one.`, + }, + owns: [`src/utils/comparison.ts`], + histories: [ + `reconstructs the finite, duplicate NaN, null, and invalid Date witnesses`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.non-finite-witnesses`, + plan, + observe, + () => { + assertNonFiniteOrder({ + family: `number`, + finiteDays: [3, 1, 5], + unorderedCount: 2, + includeNull: false, + }) + assertNonFiniteOrder({ + family: `number`, + finiteDays: [5, 1], + unorderedCount: 1, + includeNull: true, + }) + assertNonFiniteOrder({ + family: `date`, + finiteDays: [19_358], // 2023-01-01, the legacy valid-Date operand. + unorderedCount: 1, + includeNull: false, + }) + }, + ), + check: forwardAssertionJudgment, + // Each calibration replays its old body: a wrong comparison or default + // sort must make the shared non-finite order check throw, and the wrong + // answer must have been reached. + calibrations: [ + { + name: `rejects a wrong comparison that ties non-finite and finite values`, + law: `comparison.non-finite-witnesses`, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.non-finite-witnesses`, + plan, + observe, + () => { + const rejections: Array = [] + for (const family of [`number`, `date`] as const) { + let reachedFault = false + const faultyComparator = ( + a: number | Date | null, + b: number | Date | null, + ) => { + const faultApplies = + family === `number` + ? typeof a === `number` && + Number.isNaN(a) && + typeof b === `number` && + Number.isFinite(b) + : a instanceof Date && + Number.isNaN(a.getTime()) && + b instanceof Date && + Number.isFinite(b.getTime()) + if (faultApplies) { + reachedFault = true + return 0 + } + return ascComparator(a, b, DEFAULT_COMPARE_OPTIONS) + } + rejections.push( + rejection( + () => true, + () => + assertNonFiniteOrder( + { + family, + finiteDays: [1], + unorderedCount: 1, + includeNull: false, + }, + faultyComparator, + ), + ), + ) + expect(reachedFault).toBe(true) + } + rejectAt( + `calibration: a comparison that ties non-finite and finite values`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects a default sort that ties NaN to finite values`, + law: `comparison.non-finite-witnesses`, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.non-finite-witnesses`, + plan, + observe, + () => { + let reachedFault = false + const faultySort = ( + a: number | Date | null, + b: number | Date | null, + ) => { + if (typeof a === `number` && Number.isNaN(a)) { + reachedFault = true + return 0 + } + if (typeof b === `number` && Number.isNaN(b)) { + reachedFault = true + return 0 + } + return defaultComparator(a, b) + } + const rejected = rejection( + () => true, + () => + assertNonFiniteOrder( + { + family: `number`, + finiteDays: [3, 1, 5], + unorderedCount: 2, + includeNull: false, + }, + (a, b) => ascComparator(a, b, DEFAULT_COMPARE_OPTIONS), + faultySort, + ), + ) + expect(reachedFault).toBe(true) + rejectAt( + `calibration: a default sort that ties NaN to finite values`, + rejected, + ) + }, + ), + }, + ], }) comparisonProperty([arbitraryNonFiniteInput])( `default comparator refines tagged finite and non-finite order`, (input) => assertNonFiniteOrder(input), ) - - 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 = ( - a: number | Date | null, - b: number | Date | null, - ) => { - const faultApplies = - family === `number` - ? typeof a === `number` && - Number.isNaN(a) && - typeof b === `number` && - Number.isFinite(b) - : a instanceof Date && - Number.isNaN(a.getTime()) && - b instanceof Date && - Number.isFinite(b.getTime()) - if (faultApplies) { - reachedFault = true - return 0 - } - return ascComparator(a, b, DEFAULT_COMPARE_OPTIONS) - } - expect(() => - assertNonFiniteOrder( - { - family, - finiteDays: [1], - unorderedCount: 1, - includeNull: false, - }, - faultyComparator, - ), - ).toThrowError() - expect(reachedFault).toBe(true) - } - }) - - 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)) { - reachedFault = true - return 0 - } - if (typeof b === `number` && Number.isNaN(b)) { - reachedFault = true - return 0 - } - return defaultComparator(a, b) - } - expect(() => - assertNonFiniteOrder( - { - family: `number`, - finiteDays: [3, 1, 5], - unorderedCount: 2, - includeNull: false, - }, - (a, b) => ascComparator(a, b, DEFAULT_COMPARE_OPTIONS), - faultySort, - ), - ).toThrowError() - expect(reachedFault).toBe(true) - }) }) describe(`descComparator property-based tests`, () => { @@ -543,19 +606,40 @@ describe(`makeComparator property-based tests`, () => { }, ) - it(`preserves signed infinity from a valid custom string comparator`, () => { - oracleRoute(`case`) - const comparator = makeComparator({ - ...defaultOpts, - stringSort: `custom`, - compare: (a, b) => (a === b ? 0 : a < b ? -Infinity : Infinity), - }) - expect(comparator(`a`, `b`)).toBe(-Infinity) - expect(comparator(`b`, `a`)).toBe(Infinity) - expect(comparator(`a`, `a`)).toBe(0) - expect(checkAntisymmetry(comparator(`a`, `b`), comparator(`b`, `a`))).toBe( - true, - ) + oracleProperty({ + id: `comparison.signed-infinity-custom`, + law: { + statement: `A comparator made with a valid custom string comparison preserves that comparison's signed infinity and stays antisymmetric.`, + authority: comparisonAuthority, + limits: `One custom comparison over the strings a and b that returns -Infinity, 0 or Infinity.`, + }, + owns: [`src/utils/comparison.ts`], + histories: [ + `preserves signed infinity from a valid custom string comparator`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.signed-infinity-custom`, + plan, + observe, + () => { + const comparator = makeComparator({ + ...defaultOpts, + stringSort: `custom`, + compare: (a, b) => (a === b ? 0 : a < b ? -Infinity : Infinity), + }) + expect(comparator(`a`, `b`)).toBe(-Infinity) + expect(comparator(`b`, `a`)).toBe(Infinity) + expect(comparator(`a`, `a`)).toBe(0) + expect( + checkAntisymmetry(comparator(`a`, `b`), comparator(`b`, `a`)), + ).toBe(true) + }, + ), + check: forwardAssertionJudgment, }) }) @@ -797,32 +881,115 @@ 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({ - left: { year: 2023, month: 12, day: 28 }, - right: jan, - }) - assertPlainDateOrder({ left: jan, right: june }) - assertPlainDateOrder({ - left: jan, - right: { year: 2024, month: 1, day: 2 }, - }) - assertPlainDateOrder({ left: jan, right: jan }) - assertZonedDateTimeOrder({ - leftNanoOffset: 0, - rightNanoOffset: 0, - leftZone: `+00:00`, - rightZone: `+05:30`, - }) - assertZonedDateTimeOrder({ - leftNanoOffset: 0, - rightNanoOffset: 1, - leftZone: `+05:30`, - rightZone: `+00:00`, - }) + oracleProperty({ + id: `comparison.temporal-witnesses`, + law: { + statement: `The default comparator orders ISO PlainDate values by their calendar fields and ZonedDateTime values by their instants across offset zones.`, + authority: comparisonAuthority, + limits: `The fixed legacy PlainDate pairs and two ZonedDateTime pairs: one instant in two zones, and adjacent nanosecond instants.`, + }, + owns: [`src/utils/comparison.ts`], + histories: [ + `reconstructs the three PlainDate and one ZonedDateTime assertions`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.temporal-witnesses`, + plan, + observe, + () => { + const jan = { year: 2024, month: 1, day: 1 } + const june = { year: 2024, month: 6, day: 1 } + assertPlainDateOrder({ + left: { year: 2023, month: 12, day: 28 }, + right: jan, + }) + assertPlainDateOrder({ left: jan, right: june }) + assertPlainDateOrder({ + left: jan, + right: { year: 2024, month: 1, day: 2 }, + }) + assertPlainDateOrder({ left: jan, right: jan }) + assertZonedDateTimeOrder({ + leftNanoOffset: 0, + rightNanoOffset: 0, + leftZone: `+00:00`, + rightZone: `+05:30`, + }) + assertZonedDateTimeOrder({ + leftNanoOffset: 0, + rightNanoOffset: 1, + leftZone: `+05:30`, + rightZone: `+00:00`, + }) + }, + ), + check: forwardAssertionJudgment, + // A wrong comparator must make the shared ZonedDateTime check throw. + calibrations: [ + { + name: `rejects text order for one instant shown in two zones`, + law: `comparison.temporal-witnesses`, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.temporal-witnesses`, + plan, + observe, + () => { + const sample: ZonedDateTimeOrderInput = { + leftNanoOffset: 0, + rightNanoOffset: 0, + leftZone: `+00:00`, + rightZone: `+05:30`, + } + const lexical = (left: unknown, right: unknown) => + String(left).localeCompare(String(right)) + rejectAt( + `calibration: text order for one instant shown in two zones`, + rejection( + () => true, + () => assertZonedDateTimeOrder(sample, lexical), + ), + ) + }, + ), + }, + { + name: `rejects millisecond truncation across adjacent instants`, + law: `comparison.temporal-witnesses`, + driver: (plan, observe) => + recordAssertionJudgment( + `comparison.temporal-witnesses`, + plan, + observe, + () => { + const sample: ZonedDateTimeOrderInput = { + leftNanoOffset: 0, + rightNanoOffset: 1, + leftZone: `+05:30`, + rightZone: `+00:00`, + } + const truncated = (left: unknown, right: unknown) => { + const leftEpoch = (left as { epochNanoseconds: bigint }) + .epochNanoseconds + const rightEpoch = (right as { epochNanoseconds: bigint }) + .epochNanoseconds + return Number(leftEpoch / 1_000_000n - rightEpoch / 1_000_000n) + } + rejectAt( + `calibration: millisecond truncation across adjacent instants`, + rejection( + () => true, + () => assertZonedDateTimeOrder(sample, truncated), + ), + ) + }, + ), + }, + ], }) comparisonProperty([arbitraryPlainDateOrderInput])( @@ -834,40 +1001,4 @@ describe(`Temporal default-order refinement`, () => { `ZonedDateTime order refines supplied instants across offset zones`, assertZonedDateTimeOrder, ) - - it(`rejects text order for one instant shown in two zones`, () => { - oracleRoute(`calibration`) - const sample: ZonedDateTimeOrderInput = { - leftNanoOffset: 0, - rightNanoOffset: 0, - leftZone: `+00:00`, - rightZone: `+05:30`, - } - const lexical = (left: unknown, right: unknown) => - String(left).localeCompare(String(right)) - expect(() => assertZonedDateTimeOrder(sample, lexical)).toThrow() - }) - - it(`rejects millisecond truncation across adjacent instants`, () => { - oracleRoute(`calibration`) - const sample: ZonedDateTimeOrderInput = { - leftNanoOffset: 0, - rightNanoOffset: 1, - leftZone: `+05:30`, - rightZone: `+00:00`, - } - const truncated = (left: unknown, right: unknown) => { - const leftEpoch = (left as { epochNanoseconds: bigint }).epochNanoseconds - const rightEpoch = (right as { epochNanoseconds: bigint }) - .epochNanoseconds - return Number(leftEpoch / 1_000_000n - rightEpoch / 1_000_000n) - } - expect(() => assertZonedDateTimeOrder(sample, truncated)).toThrow() - }) -}) - -// Temporal models remain beside their public comparator observation cuts. -declareOracle({ - check: [assertPlainDateOrder, assertZonedDateTimeOrder], - model: [modelPlainDateSign, modelNanosecondSign], }) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index 94477a6b2b..cff71d02a1 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -37,6 +37,9 @@ export const properties: ReadonlyArray = [ `btree-map.infinite-comparator`, `btree-map.long-campaign`, `btree-map.dense-cuts`, + `comparison.non-finite-witnesses`, + `comparison.signed-infinity-custom`, + `comparison.temporal-witnesses`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -85,6 +88,11 @@ export const owners: ReadonlyArray = [ `btree-map`, `split-comparator infinite-comparator long-campaign dense-cuts`, ], + [ + `db/tests/comparison-oracle.property.test.ts`, + `comparison`, + `non-finite-witnesses signed-infinity-custom temporal-witnesses`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance From 964ca8a8d5155989d7c356018183eafb778c3933 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:13:38 -0700 Subject: [PATCH 073/116] test(db): register ordered-source loader state tests through oracleProperty The substituted-error axes and the held receiving cases become enumerated properties through the shared comparison adapter. The campaign regressions replay through checkAuthorityCase in ordered-loader.request-authority-fixed. The retained-lease probe becomes a calibration of ordered-loader.retained-lease with its old matcher. The retirement-progress checker calibration runs no production, so it moves to an ordinary test beside an extracted, test-free comparison module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 44 +- ...rdered-source-loader-state-model-oracle.ts | 31 + .../ordered-source-loader-state-model.test.ts | 22 + ...ordered-source-loader-state-oracle.test.ts | 1181 +++++++++-------- 4 files changed, 724 insertions(+), 554 deletions(-) create mode 100644 packages/db/tests/query/ordered-source-loader-state-model-oracle.ts create mode 100644 packages/db/tests/query/ordered-source-loader-state-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 08529168ec..7c691ea1b2 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -82,8 +82,24 @@ const orderedWorkOwners: ReadonlyArray = [ // ordered-lifecycle, bucket-facade-rollback, cold-join-reconciliation, and // ordered-source-loader-state oracles -const lifecycleProperties: ReadonlyArray = [] -const lifecycleOwners: ReadonlyArray = [] +const lifecycleProperties: ReadonlyArray = [ + `bucket-facade.rollback-fixed`, + `cold-join.equality-predicate`, + `cold-join.reconciliation-fixed`, + `cold-join.compound-fixed`, +] +const lifecycleOwners: ReadonlyArray = [ + [ + `db/tests/query/bucket-facade-rollback-oracle.property.test.ts`, + `bucket-facade`, + `rollback-fixed`, + ], + [ + `db/tests/query/cold-join-reconciliation-oracle.test.ts`, + `cold-join`, + `equality-predicate reconciliation-fixed compound-fixed`, + ], +] // The remaining query oracles const otherProperties: ReadonlyArray = [ @@ -124,6 +140,10 @@ const otherProperties: ReadonlyArray = [ `ir-identity.output-denotation-fixed`, `ir-identity.operator-code-outcome`, `ir-identity.operator-code-wide`, + `ordered-loader.request-authority-substitution`, + `ordered-loader.request-authority-fixed`, + `ordered-loader.retirement-receiver-fixed`, + `ordered-loader.retained-lease`, ] const otherOwners: ReadonlyArray = [ [ @@ -301,6 +321,26 @@ const otherOwners: ReadonlyArray = [ `ir-identity`, `operator-code-wide`, ], + [ + `db/tests/query/ordered-source-loader-state-oracle.test.ts`, + `ordered-loader`, + `request-authority-substitution`, + ], + [ + `db/tests/query/ordered-source-loader-state-oracle.test.ts`, + `ordered-loader`, + `request-authority-fixed`, + ], + [ + `db/tests/query/ordered-source-loader-state-oracle.test.ts`, + `ordered-loader`, + `retirement-receiver-fixed`, + ], + [ + `db/tests/query/ordered-source-loader-state-oracle.test.ts`, + `ordered-loader`, + `retained-lease`, + ], ] export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/query/ordered-source-loader-state-model-oracle.ts b/packages/db/tests/query/ordered-source-loader-state-model-oracle.ts new file mode 100644 index 0000000000..3839b4ff85 --- /dev/null +++ b/packages/db/tests/query/ordered-source-loader-state-model-oracle.ts @@ -0,0 +1,31 @@ +/** + * The retirement-progress comparison of + * `ordered-source-loader-state-oracle.test.ts`, moved unchanged so that its + * checker calibration in `ordered-source-loader-state-model.test.ts` can read + * it. That oracle file states the law and its authority. This module + * registers no tests. + */ +import { rejectAt } from './ordered-comparison-oracle.js' + +// ARCHITECTURE's ordered repair and publication laws: a read started before +// an invalidation cannot pay it, and a later read must start before replacement +// publication. Event order comes only from observed source callbacks and +// adapter requests on one clock; a read that starts after the invalidation is +// in its epoch. Dispatch may be deferred while the public gate holds. +export function checkRetirementProgress( + invalidationAt: number | undefined, + startedAt: Array, + publicationAt = Number.POSITIVE_INFINITY, +): void { + if ( + invalidationAt !== undefined && + !startedAt.some((at) => at > invalidationAt && at < publicationAt) + ) { + rejectAt( + `ordered-source-loader-state-oracle.test.ts:550`, + new Error( + `retirement invalidation: no authoritative read followed the adapter invalidation before publication`, + ), + ) + } +} diff --git a/packages/db/tests/query/ordered-source-loader-state-model.test.ts b/packages/db/tests/query/ordered-source-loader-state-model.test.ts new file mode 100644 index 0000000000..fc6794ba47 --- /dev/null +++ b/packages/db/tests/query/ordered-source-loader-state-model.test.ts @@ -0,0 +1,22 @@ +// Checker calibration for ordered-source-loader-state-oracle.test.ts. It runs +// no production: a retirement start observed before the invalidation, or +// after public replacement, must fail the shared retirement-progress +// comparison. The law and the production comparisons are in that oracle file; +// the comparison is in ordered-source-loader-state-model-oracle.ts. +import { describe, expect, it } from 'vitest' +import { checkRetirementProgress } from './ordered-source-loader-state-model-oracle.js' + +describe(`Ordered source request authority`, () => { + // A start before the invalidation, or after public replacement, cannot + // discharge the debt. These adversarial observations calibrate the same + // relation used by both consumers and the request ledger. + it.each([`before-invalidation`, `after-publication`] as const)( + `rejects a retirement start observed %s`, + (fault) => { + const startedAt = fault === `before-invalidation` ? 1 : 4 + expect(() => checkRetirementProgress(2, [startedAt], 4)).toThrow( + `retirement invalidation`, + ) + }, + ) +}) 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 3219745482..8ea3f995b0 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 @@ -1,17 +1,12 @@ import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { OrderedLoaderInvariantError, OrderedSourceLoader, } from '../../src/query/live/ordered-source-loader.js' import { PropRef } from '../../src/query/ir.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty, oracleReplayRequested } from '../oracle-config.js' import { createCollection } from '../../src/collection/index.js' import { createLiveQueryCollection } from '../../src/query/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' @@ -23,6 +18,7 @@ import { recordedComparison, rejectAt, } from './ordered-comparison-oracle.js' +import { checkRetirementProgress } from './ordered-source-loader-state-model-oracle.js' import { createDeferred, createOrderByInfo, @@ -538,29 +534,6 @@ type AuthorityObservation = { requestErrorChecks: number } -// ARCHITECTURE's ordered repair and publication laws: a read started before -// an invalidation cannot pay it, and a later read must start before replacement -// publication. Event order comes only from observed source callbacks and -// adapter requests on one clock; a read that starts after the invalidation is -// in its epoch. Dispatch may be deferred while the public gate holds. -function checkRetirementProgress( - invalidationAt: number | undefined, - startedAt: Array, - publicationAt = Number.POSITIVE_INFINITY, -): void { - if ( - invalidationAt !== undefined && - !startedAt.some((at) => at > invalidationAt && at < publicationAt) - ) { - rejectAt( - `ordered-source-loader-state-oracle.test.ts:550`, - new Error( - `retirement invalidation: no authoritative read followed the adapter invalidation before publication`, - ), - ) - } -} - type RetirementReceiverCase = { indexed: boolean limit: 1 | 2 @@ -2442,14 +2415,12 @@ const ordinaryScenario = { const authorityProperty = `ordered-loader.request-authority` +type AuthorityRegression = { label: string; testCase: AuthorityCase } + // The authority law is checked by checkAuthorityCase: it runs one generated or // replayed history through the real loader and compares each observation with // the request ledger. The grammar is authorityCaseArbitrary, which the campaigns // and the calibration witnesses below read for coverage. -declareOracle({ - check: [checkAuthorityCase, checkRetirementProgress, checkRetirementReceiver], - grammar: [authorityCaseArbitrary], -}) describe(`Ordered source request authority`, () => { // Checker calibration changes only the recorded participant reason. Every @@ -2457,57 +2428,79 @@ describe(`Ordered source request authority`, () => { // Neither cleanup timing nor error text grants another value authority to // replace that failure. The existing generated authority grammar contains // these indexed/full-source, width, and throwing-release dimensions too. - it.each( - [false, true].flatMap((indexed) => - ([1, 2] as const).flatMap((limit) => - [false, true].flatMap((releaseFault) => - [false, true].map((requiresFullSource) => ({ - indexed, - limit, - releaseFault, - requiresFullSource, - })), - ), + const substitutionAxes = [false, true].flatMap((indexed) => + ([1, 2] as const).flatMap((limit) => + [false, true].flatMap((releaseFault) => + [false, true].map((requiresFullSource) => ({ + indexed, + limit, + releaseFault, + requiresFullSource, + })), ), ), - )( - `rejects substituted participant errors across current failures: %j`, - async (axes) => { - oracleRoute(`case`) - const testCase: AuthorityCase = { - ...ordinaryScenario, - ...axes, - settledFirst: false, - syncRepair: false, - history: [{ type: `settle`, pick: 0, ok: false }], - } - const genuine = await checkAuthorityCase(testCase) - if (axes.releaseFault) expect(genuine.releaseFaults).toBeGreaterThan(0) - for (const replacement of [ - new Error(`release 0 failed`), - new Error(`another request failed`), - `release non-Error failure`, - ]) { - let substitutions = 0 - try { - await expect( - checkAuthorityCase(testCase, undefined, () => { - substitutions++ - return replacement - }), - ).rejects.toThrow(`error identity`) - } finally { - expect(substitutions).toBeGreaterThan(0) - } + ) + async function runSubstitutedParticipantErrors( + axes: (typeof substitutionAxes)[number], + ): Promise { + const testCase: AuthorityCase = { + ...ordinaryScenario, + ...axes, + settledFirst: false, + syncRepair: false, + history: [{ type: `settle`, pick: 0, ok: false }], + } + const genuine = await checkAuthorityCase(testCase) + if (axes.releaseFault) expect(genuine.releaseFaults).toBeGreaterThan(0) + for (const replacement of [ + new Error(`release 0 failed`), + new Error(`another request failed`), + `release non-Error failure`, + ]) { + let substitutions = 0 + try { + await expect( + checkAuthorityCase(testCase, undefined, () => { + substitutions++ + return replacement + }), + ).rejects.toThrow(`error identity`) + } finally { + expect(substitutions).toBeGreaterThan(0) } + } + } + oracleProperty({ + id: `ordered-loader.request-authority-substitution`, + law: { + statement: `Neither cleanup timing nor error text grants another value authority to replace a current request failure.`, + authority: `query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery; Atomic window publication.`, + limits: `One legal current-failure history over the indexed/full-source, width, throwing-release, and full-source-requirement axes, each with three substituted participant reasons.`, }, - ) + 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', + ], + histories: substitutionAxes, + label: (axes) => + `rejects substituted participant errors across current failures: ${JSON.stringify(axes)}`, + resolve: (axes) => ({ axes }), + model: (plan, observations) => recordedComparison(plan, observations), + driver: (plan, observe) => + observeComparison(plan, observe, () => + runSubstitutedParticipantErrors(plan.axes), + ), + check: (_plan, _observations, verdict) => verdict, + }) // 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 campaign replay)`, async () => { - oracleRoute(`fixed history`) - await checkAuthorityCase({ + const supersededTieRegression: AuthorityRegression = { + label: `recovers a superseded tie that fails after its repair finished (generated campaign replay)`, + testCase: { indexed: true, limit: 1, settledFirst: false, @@ -2523,8 +2516,8 @@ describe(`Ordered source request authority`, () => { { type: `invalidate`, react: false }, { type: `settle`, pick: 0, ok: false }, ], - }) - }) + }, + } // Traces the generated campaign found, kept as fixed regressions. Each ran // through the same laws and failed before its repair: @@ -2568,440 +2561,439 @@ describe(`Ordered source request authority`, () => { // 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`, - testCase: { - indexed: true, - limit: 1, - settledFirst: true, - releaseFault: true, - syncRepair: true, - ...ordinaryScenario, - history: [ - { type: `invalidate`, react: true }, - { type: `invalidateThenFailOldest` }, - { type: `move`, delta: 0 }, - { type: `settleNewest` }, - ], + const campaignRegressions: Array = ( + [ + { + name: `a throwing release during a window move that resumes a repair`, + testCase: { + indexed: true, + limit: 1, + settledFirst: true, + releaseFault: true, + syncRepair: true, + ...ordinaryScenario, + history: [ + { type: `invalidate`, react: true }, + { type: `invalidateThenFailOldest` }, + { type: `move`, delta: 0 }, + { type: `settleNewest` }, + ], + }, }, - }, - { - name: `a repair that answers at once behind an older tie`, - testCase: { - indexed: true, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: true, - ...ordinaryScenario, - history: [ - { type: `settleNewest` }, - { type: `invalidate`, react: true }, - { type: `invalidate`, react: true }, - { type: `invalidate`, react: false }, - ], + { + name: `a repair that answers at once behind an older tie`, + testCase: { + indexed: true, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: true, + ...ordinaryScenario, + history: [ + { type: `settleNewest` }, + { type: `invalidate`, react: true }, + { type: `invalidate`, react: true }, + { type: `invalidate`, react: false }, + ], + }, }, - }, - { - name: `a repair prefix that settles after a replay started its replacement`, - testCase: { - indexed: true, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - history: [ - { type: `truncate` }, - { type: `invalidate`, react: false }, - { type: `move`, delta: -1 }, - { type: `invalidate`, react: false }, - ], + { + name: `a repair prefix that settles after a replay started its replacement`, + testCase: { + indexed: true, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + history: [ + { type: `truncate` }, + { type: `invalidate`, react: false }, + { type: `move`, delta: -1 }, + { type: `invalidate`, react: false }, + ], + }, }, - }, - { - name: `an optimistic refill inside a running repair chain`, - testCase: { - indexed: false, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - needsMore: false, - history: [ - { type: `settleNewest` }, - { type: `settleNewest` }, - { type: `invalidate`, react: true }, - { type: `settle`, pick: 0, ok: true }, - { type: `optimisticDeparture` }, - ], + { + name: `an optimistic refill inside a running repair chain`, + testCase: { + indexed: false, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + needsMore: false, + history: [ + { type: `settleNewest` }, + { type: `settleNewest` }, + { type: `invalidate`, react: true }, + { type: `settle`, pick: 0, ok: true }, + { type: `optimisticDeparture` }, + ], + }, }, - }, - { - name: `a replay after an explicit retry whose release throws`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: true, - syncRepair: false, - ...ordinaryScenario, - history: [ - { type: `settleNewest` }, - { type: `settle`, pick: 0, ok: false }, - { type: `move`, delta: -1 }, - { type: `truncate` }, - { type: `replaySucceeds` }, - ], + { + name: `a replay after an explicit retry whose release throws`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: true, + syncRepair: false, + ...ordinaryScenario, + history: [ + { type: `settleNewest` }, + { type: `settle`, pick: 0, ok: false }, + { type: `move`, delta: -1 }, + { type: `truncate` }, + { type: `replaySucceeds` }, + ], + }, }, - }, - { - name: `a shrunk window's prefix deferred by joined work inside a repair`, - testCase: { - indexed: false, - limit: 2, - settledFirst: true, - releaseFault: false, - syncRepair: true, - ...ordinaryScenario, - joined: true, - needsMore: false, - history: [ - { type: `settle`, pick: 0, ok: true }, - { type: `report`, react: true }, - { type: `joinedBusy` }, - { type: `move`, delta: -1 }, - { type: `settle`, pick: 0, ok: true }, - { type: `replaySucceeds` }, - ], + { + name: `a shrunk window's prefix deferred by joined work inside a repair`, + testCase: { + indexed: false, + limit: 2, + settledFirst: true, + releaseFault: false, + syncRepair: true, + ...ordinaryScenario, + joined: true, + needsMore: false, + history: [ + { type: `settle`, pick: 0, ok: true }, + { type: `report`, react: true }, + { type: `joinedBusy` }, + { type: `move`, delta: -1 }, + { type: `settle`, pick: 0, ok: true }, + { type: `replaySucceeds` }, + ], + }, }, - }, - { - name: `a repair tie from before a truncate that fails during the next repair`, - testCase: { - indexed: true, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: true, - ...ordinaryScenario, - history: [ - { type: `syncInvalidatingPage` }, - { type: `move`, delta: -1 }, - { type: `invalidate`, react: true }, - { type: `truncate` }, - { type: `replaySucceeds` }, - { type: `settle`, pick: 0, ok: true }, - { type: `syncInvalidatingPage` }, - { type: `invalidateThenFailOldest` }, - ], + { + name: `a repair tie from before a truncate that fails during the next repair`, + testCase: { + indexed: true, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: true, + ...ordinaryScenario, + history: [ + { type: `syncInvalidatingPage` }, + { type: `move`, delta: -1 }, + { type: `invalidate`, react: true }, + { type: `truncate` }, + { type: `replaySucceeds` }, + { type: `settle`, pick: 0, ok: true }, + { type: `syncInvalidatingPage` }, + { type: `invalidateThenFailOldest` }, + ], + }, }, - }, - { - name: `a superseded page that fails before a covering replay settles`, - testCase: { - indexed: true, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - history: [ - { type: `settleNewest` }, - { type: `truncate` }, - { type: `move`, delta: 0 }, - { type: `invalidate`, react: false }, - { type: `move`, delta: 0 }, - { type: `settle`, pick: 1, ok: false }, - { type: `settleNewest` }, - { type: `replaySucceeds` }, - ], + { + name: `a superseded page that fails before a covering replay settles`, + testCase: { + indexed: true, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + history: [ + { type: `settleNewest` }, + { type: `truncate` }, + { type: `move`, delta: 0 }, + { type: `invalidate`, react: false }, + { type: `move`, delta: 0 }, + { type: `settle`, pick: 1, ok: false }, + { type: `settleNewest` }, + { type: `replaySucceeds` }, + ], + }, }, - }, - { - name: `a repair prefix that settles after full-source coverage`, - testCase: { - indexed: false, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - needsMore: false, - history: [ - { type: `settle`, pick: 0, ok: true }, - { type: `truncate` }, - { type: `sourceDelete`, react: false }, - { type: `move`, delta: -1 }, - { type: `failOldest` }, - { type: `replaySucceeds` }, - { type: `settleNewest` }, - ], + { + name: `a repair prefix that settles after full-source coverage`, + testCase: { + indexed: false, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + needsMore: false, + history: [ + { type: `settle`, pick: 0, ok: true }, + { type: `truncate` }, + { type: `sourceDelete`, react: false }, + { type: `move`, delta: -1 }, + { type: `failOldest` }, + { type: `replaySucceeds` }, + { type: `settleNewest` }, + ], + }, }, - }, - { - name: `a backoff retry whose first release starts a truncate`, - testCase: { - indexed: false, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - retryTimer: true, - releaseTruncates: true, - history: [ - { type: `settleNewest` }, - { type: `report`, react: false }, - { type: `joinedIdle` }, - { type: `failOldest` }, - { type: `invalidateThenFailOldest` }, - { type: `timer` }, - { type: `replaySucceeds` }, - ], + { + name: `a backoff retry whose first release starts a truncate`, + testCase: { + indexed: false, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + retryTimer: true, + releaseTruncates: true, + history: [ + { type: `settleNewest` }, + { type: `report`, react: false }, + { type: `joinedIdle` }, + { type: `failOldest` }, + { type: `invalidateThenFailOldest` }, + { type: `timer` }, + { type: `replaySucceeds` }, + ], + }, }, - }, - { - name: `a full-source transport that fails after a replay covered the source`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - history: [ - { type: `truncate` }, - { type: `move`, delta: 0 }, - { type: `settleNewest` }, - { type: `settleNewest` }, - { type: `replaySucceeds` }, - { type: `settle`, pick: 1, ok: false }, - ], + { + name: `a full-source transport that fails after a replay covered the source`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + history: [ + { type: `truncate` }, + { type: `move`, delta: 0 }, + { type: `settleNewest` }, + { type: `settleNewest` }, + { type: `replaySucceeds` }, + { type: `settle`, pick: 1, ok: false }, + ], + }, }, - }, - { - name: `a repair prefix that fails after a replay finished its chain`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - needsMore: false, - history: [ - { type: `report`, react: false }, - { type: `settleNewest` }, - { type: `truncate` }, - { type: `move`, delta: -1 }, - { type: `replaySucceeds` }, - { type: `failOldest` }, - ], + { + name: `a repair prefix that fails after a replay finished its chain`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + needsMore: false, + history: [ + { type: `report`, react: false }, + { type: `settleNewest` }, + { type: `truncate` }, + { type: `move`, delta: -1 }, + { type: `replaySucceeds` }, + { type: `failOldest` }, + ], + }, }, - }, - { - name: `a release that runs the graph during another request's settlement`, - testCase: { - indexed: false, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - releaseReenters: true, - history: [ - { type: `truncate` }, - { type: `replaySucceeds` }, - { type: `optimisticDeparture` }, - { type: `joinedBusy` }, - ], + { + name: `a release that runs the graph during another request's settlement`, + testCase: { + indexed: false, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + releaseReenters: true, + history: [ + { type: `truncate` }, + { type: `replaySucceeds` }, + { type: `optimisticDeparture` }, + { type: `joinedBusy` }, + ], + }, }, - }, - { - name: `a superseded failure before a truncate whose replay covers the source`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - releaseTruncates: true, - releaseReenters: true, - history: [ - { type: `invalidate`, react: true }, - { type: `failOldest` }, - { type: `replayFails` }, - ], + { + name: `a superseded failure before a truncate whose replay covers the source`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + releaseTruncates: true, + releaseReenters: true, + history: [ + { type: `invalidate`, react: true }, + { type: `failOldest` }, + { type: `replayFails` }, + ], + }, }, - }, - { - name: `a superseded failure whose recovery read throws while it starts`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - needsMore: false, - history: [ - { type: `report`, react: true }, - { type: `settleNewest` }, - { type: `settle`, pick: 7, ok: true }, - { type: `failOldestThrowingRecovery` }, - { type: `pass` }, - ], + { + name: `a superseded failure whose recovery read throws while it starts`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + needsMore: false, + history: [ + { type: `report`, react: true }, + { type: `settleNewest` }, + { type: `settle`, pick: 7, ok: true }, + { type: `failOldestThrowingRecovery` }, + { type: `pass` }, + ], + }, }, - }, - { - name: `a page whose own delivery invalidates ordering`, - testCase: { - indexed: true, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - history: [ - { type: `settle`, pick: 0, ok: true }, - { type: `syncInvalidatingPage` }, - { type: `settleNewest` }, - { type: `pass` }, - { type: `settleNewest` }, - { type: `settleNewest` }, - { type: `settle`, pick: 3, ok: true }, - ], + { + name: `a page whose own delivery invalidates ordering`, + testCase: { + indexed: true, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + history: [ + { type: `settle`, pick: 0, ok: true }, + { type: `syncInvalidatingPage` }, + { type: `settleNewest` }, + { type: `pass` }, + { type: `settleNewest` }, + { type: `settleNewest` }, + { type: `settle`, pick: 3, ok: true }, + ], + }, }, - }, - { - // The first page and its tie settle, then joined work starts. A report - // over the moved end stages only that end's tie, and pending joined - // work defers it. The pass after the joined work finishes must read it; - // a loader that dropped the staged tie read nothing. - name: `a staged end tie that waits for joined work`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - joined: true, - snapshotRows: 3, - needsMore: false, - history: [ - { type: `settle`, pick: 0, ok: true }, - { type: `settleNewest` }, - { type: `joinedBusy` }, - { type: `report`, react: true }, - { type: `joinedIdle` }, - ], + { + // The first page and its tie settle, then joined work starts. A report + // over the moved end stages only that end's tie, and pending joined + // work defers it. The pass after the joined work finishes must read it; + // a loader that dropped the staged tie read nothing. + name: `a staged end tie that waits for joined work`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + joined: true, + snapshotRows: 3, + needsMore: false, + history: [ + { type: `settle`, pick: 0, ok: true }, + { type: `settleNewest` }, + { type: `joinedBusy` }, + { type: `report`, react: true }, + { type: `joinedIdle` }, + ], + }, }, - }, - { - // A truncate after a deferred unproven report hands the window to its - // replay, so the pass after the joined work owes no repair or tie. - name: `an unproven report whose deferred pass a truncate replaces`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - joined: true, - snapshotRows: 3, - needsMore: false, - history: [ - { type: `joinedBusy` }, - { type: `settle`, pick: 0, ok: true }, - { type: `report`, react: true }, - { type: `truncate` }, - { type: `joinedIdle` }, - ], + { + // A truncate after a deferred unproven report hands the window to its + // replay, so the pass after the joined work owes no repair or tie. + name: `an unproven report whose deferred pass a truncate replaces`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + joined: true, + snapshotRows: 3, + needsMore: false, + history: [ + { type: `joinedBusy` }, + { type: `settle`, pick: 0, ok: true }, + { type: `report`, react: true }, + { type: `truncate` }, + { type: `joinedIdle` }, + ], + }, }, - }, - { - // Pending joined work defers the pass after an unproven report, so the - // repair or tie that the report owes waits for that work. The pass that - // runs when it finishes must start it. A random campaign found the law - // judging the deferred pass instead. - name: `an unproven report while joined work defers loading`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - joined: true, - snapshotRows: 3, - needsMore: false, - history: [ - { type: `joinedBusy` }, - { type: `settle`, pick: 0, ok: true }, - { type: `settleNewest` }, - { type: `report`, react: true }, - { type: `joinedIdle` }, - ], + { + // Pending joined work defers the pass after an unproven report, so the + // repair or tie that the report owes waits for that work. The pass that + // runs when it finishes must start it. A random campaign found the law + // judging the deferred pass instead. + name: `an unproven report while joined work defers loading`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + joined: true, + snapshotRows: 3, + needsMore: false, + history: [ + { type: `joinedBusy` }, + { type: `settle`, pick: 0, ok: true }, + { type: `settleNewest` }, + { type: `report`, react: true }, + { type: `joinedIdle` }, + ], + }, }, - }, - { - // The first page read only the window, and the loader's tie request - // read a value past it, not the page's end. An installed row past the - // page can come from another request, so it proves nothing, and the - // report must repair. Ignoring it kept a window that missed a row tied - // with a refetched page's end. - name: `a report over a first page that read only the window`, - testCase: { - indexed: false, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - snapshotRows: 3, - needsMore: false, - history: [ - { type: `settle`, pick: 0, ok: true }, - { type: `settle`, pick: 0, ok: true }, - { type: `report`, react: true }, - ], + { + // The first page read only the window, and the loader's tie request + // read a value past it, not the page's end. An installed row past the + // page can come from another request, so it proves nothing, and the + // report must repair. Ignoring it kept a window that missed a row tied + // with a refetched page's end. + name: `a report over a first page that read only the window`, + testCase: { + indexed: false, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + snapshotRows: 3, + needsMore: false, + history: [ + { type: `settle`, pick: 0, ok: true }, + { type: `settle`, pick: 0, ok: true }, + { type: `report`, react: true }, + ], + }, }, - }, - { - // Without the settled check, a report before the first page settled - // forced a full-source read. - name: `a report before any request settles`, - testCase: { - indexed: false, - limit: 1, - settledFirst: false, - releaseFault: false, - syncRepair: false, - ...ordinaryScenario, - history: [{ type: `report`, react: true }], + { + // Without the settled check, a report before the first page settled + // forced a full-source read. + name: `a report before any request settles`, + testCase: { + indexed: false, + limit: 1, + settledFirst: false, + releaseFault: false, + syncRepair: false, + ...ordinaryScenario, + history: [{ type: `report`, react: true }], + }, }, - }, - ] satisfies Array<{ name: string; testCase: AuthorityCase }>)( - `keeps request authority for $name (generated campaign replay)`, - async ({ testCase }) => { - oracleRoute(`case`) - await checkAuthorityCase(testCase) - }, - ) + ] satisfies Array<{ name: string; testCase: AuthorityCase }> + ).map(({ name, testCase }) => ({ + label: `keeps request authority for ${name} (generated campaign replay)`, + testCase, + })) // A replay's bookkeeping call is not a pass over the graph. In this trace, // joined work staged a repair refill, then replay settlement retired older // 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({ + const deferredRefillRegression: AuthorityRegression = { + label: `keeps a deferred repair refill in its chain after replay retirement`, + testCase: { indexed: false, limit: 1, settledFirst: true, @@ -3018,7 +3010,40 @@ describe(`Ordered source request authority`, () => { { type: `joinedBusy` }, { type: `settleNewest` }, ], - }) + }, + } + + // The fixed regressions above use the authority helper directly. The + // generated authority grammar maps its values without an unmapper, so it + // cannot take them back; each replays through `checkAuthorityCase` under its + // old test name. + oracleProperty({ + id: `ordered-loader.request-authority-fixed`, + law: { + statement: `Each ordered-source request keeps exactly the authority the request ledger grants it through settlement, invalidation, replay, and recovery.`, + authority: `query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery; Atomic window publication.`, + limits: `Named regressions that the generated campaigns found, each replayed through checkAuthorityCase.`, + }, + 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', + ], + histories: [ + supersededTieRegression, + ...campaignRegressions, + deferredRefillRegression, + ], + label: ({ label }) => label, + resolve: (regression) => ({ regression }), + model: (plan, observations) => recordedComparison(plan, observations), + driver: (plan, observe) => + observeComparison(plan, observe, () => + checkAuthorityCase(plan.regression.testCase), + ), + check: (_plan, _observations, verdict) => verdict, }) // The broad grammar already generated the source trace. This directed @@ -3448,7 +3473,7 @@ describe(`Ordered source request authority`, () => { // Fixed histories force a held answer in each real consumer. They use the // same model and comparison as the generated receiving campaign, and keep // mutant failure independent for the two publication paths. - it.each([ + const heldReceiverCases = [ [`collection`, `move`], [`collection-window`, `move`], [`effect`, `move`], @@ -3457,74 +3482,126 @@ describe(`Ordered source request authority`, () => { [`collection`, `delete-tie`], [`collection`, `evict`], [`collection`, `evict-tie`], - ] as const)( - `receives retirement invalidation in a held %s replay (unload: %s)`, - async (kind, unloadChange) => { - oracleRoute(`case`) - await checkRetirementReceiver( - { - indexed: false, - limit: - unloadChange === `delete` || unloadChange === `delete-tie` ? 2 : 1, - synchronous: false, - offset: 0, - holdReplacement: true, - kind, - unloadChange, - boundaryTie: - unloadChange === `delete-tie` - ? `before` - : unloadChange === `delete-boundary` || - unloadChange === `evict-tie` - ? `after` - : `none`, - }, - () => {}, - ) - }, - ) - - // A start before the invalidation, or after public replacement, cannot - // discharge the debt. These adversarial observations calibrate the same - // relation used by both consumers and the request ledger. - 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`, - ) + ] as const + // The scenario of one held receiving case, as the old fixed test built it. + const heldReceiverScenario = ([ + kind, + unloadChange, + ]: (typeof heldReceiverCases)[number]): RetirementReceiverCase => ({ + indexed: false, + limit: unloadChange === `delete` || unloadChange === `delete-tie` ? 2 : 1, + synchronous: false, + offset: 0, + holdReplacement: true, + kind, + unloadChange, + boundaryTie: + unloadChange === `delete-tie` + ? `before` + : unloadChange === `delete-boundary` || unloadChange === `evict-tie` + ? `after` + : `none`, + }) + oracleProperty({ + id: `ordered-loader.retirement-receiver-fixed`, + law: { + statement: `repairs retirement invalidation through a real subscription`, + authority: `query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery; Atomic window publication.`, + limits: `One held replacement answer in each real consumer and unload change, through the generated receiving campaign's checkRetirementReceiver.`, }, - ) + 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', + ], + histories: heldReceiverCases, + label: ([kind, unloadChange]) => + `receives retirement invalidation in a held ${kind} replay (unload: ${unloadChange})`, + resolve: (cell) => ({ scenario: heldReceiverScenario(cell) }), + model: (plan, observations) => recordedComparison(plan, observations), + driver: (plan, observe) => + observeComparison(plan, observe, () => + checkRetirementReceiver(plan.scenario, () => {}), + ), + check: (_plan, _observations, verdict) => verdict, + }) // Calibrate the shared no-leak judgment with a retained-lease observation. // The real release callbacks still run; only their recorded result changes // 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, - limit: 1, - settledFirst: true, - releaseFault: false, - syncRepair: false, - history: [{ type: `invalidate`, react: true }], - } - await checkAuthorityCase(testCase) - let substitutions = 0 - try { - await expect( - checkAuthorityCase(testCase, undefined, undefined, (released) => { - if (released) substitutions++ - return false - }), - ).rejects.toThrow(`no leak`) - } finally { - expect(substitutions).toBeGreaterThan(0) - } + const retainedLeaseCase: AuthorityCase = { + ...ordinaryScenario, + indexed: true, + limit: 1, + settledFirst: true, + releaseFault: false, + syncRepair: false, + history: [{ type: `invalidate`, react: true }], + } + oracleProperty({ + id: `ordered-loader.retained-lease`, + law: { + statement: `A covering repair leaves no successful acquisition held.`, + authority: `query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery; Atomic window publication.`, + limits: `One indexed history whose invalidation reaction issues a covering repair; its calibration changes only the recorded release result.`, + }, + 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', + ], + histories: [ + { + label: `rejects retained successful acquisition observations after a covering repair`, + testCase: retainedLeaseCase, + }, + ], + label: ({ label }) => label, + resolve: (regression) => ({ regression }), + model: (plan, observations) => recordedComparison(plan, observations), + driver: (plan, observe) => + observeComparison(plan, observe, () => + checkAuthorityCase(plan.regression.testCase), + ), + check: (_plan, _observations, verdict) => verdict, + calibrations: [ + { + name: `rejects retained successful acquisition observations after a covering repair`, + law: `retained successful acquisition`, + driver: (plan: { regression: AuthorityRegression }, observe) => + observeComparison(plan, observe, async () => { + let substitutions = 0 + try { + await checkAuthorityCase( + plan.regression.testCase, + undefined, + undefined, + (released) => { + if (released) substitutions++ + return false + }, + ) + } catch (error) { + // Exactly the old matcher: a rejection whose message names the + // no-leak law, after at least one substituted release. + if ( + substitutions > 0 && + error instanceof Error && + error.message.includes(`no leak`) + ) + rejectAt(`retained successful acquisition`, error) + throw error + } + if (substitutions === 0) + throw new Error(`no release result was substituted`) + }), + }, + ], }) describe(`holds, retires, and recovers requests under generated settlement orders`, () => { From 4c728050dc95c9a2a26a6eaa9d3d3a272d240a23 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:14:53 -0700 Subject: [PATCH 074/116] test(db): turn the cross-formulation replay calibrations into native calibrations registerIncludesProperty forwards calibrations to its generated campaign. Co-authored-by: Isaac --- ...-cross-formulation-oracle.property.test.ts | 237 +++++++----------- .../query/includes-registration-oracle.ts | 10 +- 2 files changed, 97 insertions(+), 150 deletions(-) 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 b9bcc78905..8e40606ed2 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 @@ -1,11 +1,12 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, test } from 'vitest' +import { describe, expect } from 'vitest' import { Temporal } from 'temporal-polyfill' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from '../oracle-assertion-adapter.js' import { createCollection } from '../../src/collection/index.js' import { createFilterFunctionFromExpression } from '../../src/collection/change-events.js' @@ -20,18 +21,8 @@ import { queryOnce, toArray, } from '../../src/query/index.js' -import { - oracleCalibrationTest, - oracleFixedSeed, - oracleProperty, - oraclePropertyOptions, - oracleReplayRequested, - oracleRoute, - oracleRuns, - readOracleRunConfig, -} from '../oracle-config.js' +import { oracleProperty, readOracleRunConfig } from '../oracle-config.js' import { flushPromises } from '../utils.js' -import { runIncludesCalibration } from './includes-calibration.js' import { registerIncludesProperty } from './includes-registration-oracle.js' import { assertTlpPartitions, @@ -1450,141 +1441,8 @@ describe(`includes cross-formulation oracle`, () => { check: forwardAssertionJudgment, }) - // 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: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 - // tests/query/includes-cross-formulation-oracle.property.test.ts - // -t 'includes-cross-formulation\\.equivalence > random seed' - // Expected: one main-property failure at cross-formulation checkpoint 0. - oracleCalibrationTest( - test, - `includes-cross-formulation.equivalence`, - `missing-parent`, - )( - `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. - const property = fc.asyncProperty(scenarioArbitrary, async (scenario) => { - await expectFormulationsEquivalent(scenario, (rows) => rows.slice(1)) - }) - const propertyId = `includes-cross-formulation.equivalence` - const directReplay = oracleReplayRequested() - const failure = await runIncludesCalibration( - property, - directReplay - ? { ...oraclePropertyOptions(8, propertyId), endOnFailure: true } - : oracleFixedSeed(1_658_004, { numRuns: oracleRuns(8) }), - ) - expect(failure.failed).toBe(true) - expect(String(failure.errorInstance)).toContain( - `cross-formulation checkpoint`, - ) - expect(String(failure.errorInstance)).toContain(`checkpoint":0`) - if (directReplay) { - 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:0:0:0:0:0:0:0`, - ) - const replayResult = await runIncludesCalibration(property, { - seed: failure.seed, - path: failure.counterexamplePath, - numRuns: 1, - endOnFailure: true, - }) - expect(replayResult.failed).toBe(true) - 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: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 - // tests/query/includes-cross-formulation-oracle.property.test.ts - // -t 'includes-cross-formulation\\.ordered-window > random seed' - // Expected: one main-property failure at ordered-window checkpoint 0. - oracleCalibrationTest( - test, - `includes-cross-formulation.ordered-window`, - `extra-child`, - )( - `generated window rejects an extra production child and replays its checkpoint`, - async () => { - oracleRoute(`calibration`) - const property = fc.asyncProperty( - windowedScenarioArbitrary, - async (sample) => { - const { scenario, offset, limit } = sample - await expectWindowedIncludeMatches(scenario, offset, limit, (rows) => - rows.map((parent, index) => - index === 0 - ? { - ...parent, - children: [...parent.children, scenario.children[0]!], - } - : parent, - ), - ) - }, - ) - const propertyId = `includes-cross-formulation.ordered-window` - const directReplay = oracleReplayRequested() - const failure = await runIncludesCalibration( - property, - directReplay - ? { ...oraclePropertyOptions(12, propertyId), endOnFailure: true } - : oracleFixedSeed(1_658_005, { numRuns: oracleRuns(12) }), - ) - expect(failure.failed).toBe(true) - expect(String(failure.errorInstance)).toContain( - `ordered-window checkpoint`, - ) - expect(String(failure.errorInstance)).toContain(`checkpoint":0`) - if (directReplay) { - 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`, - ) - const replayResult = await runIncludesCalibration(property, { - seed: failure.seed, - path: failure.counterexamplePath, - numRuns: 1, - endOnFailure: true, - }) - expect(replayResult.failed).toBe(true) - expect(String(replayResult.errorInstance)).toContain( - `ordered-window checkpoint`, - ) - expect(String(replayResult.errorInstance)).toContain(`checkpoint":0`) - expect(replayResult.counterexample).toEqual(failure.counterexample) - }, - ) + const equivalenceStatement = `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a fixed seed` + const orderedWindowStatement = `matches recomputation for ordered offset and limit child windows for a fixed seed` const checkEquivalenceScenario = (scenario: CrossFormulationScenario) => expectFormulationsEquivalent( @@ -1603,6 +1461,39 @@ describe(`includes cross-formulation oracle`, () => { runs: 8, fixedSeed: 1658004, examples: scenarioExamples, + calibrations: [ + { + name: `generated equivalence rejects a missing production parent`, + law: equivalenceStatement, + // The former calibration's hostile observation: drop the first + // nested parent after the real nested query is captured. Exactly + // its old matcher: only a cross-formulation comparison failure at + // checkpoint 0 counts as the check rejecting it. + driver: (plan, observe) => + recordAssertionJudgment( + equivalenceStatement, + plan, + observe, + async () => { + try { + await expectFormulationsEquivalent(plan.history[0], (rows) => + rows.slice(1), + ) + } catch (error) { + if ( + error instanceof Error && + String(error).includes(`cross-formulation checkpoint`) && + String(error).includes(`checkpoint":0`) + ) + rejectAt(`missing-parent calibration`, error) + throw new Error( + `the missing-parent mutant failed outside the expected rejection: ${String(error)}`, + ) + } + }, + ), + }, + ], owns: [ `src/collection/change-events.ts#createFilterFunctionFromExpression`, `src/collection/index.ts#createCollection`, @@ -1610,7 +1501,7 @@ describe(`includes cross-formulation oracle`, () => { `src/query/query-once.ts#queryOnce`, ], }, - `agrees across nested includes, flat joins, per-parent queries, and TLP partitions for a fixed seed`, + equivalenceStatement, (...args: Parameters) => { return checkEquivalenceScenario(...args) }, @@ -1651,6 +1542,54 @@ describe(`includes cross-formulation oracle`, () => { runs: 12, fixedSeed: 1658005, examples: windowedExamples, + calibrations: [ + { + name: `generated window rejects an extra production child`, + law: orderedWindowStatement, + // The former calibration's hostile observation: append a source + // child to the first published parent. Exactly its old matcher: only + // an ordered-window comparison failure at checkpoint 0 counts as the + // check rejecting it. + driver: (plan, observe) => + recordAssertionJudgment( + orderedWindowStatement, + plan, + observe, + async () => { + const [{ scenario, offset, limit }] = plan.history + try { + await expectWindowedIncludeMatches( + scenario, + offset, + limit, + (rows) => + rows.map((parent, index) => + index === 0 + ? { + ...parent, + children: [ + ...parent.children, + scenario.children[0]!, + ], + } + : parent, + ), + ) + } catch (error) { + if ( + error instanceof Error && + String(error).includes(`ordered-window checkpoint`) && + String(error).includes(`checkpoint":0`) + ) + rejectAt(`extra-child calibration`, error) + throw new Error( + `the extra-child mutant failed outside the expected rejection: ${String(error)}`, + ) + } + }, + ), + }, + ], owns: [ `src/collection/change-events.ts#createFilterFunctionFromExpression`, `src/collection/index.ts#createCollection`, @@ -1658,7 +1597,7 @@ describe(`includes cross-formulation oracle`, () => { `src/query/query-once.ts#queryOnce`, ], }, - `matches recomputation for ordered offset and limit child windows for a fixed seed`, + orderedWindowStatement, (...args: Parameters) => { return checkWindowedScenario(...args) }, diff --git a/packages/db/tests/query/includes-registration-oracle.ts b/packages/db/tests/query/includes-registration-oracle.ts index 261494db39..5365aa6215 100644 --- a/packages/db/tests/query/includes-registration-oracle.ts +++ b/packages/db/tests/query/includes-registration-oracle.ts @@ -6,7 +6,7 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import type { GeneratedHistories } from '../oracle-config.js' +import type { Calibration, GeneratedHistories } from '../oracle-config.js' export type IncludesCampaign> = { property: string @@ -15,6 +15,12 @@ export type IncludesCampaign> = { examples?: ReadonlyArray /** The calling owner's production code, forwarded unchanged to oracleProperty. */ owns?: ReadonlyArray + /** + * Wrong answers for the generated campaign, forwarded unchanged to its + * oracleProperty. A mutant driver receives the same `{ history }` plan, and + * its law is the registration's statement. + */ + calibrations?: ReadonlyArray> beforeCampaign?: () => void afterCampaign?: () => void } @@ -63,6 +69,8 @@ export function registerIncludesProperty>( }, owns: campaign.owns, histories, + calibrations: + histories === generated ? campaign.calibrations : undefined, label: (history) => `retained history ${campaign.examples!.indexOf(history) + 1}`, resolve: (history) => ({ history }), From bc9860dbb0a942ad28c9f45a0d69005f9278fdcd Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:18:44 -0700 Subject: [PATCH 075/116] test(db): register virtual-field input and public-type cases through oracleProperty Move the input checker calibrations and capture controls to a new ordinary test file. The admission controls become native calibrations of one legal construction. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 25 + .../virtual-row-fields-input-oracle.test.ts | 534 +++++++----------- .../query/virtual-row-fields-input.test.ts | 236 ++++++++ .../virtual-row-fields-types-oracle.test.ts | 304 ++++++---- 4 files changed, 644 insertions(+), 455 deletions(-) create mode 100644 packages/db/tests/query/virtual-row-fields-input.test.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 52cd13905e..e31cdae77e 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -37,6 +37,16 @@ export const properties: ReadonlyArray = [ `includes-context-transport.compound-join`, `includes-context-transport.materialized-input`, `includes-context-transport.outer-join-keys`, + `virtual-row-fields-types.public-types`, + `virtual-row-fields-types.hostile-observations`, + `virtual-row-fields-types.construction-admission`, + `virtual-row-fields-effect.callback-delivery`, + `virtual-row-fields-effect.explicit-metadata`, + `virtual-row-fields-effect.boundary-membership`, + `virtual-row-fields-input.callback`, + `virtual-row-fields-input.direct-source`, + `virtual-row-fields-input.inline`, + `virtual-row-fields-input.compiler-cache`, ] export const owners: ReadonlyArray = [ @@ -90,4 +100,19 @@ export const owners: ReadonlyArray = [ `includes-context-transport`, `grammar compound-join materialized-input outer-join-keys`, ], + [ + `db/tests/query/virtual-row-fields-types-oracle.test.ts`, + `virtual-row-fields-types`, + `public-types hostile-observations construction-admission`, + ], + [ + `db/tests/query/virtual-row-fields-effect-oracle.test.ts`, + `virtual-row-fields-effect`, + `callback-delivery explicit-metadata boundary-membership`, + ], + [ + `db/tests/query/virtual-row-fields-input-oracle.test.ts`, + `virtual-row-fields-input`, + `callback direct-source inline compiler-cache`, + ], ] 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 ff529227fd..bbe9c6920f 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 @@ -29,28 +29,24 @@ * only selection enters no receiving campaign; a partial receiving selection * still owes the selected campaign's entire required matrix. */ -import { afterAll, expect, test } from 'vitest' -import { declareOracle } from '../oracle-declaration.js' -import { oracleRoute } from '../oracle-config.js' +import { afterAll, expect } from 'vitest' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { - expectedMetadataInput, - expectedMetadataRecord, - expectedSourceVirtualFields, - metadataCallbackHistories, metadataCallbackKinds, metadataCallbackReceiverHistories, metadataCompilerCacheHistories, metadataInlineHistories, - metadataInputViolations, - metadataOutputViolations, } from './virtual-row-fields-input-oracle.js' import { - captureMetadataInput, runInlineMetadataCallbackHistory, runMetadataCallbackHistory, runMetadataCompilerCacheHistory, } from './virtual-row-fields-input-driver.js' -import type { MetadataSourceFact } from './virtual-row-fields-input-oracle.js' const cuts = [`initial`, `held`, `settled`, `remote-next-use`] as const const receiverCuts = (receiver: string) => @@ -174,348 +170,208 @@ afterAll(() => { } } }) -declareOracle({ +// The former case body, unchanged. +async function judgeCallbackMetadata( + history: (typeof callbackCases)[number], +): Promise { + callbackEntered = true + callbackHistory++ + const report = await runMetadataCallbackHistory( + history, + history.path, + (cut) => + callbackHistoriesByState + .get( + `${history.kind}/${history.receiver}/${history.path}/${key(history)}/${cut}`, + )! + .add(callbackHistory), + history.receiver, + history.kind, + ) + expect(report.violations, JSON.stringify(report.violations[0])).toStrictEqual( + [], + ) +} + +const callbackLaw = `Functional select, where, and having callbacks receive authored metadata before they derive ordinary values, at every receiver cut.` + +oracleProperty({ + id: `virtual-row-fields-input.callback`, + law: { + statement: callbackLaw, + authority: `Reserved public row fields and the boundary-only metadata rule (maintainer decision, 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md: callback inputs and inline values keep their authored values, stated at the start of this owner.`, + limits: `The enumerated callback kind, receiver, path, shape, collision, and pending histories.`, + }, 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: [ - metadataCallbackHistories, - metadataCallbackKinds, - metadataCallbackReceiverHistories, - metadataInlineHistories, - metadataCompilerCacheHistories, + `src/collection/index.ts#createCollection`, + `src/query/compiler/index.ts#compileQuery`, + `src/query/effect.ts#createEffect`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, ], + histories: callbackCases, + label: (history) => + `receives callback metadata before deriving ordinary values: ${history.label}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(callbackLaw, plan, observe, () => + judgeCallbackMetadata(plan.history), + ), + check: forwardAssertionJudgment, }) +const directSourceCases = metadataCallbackKinds.flatMap((kind) => + ([`root`, `inline-child`, `inline-child-direct`, `effect`] as const).map( + (receiver) => ({ kind, receiver }), + ), +) -test.each(callbackCases)( - `receives callback metadata before deriving ordinary values: $label`, - async (history) => { - oracleRoute(`case`) - callbackEntered = true - callbackHistory++ - const report = await runMetadataCallbackHistory( - history, - history.path, - (cut) => - callbackHistoriesByState - .get( - `${history.kind}/${history.receiver}/${history.path}/${key(history)}/${cut}`, - )! - .add(callbackHistory), - history.receiver, - history.kind, - ) - expect( - report.violations, - JSON.stringify(report.violations[0]), - ).toStrictEqual([]) +// The former case body, unchanged. +async function judgeDirectSourceMetadata({ + kind, + receiver, +}: (typeof directSourceCases)[number]): Promise { + callbackEntered = true + callbackHistory++ + const report = await runMetadataCallbackHistory( + { + shape: `functional-direct`, + collision: `none`, + authoredPending: false, + }, + `direct-source`, + (cut) => + callbackHistoriesByState + .get(`${kind}/${receiver}/direct-source/${cut}`)! + .add(callbackHistory), + receiver, + kind, + ) + expect(report.violations).toStrictEqual([]) +} + +const directSourceLaw = `A callback that returns its source row directly receives that row's genuine source metadata before deriving ordinary values.` + +oracleProperty({ + id: `virtual-row-fields-input.direct-source`, + law: { + statement: directSourceLaw, + authority: `Reserved public row fields and the boundary-only metadata rule (maintainer decision, 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md: callback inputs and inline values keep their authored values, stated at the start of this owner.`, + limits: `The enumerated callback kind and receiver cells over one functional-direct source.`, }, -) -test.each( - metadataCallbackKinds.flatMap((kind) => - ([`root`, `inline-child`, `inline-child-direct`, `effect`] as const).map( - (receiver) => ({ kind, receiver }), + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/compiler/index.ts#compileQuery`, + `src/query/effect.ts#createEffect`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: directSourceCases, + label: (history) => + `receives direct source metadata before deriving ordinary values: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(directSourceLaw, plan, observe, () => + judgeDirectSourceMetadata(plan.history), ), - ), -)( - `receives direct source metadata before deriving ordinary values: %j`, - async ({ kind, receiver }) => { - oracleRoute(`case`) - callbackEntered = true - callbackHistory++ - const report = await runMetadataCallbackHistory( - { - shape: `functional-direct`, - collision: `none`, - authoredPending: false, - }, - `direct-source`, - (cut) => - callbackHistoriesByState - .get(`${kind}/${receiver}/direct-source/${cut}`)! - .add(callbackHistory), - receiver, - kind, - ) - expect(report.violations).toStrictEqual([]) - }, -) -test.each(inlineCases)( - `receives inline authored metadata or rejects compiled Collection input: $label`, - async (history) => { - oracleRoute(`case`) - inlineEntered = true - inlineHistory++ - const report = await runInlineMetadataCallbackHistory( - history, - history.form, - (cut) => - inlineHistoriesByState - .get(`${history.path}/${history.form}/${key(history)}/${cut}`)! - .add(inlineHistory), - history.path, - ) - expect( - report.violations, - JSON.stringify(report.violations[0]), - ).toStrictEqual([]) + check: forwardAssertionJudgment, +}) +// The former case body, unchanged. +async function judgeInlineMetadata( + history: (typeof inlineCases)[number], +): Promise { + inlineEntered = true + inlineHistory++ + const report = await runInlineMetadataCallbackHistory( + history, + history.form, + (cut) => + inlineHistoriesByState + .get(`${history.path}/${history.form}/${key(history)}/${cut}`)! + .add(inlineHistory), + history.path, + ) + expect(report.violations, JSON.stringify(report.violations[0])).toStrictEqual( + [], + ) +} + +const inlineLaw = `Inline include values keep their authored metadata at callback input, and a compiled Collection-valued input is rejected.` + +oracleProperty({ + id: `virtual-row-fields-input.inline`, + law: { + statement: inlineLaw, + authority: `Reserved public row fields and the boundary-only metadata rule (maintainer decision, 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md: callback inputs and inline values keep their authored values, stated at the start of this owner.`, + limits: `The enumerated inline path, form, shape, collision, and pending histories.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/compiler/index.ts#compileQuery`, + `src/query/effect.ts#createEffect`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: inlineCases, + label: (history) => + `receives inline authored metadata or rejects compiled Collection input: ${history.label}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(inlineLaw, plan, observe, () => + judgeInlineMetadata(plan.history), + ), + check: forwardAssertionJudgment, +}) // The initial compiler graph-drain cut is distinct from the Collection // lifecycle cuts above. Both orders must preserve the cache-transparency // relation. Independent source/projection judgments still check both executions; // raw output does not borrow an implicit Effect-root attribution policy. -test.each(compilerCases)( - `preserves metadata across compiler cache modes: $label`, - async (history) => { - oracleRoute(`case`) - compilerEntered = true - compilerHistory++ - const report = await runMetadataCompilerCacheHistory( - history, - history.order, - history.cache, - history.path, - (observation) => - compilerHistoriesByState - .get( - `${history.path}/${history.order}/${history.cache}/${key(history)}/${observation}`, - )! - .add(compilerHistory), - ) - expect( - report.violations, - JSON.stringify(report.violations[0]), - ).toStrictEqual([]) - }, -) - -const sourceFact: MetadataSourceFact = { - row: { id: 1, parentId: `group`, label: `before`, optional: undefined }, - sourceId: `genuine-source`, - pending: false, -} -// Controls alter observations, never source files. The allowed answer comes -// from the same judgment used for every real receiving history above. -test.each([ - `source-spoof`, - `source-stripped`, - `projection-stripped`, - `inline-stripped`, - `own-undefined-lost`, - `mixed-before-after`, - `late-capture-rewrite`, - `compiler-symbol`, -] as const)(`rejects a hostile callback-input observation: %s`, (fault) => { - oracleRoute(`case`) - const description = { - shape: `structured-spread`, - collision: `all`, - authoredPending: true, - } as const - const projected = { ...description, shape: `functional-spread` } as const - const direct = { - ...description, - shape: `functional-direct`, - collision: `none`, - } as const - const use = - fault === `projection-stripped` - ? projected - : fault.startsWith(`source-`) || fault === `mixed-before-after` - ? direct - : description - const expected = expectedMetadataInput(sourceFact, use) - const hostile = structuredClone(expected) - if (fault === `compiler-symbol`) - hostile.symbols.push(Symbol(`compiler-state`)) - if (fault === `source-spoof`) hostile.fields.$collectionId!.value = `spoof` - if (fault === `source-stripped` || fault === `inline-stripped`) - hostile.fields.$key = { present: false, own: false, value: undefined } - if (fault === `projection-stripped`) - hostile.fields.$key = { present: false, own: false, value: undefined } - if (fault === `own-undefined-lost`) delete hostile.ordinary.optional - const next = expectedMetadataInput( - { - ...sourceFact, - row: { ...sourceFact.row, label: `pending` }, - pending: true, - }, - use, +// The former case body, unchanged. +async function judgeCompilerCacheMetadata( + history: (typeof compilerCases)[number], +): Promise { + compilerEntered = true + compilerHistory++ + const report = await runMetadataCompilerCacheHistory( + history, + history.order, + history.cache, + history.path, + (observation) => + compilerHistoriesByState + .get( + `${history.path}/${history.order}/${history.cache}/${key(history)}/${observation}`, + )! + .add(compilerHistory), ) - if (fault === `mixed-before-after`) hostile.fields = next.fields - if (fault === `late-capture-rewrite`) hostile.ordinary.label = `later` - expect(metadataInputViolations(expected, [expected])).toStrictEqual([]) - expect(metadataInputViolations(hostile, [expected, next])).not.toStrictEqual( + expect(report.violations, JSON.stringify(report.violations[0])).toStrictEqual( [], ) -}) -test.each([ - `omitted-peer`, - `duplicate-peer`, - `wrong-key-kind`, - `intermediate-sanitizer`, - `missing-derived-field`, -] as const)(`rejects a hostile derived ordinary output: %s`, (fault) => { - oracleRoute(`calibration`) - const description = { - shape: `functional-projection`, - collision: `$collectionId`, - authoredPending: false, - } as const - const expected = [ - sourceFact, - { ...sourceFact, row: { ...sourceFact.row, id: `1` } }, - ].map((fact) => ({ - id: fact.row.id, - observed: expectedMetadataInput(fact, description), - })) - const hostile = structuredClone(expected) - if (fault === `omitted-peer`) hostile.pop() - if (fault === `duplicate-peer`) hostile.push(structuredClone(hostile[1]!)) - if (fault === `wrong-key-kind`) hostile[1]!.id = 1 - if (fault === `intermediate-sanitizer`) - hostile[0]!.observed.fields.$collectionId = { - present: false, - own: false, - value: undefined, - } - if (fault === `missing-derived-field`) - Reflect.deleteProperty(hostile[0]!, `observed`) - expect(metadataOutputViolations(expected, expected)).toStrictEqual([]) - 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`, - authoredPending: false, - } as const - const value = { - ...sourceFact.row, - ...expectedSourceVirtualFields(sourceFact), - } - const capture = captureMetadataInput(value) - value.label = `later` - expect( - metadataInputViolations(capture, [ - expectedMetadataInput(sourceFact, description), - ]), - ).toStrictEqual([]) -}) -test(`accepts readable inherited source metadata at callback input`, () => { - oracleRoute(`fixed history`) - const description = { - shape: `functional-direct`, - collision: `none`, - authoredPending: false, - } as const - const value: object = Object.assign( - Object.create(expectedSourceVirtualFields(sourceFact)), - sourceFact.row, - ) - const capture = captureMetadataInput(value) - const expected = expectedMetadataInput(sourceFact, description) - expect(metadataInputViolations(capture, [expected])).toStrictEqual([]) - expect( - metadataOutputViolations( - [{ id: 1, observed: capture }], - [{ id: 1, observed: expected }], - ), - ).toStrictEqual([]) -}) -test(`copies nested callback values before later child objects change`, () => { - oracleRoute(`fixed history`) - const child = { - ...sourceFact.row, - ...expectedSourceVirtualFields(sourceFact), - } - const capture = captureMetadataInput({ id: `parent`, children: [child] }) - const expected = expectedMetadataRecord({ - id: `parent`, - children: [ - { ...sourceFact.row, ...expectedSourceVirtualFields(sourceFact) }, - ], - }) - child.label = `later` - expect(metadataInputViolations(capture, [expected])).toStrictEqual([]) -}) -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`, - authoredPending: false, - } as const - const selected = expectedMetadataInput(sourceFact, description) - const child: Record = { - ...selected.ordinary, - ...Object.fromEntries( - Object.entries(selected.fields) - .filter(([, field]) => field.present) - .map(([name, field]) => [name, field.value]), - ), - } - const expected = expectedMetadataRecord({ id: `parent`, children: [child] }) - const field = fault === `selected-virtual` ? `$collectionId` : `optional` - const inherited: object = Object.assign( - Object.create({ [field]: child[field] }), - Object.fromEntries( - Object.entries(child).filter(([name]) => name !== field), - ), - ) - const actual = captureMetadataInput({ id: `parent`, children: [inherited] }) - expect( - metadataInputViolations( - captureMetadataInput({ id: `parent`, children: [child] }), - [expected], - ), - ).toStrictEqual([]) - expect(metadataInputViolations(actual, [expected])).not.toStrictEqual([]) - expect( - metadataOutputViolations( - [{ id: `parent`, observed: actual }], - [{ id: `parent`, observed: expected }], - ), - ).not.toStrictEqual([]) - }, -) +} -// The inline array is public data too. An extra ordinary or reserved own field -// must survive capture so both judgments can reject the unauthored shape. -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`, - authoredPending: false, - }).ordinary - const authored = { id: `parent`, children: [child] } - const expected = expectedMetadataRecord(authored) - const value = structuredClone(authored) - const lawful = captureMetadataInput(value) - expect(metadataInputViolations(lawful, [expected])).toStrictEqual([]) - Object.assign(value.children, { [field]: `spoof` }) - const hostile = captureMetadataInput(value) - const rejected = { - input: metadataInputViolations(hostile, [expected]).length > 0, - output: - metadataOutputViolations( - [{ id: `parent`, observed: hostile }], - [{ id: `parent`, observed: expected }], - ).length > 0, - } - expect(rejected).toStrictEqual({ input: true, output: true }) +const compilerCacheLaw = `A compilation cache on one graph preserves the callback inputs and authored selected output that separate compilations produce, in both compilation orders.` + +oracleProperty({ + id: `virtual-row-fields-input.compiler-cache`, + law: { + statement: compilerCacheLaw, + authority: `Reserved public row fields and the boundary-only metadata rule (maintainer decision, 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md: callback inputs and inline values keep their authored values, stated at the start of this owner.`, + limits: `The enumerated path, order, cache, shape, collision, and pending histories at the initial graph drain.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/query/compiler/index.ts#compileQuery`, + `src/query/effect.ts#createEffect`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: compilerCases, + label: (history) => + `preserves metadata across compiler cache modes: ${history.label}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(compilerCacheLaw, plan, observe, () => + judgeCompilerCacheMetadata(plan.history), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/query/virtual-row-fields-input.test.ts b/packages/db/tests/query/virtual-row-fields-input.test.ts new file mode 100644 index 0000000000..e115a57d2d --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-input.test.ts @@ -0,0 +1,236 @@ +// Checker calibrations and capture controls for the virtual-field input +// oracle in virtual-row-fields-input-oracle.test.ts. They run no production: +// each builds an observation from the independent model, or captures a +// hand-built value, and feeds it to the oracle's input and output checks. +import { expect, test } from 'vitest' +import { + expectedMetadataInput, + expectedMetadataRecord, + expectedSourceVirtualFields, + metadataInputViolations, + metadataOutputViolations, +} from './virtual-row-fields-input-oracle.js' +import { captureMetadataInput } from './virtual-row-fields-input-driver.js' +import type { MetadataSourceFact } from './virtual-row-fields-input-oracle.js' + +const sourceFact: MetadataSourceFact = { + row: { id: 1, parentId: `group`, label: `before`, optional: undefined }, + sourceId: `genuine-source`, + pending: false, +} +// Controls alter observations, never source files. The allowed answer comes +// from the same judgment used for every real receiving history above. +test.each([ + `source-spoof`, + `source-stripped`, + `projection-stripped`, + `inline-stripped`, + `own-undefined-lost`, + `mixed-before-after`, + `late-capture-rewrite`, + `compiler-symbol`, +] as const)(`rejects a hostile callback-input observation: %s`, (fault) => { + const description = { + shape: `structured-spread`, + collision: `all`, + authoredPending: true, + } as const + const projected = { ...description, shape: `functional-spread` } as const + const direct = { + ...description, + shape: `functional-direct`, + collision: `none`, + } as const + const use = + fault === `projection-stripped` + ? projected + : fault.startsWith(`source-`) || fault === `mixed-before-after` + ? direct + : description + const expected = expectedMetadataInput(sourceFact, use) + const hostile = structuredClone(expected) + if (fault === `compiler-symbol`) + hostile.symbols.push(Symbol(`compiler-state`)) + if (fault === `source-spoof`) hostile.fields.$collectionId!.value = `spoof` + if (fault === `source-stripped` || fault === `inline-stripped`) + hostile.fields.$key = { present: false, own: false, value: undefined } + if (fault === `projection-stripped`) + hostile.fields.$key = { present: false, own: false, value: undefined } + if (fault === `own-undefined-lost`) delete hostile.ordinary.optional + const next = expectedMetadataInput( + { + ...sourceFact, + row: { ...sourceFact.row, label: `pending` }, + pending: true, + }, + use, + ) + if (fault === `mixed-before-after`) hostile.fields = next.fields + if (fault === `late-capture-rewrite`) hostile.ordinary.label = `later` + expect(metadataInputViolations(expected, [expected])).toStrictEqual([]) + expect(metadataInputViolations(hostile, [expected, next])).not.toStrictEqual( + [], + ) +}) + +test.each([ + `omitted-peer`, + `duplicate-peer`, + `wrong-key-kind`, + `intermediate-sanitizer`, + `missing-derived-field`, +] as const)(`rejects a hostile derived ordinary output: %s`, (fault) => { + const description = { + shape: `functional-projection`, + collision: `$collectionId`, + authoredPending: false, + } as const + const expected = [ + sourceFact, + { ...sourceFact, row: { ...sourceFact.row, id: `1` } }, + ].map((fact) => ({ + id: fact.row.id, + observed: expectedMetadataInput(fact, description), + })) + const hostile = structuredClone(expected) + if (fault === `omitted-peer`) hostile.pop() + if (fault === `duplicate-peer`) hostile.push(structuredClone(hostile[1]!)) + if (fault === `wrong-key-kind`) hostile[1]!.id = 1 + if (fault === `intermediate-sanitizer`) + hostile[0]!.observed.fields.$collectionId = { + present: false, + own: false, + value: undefined, + } + if (fault === `missing-derived-field`) + Reflect.deleteProperty(hostile[0]!, `observed`) + expect(metadataOutputViolations(expected, expected)).toStrictEqual([]) + expect(metadataOutputViolations(hostile, expected)).not.toStrictEqual([]) +}) + +test(`copies callback input before later source objects change`, () => { + const description = { + shape: `functional-direct`, + collision: `none`, + authoredPending: false, + } as const + const value = { + ...sourceFact.row, + ...expectedSourceVirtualFields(sourceFact), + } + const capture = captureMetadataInput(value) + value.label = `later` + expect( + metadataInputViolations(capture, [ + expectedMetadataInput(sourceFact, description), + ]), + ).toStrictEqual([]) +}) + +test(`accepts readable inherited source metadata at callback input`, () => { + const description = { + shape: `functional-direct`, + collision: `none`, + authoredPending: false, + } as const + const value: object = Object.assign( + Object.create(expectedSourceVirtualFields(sourceFact)), + sourceFact.row, + ) + const capture = captureMetadataInput(value) + const expected = expectedMetadataInput(sourceFact, description) + expect(metadataInputViolations(capture, [expected])).toStrictEqual([]) + expect( + metadataOutputViolations( + [{ id: 1, observed: capture }], + [{ id: 1, observed: expected }], + ), + ).toStrictEqual([]) +}) + +test(`copies nested callback values before later child objects change`, () => { + const child = { + ...sourceFact.row, + ...expectedSourceVirtualFields(sourceFact), + } + const capture = captureMetadataInput({ id: `parent`, children: [child] }) + const expected = expectedMetadataRecord({ + id: `parent`, + children: [ + { ...sourceFact.row, ...expectedSourceVirtualFields(sourceFact) }, + ], + }) + child.label = `later` + expect(metadataInputViolations(capture, [expected])).toStrictEqual([]) +}) + +test.each([`selected-virtual`, `own-undefined`] as const)( + `rejects an inherited replacement for an authored inline field: %s`, + (fault) => { + const description = { + shape: `functional-projection`, + collision: `$collectionId`, + authoredPending: false, + } as const + const selected = expectedMetadataInput(sourceFact, description) + const child: Record = { + ...selected.ordinary, + ...Object.fromEntries( + Object.entries(selected.fields) + .filter(([, field]) => field.present) + .map(([name, field]) => [name, field.value]), + ), + } + const expected = expectedMetadataRecord({ id: `parent`, children: [child] }) + const field = fault === `selected-virtual` ? `$collectionId` : `optional` + const inherited: object = Object.assign( + Object.create({ [field]: child[field] }), + Object.fromEntries( + Object.entries(child).filter(([name]) => name !== field), + ), + ) + const actual = captureMetadataInput({ id: `parent`, children: [inherited] }) + expect( + metadataInputViolations( + captureMetadataInput({ id: `parent`, children: [child] }), + [expected], + ), + ).toStrictEqual([]) + expect(metadataInputViolations(actual, [expected])).not.toStrictEqual([]) + expect( + metadataOutputViolations( + [{ id: `parent`, observed: actual }], + [{ id: `parent`, observed: expected }], + ), + ).not.toStrictEqual([]) + }, +) + +// The inline array is public data too. An extra ordinary or reserved own field +// must survive capture so both judgments can reject the unauthored shape. +test.each([`unexpected`, `$collectionId`] as const)( + `rejects an extra own field on an inline array: %s`, + (field) => { + const child = expectedMetadataInput(sourceFact, { + shape: `functional-projection`, + collision: `none`, + authoredPending: false, + }).ordinary + const authored = { id: `parent`, children: [child] } + const expected = expectedMetadataRecord(authored) + const value = structuredClone(authored) + const lawful = captureMetadataInput(value) + expect(metadataInputViolations(lawful, [expected])).toStrictEqual([]) + Object.assign(value.children, { [field]: `spoof` }) + const hostile = captureMetadataInput(value) + const rejected = { + input: metadataInputViolations(hostile, [expected]).length > 0, + output: + metadataOutputViolations( + [{ id: `parent`, observed: hostile }], + [{ id: `parent`, observed: expected }], + ).length > 0, + } + expect(rejected).toStrictEqual({ input: true, output: true }) + }, +) 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 356d210c35..1f7110bd82 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 @@ -30,10 +30,15 @@ */ import { resolve } from 'node:path' import ts from 'typescript' -import { afterAll, expect, test } from 'vitest' +import { afterAll, expect } from 'vitest' import * as publicDb from '../../src/index.js' -import { declareOracle } from '../oracle-declaration.js' -import { oracleRoute } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { checkWithCleanup } from './virtual-row-fields-support.js' import { metadataCompositions as compositions, @@ -922,44 +927,50 @@ function checkConsumer(diagnostics: ReadonlyArray): void { }), ).toBe(``) } -declareOracle({ - owns: ['src/index.ts'], - grammar: [ - typeShapes, - authoredCollisions, - authoredPaths, - authoredKinds, - groupedForms, - groupingBoundaries, - aggregateDepths, - inlineChoices, - wrappedAggregateForms, - wrappedAggregateKinds, - ], - model: [consumerSource, groupingKeyFor], - check: [checkConsumer, checkConsumerConstruction], -}) -test.each(typeShapes)( - `preserves generated public row/value types: %j`, - async (shape) => { - oracleRoute(`case`) - publicTypesEntered = true - publicTypeHistory++ - const consumer = consumerSource(shape) - await checkConsumerConstruction(consumer.source) - historiesByState.get(`construction-admitted`)!.add(publicTypeHistory) - const diagnostics = compileConsumer(consumer.source) - for (const state of consumer.states) - historiesByState.get(state)!.add(publicTypeHistory) - checkConsumer(diagnostics) +// The former case body, unchanged: compile one generated consumer against the +// public types, admit its construction, and record the states it entered. +async function judgePublicTypeShape(shape: TypeShape): Promise { + publicTypesEntered = true + publicTypeHistory++ + const consumer = consumerSource(shape) + await checkConsumerConstruction(consumer.source) + historiesByState.get(`construction-admitted`)!.add(publicTypeHistory) + const diagnostics = compileConsumer(consumer.source) + for (const state of consumer.states) + historiesByState.get(state)!.add(publicTypeHistory) + checkConsumer(diagnostics) +} + +const publicTypesLaw = `Generated consumers of the public API observe the row and value types that the virtual-field boundary promises, and their constructions are admitted at runtime.` + +oracleProperty({ + id: `virtual-row-fields-types.public-types`, + law: { + statement: publicTypesLaw, + authority: `The Collection virtual-field boundary and reserved public row fields in packages/db/src/query/live/ARCHITECTURE.md (boundary-only metadata, maintainer decision of 2026-11-05), stated at the start of this owner.`, + limits: `The enumerated key, value, optional, and union type shapes of this file's closed consumer grammar.`, }, - 30_000, -) + owns: [`src/index.ts`], + histories: typeShapes, + label: (shape) => + `preserves generated public row/value types: ${JSON.stringify(shape)}`, + resolve: (shape) => ({ shape }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(publicTypesLaw, plan, observe, () => + judgePublicTypeShape(plan.shape), + ), + check: forwardAssertionJudgment, + // The old tests allowed 30 seconds each. The framework's determinism test + // runs every third shape twice in one test, about 16 compilations, so the + // property's per-test limit covers that test too. + timeout: 300_000, +}) // Feed a hostile type observation through the actual compiler/checker. Altering // only the expected law emulates leaked value metadata or lost nullability; // the legal production query remains identical to its accepted neighbor. -test.each([ +const hostileTypeObservations = [ `value-metadata`, `lost-nullability`, `one-branch-readonly`, @@ -967,98 +978,159 @@ test.each([ `date-metadata-any`, `nested-optional-metadata`, `nested-readonly`, -] 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([ - fault === `value-metadata` - ? `inline/object/toArray` - : fault === `lost-nullability` - ? `inline/nullable-whole/toArray` - : fault === `date-metadata-any` - ? `inline/date/toArray` - : `root/functional-direct/direct`, - ]), - ) - checkConsumer(compileConsumer(consumer.source)) - const wrong = +] as const + +// The former case body, unchanged: a hostile expected law over the identical +// legal query must fail only its generated Assert constraints. +function judgeHostileTypeObservation( + fault: (typeof hostileTypeObservations)[number], +): void { + const consumer = consumerSource( + fault === `one-branch-readonly` ? typeShapes[1]! : typeShapes[0]!, + new Set([ fault === `value-metadata` - ? consumer.source.replace( - `Normalize<{ value: string }>>>`, - `Normalize>>>`, - ) + ? `inline/object/toArray` : fault === `lost-nullability` - ? consumer.source.replaceAll( - `Published | Missing>`, - `Published`, + ? `inline/nullable-whole/toArray` + : fault === `date-metadata-any` + ? `inline/date/toArray` + : `root/functional-direct/direct`, + ]), + ) + checkConsumer(compileConsumer(consumer.source)) + const wrong = + fault === `value-metadata` + ? consumer.source.replace( + `Normalize<{ value: string }>>>`, + `Normalize>>>`, + ) + : fault === `lost-nullability` + ? consumer.source.replaceAll( + `Published | Missing>`, + `Published`, + ) + : fault === `one-branch-readonly` + ? consumer.source.replace( + `Normalize>>>`, + `Normalize>>>>`, ) - : fault === `one-branch-readonly` + : fault === `date-metadata-any` ? consumer.source.replace( - `Normalize>>>`, - `Normalize>>>>`, + `Normalize>>`, + `Normalize>>`, ) - : fault === `date-metadata-any` + : fault === `nested-optional-metadata` ? consumer.source.replace( - `Normalize>>`, - `Normalize>>`, + `Normalize>>>`, + `Normalize & { profile: Profile & { readonly $key?: string } }>>>>`, ) - : fault === `nested-optional-metadata` + : fault === `nested-readonly` ? consumer.source.replace( `Normalize>>>`, - `Normalize & { profile: Profile & { readonly $key?: string } }>>>>`, + `Normalize & { profile: { readonly label: string } }>>>>`, ) - : fault === `nested-readonly` - ? consumer.source.replace( - `Normalize>>>`, - `Normalize & { profile: { readonly label: string } }>>>>`, - ) - : consumer.source.replace( - `type Profile = { label: string };`, - `type Profile = { label: any };`, - ) - expect(wrong).not.toBe(consumer.source) - const diagnostics = compileConsumer(wrong) - // A syntax/setup error cannot stand in for a rejected wrong type. Require - // only failed Assert constraints in this generated consumer's law aliases. - expect(diagnostics.length).toBeGreaterThan(0) - for (const diagnostic of diagnostics) { - expect(diagnostic.file?.fileName).toBe(filename) - expect(diagnostic.code).toBe(2344) - const text = diagnostic.file!.text - const lineStart = text.lastIndexOf(`\n`, diagnostic.start) + 1 - const lineEnd = text.indexOf(`\n`, diagnostic.start) - expect(text.slice(lineStart, lineEnd)).toMatch( - /type (Observation_|Removal|CallbackValues)/, - ) - } - expect(() => checkConsumer(diagnostics)).toThrow() + : consumer.source.replace( + `type Profile = { label: string };`, + `type Profile = { label: any };`, + ) + expect(wrong).not.toBe(consumer.source) + const diagnostics = compileConsumer(wrong) + // A syntax/setup error cannot stand in for a rejected wrong type. Require + // only failed Assert constraints in this generated consumer's law aliases. + expect(diagnostics.length).toBeGreaterThan(0) + for (const diagnostic of diagnostics) { + expect(diagnostic.file?.fileName).toBe(filename) + expect(diagnostic.code).toBe(2344) + const text = diagnostic.file!.text + const lineStart = text.lastIndexOf(`\n`, diagnostic.start) + 1 + const lineEnd = text.indexOf(`\n`, diagnostic.start) + expect(text.slice(lineStart, lineEnd)).toMatch( + /type (Observation_|Removal|CallbackValues)/, + ) + } + expect(() => checkConsumer(diagnostics)).toThrow() +} + +const hostileTypesLaw = `The public-type check rejects each hostile type observation through the actual compiler, with only the generated consumer's law aliases failing.` + +oracleProperty({ + id: `virtual-row-fields-types.hostile-observations`, + law: { + statement: hostileTypesLaw, + authority: `The Collection virtual-field boundary and reserved public row fields in packages/db/src/query/live/ARCHITECTURE.md (boundary-only metadata, maintainer decision of 2026-11-05), stated at the start of this owner.`, + limits: `The seven enumerated hostile observations over two legal type shapes.`, }, - 30_000, -) + owns: [`src/index.ts`], + histories: hostileTypeObservations, + label: (fault) => `rejects a hostile public type observation: ${fault}`, + resolve: (fault) => ({ fault }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(hostileTypesLaw, plan, observe, () => + judgeHostileTypeObservation(plan.fault), + ), + check: forwardAssertionJudgment, + // The old tests allowed 30 seconds each; the determinism test runs one + // observation in eight twice in one test. + timeout: 120_000, +}) // Grammar controls preserve the admission checkpoint that exposed two invalid // early fixtures. Their legal neighbors run the identical generated body. -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`]), - ) - await checkConsumerConstruction(consumer.source) - const wrong = - fault === `non-record-facade` - ? consumer.source.replace(`children: toArray(`, `children: include(`) - : consumer.source.replace(`, { indexType: BTreeIndex }`, ``) - expect(wrong).not.toBe(consumer.source) - await expect(checkConsumerConstruction(wrong)).rejects.toThrow( - fault === `non-record-facade` - ? /must select an object when materializing as a Collection/ - : /No index type specified and no defaultIndexType set/, - ) +const admissionLaw = `A generated consumer's construction is admitted at runtime only when it is a legal query: an illegal neighbor of an admitted construction is rejected at admission.` +const admissionFaults = [`non-record-facade`, `missing-index-type`] as const + +oracleProperty({ + id: `virtual-row-fields-types.construction-admission`, + law: { + statement: admissionLaw, + authority: `The Collection virtual-field boundary and reserved public row fields in packages/db/src/query/live/ARCHITECTURE.md (boundary-only metadata, maintainer decision of 2026-11-05), stated at the start of this owner.`, + limits: `One legal inline array consumer and its two illegal neighbors.`, }, -) + owns: [`src/index.ts`], + histories: [{ shape: 0, paths: [`inline/array/toArray`] }], + label: () => + `admits the legal generated construction beside its illegal neighbors`, + resolve: ({ shape, paths }) => ({ + consumer: consumerSource(typeShapes[shape]!, new Set(paths)), + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(admissionLaw, plan, observe, () => + checkConsumerConstruction(plan.consumer.source), + ), + check: forwardAssertionJudgment, + calibrations: admissionFaults.map((fault) => ({ + name: `rejects an illegal generated construction at admission: ${fault}`, + law: admissionLaw, + // The former calibration body. Exactly the old matcher: only a rejection + // whose message names this fault's admission error counts as the check + // rejecting the illegal construction; any other outcome fails this + // calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(admissionLaw, plan, observe, async () => { + const { consumer } = plan + const wrong = + fault === `non-record-facade` + ? consumer.source.replace( + `children: toArray(`, + `children: include(`, + ) + : consumer.source.replace(`, { indexType: BTreeIndex }`, ``) + expect(wrong).not.toBe(consumer.source) + const expected = + fault === `non-record-facade` + ? /must select an object when materializing as a Collection/ + : /No index type specified and no defaultIndexType set/ + try { + await checkConsumerConstruction(wrong) + } catch (error) { + if (error instanceof Error && expected.test(error.message)) + rejectAt(`admission ${fault}`, error) + throw new Error( + `the illegal construction failed outside the expected rejection: ${String(error)}`, + ) + } + }), + })), +}) From faa8a40a6d2270c6388b1135b9626b5f3b8d7c8b Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:18:48 -0700 Subject: [PATCH 076/116] test(sqlite): restore the demand coverage storage-fault and release sweeps A one-history property whose driver loops over scenarios and asserts their reach is the closure-runner shape that the conventions addendum forbids. The storage-fault scenarios come from a baseline production run's call count, and the release steps share one covered driver, so neither becomes one scenario per history without changing its inputs. Both stay as they were, for the integrator to decide. Co-authored-by: Isaac --- ...ed-demand-coverage-campaign-oracle.test.ts | 151 +++++++----------- .../db/tests/converted-properties/sqlite.ts | 14 +- 2 files changed, 65 insertions(+), 100 deletions(-) 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 a76fc57e86..021a774655 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 @@ -1,8 +1,8 @@ import { DatabaseSync } from 'node:sqlite' import { fc } from '@fast-check/vitest' -import { describe, expect, vi } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { oracleRuns } from '../../db/tests/oracle-config' +import { oracleReplayRequested, oracleRuns } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -1238,100 +1238,65 @@ function checkRelease( }) } -// Part 2's sweep: every call that the history makes without a failure fails -// once, before and after its effect, and every fault reach is entered. -async function runStorageFaultSweep(): Promise { - quietConsole() - const baseline = await runWithFault(undefined) - // Positive witness: without a failure, session 2 settles D offline. - expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) - const reached = new Set() - const expectedReach: Array = [ - `coverage read failed`, - `row write failed`, - `coverage check failed`, - `coverage write failed`, - ] - for (const session of [1, 2] as const) { - const count = baseline.calls.filter((c) => c.session === session).length - for (let index = 0; index < count; index++) { - for (const timing of [`before effect`, `after effect`] as const) { - const run = await runWithFault({ session, index, timing }) - const failed = run.calls.filter((c) => c.session === session)[index] - if (!failed) continue - try { - checkFault(failed, timing, run, (state) => reached.add(state)) - } catch (error) { - throw new Error( - `failure at session ${session} call ${index} ` + - `(${failed.method}, ${timing}, late: ${failed.late})`, - { cause: error }, - ) +describe(`persisted demand coverage over generated histories`, () => { + it.skipIf(oracleReplayRequested())( + `keeps the law when any one storage call fails`, + async () => { + quietConsole() + const baseline = await runWithFault(undefined) + // Positive witness: without a failure, session 2 settles D offline. + expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) + const reached = new Set() + const expectedReach: Array = [ + `coverage read failed`, + `row write failed`, + `coverage check failed`, + `coverage write failed`, + ] + for (const session of [1, 2] as const) { + const count = baseline.calls.filter((c) => c.session === session).length + for (let index = 0; index < count; index++) { + for (const timing of [`before effect`, `after effect`] as const) { + const run = await runWithFault({ session, index, timing }) + const failed = run.calls.filter((c) => c.session === session)[index] + if (!failed) continue + try { + checkFault(failed, timing, run, (state) => reached.add(state)) + } catch (error) { + throw new Error( + `failure at session ${session} call ${index} ` + + `(${failed.method}, ${timing}, late: ${failed.late})`, + { cause: error }, + ) + } + } } } - } - } - expect(reached).toEqual(new Set(expectedReach)) -} - -// Part 3's sweep: one covered driver, released after every step up to the -// step at which the load would have settled, and every release reach is -// entered. -async function runReleaseSweep(): Promise { - quietConsole() - const driver = createDriver(new DatabaseSync(`:memory:`)) - await coverGroupA(driver) - const reached = new Set() - const expectedReach: Array = [ - `released before the source load`, - `released while the source load waited`, - `released during a coverage read`, - ] - for (let steps = 0; steps <= 24; steps++) { - checkRelease( - steps, - await runReleaseAt(driver, steps, (state) => reached.add(state)), - ) - } - expect(reached).toEqual(new Set(expectedReach)) -} - -describe(`persisted demand coverage over generated histories`, () => { - oracleProperty({ - id: `persisted-demand-coverage.storage-fault`, - law: { - statement: `With one storage call failed before or after its effect, for every call of the history, session 2 settles D from SQLite only with exactly r1 and r2; a failed coverage read, row write, or coverage check leaves session 2 waiting, and a failed coverage write crashes the Collection.`, - authority: `The persisted demand coverage contract stated above, and the glossary's "crash" for a terminal local persistence failure.`, - limits: `One history, with the failure swept over every call that it makes without a failure; node:sqlite and the default coordinator only.`, + expect(reached).toEqual(new Set(expectedReach)) }, - owns: [`src/persisted.ts`], - histories: [{ name: `keeps the law when any one storage call fails` }], - label: ({ name }) => name, - model: recordedAssertionJudgment, - driver: (plan, observe) => - recordAssertionJudgment(`storage fault`, plan, observe, () => - runStorageFaultSweep(), - ), - check: forwardAssertionJudgment, - timeout: 600_000, - }) + 600_000, + ) - oracleProperty({ - id: `persisted-demand-coverage.release`, - law: { - statement: `A released load owns no wait: after the release, at every step of a load, the source Collection is not loading a subset, and it stays so when the clock passes the wait.`, - authority: `ARCHITECTURE.md law 6, as Part 3 above states it.`, - limits: `One covered driver, released after each of steps 0 to 24 under a fake clock; node:sqlite and the default coordinator only.`, + it.skipIf(oracleReplayRequested())( + `owns no wait after a release at any step of a load`, + async () => { + quietConsole() + const driver = createDriver(new DatabaseSync(`:memory:`)) + await coverGroupA(driver) + const reached = new Set() + const expectedReach: Array = [ + `released before the source load`, + `released while the source load waited`, + `released during a coverage read`, + ] + for (let steps = 0; steps <= 24; steps++) { + checkRelease( + steps, + await runReleaseAt(driver, steps, (state) => reached.add(state)), + ) + } + expect(reached).toEqual(new Set(expectedReach)) }, - owns: [`src/persisted.ts`], - histories: [{ name: `owns no wait after a release at any step of a load` }], - label: ({ name }) => name, - model: recordedAssertionJudgment, - driver: (plan, observe) => - recordAssertionJudgment(`release`, plan, observe, () => - runReleaseSweep(), - ), - check: forwardAssertionJudgment, - timeout: 300_000, - }) + 300_000, + ) }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 960b2f7a08..c71eb52208 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -12,8 +12,6 @@ export const properties: ReadonlyArray = [ `sqlite-reload.class.electron-app`, `sqlite-persistence.typed-value-reopen`, `sqlite-persistence.native-temporal-query`, - `persisted-demand-coverage.storage-fault`, - `persisted-demand-coverage.release`, `persisted-demand-coverage.cell`, `persisted-demand-coverage.lifecycle`, `persisted-demand-coverage.joined`, @@ -24,6 +22,8 @@ export const properties: ReadonlyArray = [ `browser.shared-driver-fairness.fixed`, `browser.shared-driver-fairness.persist-first`, `browser.shared-driver-fairness.barrier`, + `browser.multi-tab-refetch-storm`, + `browser.multi-tab-reserved-history-fixed`, ] export const owners: ReadonlyArray = [ @@ -47,11 +47,6 @@ export const owners: ReadonlyArray = [ `sqlite-persistence`, `typed-value-reopen native-temporal-query`, ], - [ - `db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts`, - `persisted-demand-coverage`, - `storage-fault release`, - ], [ `db-sqlite-persistence-core/tests/persisted-demand-coverage-oracle.test.ts`, `persisted-demand-coverage`, @@ -62,4 +57,9 @@ export const owners: ReadonlyArray = [ `browser.shared-driver-fairness`, `fixed persist-first barrier`, ], + [ + `browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts`, + `browser`, + `multi-tab-refetch-storm multi-tab-reserved-history-fixed`, + ], ] From 5b66ac8986190dd20fc2e28e9ec6d65b970d7fb7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:19:13 -0700 Subject: [PATCH 077/116] test(db): required lifecycle coverage is declared reach of its enumerated scenarios subscription-lifecycle.required-coverage and .async-restart-regimes each ran one history whose driver looped over a fixed scenario list and asserted that the required labels were reached. Each scenario is now its own enumerated history through the same driver, and each required label is a declared state, so the framework's enumerated reach test requires it. The judgment is unchanged: every scenario is judged, and every required label is reached by at least one. Dropping one label from the observed reach fails the reach test. Co-authored-by: Isaac --- ...-lifecycle-history-oracle.property.test.ts | 137 ++++++++++-------- ...tion-subscription-lifecycle-oracle.test.ts | 65 +++++---- 2 files changed, 114 insertions(+), 88 deletions(-) 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 f621290f7e..2c3f3d74da 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 @@ -32,6 +32,7 @@ import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, + recordedAssertionJudgment, rejectAt, } from './oracle-assertion-adapter.js' import { @@ -1853,57 +1854,60 @@ async function runReturnedResultsCase( ) } -async function runRequiredCoverage(): Promise { - const reach = new Set() - for (const history of [ - ...greenLifecycleHistories, - ...compoundLifecycleCoverageHistories, - ]) { - for (const label of await runHistory(history)) reach.add(label) - } - const commands = [ - `request`, - `abort`, - `release`, - `settle`, - `truncate`, - `cleanup`, - `restart`, - `unsubscribe`, - ] - const required = new Set([ - ...commands.map((type) => `command:${type}`), - ...commands.map((type) => `effective:${type}`), - ...commands.map((type) => `noop:${type}`), - `settle-scope:current`, - `settle-scope:obsolete`, - `settle-age:oldest`, - `settle-age:newest`, - `settle-outcome:resolve`, - `settle-outcome:reject`, - ...([`current`, `obsolete`] as const).flatMap((scope) => - ([`oldest`, `newest`] as const).flatMap((age) => - ([`resolve`, `reject`] as const).map( - (outcome) => `settle:${scope}:${age}:${outcome}`, - ), +// The commands and cross-phase transitions that the canonical green and +// compound histories must reach together (ORC-015). Each is a declared state of +// the required-coverage property, so its enumerated reach test requires it. +const coverageCommands = [ + `request`, + `abort`, + `release`, + `settle`, + `truncate`, + `cleanup`, + `restart`, + `unsubscribe`, +] +const requiredCoverage = new Set([ + ...coverageCommands.map((type) => `command:${type}`), + ...coverageCommands.map((type) => `effective:${type}`), + ...coverageCommands.map((type) => `noop:${type}`), + `settle-scope:current`, + `settle-scope:obsolete`, + `settle-age:oldest`, + `settle-age:newest`, + `settle-outcome:resolve`, + `settle-outcome:reject`, + ...([`current`, `obsolete`] as const).flatMap((scope) => + ([`oldest`, `newest`] as const).flatMap((age) => + ([`resolve`, `reject`] as const).map( + (outcome) => `settle:${scope}:${age}:${outcome}`, ), ), - `attempt-sync-run:initial`, - `attempt-sync-run:restarted`, - `attempt-replay:initial`, - `attempt-replay:replayed`, - `attempt-location:initial:initial`, - `attempt-location:initial:replayed`, - `attempt-location:restarted:initial`, - `attempt-location:restarted:replayed`, - `duplicate-owner`, - `request-while-cleaned`, - `partial-generation-supersession`, - ]) - oracleAssertion(`lifecycle history comparison at 1365`, () => - expect([...required].filter((label) => !reach.has(label))).toEqual([]), - ) -} + ), + `attempt-sync-run:initial`, + `attempt-sync-run:restarted`, + `attempt-replay:initial`, + `attempt-replay:replayed`, + `attempt-location:initial:initial`, + `attempt-location:initial:replayed`, + `attempt-location:restarted:initial`, + `attempt-location:restarted:replayed`, + `duplicate-owner`, + `request-while-cleaned`, + `partial-generation-supersession`, +]) + +// The canonical histories, each named for its replay. +const requiredCoverageHistories = [ + ...greenLifecycleHistories.map((commands, index) => ({ + name: `green lifecycle history ${index + 1}`, + commands, + })), + ...compoundLifecycleCoverageHistories.map((commands, index) => ({ + name: `compound lifecycle history ${index + 1}`, + commands, + })), +] async function runChangeRetirementCase(): Promise { const reached = await runHistory(changeRetiresReplacementHistory) @@ -2032,17 +2036,34 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { law: { statement: `The canonical green and compound lifecycle histories refine the independent model and together reach every required command and cross-phase transition.`, authority: `The shared lifecycle grammar and this file's contract prose.`, - limits: `The canonical green and compound coverage histories, replayed in order through the shared driver.`, + limits: `The canonical green and compound coverage histories, each replayed through the shared driver; a required label is a declared state of the enumerated source.`, }, owns: [`src/collection/subscription.ts`], - histories: [ - `covers every required command and cross-phase transition`, - ] as const, - label: (name) => name, - ...subscriptionComparison( - `subscription-lifecycle.required-coverage`, - runRequiredCoverage, - ), + histories: { + enumerated: requiredCoverageHistories, + states: [...requiredCoverage], + }, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `subscription-lifecycle.required-coverage`, + plan, + observe, + async () => { + const labels = await runHistory(plan.history.commands) + observe( + `required coverage reached`, + [...labels].filter((label) => requiredCoverage.has(label)).sort(), + ) + }, + ), + // The required labels that the history's driver observed. + reached: (_plan, trace) => + (trace.find( + ({ checkpoint }) => checkpoint === `required coverage reached`, + )?.value as ReadonlyArray | undefined) ?? [], check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index d14c7824ab..8a2d617e46 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -796,42 +796,37 @@ function expectedThreeGenerationSettlement( * physical acquisition and its lease exist only if the callback returns. */ describe(`CollectionSubscription demand lifecycle oracle`, () => { - const runRequiredAsyncRestartRegimes = async () => { - const reach = new Set() - for (const scenario of asyncRestartCoverageScenarios) { - for (const label of await runAsyncRestartScenario(scenario)) { - reach.add(label) - } - } - const required = [ - `demands:1`, - `demands:2`, - `sync-run-generations:2`, - `sync-run-generations:3`, - `sync-run-generations:4`, - `current:resolve`, - `current:reject`, - `mixed-current:true`, - `obsolete-reject:true`, - `order:obsolete-first`, - `order:current-first`, - `order:interleaved`, - `real-interleaving:true`, - ] - oracleAssertion('existing comparison at 818', () => - expect(required.filter((label) => !reach.has(label))).toEqual([]), - ) - } + // The regimes that the async restart coverage scenarios must reach + // together. Each is a declared state of the enumerated source. + const requiredAsyncRestartRegimes = [ + `demands:1`, + `demands:2`, + `sync-run-generations:2`, + `sync-run-generations:3`, + `sync-run-generations:4`, + `current:resolve`, + `current:reject`, + `mixed-current:true`, + `obsolete-reject:true`, + `order:obsolete-first`, + `order:current-first`, + `order:interleaved`, + `real-interleaving:true`, + ] oracleProperty({ id: `subscription-lifecycle.async-restart-regimes`, law: { statement: `Demand settlements are fenced by the sync run that owns their physical acquisition, and the async restart coverage scenarios together execute every required regime.`, authority: `Collection cleanup/restart and logical demand ownership in the glossary; the lifecycle grammar's existing model`, - limits: `The four async restart coverage scenarios, replayed in order through the shared driver.`, + limits: `The four async restart coverage scenarios, each replayed through the shared driver; a required regime is a declared state of the enumerated source.`, }, owns: [`src/collection/subscription.ts`], - histories: [`executes every required async restart regime`] as const, - label: (name) => name, + histories: { + enumerated: asyncRestartCoverageScenarios, + states: requiredAsyncRestartRegimes, + }, + label: (scenario) => + `async restart scenario ${asyncRestartCoverageScenarios.indexOf(scenario) + 1}`, resolve: (history) => ({ history }), model: recordedAssertionJudgment, driver: (plan, observe) => @@ -839,8 +834,18 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { `subscription-lifecycle.async-restart-regimes`, plan, observe, - runRequiredAsyncRestartRegimes, + async () => { + const labels = await runAsyncRestartScenario(plan.history) + observe( + `required regimes reached`, + requiredAsyncRestartRegimes.filter((label) => labels.has(label)), + ) + }, ), + // The required regimes that the scenario's driver observed. + reached: (_plan, trace) => + (trace.find(({ checkpoint }) => checkpoint === `required regimes reached`) + ?.value as ReadonlyArray | undefined) ?? [], check: forwardAssertionJudgment, }) From 0f9a2eb127f6166a9a09ab062e6dca079f0f880f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:20:24 -0700 Subject: [PATCH 078/116] test(db): register index-update cases and calibrations through oracleProperty Fixed reference, nullish, comparator, Collection-boundary, mixed-value, binary and Temporal cases become enumerated properties over the same helpers. Each spy calibration replays its old body as a mutant driver of the property whose shared check it calibrates; the append-only replacement calibration joins index-update.reference-model, whose check it exercises. Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 18 + .../index-update-oracle.property.test.ts | 2330 +++++++++++------ 2 files changed, 1495 insertions(+), 853 deletions(-) diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index cff71d02a1..d29e891202 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -40,6 +40,19 @@ export const properties: ReadonlyArray = [ `comparison.non-finite-witnesses`, `comparison.signed-infinity-custom`, `comparison.temporal-witnesses`, + `index-update.reference-model-fixed`, + `index-update.nullish-fixed`, + `index-update.nullish-string-owners`, + `index-update.invalid-comparator`, + `index-update.rejected-comparator-writes`, + `index-update.collection-boundary`, + `index-update.comparator-equal-update`, + `index-update.mixed-key-deletion`, + `index-update.mixed-key-history`, + `index-update.temporal-key-owners`, + `index-update.mixed-public-snapshot`, + `index-update.binary-ownership`, + `index-update.month-day-equality`, // transactions and mutations // IndexedDB and live-query observers // proxies and conformance @@ -93,6 +106,11 @@ export const owners: ReadonlyArray = [ `comparison`, `non-finite-witnesses signed-infinity-custom temporal-witnesses`, ], + [ + `db/tests/index-update-oracle.property.test.ts`, + `index-update`, + `reference-model-fixed nullish-fixed nullish-string-owners invalid-comparator rejected-comparator-writes collection-boundary comparator-equal-update mixed-key-deletion mixed-key-history temporal-key-owners mixed-public-snapshot binary-ownership month-day-equality`, + ], // transactions and mutations // IndexedDB and live-query observers // proxies and conformance diff --git a/packages/db/tests/index-update-oracle.property.test.ts b/packages/db/tests/index-update-oracle.property.test.ts index b0dc6e145d..13bfeae798 100644 --- a/packages/db/tests/index-update-oracle.property.test.ts +++ b/packages/db/tests/index-update-oracle.property.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { Temporal } from 'temporal-polyfill' import { createCollection } from '../src/collection/index.js' @@ -8,19 +8,16 @@ 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 { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config' -import { declareOracle } from './oracle-declaration' +import { oracleProperty } from './oracle-config' import { expectExactIdentities } from './index-identity-oracle' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter' +import { clean, rejection } from './calibration-rejection' import { indexTypes, withCollectionCleanup } from './index-update-support' import type { BaseIndex } from '../src/indexes/base-index.js' @@ -299,30 +296,6 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { expectIndexMatchesModel(rebuilt, rows) } - // These original examples use mapped string keys. Fast-check cannot - // reconstruct their map context for native fixed-history admission, so keep - // them as direct inputs to the same independent helper. - test.skipIf(oracleReplayRequested()).each([ - { - name: `replacement continuation`, - history: [ - { type: `put`, key: `0`, value: 1 }, - { type: `build`, entries: [[`1`, -1]] }, - { type: `put`, key: `1`, value: 2 }, - ] satisfies Array, - }, - { - name: `signed zero values`, - history: [ - { type: `put`, key: `0`, value: 0 }, - { type: `put`, key: `1`, value: -0 }, - ] satisfies Array, - }, - ])(`refines the fixed reference history $name`, ({ history }) => { - oracleRoute(`fixed history`) - return runIndexReferenceModel(history) - }) - oracleProperty({ id: `index-update.reference-model`, law: { @@ -348,103 +321,272 @@ describe.each(indexTypes)(`%s update properties`, (_indexName, IndexType) => { () => runIndexReferenceModel(plan.history), ), check: forwardAssertionJudgment, + // Each calibration replays its old body as a mutant driver: the shared + // check must reject every wrong answer with an error that the old matcher + // accepted, and every clean check in the body must still pass. + calibrations: [ + ...( + [ + [`duplicate key`, `takeFromStart`, [`a`, `a`]], + [`wrong reverse order`, `takeReversedFromEnd`, [`a`, `b`]], + ] as const + ).map(([name, method, wrongKeys]) => ({ + name: `full traversal rejects a ${name}`, + law: `index-update.reference-model`, + driver: ( + plan: { history: Array }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `index-update.reference-model`, + plan, + observe, + () => { + const rejections: Array = [] + + const index = new IndexType(1, new PropRef([`value`])) + index.add(`a`, { value: 1 }) + index.add(`b`, { value: 2 }) + const wrongOutput = vi + .spyOn(index, method) + .mockReturnValue([...wrongKeys]) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + expectIndexMatchesModel( + index, + new Map([ + [`a`, 1], + [`b`, 2], + ]), + ), + ), + ) + expect(wrongOutput).toHaveBeenCalled() + } finally { + wrongOutput.mockRestore() + } + + rejectAt( + `calibration: full traversal rejects a ${name}`, + rejections[0]!, + ) + }, + ), + })), + { + name: `inclusive lower bound rejects an exclusive-boundary result`, + law: `index-update.reference-model`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.reference-model`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + index.add(`boundary`, { value: -2 }) + const rangeQuery = index.rangeQuery.bind(index) + const wrongOutput = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => + options.from === -2 ? new Set() : rangeQuery(options), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + expectIndexMatchesModel( + index, + new Map([[`boundary`, -2]]), + ), + ), + ) + } finally { + wrongOutput.mockRestore() + } + + rejectAt( + `calibration: inclusive lower bound rejects an exclusive-boundary result`, + rejections[0]!, + ) + }, + ), + }, + { + name: `upper-only range rejects a dropped minimum when the lower flag is exclusive`, + law: `index-update.reference-model`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.reference-model`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + index.add(`minimum`, { value: -2 }) + const rangeQuery = index.rangeQuery.bind(index) + const wrongOutput = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => { + const result = rangeQuery(options) + if (!(`from` in options) && options.fromInclusive === false) { + result.delete(`minimum`) + } + return result + }) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + expectIndexMatchesModel( + index, + new Map([[`minimum`, -2]]), + ), + ), + ) + } finally { + wrongOutput.mockRestore() + } + + rejectAt( + `calibration: upper-only range rejects a dropped minimum when the lower flag is exclusive`, + rejections[0]!, + ) + }, + ), + }, + { + name: `explicit exclusive lower bound rejects an included boundary value`, + law: `index-update.reference-model`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.reference-model`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + index.add(`boundary`, { value: -2 }) + index.add(`later`, { value: 0 }) + const rangeQuery = index.rangeQuery.bind(index) + const wrongOutput = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => { + const result = rangeQuery(options) + if ( + options.from === -2 && + options.to === 2 && + options.fromInclusive === false + ) { + result.add(`boundary`) + } + return result + }) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + expectIndexMatchesModel( + index, + new Map([ + [`boundary`, -2], + [`later`, 0], + ]), + ), + ), + ) + } finally { + wrongOutput.mockRestore() + } + + rejectAt( + `calibration: explicit exclusive lower bound rejects an included boundary value`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects append-only replacement state and accepts replacement continuation`, + law: `index-update.reference-model`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.reference-model`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + index.add(`old`, { value: -1 }) + index.add(`new`, { value: 1 }) + const replacement = new Map([[`new`, 1]]) + // This is the raw state an append-only build would expose, not a source mutant. + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectIndexMatchesModel(index, replacement), + ), + ) + index.build([[`new`, { value: 1 }]]) + clean(() => expectIndexMatchesModel(index, replacement)) + index.update(`new`, { value: 1 }, { value: 2 }) + clean(() => expectIndexMatchesModel(index, new Map([[`new`, 2]]))) + + rejectAt( + `calibration: rejects append-only replacement state and accepts replacement continuation`, + rejections[0]!, + ) + }, + ), + }, + ], }) - test.each([ - [`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 }) - const wrongOutput = vi.spyOn(index, method).mockReturnValue([...wrongKeys]) - try { - expect(() => - expectIndexMatchesModel( - index, - new Map([ - [`a`, 1], - [`b`, 2], - ]), - ), - ).toThrowError(/expected/) - expect(wrongOutput).toHaveBeenCalled() - } finally { - wrongOutput.mockRestore() - } - }) - 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) - const wrongOutput = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => - options.from === -2 ? new Set() : rangeQuery(options), - ) - try { - expect(() => - expectIndexMatchesModel(index, new Map([[`boundary`, -2]])), - ).toThrowError(/expected/) - } finally { - wrongOutput.mockRestore() - } - }) - 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) - const wrongOutput = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => { - const result = rangeQuery(options) - if (!(`from` in options) && options.fromInclusive === false) { - result.delete(`minimum`) - } - return result - }) - try { - expect(() => - expectIndexMatchesModel(index, new Map([[`minimum`, -2]])), - ).toThrowError(/expected/) - } finally { - wrongOutput.mockRestore() - } - }) - 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 }) - const rangeQuery = index.rangeQuery.bind(index) - const wrongOutput = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => { - const result = rangeQuery(options) - if ( - options.from === -2 && - options.to === 2 && - options.fromInclusive === false - ) { - result.add(`boundary`) - } - return result - }) - try { - expect(() => - expectIndexMatchesModel( - index, - new Map([ - [`boundary`, -2], - [`later`, 0], - ]), - ), - ).toThrowError(/expected/) - } finally { - wrongOutput.mockRestore() - } + + // These original examples use mapped string keys. Fast-check cannot + // reconstruct their map context for native fixed-history admission, so one + // enumerated property replays them through the same independent helper. + oracleProperty({ + id: `index-update.reference-model-fixed`, + law: { + statement: `Each direct index action refines an independent source Map across lookups, traversal and rebuild.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `The two fixed reference histories: replacement continuation and signed zero values.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [ + { + name: `replacement continuation`, + history: [ + { type: `put`, key: `0`, value: 1 }, + { type: `build`, entries: [[`1`, -1]] }, + { type: `put`, key: `1`, value: 2 }, + ] satisfies Array, + }, + { + name: `signed zero values`, + history: [ + { type: `put`, key: `0`, value: 0 }, + { type: `put`, key: `1`, value: -0 }, + ] satisfies Array, + }, + ], + label: ({ name }) => `refines the fixed reference history ${name}`, + resolve: ({ history }) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.reference-model-fixed`, + plan, + observe, + () => runIndexReferenceModel(plan.history), + ), + check: forwardAssertionJudgment, }) }) @@ -760,214 +902,9 @@ function applyNullishAction( expectNullishMembership(index, rows) } -declareOracle({ - check: [ - expectIndexMatchesModel, - expectCustomGroups, - expectExactIdentities, - expectExactMembership, - expectNullishMembership, - expectComparatorStepRefines, - expectPublishedKeysMatchCollection, - expectStringMembershipMatches, - expectSnapshotKeysMatchSource, - expectBinaryValueMembership, - expectConservedIndexKeys, - ], - model: [groupKeysByValue, nullishPosition, predictMonthDayOwners], -}) - describe.each(indexTypes)( `%s nullish direct index refinement`, (_indexName, IndexType) => { - test.each([ - [`undefined to null`, undefined, null], - [`null to undefined`, null, undefined], - [`defined to undefined`, 1, undefined], - [`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 }) - applyNullishAction(index, rows, { type: `put`, key: `a`, value: after }) - }) - - 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 [ - [`null1`, null], - [`undef1`, undefined], - [`num1`, 1], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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`] - const inProbes = [[undefined], [undefined, `active`]] - for (const [key, value] of [ - [`a`, `active`], - [`b`, undefined], - [`c`, `inactive`], - [`d`, undefined], - ] as const) { - index.add(key, { status: value }) - rows.set(key, value) - expectExactMembership(index, rows, exactProbes, inProbes) - } - }) - - 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 }) - applyNullishAction(index, rows, { type: `put`, key: `b`, value: 2 }) - }) - - 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 [ - [`undef`, undefined], - [`one`, 1], - [`two`, 2], - [`three`, 3], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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 [ - [`undef1`, undefined], - [`undef2`, undefined], - [`one`, 1], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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 [ - [`a`, undefined], - [`b`, 1], - [`c`, 2], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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 [ - [`undef1`, undefined], - [`undef2`, undefined], - [`undef3`, undefined], - [`one`, 1], - [`two`, 2], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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, { - type: `put`, - key: `a`, - value: undefined, - }) - applyNullishAction(index, rows, { - type: `put`, - key: `b`, - value: undefined, - }) - applyNullishAction(index, rows, { type: `delete`, key: `a` }) - }) - - 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 [ - [`undef`, undefined], - [`one`, 1], - [`two`, 2], - [`three`, 3], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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 [ - [`a`, undefined], - [`b`, undefined], - [`c`, 1], - ] as const) { - applyNullishAction(index, rows, { type: `put`, key, value }) - } - }) - - 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, { - type: `build`, - entries: [ - [`undefined`, undefined], - [`one`, 1], - ], - }) - // A second build changes insertion order; the model still predicts the - // same returned key order and complete upper-bound results. - applyNullishAction(index, rows, { - type: `build`, - entries: [ - [`one`, 1], - [`undefined`, undefined], - ], - }) - applyNullishAction(index, rows, { - type: `put`, - key: `two`, - value: 2, - }) - applyNullishAction(index, rows, { - type: `build`, - entries: [ - [`undef2`, undefined], - [`null`, null], - [`undef1`, undefined], - [`one`, 1], - ], - }) - }) - const runNullishMembership = async (actions: Array) => { const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() @@ -1003,337 +940,774 @@ describe.each(indexTypes)( () => runNullishMembership(plan.history), ), check: forwardAssertionJudgment, + // Each calibration replays its old body as a mutant driver. + calibrations: [ + { + name: `rejects a skipped undefined-to-null update`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `a`, + value: undefined, + }), + ) + const skipped = vi + .spyOn(index, `update`) + .mockImplementation(() => {}) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + applyNullishAction(index, rows, { + type: `put`, + key: `a`, + value: null, + }), + ), + ) + expect(skipped).toHaveBeenCalledOnce() + } finally { + skipped.mockRestore() + } + + rejectAt( + `calibration: rejects a skipped undefined-to-null update`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects lookup dispatch that unites nullish buckets`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `null1`, + value: null, + }), + ) + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `undef1`, + value: undefined, + }), + ) + const lookup = index.lookup.bind(index) + const united = vi + .spyOn(index, `lookup`) + .mockImplementation((op, value) => + op === `eq` && value === undefined + ? new Set([`null1`, `undef1`]) + : lookup(op, value), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishMembership(index, rows), + ), + ) + expect(united).toHaveBeenCalledWith(`eq`, undefined) + } finally { + united.mockRestore() + } + + rejectAt( + `calibration: rejects lookup dispatch that unites nullish buckets`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects an exact lookup that loses one undefined owner`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`status`])) + const rows = new Map([ + [`a`, `active`], + [`b`, undefined], + [`c`, `inactive`], + [`d`, undefined], + ]) + for (const [key, status] of rows) index.add(key, { status }) + const lookup = index.equalityLookup.bind(index) + const truncated = vi + .spyOn(index, `equalityLookup`) + .mockImplementation((value) => + value === undefined ? new Set([`b`]) : lookup(value), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => + expectExactMembership( + index, + rows, + [undefined], + [[undefined]], + ), + ), + ) + expect(truncated).toHaveBeenCalledWith(undefined) + } finally { + truncated.mockRestore() + } + + rejectAt( + `calibration: rejects an exact lookup that loses one undefined owner`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects an IN lookup that drops an undefined operand`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `undef`, + value: undefined, + }), + ) + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `one`, + value: 1, + }), + ) + const lookup = index.inArrayLookup.bind(index) + const dropped = vi + .spyOn(index, `inArrayLookup`) + .mockImplementation((values) => + values.length === 2 && + values[0] === 1 && + values[1] === undefined + ? new Set([`one`]) + : lookup(values), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishMembership(index, rows), + ), + ) + expect(dropped).toHaveBeenCalledWith([1, undefined]) + } finally { + dropped.mockRestore() + } + + rejectAt( + `calibration: rejects an IN lookup that drops an undefined operand`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects an IN lookup that loses a same-value owner`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `undef1`, + value: undefined, + }), + ) + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `undef2`, + value: undefined, + }), + ) + const lookup = index.inArrayLookup.bind(index) + const truncated = vi + .spyOn(index, `inArrayLookup`) + .mockImplementation((values) => + values.length === 1 && values[0] === undefined + ? new Set([`undef1`]) + : lookup(values), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishMembership(index, rows), + ), + ) + expect(truncated).toHaveBeenCalledWith([undefined]) + } finally { + truncated.mockRestore() + } + + rejectAt( + `calibration: rejects an IN lookup that loses a same-value owner`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects IN dispatch that returns only its first value group`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `undef`, + value: undefined, + }), + ) + clean(() => + applyNullishAction(index, rows, { + type: `put`, + key: `one`, + value: 1, + }), + ) + const lookup = index.lookup.bind(index) + const firstOnly = vi + .spyOn(index, `lookup`) + .mockImplementation((op, value) => + op === `in` && + Array.isArray(value) && + value.length === 2 && + value[0] === undefined && + value[1] === 1 + ? new Set([`undef`]) + : lookup(op, value), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishMembership(index, rows), + ), + ) + expect(firstOnly).toHaveBeenCalledWith(`in`, [undefined, 1]) + } finally { + firstOnly.mockRestore() + } + + rejectAt( + `calibration: rejects IN dispatch that returns only its first value group`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects an endpoint read that drops the undefined owner`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map([ + [`a`, undefined], + [`b`, 1], + [`c`, 2], + ]) + for (const [key, value] of rows) index.add(key, { value }) + const dropped = vi + .spyOn(index, `takeFromStart`) + .mockImplementation(() => [`b`, `c`]) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishOrderedReads(index, rows), + ), + ) + expect(dropped).toHaveBeenCalled() + } finally { + dropped.mockRestore() + } + + rejectAt( + `calibration: rejects an endpoint read that drops the undefined owner`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects a cursor that repeats the last undefined owner`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map([ + [`undef1`, undefined], + [`undef2`, undefined], + [`undef3`, undefined], + [`one`, 1], + [`two`, 2], + ]) + for (const [key, value] of rows) index.add(key, { value }) + const take = index.take.bind(index) + const repeated = vi + .spyOn(index, `take`) + .mockImplementation((n, from, filter) => + n === 10 && from === undefined + ? [`undef3`, `one`, `two`] + : take(n, from, filter), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishOrderedReads(index, rows), + ), + ) + expect(repeated).toHaveBeenCalledWith(10, undefined) + } finally { + repeated.mockRestore() + } + + rejectAt( + `calibration: rejects a cursor that repeats the last undefined owner`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects a forward range that drops an explicit undefined lower bound`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map([ + [`undef`, undefined], + [`one`, 1], + [`two`, 2], + [`three`, 3], + ]) + for (const [key, value] of rows) index.add(key, { value }) + const rangeQuery = index.rangeQuery.bind(index) + const dropped = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => + `from` in options && + options.from === undefined && + options.to === 2 && + options.fromInclusive === true + ? new Set([`one`, `two`]) + : rangeQuery(options), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishOrderedReads(index, rows), + ), + ) + expect(dropped).toHaveBeenCalledWith({ + from: undefined, + fromInclusive: true, + to: 2, + toInclusive: true, + }) + } finally { + dropped.mockRestore() + } + + rejectAt( + `calibration: rejects a forward range that drops an explicit undefined lower bound`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects a double undefined range that loses one owner`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map([ + [`a`, undefined], + [`b`, undefined], + [`c`, 1], + ]) + for (const [key, value] of rows) index.add(key, { value }) + const rangeQuery = index.rangeQuery.bind(index) + const truncated = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => + `from` in options && + options.from === undefined && + `to` in options && + options.to === undefined + ? new Set([`a`]) + : rangeQuery(options), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishOrderedReads(index, rows), + ), + ) + expect(truncated).toHaveBeenCalledWith({ + from: undefined, + fromInclusive: true, + to: undefined, + toInclusive: true, + }) + } finally { + truncated.mockRestore() + } + + rejectAt( + `calibration: rejects a double undefined range that loses one owner`, + rejections[0]!, + ) + }, + ), + }, + { + name: `rejects a null bound that drops its undefined peer`, + law: `index-update.nullish-membership`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-membership`, + plan, + observe, + () => { + const rejections: Array = [] + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map([ + [`a`, undefined], + [`b`, null], + [`c`, 1], + ]) + for (const [key, value] of rows) index.add(key, { value }) + const rangeQuery = index.rangeQuery.bind(index) + const exactOnly = vi + .spyOn(index, `rangeQuery`) + .mockImplementation((options) => + `from` in options && + options.from === null && + `to` in options && + options.to === null + ? new Set([`b`]) + : rangeQuery(options), + ) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectNullishOrderedReads(index, rows), + ), + ) + expect(exactOnly).toHaveBeenCalledWith({ + from: null, + fromInclusive: true, + to: null, + toInclusive: true, + }) + } finally { + exactOnly.mockRestore() + } + + rejectAt( + `calibration: rejects a null bound that drops its undefined peer`, + rejections[0]!, + ) + }, + ), + }, + ], }) - 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, { - type: `put`, - key: `a`, - value: undefined, - }) - const skipped = vi.spyOn(index, `update`).mockImplementation(() => {}) - try { - expect(() => - applyNullishAction(index, rows, { - type: `put`, - key: `a`, - value: null, - }), - ).toThrowError(/expected/) - expect(skipped).toHaveBeenCalledOnce() - } finally { - skipped.mockRestore() - } - }) - - test(`rejects lookup dispatch that unites nullish buckets`, () => { - oracleRoute(`calibration`) + // The fixed nullish histories. Each starts from an empty index and applies + // its direct actions through the shared nullish helper, which compares the + // index with the source Map after every action. + const runNullishFixed = (actions: ReadonlyArray) => { const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() - applyNullishAction(index, rows, { - type: `put`, - key: `null1`, - value: null, - }) - applyNullishAction(index, rows, { - type: `put`, - key: `undef1`, - value: undefined, - }) - const lookup = index.lookup.bind(index) - const united = vi - .spyOn(index, `lookup`) - .mockImplementation((op, value) => - op === `eq` && value === undefined - ? new Set([`null1`, `undef1`]) - : lookup(op, value), - ) - try { - expect(() => expectNullishMembership(index, rows)).toThrowError( - /expected/, - ) - expect(united).toHaveBeenCalledWith(`eq`, undefined) - } finally { - united.mockRestore() - } + for (const action of actions) applyNullishAction(index, rows, action) + } + oracleProperty({ + id: `index-update.nullish-fixed`, + law: { + statement: `Null and undefined preserve exact membership and ordered-read semantics after each direct index action.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `The named fixed nullish histories: single-key transitions, coexisting buckets, IN lists, endpoint and cursor reads, removal, lower bounds and builds.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [ + { + label: `keeps membership after undefined to null`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `a`, value: null }, + ], + }, + { + label: `keeps membership after null to undefined`, + actions: [ + { type: `put`, key: `a`, value: null }, + { type: `put`, key: `a`, value: undefined }, + ], + }, + { + label: `keeps membership after defined to undefined`, + actions: [ + { type: `put`, key: `a`, value: 1 }, + { type: `put`, key: `a`, value: undefined }, + ], + }, + { + label: `keeps membership after undefined to defined`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `a`, value: 1 }, + ], + }, + { + label: `keeps membership after undefined to undefined`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `a`, value: undefined }, + ], + }, + { + label: `keeps coexisting null and undefined buckets distinct`, + actions: [ + { type: `put`, key: `null1`, value: null }, + { type: `put`, key: `undef1`, value: undefined }, + { type: `put`, key: `num1`, value: 1 }, + ], + }, + { + label: `returns no undefined key from a nonempty all-defined index`, + actions: [ + { type: `put`, key: `a`, value: 1 }, + { type: `put`, key: `b`, value: 2 }, + ], + }, + { + label: `unites undefined and defined IN members without distractors`, + actions: [ + { type: `put`, key: `undef`, value: undefined }, + { type: `put`, key: `one`, value: 1 }, + { type: `put`, key: `two`, value: 2 }, + { type: `put`, key: `three`, value: 3 }, + ], + }, + { + label: `returns every undefined owner from a singleton IN list`, + actions: [ + { type: `put`, key: `undef1`, value: undefined }, + { type: `put`, key: `undef2`, value: undefined }, + { type: `put`, key: `one`, value: 1 }, + ], + }, + { + label: `distinguishes endpoint reads from explicit undefined cursors`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `b`, value: 1 }, + { type: `put`, key: `c`, value: 2 }, + ], + }, + { + label: `traverses every undefined owner before continuing to numbers`, + actions: [ + { type: `put`, key: `undef1`, value: undefined }, + { type: `put`, key: `undef2`, value: undefined }, + { type: `put`, key: `undef3`, value: undefined }, + { type: `put`, key: `one`, value: 1 }, + { type: `put`, key: `two`, value: 2 }, + ], + }, + { + label: `retains the second undefined owner after removing the first`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `b`, value: undefined }, + { type: `delete`, key: `a` }, + ], + }, + { + label: `distinguishes explicit and omitted undefined lower bounds`, + actions: [ + { type: `put`, key: `undef`, value: undefined }, + { type: `put`, key: `one`, value: 1 }, + { type: `put`, key: `two`, value: 2 }, + { type: `put`, key: `three`, value: 3 }, + ], + }, + { + label: `includes both undefined owners within double undefined bounds`, + actions: [ + { type: `put`, key: `a`, value: undefined }, + { type: `put`, key: `b`, value: undefined }, + { type: `put`, key: `c`, value: 1 }, + ], + }, + { + label: `build preserves nulls-first ordered reads and ranges`, + // A second build changes insertion order; the model still predicts the + // same returned key order and complete upper-bound results. + actions: [ + { + type: `build`, + entries: [ + [`undefined`, undefined], + [`one`, 1], + ], + }, + { + type: `build`, + entries: [ + [`one`, 1], + [`undefined`, undefined], + ], + }, + { type: `put`, key: `two`, value: 2 }, + { + type: `build`, + entries: [ + [`undef2`, undefined], + [`null`, null], + [`undef1`, undefined], + [`one`, 1], + ], + }, + ], + }, + ] satisfies Array<{ label: string; actions: Array }>, + label: ({ label }) => label, + resolve: ({ actions }) => ({ actions }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-fixed`, + plan, + observe, + () => runNullishFixed(plan.actions), + ), + check: forwardAssertionJudgment, }) - test(`rejects an exact lookup that loses one undefined owner`, () => { - oracleRoute(`calibration`) + const runUndefinedStringOwners = () => { const index = new IndexType(1, new PropRef([`status`])) - const rows = new Map([ + const rows = new Map() + const exactProbes = [undefined, `active`, `inactive`] + const inProbes = [[undefined], [undefined, `active`]] + for (const [key, value] of [ [`a`, `active`], [`b`, undefined], [`c`, `inactive`], [`d`, undefined], - ]) - for (const [key, status] of rows) index.add(key, { status }) - const lookup = index.equalityLookup.bind(index) - const truncated = vi - .spyOn(index, `equalityLookup`) - .mockImplementation((value) => - value === undefined ? new Set([`b`]) : lookup(value), - ) - try { - expect(() => - expectExactMembership(index, rows, [undefined], [[undefined]]), - ).toThrowError(/expected/) - expect(truncated).toHaveBeenCalledWith(undefined) - } finally { - truncated.mockRestore() - } - }) - - 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, { - type: `put`, - key: `undef`, - value: undefined, - }) - applyNullishAction(index, rows, { type: `put`, key: `one`, value: 1 }) - const lookup = index.inArrayLookup.bind(index) - const dropped = vi - .spyOn(index, `inArrayLookup`) - .mockImplementation((values) => - values.length === 2 && values[0] === 1 && values[1] === undefined - ? new Set([`one`]) - : lookup(values), - ) - try { - expect(() => expectNullishMembership(index, rows)).toThrowError( - /expected/, - ) - expect(dropped).toHaveBeenCalledWith([1, undefined]) - } finally { - dropped.mockRestore() - } - }) - - 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, { - type: `put`, - key: `undef1`, - value: undefined, - }) - applyNullishAction(index, rows, { - type: `put`, - key: `undef2`, - value: undefined, - }) - const lookup = index.inArrayLookup.bind(index) - const truncated = vi - .spyOn(index, `inArrayLookup`) - .mockImplementation((values) => - values.length === 1 && values[0] === undefined - ? new Set([`undef1`]) - : lookup(values), - ) - try { - expect(() => expectNullishMembership(index, rows)).toThrowError( - /expected/, - ) - expect(truncated).toHaveBeenCalledWith([undefined]) - } finally { - truncated.mockRestore() - } - }) - - 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, { - type: `put`, - key: `undef`, - value: undefined, - }) - applyNullishAction(index, rows, { type: `put`, key: `one`, value: 1 }) - const lookup = index.lookup.bind(index) - const firstOnly = vi - .spyOn(index, `lookup`) - .mockImplementation((op, value) => - op === `in` && - Array.isArray(value) && - value.length === 2 && - value[0] === undefined && - value[1] === 1 - ? new Set([`undef`]) - : lookup(op, value), - ) - try { - expect(() => expectNullishMembership(index, rows)).toThrowError( - /expected/, - ) - expect(firstOnly).toHaveBeenCalledWith(`in`, [undefined, 1]) - } finally { - firstOnly.mockRestore() - } - }) - - 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], - [`b`, 1], - [`c`, 2], - ]) - for (const [key, value] of rows) index.add(key, { value }) - const dropped = vi - .spyOn(index, `takeFromStart`) - .mockImplementation(() => [`b`, `c`]) - try { - expect(() => expectNullishOrderedReads(index, rows)).toThrowError( - /expected/, - ) - expect(dropped).toHaveBeenCalled() - } finally { - dropped.mockRestore() - } - }) - - 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], - [`undef2`, undefined], - [`undef3`, undefined], - [`one`, 1], - [`two`, 2], - ]) - for (const [key, value] of rows) index.add(key, { value }) - const take = index.take.bind(index) - const repeated = vi - .spyOn(index, `take`) - .mockImplementation((n, from, filter) => - n === 10 && from === undefined - ? [`undef3`, `one`, `two`] - : take(n, from, filter), - ) - try { - expect(() => expectNullishOrderedReads(index, rows)).toThrowError( - /expected/, - ) - expect(repeated).toHaveBeenCalledWith(10, undefined) - } finally { - repeated.mockRestore() - } - }) - - 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], - [`one`, 1], - [`two`, 2], - [`three`, 3], - ]) - for (const [key, value] of rows) index.add(key, { value }) - const rangeQuery = index.rangeQuery.bind(index) - const dropped = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => - `from` in options && - options.from === undefined && - options.to === 2 && - options.fromInclusive === true - ? new Set([`one`, `two`]) - : rangeQuery(options), - ) - try { - expect(() => expectNullishOrderedReads(index, rows)).toThrowError( - /expected/, - ) - expect(dropped).toHaveBeenCalledWith({ - from: undefined, - fromInclusive: true, - to: 2, - toInclusive: true, - }) - } finally { - dropped.mockRestore() - } - }) - - 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], - [`b`, undefined], - [`c`, 1], - ]) - for (const [key, value] of rows) index.add(key, { value }) - const rangeQuery = index.rangeQuery.bind(index) - const truncated = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => - `from` in options && - options.from === undefined && - `to` in options && - options.to === undefined - ? new Set([`a`]) - : rangeQuery(options), - ) - try { - expect(() => expectNullishOrderedReads(index, rows)).toThrowError( - /expected/, - ) - expect(truncated).toHaveBeenCalledWith({ - from: undefined, - fromInclusive: true, - to: undefined, - toInclusive: true, - }) - } finally { - truncated.mockRestore() - } - }) - - 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], - [`b`, null], - [`c`, 1], - ]) - for (const [key, value] of rows) index.add(key, { value }) - const rangeQuery = index.rangeQuery.bind(index) - const exactOnly = vi - .spyOn(index, `rangeQuery`) - .mockImplementation((options) => - `from` in options && - options.from === null && - `to` in options && - options.to === null - ? new Set([`b`]) - : rangeQuery(options), - ) - try { - expect(() => expectNullishOrderedReads(index, rows)).toThrowError( - /expected/, - ) - expect(exactOnly).toHaveBeenCalledWith({ - from: null, - fromInclusive: true, - to: null, - toInclusive: true, - }) - } finally { - exactOnly.mockRestore() + ] as const) { + index.add(key, { status: value }) + rows.set(key, value) + expectExactMembership(index, rows, exactProbes, inProbes) } + } + oracleProperty({ + id: `index-update.nullish-string-owners`, + law: { + statement: `Exact and IN lookups find every undefined owner beside distinct string values after each add.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed history of four string-or-undefined status rows with exact and IN probes.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [`finds every undefined owner beside distinct string values`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.nullish-string-owners`, + plan, + observe, + runUndefinedStringOwners, + ), + check: forwardAssertionJudgment, }) }, ) @@ -1439,52 +1813,96 @@ describe.each(indexTypes)( `$name comparator`, ({ compare, ordersNumbers }) => { describe.each(probes)(`$name`, ({ run, atomic }) => { - 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++) { - if (step(() => index.add(String(value), { value }))) return - rows.set(String(value), value) - } - if (step(() => run(index, size)) && atomic && ordersNumbers) - expectIndexMatchesModel(index, rows) + const runInvalidComparator = (size: number) => { + const { index, step } = createRecordedIndex(compare) + const rows = new Map() + for (let value = 0; value < size; value++) { + if (step(() => index.add(String(value), { value }))) return + rows.set(String(value), value) + } + if (step(() => run(index, size)) && atomic && ordersNumbers) + expectIndexMatchesModel(index, rows) + } + oracleProperty({ + id: `index-update.invalid-comparator`, + law: { + statement: `An index operation throws a comparator TypeError exactly when its comparator returned an invalid result, and an atomic rejected write leaves the numeric model intact.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `Three comparators by seven probes, after 0, 1, 4, 5, 32, 33 and 65 accepted rows.`, }, - ) + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [0, 1, 4, 5, 32, 33, 65], + label: (size) => + `throws exactly when it receives an invalid result after ${size} rows`, + resolve: (size) => ({ size }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.invalid-comparator`, + plan, + observe, + () => runInvalidComparator(plan.size), + ), + check: forwardAssertionJudgment, + }) }) }, ) - 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) - expect(index.keyCount).toBe(1) - 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 - : String(left).localeCompare(String(right)), - ) - for (const value of [`a`, `x`, `z`]) { - expect(step(() => index.add(value, { value }))).toBe(false) - } - expect(step(() => index.remove(`x`, { value: `x` }))).toBe(true) - expect(index.keyCount).toBe(3) - for (const value of [`a`, `x`, `z`]) { - expect(index.lookup(`eq`, value)).toEqual(new Set([value])) - } + const runRejectedComparatorWrite = (name: string) => { + if (name === `crashes at the second write of the reported string rows`) { + 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) + } else if (name === `a rejected remove leaves its row indexed`) { + 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) + expect(index.keyCount).toBe(1) + expect(index.lookup(`eq`, `x`)).toEqual(new Set([`x`])) + } else if (name === `a rejected remove preserves other exact values`) { + const { index, step } = createRecordedIndex((left, right) => + left === `x` && right === `x` + ? NaN + : String(left).localeCompare(String(right)), + ) + for (const value of [`a`, `x`, `z`]) { + expect(step(() => index.add(value, { value }))).toBe(false) + } + expect(step(() => index.remove(`x`, { value: `x` }))).toBe(true) + expect(index.keyCount).toBe(3) + for (const value of [`a`, `x`, `z`]) { + expect(index.lookup(`eq`, value)).toEqual(new Set([value])) + } + } else throw new Error(`unknown rejected comparator history: ${name}`) + } + oracleProperty({ + id: `index-update.rejected-comparator-writes`, + law: { + statement: `A write that receives an invalid comparator result throws, and a rejected remove leaves its row and the other exact values indexed.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `Three fixed string-row histories: the second write of the reported rows, and a rejected remove with and without peers.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [ + `crashes at the second write of the reported string rows`, + `a rejected remove leaves its row indexed`, + `a rejected remove preserves other exact values`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.rejected-comparator-writes`, + plan, + observe, + () => runRejectedComparatorWrite(plan.name), + ), + check: forwardAssertionJudgment, }) }, ) @@ -1573,33 +1991,55 @@ describe.each([ return { collection, sync: () => syncApi! } } - 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() - collection.subscribeChanges((changes) => { - for (const change of changes) - if (change.type === `delete`) published.delete(change.key) - else published.add(change.key) - }) + const runCollectionBoundary = async ({ + write, + }: (typeof writePaths)[number]) => { + const { collection, sync } = createNamedCollection() + await withCollectionCleanup(collection, () => { + const published = new Set() + collection.subscribeChanges((changes) => { + for (const change of changes) + if (change.type === `delete`) published.delete(change.key) + else published.add(change.key) + }) - write(collection, sync, { id: 1, name: `ann` }) - expect(collection.status).toBe(`ready`) - expectPublishedKeysMatchCollection(published, collection) + write(collection, sync, { id: 1, name: `ann` }) + expect(collection.status).toBe(`ready`) + expectPublishedKeysMatchCollection(published, collection) - expect(() => write(collection, sync, { id: 2, name: `bob` })).toThrow( - TypeError, - ) - expect(collection.status).toBe(`crashed`) - expect(() => collection.insert({ id: 3, name: `cy` })).toThrow( - CollectionInErrorStateError, - ) - }) + expect(() => write(collection, sync, { id: 2, name: `bob` })).toThrow( + TypeError, + ) + expect(collection.status).toBe(`crashed`) + expect(() => collection.insert({ id: 3, name: `cy` })).toThrow( + CollectionInErrorStateError, + ) + }) + } + oracleProperty({ + id: `index-update.collection-boundary`, + law: { + statement: `An index throw during a Collection write crashes the Collection instead of publishing partial success: published keys equal live keys until the throw, then the status is crashed and the next mutation throws.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `BasicIndex and BTreeIndex with a subtracting comparator over string names, through an optimistic insert and a sync commit.`, }, - ) + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: writePaths.map(({ name }) => name), + label: (name) => + `${name} crashes the collection instead of publishing partial success`, + resolve: (name) => ({ + path: writePaths.find((candidate) => candidate.name === name)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.collection-boundary`, + plan, + observe, + () => runCollectionBoundary(plan.path), + ), + check: forwardAssertionJudgment, + }) }, ) @@ -1633,101 +2073,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] } - const index = new IndexType(1, new PropRef([`value`])) - index.add(retired.key, retired) - index.add(live.key, live) - index.remove(retired.key, retired) - const identities = [retired.value, live.value] - expectExactIdentities(index, [live], identities) - const lookup = index.equalityLookup.bind(index) - const wrongOutput = vi - .spyOn(index, `equalityLookup`) - .mockImplementation((value) => - value === retired.value ? new Set([retired.key]) : lookup(value), - ) - try { - // The former live-only observation accepts this stale retired bucket. - expectExactIdentities(index, [live], [live.value]) - expect(() => - expectExactIdentities(index, [live], identities), - ).toThrowError(/expected/) - } finally { - wrongOutput.mockRestore() - } - const reused = { ...retired, key: `reused` } - index.add(reused.key, reused) - expectExactIdentities(index, [live, reused], identities) - }) - - 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 }) - const replacement = new Map([[`new`, 1]]) - // This is the raw state an append-only build would expose, not a source mutant. - expect(() => expectIndexMatchesModel(index, replacement)).toThrowError( - /expected/, - ) - index.build([[`new`, { value: 1 }]]) - expectIndexMatchesModel(index, replacement) - index.update(`new`, { value: 1 }, { value: 2 }) - expectIndexMatchesModel(index, new Map([[`new`, 2]])) - }) - - 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), - groupId, - value: { groupId, position }, - }), - ) - const identities = rows.map((row) => row.value) - const index = new IndexType(1, new PropRef([`value`]), undefined, { - compareFn: (left, right) => - (left as { groupId: number }).groupId - - (right as { groupId: number }).groupId, - }) - for (const row of [...rows].reverse()) - index.add(row.key, { value: row.value }) - expectCustomGroups(index, rows, identities) - const wrongOutput = vi - .spyOn(index, `takeFromStart`) - .mockReturnValue([`1`, `0`, `2`]) - try { - expect(() => expectCustomGroups(index, rows, identities)).toThrowError( - /expected/, - ) - } finally { - wrongOutput.mockRestore() - } - // The driver-visible objects can change without rewriting the authority. - for (const row of rows) row.value.groupId = 0 - index.build(rows.map((row) => [row.key, { value: row.value }])) - expect(rows.map((row) => row.groupId)).toEqual([1, 1, 2]) - // Re-reading payload fields as facts reproduces the old false green. - const driftedAuthority = rows.map((row) => ({ - ...row, - groupId: row.value.groupId, - })) - expectCustomGroups(index, driftedAuthority, identities) - expect(() => expectCustomGroups(index, rows, identities)).toThrowError( - /expected/, - ) - for (const row of rows) row.value.groupId = row.groupId - index.build(rows.map((row) => [row.key, { value: row.value }])) - expectCustomGroups(index, rows, identities) - }) - - test(`moves exact string membership across a comparator-equal update`, () => { - oracleRoute(`fixed history`) + const runComparatorEqualUpdate = () => { // 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. @@ -1806,6 +2152,29 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { } finally { wrongLookup.mockRestore() } + } + oracleProperty({ + id: `index-update.comparator-equal-update`, + law: { + statement: `Exact string membership follows the source value across comparator-equal updates, while a range over a comparator group returns every key of that group.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed history over A/a and B/b in two comparator groups, with two inline wrong-dispatch spies that the shared check must reject.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [ + `moves exact string membership across a comparator-equal update`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.comparator-equal-update`, + plan, + observe, + runComparatorEqualUpdate, + ), + check: forwardAssertionJudgment, }) // Group grammar: 2..20 generated IDs in 0..4, with a forced duplicate first @@ -1965,6 +2334,61 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { () => runExactIdentityHistory(plan.history), ), check: forwardAssertionJudgment, + // The calibration replays its old body as a mutant driver. + calibrations: [ + { + name: `rejects stale retired identity outputs without rejecting live reuse`, + law: `index-update.exact-identity`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.exact-identity`, + plan, + observe, + () => { + const rejections: Array = [] + const symbol = Symbol(`same group`) + const retired = { key: `old`, groupId: 0, value: [symbol] } + const live = { key: `live`, groupId: 0, value: [symbol] } + const index = new IndexType(1, new PropRef([`value`])) + index.add(retired.key, retired) + index.add(live.key, live) + index.remove(retired.key, retired) + const identities = [retired.value, live.value] + clean(() => expectExactIdentities(index, [live], identities)) + const lookup = index.equalityLookup.bind(index) + const wrongOutput = vi + .spyOn(index, `equalityLookup`) + .mockImplementation((value) => + value === retired.value + ? new Set([retired.key]) + : lookup(value), + ) + try { + // The former live-only observation accepts this stale retired bucket. + clean(() => expectExactIdentities(index, [live], [live.value])) + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectExactIdentities(index, [live], identities), + ), + ) + } finally { + wrongOutput.mockRestore() + } + const reused = { ...retired, key: `reused` } + index.add(reused.key, reused) + clean(() => + expectExactIdentities(index, [live, reused], identities), + ) + + rejectAt( + `calibration: rejects stale retired identity outputs without rejecting live reuse`, + rejections[0]!, + ) + }, + ), + }, + ], }) const runCustomComparatorHistory = async (generatedGroups: Array) => { @@ -2096,6 +2520,82 @@ describe.each(indexTypes)(`%s comparator groups`, (_indexName, IndexType) => { () => runCustomComparatorHistory(plan.history), ), check: forwardAssertionJudgment, + // The calibration replays its old body as a mutant driver. + calibrations: [ + { + name: `custom facts reject mutated payload groups and insertion-order ties`, + law: `index-update.custom-comparator`, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.custom-comparator`, + plan, + observe, + () => { + const rejections: Array = [] + const rows = [1, 1, 2].map((groupId, position) => + Object.freeze({ + key: String(position), + groupId, + value: { groupId, position }, + }), + ) + const identities = rows.map((row) => row.value) + const index = new IndexType( + 1, + new PropRef([`value`]), + undefined, + { + compareFn: (left, right) => + (left as { groupId: number }).groupId - + (right as { groupId: number }).groupId, + }, + ) + for (const row of [...rows].reverse()) + index.add(row.key, { value: row.value }) + clean(() => expectCustomGroups(index, rows, identities)) + const wrongOutput = vi + .spyOn(index, `takeFromStart`) + .mockReturnValue([`1`, `0`, `2`]) + try { + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectCustomGroups(index, rows, identities), + ), + ) + } finally { + wrongOutput.mockRestore() + } + // The driver-visible objects can change without rewriting the authority. + for (const row of rows) row.value.groupId = 0 + index.build(rows.map((row) => [row.key, { value: row.value }])) + expect(rows.map((row) => row.groupId)).toEqual([1, 1, 2]) + // Re-reading payload fields as facts reproduces the old false green. + const driftedAuthority = rows.map((row) => ({ + ...row, + groupId: row.value.groupId, + })) + clean(() => + expectCustomGroups(index, driftedAuthority, identities), + ) + rejections.push( + rejection( + (error) => /expected/.test(error.message), + () => expectCustomGroups(index, rows, identities), + ), + ) + for (const row of rows) row.value.groupId = row.groupId + index.build(rows.map((row) => [row.key, { value: row.value }])) + clean(() => expectCustomGroups(index, rows, identities)) + + rejectAt( + `calibration: custom facts reject mutated payload groups and insertion-order ties`, + rejections[0]!, + ) + }, + ), + }, + ], }) }) @@ -2141,25 +2641,41 @@ function expectConservedIndexKeys( describe.each(indexTypes)( `%s mixed-value key conservation`, (_indexName, IndexType) => { - 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) { - index.add(row.id, row) - rows.set(row.id, row.value) - expectConservedIndexKeys(index, rows) - } - index.remove(removed.id, removed) - rows.delete(removed.id) + const runMixedKeyDeletion = (removed: (typeof mixedRows)[number]) => { + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + for (const row of mixedRows) { + index.add(row.id, row) + rows.set(row.id, row.value) expectConservedIndexKeys(index, rows) + } + index.remove(removed.id, removed) + rows.delete(removed.id) + expectConservedIndexKeys(index, rows) + } + oracleProperty({ + id: `index-update.mixed-key-deletion`, + law: { + statement: `A full ordered read of a mixed number/string index returns each live source key once after each add and a deletion.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `The fixed mixed number/string rows, each deleted once after all are added; key conservation only, not relative order.`, }, - ) + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: mixedRows, + label: ({ id }) => `keeps every surviving key once after deleting ${id}`, + resolve: (removed) => ({ removed }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.mixed-key-deletion`, + plan, + observe, + () => runMixedKeyDeletion(plan.removed), + ), + check: forwardAssertionJudgment, + }) - test(`keeps one copy of each key after a mixed-value delete, update, and add`, () => { - oracleRoute(`fixed history`) + const runMixedKeyHistory = () => { const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() const add = (id: string, value: number | string) => { @@ -2178,9 +2694,32 @@ describe.each(indexTypes)( rows.set(`b`, 2) expectConservedIndexKeys(index, rows) add(`a`, `2`) + } + oracleProperty({ + id: `index-update.mixed-key-history`, + law: { + statement: `A full ordered read of a mixed number/string index returns each live source key once after each action.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed history of adds, a delete, a cross-type update and a re-add over equal-looking number and string values.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [ + `keeps one copy of each key after a mixed-value delete, update, and add`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.mixed-key-history`, + plan, + observe, + runMixedKeyHistory, + ), + check: forwardAssertionJudgment, }) - test.each([ + const temporalOwnerRows = [ { name: `ZonedDateTime`, first: @@ -2195,26 +2734,51 @@ describe.each(indexTypes)( second: Temporal.Duration.from({ nanoseconds: 10 }), text: `tanstack-db:temporal:Temporal.Duration:time:5:fake`, }, - ])( - `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 [ - [`a`, first], - [`b`, second], - [`c`, text], - ] as const) { - index.add(id, { id, value }) - rows.set(id, value) - expectConservedIndexKeys(index, rows) - } - index.remove(`b`, { id: `b`, value: second }) - rows.delete(`b`) + ] + const runTemporalOwners = ({ + first, + second, + text, + }: (typeof temporalOwnerRows)[number]) => { + const index = new IndexType(1, new PropRef([`value`])) + const rows = new Map() + for (const [id, value] of [ + [`a`, first], + [`b`, second], + [`c`, text], + ] as const) { + index.add(id, { id, value }) + rows.set(id, value) expectConservedIndexKeys(index, rows) + } + index.remove(`b`, { id: `b`, value: second }) + rows.delete(`b`) + expectConservedIndexKeys(index, rows) + } + oracleProperty({ + id: `index-update.temporal-key-owners`, + law: { + statement: `A full ordered read returns each live source key once when Temporal values share an index with an ordinary string that imitates their encoding.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `ZonedDateTime and Duration values beside one imitating string, after adds and one deletion.`, }, - ) + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: temporalOwnerRows.map(({ name }) => name), + label: (name) => + `keeps ${name} and ordinary-string owners after deletion`, + resolve: (name) => ({ + row: temporalOwnerRows.find((candidate) => candidate.name === name)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.temporal-key-owners`, + plan, + observe, + () => runTemporalOwners(plan.row), + ), + check: forwardAssertionJudgment, + }) }, ) @@ -2222,8 +2786,7 @@ describe.each([ [`BasicIndex`, BasicIndex], [`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 runMixedPublicSnapshot = async () => { const options = mockSyncCollectionOptions<{ id: string value: number | string @@ -2266,6 +2829,27 @@ describe.each([ rows, ) }) + } + oracleProperty({ + id: `index-update.mixed-public-snapshot`, + law: { + statement: `A complete bounded indexed public snapshot returns each surviving source key once after a source deletion.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed history: the mixed rows, one synced deletion, then an optimized ordered read through the created index.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [`returns each surviving key once after a source deletion`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.mixed-public-snapshot`, + plan, + observe, + runMixedPublicSnapshot, + ), + check: forwardAssertionJudgment, }) }) @@ -2307,8 +2891,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 runBinaryOwnership = () => { const index = new IndexType(1, new PropRef([`value`])) const rows = new Map() const bytes = (value = 1) => new Uint8Array([value]) @@ -2336,6 +2919,27 @@ describe.each(indexTypes)(`%s binary-value ownership`, (_name, IndexType) => { index.update(`d`, { value: oldBytes }, { value: bytes() }) rows.set(`d`, bytes()) check() + } + oracleProperty({ + id: `index-update.binary-ownership`, + law: { + statement: `Exact membership and both complete index reads of byte values agree with the source Map after each action.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed history of equal-content and distinct byte adds, removes and an update; byte order is not part of the law.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [`does not retain an ordered entry after equal-content removal`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.binary-ownership`, + plan, + observe, + runBinaryOwnership, + ), + check: forwardAssertionJudgment, }) }) @@ -2367,8 +2971,7 @@ function predictMonthDayOwners( describe.each(indexTypes)( `%s unordered Temporal equality`, (_name, IndexType) => { - test(`accepts distinct PlainMonthDay values for exact lookup`, async () => { - oracleRoute(`fixed history`) + const runMonthDayEquality = async () => { const rows = [ { id: `jan`, value: Temporal.PlainMonthDay.from(`--01-01`) }, { id: `feb`, value: Temporal.PlainMonthDay.from(`--02-01`) }, @@ -2402,6 +3005,27 @@ describe.each(indexTypes)( expect(read()).toEqual([...expected].sort()) expect(lookup).toHaveBeenCalled() }) + } + oracleProperty({ + id: `index-update.month-day-equality`, + law: { + statement: `Exact lookup and public eq reads of PlainMonthDay values return the source keys with the same month code and day, before and after index creation.`, + authority: `IndexInterface and Collection ordered-read/createIndex contracts stated above`, + limits: `One fixed two-row history probing a copy of February 1.`, + }, + owns: [`src/indexes/basic-index.ts`, `src/indexes/btree-index.ts`], + histories: [`accepts distinct PlainMonthDay values for exact lookup`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `index-update.month-day-equality`, + plan, + observe, + runMonthDayEquality, + ), + check: forwardAssertionJudgment, }) }, ) From e99dfe05fac0718f63987703b17afb19326c17db Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:20:59 -0700 Subject: [PATCH 079/116] test(db): make indexing-reuse calibrations ordinary tests The rename to indexing-reuse-calibration.test.ts landed in the previous BTree commit by mistake. This commit completes it: both tests hand planted relations or renamed constructors to the shared indexing checker and run no production, so the file drops its declareOracle call and oracle routes. Its owns duplicated index-update-oracle's basic-index.ts and btree-index.ts entries. Co-authored-by: Isaac --- .../indexing-reuse-calibration.test.ts | 17 +++-------------- 1 file changed, 3 insertions(+), 14 deletions(-) diff --git a/packages/db/tests/conformance/indexing-reuse-calibration.test.ts b/packages/db/tests/conformance/indexing-reuse-calibration.test.ts index 109716402e..c106deee3d 100644 --- a/packages/db/tests/conformance/indexing-reuse-calibration.test.ts +++ b/packages/db/tests/conformance/indexing-reuse-calibration.test.ts @@ -3,12 +3,12 @@ * finite grammar. Each pair of admitted declarations supplies the opposite of * the promised identity relation: a shared output despite independent bindings. Rejected * declarations supply no output and impose no output-identity comparison. - * This tests checker sensitivity, not another production receiving path. + * This tests checker sensitivity, not another production receiving path, so + * these are ordinary calibration tests: they run no production. The shared + * checker, model, and grammar live in indexing-config-oracle.ts. */ import { expect, test } from 'vitest' import { BTreeIndex, BasicIndex } from '../../src' -import { declareOracle } from '../oracle-declaration' -import { oracleRoute } from '../oracle-config' import { expectIndependentIndexingOutputs, expectedIndexing, @@ -22,19 +22,9 @@ 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], -}) - 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) { @@ -55,7 +45,6 @@ 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 }) From 79d6d9b5a1dc10233fccfb16148682abf381f48a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:22:21 -0700 Subject: [PATCH 080/116] test(db): register transaction and mutation oracle cases through oracleProperty Production-error, optimistic-history, optimistic-transaction, transaction-ownership, paced-mutation and composition cases become enumerated or fixed histories of properties that reuse the files' existing drivers and checks; their calibrations become mutant drivers with the old matchers. Tests that run no production move to calibration files: documented-mutations and mutation-confirmation become *-calibration.test.ts, and the production-error docs check moves to a test-free production-error-messages-docs-oracle.ts. The test:oracles script follows the mutation-confirmation rename. Co-authored-by: Isaac --- packages/db/package.json | 2 +- .../db/tests/converted-properties/db-core.ts | 44 + ... documented-mutations-calibration.test.ts} | 16 +- ...mutation-confirmation-calibration.test.ts} | 15 +- .../optimistic-composition-oracle.test.ts | 140 +-- ...istic-history-outcomes-calibration.test.ts | 97 ++- ...optimistic-history-outcomes-oracle.test.ts | 442 +++++----- ...imistic-history-publication-oracle.test.ts | 771 ++++++++-------- ...mistic-transaction-oracle.property.test.ts | 823 ++++++++++-------- .../db/tests/paced-mutations-oracle.test.ts | 647 ++++++++------ ...duction-error-messages-calibration.test.ts | 96 ++ .../production-error-messages-docs-oracle.ts | 74 ++ .../production-error-messages-oracle.test.ts | 397 ++++----- ...nsaction-ownership-oracle.property.test.ts | 125 ++- 14 files changed, 2086 insertions(+), 1603 deletions(-) rename packages/db/tests/{documented-mutations-oracle.test.ts => documented-mutations-calibration.test.ts} (92%) rename packages/db/tests/{mutation-confirmation-oracle.test.ts => mutation-confirmation-calibration.test.ts} (89%) create mode 100644 packages/db/tests/production-error-messages-calibration.test.ts create mode 100644 packages/db/tests/production-error-messages-docs-oracle.ts diff --git a/packages/db/package.json b/packages/db/package.json index fb2b995ea4..505e8950a9 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-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", + "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-calibration.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", diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index d29e891202..cb9ce5230c 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -54,6 +54,20 @@ export const properties: ReadonlyArray = [ `index-update.binary-ownership`, `index-update.month-day-equality`, // transactions and mutations + `collection-state.optimistic-composition`, + `paced-mutations.timeline`, + `paced-mutations.held-pacing`, + `paced-mutations.settlement`, + `collection-state.optimistic-outcome-cells`, + `collection-state.pending-peer-settlement`, + `collection-state.external-completion`, + `collection-state.same-key-cases`, + `collection-state.delete-insert`, + `collection-state.typed-keys`, + `optimistic-history.publication-cases`, + `production-errors.classes`, + `production-errors.sites`, + `production-errors.generated-docs`, // IndexedDB and live-query observers // proxies and conformance `proxy.own-property-histories`, @@ -112,6 +126,36 @@ export const owners: ReadonlyArray = [ `reference-model-fixed nullish-fixed nullish-string-owners invalid-comparator rejected-comparator-writes collection-boundary comparator-equal-update mixed-key-deletion mixed-key-history temporal-key-owners mixed-public-snapshot binary-ownership month-day-equality`, ], // transactions and mutations + [ + `db/tests/optimistic-composition-oracle.test.ts`, + `collection-state`, + `optimistic-composition`, + ], + [ + `db/tests/paced-mutations-oracle.test.ts`, + `paced-mutations`, + `timeline held-pacing settlement`, + ], + [ + `db/tests/optimistic-history-outcomes-oracle.test.ts`, + `collection-state`, + `optimistic-outcome-cells pending-peer-settlement external-completion`, + ], + [ + `db/tests/optimistic-transaction-oracle.property.test.ts`, + `collection-state`, + `same-key-cases delete-insert typed-keys`, + ], + [ + `db/tests/optimistic-history-publication-oracle.test.ts`, + `optimistic-history`, + `publication-cases`, + ], + [ + `db/tests/production-error-messages-oracle.test.ts`, + `production-errors`, + `classes sites generated-docs`, + ], // IndexedDB and live-query observers // proxies and conformance [ diff --git a/packages/db/tests/documented-mutations-oracle.test.ts b/packages/db/tests/documented-mutations-calibration.test.ts similarity index 92% rename from packages/db/tests/documented-mutations-oracle.test.ts rename to packages/db/tests/documented-mutations-calibration.test.ts index ddf953e261..862e8a444f 100644 --- a/packages/db/tests/documented-mutations-oracle.test.ts +++ b/packages/db/tests/documented-mutations-calibration.test.ts @@ -3,10 +3,10 @@ * API rejection. Every control uses the documentation grammar, existing shared * outcome model and shared comparison; it supplies no separate expected answer. * These controls establish judgment sensitivity, not execution of an adapter. + * They run no production, so they are ordinary tests beside the shared + * documented-mutation judgment in documented-mutations-oracle.ts. */ import { expect, test } from 'vitest' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' import { checkDocumentedMutation, documentedMutationHistories, @@ -70,7 +70,6 @@ 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) => @@ -110,7 +109,6 @@ 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`, @@ -152,7 +150,6 @@ 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) => @@ -201,7 +198,6 @@ 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) @@ -244,11 +240,3 @@ test.each([ ).toThrow() } }) - -// 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], - grammar: [documentedMutationHistories], -}) diff --git a/packages/db/tests/mutation-confirmation-oracle.test.ts b/packages/db/tests/mutation-confirmation-calibration.test.ts similarity index 89% rename from packages/db/tests/mutation-confirmation-oracle.test.ts rename to packages/db/tests/mutation-confirmation-calibration.test.ts index 280b1501ab..6dfbb18a52 100644 --- a/packages/db/tests/mutation-confirmation-oracle.test.ts +++ b/packages/db/tests/mutation-confirmation-calibration.test.ts @@ -3,6 +3,8 @@ * controls use the same grammar, model and comparison as the Query and Electric * receivers. A legal model observation passes before each mutation is applied. * They establish checker sensitivity, not execution of either adapter. + * They run no production, so they are ordinary tests beside the shared + * judgment in mutation-confirmation-oracle.ts. */ import { expect, test } from 'vitest' import { @@ -11,8 +13,6 @@ import { mutationConfirmationHistories, mutationConfirmationRows, } from './mutation-confirmation-oracle' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' import type { MutationConfirmationHistory, MutationConfirmationObservation, @@ -116,7 +116,6 @@ 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) @@ -137,7 +136,6 @@ 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`), @@ -165,12 +163,3 @@ 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], - grammar: [mutationConfirmationHistories], -}) diff --git a/packages/db/tests/optimistic-composition-oracle.test.ts b/packages/db/tests/optimistic-composition-oracle.test.ts index 3b0f1da176..8060c0bfeb 100644 --- a/packages/db/tests/optimistic-composition-oracle.test.ts +++ b/packages/db/tests/optimistic-composition-oracle.test.ts @@ -1,6 +1,10 @@ -import { describe, expect, it } from 'vitest' -import { oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { describe, expect } from 'vitest' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' import { stripVirtualProps } from './utils.js' import { initial, @@ -73,65 +77,79 @@ function expectVisibleRow( expect(stripVirtualProps(collection.get(1))).toEqual(expectedRow) } -// 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], -}) +async function runOwnershipCase({ + order, + phase, + removed, + fields, +}: (typeof cases)[number]): Promise { + const fixture = source() + const pending = [ + pendingTransaction(), + pendingTransaction(), + pendingTransaction(), + ] + const patches: Array> = + fields === `disjoint` + ? [{ a: `a1` }, { b: `b2` }, { c: `c3` }] + : [{ a: `a1` }, { a: `a2`, b: `b2` }, { c: `c3` }] + const snapshots: Array = [undefined, undefined, undefined] + const expected = (excluded = -1) => + expectedOwnedRow(snapshots, initial, excluded) + try { + await fixture.collection.preload() + for (const index of order) { + snapshots[index] = { ...expected(), ...patches[index] } + pending[index]!.tx.mutate(() => + fixture.collection.update(1, (row) => { + Object.assign(row, patches[index]) + }), + ) + } + if (phase === `persisting`) { + for (const entry of pending) void entry.tx.commit().catch(() => {}) + } + expectVisibleRow(fixture.collection, expected()) + // Isolate one rollback's projection; conflict-cascade policy is unchanged. + pending[removed]!.tx.rollback({ isSecondaryRollback: true }) + expectVisibleRow(fixture.collection, expected(removed)) + } finally { + for (const entry of pending) { + entry.tx.rollback({ isSecondaryRollback: true }) + entry.done.resolve() + } + await Promise.all(pending.map((entry) => entry.settled)) + await fixture.collection.cleanup() + } +} 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(), - pendingTransaction(), - pendingTransaction(), - ] - const patches: Array> = - fields === `disjoint` - ? [{ a: `a1` }, { b: `b2` }, { c: `c3` }] - : [{ a: `a1` }, { a: `a2`, b: `b2` }, { c: `c3` }] - const snapshots: Array = [ - undefined, - undefined, - undefined, - ] - const expected = (excluded = -1) => - expectedOwnedRow(snapshots, initial, excluded) - try { - await fixture.collection.preload() - for (const index of order) { - snapshots[index] = { ...expected(), ...patches[index] } - pending[index]!.tx.mutate(() => - fixture.collection.update(1, (row) => { - Object.assign(row, patches[index]) - }), - ) - } - if (phase === `persisting`) { - for (const entry of pending) void entry.tx.commit().catch(() => {}) - } - expectVisibleRow(fixture.collection, expected()) - // Isolate one rollback's projection; conflict-cascade policy is unchanged. - pending[removed]!.tx.rollback({ isSecondaryRollback: true }) - expectVisibleRow(fixture.collection, expected(removed)) - } finally { - for (const entry of pending) { - entry.tx.rollback({ isSecondaryRollback: true }) - entry.done.resolve() - } - await Promise.all(pending.map((entry) => entry.settled)) - await fixture.collection.cleanup() - } + // 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. + oracleProperty({ + id: `collection-state.optimistic-composition`, + law: { + statement: `A key shows the snapshot of its newest live optimistic transaction, or its synced row when none is live, before and after one member rolls back.`, + authority: `The optimistic snapshot contract stated above and owned by optimistic-history-oracle.ts`, + limits: `One key, every creation order of three updates, both live phases, each rolled-back member, and disjoint or overlapping fields.`, }, - ) + owns: [ + `src/collection/state.ts#CollectionStateManager.recomputeOptimisticState`, + `src/transactions.ts`, + ], + histories: cases, + label: ({ order, phase, removed, fields }) => + `[ ${order.join(`, `)} ] / ${phase} / rollback ${removed} / ${fields}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.optimistic-composition`, + plan, + observe, + () => runOwnershipCase(plan.history), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/optimistic-history-outcomes-calibration.test.ts b/packages/db/tests/optimistic-history-outcomes-calibration.test.ts index c418bbef20..fe55679409 100644 --- a/packages/db/tests/optimistic-history-outcomes-calibration.test.ts +++ b/packages/db/tests/optimistic-history-outcomes-calibration.test.ts @@ -1,8 +1,16 @@ -// Preserve the premature-success shrinking and direct-replay control. -import { expect, it } from 'vitest' +// Preserve the premature-success shrinking and direct-replay control. The +// outcome-observer calibrations below run no production: they hand built +// outcomes and test-helper promises to the shared outcome checker. +import { describe, expect, it } from 'vitest' import fc from 'fast-check' +import { createDeferred } from '../src/deferred.js' import { oracleRoute } from './oracle-config.js' -import { expectHistoryOutcome } from './optimistic-history-oracle.js' +import { + expectHistoryOutcome, + observeHistoryPromise, + withHistoryCleanup, +} from './optimistic-history-oracle.js' +import type { HistoryOutcome } from './optimistic-history-oracle.js' it(`shrinks and replays a premature success counterexample`, () => { oracleRoute(`calibration`) @@ -27,3 +35,86 @@ it(`shrinks and replays a premature success counterexample`, () => { expect(replay.counterexample).toEqual(failed.counterexample) expect(replay.errorInstance).toMatchObject({ name: `AssertionError` }) }) + +describe(`Outcome observer calibration`, () => { + it.each([ + `early-success`, + `wrong-value`, + `wrong-error`, + `missing-error`, + `receipt-error`, + ] as const)( + `rejects %s through the actual outcome checker`, + async (fault) => { + const value = { id: 1 } + const error = new Error(`original`) + let expected: HistoryOutcome + let bad: HistoryOutcome + if (fault === `early-success`) { + expected = { status: `pending` } + bad = { status: `fulfilled`, value } + } else if (fault === `wrong-value`) { + expected = { status: `fulfilled`, value } + bad = { status: `fulfilled`, value: { ...value } } + } else if (fault === `receipt-error`) { + expected = { status: `fulfilled`, value: undefined } + bad = { status: `rejected`, reason: error } + } else { + expected = { status: `rejected`, reason: error } + bad = { + status: `rejected`, + reason: fault === `wrong-error` ? new Error(`original`) : undefined, + } + } + expectHistoryOutcome(expected, expected, `valid neighbor`) + expect(() => expectHistoryOutcome(bad, expected, fault)).toThrowError( + expect.objectContaining({ name: `AssertionError` }), + ) + const gate = createDeferred() + const observed = observeHistoryPromise(gate.promise) + expectHistoryOutcome( + observed.read(), + { status: `pending` }, + `before settlement`, + ) + if (expected.status === `rejected`) gate.reject(error) + else gate.resolve(value) + await observed.settled + expectHistoryOutcome( + observed.read(), + expected.status === `rejected` + ? expected + : { status: `fulfilled`, value }, + `after settlement`, + ) + }, + ) + it(`preserves successful results and single errors without wrappers`, async () => { + const value = { id: 1 } + expect( + await withHistoryCleanup( + () => Promise.resolve(value), + () => [() => {}], + ), + ).toBe(value) + const error = Object.freeze(new Error(`single`)) + for (const fromCleanup of [false, true]) { + const observed = observeHistoryPromise( + withHistoryCleanup( + () => (fromCleanup ? Promise.resolve(value) : Promise.reject(error)), + () => [ + () => { + if (fromCleanup) throw error + }, + ], + ), + ) + await observed.settled + expectHistoryOutcome( + observed.read(), + { status: `rejected`, reason: error }, + `single error`, + ) + } + }) +}) diff --git a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts index 5a0f9b984b..a08fbf9095 100644 --- a/packages/db/tests/optimistic-history-outcomes-oracle.test.ts +++ b/packages/db/tests/optimistic-history-outcomes-oracle.test.ts @@ -1,28 +1,14 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import fc from 'fast-check' -import { createDeferred } from '../src/deferred.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty, oracleReplayRequested } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter.js' -import { - expectHistoryOutcome, - observeHistoryPromise, - runOptimisticHistory, - withHistoryCleanup, -} from './optimistic-history-oracle.js' -import type { - HistoryOutcome, - OptimisticStep, -} from './optimistic-history-oracle.js' +import { runOptimisticHistory } from './optimistic-history-oracle.js' +import type { OptimisticStep } from './optimistic-history-oracle.js' /** * # Do callers observe the exact optimistic request outcome? @@ -38,14 +24,11 @@ import type { * and that a shrunk failure can be replayed by seed and path. */ -// The outcome checker is imported from the companion, which does not declare -// it. This file's calibrations reach it through the same check. // 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({ - check: [expectHistoryOutcome], -}) +// generated failed-request histories, and its completion histories. The +// outcome-observer calibrations are in +// optimistic-history-outcomes-calibration.test.ts. type Scenario = { existing: boolean @@ -91,6 +74,12 @@ async function runOutcomeScenario(scenario: Scenario) { ) } +const outcomesLaw = { + statement: `Each failed optimistic request exposes its exact outcome while independently modeled peer and source work survives.`, + authority: `The optimistic snapshot and request settlement laws in optimistic-history-oracle.ts`, + limits: `The existing bounded two-request grammar with controlled sync work; provider timing and arbitrary overlapping request histories remain outside this driver.`, +} + describe(`Optimistic request outcome histories`, () => { const cells = [false, true].flatMap((existing) => [false, true].flatMap((optimistic) => @@ -101,21 +90,38 @@ 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 }) - } - } + oracleProperty({ + id: `collection-state.optimistic-outcome-cells`, + law: outcomesLaw, + owns: [`src/transactions.ts`], + histories: cells, + label: (cell) => `observes exact request outcomes: ${JSON.stringify(cell)}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.optimistic-outcomes`, + plan, + observe, + async () => { + for (const queued of [false, true]) { + for (const settlePeer of [false, true]) { + await runOutcomeScenario({ + ...plan.cell, + queued, + settlePeer, + a: 2, + b: 3, + }) + } + } + }, + ), + check: forwardAssertionJudgment, }) oracleProperty({ id: `collection-state.optimistic-outcomes`, - law: { - statement: `Each failed optimistic request exposes its exact outcome while independently modeled peer and source work survives.`, - authority: `The optimistic snapshot and request settlement laws in optimistic-history-oracle.ts`, - limits: `The existing bounded two-request grammar with controlled sync work; provider timing and arbitrary overlapping request histories remain outside this driver.`, - }, + law: outcomesLaw, owns: [`src/transactions.ts`], histories: { arbitrary: fc.record({ @@ -153,130 +159,69 @@ const pendingPeerCases = [true, false].flatMap((sameKey) => { failure: `reject` as const, cascade: false }, ].map((decision) => ({ sameKey, ...decision })), ) -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( - [], - [ - { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - pending: true, - }, - { - type: `edit`, - key: sameKey ? 1 : 2, - fields: { b: 2 }, - optimistic: true, - }, - { type: `settle`, slot: 1, success: false, cascade, failure }, - ...(peerSurvives - ? [ - { - type: `settle` as const, - slot: 0, - success: true, - cascade: false, - }, - ] - : []), - ], - ) - expect(counts).toMatchObject({ - edits: 2, - settlements: peerSurvives ? 2 : 1, - failures: 1, - }) - }, -) - -describe(`Outcome observer calibration`, () => { - it.each([ - `early-success`, - `wrong-value`, - `wrong-error`, - `missing-error`, - `receipt-error`, - ] 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 - let bad: HistoryOutcome - if (fault === `early-success`) { - expected = { status: `pending` } - bad = { status: `fulfilled`, value } - } else if (fault === `wrong-value`) { - expected = { status: `fulfilled`, value } - bad = { status: `fulfilled`, value: { ...value } } - } else if (fault === `receipt-error`) { - expected = { status: `fulfilled`, value: undefined } - bad = { status: `rejected`, reason: error } - } else { - expected = { status: `rejected`, reason: error } - bad = { - status: `rejected`, - reason: fault === `wrong-error` ? new Error(`original`) : undefined, - } - } - expectHistoryOutcome(expected, expected, `valid neighbor`) - expect(() => expectHistoryOutcome(bad, expected, fault)).toThrowError( - expect.objectContaining({ name: `AssertionError` }), - ) - const gate = createDeferred() - const observed = observeHistoryPromise(gate.promise) - expectHistoryOutcome( - observed.read(), - { status: `pending` }, - `before settlement`, - ) - if (expected.status === `rejected`) gate.reject(error) - else gate.resolve(value) - await observed.settled - expectHistoryOutcome( - observed.read(), - expected.status === `rejected` - ? expected - : { status: `fulfilled`, value }, - `after settlement`, - ) - }, - ) - it(`preserves successful results and single errors without wrappers`, async () => { - const value = { id: 1 } - expect( - await withHistoryCleanup( - () => Promise.resolve(value), - () => [() => {}], - ), - ).toBe(value) - const error = Object.freeze(new Error(`single`)) - for (const fromCleanup of [false, true]) { - const observed = observeHistoryPromise( - withHistoryCleanup( - () => (fromCleanup ? Promise.resolve(value) : Promise.reject(error)), - () => [ - () => { - if (fromCleanup) throw error +async function settlePendingPeer({ + sameKey, + failure, + cascade, +}: (typeof pendingPeerCases)[number]): Promise { + const peerSurvives = !sameKey || (failure === `rollback` && !cascade) + const counts = await runOptimisticHistory( + [], + [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic: true, + pending: true, + }, + { + type: `edit`, + key: sameKey ? 1 : 2, + fields: { b: 2 }, + optimistic: true, + }, + { type: `settle`, slot: 1, success: false, cascade, failure }, + ...(peerSurvives + ? [ + { + type: `settle` as const, + slot: 0, + success: true, + cascade: false, }, - ], - ), - ) - await observed.settled - expectHistoryOutcome( - observed.read(), - { status: `rejected`, reason: error }, - `single error`, - ) - } + ] + : []), + ], + ) + expect(counts).toMatchObject({ + edits: 2, + settlements: peerSurvives ? 2 : 1, + failures: 1, }) +} + +oracleProperty({ + id: `collection-state.pending-peer-settlement`, + law: { + statement: `A failed persisting edit cancels a pending same-key manual peer only when rollback cascades; otherwise the peer stays pending and its later commit settles its own request.`, + authority: `The optimistic snapshot and request settlement laws in optimistic-history-oracle.ts`, + limits: `Same and distinct keys crossed with a cascading rollback, a secondary rollback and a rejection, through runOptimisticHistory.`, + }, + owns: [`src/transactions.ts`], + histories: pendingPeerCases, + label: ({ sameKey, failure, cascade }) => + `settles a pending manual peer after failure, sameKey=${sameKey} failure=${failure} cascade=${cascade}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.pending-peer-settlement`, + plan, + observe, + () => settlePendingPeer(plan.history), + ), + check: forwardAssertionJudgment, }) /** External completion shares the primary optimistic model. The finite grammar @@ -316,89 +261,108 @@ const completionHistories = completionStates.flatMap((state) => ), ) -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 reachStates = [ - `attempts-compared`, - `later-source-compared`, - ] as const - const reached = new Set<(typeof reachStates)[number]>() - const steps: Array = [ +async function runCompletionHistory({ + state, + peer, + source, + attempts, +}: (typeof completionHistories)[number]): Promise { + const reachStates = [`attempts-compared`, `later-source-compared`] as const + const reached = new Set<(typeof reachStates)[number]>() + const steps: Array = [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic: true, + pending: state === `pending`, + }, + ] + if (peer !== `none`) + steps.push({ + type: `edit`, + key: peer.endsWith(`same`) ? 1 : 2, + fields: { b: 2 }, + optimistic: true, + pending: peer.startsWith(`pending`), + }) + if (state === `completed` || state === `failed`) + steps.push({ + type: `settle`, + slot: 0, + success: state === `completed`, + cascade: false, + }) + if (source !== `none`) + steps.push({ + type: `sync`, + rows: [ { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - pending: state === `pending`, + id: source === `other` ? 3 : source === `peer` ? 2 : 1, + a: 7, + b: 8, + c: 9, }, - ] - if (peer !== `none`) - steps.push({ - type: `edit`, - key: peer.endsWith(`same`) ? 1 : 2, - fields: { b: 2 }, - optimistic: true, - pending: peer.startsWith(`pending`), - }) - if (state === `completed` || state === `failed`) - steps.push({ - type: `settle`, - slot: 0, - success: state === `completed`, - cascade: false, - }) - if (source !== `none`) - steps.push({ - type: `sync`, - rows: [ - { - id: source === `other` ? 3 : source === `peer` ? 2 : 1, - a: 7, - b: 8, - c: 9, - }, - ], - truncate: source === `truncate`, - copies: 1, - }) - for (let i = 0; i < attempts; i++) - steps.push({ type: `complete`, slot: 0 }) - // Drain each still-active request by its own explicit success. Target - // and peer are independently held; an accepted completion removes only - // the pending target from this active ordering. - if (state === `persisting`) - steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) - if (peer !== `none`) - steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) - steps.push({ - type: `sync`, - rows: [ - { id: 1, a: 11, b: 12, c: 13 }, - { id: 2, a: 21, b: 22, c: 23 }, - ], - truncate: false, - copies: 1, - }) - const counts = await runOptimisticHistory( - [{ id: 1, a: 0, b: 0, c: 0 }], - steps, - ) - expect(counts.edits).toBe(peer === `none` ? 1 : 2) - expect(counts.completionAttempts).toBe(attempts) - expect(counts.completionRejections).toBe( - attempts - (state === `pending` ? 1 : 0), + ], + truncate: source === `truncate`, + copies: 1, + }) + for (let i = 0; i < attempts; i++) steps.push({ type: `complete`, slot: 0 }) + // Drain each still-active request by its own explicit success. Target + // and peer are independently held; an accepted completion removes only + // the pending target from this active ordering. + if (state === `persisting`) + steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) + if (peer !== `none`) + steps.push({ type: `settle`, slot: 0, success: true, cascade: false }) + steps.push({ + type: `sync`, + rows: [ + { id: 1, a: 11, b: 12, c: 13 }, + { id: 2, a: 21, b: 22, c: 23 }, + ], + truncate: false, + copies: 1, + }) + const counts = await runOptimisticHistory( + [{ id: 1, a: 0, b: 0, c: 0 }], + steps, + ) + expect(counts.edits).toBe(peer === `none` ? 1 : 2) + expect(counts.completionAttempts).toBe(attempts) + expect(counts.completionRejections).toBe( + attempts - (state === `pending` ? 1 : 0), + ) + reached.add(`attempts-compared`) + reached.add(`later-source-compared`) + if (!oracleReplayRequested()) + for (const reachedState of reachStates) + expect(reached.has(reachedState), `${reachedState} history minimum`).toBe( + true, ) - reached.add(`attempts-compared`) - reached.add(`later-source-compared`) - if (!oracleReplayRequested()) - for (const reachedState of reachStates) - expect( - reached.has(reachedState), - `${reachedState} history minimum`, - ).toBe(true) +} + +describe(`external completion with peers and source work`, () => { + oracleProperty({ + id: `collection-state.external-completion`, + law: { + statement: `Only pending target work admits its first external completion; every repeated or other-state attempt rejects, while independent peers, queued source work and the later usable Collection are preserved.`, + authority: `The optimistic snapshot and request settlement laws in optimistic-history-oracle.ts`, + limits: `The finite grammar above: four admission states, five peers, the source transactions, and one to three attempts, through runOptimisticHistory.`, }, - ) + owns: [`src/transactions.ts`], + histories: completionHistories, + label: (history) => + `preserves completion admission and independent work: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.external-completion`, + plan, + observe, + () => runCompletionHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/optimistic-history-publication-oracle.test.ts b/packages/db/tests/optimistic-history-publication-oracle.test.ts index 5c28237db0..9c8c0af2c4 100644 --- a/packages/db/tests/optimistic-history-publication-oracle.test.ts +++ b/packages/db/tests/optimistic-history-publication-oracle.test.ts @@ -1,7 +1,12 @@ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { runOptimisticHistory } from './optimistic-history-oracle.js' -import { oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from './oracle-assertion-adapter.js' import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' /** @@ -13,42 +18,18 @@ import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' * semantics. The shared history model supplies expected rows and batches. * Corruption cases prove the driver rejects wrong keys, partial or reversed * cuts, stale previous values, and updates mislabeled as inserts. + * + * Each history below is data: its initial rows, its steps, and, where the + * history must reach a particular attribution boundary, the counts that + * `runOptimisticHistory` must report for it. One enumerated property replays + * every history through that shared model and driver. */ -// 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 }, ] -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 }, - { - type: `sync`, - rows: [{ id: 2, a: 0, b: 0, c: -1 }], - truncate: false, - copies: 1, - }, - { type: `sync`, rows: [], truncate: true, copies: 1 }, - ]) - }, -) - const history = (success: boolean): Array => [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { @@ -60,11 +41,33 @@ const history = (success: boolean): Array => [ { type: `settle`, slot: 0, success, cascade: false }, ] -it.each([false, true])( - `preserves old origin through truncate, replacement=%s`, - async (replace) => { - oracleRoute(`case`) - await runOptimisticHistory(initial, [ +type PublicationHistory = { + name: string + initial: Array + steps: Array + /** The counts the history must report, when it must reach a boundary. */ + counts?: Record +} + +const publicationHistories: Array = [ + ...[false, true].map((insert): PublicationHistory => ({ + name: `deletes the prior published row, queued insert=${insert}`, + initial: insert ? [] : [{ id: 2, a: 0, b: 0, c: 0 }], + steps: [ + { type: `edit`, key: 1, fields: { b: 0 }, optimistic: false }, + { + type: `sync`, + rows: [{ id: 2, a: 0, b: 0, c: -1 }], + truncate: false, + copies: 1, + }, + { type: `sync`, rows: [], truncate: true, copies: 1 }, + ], + })), + ...[false, true].map((replace): PublicationHistory => ({ + name: `preserves old origin through truncate, replacement=${replace}`, + initial, + steps: [ { type: `edit`, key: 1, fields: { c: 1 }, optimistic: false }, { type: `sync`, @@ -72,28 +75,22 @@ it.each([false, true])( truncate: true, copies: 1, }, - ]) - }, -) - -it.each([true, false])( - `checks every complete publication through settlement %s`, - async (success) => { - oracleRoute(`case`) - await runOptimisticHistory(initial, history(success)) - }, -) - -// A source has no causal-origin field. The model therefore gives the first -// queued same-key sync transaction local attribution at successful settlement, -// even when an independent peer supplied it. A later transaction on that key -// replaces its row with remote attribution. These histories make that limit -// visible at the settlement publication. -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 = [ + ], + })), + ...[true, false].map((success): PublicationHistory => ({ + name: `checks every complete publication through settlement ${success}`, + initial, + steps: history(success), + })), + // A source has no causal-origin field. The model therefore gives the first + // queued same-key sync transaction local attribution at successful + // settlement, even when an independent peer supplied it. A later transaction + // on that key replaces its row with remote attribution. These histories make + // that limit visible at the settlement publication. + ...([1, 2] as const).map((sourceTransactions): PublicationHistory => ({ + name: `attributes only the first of ${sourceTransactions} queued same-key source transactions locally`, + initial: [], + steps: [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { type: `sync`, @@ -112,24 +109,20 @@ it.each([1, 2] as const)( ] : []), { type: `settle`, slot: 0, success: true, cascade: false }, - ] - const counts = await runOptimisticHistory([], steps) - expect(counts).toMatchObject({ + ], + counts: { edits: 1, settlements: 1, queued: sourceTransactions, sourceInserts: 1, - }) - }, -) - -// 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 }], - [ + }, + })), + // 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. + { + name: `keeps local attribution through repeated same-key writes in one source transaction`, + initial: [{ id: 1, a: 0, b: 0, c: 0 }], + steps: [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { type: `sync`, @@ -142,64 +135,55 @@ it(`keeps local attribution through repeated same-key writes in one source trans }, { type: `settle`, slot: 0, success: true, cascade: false }, ], - ) - expect(counts).toMatchObject({ - edits: 1, - queued: 1, - settlements: 1, - sourceInserts: 0, - }) -}) - -// A delete and reinsert in one accepted source transaction have one origin -// snapshot. Moving the reinsert to a later transaction consumes that origin -// before the later write, which distinguishes the atomic boundary. -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 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, - { - type: `sync`, - rows: [], - operations: [ - { type: `delete`, key: 1 }, - { type: `row`, row: { id: 1, a: 2, b: 0, c: 0 } }, - ], - truncate: false, - copies: 1, - }, - ...(later - ? [ - { - type: `sync` as const, - rows: [{ id: 1, a: 3, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - ] - : []), - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) - expect(counts).toMatchObject({ + counts: { + edits: 1, + queued: 1, + settlements: 1, + sourceInserts: 0, + }, + }, + // A delete and reinsert in one accepted source transaction have one origin + // snapshot. Moving the reinsert to a later transaction consumes that origin + // before the later write, which distinguishes the atomic boundary. + ...[false, true].map((later): PublicationHistory => ({ + name: `attributes a delete and reinsert in one source transaction, later=${later}`, + initial: [{ id: 1, a: 0, b: 0, c: 0 }], + steps: [ + { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, + { + type: `sync`, + rows: [], + operations: [ + { type: `delete`, key: 1 }, + { type: `row`, row: { id: 1, a: 2, b: 0, c: 0 } }, + ], + truncate: false, + copies: 1, + }, + ...(later + ? [ + { + type: `sync` as const, + rows: [{ id: 1, a: 3, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + ] + : []), + { type: `settle`, slot: 0, success: true, cascade: false }, + ], + counts: { sourceInserts: 1, sourceDeletes: 1, queued: later ? 2 : 1, - }) - }, -) - -// 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( - [], - [ + }, + })), + // 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. + { + name: `consumes local attribution with an absent-key source delete`, + initial: [], + steps: [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { type: `sync`, rows: [], deletes: [1], truncate: false, copies: 1 }, { @@ -210,25 +194,23 @@ it(`consumes local attribution with an absent-key source delete`, async () => { }, { type: `settle`, slot: 0, success: true, cascade: false }, ], - ) - expect(counts).toMatchObject({ - edits: 1, - settlements: 1, - queued: 2, - sourceDeletes: 1, - absentSourceDeletes: 1, - sourceInserts: 1, - }) -}) - -// Unlike an ordinary queued batch, a truncate publishes while the mutation -// 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( - [], - [ + counts: { + edits: 1, + settlements: 1, + queued: 2, + sourceDeletes: 1, + absentSourceDeletes: 1, + sourceInserts: 1, + }, + }, + // Unlike an ordinary queued batch, a truncate publishes while the mutation + // still persists. A same-key source row can therefore receive local + // attribution even if that mutation later fails. The model compares both + // publication cuts. + { + name: `retains active-mutation attribution from a truncate after failure`, + initial: [], + steps: [ { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, { type: `sync`, @@ -238,283 +220,300 @@ it(`retains active-mutation attribution from a truncate after failure`, async () }, { type: `settle`, slot: 0, success: false, cascade: false }, ], - ) - expect(counts).toMatchObject({ - edits: 1, - settlements: 1, - replacements: 1, - failures: 1, - sourceInserts: 1, - }) -}) - -// A manual local transaction is active before its mutation function starts. -// Source writes do not wait for it, so settlement exposes the already applied -// source row and its key-and-timing attribution. Optimistic visibility and -// fulfillment and handler rejection are separate dimensions of that attribution. -it.each( - [false, true].flatMap((optimistic) => - ([`rollback`, `reject`, `success`] as const).map((outcome) => ({ - optimistic, - outcome, - })), - ), -)( - `keeps source attribution through pending settlement, optimistic=$optimistic outcome=$outcome`, - async ({ optimistic, outcome }) => { - oracleRoute(`case`) - const counts = await runOptimisticHistory( - [], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic, pending: true }, - { - type: `sync`, - rows: [{ id: 1, a: 2, b: 0, c: 0 }], - truncate: false, - copies: 1, - }, - { - type: `settle`, - slot: 0, - success: outcome === `success`, - cascade: false, - ...(outcome === `success` ? {} : { failure: outcome }), - }, - ], - ) - expect(counts).toMatchObject({ + counts: { edits: 1, - failures: outcome === `success` ? 0 : 1, + settlements: 1, + replacements: 1, + failures: 1, sourceInserts: 1, - }) + }, }, -) - -// The first manual transaction starts its mutation function only at commit. -// A later automatic edit must not replace the handler promise for that request. -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( - [], - [ - { - type: `edit`, - key: 1, - fields: { a: 1 }, - optimistic: true, - pending: true, - }, - { type: `edit`, key: 2, fields: { a: 2 }, optimistic: true }, - { - type: `settle`, - slot: 0, - success, - cascade: false, - ...(success ? {} : { failure: `reject` as const }), + // A manual local transaction is active before its mutation function starts. + // Source writes do not wait for it, so settlement exposes the already applied + // source row and its key-and-timing attribution. Optimistic visibility and + // fulfillment and handler rejection are separate dimensions of that + // attribution. + ...[false, true].flatMap((optimistic) => + ([`rollback`, `reject`, `success`] as const).map( + (outcome): PublicationHistory => ({ + name: `keeps source attribution through pending settlement, optimistic=${optimistic} outcome=${outcome}`, + initial: [], + steps: [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic, + pending: true, + }, + { + type: `sync`, + rows: [{ id: 1, a: 2, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + { + type: `settle`, + slot: 0, + success: outcome === `success`, + cascade: false, + ...(outcome === `success` ? {} : { failure: outcome }), + }, + ], + counts: { + edits: 1, + failures: outcome === `success` ? 0 : 1, + sourceInserts: 1, }, - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) - expect(counts).toMatchObject({ + }), + ), + ), + // The first manual transaction starts its mutation function only at commit. + // A later automatic edit must not replace the handler promise for that + // request. + ...[false, true].map((success): PublicationHistory => ({ + name: `settles an earlier pending manual request after a later edit starts, success=${success}`, + initial: [], + steps: [ + { + type: `edit`, + key: 1, + fields: { a: 1 }, + optimistic: true, + pending: true, + }, + { type: `edit`, key: 2, fields: { a: 2 }, optimistic: true }, + { + type: `settle`, + slot: 0, + success, + cascade: false, + ...(success ? {} : { failure: `reject` as const }), + }, + { type: `settle`, slot: 0, success: true, cascade: false }, + ], + counts: { edits: 2, settlements: 2, failures: success ? 0 : 1, - }) - }, -) - -// A source callback commits a truncate and successor during an earlier drain. -// The suffix truncate gets active attribution again. The later same-key -// transaction in the suffix drain is remote after the truncate consumes it. -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 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, - { - type: `reentrant`, - trigger: { + }, + })), + // A source callback commits a truncate and successor during an earlier + // drain. The suffix truncate gets active attribution again. The later + // same-key transaction in the suffix drain is remote after the truncate + // consumes it. + ...[false, true].map((later): PublicationHistory => ({ + name: `consumes truncate attribution before a later same-key transaction in one drain, later=${later}`, + initial: [{ id: 1, a: 0, b: 0, c: 0 }], + steps: [ + { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, + { + type: `reentrant`, + trigger: { + type: `sync`, + rows: [{ id: 1, a: 2, b: 0, c: 0 }], + truncate: true, + copies: 1, + }, + batches: [ + { + type: `sync`, + rows: [{ id: 1, a: 3, b: 0, c: 0 }], + truncate: true, + copies: 1, + }, + ...(later + ? [ + { + type: `sync` as const, + rows: [{ id: 1, a: 4, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + ] + : []), + ], + }, + { type: `settle`, slot: 0, success: false, cascade: false }, + ], + counts: { + edits: 1, + failures: 1, + replacements: 2, + }, + })), + // 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 production path exposes source origin at the + // drain cut; the optimistic production path exposes it when the failed edit + // drops its overlay. + ...[false, true].map((optimistic): PublicationHistory => ({ + name: `retains active attribution for another key after truncate, optimistic=${optimistic}`, + initial: [], + steps: [ + { type: `edit`, key: 2, fields: { a: 1 }, optimistic }, + { + type: `reentrant`, + trigger: { + type: `sync`, + rows: [{ id: 1, a: 1, b: 0, c: 0 }], + truncate: true, + copies: 1, + }, + batches: [ + { type: `sync`, rows: [{ id: 1, a: 2, b: 0, c: 0 }], truncate: true, copies: 1, }, - batches: [ + { + type: `sync`, + rows: [{ id: 2, a: 3, b: 0, c: 0 }], + truncate: false, + copies: 1, + }, + ], + }, + { type: `settle`, slot: 0, success: false, cascade: false }, + ], + counts: { + edits: 1, + settlements: 1, + failures: 1, + replacements: 2, + sourceInserts: 3, + }, + })), + // Two active same-key mutations share one source key but settle + // independently. A failure cannot consume another mutation's held + // attribution; a second source transaction still needs to lose attribution + // after the first. + ...[false, true].flatMap((firstSucceeds) => + [false, true].flatMap((secondSucceeds) => + [1, 2].flatMap((sourceTransactions) => + [false, true].map((settleSecondFirst): PublicationHistory => ({ + name: `settles overlapping same-key mutations, first=${firstSucceeds} second=${secondSucceeds} sources=${sourceTransactions} reverse=${settleSecondFirst}`, + initial: [{ id: 1, a: 0, b: 0, c: 0 }], + steps: [ + { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, + { type: `edit`, key: 1, fields: { b: 2 }, optimistic: true }, { type: `sync`, - rows: [{ id: 1, a: 3, b: 0, c: 0 }], - truncate: true, + rows: [{ id: 1, a: 3, b: 3, c: 0 }], + truncate: false, copies: 1, }, - ...(later + ...(sourceTransactions === 2 ? [ { type: `sync` as const, - rows: [{ id: 1, a: 4, b: 0, c: 0 }], + rows: [{ id: 1, a: 4, b: 4, c: 0 }], truncate: false, copies: 1, }, ] : []), - ], - }, - { type: `settle`, slot: 0, success: false, cascade: false }, - ], - ) - expect(counts).toMatchObject({ - edits: 1, - failures: 1, - replacements: 2, - }) - }, -) - -// 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 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( - [], - [ - { type: `edit`, key: 2, fields: { a: 1 }, optimistic }, - { - type: `reentrant`, - trigger: { - type: `sync`, - rows: [{ id: 1, a: 1, b: 0, c: 0 }], - truncate: true, - copies: 1, - }, - batches: [ { - type: `sync`, - rows: [{ id: 1, a: 2, b: 0, c: 0 }], - truncate: true, - copies: 1, + type: `settle`, + slot: settleSecondFirst ? 1 : 0, + success: firstSucceeds, + cascade: false, }, { - type: `sync`, - rows: [{ id: 2, a: 3, b: 0, c: 0 }], - truncate: false, - copies: 1, + type: `settle`, + slot: 0, + success: secondSucceeds, + cascade: false, }, ], - }, - { type: `settle`, slot: 0, success: false, cascade: false }, - ], - ) - expect(counts).toMatchObject({ - edits: 1, - settlements: 1, - failures: 1, - replacements: 2, - sourceInserts: 3, - }) - }, -) - -// Two active same-key mutations share one source key but settle independently. -// A failure cannot consume another mutation's held attribution; a second -// source transaction still needs to lose attribution after the first. -const overlappingCases = [false, true].flatMap((firstSucceeds) => - [false, true].flatMap((secondSucceeds) => - [1, 2].flatMap((sourceTransactions) => - [false, true].map((settleSecondFirst) => ({ - firstSucceeds, - secondSucceeds, - sourceTransactions, - settleSecondFirst, - })), + counts: { + edits: 2, + settlements: 2, + queued: sourceTransactions, + }, + })), + ), ), ), -) -it.each(overlappingCases)( - `settles overlapping same-key mutations, first=$firstSucceeds second=$secondSucceeds sources=$sourceTransactions reverse=$settleSecondFirst`, - async ({ - firstSucceeds, - secondSucceeds, - sourceTransactions, - settleSecondFirst, - }) => { - oracleRoute(`case`) - const counts = await runOptimisticHistory( - [{ id: 1, a: 0, b: 0, c: 0 }], - [ - { type: `edit`, key: 1, fields: { a: 1 }, optimistic: true }, - { type: `edit`, key: 1, fields: { b: 2 }, optimistic: true }, - { - type: `sync`, - rows: [{ id: 1, a: 3, b: 3, c: 0 }], - truncate: false, - copies: 1, - }, - ...(sourceTransactions === 2 - ? [ - { - type: `sync` as const, - rows: [{ id: 1, a: 4, b: 4, c: 0 }], - truncate: false, - copies: 1, - }, - ] - : []), - { - type: `settle`, - slot: settleSecondFirst ? 1 : 0, - success: firstSucceeds, - cascade: false, - }, - { type: `settle`, slot: 0, success: secondSucceeds, cascade: false }, - ], - ) - expect(counts).toMatchObject({ - edits: 2, - settlements: 2, - queued: sourceTransactions, - }) + // Found by the raw subscriber in a random campaign. A settled delete drops, + // 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. + { + name: `publishes a key once after a settled delete beneath a later active edit`, + initial: [{ id: 1, a: 0, b: 0, c: 0 }], + steps: [ + { type: `delete`, key: 1, optimistic: true }, + { type: `settle`, slot: 0, success: true, cascade: false }, + { type: `edit`, key: 1, fields: { b: 1 }, optimistic: true }, + { type: `sync`, rows: [], truncate: false, copies: 1 }, + { type: `settle`, slot: 0, success: true, cascade: false }, + ], }, -) +] -it.each([ +const publicationLaw = `optimistic-history.publication-cases` +// The captured-callback corruptions that the shared driver must reject. +const callbackFaults = [ `wrong-key`, `transient-field`, `partial-batch`, `backwards-cuts`, `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( - runOptimisticHistory(initial, history(false), fault), - ).rejects.toThrow( - /native callback key|whole forward publication|callback-time complete source|event semantics/, - ) -}) +] as const -// Found by the raw subscriber in a random campaign. A settled delete drops, -// 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 }], - [ - { type: `delete`, key: 1, optimistic: true }, - { type: `settle`, slot: 0, success: true, cascade: false }, - { type: `edit`, key: 1, fields: { b: 1 }, optimistic: true }, - { type: `sync`, rows: [], truncate: false, copies: 1 }, - { type: `settle`, slot: 0, success: true, cascade: false }, - ], - ) +// 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. +oracleProperty({ + id: publicationLaw, + law: { + statement: `Each optimistic history publishes complete source cuts with the shared history model's rows, batches and event semantics, and reaches its stated attribution counts.`, + authority: `The optimistic snapshot and settlement contract stated in optimistic-history-oracle.ts.`, + limits: `The named distinguishing histories above and the six captured-callback corruptions of the rollback-then-drain history; no generated histories.`, + }, + owns: [ + `src/collection/state.ts`, + `src/collection/sync.ts`, + `src/transactions.ts`, + ], + histories: publicationHistories, + label: ({ name }) => name, + resolve: (publication) => ({ publication }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(publicationLaw, plan, observe, async () => { + const { initial: rows, steps, counts } = plan.publication + const reported = await runOptimisticHistory(rows, steps) + if (counts) expect(reported).toMatchObject(counts) + }), + check: forwardAssertionJudgment, + calibrations: callbackFaults.map((fault) => ({ + name: `rejects captured callback corruption: ${fault}`, + law: publicationLaw, + driver: (plan: object, observe: (c: string, v: unknown) => void) => + recordAssertionJudgment(publicationLaw, plan, observe, async () => { + // Rollback then queued source drain supplies two distinct complete + // cuts. + await runOptimisticHistory(initial, history(false)) + try { + await runOptimisticHistory(initial, history(false), fault) + } catch (error) { + // Exactly the old matcher: only these rejections count as the + // check rejecting the wrong answer. + if ( + error instanceof Error && + /native callback key|whole forward publication|callback-time complete source|event semantics/.test( + error.message, + ) + ) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `rejects captured callback corruption: ${fault}: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + }), + })), }) diff --git a/packages/db/tests/optimistic-transaction-oracle.property.test.ts b/packages/db/tests/optimistic-transaction-oracle.property.test.ts index 536aa623cf..0d9b664672 100644 --- a/packages/db/tests/optimistic-transaction-oracle.property.test.ts +++ b/packages/db/tests/optimistic-transaction-oracle.property.test.ts @@ -1,5 +1,5 @@ import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { afterEach, beforeEach, describe, expect } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { createLiveQueryCollection, eq } from '../src/query/index.js' @@ -10,17 +10,13 @@ import { observeHistoryPromise, withHistoryCleanup, } from './optimistic-history-oracle.js' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty, oracleReplayRequested } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import type { Collection } from '../src/collection/index.js' import type { ChangeMessage, SyncConfig } from '../src/types.js' @@ -281,26 +277,89 @@ async function runSameKey( describe(`Same-key transaction laws`, () => { // A reinsert of the original row restores it, so the pair cancels: no net // request and no persistence call. Generation never restores (value >= 1). - 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, - note: `original`, + // Each authored sequence then runs with both handler outcomes. One + // enumerated property replays these scenarios through runSameKey. + const sameKeyCases: Array<{ + name: string + scenarios: Array + }> = [ + ...[true, false].map((success) => ({ + name: `cancels a delete and a reinsert that restores the row (success=${success})`, + scenarios: [ + { + sequence: `delete-insert` as const, + value: 0, + note: `original`, + success, + }, + ], + })), + ...sameKeySequences.map((sequence) => ({ + name: `preserves authored sequence ${sequence}`, + scenarios: [false, true].map((success) => ({ + sequence, success, - }) - }, - ) - 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` }) + value: 7, + note: `last`, + })), + })), + ] + oracleProperty({ + id: `collection-state.same-key-cases`, + law: { + statement: `Same-key authored operations collapse to one net request while retaining exact original, modified and changed fields.`, + authority: `The public manual transaction and Collection mutation contracts in docs/guides/mutations.md and the same-key truth table stated above`, + limits: `The restoring delete-insert pair and each authored two-operation sequence with both handler outcomes, through the same runSameKey driver and checks as the generated campaign.`, }, - ) + owns: [`src/transactions.ts`, `src/collection/mutations.ts`], + histories: sameKeyCases, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.same-key`, + plan, + observe, + async () => { + for (const scenario of plan.history.scenarios) + await runSameKey(scenario) + }, + ), + check: forwardAssertionJudgment, + calibrations: ([`dropped-field`, `canceled-persistence`] as const).map( + (fault) => ({ + name: `rejects ${fault}`, + law: `collection-state.same-key`, + driver: (plan: object, observe: (c: string, v: unknown) => void) => + recordAssertionJudgment( + `collection-state.same-key`, + plan, + observe, + async () => { + const scenario: SameKeyScenario = { + sequence: + fault === `dropped-field` ? `update-update` : `insert-delete`, + success: true, + value: 7, + note: `last`, + } + await runSameKey(scenario) + try { + await runSameKey(scenario, fault) + } catch (error) { + // Exactly the old matcher: the rejection is an AssertionError. + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `rejects ${fault}: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + }, + ), + }), + ), + }) // Preserve the original per-campaign minimum of three sign-only // reinserts. Each generated scenario contributes at most one reach hit. describe(`same-key campaign reach`, () => { @@ -388,22 +447,6 @@ describe(`Same-key transaction laws`, () => { check: forwardAssertionJudgment, }) }) - 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, - value: 7, - note: `last`, - } - await runSameKey(scenario) - await expect(runSameKey(scenario, fault)).rejects.toMatchObject({ - name: `AssertionError`, - }) - }, - ) }) type ReplacementMutation = { @@ -498,211 +541,265 @@ 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, - ) - }) +type SymmetricReplacement = { + name: string + replacement: ReplacementRow + changes: object +} +const symmetricReplacements: Array = [ + { + name: `removed field`, + replacement: { id: 1, value: 0 }, + changes: { note: undefined }, + }, + { + name: `null field`, + replacement: { id: 1, value: null, note: null }, + changes: { value: null, note: null }, + }, + { + name: `undefined field`, + replacement: { id: 1, value: undefined, note: `original` }, + changes: { value: undefined }, + }, + { + name: `added field`, + replacement: { id: 1, value: 0, note: `original`, added: `new` }, + changes: { added: `new` }, + }, +] - it.each([ - { - name: `removed field`, - replacement: { id: 1, value: 0 }, - changes: { note: undefined }, - }, - { - name: `null field`, - replacement: { id: 1, value: null, note: null }, - changes: { value: null, note: null }, +async function convertToSymmetricUpdate({ + replacement, + changes, +}: SymmetricReplacement): Promise { + await runDeleteInsertReplacement([replacement], { + type: `update`, + original: { id: 1, value: 0, note: `original` }, + modified: replacement, + changes, + metadata: { operation: `insert`, index: 0 }, + syncMetadata: { + delete: true, + shared: `insert`, + insert: true, }, - { - name: `undefined field`, - replacement: { id: 1, value: undefined, note: `original` }, - changes: { value: undefined }, + }) +} + +async function cancelExactRestoration(): Promise { + await runDeleteInsertReplacement( + [{ id: 1, value: 0, note: `original` }], + undefined, + ) +} + +async function reduceRepeatedPairs(): Promise { + const first = { id: 1, value: 1, note: `first` } + const final = { id: 1, value: 2, added: `final` } + await runDeleteInsertReplacement([first, final], { + type: `update`, + original: { id: 1, value: 0, note: `original` }, + modified: final, + changes: { value: 2, note: undefined, added: `final` }, + metadata: { operation: `insert`, index: 1 }, + syncMetadata: { + delete: true, + shared: `insert`, + insert: true, }, + }) +} + +async function preserveFirstPreimage(): Promise { + const original = { id: 1, value: 0, note: `original` } + const replacement = { id: 1, value: 2, note: `original` } + await runDeleteInsertReplacement( + [], { - name: `added field`, - replacement: { id: 1, value: 0, note: `original`, added: `new` }, - changes: { added: `new` }, + type: `update`, + original, + modified: replacement, + changes: { value: 2 }, + metadata: { operation: `insert` }, + syncMetadata: { + delete: true, + shared: `insert`, + insert: true, + }, }, - ] satisfies Array<{ - name: string - replacement: ReplacementRow - changes: object - }>)( - `converts a $name to one symmetric update`, - async ({ replacement, changes }) => { - oracleRoute(`case`) - await runDeleteInsertReplacement([replacement], { - type: `update`, - original: { id: 1, value: 0, note: `original` }, - modified: replacement, - changes, - metadata: { operation: `insert`, index: 0 }, - syncMetadata: { - delete: true, - shared: `insert`, - insert: true, - }, + original, + (collection) => { + collection.update(1, (draft) => { + draft.value = 1 + }) + collection.delete([1, 1]) + collection.insert(replacement, { + metadata: { operation: `insert` }, }) }, ) +} - 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], { +async function compareNestedReplacement(): Promise { + const original = { + id: 1, + value: 0, + note: `original`, + nested: { count: 1 }, + } + await runDeleteInsertReplacement( + [{ ...original, nested: { count: 1 } }], + undefined, + original, + ) + await runDeleteInsertReplacement( + [{ ...original, nested: { count: 2 } }], + { type: `update`, - original: { id: 1, value: 0, note: `original` }, - modified: final, - changes: { value: 2, note: undefined, added: `final` }, - metadata: { operation: `insert`, index: 1 }, + original, + modified: { ...original, nested: { count: 2 } }, + changes: { nested: { count: 2 } }, + metadata: { operation: `insert`, index: 0 }, syncMetadata: { delete: true, shared: `insert`, insert: true, }, - }) - }) + }, + original, + ) +} - 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( - [], - { - type: `update`, - original, - modified: replacement, - changes: { value: 2 }, - metadata: { operation: `insert` }, - syncMetadata: { - delete: true, - shared: `insert`, - insert: true, - }, - }, +async function deliverSymbolReplacement(): Promise { + const field = Symbol(`replacement field`) + const original = { id: 1, value: 0, [field]: 0 } + const replacement = { id: 1, value: 0, [field]: 1 } + const request = await runDeleteInsertReplacement( + [replacement], + { + type: `update`, original, - (collection) => { - collection.update(1, (draft) => { - draft.value = 1 - }) - collection.delete([1, 1]) - collection.insert(replacement, { - metadata: { operation: `insert` }, - }) + modified: replacement, + changes: {}, + metadata: { operation: `insert`, index: 0 }, + syncMetadata: { + delete: true, + shared: `insert`, + insert: true, }, - ) - }) + }, + original, + ) - it(`compares nested replacement values structurally`, async () => { - oracleRoute(`fixed history`) - const original = { - id: 1, - value: 0, - note: `original`, - nested: { count: 1 }, - } - await runDeleteInsertReplacement( - [{ ...original, nested: { count: 1 } }], - undefined, + expect(Reflect.ownKeys(request[0]!.changes)).toStrictEqual([]) + expect(Reflect.get(request[0]!.modified, field)).toBe(1) +} + +async function useOwnStringKeyOrder(): Promise { + const original = { + 10: `remove numeric`, + 2: `stable numeric`, + id: 1, + value: 0, + before: `remove string`, + stable: `same`, + } + const replacement = { + 1: `add numeric`, + 2: `stable numeric`, + id: 1, + value: 0, + stable: `same`, + after: `add string`, + } + const request = await runDeleteInsertReplacement( + [replacement], + { + type: `update`, original, - ) - await runDeleteInsertReplacement( - [{ ...original, nested: { count: 2 } }], - { - type: `update`, - original, - modified: { ...original, nested: { count: 2 } }, - changes: { nested: { count: 2 } }, - metadata: { operation: `insert`, index: 0 }, - syncMetadata: { - delete: true, - shared: `insert`, - insert: true, - }, + modified: replacement, + changes: { + 1: `add numeric`, + 10: undefined, + before: undefined, + after: `add string`, }, - original, - ) - }) - - 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 } - const request = await runDeleteInsertReplacement( - [replacement], - { - type: `update`, - original, - modified: replacement, - changes: {}, - metadata: { operation: `insert`, index: 0 }, - syncMetadata: { - delete: true, - shared: `insert`, - insert: true, - }, + metadata: { operation: `insert`, index: 0 }, + syncMetadata: { + delete: true, + shared: `insert`, + insert: true, }, - original, - ) + }, + original, + ) - expect(Reflect.ownKeys(request[0]!.changes)).toStrictEqual([]) - expect(Reflect.get(request[0]!.modified, field)).toBe(1) - }) + expect(Object.keys(request[0]!.changes)).toStrictEqual([ + `1`, + `10`, + `before`, + `after`, + ]) +} - it(`uses own-string key order for a symmetric replacement diff`, async () => { - oracleRoute(`fixed history`) - const original = { - 10: `remove numeric`, - 2: `stable numeric`, - id: 1, - value: 0, - before: `remove string`, - stable: `same`, - } - const replacement = { - 1: `add numeric`, - 2: `stable numeric`, - id: 1, - value: 0, - stable: `same`, - after: `add string`, - } - const request = await runDeleteInsertReplacement( - [replacement], - { - type: `update`, - original, - modified: replacement, - changes: { - 1: `add numeric`, - 10: undefined, - before: undefined, - after: `add string`, - }, - metadata: { operation: `insert`, index: 0 }, - syncMetadata: { - delete: true, - shared: `insert`, - insert: true, - }, - }, - original, - ) +// Each history names one authored replacement; its body runs +// runDeleteInsertReplacement with the request that history owes. +const deleteInsertCases: ReadonlyArray Promise]> = + [ + [`cancels an exact same-key restoration`, cancelExactRestoration], + ...symmetricReplacements.map( + (row) => + [ + `converts a ${row.name} to one symmetric update`, + () => convertToSymmetricUpdate(row), + ] as const, + ), + [ + `reduces repeated delete and insert pairs to the final net update`, + reduceRepeatedPairs, + ], + [ + `preserves the first preimage through duplicate deletes before replacement`, + preserveFirstPreimage, + ], + [ + `compares nested replacement values structurally`, + compareNestedReplacement, + ], + [ + `delivers a replacement that differs only by an enumerable symbol`, + deliverSymbolReplacement, + ], + [ + `uses own-string key order for a symmetric replacement diff`, + useOwnStringKeyOrder, + ], + ] - expect(Object.keys(request[0]!.changes)).toStrictEqual([ - `1`, - `10`, - `before`, - `after`, - ]) +describe(`Delete then insert transaction laws`, () => { + oracleProperty({ + id: `collection-state.delete-insert`, + law: { + statement: `A same-key delete then insert in one transaction reaches the handler as its net request: nothing for an exact restoration, otherwise one symmetric update with the first preimage, the final row and its exact changes.`, + authority: `The public manual transaction and Collection mutation contracts in docs/guides/mutations.md and the same-key truth table stated above`, + limits: `The named authored replacements over one Collection and one manual transaction; each history states the request it owes.`, + }, + owns: [`src/transactions.ts`, `src/collection/mutations.ts`], + histories: deleteInsertCases.map(([name]) => name), + label: (name) => name, + resolve: (name) => ({ + run: deleteInsertCases.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `collection-state.delete-insert`, + plan, + observe, + () => plan.run(), + ), + check: forwardAssertionJudgment, }) }) @@ -1095,10 +1192,6 @@ 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` }) - }) oracleProperty({ id: `collection-state.mixed-transaction`, law: { @@ -1121,6 +1214,10 @@ describe(`Whole mixed transaction publication`, () => { }), runs: 100, fixedSeeds: [505202], + fixed: cells.map((cell) => ({ + name: `publishes complete transaction cuts ${JSON.stringify(cell)}`, + history: { ...cell, value: 17, note: `new row` }, + })), }, resolve: (history) => ({ history }), model: recordedAssertionJudgment, @@ -1132,32 +1229,47 @@ describe(`Whole mixed transaction publication`, () => { () => runTransaction(plan.history), ), check: forwardAssertionJudgment, + calibrations: ( + [ + `missing-delete`, + `untouched-field`, + `partial-publication`, + `payload`, + `string-key`, + `previous-value`, + `update-as-insert`, + ] as const + ).map((fault) => ({ + name: `rejects captured ${fault} through the real transaction driver's checks`, + law: `collection-state.mixed-transaction`, + driver: (plan: object, observe: (c: string, v: unknown) => void) => + recordAssertionJudgment( + `collection-state.mixed-transaction`, + plan, + observe, + async () => { + const scenario: Scenario = { + order: [`insert`, `update`, `delete`], + optimistic: true, + outcome: `resolve`, + value: 17, + note: `new row`, + } + await runTransaction(scenario) + try { + await runTransaction(scenario, fault) + } catch (error) { + // Exactly the old matcher: the rejection is an AssertionError. + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `rejects captured ${fault}: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + }, + ), + })), }) - it.each([ - `missing-delete`, - `untouched-field`, - `partial-publication`, - `payload`, - `string-key`, - `previous-value`, - `update-as-insert`, - ] 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, - outcome: `resolve`, - value: 17, - note: `new row`, - } - await runTransaction(scenario) - await expect(runTransaction(scenario, fault)).rejects.toMatchObject({ - name: `AssertionError`, - }) - }, - ) }) // The authored key list, in order and with multiplicity, is the payload that @@ -1179,104 +1291,127 @@ function expectedTypedKeyPayload( // no encoded production globalKey participates in the comparison. Cross each // same-text pair with inserts, updates, deletes, repeated updates, and rollback. // 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, - reversed: reverseOrder, - })), -)) { - 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] - : [numberKey, String(numberKey)] - const initial = - operation === `insert` ? [] : keys.map((id) => ({ id, value: 0 })) - const proposed = - operation === `delete` - ? [] - : keys.map((id, i) => ({ id, value: i + 10 })) - let sync!: Parameters[`sync`]>[0] - const source = createCollection({ - getKey: (row) => row.id, - sync: { - sync(actions) { - sync = actions - actions.begin() - for (const value of initial) - actions.write({ type: `insert`, value }) - actions.commit() - actions.markReady() - }, - }, - }) - const payload: Array<{ - key: string | number - type: string - value: number - }> = [] - const tx = createTransaction({ - autoCommit: false, - mutationFn: ({ transaction }) => { - for (const mutation of transaction.mutations) - payload.push({ - key: mutation.key, - type: mutation.type, - value: operation === `delete` ? 0 : mutation.modified.value, - }) - if (!success) return Promise.reject(new Error(`authored rejection`)) - sync.begin() - for (const mutation of transaction.mutations) { - if (mutation.type === `delete`) - sync.write({ type: `delete`, key: mutation.key }) - else sync.write({ type: mutation.type, value: mutation.modified }) - } - sync.commit() - return Promise.resolve() - }, +type TypedKeyHistory = { + numberKey: number + reversed: boolean + operation: `insert` | `update` | `delete` | `repeat` + success: boolean +} + +async function runTypedKeys({ + numberKey, + reversed, + operation, + success, +}: TypedKeyHistory): Promise { + type TypedRow = { id: string | number; value: number } + const keys = reversed + ? [String(numberKey), numberKey] + : [numberKey, String(numberKey)] + const initial = + operation === `insert` ? [] : keys.map((id) => ({ id, value: 0 })) + const proposed = + operation === `delete` ? [] : keys.map((id, i) => ({ id, value: i + 10 })) + let sync!: Parameters[`sync`]>[0] + const source = createCollection({ + getKey: (row) => row.id, + sync: { + sync(actions) { + sync = actions + actions.begin() + for (const value of initial) actions.write({ type: `insert`, value }) + actions.commit() + actions.markReady() + }, + }, + }) + const payload: Array<{ + key: string | number + type: string + value: number + }> = [] + const tx = createTransaction({ + autoCommit: false, + mutationFn: ({ transaction }) => { + for (const mutation of transaction.mutations) + payload.push({ + key: mutation.key, + type: mutation.type, + value: operation === `delete` ? 0 : mutation.modified.value, }) - const persisted = observeHistoryPromise(tx.when(`settled`)) - await withHistoryCleanup( - async () => { - await source.preload() - tx.mutate(() => { - keys.forEach((key, index) => { - if (operation === `insert`) - source.insert({ id: key, value: index + 10 }) - else if (operation === `delete`) source.delete(key) - else { - if (operation === `repeat`) - source.update(key, (draft) => { - draft.value = 3 - }) - source.update(key, (draft) => { - draft.value = index + 10 - }) - } + if (!success) return Promise.reject(new Error(`authored rejection`)) + sync.begin() + for (const mutation of transaction.mutations) { + if (mutation.type === `delete`) + sync.write({ type: `delete`, key: mutation.key }) + else sync.write({ type: mutation.type, value: mutation.modified }) + } + sync.commit() + return Promise.resolve() + }, + }) + const persisted = observeHistoryPromise(tx.when(`settled`)) + await withHistoryCleanup( + async () => { + await source.preload() + tx.mutate(() => { + keys.forEach((key, index) => { + if (operation === `insert`) + source.insert({ id: key, value: index + 10 }) + else if (operation === `delete`) source.delete(key) + else { + if (operation === `repeat`) + source.update(key, (draft) => { + draft.value = 3 }) + source.update(key, (draft) => { + draft.value = index + 10 }) - const committed = observeHistoryPromise(tx.commit()) - await committed.settled - await persisted.settled - expect(payload, `handler payload retains typed keys`).toEqual( - expectedTypedKeyPayload(keys, operation), - ) - const expected = success ? proposed : initial - expect(source.size).toBe(expected.length) - for (const row of expected) - expect(source.get(row.id)?.value).toBe(row.value) - }, - () => [() => source.cleanup()], - ) + } + }) }) - } - } + const committed = observeHistoryPromise(tx.commit()) + await committed.settled + await persisted.settled + expect(payload, `handler payload retains typed keys`).toEqual( + expectedTypedKeyPayload(keys, operation), + ) + const expected = success ? proposed : initial + expect(source.size).toBe(expected.length) + for (const row of expected) + expect(source.get(row.id)?.value).toBe(row.value) + }, + () => [() => source.cleanup()], + ) } -declareOracle({ - check: [runTransaction, runSameKey, runDeleteInsertReplacement], - model: [expectedTypedKeyPayload], +oracleProperty({ + id: `collection-state.typed-keys`, + law: { + statement: `Typed keys that share their text keep distinct identities through a whole transaction: the handler payload is the authored key list, in order and with multiplicity, and settlement leaves the authored or initial rows.`, + authority: `The public manual transaction and Collection mutation contracts in docs/guides/mutations.md`, + limits: `Number keys 0, -1, 1 and 1.5 beside their string forms in both orders, under insert, update, delete and repeated update, with success and rejection.`, + }, + owns: [`src/transactions.ts`, `src/collection/mutations.ts`], + histories: [0, -1, 1, 1.5].flatMap((numberKey) => + [false, true].flatMap((reversed) => + ([`insert`, `update`, `delete`, `repeat`] as const).flatMap((operation) => + [true, false].map((success): TypedKeyHistory => ({ + numberKey, + reversed, + operation, + success, + })), + ), + ), + ), + label: ({ numberKey, reversed, operation, success }) => + `keeps typed ${numberKey} keys distinct during ${operation}, success=${success}, reversed=${reversed}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`collection-state.typed-keys`, plan, observe, () => + runTypedKeys(plan.history), + ), + check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/paced-mutations-oracle.test.ts b/packages/db/tests/paced-mutations-oracle.test.ts index 70fd3e6bfd..c290e92587 100644 --- a/packages/db/tests/paced-mutations-oracle.test.ts +++ b/packages/db/tests/paced-mutations-oracle.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { createDeferred } from '../src/deferred' import { createPacedMutations } from '../src/paced-mutations' import { @@ -6,14 +6,20 @@ import { queueStrategy, throttleStrategy, } from '../src/strategies' -import { declareOracle } from './oracle-declaration.js' import { createReadyCollection, observeReceipt, origin, withCleanup, } from './paced-mutations-support' -import { oracleRoute } from './oracle-config.js' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from './oracle-assertion-adapter.js' +import { asyncRejection, rejection } from './calibration-rejection.js' import type { Start } from './paced-mutations-support' import type { Strategy } from '../src/strategies/types' import type { Transaction } from '../src/transactions' @@ -324,25 +330,15 @@ 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, - debounceStarts, - throttleStarts, - nonLeadingThrottleStarts, - heldPacingTimes, - expectedHeldStarts, - expectedSuccessorCanceled, - ], -}) +const pacedAuthority = `The public strategy docs and docs/guides/mutations.md "Key Design" stated above, with the virtual-clock model below` + +// Each history runs from the state the file's beforeEach gives an ordinary +// test: fake timers at `origin`. A property test can run several histories +// in one test (determinism, calibration), so each driver restores it first. +function startAtOrigin(): void { + vi.useFakeTimers() + vi.setSystemTime(origin) +} const queueActions: Array = [ { kind: `mutate`, id: 1 }, @@ -463,104 +459,193 @@ const cases: Array = [ }, ] +// Exactly the old matchers: each wrong answer must reject with an AssertionError. +const isAssertionError = (error: Error) => error.name === `AssertionError` + 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 - }> = [ - { - caseName: `queue adds front and takes front`, - strategy: () => - queueStrategy({ - wait: 10, - addItemsTo: `front`, - getItemsFrom: `back`, - }), - }, + oracleProperty({ + id: `paced-mutations.timeline`, + law: { + statement: `Each pacing strategy starts the model's persistence calls at the model's virtual-clock times, groups calls into the model's transactions, keeps every call optimistic, and settles every receipt.`, + authority: pacedAuthority, + limits: `The named queue, debounce and throttle timeline cases with immediately fulfilled writes; the two calibrations replay their wrong strategies and options.`, + }, + owns: [ + `src/paced-mutations.ts`, + `src/strategies/debounceStrategy.ts`, + `src/strategies/throttleStrategy.ts`, + `src/strategies/queueStrategy.ts`, + `src/strategies/serial-pacer.ts`, + ], + histories: cases.map(({ name }) => name), + label: (name) => name, + resolve: (name) => ({ + testCase: cases.find((candidate) => candidate.name === name)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `paced-mutations.timeline`, + plan, + observe, + async () => { + startAtOrigin() + await runProduction(plan.testCase, (actual) => + checkTimelineObservation(plan.testCase, actual), + ) + }, + ), + check: forwardAssertionJudgment, + calibrations: [ { - caseName: `debounce persists after each quiet period`, - strategy: () => - debounceStrategy({ wait: 10, leading: false, trailing: false }), + name: `rejects front/back, trailing, and leading-edge mutants`, + law: `paced-mutations.timeline`, + driver: (plan, observe) => + recordAssertionJudgment( + `paced-mutations.timeline`, + plan, + observe, + async () => { + startAtOrigin() + const rejections: Array = [] + const wrongStrategies: Array<{ + caseName: string + strategy: () => Strategy + }> = [ + { + caseName: `queue adds front and takes front`, + strategy: () => + queueStrategy({ + wait: 10, + addItemsTo: `front`, + getItemsFrom: `back`, + }), + }, + { + caseName: `debounce persists after each quiet period`, + strategy: () => + debounceStrategy({ + wait: 10, + leading: false, + trailing: false, + }), + }, + { + caseName: `throttle persists leading and trailing transactions`, + strategy: () => + throttleStrategy({ + wait: 10, + leading: false, + trailing: true, + }), + }, + ] + for (const { caseName, strategy } of wrongStrategies) { + const lawful = cases.find( + (testCase) => testCase.name === caseName, + ) + if (!lawful) throw new Error(`Missing calibration case`) + rejections.push( + await asyncRejection(isAssertionError, () => + runProduction({ ...lawful, strategy }, (actual) => { + expect(actual.starts).toEqual(lawful.expected) + }), + ), + ) + } + rejectAt( + `calibration: rejects front/back, trailing, and leading-edge mutants`, + rejections[0]!, + ) + }, + ), }, { - caseName: `throttle persists leading and trailing transactions`, - strategy: () => - throttleStrategy({ wait: 10, leading: false, trailing: true }), - }, - ] - for (const { caseName, strategy } of wrongStrategies) { - const lawful = cases.find((testCase) => testCase.name === caseName) - if (!lawful) throw new Error(`Missing calibration case`) - await expect( - runProduction({ ...lawful, strategy }, (actual) => { - expect(actual.starts).toEqual(lawful.expected) - }), - ).rejects.toMatchObject({ name: `AssertionError` }) - } - }) - 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({ - onMutate: (id) => collection.insert({ id }), - mutationFn: () => Promise.resolve(), - strategy, - }) - return withCleanup(strategy, collection, () => { - mutate(1) - mutate(2) - const third = mutate(3) - expect(third.state, `overflow settlement`).toBe(`failed`) - return Promise.resolve() - }) - } - await expect( - capacityChecker(queueStrategy({ maxSize: Infinity, wait: 10 })), - ).rejects.toMatchObject({ name: `AssertionError` }) - - const falseGreen = queueStrategy({ maxSize: 1, wait: 10 }) - const originalExecute = falseGreen.execute - falseGreen.execute = (fn) => { - originalExecute(fn) - return true - } - await expect(capacityChecker(falseGreen)).rejects.toMatchObject({ - name: `AssertionError`, - }) - - const nonLeading = cases.find( - (testCase) => - testCase.name === - `explicit non-leading throttle waits for each trailing edge`, - ) - if (!nonLeading) throw new Error(`Missing non-leading calibration case`) - await expect( - runProduction( - { - ...nonLeading, - strategy: () => - throttleStrategy({ wait: 10, leading: true, trailing: true }), - }, - (actual) => expect(actual.starts).toEqual(nonLeading.expected), - ), - ).rejects.toMatchObject({ name: `AssertionError` }) + name: `rejects capacity, overflow settlement, early throttle, and option mutation mutants`, + law: `paced-mutations.timeline`, + driver: (plan, observe) => + recordAssertionJudgment( + `paced-mutations.timeline`, + plan, + observe, + async () => { + startAtOrigin() + const rejections: Array = [] + const capacityChecker = async (strategy: Strategy) => { + const collection = await createReadyCollection() + const mutate = createPacedMutations({ + onMutate: (id) => collection.insert({ id }), + mutationFn: () => Promise.resolve(), + strategy, + }) + return withCleanup(strategy, collection, () => { + mutate(1) + mutate(2) + const third = mutate(3) + expect(third.state, `overflow settlement`).toBe(`failed`) + return Promise.resolve() + }) + } + rejections.push( + await asyncRejection(isAssertionError, () => + capacityChecker( + queueStrategy({ maxSize: Infinity, wait: 10 }), + ), + ), + ) - const options: { wait: number; trailing?: boolean } = { wait: 10 } - options.trailing = true - expect(() => expect(options).toEqual({ wait: 10 })).toThrowError( - expect.objectContaining({ name: `AssertionError` }), - ) + const falseGreen = queueStrategy({ maxSize: 1, wait: 10 }) + const originalExecute = falseGreen.execute + falseGreen.execute = (fn) => { + originalExecute(fn) + return true + } + rejections.push( + await asyncRejection(isAssertionError, () => + capacityChecker(falseGreen), + ), + ) + + const nonLeading = cases.find( + (testCase) => + testCase.name === + `explicit non-leading throttle waits for each trailing edge`, + ) + if (!nonLeading) + throw new Error(`Missing non-leading calibration case`) + rejections.push( + await asyncRejection(isAssertionError, () => + runProduction( + { + ...nonLeading, + strategy: () => + throttleStrategy({ + wait: 10, + leading: true, + trailing: true, + }), + }, + (actual) => + expect(actual.starts).toEqual(nonLeading.expected), + ), + ), + ) + + const options: { wait: number; trailing?: boolean } = { wait: 10 } + options.trailing = true + rejections.push( + rejection(isAssertionError, () => + expect(options).toEqual({ wait: 10 }), + ), + ) + rejectAt( + `calibration: rejects capacity, overflow settlement, early throttle, and option mutation mutants`, + rejections[0]!, + ) + }, + ), + }, + ], }) }) @@ -761,14 +846,48 @@ async function checkHeldPacing(testCase: HeldPacingCase): Promise { } describe(`paced persistence hold/release oracle`, () => { - for (const kind of [`debounce`, `throttle`] as const) - for (const leading of [true, false, undefined]) - 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 }) - }) + oracleProperty({ + id: `paced-mutations.held-pacing`, + law: { + statement: `Debounce and throttle keep one pending and one persisting transaction: a second write starts at the later of its timer edge and the first write's release, and settlement keeps each optimistic row and receipt until its own write settles.`, + authority: pacedAuthority, + limits: `Both strategies, leading/non-leading/default admission, and release one tick before, at, one tick after, or ten ticks after the second edge.`, + }, + owns: [ + `src/paced-mutations.ts`, + `src/strategies/debounceStrategy.ts`, + `src/strategies/throttleStrategy.ts`, + `src/strategies/queueStrategy.ts`, + `src/strategies/serial-pacer.ts`, + ], + histories: ([`debounce`, `throttle`] as const).flatMap((kind) => + [true, false, undefined].flatMap((leading) => + ([true, undefined] as const).flatMap((trailing) => + [-1, 0, 1, 10].map((releaseOffset): HeldPacingCase => ({ + kind, + leading, + trailing, + releaseOffset, + })), + ), + ), + ), + label: ({ kind, leading, trailing, releaseOffset }) => + `${kind} leading ${String(leading)} trailing ${String(trailing)} release offset ${releaseOffset} preserves serial persistence and settlement`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `paced-mutations.held-pacing`, + plan, + observe, + async () => { + startAtOrigin() + await checkHeldPacing(plan.history) + }, + ), + check: forwardAssertionJudgment, + }) }) // The settlement model: a pending successor is canceled by its own explicit @@ -791,125 +910,167 @@ function expectedSuccessorCanceled( * and first success/rejection or pending rollback. The model uses the authored * key relationship and outcome, never production's conflict classifier. */ +type SettlementHistory = { + factory: `debounceStrategy` | `throttleStrategy` + leading: boolean + sameKey: boolean + outcome: `resolve` | `reject` | `cancel-pending` +} +const settlementFactories = { debounceStrategy, throttleStrategy } + +async function runSettlementHistory(history: SettlementHistory): Promise { + const { leading, sameKey, outcome } = history + const factory = settlementFactories[history.factory] + const collection = await createReadyCollection([ + { id: 1, value: 0 }, + { id: 2, value: 0 }, + ]) + const strategy = factory({ wait: 10, leading, trailing: true }) + const firstWrite = createDeferred() + const secondWrite = createDeferred() + const failure = new Error(`controlled persistence failure`) + const canceled = new Error(`pending transaction canceled`) + const starts: Array = [] + const mutate = createPacedMutations< + { id: number; value: number }, + { id: number; value: number } + >({ + strategy, + onMutate: ({ id, value }) => + collection.update(id, (draft) => { + draft.value = value + }), + mutationFn: ({ transaction }) => { + const value = transaction.mutations[0].modified.value + starts.push(value) + return value === 1 ? firstWrite.promise : secondWrite.promise + }, + }) + await withCleanup( + strategy, + collection, + async () => { + const first = mutate({ id: 1, value: 1 }) + const firstReceipt = observeReceipt(first) + await vi.advanceTimersByTimeAsync(leading ? 1 : 11) + const key = sameKey ? 1 : 2 + const second = mutate({ id: key, value: 2 }) + const secondReceipt = observeReceipt(second) + if (outcome === `cancel-pending`) + second.rollback({ + error: canceled, + isSecondaryRollback: true, + }) + await vi.advanceTimersByTimeAsync(30) + expect(starts, `held first write`).toEqual([1]) + expect(firstReceipt.outcome).toBe(`pending`) + expect( + secondReceipt.outcome, + `pending admission or explicit cancellation`, + ).toBe(outcome === `cancel-pending` ? `rejected` : `pending`) + expect( + collection.get(key)?.value, + `optimistic successor while held`, + ).toBe(outcome === `cancel-pending` ? (sameKey ? 1 : 0) : 2) + if (outcome === `reject`) firstWrite.reject(failure) + else firstWrite.resolve() + await vi.advanceTimersByTimeAsync(0) + const successorCanceled = expectedSuccessorCanceled(outcome, sameKey) + expect(starts, `only uncanceled successor persists`).toEqual( + successorCanceled ? [1] : [1, 2], + ) + expect(firstReceipt.outcome).toBe( + outcome === `reject` ? `rejected` : `fulfilled`, + ) + if (outcome === `reject`) expect(firstReceipt.error).toBe(failure) + expect(second.state).toBe(successorCanceled ? `failed` : `persisting`) + expect(secondReceipt.outcome).toBe( + successorCanceled ? `rejected` : `pending`, + ) + expect( + collection.get(key)?.value, + `successor after first settlement`, + ).toBe(successorCanceled ? 0 : 2) + if (outcome === `cancel-pending`) + expect(secondReceipt.error).toBe(canceled) + secondWrite.resolve() + await vi.advanceTimersByTimeAsync(20) + expect(secondReceipt.outcome, `terminal successor receipt`).toBe( + successorCanceled ? `rejected` : `fulfilled`, + ) + if (!successorCanceled) expect(secondReceipt.returnedSame).toBe(true) + expect( + [...collection.values()].map(({ id, value }) => ({ + id, + value, + })), + ).toEqual([ + { id: 1, value: 0 }, + { id: 2, value: 0 }, + ]) + const next = mutate({ id: 2, value: 3 }) + const nextReceipt = observeReceipt(next) + await vi.advanceTimersByTimeAsync(20) + expect(starts, `manager remains usable after settlement`).toEqual( + successorCanceled ? [1, 3] : [1, 2, 3], + ) + expect(nextReceipt).toMatchObject({ + outcome: `fulfilled`, + returnedSame: true, + }) + }, + async () => { + firstWrite.resolve() + secondWrite.resolve() + await vi.advanceTimersByTimeAsync(50) + }, + ) +} + describe(`paced settlement and pending ownership oracle`, () => { - for (const factory of [debounceStrategy, throttleStrategy]) - for (const leading of [true, false]) - 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 }, - ]) - const strategy = factory({ wait: 10, leading, trailing: true }) - const firstWrite = createDeferred() - const secondWrite = createDeferred() - const failure = new Error(`controlled persistence failure`) - const canceled = new Error(`pending transaction canceled`) - const starts: Array = [] - const mutate = createPacedMutations< - { id: number; value: number }, - { id: number; value: number } - >({ - strategy, - onMutate: ({ id, value }) => - collection.update(id, (draft) => { - draft.value = value - }), - mutationFn: ({ transaction }) => { - const value = transaction.mutations[0].modified.value - starts.push(value) - return value === 1 ? firstWrite.promise : secondWrite.promise - }, - }) - await withCleanup( - strategy, - collection, - async () => { - const first = mutate({ id: 1, value: 1 }) - const firstReceipt = observeReceipt(first) - await vi.advanceTimersByTimeAsync(leading ? 1 : 11) - const key = sameKey ? 1 : 2 - const second = mutate({ id: key, value: 2 }) - const secondReceipt = observeReceipt(second) - if (outcome === `cancel-pending`) - second.rollback({ - error: canceled, - isSecondaryRollback: true, - }) - await vi.advanceTimersByTimeAsync(30) - expect(starts, `held first write`).toEqual([1]) - expect(firstReceipt.outcome).toBe(`pending`) - expect( - secondReceipt.outcome, - `pending admission or explicit cancellation`, - ).toBe(outcome === `cancel-pending` ? `rejected` : `pending`) - expect( - collection.get(key)?.value, - `optimistic successor while held`, - ).toBe(outcome === `cancel-pending` ? (sameKey ? 1 : 0) : 2) - if (outcome === `reject`) firstWrite.reject(failure) - else firstWrite.resolve() - await vi.advanceTimersByTimeAsync(0) - const successorCanceled = expectedSuccessorCanceled( - outcome, - sameKey, - ) - expect(starts, `only uncanceled successor persists`).toEqual( - successorCanceled ? [1] : [1, 2], - ) - expect(firstReceipt.outcome).toBe( - outcome === `reject` ? `rejected` : `fulfilled`, - ) - if (outcome === `reject`) - expect(firstReceipt.error).toBe(failure) - expect(second.state).toBe( - successorCanceled ? `failed` : `persisting`, - ) - expect(secondReceipt.outcome).toBe( - successorCanceled ? `rejected` : `pending`, - ) - expect( - collection.get(key)?.value, - `successor after first settlement`, - ).toBe(successorCanceled ? 0 : 2) - if (outcome === `cancel-pending`) - expect(secondReceipt.error).toBe(canceled) - secondWrite.resolve() - await vi.advanceTimersByTimeAsync(20) - expect( - secondReceipt.outcome, - `terminal successor receipt`, - ).toBe(successorCanceled ? `rejected` : `fulfilled`) - if (!successorCanceled) - expect(secondReceipt.returnedSame).toBe(true) - expect( - [...collection.values()].map(({ id, value }) => ({ - id, - value, - })), - ).toEqual([ - { id: 1, value: 0 }, - { id: 2, value: 0 }, - ]) - const next = mutate({ id: 2, value: 3 }) - const nextReceipt = observeReceipt(next) - await vi.advanceTimersByTimeAsync(20) - expect( - starts, - `manager remains usable after settlement`, - ).toEqual(successorCanceled ? [1, 3] : [1, 2, 3]) - expect(nextReceipt).toMatchObject({ - outcome: `fulfilled`, - returnedSame: true, - }) - }, - async () => { - firstWrite.resolve() - secondWrite.resolve() - await vi.advanceTimersByTimeAsync(50) - }, - ) - }) + oracleProperty({ + id: `paced-mutations.settlement`, + law: { + statement: `Pacing keeps the Transaction rollback contract: a failed prior write rolls back pending same-key work, distinct-key work still persists, an explicitly rolled-back pending transaction never persists, and the manager stays usable.`, + authority: `Transaction.rollback and the optimistic-history owner, with ${pacedAuthority}`, + limits: `Both factories, leading/non-leading, same/distinct key, and first success, rejection or pending rollback.`, + }, + owns: [ + `src/paced-mutations.ts`, + `src/strategies/debounceStrategy.ts`, + `src/strategies/throttleStrategy.ts`, + `src/strategies/queueStrategy.ts`, + `src/strategies/serial-pacer.ts`, + ], + histories: ([`debounceStrategy`, `throttleStrategy`] as const).flatMap( + (factory) => + [true, false].flatMap((leading) => + [true, false].flatMap((sameKey) => + ([`resolve`, `reject`, `cancel-pending`] as const).map( + (outcome): SettlementHistory => ({ + factory, + leading, + sameKey, + outcome, + }), + ), + ), + ), + ), + label: ({ factory, leading, sameKey, outcome }) => + `${factory} leading ${leading} same key ${sameKey} with ${outcome} preserves transaction settlement`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `paced-mutations.settlement`, + plan, + observe, + async () => { + startAtOrigin() + await runSettlementHistory(plan.history) + }, + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/db/tests/production-error-messages-calibration.test.ts b/packages/db/tests/production-error-messages-calibration.test.ts new file mode 100644 index 0000000000..fd0bd70460 --- /dev/null +++ b/packages/db/tests/production-error-messages-calibration.test.ts @@ -0,0 +1,96 @@ +// Calibrations of the production error-message oracle's model-side checks. +// These tests run no production: the generated-docs check is calibrated with +// the forged catalog observation, and the error-site census classifier with +// the hand-written cases in fixtures/census. The production judgments are in +// production-error-messages-oracle.test.ts. +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { findErrorSites } from './error-sites-oracle' +import { + catalogObservation, + codesPath, + expectCodesDocumented, + sitesPath, +} from './production-error-messages-docs-oracle' + +const testsDirectory = dirname(fileURLToPath(import.meta.url)) + +describe(`production error messages`, () => { + describe(`error sites`, () => { + // The census is the classifier these laws rest on, so its rules are + // fixed on hand-written cases: one per rule, in fixtures/census. + it(`classifies guarded, unguarded, and coded census cases`, () => { + const cases = findErrorSites( + resolve(testsDirectory, `fixtures/census/src`), + ) + expect(cases.plain.map(({ template }) => template)).toEqual([ + `console alias: console.warn`, + `Or-guarded hint text`, + `\${devBuild() && process.env.NODE_ENV !== \`production\` ? Failed to save the row: : }`, + `console alias: {warn}=console`, + `Helper hint text`, + ]) + expect(cases.developmentOnly).toEqual([ + { file: `cases.ts`, literals: [`Guarded hint text`] }, + { file: `cases.ts`, literals: [`Braced return hint text`] }, + ]) + expect( + cases.coded.map(({ code, template, stored }) => [ + code, + template, + stored, + ]), + ).toEqual([ + [1, `Failed for \${id}`, false], + [2, `Stored failure text`, true], + ]) + }) + }) + + it.each([`heading`, `class-anchor`, `site-anchor`] as const)( + `rejects a missing generated %s`, + (part) => { + const docs = catalogObservation() + expectCodesDocumented(docs) + let wrong: string + if (part === `heading` || part === `class-anchor`) { + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const code = Object.values(codes)[0]! + if (part === `class-anchor`) + expectCodesDocumented( + docs.replace( + ``, + ``, + ), + ) + wrong = + part === `heading` + ? docs.replace(new RegExp(`^## Error ${code}\\b.*$`, `m`), ``) + : docs.replace(``, ``) + } else { + const sites: Record = JSON.parse( + readFileSync(sitesPath, `utf8`), + ) + const [code, { kind = `error` }] = Object.entries(sites)[0]! + wrong = docs.replace(``, ``) + } + expect(() => expectCodesDocumented(wrong)).toThrow() + }, + ) + + it(`rejects class IDs inside canonical fenced message examples`, () => { + const docs = catalogObservation() + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const code = Object.values(codes)[0]! + const anchor = `` + const wrong = docs.replace(anchor, `\`\`\`text\n${anchor}\n\`\`\`\n`) + expectCodesDocumented(docs) + expect(() => expectCodesDocumented(wrong)).toThrow() + }) +}) diff --git a/packages/db/tests/production-error-messages-docs-oracle.ts b/packages/db/tests/production-error-messages-docs-oracle.ts new file mode 100644 index 0000000000..b91df47c38 --- /dev/null +++ b/packages/db/tests/production-error-messages-docs-oracle.ts @@ -0,0 +1,74 @@ +// The generated-docs check of the production error-message oracle and the +// forged catalog observation that calibrates it. The law, its authority, and +// its limits are stated in production-error-messages-oracle.test.ts, which +// judges the real generator's stdout with this check; +// production-error-messages-calibration.test.ts calibrates the check with the +// forged catalog. This module registers no tests and has no side effects. +import { readFileSync } from 'node:fs' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { expect } from 'vitest' + +const testsDirectory = dirname(fileURLToPath(import.meta.url)) +export const codesPath = resolve(testsDirectory, `fixtures/error-codes.json`) +export const sitesPath = resolve( + testsDirectory, + `fixtures/error-site-messages.json`, +) + +// The check: the production outputs match the expected record for this claim. +export function expectCodesDocumented(docs: string): void { + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + // The link names an HTML ID, not one spelling of an anchor tag. Parsing + // admits valid quote, attribute-spacing and element choices. + // The actual generator puts literal messages in canonical ```text fences. + // Their contents render as code, so they cannot supply link targets. This + // bounded extraction covers those fences, not arbitrary Markdown layouts. + const html = docs.replace(/^```text\r?\n[\s\S]*?^```(?:\r?\n|$)/gm, ``) + const ids = new Set( + [ + ...new DOMParser() + .parseFromString(html, `text/html`) + .querySelectorAll(`[id]`), + ].map((element) => element.id), + ) + for (const [name, code] of Object.entries(codes)) { + const heading = new RegExp(`^## Error ${code}\\b.*${name}`, `m`) + expect(docs, `${name} (${code})`).toMatch(heading) + expect(ids, `${name} (${code}) anchor`).toContain(`error-${code}`) + } + const sites: Record = JSON.parse( + readFileSync(sitesPath, `utf8`), + ) + for (const [code, { file, kind }] of Object.entries(sites)) { + const title = kind === `warning` ? `Warning` : `Error` + const anchor = `` + const heading = new RegExp(`^## ${title} ${code}\\b.*${file}`, `m`) + expect(docs, `${title} ${code} (${file})`).toMatch(heading) + expect(docs, `${title} ${code} anchor`).toContain(anchor) + } +} + +// Checker calibration supplies only the catalog facts the law requires. +// This forged observation has no prose/examples/layout from the renderer and +// supplies no production evidence. Its validity is independent of CLI stdout. +export function catalogObservation(): string { + const codes: Record = JSON.parse( + readFileSync(codesPath, `utf8`), + ) + const sites: Record = JSON.parse( + readFileSync(sitesPath, `utf8`), + ) + return [ + ...Object.entries(codes).flatMap(([name, code]) => [ + ``, + `## Error ${code}: ${name}`, + ]), + ...Object.entries(sites).flatMap(([code, { file, kind }]) => [ + ``, + `## ${kind === `warning` ? `Warning` : `Error`} ${code}: ${file}`, + ]), + ].join(`\n`) +} diff --git a/packages/db/tests/production-error-messages-oracle.test.ts b/packages/db/tests/production-error-messages-oracle.test.ts index 2ec5983dff..1e28b6f00e 100644 --- a/packages/db/tests/production-error-messages-oracle.test.ts +++ b/packages/db/tests/production-error-messages-oracle.test.ts @@ -68,22 +68,29 @@ import { readFileSync } from 'node:fs' import { dirname, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, vi } from 'vitest' import * as Errors from '../src/index' import { codedWarning } from '../src/error-message' -import { declareOracle } from './oracle-declaration.js' 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' +import { oracleProperty } from './oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from './oracle-assertion-adapter.js' +import { + codesPath, + expectCodesDocumented, + sitesPath, +} from './production-error-messages-docs-oracle' const testsDirectory = dirname(fileURLToPath(import.meta.url)) const devMessages: Record> = JSON.parse( readFileSync(resolve(testsDirectory, `fixtures/error-messages.json`), `utf8`), ) -const codesPath = resolve(testsDirectory, `fixtures/error-codes.json`) -const sitesPath = resolve(testsDirectory, `fixtures/error-site-messages.json`) const developmentOnlyPath = resolve( testsDirectory, `fixtures/development-only-messages.json`, @@ -276,6 +283,9 @@ function expectHostileInputsCoded(): void { } } +// JSON cannot show these values faithfully, but production logs need them: +// a symbol or bigint shows as a quoted string, and `undefined`, `NaN`, and +// the infinities show as themselves rather than as `null` or nothing. // The check: the production outputs match the expected record for this claim. function expectUnencodableValuesShown(): void { const codes: Record = JSON.parse( @@ -306,63 +316,6 @@ function expectUnencodableValuesShown(): void { } } -// The check: the production outputs match the expected record for this claim. -function expectCodesDocumented(docs: string): void { - const codes: Record = JSON.parse( - readFileSync(codesPath, `utf8`), - ) - // The link names an HTML ID, not one spelling of an anchor tag. Parsing - // admits valid quote, attribute-spacing and element choices. - // The actual generator puts literal messages in canonical ```text fences. - // Their contents render as code, so they cannot supply link targets. This - // bounded extraction covers those fences, not arbitrary Markdown layouts. - const html = docs.replace(/^```text\r?\n[\s\S]*?^```(?:\r?\n|$)/gm, ``) - const ids = new Set( - [ - ...new DOMParser() - .parseFromString(html, `text/html`) - .querySelectorAll(`[id]`), - ].map((element) => element.id), - ) - for (const [name, code] of Object.entries(codes)) { - const heading = new RegExp(`^## Error ${code}\\b.*${name}`, `m`) - expect(docs, `${name} (${code})`).toMatch(heading) - expect(ids, `${name} (${code}) anchor`).toContain(`error-${code}`) - } - const sites: Record = JSON.parse( - readFileSync(sitesPath, `utf8`), - ) - for (const [code, { file, kind }] of Object.entries(sites)) { - const title = kind === `warning` ? `Warning` : `Error` - const anchor = `` - const heading = new RegExp(`^## ${title} ${code}\\b.*${file}`, `m`) - expect(docs, `${title} ${code} (${file})`).toMatch(heading) - expect(docs, `${title} ${code} anchor`).toContain(anchor) - } -} - -// Checker calibration supplies only the catalog facts the law requires. -// This forged observation has no prose/examples/layout from the renderer and -// supplies no production evidence. Its validity is independent of CLI stdout. -function catalogObservation(): string { - const codes: Record = JSON.parse( - readFileSync(codesPath, `utf8`), - ) - const sites: Record = JSON.parse( - readFileSync(sitesPath, `utf8`), - ) - return [ - ...Object.entries(codes).flatMap(([name, code]) => [ - ``, - `## Error ${code}: ${name}`, - ]), - ...Object.entries(sites).flatMap(([code, { file, kind }]) => [ - ``, - `## ${kind === `warning` ? `Warning` : `Error`} ${code}: ${file}`, - ]), - ].join(`\n`) -} - // This receiving driver copies the actual CLI and full frozen catalog into a // disposable repository, then judges its real stdout. Even a broken stdout // mode cannot write the active checkout's generated page. @@ -382,52 +335,78 @@ function renderErrorDocs(): string { // 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, - expectFullTextInProduction, - expectDevelopmentMessages, - expectClassShapeInBothModes, - expectCodedProductionLines, - expectHostileInputsCoded, - expectUnencodableValuesShown, - expectCodesDocumented, - ], -}) +const errorMessagesLaw = { + statement: `Each exported error class keeps its frozen development message, class shape and fields in both modes, and writes one coded production line that shows its inputs; each library error site keeps its frozen template and code; the generated docs document every code.`, + authority: `The maintainer decisions of 2026-10-07 and 2026-10-08 stated above, with the frozen fixtures as the model.`, + limits: `The sample inputs per class and the fixed hostile inputs substituted one argument at a time; error sites are read from source; the docs check covers the actual generator's stdout.`, +} + +// The real generator's stdout must document every code with its class. +function documentEveryCode(): void { + const states = [`cli-stdout-received`, `catalog-compared`] as const + const reached = new Set<(typeof states)[number]>() + try { + const docs = renderErrorDocs() + reached.add(`cli-stdout-received`) + expectCodesDocumented(docs) + reached.add(`catalog-compared`) + for (const state of states) + expect(reached.has(state), `${state} history minimum`).toBe(true) + } finally { + if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) + console.info( + `production-errors.generated-docs reach`, + Object.fromEntries( + states.map((state) => [state, Number(reached.has(state))]), + ), + ) + } +} 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() + // Each history names one claim, and its existing check runs the production + // constructors against the frozen fixtures. + const classChecks: ReadonlyArray void]> = [ + [ + `codes every exported class except the caller-message bases`, + expectEveryClassCoded, + ], + [ + `keeps the full message in production for full-text classes`, + expectFullTextInProduction, + ], + [`keeps every development message unchanged`, expectDevelopmentMessages], + [ + `keeps class, name, instanceof, and fields in both modes`, + expectClassShapeInBothModes, + ], + [ + `writes a coded production line with the shown inputs`, + expectCodedProductionLines, + ], + [`writes one coded line for hostile inputs`, expectHostileInputsCoded], + [ + `shows values that JSON cannot encode in a shown position`, + expectUnencodableValuesShown, + ], + ] + oracleProperty({ + id: `production-errors.classes`, + law: errorMessagesLaw, + owns: [`src/errors.ts`, `src/error-message.ts`], + histories: classChecks.map(([name]) => name), + label: (name) => name, + resolve: (name) => ({ + check: classChecks.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`production-errors.classes`, plan, observe, () => + plan.check(), + ), + check: forwardAssertionJudgment, }) describe(`error sites`, () => { @@ -450,15 +429,13 @@ describe(`production error messages`, () => { // bundles drop the whole module. The bundle check proves that erasure. const developmentOnlyFiles = new Set([`collection/config-errors.ts`]) - it(`codes every site that throws library text`, () => { - oracleRoute(`case`) + function expectEverySiteCoded(): void { expect( sites.plain.filter(({ file }) => !developmentOnlyFiles.has(file)), ).toEqual([]) - }) + } - it(`keeps each site's development message and code`, () => { - oracleRoute(`case`) + function expectSiteMessagesFrozen(): void { const current = Object.fromEntries( sites.coded.map(({ code, file, template, literals, kind }) => [ code, @@ -472,10 +449,9 @@ describe(`production error messages`, () => { ) expect(sites.coded).toHaveLength(Object.keys(current).length) expect(current).toEqual(frozen) - }) + } - it(`passes every showable interpolated value to the code line`, () => { - oracleRoute(`case`) + function expectShowableValuesPassed(): void { for (const site of sites.coded) for (const interpolation of site.interpolations.filter( ({ showable }) => showable, @@ -491,21 +467,19 @@ describe(`production error messages`, () => { ), `${site.file} error ${site.code} drops \${${interpolation.expression}}`, ).toBe(true) - }) + } // Production lines never carry plain objects, such as rows. - it(`passes only values that the code line can show`, () => { - oracleRoute(`case`) + function expectOnlyShowableValues(): void { const hidden = sites.coded.flatMap(({ file, code, values }) => values .filter(({ showable }) => !showable) .map(({ expression }) => `${file} error ${code}: ${expression}`), ) expect(hidden).toEqual([]) - }) + } - it(`registers the code of every error class outside errors.ts`, () => { - oracleRoute(`case`) + function expectClassGuardCodes(): void { const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), ) @@ -513,63 +487,30 @@ describe(`production error messages`, () => { expect(guard.code, `${guard.file} ${guard.name}`).toBe( codes[guard.name], ) - }) + } // Developer hints are development-only (maintainer decision, // 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`) + function expectDevelopmentOnlyLiteralsFrozen(): void { 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 - // 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`), - ) - expect(cases.plain.map(({ template }) => template)).toEqual([ - `console alias: console.warn`, - `Or-guarded hint text`, - `\${devBuild() && process.env.NODE_ENV !== \`production\` ? Failed to save the row: : }`, - `console alias: {warn}=console`, - `Helper hint text`, - ]) - expect(cases.developmentOnly).toEqual([ - { file: `cases.ts`, literals: [`Guarded hint text`] }, - { file: `cases.ts`, literals: [`Braced return hint text`] }, - ]) - expect( - cases.coded.map(({ code, template, stored }) => [ - code, - template, - stored, - ]), - ).toEqual([ - [1, `Failed for \${id}`, false], - [2, `Stored failure text`, true], - ]) - }) + } // 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`) + function expectCodedMessagesUsedInPlace(): void { expect( sites.coded .filter(({ stored }) => stored) .map(({ file, code }) => `${file} ${code}`), ).toEqual([]) - }) + } // 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`) + function expectWarningsCoded(): void { const mismatched = sites.coded .filter(({ kind, inWarn }) => (kind === `warning`) !== inWarn) .map(({ file, code, kind }) => `${file} ${kind} ${code}`) @@ -577,92 +518,78 @@ describe(`production error messages`, () => { expect(codedWarning(7, { key: 1 })).toBe( `TanStack DB warning 7 (key=1): ${docsUrl}#warning-7`, ) - }) + } - it(`never reuses a class code`, () => { - oracleRoute(`case`) + function expectNoClassCodeReuse(): void { const codes: Record = JSON.parse( readFileSync(codesPath, `utf8`), ) const classCodes = new Set(Object.values(codes)) expect(sites.coded.filter(({ code }) => classCodes.has(code))).toEqual([]) - }) - }) - - // JSON cannot show these values faithfully, but production logs need them: - // 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 - if (part === `heading` || part === `class-anchor`) { - const codes: Record = JSON.parse( - readFileSync(codesPath, `utf8`), - ) - const code = Object.values(codes)[0]! - if (part === `class-anchor`) - expectCodesDocumented( - docs.replace( - ``, - ``, - ), - ) - wrong = - part === `heading` - ? docs.replace(new RegExp(`^## Error ${code}\\b.*$`, `m`), ``) - : docs.replace(``, ``) - } else { - const sites: Record = JSON.parse( - readFileSync(sitesPath, `utf8`), - ) - const [code, { kind = `error` }] = Object.entries(sites)[0]! - wrong = docs.replace(``, ``) - } - expect(() => expectCodesDocumented(wrong)).toThrow() - }, - ) + } - it(`rejects class IDs inside canonical fenced message examples`, () => { - oracleRoute(`calibration`) - const docs = catalogObservation() - const codes: Record = JSON.parse( - readFileSync(codesPath, `utf8`), - ) - const code = Object.values(codes)[0]! - const anchor = `` - const wrong = docs.replace(anchor, `\`\`\`text\n${anchor}\n\`\`\`\n`) - expectCodesDocumented(docs) - expect(() => expectCodesDocumented(wrong)).toThrow() + const siteChecks: ReadonlyArray void]> = [ + [`codes every site that throws library text`, expectEverySiteCoded], + [ + `keeps each site's development message and code`, + expectSiteMessagesFrozen, + ], + [ + `passes every showable interpolated value to the code line`, + expectShowableValuesPassed, + ], + [ + `passes only values that the code line can show`, + expectOnlyShowableValues, + ], + [ + `registers the code of every error class outside errors.ts`, + expectClassGuardCodes, + ], + [ + `freezes every development-only literal`, + expectDevelopmentOnlyLiteralsFrozen, + ], + [ + `uses every coded message where it is written`, + expectCodedMessagesUsedInPlace, + ], + [`codes a console.warn message as a warning`, expectWarningsCoded], + [`never reuses a class code`, expectNoClassCodeReuse], + ] + oracleProperty({ + id: `production-errors.sites`, + law: errorMessagesLaw, + owns: [`src/errors.ts`, `src/error-message.ts`], + histories: siteChecks.map(([name]) => name), + label: (name) => name, + resolve: (name) => ({ + check: siteChecks.find(([candidate]) => candidate === name)![1], + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`production-errors.sites`, plan, observe, () => + plan.check(), + ), + check: forwardAssertionJudgment, + }) }) - it(`documents every code with its class`, () => { - oracleRoute(`case`) - const states = [`cli-stdout-received`, `catalog-compared`] as const - const reached = new Set<(typeof states)[number]>() - try { - const docs = renderErrorDocs() - reached.add(`cli-stdout-received`) - expectCodesDocumented(docs) - reached.add(`catalog-compared`) - for (const state of states) - expect(reached.has(state), `${state} history minimum`).toBe(true) - } finally { - if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) - console.info( - `production-errors.generated-docs reach`, - Object.fromEntries( - states.map((state) => [state, Number(reached.has(state))]), - ), - ) - } + oracleProperty({ + id: `production-errors.generated-docs`, + law: errorMessagesLaw, + owns: [`src/errors.ts`, `src/error-message.ts`], + histories: [`documents every code with its class`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `production-errors.generated-docs`, + plan, + observe, + documentEveryCode, + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index 04cea036cc..f38eb6dd60 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -1,10 +1,9 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' 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 { oracleProperty, oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' +import { oracleProperty } from './oracle-config.js' import { forwardAssertionJudgment, oracleAssertion, @@ -801,36 +800,11 @@ async function runHistory(steps: ReadonlyArray): Promise { const history = fc.array(step, { minLength: 1, maxLength: 24 }) -declareOracle({ - check: [runHistory], - model: [OwnershipModel], -}) - -describe(`settled transactions leave every Collection that tracked them`, () => { - oracleProperty({ - id: PROPERTY, - law: { - statement: `A settled transaction leaves every Collection that tracked it, drops its optimistic rows, and settles its public promise despite subscriber failures.`, - authority: `Transaction settlement and Collection ownership contracts stated above`, - limits: `Two Collections, four manual transactions, bounded legal mutations and settlement; excludes queued sync holds, offline restoration and mid-history cleanup.`, - }, - // What this oracle owns: the gap report judges the transaction's settlement - // and the Collection's tracking of it, by this file's tests. - owns: [`src/transactions.ts`, `src/collection/state.ts`], - histories: { arbitrary: history, runs: 200, fixedSeeds: [2080] }, - resolve: (steps) => ({ steps }), - model: recordedAssertionJudgment, - driver: (plan, observe) => - recordAssertionJudgment(`transaction ownership`, plan, observe, () => - runHistory(plan.steps), - ), - check: forwardAssertionJudgment, - timeout: 300_000, - }) - - it(`fixed history: a subscriber throws during an asynchronous success and failure settlement`, async () => { - oracleRoute(`fixed history`) - await runHistory([ +// Named regressions, replayed through the same driver and comparison. +const fixedHistories: Array<{ name: string; history: Array }> = [ + { + name: `a subscriber throws during an asynchronous success and failure settlement`, + history: [ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 3 }, { type: `edit`, tx: 0, on: `B`, key: 1, value: 3 }, @@ -841,12 +815,11 @@ describe(`settled transactions leave every Collection that tracked them`, () => { type: `edit`, tx: 0, on: `B`, key: 2, value: 4 }, { type: `commit`, tx: 0 }, { type: `settle`, tx: 0, ok: false, throwOn: `A` }, - ]) - }) - - it(`fixed history: a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, async () => { - oracleRoute(`fixed history`) - await runHistory([ + ], + }, + { + name: `a merged-away pair, a conflicting rollback that throws, and a truncate rollback`, + history: [ { type: `open`, reuse: undefined }, { type: `cancel`, tx: 0, on: `A` }, { type: `commit`, tx: 0 }, @@ -860,12 +833,11 @@ describe(`settled transactions leave every Collection that tracked them`, () => { type: `open`, reuse: undefined }, { type: `edit`, tx: 3, on: `B`, key: 2, value: 5 }, { type: `truncateRollback`, tx: 3, on: `B` }, - ]) - }) - - it(`fixed history: a rolled-back transaction does not remove a live one that shares its id`, async () => { - oracleRoute(`fixed history`) - await runHistory([ + ], + }, + { + name: `a rolled-back transaction does not remove a live one that shares its id`, + history: [ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, { type: `open`, reuse: 0 }, @@ -876,22 +848,20 @@ describe(`settled transactions leave every Collection that tracked them`, () => // Pending 0 and 2: the rollback of 2 also rolls back 0, which shares A1. { type: `edit`, tx: 1, on: `A`, key: 1, value: 7 }, { type: `rollback`, tx: 1, throwOn: undefined }, - ]) - }) - - it(`fixed history: a second live transaction with a tracked id is rejected`, async () => { - oracleRoute(`fixed history`) - await runHistory([ + ], + }, + { + name: `a second live transaction with a tracked id is rejected`, + history: [ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, { type: `open`, reuse: 0 }, { type: `edit`, tx: 1, on: `A`, key: 2, value: 6 }, - ]) - }) - - it(`fixed history: conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, async () => { - oracleRoute(`fixed history`) - await runHistory([ + ], + }, + { + name: `conflicting rollbacks that throw settle every transaction and rethrow one subscriber error`, + history: [ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, { type: `open`, reuse: undefined }, @@ -901,16 +871,43 @@ describe(`settled transactions leave every Collection that tracked them`, () => { type: `edit`, tx: 2, on: `A`, key: 1, value: 7 }, { type: `edit`, tx: 2, on: `B`, key: 2, value: 7 }, { type: `rollback`, tx: 0, throwOn: `both` }, - ]) - }) - - it(`fixed history: a failed commit whose rollback subscriber throws keeps the mutation error`, async () => { - oracleRoute(`fixed history`) - await runHistory([ + ], + }, + { + name: `a failed commit whose rollback subscriber throws keeps the mutation error`, + history: [ { type: `open`, reuse: undefined }, { type: `edit`, tx: 0, on: `A`, key: 1, value: 5 }, { type: `commit`, tx: 0 }, { type: `settle`, tx: 0, ok: false, throwOn: `A` }, - ]) + ], + }, +] + +describe(`settled transactions leave every Collection that tracked them`, () => { + oracleProperty({ + id: PROPERTY, + law: { + statement: `A settled transaction leaves every Collection that tracked it, drops its optimistic rows, and settles its public promise despite subscriber failures.`, + authority: `Transaction settlement and Collection ownership contracts stated above`, + limits: `Two Collections, four manual transactions, bounded legal mutations and settlement; excludes queued sync holds, offline restoration and mid-history cleanup.`, + }, + // What this oracle owns: the gap report judges the transaction's settlement + // and the Collection's tracking of it, by this file's tests. + owns: [`src/transactions.ts`, `src/collection/state.ts`], + histories: { + arbitrary: history, + runs: 200, + fixedSeeds: [2080], + fixed: fixedHistories, + }, + resolve: (steps) => ({ steps }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`transaction ownership`, plan, observe, () => + runHistory(plan.steps), + ), + check: forwardAssertionJudgment, + timeout: 300_000, }) }) From 5b6d8299da40677692ac75d68a2106ccdd9ac41a Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:23:23 -0700 Subject: [PATCH 081/116] test(db): register virtual-field publication histories through oracleProperty Move the hostile publication calibrations to the ordinary virtual-field tests, with the models and checks they read in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 13 + .../query/virtual-row-fields-model-oracle.ts | 493 +++ .../query/virtual-row-fields-oracle.test.ts | 3794 +++++++---------- .../db/tests/query/virtual-row-fields.test.ts | 232 + 4 files changed, 2384 insertions(+), 2148 deletions(-) create mode 100644 packages/db/tests/query/virtual-row-fields-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index e31cdae77e..35055169dd 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -47,6 +47,14 @@ export const properties: ReadonlyArray = [ `virtual-row-fields-input.direct-source`, `virtual-row-fields-input.inline`, `virtual-row-fields-input.compiler-cache`, + `virtual-row-fields.boundary-publication`, + `virtual-row-fields.projection-attribution`, + `virtual-row-fields.last-spread`, + `virtual-row-fields.union-branches`, + `virtual-row-fields.metadata-timeline`, + `virtual-row-fields.facade-intervals`, + `virtual-row-fields.nullable-facade`, + `virtual-row-fields.runtime-boundary`, ] export const owners: ReadonlyArray = [ @@ -115,4 +123,9 @@ export const owners: ReadonlyArray = [ `virtual-row-fields-input`, `callback direct-source inline compiler-cache`, ], + [ + `db/tests/query/virtual-row-fields-oracle.test.ts`, + `virtual-row-fields`, + `boundary-publication projection-attribution last-spread union-branches metadata-timeline facade-intervals nullable-facade runtime-boundary`, + ], ] diff --git a/packages/db/tests/query/virtual-row-fields-model-oracle.ts b/packages/db/tests/query/virtual-row-fields-model-oracle.ts new file mode 100644 index 0000000000..43045e3bdc --- /dev/null +++ b/packages/db/tests/query/virtual-row-fields-model-oracle.ts @@ -0,0 +1,493 @@ +import { expect } from 'vitest' +import { metadataCollisionCases } from './virtual-row-fields-grammar-oracle.js' +import { virtualFieldNames } from './virtual-row-fields-support.js' +import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' + +// The attribution, boundary-publication, publication-ledger, inline-value, +// and nullable-selection models and checks of the virtual-field publication +// oracle, moved here unchanged from virtual-row-fields-oracle.test.ts. The +// oracle compares production with them; the ordinary checker calibrations in +// virtual-row-fields.test.ts feed them hostile observations. This module +// registers no tests. + +export function sourceOwnsMetadata(shape: string): boolean { + return shape === `structured-spread` || shape === `functional-direct` +} + +export function checkRootMetadata( + actual: Record<(typeof virtualFieldNames)[number], unknown>, + expected: Record<(typeof virtualFieldNames)[number], unknown>, +): void { + for (const field of virtualFieldNames) + expect(actual[field], field).toBe(expected[field]) +} + +export type AttributionKey = string | number +export type AttributionRow = { + id: AttributionKey + parentId: string + label: string +} +// The finite grammar varies compiler context types, but every accepted root +// has these ordinary fields. Erase only that context union at the driver edge; +// the full runtime shape remains independently checked below. +export type AttributionContext = { + schema: {} + baseSchema: {} + fromSourceName: string + result: { id: AttributionKey; label: string } + hasResult: true +} +// Model rows are authored source facts, never sampled from the production +// Collection. Pending overlays affect only their named key; settlement without +// source confirmation restores the authored base (optimistic-history contract). +export function expectedAttributedRows( + rows: ReadonlyArray, + shape: string, + pending: ReadonlySet, + sourceId: string, + outputId: string, + outputKey: (id: AttributionKey) => unknown, + // Single-source rows keep their source key. Joined rows use their actual + // default pair or explicit custom key (maintainer decision, November 5). + sourceKey: (id: AttributionKey) => AttributionKey = (id) => id, +) { + return rows.map((row) => ({ + ordinary: + shape === `structured-projection` + ? { + id: row.id, + label: row.label, + hasPendingWrites: pending.has(row.id), + } + : shape === `functional-projection` + ? { id: row.id, label: row.label } + : { ...row }, + metadata: sourceOwnsMetadata(shape) + ? expectedSourceMetadata(sourceKey(row.id), sourceId, pending.has(row.id)) + : { + $key: outputKey(row.id), + $collectionId: outputId, + $hasPendingWrites: false, + $origin: `remote`, + }, + })) +} +export function checkAttributedRows( + actual: ReadonlyArray, + expected: ReturnType, +): void { + const observed = actual.map((row) => ({ + ordinary: Object.fromEntries( + Object.entries(row).filter( + ([name]) => !virtualFieldNames.some((field) => field === name), + ), + ), + metadata: Object.fromEntries( + virtualFieldNames.map((field) => [ + field, + (row as Record)[field], + ]), + ), + })) + const identity = (row: { ordinary: object }) => { + const { id } = row.ordinary as { id: unknown } + return `${typeof id}:${String(id)}` + } + const byId = (a: { ordinary: object }, b: { ordinary: object }) => + identity(a).localeCompare(identity(b)) + // Sorting permits unspecified result order but preserves every occurrence, + // field and extra row. No Map can collapse duplicate contributions here. + expect(observed.sort(byId)).toEqual([...expected].sort(byId)) +} + +// Each finite history has one numeric token. A state keeps that token once, +// so repeated observations preserve the original per-history checkpoint count. + +// Both boolean values are legal authored collisions. True distinguishes an +// output-owned row's mandatory false pending flag; false distinguishes a +// pending source-owned row. Use a legal local origin to challenge remote roots. +export const collisionCases = metadataCollisionCases + +export type BoundaryPublicationHistory = { + kind: + | `implicit-single` + | `implicit-join` + | `inline-return` + | `grouped` + | `aggregate` + composition: `direct` | `query-ref` + pendingSource: `parent` | `child` + inline: `none` | `array` | `singleton` + collision: string + authoredPending: boolean +} +export const boundaryPublicationHistories: Array = [ + ...([`grouped`, `aggregate`] as const).flatMap((kind) => + ([`direct`, `query-ref`] as const).map((composition) => ({ + kind, + composition, + pendingSource: `child` as const, + inline: `none` as const, + collision: `none`, + authoredPending: false, + })), + ), + ...([`implicit-single`, `implicit-join`] as const).flatMap((kind) => + ([`direct`, `query-ref`] as const).flatMap((composition) => + (kind === `implicit-single` + ? ([`child`] as const) + : ([`parent`, `child`] as const) + ).map((pendingSource) => ({ + kind, + composition, + pendingSource, + inline: `none` as const, + collision: `none`, + authoredPending: false, + })), + ), + ), + ...([`none`, `array`, `singleton`] as const).flatMap((inline) => + collisionCases.flatMap(({ collision, authoredPending }) => + ([`direct`, `query-ref`] as const).flatMap((composition) => + ([`parent`, `child`] as const).map((pendingSource) => ({ + kind: `inline-return` as const, + inline, + collision, + authoredPending, + composition, + pendingSource, + })), + ), + ), + ), +] +export type BoundaryPublishedRow = AttributionRow & { children?: unknown } +export type BoundaryJoinedRow = { + parent: AttributionRow + chosen: AttributionRow +} +export type BoundaryPublicationContext = Omit & { + result: + | BoundaryPublishedRow + | BoundaryJoinedRow + | { id: AttributionKey; total: number } +} +// This grammar also models joined namespaces and inline children. Keep its +// carrier open to those shapes; exact fields still come from the model below. +export type BoundaryMetadataResult = { + ordinary: object + metadata: ReturnType[number][`metadata`] +} + +// A namespace combines two source values rather than selecting either one. +// Returning an actual source input, including a spread projected upstream, +// retains that input's source owner. No selected metadata values choose it. +export function expectedBoundaryPublication( + history: BoundaryPublicationHistory, + parent: AttributionRow, + children: ReadonlyArray, + pending: ReadonlySet, + parentId: string, + childId: string, + outputId: string, +): Array { + if (history.kind === `grouped` || history.kind === `aggregate`) { + // Group membership and counts follow authored rows, independent of their + // metadata. No source row is selected whole, so publication owns metadata. + const counts = new Map() + for (const child of children) { + const id = history.kind === `aggregate` ? `all` : child.parentId + counts.set(id, (counts.get(id) ?? 0) + 1) + } + return [...counts].map(([id, total]) => ({ + ordinary: { id, total }, + metadata: expectedSourceMetadata(`output-${id}`, outputId, false), + })) + } + if (history.kind === `implicit-single`) + return expectedAttributedRows( + children, + `functional-direct`, + pending, + childId, + outputId, + (id) => id, + ) + if (history.kind === `implicit-join`) + return children + .filter((child) => child.parentId === parent.id) + .map((child) => ({ + ordinary: { + parent: { + ...parent, + ...expectedSourceMetadata( + parent.id, + parentId, + pending.has(parent.id), + ), + }, + chosen: { + ...child, + ...expectedSourceMetadata(child.id, childId, pending.has(child.id)), + }, + }, + metadata: expectedSourceMetadata(`result-${child.id}`, outputId, false), + })) + const selected = expectedAttributedRows( + [parent], + `functional-direct`, + pending, + parentId, + outputId, + (id) => id, + )[0]! + if (history.inline !== `none`) { + const values = children + .filter((child) => child.parentId === parent.id) + .map(({ id, label }) => ({ id, label })) + return [ + { + ...selected, + ordinary: { + ...selected.ordinary, + children: history.inline === `singleton` ? values[0] : values, + }, + }, + ] + } + return [selected] +} + +// Compare physical membership keys and every readable row field together. +// Sorting by the entry key permits unspecified delivery order, while retaining +// duplicate rows and mismatches between a public row's key and its entry key. +export function checkBoundaryPublication( + actual: ReadonlyArray, + expected: ReadonlyArray, +): void { + const order = ( + a: readonly [AttributionKey, object], + b: readonly [AttributionKey, object], + ) => `${typeof a[0]}:${a[0]}`.localeCompare(`${typeof b[0]}:${b[0]}`) + expect( + actual.map(([key, row]) => [key, { ...row }] as const).sort(order), + ).toEqual( + expected + .map( + ({ ordinary, metadata }) => + [ + metadata.$key as AttributionKey, + { ...ordinary, ...metadata }, + ] as const, + ) + .sort(order), + ) +} + +export type MetadataResult = ReturnType[number] +export function expectedJoinedMetadata( + leftRows: ReadonlyArray, + rightRows: ReadonlyArray, + shape: string, + reverse: boolean, + pending: ReadonlySet, + rightOrigin: `local` | `remote`, + leftId: string, + rightId: string, + outputId: string, +): Array { + return leftRows.flatMap((a) => { + const matches = rightRows.filter((b) => b.parentId === a.id) + return (matches.length ? matches : [undefined]).map((b) => { + const spread = reverse ? { ...b, ...a } : { ...a, ...b } + const owner = shape === `functional-direct` || reverse || !b ? a : b + const selected = + shape.endsWith(`projection`) || shape === `functional-direct` + ? a + : spread + const expected = expectedAttributedRows( + [selected], + shape, + pending, + owner === b ? rightId : leftId, + outputId, + (id) => `projection-${id}`, + )[0]! + if (sourceOwnsMetadata(shape) && owner === b && !pending.has(b.id)) + expected.metadata.$origin = rightOrigin + return expected + }) + }) +} + +// Capture all row fields now. Retaining production row handles could let a +// later metadata getter or mutation rewrite evidence from an earlier callback. +export function captureMetadataRows( + rows: ReadonlyArray, +): Array { + return rows.map((row) => ({ + ...row, + ...Object.fromEntries( + virtualFieldNames.map((field) => [ + field, + (row as Record)[field], + ]), + ), + })) +} +export type MetadataBatch = { + rows: Array + changes: Array<{ + type: string + key: AttributionKey + value: object + previousValue?: object + }> +} +export function publishedModelRows(rows: ReadonlyArray) { + return rows.map((row) => ({ ...row.ordinary, ...row.metadata })) +} +// Collection change messages are the exact before/after delta for one atomic +// publication. Their ordering is unspecified; duplicate or omitted messages +// remain visible because neither side collapses actual messages into a Map. +export function expectedMetadataChanges( + before: ReadonlyArray, + after: ReadonlyArray, +) { + const changes: MetadataBatch[`changes`] = [] + const same = (a: MetadataResult, b: MetadataResult) => + JSON.stringify(a) === JSON.stringify(b) + for (const row of before) { + if (!after.some((next) => next.metadata.$key === row.metadata.$key)) + changes.push({ + type: `delete`, + key: row.metadata.$key as AttributionKey, + value: publishedModelRows([row])[0]!, + }) + } + for (const row of after) { + const previous = before.find( + (old) => old.metadata.$key === row.metadata.$key, + ) + if (!previous || !same(previous, row)) + changes.push({ + type: previous ? `update` : `insert`, + key: row.metadata.$key as AttributionKey, + value: publishedModelRows([row])[0]!, + ...(previous + ? { previousValue: publishedModelRows([previous])[0]! } + : {}), + }) + } + return changes +} +export function checkMetadataPublications( + actual: ReadonlyArray, + before: ReadonlyArray, + after: ReadonlyArray, +): void { + const expectedChanges = expectedMetadataChanges(before, after) + if (expectedChanges.length) + expect(actual.length, `owed metadata publication`).toBeGreaterThan(0) + const byChange = ( + a: MetadataBatch[`changes`][number], + b: MetadataBatch[`changes`][number], + ) => `${typeof a.key}:${a.key}`.localeCompare(`${typeof b.key}:${b.key}`) + for (const batch of actual) { + checkAttributedRows(batch.rows, [...after]) + } + // Compare every callback's delta together. Splitting a batch stays legal; + // repeating a whole batch cannot consume the same public change twice. + expect(actual.flatMap((batch) => batch.changes).sort(byChange)).toEqual( + [...expectedChanges].sort(byChange), + ) +} + +export function expectedInlineMetadataValues( + rows: ReadonlyArray, + shape: string, + pending: ReadonlySet, + sourceId: string, + overrides: Readonly>, +): Array { + return rows.map((row) => { + const source = expectedAttributedRows( + [row], + `structured-spread`, + pending, + sourceId, + `unused`, + (id) => id, + )[0]! + const selected = shape.endsWith(`projection`) + ? expectedAttributedRows( + [row], + shape, + pending, + sourceId, + `unused`, + (id) => id, + )[0]!.ordinary + : { ...source.ordinary, ...source.metadata } + return shape === `functional-direct` + ? selected + : { ...selected, ...overrides } + }) +} +export function checkInlineMetadataValues( + actual: ReadonlyArray, + expected: ReadonlyArray, +): void { + // These values are flat authored records. Sorting ignores unspecified order, + // but preserves every selected property and duplicate occurrence. + const byId = (a: object, b: object) => + String((a as { id: unknown }).id).localeCompare( + String((b as { id: unknown }).id), + ) + expect([...actual].sort(byId)).toEqual([...expected].sort(byId)) +} + +export function expectedNullableSelection( + match: { id: string; label: string } | undefined, + sourceId: string, + facadeId: string, + facadeKey: string | number, +): { inline: Array; facade: Array } { + const inline = match + ? [ + { + ...match, + $key: match.id, + $collectionId: sourceId, + $origin: `remote`, + $hasPendingWrites: false, + }, + ] + : [{}] + return { + inline, + facade: match + ? inline.map((row) => ({ ...row, $key: facadeKey })) + : [ + { + $key: facadeKey, + $collectionId: facadeId, + $origin: `remote`, + $hasPendingWrites: false, + }, + ], + } +} +export function checkNullableSelection( + actual: { + facade: ReadonlyArray + inline: ReadonlyArray> + }, + expected: ReturnType, +): void { + checkInlineMetadataValues(actual.facade, expected.facade) + for (const value of actual.inline) + checkInlineMetadataValues(value, expected.inline) +} 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 681492e5b2..c083e382c7 100644 --- a/packages/db/tests/query/virtual-row-fields-oracle.test.ts +++ b/packages/db/tests/query/virtual-row-fields-oracle.test.ts @@ -62,7 +62,7 @@ * observations. `checkWithCleanup` retains the primary mismatch as the cause * when Collection cleanup also fails and attempts every cleanup. */ -import { afterAll, describe, expect, test, vi } from 'vitest' +import { afterAll, describe, expect, vi } from 'vitest' import { Query, count, @@ -75,11 +75,14 @@ import { import { createCollection } from '../../src/collection/index.js' import { hasVirtualProps } from '../../src/virtual-props.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' -import { declareOracle } from '../oracle-declaration.js' import { mockSyncCollectionOptions } from '../utils.js' -import { oracleReplayRequested, oracleRoute } from '../oracle-config.js' +import { oracleProperty, oracleReplayRequested } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { - metadataCollisionCases, metadataCompositions, metadataProjectionShapes, } from './virtual-row-fields-grammar-oracle.js' @@ -87,17 +90,44 @@ import { checkWithCleanup, virtualFieldNames, } from './virtual-row-fields-support.js' -import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' import { advanceFacadeIntervals, checkFacadeIntervals, } from './includes-collection-driver-oracle.js' +import { + boundaryPublicationHistories, + captureMetadataRows, + checkAttributedRows, + checkBoundaryPublication, + checkInlineMetadataValues, + checkMetadataPublications, + checkNullableSelection, + checkRootMetadata, + collisionCases, + expectedAttributedRows, + expectedBoundaryPublication, + expectedInlineMetadataValues, + expectedJoinedMetadata, + expectedMetadataChanges, + expectedNullableSelection, + sourceOwnsMetadata, +} from './virtual-row-fields-model-oracle.js' import type { QueryBuilder } from '../../src/query/builder/index.js' import type { FacadeIntervals, MetadataFacade, } from './includes-collection-driver-oracle.js' +import type { + AttributionContext, + AttributionKey, + AttributionRow, + BoundaryPublicationContext, + BoundaryPublicationHistory, + MetadataBatch, + MetadataResult, +} from './virtual-row-fields-model-oracle.js' + type Profile = { label: string } type Row = { @@ -145,112 +175,8 @@ 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, - checkAttributedRows, - ], - model: [expectsVirtualFields, sourceOwnsMetadata, expectedAttributedRows], -}) - // The independent non-key attribution law uses authoring shape, never selected // values. The published key is supplied separately by the receiving identity law. -function sourceOwnsMetadata(shape: string): boolean { - return shape === `structured-spread` || shape === `functional-direct` -} - -function checkRootMetadata( - actual: Record<(typeof virtualFieldNames)[number], unknown>, - expected: Record<(typeof virtualFieldNames)[number], unknown>, -): void { - for (const field of virtualFieldNames) - expect(actual[field], field).toBe(expected[field]) -} - -type AttributionKey = string | number -type AttributionRow = { id: AttributionKey; parentId: string; label: string } -// The finite grammar varies compiler context types, but every accepted root -// has these ordinary fields. Erase only that context union at the driver edge; -// the full runtime shape remains independently checked below. -type AttributionContext = { - schema: {} - baseSchema: {} - fromSourceName: string - result: { id: AttributionKey; label: string } - hasResult: true -} -// Model rows are authored source facts, never sampled from the production -// Collection. Pending overlays affect only their named key; settlement without -// source confirmation restores the authored base (optimistic-history contract). -function expectedAttributedRows( - rows: ReadonlyArray, - shape: string, - pending: ReadonlySet, - sourceId: string, - outputId: string, - outputKey: (id: AttributionKey) => unknown, - // Single-source rows keep their source key. Joined rows use their actual - // default pair or explicit custom key (maintainer decision, November 5). - sourceKey: (id: AttributionKey) => AttributionKey = (id) => id, -) { - return rows.map((row) => ({ - ordinary: - shape === `structured-projection` - ? { - id: row.id, - label: row.label, - hasPendingWrites: pending.has(row.id), - } - : shape === `functional-projection` - ? { id: row.id, label: row.label } - : { ...row }, - metadata: sourceOwnsMetadata(shape) - ? expectedSourceMetadata(sourceKey(row.id), sourceId, pending.has(row.id)) - : { - $key: outputKey(row.id), - $collectionId: outputId, - $hasPendingWrites: false, - $origin: `remote`, - }, - })) -} -function checkAttributedRows( - actual: ReadonlyArray, - expected: ReturnType, -): void { - const observed = actual.map((row) => ({ - ordinary: Object.fromEntries( - Object.entries(row).filter( - ([name]) => !virtualFieldNames.some((field) => field === name), - ), - ), - metadata: Object.fromEntries( - virtualFieldNames.map((field) => [ - field, - (row as Record)[field], - ]), - ), - })) - const identity = (row: { ordinary: object }) => { - const { id } = row.ordinary as { id: unknown } - return `${typeof id}:${String(id)}` - } - const byId = (a: { ordinary: object }, b: { ordinary: object }) => - identity(a).localeCompare(identity(b)) - // Sorting permits unspecified result order but preserves every occurrence, - // field and extra row. No Map can collapse duplicate contributions here. - expect(observed.sort(byId)).toEqual([...expected].sort(byId)) -} - -// Each finite history has one numeric token. A state keeps that token once, -// so repeated observations preserve the original per-history checkpoint count. const metadataHistoriesByState = new Map>( [ `joined-pending-source`, @@ -309,11 +235,6 @@ afterAll(() => { } }) -// Both boolean values are legal authored collisions. True distinguishes an -// output-owned row's mandatory false pending flag; false distinguishes a -// pending source-owned row. Use a legal local origin to challenge remote roots. -const collisionCases = metadataCollisionCases - /** * Publication distinguishes an implicit source row from an implicit joined * namespace. The former retains its source attribution; the latter is a new @@ -333,182 +254,6 @@ const collisionCases = metadataCollisionCases * settlement without confirmation, and a later remote source update. It does * not claim framework timing, rejected writes, or arbitrary include depth. */ -type BoundaryPublicationHistory = { - kind: - | `implicit-single` - | `implicit-join` - | `inline-return` - | `grouped` - | `aggregate` - composition: `direct` | `query-ref` - pendingSource: `parent` | `child` - inline: `none` | `array` | `singleton` - collision: string - authoredPending: boolean -} -const boundaryPublicationHistories: Array = [ - ...([`grouped`, `aggregate`] as const).flatMap((kind) => - ([`direct`, `query-ref`] as const).map((composition) => ({ - kind, - composition, - pendingSource: `child` as const, - inline: `none` as const, - collision: `none`, - authoredPending: false, - })), - ), - ...([`implicit-single`, `implicit-join`] as const).flatMap((kind) => - ([`direct`, `query-ref`] as const).flatMap((composition) => - (kind === `implicit-single` - ? ([`child`] as const) - : ([`parent`, `child`] as const) - ).map((pendingSource) => ({ - kind, - composition, - pendingSource, - inline: `none` as const, - collision: `none`, - authoredPending: false, - })), - ), - ), - ...([`none`, `array`, `singleton`] as const).flatMap((inline) => - collisionCases.flatMap(({ collision, authoredPending }) => - ([`direct`, `query-ref`] as const).flatMap((composition) => - ([`parent`, `child`] as const).map((pendingSource) => ({ - kind: `inline-return` as const, - inline, - collision, - authoredPending, - composition, - pendingSource, - })), - ), - ), - ), -] -type BoundaryPublishedRow = AttributionRow & { children?: unknown } -type BoundaryJoinedRow = { - parent: AttributionRow - chosen: AttributionRow -} -type BoundaryPublicationContext = Omit & { - result: - | BoundaryPublishedRow - | BoundaryJoinedRow - | { id: AttributionKey; total: number } -} -// This grammar also models joined namespaces and inline children. Keep its -// carrier open to those shapes; exact fields still come from the model below. -type BoundaryMetadataResult = { - ordinary: object - metadata: ReturnType[number][`metadata`] -} - -// A namespace combines two source values rather than selecting either one. -// Returning an actual source input, including a spread projected upstream, -// retains that input's source owner. No selected metadata values choose it. -function expectedBoundaryPublication( - history: BoundaryPublicationHistory, - parent: AttributionRow, - children: ReadonlyArray, - pending: ReadonlySet, - parentId: string, - childId: string, - outputId: string, -): Array { - if (history.kind === `grouped` || history.kind === `aggregate`) { - // Group membership and counts follow authored rows, independent of their - // metadata. No source row is selected whole, so publication owns metadata. - const counts = new Map() - for (const child of children) { - const id = history.kind === `aggregate` ? `all` : child.parentId - counts.set(id, (counts.get(id) ?? 0) + 1) - } - return [...counts].map(([id, total]) => ({ - ordinary: { id, total }, - metadata: expectedSourceMetadata(`output-${id}`, outputId, false), - })) - } - if (history.kind === `implicit-single`) - return expectedAttributedRows( - children, - `functional-direct`, - pending, - childId, - outputId, - (id) => id, - ) - if (history.kind === `implicit-join`) - return children - .filter((child) => child.parentId === parent.id) - .map((child) => ({ - ordinary: { - parent: { - ...parent, - ...expectedSourceMetadata( - parent.id, - parentId, - pending.has(parent.id), - ), - }, - chosen: { - ...child, - ...expectedSourceMetadata(child.id, childId, pending.has(child.id)), - }, - }, - metadata: expectedSourceMetadata(`result-${child.id}`, outputId, false), - })) - const selected = expectedAttributedRows( - [parent], - `functional-direct`, - pending, - parentId, - outputId, - (id) => id, - )[0]! - if (history.inline !== `none`) { - const values = children - .filter((child) => child.parentId === parent.id) - .map(({ id, label }) => ({ id, label })) - return [ - { - ...selected, - ordinary: { - ...selected.ordinary, - children: history.inline === `singleton` ? values[0] : values, - }, - }, - ] - } - return [selected] -} - -// Compare physical membership keys and every readable row field together. -// Sorting by the entry key permits unspecified delivery order, while retaining -// duplicate rows and mismatches between a public row's key and its entry key. -function checkBoundaryPublication( - actual: ReadonlyArray, - expected: ReadonlyArray, -): void { - const order = ( - a: readonly [AttributionKey, object], - b: readonly [AttributionKey, object], - ) => `${typeof a[0]}:${a[0]}`.localeCompare(`${typeof b[0]}:${b[0]}`) - expect( - actual.map(([key, row]) => [key, { ...row }] as const).sort(order), - ).toEqual( - expected - .map( - ({ ordinary, metadata }) => - [ - metadata.$key as AttributionKey, - { ...ordinary, ...metadata }, - ] as const, - ) - .sort(order), - ) -} const boundaryPublicationCuts = [ `initial`, `held`, @@ -552,232 +297,218 @@ afterAll(() => { ), ) }) -declareOracle({ - model: [expectedBoundaryPublication], - check: [checkBoundaryPublication], - grammar: [boundaryPublicationHistories], -}) -test.each(boundaryPublicationHistories)( - `publishes implicit and materialized-input metadata: %j`, - async (history) => { - oracleRoute(`case`) - boundaryPublicationHistory++ - let parent: AttributionRow = { - id: `parent`, - parentId: `root`, - label: `parent-before`, - } - let children: Array = [ - { id: `child`, parentId: `parent`, label: `before` }, - { id: `peer`, parentId: `parent`, label: `untouched` }, - ] - const parents = createCollection( - mockSyncCollectionOptions({ - id: `boundary-parent`, - getKey: (row) => row.id, - initialData: [{ ...parent }], - }), - ) - const rows = createCollection( - mockSyncCollectionOptions({ - id: `boundary-child`, - getKey: (row) => row.id, - initialData: children.map((row) => ({ ...row })), - }), - ) - rows.createIndex((row) => row.parentId, { indexType: BTreeIndex }) - const overrides: Record = {} - for (const field of virtualFieldNames) - if (history.collision === field || history.collision === `all`) - overrides[field] = - field === `$hasPendingWrites` - ? history.authoredPending - : field === `$origin` - ? `local` - : `authored` - const selected = (() => { - if (history.kind === `grouped`) - return new Query() - .from({ chosen: rows }) - .groupBy(({ chosen }) => chosen.parentId) - .select(({ chosen }) => ({ - id: chosen.parentId, - total: count(chosen.id), - })) - if (history.kind === `aggregate`) - return new Query().from({ chosen: rows }).select(({ chosen }) => ({ - id: `all`, +// The former case body, unchanged. +async function judgeBoundaryPublication( + history: (typeof boundaryPublicationHistories)[number], +): Promise { + boundaryPublicationHistory++ + let parent: AttributionRow = { + id: `parent`, + parentId: `root`, + label: `parent-before`, + } + let children: Array = [ + { id: `child`, parentId: `parent`, label: `before` }, + { id: `peer`, parentId: `parent`, label: `untouched` }, + ] + const parents = createCollection( + mockSyncCollectionOptions({ + id: `boundary-parent`, + getKey: (row) => row.id, + initialData: [{ ...parent }], + }), + ) + const rows = createCollection( + mockSyncCollectionOptions({ + id: `boundary-child`, + getKey: (row) => row.id, + initialData: children.map((row) => ({ ...row })), + }), + ) + rows.createIndex((row) => row.parentId, { indexType: BTreeIndex }) + const overrides: Record = {} + for (const field of virtualFieldNames) + if (history.collision === field || history.collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? history.authoredPending + : field === `$origin` + ? `local` + : `authored` + const selected = (() => { + if (history.kind === `grouped`) + return new Query() + .from({ chosen: rows }) + .groupBy(({ chosen }) => chosen.parentId) + .select(({ chosen }) => ({ + id: chosen.parentId, total: count(chosen.id), })) - if (history.kind === `implicit-single`) - return new Query().from({ chosen: rows }) - if (history.kind === `implicit-join`) - return new Query() - .from({ parent: parents }) - .innerJoin({ chosen: rows }, ({ parent: p, chosen }) => - eq(p.id, chosen.parentId), - ) - const projected = new Query() + if (history.kind === `aggregate`) + return new Query().from({ chosen: rows }).select(({ chosen }) => ({ + id: `all`, + total: count(chosen.id), + })) + if (history.kind === `implicit-single`) + return new Query().from({ chosen: rows }) + if (history.kind === `implicit-join`) + return new Query() .from({ parent: parents }) - .select(({ parent: p }) => { - const child = new Query() - .from({ child: rows }) - .where(({ child: c }) => eq(c.parentId, p.id)) - .select(({ child: c }) => ({ id: c.id, label: c.label })) - return { - ...p, - ...overrides, - ...(history.inline === `none` - ? {} - : { - children: - history.inline === `singleton` - ? materialize(child.findOne()) - : toArray(child), - }), - } - }) - return new Query().from({ sub: projected }).fn.select(({ sub }) => sub) - })() - const live = createLiveQueryCollection({ - query: () => - (history.composition === `direct` - ? selected - : new Query().from({ - outer: selected, - })) as unknown as QueryBuilder, - getKey: (row) => - `total` in row - ? `output-${row.id}` - : `chosen` in row - ? `result-${row.chosen.id}` - : row.id, - }) - const pending = new Set() - const failures: Array = [] - const check = (cut: (typeof boundaryPublicationCuts)[number]) => { - boundaryPublicationHistoriesByState - .get(boundaryPublicationState(history, cut))! - .add(boundaryPublicationHistory) - try { - checkBoundaryPublication( - [...live.entries()], - expectedBoundaryPublication( - history, - parent, - children, - pending, - parents.id, - rows.id, - live.id, - ), + .innerJoin({ chosen: rows }, ({ parent: p, chosen }) => + eq(p.id, chosen.parentId), ) - } catch (error) { - failures.push( - new Error(`boundary publication ${cut}`, { cause: error }), - ) - } - } - let finishMutation = async () => {} - await checkWithCleanup(async () => { - await live.preload() - check(`initial`) - const inserting = - history.kind === `grouped` || history.kind === `aggregate` - const source = history.pendingSource === `parent` ? parents : rows - const key = inserting - ? `inserted` - : history.pendingSource === `parent` - ? `parent` - : `child` - const before = - history.pendingSource === `parent` ? { ...parent } : { ...children[0]! } - if (inserting) - children = [ - ...children, - { id: key, parentId: `parent`, label: `pending` }, - ] - else if (history.pendingSource === `parent`) - parent = { ...parent, label: `pending` } - else - children = children.map((row) => - row.id === key ? { ...row, label: `pending` } : row, - ) - pending.add(key) - const mutation = inserting - ? source.insert({ id: key, parentId: `parent`, label: `pending` }) - : source.update(key, (row) => { - row.label = `pending` - }) - finishMutation = async () => { - source.utils.resolveSync() - finishMutation = async () => {} - await mutation.when(`settled`) - } - expect(source.get(key)!.$hasPendingWrites).toBe(true) - check(`held`) - await finishMutation() - pending.clear() - if (inserting) children = children.filter((row) => row.id !== key) - else if (history.pendingSource === `parent`) parent = before - else children = children.map((row) => (row.id === key ? before : row)) - check(`settled`) - const remote = { ...before, id: key, label: `remote-next-use` } - source.utils.begin() - source.utils.write({ - type: inserting ? `insert` : `update`, - value: remote, + const projected = new Query() + .from({ parent: parents }) + .select(({ parent: p }) => { + const child = new Query() + .from({ child: rows }) + .where(({ child: c }) => eq(c.parentId, p.id)) + .select(({ child: c }) => ({ id: c.id, label: c.label })) + return { + ...p, + ...overrides, + ...(history.inline === `none` + ? {} + : { + children: + history.inline === `singleton` + ? materialize(child.findOne()) + : toArray(child), + }), + } }) - source.utils.commit() - if (inserting) children = [...children, remote] - else if (history.pendingSource === `parent`) parent = remote - else children = children.map((row) => (row.id === key ? remote : row)) - check(`remote`) - if (failures.length) - throw new AggregateError( - failures, - `Boundary publication comparisons failed`, - { cause: failures[0] }, - ) - }, [ - () => finishMutation(), - () => live.cleanup(), - () => rows.cleanup(), - () => parents.cleanup(), - ]) - }, -) - -// Calibrate the same comparison against the former source-attribution rule. -// Mutate one metadata observation at a time; ordinary group values stay valid. -test.each( - boundaryPublicationHistories.filter( - ({ 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( - history, - parent, - [child], - new Set([child.id]), - `parent-source`, - `child-source`, - `output`, - ) - const sourceMetadata = expectedSourceMetadata(child.id, `child-source`, true) - for (const field of virtualFieldNames) { - const wrong = expected.map( - ({ ordinary, metadata }) => - [ - metadata.$key as AttributionKey, - { ...ordinary, ...metadata, [field]: sourceMetadata[field] }, - ] as const, - ) - expect(() => checkBoundaryPublication(wrong, expected), field).toThrow() + return new Query().from({ sub: projected }).fn.select(({ sub }) => sub) + })() + const live = createLiveQueryCollection({ + query: () => + (history.composition === `direct` + ? selected + : new Query().from({ + outer: selected, + })) as unknown as QueryBuilder, + getKey: (row) => + `total` in row + ? `output-${row.id}` + : `chosen` in row + ? `result-${row.chosen.id}` + : row.id, + }) + const pending = new Set() + const failures: Array = [] + const check = (cut: (typeof boundaryPublicationCuts)[number]) => { + boundaryPublicationHistoriesByState + .get(boundaryPublicationState(history, cut))! + .add(boundaryPublicationHistory) + try { + checkBoundaryPublication( + [...live.entries()], + expectedBoundaryPublication( + history, + parent, + children, + pending, + parents.id, + rows.id, + live.id, + ), + ) + } catch (error) { + failures.push(new Error(`boundary publication ${cut}`, { cause: error })) + } } + let finishMutation = async () => {} + await checkWithCleanup(async () => { + await live.preload() + check(`initial`) + const inserting = history.kind === `grouped` || history.kind === `aggregate` + const source = history.pendingSource === `parent` ? parents : rows + const key = inserting + ? `inserted` + : history.pendingSource === `parent` + ? `parent` + : `child` + const before = + history.pendingSource === `parent` ? { ...parent } : { ...children[0]! } + if (inserting) + children = [ + ...children, + { id: key, parentId: `parent`, label: `pending` }, + ] + else if (history.pendingSource === `parent`) + parent = { ...parent, label: `pending` } + else + children = children.map((row) => + row.id === key ? { ...row, label: `pending` } : row, + ) + pending.add(key) + const mutation = inserting + ? source.insert({ id: key, parentId: `parent`, label: `pending` }) + : source.update(key, (row) => { + row.label = `pending` + }) + finishMutation = async () => { + source.utils.resolveSync() + finishMutation = async () => {} + await mutation.when(`settled`) + } + expect(source.get(key)!.$hasPendingWrites).toBe(true) + check(`held`) + await finishMutation() + pending.clear() + if (inserting) children = children.filter((row) => row.id !== key) + else if (history.pendingSource === `parent`) parent = before + else children = children.map((row) => (row.id === key ? before : row)) + check(`settled`) + const remote = { ...before, id: key, label: `remote-next-use` } + source.utils.begin() + source.utils.write({ + type: inserting ? `insert` : `update`, + value: remote, + }) + source.utils.commit() + if (inserting) children = [...children, remote] + else if (history.pendingSource === `parent`) parent = remote + else children = children.map((row) => (row.id === key ? remote : row)) + check(`remote`) + if (failures.length) + throw new AggregateError( + failures, + `Boundary publication comparisons failed`, + { cause: failures[0] }, + ) + }, [ + () => finishMutation(), + () => live.cleanup(), + () => rows.cleanup(), + () => parents.cleanup(), + ]) +} + +const boundaryPublicationLaw = `An implicit source row keeps its source attribution, an implicit joined namespace and grouped or aggregate output receive output metadata, and authored reserved fields stay data until Collection publication.` + +oracleProperty({ + id: `virtual-row-fields.boundary-publication`, + law: { + statement: boundaryPublicationLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The enumerated direct, QueryRef, join, collision, and inline-input histories at preload, held mutation, settlement, and a later remote update; no framework timing, rejected write, or arbitrary include depth.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: boundaryPublicationHistories, + label: (history) => + `publishes implicit and materialized-input metadata: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(boundaryPublicationLaw, plan, observe, () => + judgeBoundaryPublication(plan.history), + ), + check: forwardAssertionJudgment, }) /** @@ -790,537 +521,606 @@ test.each( * come from the independent shape rule. A held source update distinguishes * source metadata from output metadata before and after settlement. */ -test.each( - [false, true].flatMap((joined) => - collisionCases.flatMap(({ collision, authoredPending }) => - metadataProjectionShapes - .filter( - (shape) => shape !== `functional-direct` || collision === `none`, - ) - .flatMap((shape) => - metadataCompositions.flatMap((composition) => - [false, true].map((customKey) => ({ - joined, - collision, - authoredPending, - shape, - composition, - customKey, - })), - ), +const projectionAttributionHistories = [false, true].flatMap((joined) => + collisionCases.flatMap(({ collision, authoredPending }) => + metadataProjectionShapes + .filter((shape) => shape !== `functional-direct` || collision === `none`) + .flatMap((shape) => + metadataCompositions.flatMap((composition) => + [false, true].map((customKey) => ({ + joined, + collision, + authoredPending, + shape, + composition, + customKey, + })), ), - ), + ), ), -)( - `attributes root metadata by projection shape: %j`, - async ({ - joined, - collision, - authoredPending, - shape, - composition, - customKey, - }) => { - oracleRoute(`case`) - metadataHistory++ - const parents = createCollection( - mockSyncCollectionOptions<{ id: string }>({ - id: `metadata-parent`, - getKey: (row) => row.id, - initialData: [{ id: `parent` }], - }), - ) - const rows = createCollection( - mockSyncCollectionOptions<{ - id: string - parentId: string - label: string - }>({ - id: `metadata-child`, - getKey: (row) => row.id, - initialData: [ - { id: `child`, parentId: `parent`, label: `before` }, - { id: `peer`, parentId: `parent`, label: `untouched` }, - ], - }), - ) - const overrides: Record = {} - for (const field of virtualFieldNames) { - if (collision === field || collision === `all`) - overrides[field] = - field === `$hasPendingWrites` - ? authoredPending - : field === `$origin` - ? `local` - : `spoof` - } - const selectedQuery = (() => { - const q = new Query() - const base = joined - ? q - .from({ parent: parents }) - .innerJoin({ chosen: rows }, ({ parent, chosen }) => - eq(parent.id, chosen.parentId), - ) - : q.from({ chosen: rows }) - if (shape === `functional-direct`) - return base.fn.select(({ chosen }) => chosen) - if (shape === `functional-spread`) - return base.fn.select(({ chosen }) => ({ ...chosen, ...overrides })) - if (shape === `functional-projection`) - return base.fn.select(({ chosen }) => ({ - id: chosen.id, - label: chosen.label, - ...overrides, - })) - if (shape === `structured-spread`) - return base.select(({ chosen }) => ({ ...chosen, ...overrides })) - return base.select(({ chosen }) => ({ +) + +// The former case body, unchanged. +async function judgeProjectionAttribution({ + joined, + collision, + authoredPending, + shape, + composition, + customKey, +}: (typeof projectionAttributionHistories)[number]): Promise { + metadataHistory++ + const parents = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `metadata-parent`, + getKey: (row) => row.id, + initialData: [{ id: `parent` }], + }), + ) + const rows = createCollection( + mockSyncCollectionOptions<{ + id: string + parentId: string + label: string + }>({ + id: `metadata-child`, + getKey: (row) => row.id, + initialData: [ + { id: `child`, parentId: `parent`, label: `before` }, + { id: `peer`, parentId: `parent`, label: `untouched` }, + ], + }), + ) + const overrides: Record = {} + for (const field of virtualFieldNames) { + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + } + const selectedQuery = (() => { + const q = new Query() + const base = joined + ? q + .from({ parent: parents }) + .innerJoin({ chosen: rows }, ({ parent, chosen }) => + eq(parent.id, chosen.parentId), + ) + : q.from({ chosen: rows }) + if (shape === `functional-direct`) + return base.fn.select(({ chosen }) => chosen) + if (shape === `functional-spread`) + return base.fn.select(({ chosen }) => ({ ...chosen, ...overrides })) + if (shape === `functional-projection`) + return base.fn.select(({ chosen }) => ({ id: chosen.id, label: chosen.label, - hasPendingWrites: chosen.$hasPendingWrites, ...overrides, })) - })() - const compose = (query: typeof selectedQuery) => { - if (composition === `direct`) return query - if (composition.startsWith(`union`)) { - const emptyBranch = new Query() - .from({ empty: parents }) - .where(({ empty }) => eq(empty.id, `absent`)) - .select(({ empty }) => ({ id: empty.id, label: empty.id })) - const union = - composition === `union-last` - ? new Query().unionAll(emptyBranch, query) - : new Query().unionAll(query, emptyBranch) - return composition === `union-query-ref` - ? new Query().from({ union }) - : union - } - if (composition === `joined-query-ref`) - return new Query() - .from({ peer: rows }) - .innerJoin({ nested: query }, ({ peer, nested }) => - eq(peer.id, nested.id), - ) - .select(({ nested }) => nested) - const once = new Query().from({ nested: query }) - if (composition === `functional-query-ref`) - return once.fn.select(({ nested }) => nested) - return composition === `query-ref` - ? once - : new Query().from({ outer: once }) + if (shape === `structured-spread`) + return base.select(({ chosen }) => ({ ...chosen, ...overrides })) + return base.select(({ chosen }) => ({ + id: chosen.id, + label: chosen.label, + hasPendingWrites: chosen.$hasPendingWrites, + ...overrides, + })) + })() + const compose = (query: typeof selectedQuery) => { + if (composition === `direct`) return query + if (composition.startsWith(`union`)) { + const emptyBranch = new Query() + .from({ empty: parents }) + .where(({ empty }) => eq(empty.id, `absent`)) + .select(({ empty }) => ({ id: empty.id, label: empty.id })) + const union = + composition === `union-last` + ? new Query().unionAll(emptyBranch, query) + : new Query().unionAll(query, emptyBranch) + return composition === `union-query-ref` + ? new Query().from({ union }) + : union } - const sourceOwned = sourceOwnsMetadata(shape) - const live = createLiveQueryCollection({ - query: () => - compose(selectedQuery) as unknown as QueryBuilder, - ...(customKey - ? { - getKey: (row: { id: AttributionKey }) => - sourceOwned ? row.id : `projected-${row.id}`, - } - : {}), - }) - const clean = createLiveQueryCollection(() => - compose( - (() => { - const q = new Query() - const base = joined - ? q - .from({ parent: parents }) - .innerJoin({ chosen: rows }, ({ parent, chosen }) => - eq(parent.id, chosen.parentId), - ) - : q.from({ chosen: rows }) - return base.select(({ chosen }) => ({ - id: chosen.id, - label: chosen.label, - })) - })(), + if (composition === `joined-query-ref`) + return new Query() + .from({ peer: rows }) + .innerJoin({ nested: query }, ({ peer, nested }) => + eq(peer.id, nested.id), + ) + .select(({ nested }) => nested) + const once = new Query().from({ nested: query }) + if (composition === `functional-query-ref`) + return once.fn.select(({ nested }) => nested) + return composition === `query-ref` + ? once + : new Query().from({ outer: once }) + } + const sourceOwned = sourceOwnsMetadata(shape) + const live = createLiveQueryCollection({ + query: () => + compose(selectedQuery) as unknown as QueryBuilder, + ...(customKey + ? { + getKey: (row: { id: AttributionKey }) => + sourceOwned ? row.id : `projected-${row.id}`, + } + : {}), + }) + const clean = createLiveQueryCollection(() => + compose( + (() => { + const q = new Query() + const base = joined + ? q + .from({ parent: parents }) + .innerJoin({ chosen: rows }, ({ parent, chosen }) => + eq(parent.id, chosen.parentId), + ) + : q.from({ chosen: rows }) + return base.select(({ chosen }) => ({ + id: chosen.id, + label: chosen.label, + })) + })(), + ), + ) + let truth: Array = [ + { id: `child`, parentId: `parent`, label: `before` }, + { id: `peer`, parentId: `parent`, label: `untouched` }, + ] + const pendingKeys = new Set() + let finishMutation = async () => {} + const check = () => { + // 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}` + : clean.toArray.find((row) => `id` in row && row.id === id)!.$key + checkAttributedRows( + live.toArray, + expectedAttributedRows( + truth, + shape, + pendingKeys, + rows.id, + live.id, + outputKey, + joined || composition === `joined-query-ref` + ? (id) => (customKey ? id : (outputKey(id) as AttributionKey)) + : (id) => id, ), ) - let truth: Array = [ - { id: `child`, parentId: `parent`, label: `before` }, - { id: `peer`, parentId: `parent`, label: `untouched` }, - ] - const pendingKeys = new Set() - let finishMutation = async () => {} - const check = () => { - // 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}` - : clean.toArray.find((row) => `id` in row && row.id === id)!.$key - checkAttributedRows( - live.toArray, - expectedAttributedRows( - truth, - shape, - pendingKeys, - rows.id, - live.id, - outputKey, - joined || composition === `joined-query-ref` - ? (id) => (customKey ? id : (outputKey(id) as AttributionKey)) - : (id) => id, - ), - ) - if (customKey) - expect([...live.keys()].sort()).toEqual( - truth - .map((row) => (sourceOwned ? row.id : `projected-${row.id}`)) - .sort(), - ) - } - await checkWithCleanup(async () => { - await Promise.all([live.preload(), clean.preload()]) - check() - if ( - !sourceOwned && - authoredPending && - (collision === `$hasPendingWrites` || collision === `all`) - ) - metadataHistoriesByState - .get(`output-rejects-authored-pending-true`)! - .add(metadataHistory) - metadataHistoriesByState.get(`multi-row`)!.add(metadataHistory) - if (customKey && !sourceOwned) - metadataHistoriesByState - .get(`independent-output-key`)! - .add(metadataHistory) - truth = truth.map((row) => - row.id === `child` ? { ...row, label: `pending` } : row, - ) - pendingKeys.add(`child`) - const mutation = rows.update(`child`, (row) => { - row.label = `pending` - }) - finishMutation = async () => { - rows.utils.resolveSync() - finishMutation = async () => {} - await mutation.when(`settled`) - } - await vi.waitFor(() => - expect(live.toArray.find((row) => row.id === `child`)).toMatchObject({ - label: `pending`, - }), + if (customKey) + expect([...live.keys()].sort()).toEqual( + truth + .map((row) => (sourceOwned ? row.id : `projected-${row.id}`)) + .sort(), ) - expect(rows.get(`child`)!.$hasPendingWrites).toBe(true) + } + await checkWithCleanup(async () => { + await Promise.all([live.preload(), clean.preload()]) + check() + if ( + !sourceOwned && + authoredPending && + (collision === `$hasPendingWrites` || collision === `all`) + ) metadataHistoriesByState - .get(joined ? `joined-pending-source` : `single-pending-source`)! + .get(`output-rejects-authored-pending-true`)! .add(metadataHistory) - if (collision !== `none`) - metadataHistoriesByState - .get(`colliding-projection`)! - .add(metadataHistory) - if (shape === `functional-direct`) - metadataHistoriesByState.get(`functional-direct`)!.add(metadataHistory) - if (composition.startsWith(`union`)) - metadataHistoriesByState.get(`union-pending`)!.add(metadataHistory) - if (composition.endsWith(`query-ref`)) - metadataHistoriesByState.get(`query-ref-pending`)!.add(metadataHistory) - check() - await finishMutation() - pendingKeys.clear() - truth = truth.map((row) => - row.id === `child` ? { ...row, label: `before` } : row, - ) - check() - rows.utils.begin() - rows.utils.write({ - type: `delete`, - value: { id: `peer`, parentId: `parent`, label: `untouched` }, - }) - rows.utils.commit() - truth = truth.filter((row) => row.id !== `peer`) - check() + metadataHistoriesByState.get(`multi-row`)!.add(metadataHistory) + if (customKey && !sourceOwned) metadataHistoriesByState - .get(`non-first-row-deleted`)! + .get(`independent-output-key`)! .add(metadataHistory) - }, [ - () => finishMutation(), - () => live.cleanup(), - () => clean.cleanup(), - () => rows.cleanup(), - () => parents.cleanup(), - ]) - }, -) - -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({ - id: `virtual-row-fields-runtime-oracle-source`, - getKey: (row) => row.id, - initialData: [ - { - id: `row-1`, - profile: { label: `nested` }, - createdAt, - tags: [`one`, `two`], - }, - ], - }), + truth = truth.map((row) => + row.id === `child` ? { ...row, label: `pending` } : row, ) - const missingRows = createCollection( - mockSyncCollectionOptions({ - id: `virtual-row-fields-runtime-oracle-missing-source`, - getKey: (row) => row.id, - initialData: [], + pendingKeys.add(`child`) + const mutation = rows.update(`child`, (row) => { + row.label = `pending` + }) + finishMutation = async () => { + rows.utils.resolveSync() + finishMutation = async () => {} + await mutation.when(`settled`) + } + await vi.waitFor(() => + expect(live.toArray.find((row) => row.id === `child`)).toMatchObject({ + label: `pending`, }), ) - const live = createLiveQueryCollection((q) => - q.from({ row: rows }).select(({ row }) => ({ - id: row.id, - profile: row.profile, - createdAt: row.createdAt, - dates: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => child.createdAt), - ), - firstDate: materialize( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => child.createdAt) - .findOne(), - ), - wholeRows: toArray( - q.from({ child: rows }).where(({ child }) => eq(child.id, row.id)), - ), - selectedWholeRows: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => child), - ), - unmatchedRows: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .leftJoin({ missing: missingRows }, ({ child, missing }) => - eq(child.id, missing.id), - ) - .select(({ missing }) => missing), - ), - objects: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => ({ label: child.profile.label })), - ), - nestedObjects: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => ({ - nested: { label: child.profile.label }, - })), - ), - arrays: toArray( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => child.tags), - ), - firstObject: materialize( - q - .from({ child: rows }) - .where(({ child }) => eq(child.id, row.id)) - .select(({ child }) => ({ label: child.profile.label })) - .findOne(), - ), - })), + expect(rows.get(`child`)!.$hasPendingWrites).toBe(true) + metadataHistoriesByState + .get(joined ? `joined-pending-source` : `single-pending-source`)! + .add(metadataHistory) + if (collision !== `none`) + metadataHistoriesByState.get(`colliding-projection`)!.add(metadataHistory) + if (shape === `functional-direct`) + metadataHistoriesByState.get(`functional-direct`)!.add(metadataHistory) + if (composition.startsWith(`union`)) + metadataHistoriesByState.get(`union-pending`)!.add(metadataHistory) + if (composition.endsWith(`query-ref`)) + metadataHistoriesByState.get(`query-ref-pending`)!.add(metadataHistory) + check() + await finishMutation() + pendingKeys.clear() + truth = truth.map((row) => + row.id === `child` ? { ...row, label: `before` } : row, ) + check() + rows.utils.begin() + rows.utils.write({ + type: `delete`, + value: { id: `peer`, parentId: `parent`, label: `untouched` }, + }) + rows.utils.commit() + truth = truth.filter((row) => row.id !== `peer`) + check() + metadataHistoriesByState.get(`non-first-row-deleted`)!.add(metadataHistory) + }, [ + () => finishMutation(), + () => live.cleanup(), + () => clean.cleanup(), + () => rows.cleanup(), + () => parents.cleanup(), + ]) +} - await checkWithCleanup(async () => { - await live.preload() +const projectionAttributionLaw = `Projection shape controls attribution, not the spelling of selected fields: a new object has output metadata and a direct source or structured spread keeps source metadata.` - const result = live.toArray[0]! - const observations: Array<{ - name: string - subject: VirtualFieldSubject - value: unknown - }> = [ - { name: `published query row`, subject: `row-root`, value: result }, - { - name: `nested profile`, - subject: `nested-value`, - value: result.profile, - }, - { - name: `selected Date`, - subject: `opaque-value`, - value: result.createdAt, - }, - { - name: `toArray Date`, - subject: `opaque-value`, - value: result.dates[0], - }, - { - name: `materialized Date`, - subject: `opaque-value`, - value: result.firstDate, - }, - { - name: `whole-row child`, - subject: `whole-row-child`, - value: result.wholeRows[0], - }, - { - name: `directly selected whole-row child`, - subject: `whole-row-child`, - value: result.selectedWholeRows[0], - }, - { - name: `projected child object`, - subject: `projected-child`, - value: result.objects[0], - }, - { - name: `projected child wrapper`, - subject: `projected-child`, - value: result.nestedObjects[0], - }, - { - name: `nested projected value`, - subject: `nested-value`, - value: result.nestedObjects[0]!.nested, - }, - { - name: `selected array value`, - subject: `opaque-value`, - value: result.arrays[0], - }, +oracleProperty({ + id: `virtual-row-fields.projection-attribution`, + law: { + statement: projectionAttributionLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The enumerated joined, collision, projection-shape, composition, and custom-key cells with one held source update.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: projectionAttributionHistories, + label: (history) => + `attributes root metadata by projection shape: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(projectionAttributionLaw, plan, observe, () => + judgeProjectionAttribution(plan.history), + ), + check: forwardAssertionJudgment, +}) + +// The former fixed-history body, unchanged: publish one source through the +// row and value projections and classify every published field. +async function judgeRuntimeBoundary(): Promise { + const createdAt = new Date(`2026-09-20T12:34:56.000Z`) + const rows = createCollection( + mockSyncCollectionOptions({ + id: `virtual-row-fields-runtime-oracle-source`, + getKey: (row) => row.id, + initialData: [ { - name: `materialized projected child`, - subject: `projected-child`, - value: result.firstObject, + id: `row-1`, + profile: { label: `nested` }, + createdAt, + tags: [`one`, `two`], }, - ] - - expectPublishedFieldClassification(observations) - expect(result.$key).toBe(`row-1`) - expect(result.$hasPendingWrites).toBe(false) - expect(result.wholeRows[0]!.$hasPendingWrites).toBe(false) - expect(result.selectedWholeRows[0]!.$hasPendingWrites).toBe(false) - - expect(result.profile).toEqual({ label: `nested` }) - expect(`$key` in result.profile).toBe(false) - - expect(result.createdAt).toBeInstanceOf(Date) - expect(result.dates).toHaveLength(1) - expect(result.dates[0]).toBeInstanceOf(Date) - expect(result.firstDate).toBeInstanceOf(Date) + ], + }), + ) + const missingRows = createCollection( + mockSyncCollectionOptions({ + id: `virtual-row-fields-runtime-oracle-missing-source`, + getKey: (row) => row.id, + initialData: [], + }), + ) + const live = createLiveQueryCollection((q) => + q.from({ row: rows }).select(({ row }) => ({ + id: row.id, + profile: row.profile, + createdAt: row.createdAt, + dates: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt), + ), + firstDate: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.createdAt) + .findOne(), + ), + wholeRows: toArray( + q.from({ child: rows }).where(({ child }) => eq(child.id, row.id)), + ), + selectedWholeRows: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child), + ), + unmatchedRows: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .leftJoin({ missing: missingRows }, ({ child, missing }) => + eq(child.id, missing.id), + ) + .select(({ missing }) => missing), + ), + objects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })), + ), + nestedObjects: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ + nested: { label: child.profile.label }, + })), + ), + arrays: toArray( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => child.tags), + ), + firstObject: materialize( + q + .from({ child: rows }) + .where(({ child }) => eq(child.id, row.id)) + .select(({ child }) => ({ label: child.profile.label })) + .findOne(), + ), + })), + ) - expect(result.wholeRows).toHaveLength(1) - expect(result.wholeRows[0]!.$key).toBe(`row-1`) - expect(result.selectedWholeRows).toHaveLength(1) - expect(result.selectedWholeRows[0]!.$key).toBe(`row-1`) - expect(`optionalProfile` in result.selectedWholeRows[0]!).toBe(false) - expect(result.unmatchedRows).toEqual([{}]) - expect(result.objects).toEqual([{ label: `nested` }]) - expect(result.nestedObjects).toEqual([{ nested: { label: `nested` } }]) - expect(result.arrays).toEqual([[`one`, `two`]]) - expect(result.firstObject).toEqual({ label: `nested` }) - }, [ - () => live.cleanup(), - () => missingRows.cleanup(), - () => rows.cleanup(), - ]) - }) -}) + await checkWithCleanup(async () => { + await live.preload() -/** A nullish source contributes no fields to a JS spread. The last present - * structured source owns metadata, independent of join side and spread order. - * This finite grammar crosses match presence, spread order and which source - * has a held optimistic update. All checkpoints compare the same public law. + const result = live.toArray[0]! + const observations: Array<{ + name: string + subject: VirtualFieldSubject + value: unknown + }> = [ + { name: `published query row`, subject: `row-root`, value: result }, + { + name: `nested profile`, + subject: `nested-value`, + value: result.profile, + }, + { + name: `selected Date`, + subject: `opaque-value`, + value: result.createdAt, + }, + { + name: `toArray Date`, + subject: `opaque-value`, + value: result.dates[0], + }, + { + name: `materialized Date`, + subject: `opaque-value`, + value: result.firstDate, + }, + { + name: `whole-row child`, + subject: `whole-row-child`, + value: result.wholeRows[0], + }, + { + name: `directly selected whole-row child`, + subject: `whole-row-child`, + value: result.selectedWholeRows[0], + }, + { + name: `projected child object`, + subject: `projected-child`, + value: result.objects[0], + }, + { + name: `projected child wrapper`, + subject: `projected-child`, + value: result.nestedObjects[0], + }, + { + name: `nested projected value`, + subject: `nested-value`, + value: result.nestedObjects[0]!.nested, + }, + { + name: `selected array value`, + subject: `opaque-value`, + value: result.arrays[0], + }, + { + name: `materialized projected child`, + subject: `projected-child`, + value: result.firstObject, + }, + ] + + expectPublishedFieldClassification(observations) + expect(result.$key).toBe(`row-1`) + expect(result.$hasPendingWrites).toBe(false) + expect(result.wholeRows[0]!.$hasPendingWrites).toBe(false) + expect(result.selectedWholeRows[0]!.$hasPendingWrites).toBe(false) + + expect(result.profile).toEqual({ label: `nested` }) + expect(`$key` in result.profile).toBe(false) + + expect(result.createdAt).toBeInstanceOf(Date) + expect(result.dates).toHaveLength(1) + expect(result.dates[0]).toBeInstanceOf(Date) + expect(result.firstDate).toBeInstanceOf(Date) + + expect(result.wholeRows).toHaveLength(1) + expect(result.wholeRows[0]!.$key).toBe(`row-1`) + expect(result.selectedWholeRows).toHaveLength(1) + expect(result.selectedWholeRows[0]!.$key).toBe(`row-1`) + expect(`optionalProfile` in result.selectedWholeRows[0]!).toBe(false) + expect(result.unmatchedRows).toEqual([{}]) + expect(result.objects).toEqual([{ label: `nested` }]) + expect(result.nestedObjects).toEqual([{ nested: { label: `nested` } }]) + expect(result.arrays).toEqual([[`one`, `two`]]) + expect(result.firstObject).toEqual({ label: `nested` }) + }, [() => live.cleanup(), () => missingRows.cleanup(), () => rows.cleanup()]) +} + +const runtimeBoundaryLaw = `Row roots expose genuine computed virtual fields after publication, and selected values and nested payloads do not.` + +describe(`virtual row field runtime boundary`, () => { + oracleProperty({ + id: `virtual-row-fields.runtime-boundary`, + law: { + statement: runtimeBoundaryLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The one fixed source row and its row and value projections after publication.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: [`matches the row and value classification after publication`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(runtimeBoundaryLaw, plan, observe, () => + judgeRuntimeBoundary(), + ), + check: forwardAssertionJudgment, + }) +}) + +/** A nullish source contributes no fields to a JS spread. The last present + * structured source owns metadata, independent of join side and spread order. + * This finite grammar crosses match presence, spread order and which source + * has a held optimistic update. All checkpoints compare the same public law. */ -test.each( - [false, true].flatMap((matched) => - [false, true].flatMap((reverse) => - [false, true].map((pendingRight) => ({ matched, reverse, pendingRight })), - ), +const lastSpreadHistories = [false, true].flatMap((matched) => + [false, true].flatMap((reverse) => + [false, true].map((pendingRight) => ({ matched, reverse, pendingRight })), ), -)( - `retains metadata from the last present spread: %j`, - async ({ matched, reverse, pendingRight }) => { - oracleRoute(`case`) - metadataHistory++ - const left = createCollection( - mockSyncCollectionOptions<{ id: string; label: string }>({ - id: `spread-left`, - getKey: (row) => row.id, - initialData: [{ id: `left`, label: `left` }], - }), - ) - const right = createCollection( - mockSyncCollectionOptions<{ - id: string - parentId: string - label: string - }>({ - id: `spread-right`, - getKey: (row) => row.id, - initialData: [ - { - id: `right`, - parentId: matched ? `left` : `missing`, - label: `right`, - }, - ], - }), - ) - const live = createLiveQueryCollection((q) => - q - .from({ left }) - .leftJoin({ right }, ({ left: a, right: b }) => eq(a.id, b.parentId)) - .select(({ left: a, right: b }) => - reverse ? { ...b, ...a } : { ...a, ...b }, - ), +) + +// The former case body, unchanged. +async function judgeLastSpread({ + matched, + reverse, + pendingRight, +}: (typeof lastSpreadHistories)[number]): Promise { + metadataHistory++ + const left = createCollection( + mockSyncCollectionOptions<{ id: string; label: string }>({ + id: `spread-left`, + getKey: (row) => row.id, + initialData: [{ id: `left`, label: `left` }], + }), + ) + const right = createCollection( + mockSyncCollectionOptions<{ + id: string + parentId: string + label: string + }>({ + id: `spread-right`, + getKey: (row) => row.id, + initialData: [ + { + id: `right`, + parentId: matched ? `left` : `missing`, + label: `right`, + }, + ], + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ left }) + .leftJoin({ right }, ({ left: a, right: b }) => eq(a.id, b.parentId)) + .select(({ left: a, right: b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ), + ) + const owner = matched && !reverse ? right : left + const key = owner === right ? `right` : `left` + const check = () => + checkRootMetadata(live.toArray[0]!, { + ...owner.get(key)!, + $key: [...live.keys()][0], + }) + await checkWithCleanup(async () => { + await live.preload() + check() + const mutation = pendingRight + ? right.update(`right`, (row) => { + row.label = `pending` + }) + : left.update(`left`, (row) => { + row.label = `pending` + }) + await vi.waitFor(() => + expect( + (pendingRight ? right.get(`right`) : left.get(`left`))! + .$hasPendingWrites, + ).toBe(true), ) - const owner = matched && !reverse ? right : left - const key = owner === right ? `right` : `left` - const check = () => - checkRootMetadata(live.toArray[0]!, { - ...owner.get(key)!, - $key: [...live.keys()][0], - }) - await checkWithCleanup(async () => { - await live.preload() - check() - const mutation = pendingRight - ? right.update(`right`, (row) => { - row.label = `pending` - }) - : left.update(`left`, (row) => { - row.label = `pending` - }) - await vi.waitFor(() => - expect( - (pendingRight ? right.get(`right`) : left.get(`left`))! - .$hasPendingWrites, - ).toBe(true), - ) - if (!matched && !reverse) - metadataHistoriesByState.get(`null-later-spread`)!.add(metadataHistory) - check() - ;(pendingRight ? right : left).utils.resolveSync() - await mutation.when(`settled`) - check() - }, [() => live.cleanup(), () => left.cleanup(), () => right.cleanup()]) + if (!matched && !reverse) + metadataHistoriesByState.get(`null-later-spread`)!.add(metadataHistory) + check() + ;(pendingRight ? right : left).utils.resolveSync() + await mutation.when(`settled`) + check() + }, [() => live.cleanup(), () => left.cleanup(), () => right.cleanup()]) +} + +const lastSpreadLaw = `Metadata comes from the last present structured spread, independent of join side and spread order.` + +oracleProperty({ + id: `virtual-row-fields.last-spread`, + law: { + statement: lastSpreadLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The eight enumerated matched, order, and pending cells.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: lastSpreadHistories, + label: (history) => + `retains metadata from the last present spread: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(lastSpreadLaw, plan, observe, () => + judgeLastSpread(plan.history), + ), + check: forwardAssertionJudgment, +}) /** * Populated union branches retain both contributions, including numeric 1 and @@ -1376,227 +1176,194 @@ const unionMetadataHistoriesByState = new Map>( ) let unionMetadataHistory = 0 -test.each(unionHistories)( - `preserves each populated union branch: %j`, - async ({ - reverse, - nested, - pendingRight, - collision, - authoredPending, - shape, - }) => { - oracleRoute(`case`) - metadataHistory++ - unionMetadataHistory++ - const reach = (cut: string) => - unionMetadataHistoriesByState - .get( - `${nested ? `query-ref` : `direct`}/${sourceOwnsMetadata(shape) ? `source` : `output`}/${pendingRight ? `right` : `left`}/${cut}`, - )! - .add(unionMetadataHistory) - const initialLeft: AttributionRow = { - id: 1, - parentId: `parent`, - label: `same`, - } - const initialRight: AttributionRow = { - id: `1`, - parentId: `parent`, - label: `same`, - } - const left = createCollection( - mockSyncCollectionOptions({ - id: `union-left`, - getKey: (row) => row.id, - initialData: [{ ...initialLeft }], - }), - ) - const right = createCollection( - mockSyncCollectionOptions({ - id: `union-right`, - getKey: (row) => row.id, - initialData: [{ ...initialRight }], - }), - ) - const overrides: Record = {} - for (const field of virtualFieldNames) - if (collision === field || collision === `all`) - overrides[field] = - field === `$hasPendingWrites` - ? authoredPending - : field === `$origin` - ? `local` - : `spoof` - const branch = (source: typeof left, alias: string) => { - const base = new Query().from({ [alias]: source }) - if (shape === `functional-direct`) - return base.fn.select((refs) => refs[alias]!) - if (shape === `functional-spread`) - return base.fn.select((refs) => ({ ...refs[alias]!, ...overrides })) - if (shape === `functional-projection`) - return base.fn.select((refs) => ({ - id: refs[alias]!.id, - label: refs[alias]!.label, - ...overrides, - })) - if (shape === `structured-spread`) - return base.select((refs) => ({ ...refs[alias]!, ...overrides })) - return base.select((refs) => ({ +// The former case body, unchanged. +async function judgeUnionBranches({ + reverse, + nested, + pendingRight, + collision, + authoredPending, + shape, +}: (typeof unionHistories)[number]): Promise { + metadataHistory++ + unionMetadataHistory++ + const reach = (cut: string) => + unionMetadataHistoriesByState + .get( + `${nested ? `query-ref` : `direct`}/${sourceOwnsMetadata(shape) ? `source` : `output`}/${pendingRight ? `right` : `left`}/${cut}`, + )! + .add(unionMetadataHistory) + const initialLeft: AttributionRow = { + id: 1, + parentId: `parent`, + label: `same`, + } + const initialRight: AttributionRow = { + id: `1`, + parentId: `parent`, + label: `same`, + } + const left = createCollection( + mockSyncCollectionOptions({ + id: `union-left`, + getKey: (row) => row.id, + initialData: [{ ...initialLeft }], + }), + ) + const right = createCollection( + mockSyncCollectionOptions({ + id: `union-right`, + getKey: (row) => row.id, + initialData: [{ ...initialRight }], + }), + ) + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + const branch = (source: typeof left, alias: string) => { + const base = new Query().from({ [alias]: source }) + if (shape === `functional-direct`) + return base.fn.select((refs) => refs[alias]!) + if (shape === `functional-spread`) + return base.fn.select((refs) => ({ ...refs[alias]!, ...overrides })) + if (shape === `functional-projection`) + return base.fn.select((refs) => ({ id: refs[alias]!.id, label: refs[alias]!.label, - hasPendingWrites: refs[alias]!.$hasPendingWrites, ...overrides, })) + if (shape === `structured-spread`) + return base.select((refs) => ({ ...refs[alias]!, ...overrides })) + return base.select((refs) => ({ + id: refs[alias]!.id, + label: refs[alias]!.label, + hasPendingWrites: refs[alias]!.$hasPendingWrites, + ...overrides, + })) + } + const a = branch(left, `leftBranch`) + const b = branch(right, `rightBranch`) + const union = reverse + ? new Query().unionAll(b, a) + : new Query().unionAll(a, b) + const selected = nested ? new Query().from({ union }) : union + const keyOf = (id: AttributionKey) => `${typeof id}:${id}` + const live = createLiveQueryCollection({ + query: () => selected as unknown as QueryBuilder, + getKey: (row) => (sourceOwnsMetadata(shape) ? row.id : keyOf(row.id)), + }) + let leftTruth = [{ ...initialLeft }] + let rightTruth = [{ ...initialRight }] + const pendingKeys = new Set() + const check = () => + checkAttributedRows(live.toArray, [ + ...expectedAttributedRows( + leftTruth, + shape, + pendingKeys, + left.id, + live.id, + keyOf, + ), + ...expectedAttributedRows( + rightTruth, + shape, + pendingKeys, + right.id, + live.id, + keyOf, + ), + ]) + const pendingSource = pendingRight ? right : left + const pendingId = pendingRight ? initialRight.id : initialLeft.id + const peer = pendingRight ? left : right + const peerInitial = pendingRight ? initialLeft : initialRight + let finishMutation = async () => {} + await checkWithCleanup(async () => { + await live.preload() + check() + metadataHistoriesByState + .get(`two-populated-union-branches`)! + .add(metadataHistory) + metadataHistoriesByState.get(`typed-union-keys`)!.add(metadataHistory) + pendingKeys.add(pendingId) + if (pendingRight) rightTruth = [{ ...initialRight, label: `pending` }] + else leftTruth = [{ ...initialLeft, label: `pending` }] + const mutation = pendingSource.update(pendingId, (row) => { + row.label = `pending` + }) + finishMutation = async () => { + pendingSource.utils.resolveSync() + finishMutation = async () => {} + await mutation.when(`settled`) } - const a = branch(left, `leftBranch`) - const b = branch(right, `rightBranch`) - const union = reverse - ? new Query().unionAll(b, a) - : new Query().unionAll(a, b) - const selected = nested ? new Query().from({ union }) : union - const keyOf = (id: AttributionKey) => `${typeof id}:${id}` - const live = createLiveQueryCollection({ - query: () => selected as unknown as QueryBuilder, - getKey: (row) => (sourceOwnsMetadata(shape) ? row.id : keyOf(row.id)), + await vi.waitFor(() => + expect(pendingSource.get(pendingId)!.$hasPendingWrites).toBe(true), + ) + check() + reach(`held`) + peer.utils.begin() + peer.utils.write({ type: `delete`, value: { ...peerInitial } }) + peer.utils.commit() + if (pendingRight) leftTruth = [] + else rightTruth = [] + check() + peer.utils.begin() + peer.utils.write({ + type: `insert`, + value: { ...peerInitial, label: `returned` }, }) - let leftTruth = [{ ...initialLeft }] - let rightTruth = [{ ...initialRight }] - const pendingKeys = new Set() - const check = () => - checkAttributedRows(live.toArray, [ - ...expectedAttributedRows( - leftTruth, - shape, - pendingKeys, - left.id, - live.id, - keyOf, - ), - ...expectedAttributedRows( - rightTruth, - shape, - pendingKeys, - right.id, - live.id, - keyOf, - ), - ]) - const pendingSource = pendingRight ? right : left - const pendingId = pendingRight ? initialRight.id : initialLeft.id - const peer = pendingRight ? left : right - const peerInitial = pendingRight ? initialLeft : initialRight - let finishMutation = async () => {} - await checkWithCleanup(async () => { - await live.preload() - check() - metadataHistoriesByState - .get(`two-populated-union-branches`)! - .add(metadataHistory) - metadataHistoriesByState.get(`typed-union-keys`)!.add(metadataHistory) - pendingKeys.add(pendingId) - if (pendingRight) rightTruth = [{ ...initialRight, label: `pending` }] - else leftTruth = [{ ...initialLeft, label: `pending` }] - const mutation = pendingSource.update(pendingId, (row) => { - row.label = `pending` - }) - finishMutation = async () => { - pendingSource.utils.resolveSync() - finishMutation = async () => {} - await mutation.when(`settled`) - } - await vi.waitFor(() => - expect(pendingSource.get(pendingId)!.$hasPendingWrites).toBe(true), - ) - check() - reach(`held`) - peer.utils.begin() - peer.utils.write({ type: `delete`, value: { ...peerInitial } }) - peer.utils.commit() - if (pendingRight) leftTruth = [] - else rightTruth = [] - check() - peer.utils.begin() - peer.utils.write({ - type: `insert`, - value: { ...peerInitial, label: `returned` }, - }) - peer.utils.commit() - if (pendingRight) leftTruth = [{ ...initialLeft, label: `returned` }] - else rightTruth = [{ ...initialRight, label: `returned` }] - check() - metadataHistoriesByState - .get(`union-peer-reinserted`)! - .add(metadataHistory) - reach(`reinserted`) - await finishMutation() - pendingKeys.clear() - if (pendingRight) rightTruth = [{ ...initialRight }] - else leftTruth = [{ ...initialLeft }] - check() - reach(`settled`) - }, [ - () => finishMutation(), - () => live.cleanup(), - () => left.cleanup(), - () => right.cleanup(), - ]) - }, -) + peer.utils.commit() + if (pendingRight) leftTruth = [{ ...initialLeft, label: `returned` }] + else rightTruth = [{ ...initialRight, label: `returned` }] + check() + metadataHistoriesByState.get(`union-peer-reinserted`)!.add(metadataHistory) + reach(`reinserted`) + await finishMutation() + pendingKeys.clear() + if (pendingRight) rightTruth = [{ ...initialRight }] + else leftTruth = [{ ...initialLeft }] + check() + reach(`settled`) + }, [ + () => finishMutation(), + () => live.cleanup(), + () => left.cleanup(), + () => right.cleanup(), + ]) +} -// Calibration feeds allowed and hostile full results through the very same -// checker. It must see faults away from the first row and retain multiplicity. -test.each( - [`functional-projection`, `structured-spread`].flatMap((shape) => - [ - `missing-peer`, - `duplicate-peer`, - `missing-id`, - `wrong-key`, - `wrong-owner`, - `stale-pending`, - `wrong-origin`, - ].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( - [{ id: 1, parentId: `parent`, label: `same` }], - shape, - new Set(), - `left`, - `output`, - keyOf, - ), - ...expectedAttributedRows( - [{ id: `1`, parentId: `parent`, label: `same` }], - shape, - new Set([`1`]), - `right`, - `output`, - keyOf, +const unionBranchesLaw = `Each populated union branch keeps the attribution its own projection owns.` + +oracleProperty({ + id: `virtual-row-fields.union-branches`, + law: { + statement: unionBranchesLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The enumerated union order, nesting, pending, collision, and shape histories.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: unionHistories, + label: (history) => + `preserves each populated union branch: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(unionBranchesLaw, plan, observe, () => + judgeUnionBranches(plan.history), ), - ] - const allowed = expected.map((row) => ({ ...row.ordinary, ...row.metadata })) - checkAttributedRows(allowed, expected) - // A different legal output order remains accepted. - checkAttributedRows([...allowed].reverse(), expected) - const hostile: Array> = allowed.map((row) => ({ - ...row, - })) - if (fault === `missing-peer`) hostile.pop() - if (fault === `duplicate-peer`) hostile.push({ ...hostile[1]! }) - if (fault === `missing-id`) delete hostile[1]!.id - if (fault === `wrong-key`) hostile[1]!.$key = `wrong` - if (fault === `wrong-owner`) hostile[1]!.$collectionId = `left` - if (fault === `stale-pending`) - hostile[1]!.$hasPendingWrites = !expected[1]!.metadata.$hasPendingWrites - if (fault === `wrong-origin`) - hostile[1]!.$origin = - expected[1]!.metadata.$origin === `local` ? `remote` : `local` - expect(() => checkAttributedRows(hostile, expected)).toThrow() + check: forwardAssertionJudgment, }) /** @@ -1621,122 +1388,6 @@ test.each( * authoritative update. An untouched parent checks isolation. These bounds do * not cover same-source overlapping writes, truncate or provider scheduling. */ -type MetadataResult = ReturnType[number] -function expectedJoinedMetadata( - leftRows: ReadonlyArray, - rightRows: ReadonlyArray, - shape: string, - reverse: boolean, - pending: ReadonlySet, - rightOrigin: `local` | `remote`, - leftId: string, - rightId: string, - outputId: string, -): Array { - return leftRows.flatMap((a) => { - const matches = rightRows.filter((b) => b.parentId === a.id) - return (matches.length ? matches : [undefined]).map((b) => { - const spread = reverse ? { ...b, ...a } : { ...a, ...b } - const owner = shape === `functional-direct` || reverse || !b ? a : b - const selected = - shape.endsWith(`projection`) || shape === `functional-direct` - ? a - : spread - const expected = expectedAttributedRows( - [selected], - shape, - pending, - owner === b ? rightId : leftId, - outputId, - (id) => `projection-${id}`, - )[0]! - if (sourceOwnsMetadata(shape) && owner === b && !pending.has(b.id)) - expected.metadata.$origin = rightOrigin - return expected - }) - }) -} - -// Capture all row fields now. Retaining production row handles could let a -// later metadata getter or mutation rewrite evidence from an earlier callback. -function captureMetadataRows(rows: ReadonlyArray): Array { - return rows.map((row) => ({ - ...row, - ...Object.fromEntries( - virtualFieldNames.map((field) => [ - field, - (row as Record)[field], - ]), - ), - })) -} -type MetadataBatch = { - rows: Array - changes: Array<{ - type: string - key: AttributionKey - value: object - previousValue?: object - }> -} -function publishedModelRows(rows: ReadonlyArray) { - return rows.map((row) => ({ ...row.ordinary, ...row.metadata })) -} -// Collection change messages are the exact before/after delta for one atomic -// publication. Their ordering is unspecified; duplicate or omitted messages -// remain visible because neither side collapses actual messages into a Map. -function expectedMetadataChanges( - before: ReadonlyArray, - after: ReadonlyArray, -) { - const changes: MetadataBatch[`changes`] = [] - const same = (a: MetadataResult, b: MetadataResult) => - JSON.stringify(a) === JSON.stringify(b) - for (const row of before) { - if (!after.some((next) => next.metadata.$key === row.metadata.$key)) - changes.push({ - type: `delete`, - key: row.metadata.$key as AttributionKey, - value: publishedModelRows([row])[0]!, - }) - } - for (const row of after) { - const previous = before.find( - (old) => old.metadata.$key === row.metadata.$key, - ) - if (!previous || !same(previous, row)) - changes.push({ - type: previous ? `update` : `insert`, - key: row.metadata.$key as AttributionKey, - value: publishedModelRows([row])[0]!, - ...(previous - ? { previousValue: publishedModelRows([previous])[0]! } - : {}), - }) - } - return changes -} -function checkMetadataPublications( - actual: ReadonlyArray, - before: ReadonlyArray, - after: ReadonlyArray, -): void { - const expectedChanges = expectedMetadataChanges(before, after) - if (expectedChanges.length) - expect(actual.length, `owed metadata publication`).toBeGreaterThan(0) - const byChange = ( - a: MetadataBatch[`changes`][number], - b: MetadataBatch[`changes`][number], - ) => `${typeof a.key}:${a.key}`.localeCompare(`${typeof b.key}:${b.key}`) - for (const batch of actual) { - checkAttributedRows(batch.rows, [...after]) - } - // Compare every callback's delta together. Splitting a batch stays legal; - // repeating a whole batch cannot consume the same public change twice. - expect(actual.flatMap((batch) => batch.changes).sort(byChange)).toEqual( - [...expectedChanges].sort(byChange), - ) -} const metadataTimelineShapes = [ `structured-spread`, `functional-spread`, @@ -1802,365 +1453,330 @@ afterAll(() => { ), ) }) -declareOracle({ - model: [expectedJoinedMetadata, expectedMetadataChanges], - check: [checkMetadataPublications], - grammar: [metadataTimelineHistories], -}) -test.each(metadataTimelineHistories)( - `publishes metadata through join and write histories: %j`, - async ({ shape, reverse, topology, reject, rightFirst }) => { - oracleRoute(`case`) - metadataTimelineHistory++ - const reach = (cut: string) => - metadataTimelineHistoriesByState - .get(`${shape}/${topology}/${cut}`)! - .add(metadataTimelineHistory) - let releaseLeft!: () => void - let rejectLeft!: (reason: Error) => void - let releaseRight!: () => void - let rejectRight!: (reason: Error) => void - const leftGate = new Promise((resolve, rejectGate) => { - releaseLeft = resolve - rejectLeft = rejectGate - }) - const rightGate = new Promise((resolve, rejectGate) => { - releaseRight = resolve - rejectRight = rejectGate +// The former case body, unchanged. +async function judgeMetadataTimeline({ + shape, + reverse, + topology, + reject, + rightFirst, +}: (typeof metadataTimelineHistories)[number]): Promise { + metadataTimelineHistory++ + const reach = (cut: string) => + metadataTimelineHistoriesByState + .get(`${shape}/${topology}/${cut}`)! + .add(metadataTimelineHistory) + let releaseLeft!: () => void + let rejectLeft!: (reason: Error) => void + let releaseRight!: () => void + let rejectRight!: (reason: Error) => void + const leftGate = new Promise((resolve, rejectGate) => { + releaseLeft = resolve + rejectLeft = rejectGate + }) + const rightGate = new Promise((resolve, rejectGate) => { + releaseRight = resolve + rejectRight = rejectGate + }) + // Attach rejection observers before any path can abandon construction. + void leftGate.catch(() => {}) + void rightGate.catch(() => {}) + const baseLeft: Array = [ + { id: `a`, parentId: `unused`, label: `left-base` }, + { id: `anchor`, parentId: `unused`, label: `untouched` }, + ] + const rightRow: AttributionRow = { + id: `r`, + parentId: `a`, + label: `right-base`, + } + let rightBase = + topology === `join-key` ? [{ ...rightRow, parentId: `missing` }] : [] + const left = createCollection({ + ...mockSyncCollectionOptions({ + id: `timeline-left`, + getKey: (row) => row.id, + initialData: baseLeft.map((row) => ({ ...row })), + }), + onUpdate: async () => leftGate, + }) + const right = createCollection({ + ...mockSyncCollectionOptions({ + id: `timeline-right`, + getKey: (row) => row.id, + initialData: rightBase.map((row) => ({ ...row })), + }), + onUpdate: async () => rightGate, + }) + const baseQuery = new Query() + .from({ a: left }) + .leftJoin({ b: right }, ({ a, b }) => eq(a.id, b.parentId)) + const selected = + shape === `functional-direct` + ? baseQuery.fn.select(({ a }) => a) + : shape === `functional-spread` + ? baseQuery.fn.select(({ a, b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ) + : shape === `functional-projection` + ? baseQuery.fn.select(({ a }) => ({ id: a.id, label: a.label })) + : shape === `structured-projection` + ? baseQuery.select(({ a }) => ({ + id: a.id, + label: a.label, + hasPendingWrites: a.$hasPendingWrites, + })) + : baseQuery.select(({ a, b }) => + reverse ? { ...b, ...a } : { ...a, ...b }, + ) + const root = createLiveQueryCollection({ + query: () => selected as unknown as QueryBuilder, + getKey: (row) => + sourceOwnsMetadata(shape) ? row.id : `projection-${row.id}`, + }) + const downstream = createLiveQueryCollection({ + query: (q) => + q.from({ root }).select(({ root: row }) => ({ + id: row.id, + label: row.label, + observedKey: row.$key, + observedCollectionId: row.$collectionId, + observedOrigin: row.$origin, + observedPending: row.$hasPendingWrites, + })), + getKey: (row) => `downstream-${row.id}`, + }) + const pending = new Set() + let rightOrigin: `remote` | `local` = `remote` + let leftOverlay: AttributionRow | undefined + let rightOverlay: AttributionRow | undefined + const expectedRoot = () => + expectedJoinedMetadata( + baseLeft.map((row) => + row.id === `a` && leftOverlay ? { ...leftOverlay } : { ...row }, + ), + rightOverlay + ? [{ ...rightOverlay }] + : rightBase.map((row) => ({ ...row })), + shape, + reverse, + pending, + rightOrigin, + left.id, + right.id, + root.id, + ) + const expectedDownstream = (rows: ReadonlyArray) => + rows.map((row) => ({ + ordinary: { + id: (row.ordinary as { id: AttributionKey }).id, + label: (row.ordinary as { label: string }).label, + observedKey: row.metadata.$key, + observedCollectionId: row.metadata.$collectionId, + observedOrigin: row.metadata.$origin, + observedPending: row.metadata.$hasPendingWrites, + }, + metadata: { + $key: `downstream-${(row.ordinary as { id: AttributionKey }).id}`, + $collectionId: downstream.id, + $hasPendingWrites: false, + $origin: `remote`, + }, + })) + let previous = expectedRoot() + const rootBatches: Array = [] + const downstreamBatches: Array = [] + const record = ( + collection: typeof root | typeof downstream, + batches: Array, + ) => + collection.subscribeChanges( + (changes) => + batches.push({ + rows: captureMetadataRows(collection.toArray), + changes: changes.map((change) => ({ + type: change.type, + key: change.key, + value: captureMetadataRows([change.value])[0]!, + ...(change.previousValue + ? { + previousValue: captureMetadataRows([ + change.previousValue, + ])[0]!, + } + : {}), + })), + }), + { includeInitialState: true }, + ) + let rootSubscription: ReturnType | undefined + let downstreamSubscription: ReturnType | undefined + let leftSettlement: Promise | undefined + let rightSettlement: Promise | undefined + const observe = () => { + const next = expectedRoot() + const nextDownstream = expectedDownstream(next) + checkAttributedRows(root.toArray, next) + checkAttributedRows(downstream.toArray, nextDownstream) + checkMetadataPublications(rootBatches, previous, next) + checkMetadataPublications( + downstreamBatches, + expectedDownstream(previous), + nextDownstream, + ) + if (pending.size && expectedMetadataChanges(previous, next).length) + reach(`root-callback-held`) + if ( + pending.size && + expectedMetadataChanges(expectedDownstream(previous), nextDownstream) + .length + ) + reach(`downstream-callback-held`) + rootBatches.length = 0 + downstreamBatches.length = 0 + previous = next + } + const writeRight = ( + type: `insert` | `update` | `delete`, + row: AttributionRow, + ) => { + right.utils.begin() + right.utils.write({ type, value: { ...row } }) + right.utils.commit() + } + await checkWithCleanup(async () => { + await downstream.preload() + checkAttributedRows(root.toArray, previous) + checkAttributedRows(downstream.toArray, expectedDownstream(previous)) + rootSubscription = record(root, rootBatches) + downstreamSubscription = record(downstream, downstreamBatches) + checkMetadataPublications(rootBatches, [], previous) + checkMetadataPublications( + downstreamBatches, + [], + expectedDownstream(previous), + ) + rootBatches.length = 0 + downstreamBatches.length = 0 + leftOverlay = { ...baseLeft[0]!, label: `left-pending` } + pending.add(`a`) + const leftMutation = left.update(`a`, (row) => { + row.label = `left-pending` }) - // Attach rejection observers before any path can abandon construction. - void leftGate.catch(() => {}) - void rightGate.catch(() => {}) - const baseLeft: Array = [ - { id: `a`, parentId: `unused`, label: `left-base` }, - { id: `anchor`, parentId: `unused`, label: `untouched` }, - ] - const rightRow: AttributionRow = { - id: `r`, - parentId: `a`, - label: `right-base`, - } - let rightBase = + leftSettlement = leftMutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe() + rightBase = [{ ...rightRow }] + writeRight(topology === `join-key` ? `update` : `insert`, rightRow) + observe() + rightBase = topology === `join-key` ? [{ ...rightRow, parentId: `missing` }] : [] - const left = createCollection({ - ...mockSyncCollectionOptions({ - id: `timeline-left`, - getKey: (row) => row.id, - initialData: baseLeft.map((row) => ({ ...row })), - }), - onUpdate: async () => leftGate, - }) - const right = createCollection({ - ...mockSyncCollectionOptions({ - id: `timeline-right`, - getKey: (row) => row.id, - initialData: rightBase.map((row) => ({ ...row })), - }), - onUpdate: async () => rightGate, - }) - const baseQuery = new Query() - .from({ a: left }) - .leftJoin({ b: right }, ({ a, b }) => eq(a.id, b.parentId)) - const selected = - shape === `functional-direct` - ? baseQuery.fn.select(({ a }) => a) - : shape === `functional-spread` - ? baseQuery.fn.select(({ a, b }) => - reverse ? { ...b, ...a } : { ...a, ...b }, - ) - : shape === `functional-projection` - ? baseQuery.fn.select(({ a }) => ({ id: a.id, label: a.label })) - : shape === `structured-projection` - ? baseQuery.select(({ a }) => ({ - id: a.id, - label: a.label, - hasPendingWrites: a.$hasPendingWrites, - })) - : baseQuery.select(({ a, b }) => - reverse ? { ...b, ...a } : { ...a, ...b }, - ) - const root = createLiveQueryCollection({ - query: () => selected as unknown as QueryBuilder, - getKey: (row) => - sourceOwnsMetadata(shape) ? row.id : `projection-${row.id}`, - }) - const downstream = createLiveQueryCollection({ - query: (q) => - q.from({ root }).select(({ root: row }) => ({ - id: row.id, - label: row.label, - observedKey: row.$key, - observedCollectionId: row.$collectionId, - observedOrigin: row.$origin, - observedPending: row.$hasPendingWrites, - })), - getKey: (row) => `downstream-${row.id}`, + writeRight( + topology === `join-key` ? `update` : `delete`, + topology === `join-key` ? { ...rightRow, parentId: `missing` } : rightRow, + ) + observe() + rightBase = [{ ...rightRow }] + writeRight(topology === `join-key` ? `update` : `insert`, rightRow) + observe() + reach(`rematched-held`) + rightOverlay = { ...rightRow, label: `right-pending` } + pending.add(`r`) + const rightMutation = right.update(`r`, (row) => { + row.label = `right-pending` }) - const pending = new Set() - let rightOrigin: `remote` | `local` = `remote` - let leftOverlay: AttributionRow | undefined - let rightOverlay: AttributionRow | undefined - const expectedRoot = () => - expectedJoinedMetadata( - baseLeft.map((row) => - row.id === `a` && leftOverlay ? { ...leftOverlay } : { ...row }, - ), - rightOverlay - ? [{ ...rightOverlay }] - : rightBase.map((row) => ({ ...row })), - shape, - reverse, - pending, - rightOrigin, - left.id, - right.id, - root.id, - ) - const expectedDownstream = (rows: ReadonlyArray) => - rows.map((row) => ({ - ordinary: { - id: (row.ordinary as { id: AttributionKey }).id, - label: (row.ordinary as { label: string }).label, - observedKey: row.metadata.$key, - observedCollectionId: row.metadata.$collectionId, - observedOrigin: row.metadata.$origin, - observedPending: row.metadata.$hasPendingWrites, - }, - metadata: { - $key: `downstream-${(row.ordinary as { id: AttributionKey }).id}`, - $collectionId: downstream.id, - $hasPendingWrites: false, - $origin: `remote`, - }, - })) - let previous = expectedRoot() - const rootBatches: Array = [] - const downstreamBatches: Array = [] - const record = ( - collection: typeof root | typeof downstream, - batches: Array, - ) => - collection.subscribeChanges( - (changes) => - batches.push({ - rows: captureMetadataRows(collection.toArray), - changes: changes.map((change) => ({ - type: change.type, - key: change.key, - value: captureMetadataRows([change.value])[0]!, - ...(change.previousValue - ? { - previousValue: captureMetadataRows([ - change.previousValue, - ])[0]!, - } - : {}), - })), - }), - { includeInitialState: true }, - ) - let rootSubscription: ReturnType | undefined - let downstreamSubscription: ReturnType | undefined - let leftSettlement: Promise | undefined - let rightSettlement: Promise | undefined - const observe = () => { - const next = expectedRoot() - const nextDownstream = expectedDownstream(next) - checkAttributedRows(root.toArray, next) - checkAttributedRows(downstream.toArray, nextDownstream) - checkMetadataPublications(rootBatches, previous, next) - checkMetadataPublications( - downstreamBatches, - expectedDownstream(previous), - nextDownstream, - ) - if (pending.size && expectedMetadataChanges(previous, next).length) - reach(`root-callback-held`) - if ( - pending.size && - expectedMetadataChanges(expectedDownstream(previous), nextDownstream) - .length - ) - reach(`downstream-callback-held`) - rootBatches.length = 0 - downstreamBatches.length = 0 - previous = next - } - const writeRight = ( - type: `insert` | `update` | `delete`, - row: AttributionRow, - ) => { - right.utils.begin() - right.utils.write({ type, value: { ...row } }) - right.utils.commit() + rightSettlement = rightMutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe() + reach(`two-pending`) + writeRight(`update`, { ...rightRow, label: `server` }) + // Acceptance alone changes neither the applied base nor the active overlay. + observe() + const failures = { + left: new Error(`authored left rejection`), + right: new Error(`authored right rejection`), } - await checkWithCleanup(async () => { - await downstream.preload() - checkAttributedRows(root.toArray, previous) - checkAttributedRows(downstream.toArray, expectedDownstream(previous)) - rootSubscription = record(root, rootBatches) - downstreamSubscription = record(downstream, downstreamBatches) - checkMetadataPublications(rootBatches, [], previous) - checkMetadataPublications( - downstreamBatches, - [], - expectedDownstream(previous), - ) - rootBatches.length = 0 - downstreamBatches.length = 0 - leftOverlay = { ...baseLeft[0]!, label: `left-pending` } - pending.add(`a`) - const leftMutation = left.update(`a`, (row) => { - row.label = `left-pending` - }) - leftSettlement = leftMutation.when(`settled`).then( - () => undefined, - (error: unknown) => error, - ) - observe() - rightBase = [{ ...rightRow }] - writeRight(topology === `join-key` ? `update` : `insert`, rightRow) - observe() - rightBase = - topology === `join-key` ? [{ ...rightRow, parentId: `missing` }] : [] - writeRight( - topology === `join-key` ? `update` : `delete`, - topology === `join-key` - ? { ...rightRow, parentId: `missing` } - : rightRow, - ) - observe() - rightBase = [{ ...rightRow }] - writeRight(topology === `join-key` ? `update` : `insert`, rightRow) - observe() - reach(`rematched-held`) - rightOverlay = { ...rightRow, label: `right-pending` } - pending.add(`r`) - const rightMutation = right.update(`r`, (row) => { - row.label = `right-pending` - }) - rightSettlement = rightMutation.when(`settled`).then( - () => undefined, - (error: unknown) => error, - ) - observe() - reach(`two-pending`) - writeRight(`update`, { ...rightRow, label: `server` }) - // Acceptance alone changes neither the applied base nor the active overlay. - observe() - const failures = { - left: new Error(`authored left rejection`), - right: new Error(`authored right rejection`), + const settle = async (side: `left` | `right`) => { + const fails = reject === side || reject === `both` + const failure = failures[side] + if (side === `left`) { + pending.delete(`a`) + leftOverlay = undefined + if (fails) rejectLeft(failure) + else releaseLeft() + expect(await leftSettlement).toBe(fails ? failure : undefined) + } else { + pending.delete(`r`) + rightOverlay = undefined + rightBase = [{ ...rightRow, label: `server` }] + rightOrigin = fails ? `remote` : `local` + if (fails) rejectRight(failure) + else releaseRight() + expect(await rightSettlement).toBe(fails ? failure : undefined) } - const settle = async (side: `left` | `right`) => { - const fails = reject === side || reject === `both` - const failure = failures[side] - if (side === `left`) { - pending.delete(`a`) - leftOverlay = undefined - if (fails) rejectLeft(failure) - else releaseLeft() - expect(await leftSettlement).toBe(fails ? failure : undefined) - } else { - pending.delete(`r`) - rightOverlay = undefined - rightBase = [{ ...rightRow, label: `server` }] - rightOrigin = fails ? `remote` : `local` - if (fails) rejectRight(failure) - else releaseRight() - expect(await rightSettlement).toBe(fails ? failure : undefined) - } - observe() - if (fails) reach(`${side}-rejected`) - } - await settle(rightFirst ? `right` : `left`) - reach(`first-settled`) - reach(rightFirst ? `right-first` : `left-first`) - await settle(rightFirst ? `left` : `right`) - reach(`both-settled`) - if (reject === `both`) reach(`both-rejected`) - rightBase = [{ ...rightRow, label: `later` }] - rightOrigin = `remote` - writeRight(`update`, rightBase[0]!) observe() - reach(`remote-followup`) - }, [ - async () => { - releaseLeft() - releaseRight() - await Promise.all([leftSettlement, rightSettlement]) - }, - async () => { - rootSubscription?.unsubscribe() - }, - async () => { - downstreamSubscription?.unsubscribe() - }, - () => downstream.cleanup(), - () => root.cleanup(), - () => right.cleanup(), - () => left.cleanup(), - ]) - }, -) + if (fails) reach(`${side}-rejected`) + } + await settle(rightFirst ? `right` : `left`) + reach(`first-settled`) + reach(rightFirst ? `right-first` : `left-first`) + await settle(rightFirst ? `left` : `right`) + reach(`both-settled`) + if (reject === `both`) reach(`both-rejected`) + rightBase = [{ ...rightRow, label: `later` }] + rightOrigin = `remote` + writeRight(`update`, rightBase[0]!) + observe() + reach(`remote-followup`) + }, [ + async () => { + releaseLeft() + releaseRight() + await Promise.all([leftSettlement, rightSettlement]) + }, + async () => { + rootSubscription?.unsubscribe() + }, + async () => { + downstreamSubscription?.unsubscribe() + }, + () => downstream.cleanup(), + () => root.cleanup(), + () => right.cleanup(), + () => left.cleanup(), + ]) +} -// Publication calibrations corrupt an earlier callback while the final public -// answer remains correct. A final-snapshot-only check would miss these faults. -test.each([ - `stale-read`, - `stale-event`, - `new-previous-value`, - `duplicate-message`, - `duplicate-batch`, - `missing-message`, - `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], - `structured-spread`, - new Set(), - `source`, - `output`, - (id) => id, - ) - const after = expectedAttributedRows( - [{ ...base, label: `pending` }], - `structured-spread`, - new Set([base.id]), - `source`, - `output`, - (id) => id, - ) - const allowed = { - rows: publishedModelRows(after), - changes: expectedMetadataChanges(before, after), - } - checkMetadataPublications([allowed], before, after) - const hostile = structuredClone(allowed) - if (fault === `stale-read`) - (hostile.rows[0] as Record).$hasPendingWrites = false - if (fault === `stale-event`) - (hostile.changes[0]!.value as Record).$origin = `remote` - if (fault === `new-previous-value`) - hostile.changes[0]!.previousValue = { ...hostile.changes[0]!.value } - if (fault === `duplicate-message`) - hostile.changes.push({ ...hostile.changes[0]! }) - if (fault === `missing-message`) hostile.changes = [] - if (fault === `early-wrong-later-correct`) - (hostile.rows[0] as Record).$collectionId = `wrong` - const trace = - fault === `no-callback` - ? [] - : fault === `duplicate-batch` - ? [allowed, structuredClone(allowed)] - : fault === `early-wrong-later-correct` - ? [hostile, allowed] - : [hostile] - expect(() => checkMetadataPublications(trace, before, after)).toThrow() +const metadataTimelineLaw = `Metadata publications through join and write histories match the model at every read, event, and downstream callback cut.` + +oracleProperty({ + id: `virtual-row-fields.metadata-timeline`, + law: { + statement: metadataTimelineLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The enumerated shape, order, topology, rejection, and right-first timeline histories.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: metadataTimelineHistories, + label: (history) => + `publishes metadata through join and write histories: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(metadataTimelineLaw, plan, observe, () => + judgeMetadataTimeline(plan.history), + ), + check: forwardAssertionJudgment, }) /** @@ -2180,49 +1796,6 @@ test.each([ * authored shapes. Only single-source child keys are modeled here; composite * compiler key encoding, facade mutation and provider scheduling are excluded. */ -function expectedInlineMetadataValues( - rows: ReadonlyArray, - shape: string, - pending: ReadonlySet, - sourceId: string, - overrides: Readonly>, -): Array { - return rows.map((row) => { - const source = expectedAttributedRows( - [row], - `structured-spread`, - pending, - sourceId, - `unused`, - (id) => id, - )[0]! - const selected = shape.endsWith(`projection`) - ? expectedAttributedRows( - [row], - shape, - pending, - sourceId, - `unused`, - (id) => id, - )[0]!.ordinary - : { ...source.ordinary, ...source.metadata } - return shape === `functional-direct` - ? selected - : { ...selected, ...overrides } - }) -} -function checkInlineMetadataValues( - actual: ReadonlyArray, - expected: ReadonlyArray, -): void { - // These values are flat authored records. Sorting ignores unspecified order, - // but preserves every selected property and duplicate occurrence. - const byId = (a: object, b: object) => - String((a as { id: unknown }).id).localeCompare( - String((b as { id: unknown }).id), - ) - expect([...actual].sort(byId)).toEqual([...expected].sort(byId)) -} const facadeMetadataHistories = collisionCases.flatMap( ({ collision, authoredPending }) => metadataTimelineShapes @@ -2280,277 +1853,260 @@ afterAll(() => { ), ) }) -declareOracle({ - model: [expectedInlineMetadataValues], - check: [checkInlineMetadataValues], - grammar: [facadeMetadataHistories], -}) -test.each(facadeMetadataHistories)( - `receives metadata through shared facade intervals: %j`, - async ({ - collision, - authoredPending, - shape, - destinationPopulated, - reject, - }) => { - oracleRoute(`case`) - facadeMetadataHistory++ - const reach = (cut: string) => - facadeMetadataHistoriesByState - .get(`${shape}/${cut}`)! - .add(facadeMetadataHistory) - let release!: () => void - let rejectWrite!: (error: Error) => void - const gate = new Promise((resolve, rejectGate) => { - release = resolve - rejectWrite = rejectGate - }) - void gate.catch(() => {}) - let parentTruth = [ - { id: `p1`, parentId: `a` }, - { id: `p2`, parentId: `a` }, - { id: `empty`, parentId: `empty` }, - ] - const initialChildren: Array = [ - { id: `c1`, parentId: `a`, label: `first` }, - { id: `c2`, parentId: `a`, label: `second` }, - ...(destinationPopulated - ? [{ id: `c3`, parentId: `b`, label: `destination` }] - : []), - ] - let childTruth = initialChildren.map((row) => ({ ...row })) - const parents = createCollection( - mockSyncCollectionOptions({ - id: `facade-metadata-parents`, - getKey: (row: { id: string; parentId: string }) => row.id, - initialData: parentTruth.map((row) => ({ ...row })), - }), - ) - const children = createCollection({ - ...mockSyncCollectionOptions({ - id: `facade-metadata-children`, - getKey: (row) => row.id, - initialData: initialChildren.map((row) => ({ ...row })), - }), - onUpdate: async () => gate, - }) - const overrides: Record = {} - for (const field of virtualFieldNames) - if (collision === field || collision === `all`) - overrides[field] = - field === `$hasPendingWrites` - ? authoredPending - : field === `$origin` - ? `local` - : `spoof` - const live = createLiveQueryCollection((q) => - q.from({ parent: parents }).select(({ parent }) => { - const childRows = () => { - const base = new Query() - .from({ child: children }) - .where(({ child }) => eq(child.parentId, parent.parentId)) - const selected = - shape === `functional-direct` - ? base.fn.select(({ child }) => child) - : shape === `functional-projection` - ? base.fn.select(({ child }) => ({ +// The former case body, unchanged. +async function judgeFacadeMetadata({ + collision, + authoredPending, + shape, + destinationPopulated, + reject, +}: (typeof facadeMetadataHistories)[number]): Promise { + facadeMetadataHistory++ + const reach = (cut: string) => + facadeMetadataHistoriesByState + .get(`${shape}/${cut}`)! + .add(facadeMetadataHistory) + let release!: () => void + let rejectWrite!: (error: Error) => void + const gate = new Promise((resolve, rejectGate) => { + release = resolve + rejectWrite = rejectGate + }) + void gate.catch(() => {}) + let parentTruth = [ + { id: `p1`, parentId: `a` }, + { id: `p2`, parentId: `a` }, + { id: `empty`, parentId: `empty` }, + ] + const initialChildren: Array = [ + { id: `c1`, parentId: `a`, label: `first` }, + { id: `c2`, parentId: `a`, label: `second` }, + ...(destinationPopulated + ? [{ id: `c3`, parentId: `b`, label: `destination` }] + : []), + ] + let childTruth = initialChildren.map((row) => ({ ...row })) + const parents = createCollection( + mockSyncCollectionOptions({ + id: `facade-metadata-parents`, + getKey: (row: { id: string; parentId: string }) => row.id, + initialData: parentTruth.map((row) => ({ ...row })), + }), + ) + const children = createCollection({ + ...mockSyncCollectionOptions({ + id: `facade-metadata-children`, + getKey: (row) => row.id, + initialData: initialChildren.map((row) => ({ ...row })), + }), + onUpdate: async () => gate, + }) + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? authoredPending + : field === `$origin` + ? `local` + : `spoof` + const live = createLiveQueryCollection((q) => + q.from({ parent: parents }).select(({ parent }) => { + const childRows = () => { + const base = new Query() + .from({ child: children }) + .where(({ child }) => eq(child.parentId, parent.parentId)) + const selected = + shape === `functional-direct` + ? base.fn.select(({ child }) => child) + : shape === `functional-projection` + ? base.fn.select(({ child }) => ({ + id: child.id, + label: child.label, + ...overrides, + })) + : shape === `structured-projection` + ? base.select(({ child }) => ({ id: child.id, label: child.label, + hasPendingWrites: child.$hasPendingWrites, ...overrides, })) - : shape === `structured-projection` - ? base.select(({ child }) => ({ - id: child.id, - label: child.label, - hasPendingWrites: child.$hasPendingWrites, + : // Explicit virtual reads make the inline selected shape independent of + // whether source metadata properties happen to be enumerable. + shape === `functional-spread` + ? base.fn.select(({ child }) => ({ + ...child, + $key: child.$key, + $collectionId: child.$collectionId, + $origin: child.$origin, + $hasPendingWrites: child.$hasPendingWrites, ...overrides, })) - : // Explicit virtual reads make the inline selected shape independent of - // whether source metadata properties happen to be enumerable. - shape === `functional-spread` - ? base.fn.select(({ child }) => ({ - ...child, - $key: child.$key, - $collectionId: child.$collectionId, - $origin: child.$origin, - $hasPendingWrites: child.$hasPendingWrites, - ...overrides, - })) - : base.select(({ child }) => ({ - ...child, - $key: child.$key, - $collectionId: child.$collectionId, - $origin: child.$origin, - $hasPendingWrites: child.$hasPendingWrites, - ...overrides, - })) - return selected as unknown as QueryBuilder - } - return { - id: parent.id, - parentId: parent.parentId, - children: include(childRows()), - array: toArray(childRows()), - materialized: materialize(childRows()), - } - }), + : base.select(({ child }) => ({ + ...child, + $key: child.$key, + $collectionId: child.$collectionId, + $origin: child.$origin, + $hasPendingWrites: child.$hasPendingWrites, + ...overrides, + })) + return selected as unknown as QueryBuilder + } + return { + id: parent.id, + parentId: parent.parentId, + children: include(childRows()), + array: toArray(childRows()), + materialized: materialize(childRows()), + } + }), + ) + const pending = new Set() + let intervals: FacadeIntervals = { active: new Map(), next: 0 } + const observedIntervals = new Map() + const observe = (cut?: `held` | `reactivated`) => { + expect( + live.toArray + .map(({ id, parentId }) => ({ id, parentId })) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual([...parentTruth].sort((a, b) => a.id.localeCompare(b.id))) + intervals = advanceFacadeIntervals( + intervals, + parentTruth.map((row) => row.parentId), ) - const pending = new Set() - let intervals: FacadeIntervals = { active: new Map(), next: 0 } - const observedIntervals = new Map() - const observe = (cut?: `held` | `reactivated`) => { - expect( - live.toArray - .map(({ id, parentId }) => ({ id, parentId })) - .sort((a, b) => a.id.localeCompare(b.id)), - ).toEqual([...parentTruth].sort((a, b) => a.id.localeCompare(b.id))) - intervals = advanceFacadeIntervals( - intervals, - parentTruth.map((row) => row.parentId), - ) - checkFacadeIntervals( - intervals, - live.toArray.map((row) => ({ - group: row.parentId, - facade: row.children, - })), - observedIntervals, + checkFacadeIntervals( + intervals, + live.toArray.map((row) => ({ + group: row.parentId, + facade: row.children, + })), + observedIntervals, + ) + for (const parent of parentTruth) { + const actual = live.get(parent.id)! + const expectedChildren = childTruth.filter( + (child) => child.parentId === parent.parentId, ) - for (const parent of parentTruth) { - const actual = live.get(parent.id)! - const expectedChildren = childTruth.filter( - (child) => child.parentId === parent.parentId, - ) - if (!expectedChildren.length) reach(`active-empty-ready`) - checkAttributedRows( - actual.children.toArray, - expectedAttributedRows( - expectedChildren, - shape, - pending, - children.id, - actual.children.id, - (id) => id, - ), - ) - const expectedInline = expectedInlineMetadataValues( + if (!expectedChildren.length) reach(`active-empty-ready`) + checkAttributedRows( + actual.children.toArray, + expectedAttributedRows( expectedChildren, shape, pending, children.id, - overrides, - ) - checkInlineMetadataValues(actual.array, expectedInline) - checkInlineMetadataValues(actual.materialized, expectedInline) - if (cut && expectedChildren.length) { - for (const form of [`facade`, `toArray`, `materialize`]) - facadeMetadataHistoriesByState - .get(`${shape}/${form}/${cut}`)! - .add(facadeMetadataHistory) - } - } - } - const move = (id: string, parentId: string) => { - parentTruth = parentTruth.map((row) => - row.id === id ? { ...row, parentId } : row, - ) - parents.utils.begin() - parents.utils.write({ type: `update`, value: { id, parentId } }) - parents.utils.commit() - observe() - } - let settlement: Promise | undefined - await checkWithCleanup(async () => { - await live.preload() - observe() - childTruth = childTruth.map((row) => - row.id === `c1` ? { ...row, label: `pending` } : row, - ) - pending.add(`c1`) - const mutation = children.update(`c1`, (row) => { - row.label = `pending` - }) - settlement = mutation.when(`settled`).then( - () => undefined, - (error: unknown) => error, + actual.children.id, + (id) => id, + ), ) - observe(`held`) - reach(`shared-held`) - move(`p1`, `b`) - move(`p2`, `b`) - reach(`last-route-retired`) - move(`p1`, `a`) - observe(`reactivated`) - reach(`reactivated-held`) - childTruth = initialChildren.map((row) => ({ ...row })) - pending.clear() - const failure = new Error(`authored facade child rejection`) - if (reject) rejectWrite(failure) - else release() - expect(await settlement).toBe(reject ? failure : undefined) - observe() - reach(`settled`) - childTruth = childTruth.map((row) => - row.id === `c1` ? { ...row, label: `later` } : row, + const expectedInline = expectedInlineMetadataValues( + expectedChildren, + shape, + pending, + children.id, + overrides, ) - children.utils.begin() - children.utils.write({ - type: `update`, - value: { id: `c1`, parentId: `a`, label: `later` }, - }) - children.utils.commit() - observe() - reach(`later-source`) - move(`p2`, `a`) - reach(`reshared`) - }, [ - async () => { - release() - await settlement - }, - () => live.cleanup(), - () => children.cleanup(), - () => parents.cleanup(), - ]) - }, -) - -// Inline values must not receive a facade's publication metadata. These -// controls run the same selected-shape model for absent and authored fields. -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`) - overrides[field] = - field === `$hasPendingWrites` - ? true - : field === `$origin` - ? `local` - : `authored` - const expected = expectedInlineMetadataValues( - [{ id: `child`, parentId: `a`, label: `value` }], - `functional-projection`, - new Set(), - `source`, - overrides, + checkInlineMetadataValues(actual.array, expectedInline) + checkInlineMetadataValues(actual.materialized, expectedInline) + if (cut && expectedChildren.length) { + for (const form of [`facade`, `toArray`, `materialize`]) + facadeMetadataHistoriesByState + .get(`${shape}/${form}/${cut}`)! + .add(facadeMetadataHistory) + } + } + } + const move = (id: string, parentId: string) => { + parentTruth = parentTruth.map((row) => + row.id === id ? { ...row, parentId } : row, ) - checkInlineMetadataValues(expected, expected) - const hostile = [ - { - ...expected[0], - $key: `compiler-key`, - $collectionId: `facade`, - $origin: `remote`, - $hasPendingWrites: false, - }, - ] - expect(() => checkInlineMetadataValues(hostile, expected)).toThrow() + parents.utils.begin() + parents.utils.write({ type: `update`, value: { id, parentId } }) + parents.utils.commit() + observe() + } + let settlement: Promise | undefined + await checkWithCleanup(async () => { + await live.preload() + observe() + childTruth = childTruth.map((row) => + row.id === `c1` ? { ...row, label: `pending` } : row, + ) + pending.add(`c1`) + const mutation = children.update(`c1`, (row) => { + row.label = `pending` + }) + settlement = mutation.when(`settled`).then( + () => undefined, + (error: unknown) => error, + ) + observe(`held`) + reach(`shared-held`) + move(`p1`, `b`) + move(`p2`, `b`) + reach(`last-route-retired`) + move(`p1`, `a`) + observe(`reactivated`) + reach(`reactivated-held`) + childTruth = initialChildren.map((row) => ({ ...row })) + pending.clear() + const failure = new Error(`authored facade child rejection`) + if (reject) rejectWrite(failure) + else release() + expect(await settlement).toBe(reject ? failure : undefined) + observe() + reach(`settled`) + childTruth = childTruth.map((row) => + row.id === `c1` ? { ...row, label: `later` } : row, + ) + children.utils.begin() + children.utils.write({ + type: `update`, + value: { id: `c1`, parentId: `a`, label: `later` }, + }) + children.utils.commit() + observe() + reach(`later-source`) + move(`p2`, `a`) + reach(`reshared`) + }, [ + async () => { + release() + await settlement + }, + () => live.cleanup(), + () => children.cleanup(), + () => parents.cleanup(), + ]) +} + +const facadeMetadataLaw = `Collection facades publish the metadata their projection owns across shared facade intervals, and inline values keep their authored fields.` + +oracleProperty({ + id: `virtual-row-fields.facade-intervals`, + law: { + statement: facadeMetadataLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The enumerated collision, pending, shape, destination, and rejection facade histories.`, }, -) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: facadeMetadataHistories, + label: (history) => + `receives metadata through shared facade intervals: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(facadeMetadataLaw, plan, observe, () => + judgeFacadeMetadata(plan.history), + ), + check: forwardAssertionJudgment, +}) /** * A nullable whole-row selection yields either the complete source row or an @@ -2561,48 +2117,6 @@ test.each([`none`, `$key`, `$hasPendingWrites`, `all`] as const)( * Compiler-generated empty-row keys are checked against the facade's own public * keys, not against an independent key-encoding model. */ -function expectedNullableSelection( - match: { id: string; label: string } | undefined, - sourceId: string, - facadeId: string, - facadeKey: string | number, -): { inline: Array; facade: Array } { - const inline = match - ? [ - { - ...match, - $key: match.id, - $collectionId: sourceId, - $origin: `remote`, - $hasPendingWrites: false, - }, - ] - : [{}] - return { - inline, - facade: match - ? inline.map((row) => ({ ...row, $key: facadeKey })) - : [ - { - $key: facadeKey, - $collectionId: facadeId, - $origin: `remote`, - $hasPendingWrites: false, - }, - ], - } -} -function checkNullableSelection( - actual: { - facade: ReadonlyArray - inline: ReadonlyArray> - }, - expected: ReturnType, -): void { - checkInlineMetadataValues(actual.facade, expected.facade) - for (const value of actual.inline) - checkInlineMetadataValues(value, expected.inline) -} const nullableFacadeHistories = [false, true].map((initialMatch) => ({ initialMatch, })) @@ -2620,123 +2134,107 @@ afterAll(() => `nullable whole-row facade`, ).toEqual([]), ) -declareOracle({ - model: [expectedNullableSelection], - check: [checkNullableSelection], - grammar: [nullableFacadeHistories], -}) -test.each(nullableFacadeHistories)( - `publishes nullable whole rows through a facade: %j`, - async ({ initialMatch }) => { - oracleRoute(`case`) - nullableFacadeHistory++ - const children = createCollection( - mockSyncCollectionOptions<{ id: string }>({ - id: `nullable-facade-children`, - getKey: (row) => row.id, - initialData: [{ id: `child` }], - }), - ) - const authored = { id: `child`, label: `source` } - let match = initialMatch ? { ...authored } : undefined - const matches = createCollection( - mockSyncCollectionOptions({ - id: `nullable-facade-matches`, - getKey: (row) => row.id, - initialData: match ? [{ ...authored }] : [], - }), - ) - children.createIndex((row) => row.id, { indexType: BTreeIndex }) - matches.createIndex((row) => row.id, { indexType: BTreeIndex }) - const live = createLiveQueryCollection((q) => - q.from({ parent: children }).select(({ parent }) => { - const selected = q - .from({ child: children }) - .where(({ child }) => eq(child.id, parent.id)) - .leftJoin({ maybe: matches }, ({ child, maybe }) => - eq(child.id, maybe.id), - ) - .select(({ maybe }) => maybe) - return { - id: parent.id, - children: include(selected), - array: toArray(selected), - materialized: materialize(selected), - first: materialize(selected.findOne()), - } - }), - ) - const observe = () => { - const parent = live.toArray[0]! - expect(live.toArray).toHaveLength(1) - const keys = [...parent.children.keys()] - expect(keys).toHaveLength(1) - checkNullableSelection( - { - facade: parent.children.toArray, - inline: [parent.array, parent.materialized, [parent.first!]], - }, - expectedNullableSelection( - match, - matches.id, - parent.children.id, - keys[0]!, - ), - ) - nullableFacadeHistoriesByState - .get(match ? `matched` : `unmatched`)! - .add(nullableFacadeHistory) - } - await checkWithCleanup(async () => { - await live.preload() - observe() - for (let i = 0; i < 2; i++) { - matches.utils.begin() - matches.utils.write({ - type: match ? `delete` : `insert`, - value: { ...authored }, - }) - matches.utils.commit() - match = match ? undefined : { ...authored } - observe() +// The former case body, unchanged. +async function judgeNullableFacade({ + initialMatch, +}: (typeof nullableFacadeHistories)[number]): Promise { + nullableFacadeHistory++ + const children = createCollection( + mockSyncCollectionOptions<{ id: string }>({ + id: `nullable-facade-children`, + getKey: (row) => row.id, + initialData: [{ id: `child` }], + }), + ) + const authored = { id: `child`, label: `source` } + let match = initialMatch ? { ...authored } : undefined + const matches = createCollection( + mockSyncCollectionOptions({ + id: `nullable-facade-matches`, + getKey: (row) => row.id, + initialData: match ? [{ ...authored }] : [], + }), + ) + children.createIndex((row) => row.id, { indexType: BTreeIndex }) + matches.createIndex((row) => row.id, { indexType: BTreeIndex }) + const live = createLiveQueryCollection((q) => + q.from({ parent: children }).select(({ parent }) => { + const selected = q + .from({ child: children }) + .where(({ child }) => eq(child.id, parent.id)) + .leftJoin({ maybe: matches }, ({ child, maybe }) => + eq(child.id, maybe.id), + ) + .select(({ maybe }) => maybe) + return { + id: parent.id, + children: include(selected), + array: toArray(selected), + materialized: materialize(selected), + first: materialize(selected.findOne()), } - }, [ - () => live.cleanup(), - () => matches.cleanup(), - () => children.cleanup(), - ]) - }, -) - -// The same nullable-boundary checker must reject lost facade metadata, enriched -// inline emptiness and a missing ordinary field on a complete source row. -test.each([ - `empty-facade-metadata`, - `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` } - : undefined, - `source`, - `facade`, - `generated-key`, + }), ) - const observed = { - facade: structuredClone(expected.facade), - inline: [structuredClone(expected.inline)], - } - checkNullableSelection(observed, expected) - if (fault === `empty-facade-metadata`) observed.facade = [{}] - else if (fault === `empty-inline-enriched`) - observed.inline = [structuredClone(expected.facade)] - else - observed.facade = observed.facade.map((row) => - Object.fromEntries( - Object.entries(row).filter(([key]) => key !== `label`), + const observe = () => { + const parent = live.toArray[0]! + expect(live.toArray).toHaveLength(1) + const keys = [...parent.children.keys()] + expect(keys).toHaveLength(1) + checkNullableSelection( + { + facade: parent.children.toArray, + inline: [parent.array, parent.materialized, [parent.first!]], + }, + expectedNullableSelection( + match, + matches.id, + parent.children.id, + keys[0]!, ), ) - expect(() => checkNullableSelection(observed, expected)).toThrow() + nullableFacadeHistoriesByState + .get(match ? `matched` : `unmatched`)! + .add(nullableFacadeHistory) + } + await checkWithCleanup(async () => { + await live.preload() + observe() + for (let i = 0; i < 2; i++) { + matches.utils.begin() + matches.utils.write({ + type: match ? `delete` : `insert`, + value: { ...authored }, + }) + matches.utils.commit() + match = match ? undefined : { ...authored } + observe() + } + }, [() => live.cleanup(), () => matches.cleanup(), () => children.cleanup()]) +} + +const nullableFacadeLaw = `A nullable whole-row selection publishes through a facade with the facade's metadata when matched and an empty value otherwise.` + +oracleProperty({ + id: `virtual-row-fields.nullable-facade`, + law: { + statement: nullableFacadeLaw, + authority: `The Collection virtual-field boundary and reserved public row fields (boundary-only metadata, maintainer decision of 2026-11-05) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The two enumerated initial-match histories.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + `src/virtual-props.ts#hasVirtualProps`, + ], + histories: nullableFacadeHistories, + label: (history) => + `publishes nullable whole rows through a facade: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(nullableFacadeLaw, plan, observe, () => + judgeNullableFacade(plan.history), + ), + check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/query/virtual-row-fields.test.ts b/packages/db/tests/query/virtual-row-fields.test.ts index 7e3f9ad88a..4647d396ed 100644 --- a/packages/db/tests/query/virtual-row-fields.test.ts +++ b/packages/db/tests/query/virtual-row-fields.test.ts @@ -11,10 +11,29 @@ import { } from '../../src/query/index.js' import { hasVirtualProps } from '../../src/virtual-props.js' import { mockSyncCollectionOptions } from '../utils.js' +import { expectedSourceMetadata } from './virtual-row-fields-source-oracle.js' +import { + boundaryPublicationHistories, + checkAttributedRows, + checkBoundaryPublication, + checkInlineMetadataValues, + checkMetadataPublications, + checkNullableSelection, + expectedAttributedRows, + expectedBoundaryPublication, + expectedInlineMetadataValues, + expectedMetadataChanges, + expectedNullableSelection, + publishedModelRows, +} from './virtual-row-fields-model-oracle.js' import { checkWithCleanup, virtualFieldNames, } from './virtual-row-fields-support.js' +import type { + AttributionKey, + AttributionRow, +} from './virtual-row-fields-model-oracle.js' type Profile = { label: string } @@ -416,3 +435,216 @@ describe(`virtual metadata attribution regressions`, () => { }, [() => functional.cleanup(), () => live.cleanup(), () => rows.cleanup()]) }) }) + +// Checker calibrations for the virtual-field publication oracle in +// virtual-row-fields-oracle.test.ts. They run no production: each builds a +// hostile observation from the independent model and feeds it to the oracle's +// check, which must reject it. +// Calibrate the same comparison against the former source-attribution rule. +// Mutate one metadata observation at a time; ordinary group values stay valid. +test.each( + boundaryPublicationHistories.filter( + ({ kind }) => kind === `grouped` || kind === `aggregate`, + ), +)(`rejects source metadata on a grouped output: %j`, (history) => { + const parent = { id: `parent`, parentId: `root`, label: `parent` } + const child = { id: `child`, parentId: `parent`, label: `held` } + const expected = expectedBoundaryPublication( + history, + parent, + [child], + new Set([child.id]), + `parent-source`, + `child-source`, + `output`, + ) + const sourceMetadata = expectedSourceMetadata(child.id, `child-source`, true) + for (const field of virtualFieldNames) { + const wrong = expected.map( + ({ ordinary, metadata }) => + [ + metadata.$key as AttributionKey, + { ...ordinary, ...metadata, [field]: sourceMetadata[field] }, + ] as const, + ) + expect(() => checkBoundaryPublication(wrong, expected), field).toThrow() + } +}) + +// Calibration feeds allowed and hostile full results through the very same +// checker. It must see faults away from the first row and retain multiplicity. +test.each( + [`functional-projection`, `structured-spread`].flatMap((shape) => + [ + `missing-peer`, + `duplicate-peer`, + `missing-id`, + `wrong-key`, + `wrong-owner`, + `stale-pending`, + `wrong-origin`, + ].map((fault) => ({ shape, fault })), + ), +)(`rejects a hostile complete metadata result: %j`, ({ shape, fault }) => { + const keyOf = (id: AttributionKey) => `key-${typeof id}:${id}` + const expected = [ + ...expectedAttributedRows( + [{ id: 1, parentId: `parent`, label: `same` }], + shape, + new Set(), + `left`, + `output`, + keyOf, + ), + ...expectedAttributedRows( + [{ id: `1`, parentId: `parent`, label: `same` }], + shape, + new Set([`1`]), + `right`, + `output`, + keyOf, + ), + ] + const allowed = expected.map((row) => ({ ...row.ordinary, ...row.metadata })) + checkAttributedRows(allowed, expected) + // A different legal output order remains accepted. + checkAttributedRows([...allowed].reverse(), expected) + const hostile: Array> = allowed.map((row) => ({ + ...row, + })) + if (fault === `missing-peer`) hostile.pop() + if (fault === `duplicate-peer`) hostile.push({ ...hostile[1]! }) + if (fault === `missing-id`) delete hostile[1]!.id + if (fault === `wrong-key`) hostile[1]!.$key = `wrong` + if (fault === `wrong-owner`) hostile[1]!.$collectionId = `left` + if (fault === `stale-pending`) + hostile[1]!.$hasPendingWrites = !expected[1]!.metadata.$hasPendingWrites + if (fault === `wrong-origin`) + hostile[1]!.$origin = + expected[1]!.metadata.$origin === `local` ? `remote` : `local` + expect(() => checkAttributedRows(hostile, expected)).toThrow() +}) + +// Publication calibrations corrupt an earlier callback while the final public +// answer remains correct. A final-snapshot-only check would miss these faults. +test.each([ + `stale-read`, + `stale-event`, + `new-previous-value`, + `duplicate-message`, + `duplicate-batch`, + `missing-message`, + `no-callback`, + `early-wrong-later-correct`, +] as const)(`rejects a hostile metadata publication: %s`, (fault) => { + const base: AttributionRow = { id: `a`, parentId: `parent`, label: `before` } + const before = expectedAttributedRows( + [base], + `structured-spread`, + new Set(), + `source`, + `output`, + (id) => id, + ) + const after = expectedAttributedRows( + [{ ...base, label: `pending` }], + `structured-spread`, + new Set([base.id]), + `source`, + `output`, + (id) => id, + ) + const allowed = { + rows: publishedModelRows(after), + changes: expectedMetadataChanges(before, after), + } + checkMetadataPublications([allowed], before, after) + const hostile = structuredClone(allowed) + if (fault === `stale-read`) + (hostile.rows[0] as Record).$hasPendingWrites = false + if (fault === `stale-event`) + (hostile.changes[0]!.value as Record).$origin = `remote` + if (fault === `new-previous-value`) + hostile.changes[0]!.previousValue = { ...hostile.changes[0]!.value } + if (fault === `duplicate-message`) + hostile.changes.push({ ...hostile.changes[0]! }) + if (fault === `missing-message`) hostile.changes = [] + if (fault === `early-wrong-later-correct`) + (hostile.rows[0] as Record).$collectionId = `wrong` + const trace = + fault === `no-callback` + ? [] + : fault === `duplicate-batch` + ? [allowed, structuredClone(allowed)] + : fault === `early-wrong-later-correct` + ? [hostile, allowed] + : [hostile] + expect(() => checkMetadataPublications(trace, before, after)).toThrow() +}) + +// Inline values must not receive a facade's publication metadata. These +// controls run the same selected-shape model for absent and authored fields. +test.each([`none`, `$key`, `$hasPendingWrites`, `all`] as const)( + `rejects facade enrichment of an inline value: %s`, + (collision) => { + const overrides: Record = {} + for (const field of virtualFieldNames) + if (collision === field || collision === `all`) + overrides[field] = + field === `$hasPendingWrites` + ? true + : field === `$origin` + ? `local` + : `authored` + const expected = expectedInlineMetadataValues( + [{ id: `child`, parentId: `a`, label: `value` }], + `functional-projection`, + new Set(), + `source`, + overrides, + ) + checkInlineMetadataValues(expected, expected) + const hostile = [ + { + ...expected[0], + $key: `compiler-key`, + $collectionId: `facade`, + $origin: `remote`, + $hasPendingWrites: false, + }, + ] + expect(() => checkInlineMetadataValues(hostile, expected)).toThrow() + }, +) + +// The same nullable-boundary checker must reject lost facade metadata, enriched +// inline emptiness and a missing ordinary field on a complete source row. +test.each([ + `empty-facade-metadata`, + `empty-inline-enriched`, + `matched-field-missing`, +] as const)(`rejects a hostile nullable whole-row observation: %s`, (fault) => { + const expected = expectedNullableSelection( + fault === `matched-field-missing` + ? { id: `child`, label: `source` } + : undefined, + `source`, + `facade`, + `generated-key`, + ) + const observed = { + facade: structuredClone(expected.facade), + inline: [structuredClone(expected.inline)], + } + checkNullableSelection(observed, expected) + if (fault === `empty-facade-metadata`) observed.facade = [{}] + else if (fault === `empty-inline-enriched`) + observed.inline = [structuredClone(expected.facade)] + else + observed.facade = observed.facade.map((row) => + Object.fromEntries( + Object.entries(row).filter(([key]) => key !== `label`), + ), + ) + expect(() => checkNullableSelection(observed, expected)).toThrow() +}) From 971235c9fb5d29ed14788bbf2eaa568a6d037046 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:24:08 -0700 Subject: [PATCH 082/116] test(db): register lifecycle, bucket-facade, and cold-join oracle tests through oracleProperty Ordered lifecycle cells become ordered-work.lifecycle-* enumerated properties through the file's comparison adapter; the 192-history product declares each coordinate tuple as a required enumerated state. The bucket-facade and cold-join named histories replay through bucket-facade.rollback-fixed, cold-join.reconciliation-fixed, and cold-join.compound-fixed, whose calibrations keep the old witness histories and matchers. Grammar-size checks and model calibrations that run no production move to ordered-lifecycle-model.test.ts beside an extracted, test-free model module. Co-authored-by: Isaac --- .../converted-properties/db-query-ordered.ts | 15 + ...et-facade-rollback-oracle.property.test.ts | 221 ++-- .../cold-join-reconciliation-oracle.test.ts | 457 ++++--- .../query/ordered-lifecycle-model-oracle.ts | 254 ++++ .../query/ordered-lifecycle-model.test.ts | 138 +++ .../ordered-lifecycle-oracle.property.test.ts | 1079 ++++++++--------- 6 files changed, 1306 insertions(+), 858 deletions(-) create mode 100644 packages/db/tests/query/ordered-lifecycle-model-oracle.ts create mode 100644 packages/db/tests/query/ordered-lifecycle-model.test.ts diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/converted-properties/db-query-ordered.ts index 7c691ea1b2..9f9b0d3ad7 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/converted-properties/db-query-ordered.ts @@ -87,8 +87,23 @@ const lifecycleProperties: ReadonlyArray = [ `cold-join.equality-predicate`, `cold-join.reconciliation-fixed`, `cold-join.compound-fixed`, + `ordered-work.lifecycle-initial-settlement`, + `ordered-work.lifecycle-product`, + `ordered-work.lifecycle-initial-fallback`, + `ordered-work.lifecycle-initial-fallback-publication`, + `ordered-work.lifecycle-later-fallback`, + `ordered-work.lifecycle-later-fallback-cleanup`, + `ordered-work.lifecycle-nullable-cells`, + `ordered-work.lifecycle-nullable-directions`, + `ordered-work.lifecycle-full-source`, + `ordered-work.lifecycle-eager-full-source`, ] const lifecycleOwners: ReadonlyArray = [ + [ + `db/tests/query/ordered-lifecycle-oracle.property.test.ts`, + `ordered-work`, + `lifecycle-initial-settlement lifecycle-product lifecycle-initial-fallback lifecycle-initial-fallback-publication lifecycle-later-fallback lifecycle-later-fallback-cleanup lifecycle-nullable-cells lifecycle-nullable-directions lifecycle-full-source lifecycle-eager-full-source`, + ], [ `db/tests/query/bucket-facade-rollback-oracle.property.test.ts`, `bucket-facade`, 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 6a71a77397..5f6dc2daec 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,5 +1,5 @@ import fc from 'fast-check' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { stripVirtualProps } from '../utils.js' import { forwardAssertionJudgment, @@ -7,8 +7,7 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../oracle-assertion-adapter.js' -import { oracleProperty, oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { BUCKETS, Driver, @@ -805,53 +804,32 @@ 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 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({ - check: [runHistory, checkEvents], - model: [applyOps, layoutExpectation, shownLayoutExpectation, touchedIds], -}) +// 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. -describe(`bucket facade rollback`, () => { - // Keep the complete independent model and its comparisons at each flush. - // The shared adapter transports only authored assertion judgments; it does - // not classify production, setup, or cleanup exceptions as law violations. - oracleProperty({ - id: PROPERTY, - law: { - statement: `Failed facade flushes restore all written facades without unrelated row reads, events, or layout revisions; successful flushes publish pending changes once.`, - authority: `ARCHITECTURE.md Coherent publication and the three facade laws stated above.`, - limits: `The unchanged one-edge, four-bucket, one-to-eight-step grammar; held spanning commits keep their existing row-only comparison.`, - }, - owns: [ - 'src/query/live/bucket-facade-adapter.ts', - 'src/transactions.ts#createTransaction', - ], - histories: { - arbitrary: history, - runs: 150, - fixedSeeds: [2081], - states: [], - constructed: [], - }, - resolve: (steps) => ({ steps }), - model: recordedAssertionJudgment, - driver: (plan: { steps: Parameters[0] }, observe) => - recordAssertionJudgment(PROPERTY, plan, observe, () => - runHistory(plan.steps), - ), - check: forwardAssertionJudgment, - }) +// Shared optional fields of the last named history below. +const plainStep = { + throwPick: 0, + local: 0, + hold: 0, + holdFor: 0, + shareId: false, + settleLocal: true, +} +// Fixed histories and regressions of generated and CI failures. +const fixedRollbackHistories: Array<{ + name: string + steps: Parameters[0] +}> = [ // 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([ + { + name: `publishes a reorder on retry after a failed reordering flush`, + steps: [ { choices: [ { kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }, @@ -871,15 +849,14 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, throwPick: 0, }, - ]) - }) - + ], + }, // 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([ + { + name: `publishes a reorder sent again after a rolled-back reorder`, + steps: [ { choices: [ { kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }, @@ -899,15 +876,14 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, throwPick: 0, }, - ]) - }) - + ], + }, // 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([ + { + name: `publishes a pending facade change after a failed root commit`, + steps: [ { choices: [ { kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }, @@ -926,15 +902,14 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, throwPick: 0, }, - ]) - }) - + ], + }, // A retired facade can still show rows the graph never sent: an optimistic // 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([ + { + name: `retires a bucket whose facade shows an optimistic row`, + steps: [ { choices: [ { kind: 0, bucket: 0, id: 0, v: 1, rank: 1 }, @@ -949,14 +924,13 @@ describe(`bucket facade rollback`, () => { throwPick: 0, local: 0, }, - ]) - }) - + ], + }, // 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([ + { + name: `restores two written buckets and a retired bucket, then retries`, + steps: [ { choices: [ { kind: 0, bucket: 0, id: 0, v: 1, rank: 1 }, @@ -983,14 +957,13 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, throwPick: 0, }, - ]) - }) - + ], + }, // 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([ + { + name: `updates a row that a persisting transaction still holds`, + steps: [ { choices: [{ kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }], outcome: `publish`, @@ -1008,15 +981,14 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, throwPick: 0, }, - ]) - }) - + ], + }, // 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([ + { + name: `restores a held row after a rolled-back update`, + steps: [ { choices: [{ kind: 0, bucket: 3, id: 0, v: 0, rank: 0 }], outcome: `publish`, @@ -1037,37 +1009,28 @@ describe(`bucket facade rollback`, () => { outcome: `rollback`, throwPick: 0, }, - ]) - }) - + ], + }, // 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, - hold: 0, - holdFor: 0, - shareId: false, - settleLocal: true, - } - await runHistory([ + { + name: `publishes nothing from a failed flush after a shared optimistic row leaves`, + steps: [ { - ...plain, + ...plainStep, cleanup: 0, choices: [{ kind: 0, bucket: 1, id: 0, v: 0, rank: 0 }], outcome: `publish`, }, { - ...plain, + ...plainStep, cleanup: 0, choices: [{ kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }], outcome: `publish`, }, { - ...plain, + ...plainStep, shareId: true, choices: [ { kind: 0, bucket: 0, id: 2, v: 0, rank: 0 }, @@ -1076,11 +1039,69 @@ describe(`bucket facade rollback`, () => { outcome: `publish`, }, { - ...plain, + ...plainStep, cleanup: 1, choices: [{ kind: 0, bucket: 0, id: 0, v: 0, rank: 0 }], outcome: `throwWrite`, }, - ]) + ], + }, +] + +describe(`bucket facade rollback`, () => { + // Keep the complete independent model and its comparisons at each flush. + // The shared adapter transports only authored assertion judgments; it does + // not classify production, setup, or cleanup exceptions as law violations. + oracleProperty({ + id: PROPERTY, + law: { + statement: `Failed facade flushes restore all written facades without unrelated row reads, events, or layout revisions; successful flushes publish pending changes once.`, + authority: `ARCHITECTURE.md Coherent publication and the three facade laws stated above.`, + limits: `The unchanged one-edge, four-bucket, one-to-eight-step grammar; held spanning commits keep their existing row-only comparison.`, + }, + owns: [ + 'src/query/live/bucket-facade-adapter.ts', + 'src/transactions.ts#createTransaction', + ], + histories: { + arbitrary: history, + runs: 150, + fixedSeeds: [2081], + states: [], + constructed: [], + }, + resolve: (steps) => ({ steps }), + model: recordedAssertionJudgment, + driver: (plan: { steps: Parameters[0] }, observe) => + recordAssertionJudgment(PROPERTY, plan, observe, () => + runHistory(plan.steps), + ), + check: forwardAssertionJudgment, + }) + + // The named histories above lie outside the generated grammar: they leave + // out optional step fields, which the grammar's records always hold. Each + // replays through `runHistory`, the shared driver and comparison, under its + // old test name. + oracleProperty({ + id: `bucket-facade.rollback-fixed`, + law: { + statement: `Failed facade flushes restore all written facades without unrelated row reads, events, or layout revisions; successful flushes publish pending changes once.`, + authority: `ARCHITECTURE.md Coherent publication and the three facade laws stated above.`, + limits: `Named regressions that the generated step grammar cannot take back; each replays through the same driver and comparison.`, + }, + owns: [ + 'src/query/live/bucket-facade-adapter.ts', + 'src/transactions.ts#createTransaction', + ], + histories: fixedRollbackHistories, + label: ({ name }) => name, + resolve: ({ steps }) => ({ steps }), + model: recordedAssertionJudgment, + driver: (plan: { steps: Parameters[0] }, observe) => + recordAssertionJudgment(PROPERTY, plan, observe, () => + runHistory(plan.steps), + ), + check: forwardAssertionJudgment, }) }) 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 44fd1d06ba..95f62e1b05 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -1,6 +1,7 @@ +import { isDeepStrictEqual } from 'node:util' import fc from 'fast-check' import { Temporal } from 'temporal-polyfill' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { and, createLiveQueryCollection, eq } from '../../src/query/index.js' @@ -9,9 +10,9 @@ import { oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from '../oracle-assertion-adapter.js' -import { oracleProperty, oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { runTrace } from '../trace-runner.js' import { checkWithCleanup, @@ -384,80 +385,108 @@ function expectEqualityJoinMatchesPredicate( ) } -describe.each([`off`, `eager`] as const)( - `join equality oracle with auto-index %s`, - (autoIndex) => { - 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}`, - [{ id: 1, value: leftValue }], - autoIndex, - ) - const rightRows = [ - { id: 10, value: rightValue }, - { id: 20, value: other }, - ] - const right = createEqualitySource( - `equality-right-${label}`, - rightRows, - autoIndex, - ) - const predicateRight = createEqualitySource( - `equality-predicate-${label}`, - rightRows, - autoIndex, - ) - const joined = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ left }) - .innerJoin({ right }, ({ left: l, right: r }) => - eq(l.value, r.value), - ) - .select(({ right: row }) => ({ id: row.id })), - }) - const filtered = createLiveQueryCollection({ - startSync: true, - query: (q) => - q - .from({ right: predicateRight }) - .where(({ right: row }) => eq(row.value, leftValue)) - .select(({ right: row }) => ({ id: row.id })), - }) +// The production driver of one equality-join case: an equality join and an +// equality predicate over the same values, compared by +// `expectEqualityJoinMatchesPredicate`. +async function runEqualityJoinCase( + autoIndex: EqualityMode, + { label, createValues }: (typeof equalityJoinCases)[number], +): Promise { + const { left: leftValue, right: rightValue, other } = createValues() + const left = createEqualitySource( + `equality-left-${label}`, + [{ id: 1, value: leftValue }], + autoIndex, + ) + const rightRows = [ + { id: 10, value: rightValue }, + { id: 20, value: other }, + ] + const right = createEqualitySource( + `equality-right-${label}`, + rightRows, + autoIndex, + ) + const predicateRight = createEqualitySource( + `equality-predicate-${label}`, + rightRows, + autoIndex, + ) + const joined = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ left }) + .innerJoin({ right }, ({ left: l, right: r }) => eq(l.value, r.value)) + .select(({ right: row }) => ({ id: row.id })), + }) + const filtered = createLiveQueryCollection({ + startSync: true, + query: (q) => + q + .from({ right: predicateRight }) + .where(({ right: row }) => eq(row.value, leftValue)) + .select(({ right: row }) => ({ id: row.id })), + }) - await checkWithCleanup( - async () => { - await Promise.all([joined.preload(), filtered.preload()]) - const expected = [{ id: 10 }] - expect(joined.toArray, `${label} join result`).toMatchObject( - expected, - ) - expect(filtered.toArray, `${label} predicate result`).toMatchObject( - expected, - ) - expectEqualityJoinMatchesPredicate(joined, filtered) - if (autoIndex === `eager`) - expect( - right.indexes.size, - `${label} join auto-index reach`, - ).toBeGreaterThan(0) - else expect(right.indexes.size, `${label} join scan path`).toBe(0) - }, - joined, - filtered, - predicateRight, - right, - left, - ) - }, - ) + await checkWithCleanup( + async () => { + await Promise.all([joined.preload(), filtered.preload()]) + const expected = [{ id: 10 }] + expect(joined.toArray, `${label} join result`).toMatchObject(expected) + expect(filtered.toArray, `${label} predicate result`).toMatchObject( + expected, + ) + expectEqualityJoinMatchesPredicate(joined, filtered) + if (autoIndex === `eager`) + expect( + right.indexes.size, + `${label} join auto-index reach`, + ).toBeGreaterThan(0) + else expect(right.indexes.size, `${label} join scan path`).toBe(0) + }, + joined, + filtered, + predicateRight, + right, + left, + ) +} + +// The rows of `equalityJoinCases` create their values, so a history names its +// case by label. +const equalityJoinCasesByLabel = new Map< + string, + (typeof equalityJoinCases)[number] +>(equalityJoinCases.map((row) => [row.label, row])) +oracleProperty({ + id: `cold-join.equality-predicate`, + law: { + statement: `A compiled equality join publishes the same rows as an equality predicate over the same value, on the scan and auto-index paths.`, + authority: `ARCHITECTURE.md Identity; the established predicate equality classes stated above.`, + limits: `The finite equality-class cases above, each with auto-indexing off and eager, at preload.`, }, -) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: ([`off`, `eager`] as const).flatMap((autoIndex) => + equalityJoinCases.map(({ label }) => ({ autoIndex, label })), + ), + label: ({ autoIndex, label }) => + `join equality oracle with auto-index ${autoIndex}: ${label} joins agree with equality predicates`, + resolve: ({ autoIndex, label }) => ({ + autoIndex, + row: equalityJoinCasesByLabel.get(label)!, + }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`cold-join.equality-predicate`, plan, observe, () => + runEqualityJoinCase(plan.autoIndex, plan.row), + ), + check: forwardAssertionJudgment, +}) const history: ReadonlyArray = [ { type: `delete`, id: 10 }, @@ -475,13 +504,6 @@ const history: ReadonlyArray = [ // fresh insertion. The bounded tail uses keys 10/20/30/40, parent IDs 1/2, // 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, - ).toBeGreaterThanOrEqual(2) -}) const coldJoinProperty = `cold-join.reconciliation` // The original random tail follows the same fixed route-move prefix. The // assertion adapter retains the complete recomputation and event-replica check. @@ -524,17 +546,61 @@ oracleProperty({ }), check: forwardAssertionJudgment, }) -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`, - cause: { name: `AssertionError` }, - }) +// The fixed route-move prefix alone: it must delete at least twice, and an +// observation mutant of the real trace checker must fail it. +async function runColdJoinPrefix(): Promise { + const batches = await runColdJoin(history) + expect( + batches.flat().filter((change) => change.type === `delete`).length, + ).toBeGreaterThanOrEqual(2) +} +oracleProperty({ + id: `cold-join.reconciliation-fixed`, + law: { + statement: `Cold acquisition and every applied child change agree with independent relational recomputation and the event replica.`, + authority: `ARCHITECTURE.md Identity and Initial demand; the existing cold-join law above.`, + limits: `The fixed route-move prefix with no tail, which must reach deletion twice.`, }, -) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + `reconciles cold join acquisition, deletion, restoration and route moves`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coldJoinProperty, plan, observe, () => + runColdJoinPrefix(), + ), + check: forwardAssertionJudgment, + // Each mutant must fail the real trace checker with a trace assertion + // whose cause is an assertion failure, exactly as its old witness required. + calibrations: ( + [`hide-acquisition`, `drop-delete`, `wrong-result`] as const + ).map((mutant) => ({ + name: `rejects ${mutant} through the real cold join trace checker`, + law: coldJoinProperty, + driver: (plan: { name: string }, observe) => + recordAssertionJudgment(coldJoinProperty, plan, observe, async () => { + try { + await runColdJoin(history, mutant) + } catch (error) { + if ( + error instanceof Error && + error.name === `TraceAssertionError` && + error.cause instanceof Error && + error.cause.name === `AssertionError` + ) + rejectAt(`calibration ${mutant}`, error) + throw error + } + }), + })), +}) /** * Compound joins extend the same equality law to a nonempty conjunction. Every @@ -868,10 +934,6 @@ async function runCompoundHistory( * the compound nested-loop model. Each driver builds its own expectation from * the scenario it runs, so the caller supplies inputs, not expected rows. */ -declareOracle({ - 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 fixed scenario @@ -906,97 +968,146 @@ const compoundWitness: CompoundHistory = { cold: false, } -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 }], - right: [{ id: 2, atom: 10, b: 7, c: 9 }], - steps: [], - join: `left`, - autoIndex, - atomFirst, - cold: true, - }) - }) - } - } - +// Named compound histories, which the generated grammar cannot take back: its +// rows hold only ids 0 to 3, and the witness holds ids up to 31. Each replays +// through `runCompoundHistory` under its old test name. +const compoundFixedHistories: Array<{ + name: string + scenario: CompoundHistory +}> = [ + ...([`off`, `eager`] as const).flatMap((autoIndex) => + [false, true].map((atomFirst) => ({ + name: `loads the matching compound tuple: index=${autoIndex}, atomFirst=${atomFirst}`, + scenario: { + ...compoundWitness, + left: [{ id: 1, atom: 10, b: 7, c: 9 }], + right: [{ id: 2, atom: 10, b: 7, c: 9 }], + steps: [], + join: `left` as const, + autoIndex, + atomFirst, + cold: true, + }, + })), + ), // A nullish component makes the whole tuple unsatisfiable, regardless of // term order. Such a row may need no acquisition. The nonnull neighbor must // acquire even when the empty right source gives the same unmatched output. - describe(`compound demand premise`, () => { - for (const autoIndex of [`off`, `eager`] as const) { - for (const atomFirst of [false, true]) { - 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 }], - right: [], - steps: [], - join: `left`, - width: 2, - atomFirst, - autoIndex, - cold: true, - }) - }) - } - } - } - } - }) - it.each([ + ...([`off`, `eager`] as const).flatMap((autoIndex) => + [false, true].flatMap((atomFirst) => + [0, 21].flatMap((atom) => + [null, 0].map((b) => ({ + name: `compound demand premise: checks nullish and satisfiable tuples: index=${autoIndex}, atomFirst=${atomFirst}, atom=${atom}, b=${b}`, + scenario: { + ...compoundWitness, + left: [{ id: 0, atom, b, c: 0 }], + right: [], + steps: [], + join: `left` as const, + width: 2 as const, + atomFirst, + autoIndex, + cold: true, + }, + })), + ), + ), + ), + ...[ { name: `distinct objects`, leftAtom: 11, rightAtom: 13 }, { name: `opposite infinities`, leftAtom: 6, rightAtom: 7 }, { 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({ + ].map(({ name, leftAtom, rightAtom }) => ({ + name: `preserves compound ${name} equality`, + scenario: { ...compoundWitness, atomFirst: false, left: [{ id: 1, atom: leftAtom, b: 1, c: 1 }], right: [{ id: 2, atom: rightAtom, b: 1, c: 1 }], steps: [], - }) - }) - 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 }) - }) - } - } - it.each([`off`, `eager`] as const)( - `rejects a hidden acquisition for a satisfiable tuple, index=%s`, - async (autoIndex) => { - oracleRoute(`calibration`) - await expect( - runCompoundHistory( - { - ...compoundWitness, - left: [{ id: 0, atom: 0, b: 0, c: 0 }], - right: [], - steps: [], - join: `left`, - width: 2, - atomFirst: false, - autoIndex, - cold: true, + }, + })), + ...([`inner`, `left`, `right`, `full`] as const).flatMap((join) => + [false, true].map((cold) => ({ + name: `preserves all equality classes through ${join} histories, cold=${cold}`, + scenario: { ...compoundWitness, join, cold }, + })), + ), +] + +// The satisfiable tuple that a hidden acquisition must not pass, as its old +// witness built it. +const hiddenAcquisitionWitness = ( + autoIndex: EqualityMode, +): CompoundHistory => ({ + ...compoundWitness, + left: [{ id: 0, atom: 0, b: 0, c: 0 }], + right: [], + steps: [], + join: `left`, + width: 2, + atomFirst: false, + autoIndex, + cold: true, +}) + +describe(`compound join relational oracle`, () => { + oracleProperty({ + id: `cold-join.compound-fixed`, + law: { + statement: `Every component equality is TRUE for a compound match, with exact unmatched rows and event multiplicity for the selected join.`, + authority: `ARCHITECTURE.md Identity and Initial demand; the compound relational law above.`, + limits: `Named histories that the generated grammar cannot take back: the matching, nullish, equality-class, and all-class witnesses above.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: compoundFixedHistories, + label: ({ name }) => name, + resolve: ({ scenario }) => ({ scenario }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`cold-join.compound`, plan, observe, () => + runCompoundHistory(plan.scenario), + ), + check: forwardAssertionJudgment, + // A hidden acquisition must fail the acquisition comparison on the + // satisfiable tuple its old witness used, with the old message. + calibrations: ([`off`, `eager`] as const).map((autoIndex) => ({ + name: `rejects a hidden acquisition for a satisfiable tuple, index=${autoIndex}`, + law: `cold-join.compound`, + driver: (plan: { scenario: CompoundHistory }, observe) => + recordAssertionJudgment( + `cold-join.compound`, + plan, + observe, + async () => { + const witness = isDeepStrictEqual( + plan.scenario, + hiddenAcquisitionWitness(autoIndex), + ) + try { + await runCompoundHistory( + plan.scenario, + witness ? `hide-acquisition` : undefined, + ) + } catch (error) { + if ( + witness && + error instanceof Error && + error.message.includes(`cold source acquisition`) + ) + rejectAt(`calibration hide-acquisition ${autoIndex}`, error) + throw error + } }, - `hide-acquisition`, ), - ).rejects.toThrow(`cold source acquisition`) - }, - ) + })), + }) + const property = `cold-join.compound` const rowArbitrary = fc.record({ id: fc.integer({ min: 0, max: 3 }), diff --git a/packages/db/tests/query/ordered-lifecycle-model-oracle.ts b/packages/db/tests/query/ordered-lifecycle-model-oracle.ts new file mode 100644 index 0000000000..f3bfde2dd6 --- /dev/null +++ b/packages/db/tests/query/ordered-lifecycle-model-oracle.ts @@ -0,0 +1,254 @@ +/** + * The finite grammars, the nullable-order model and its palette, and the + * initial-settlement model and check of + * `ordered-lifecycle-oracle.property.test.ts`, moved unchanged so that the + * grammar and model checks in `ordered-lifecycle-model.test.ts` can read + * them. That oracle file states the law and its authority. This module + * registers no tests. + */ +import { isDeepStrictEqual } from 'node:util' +import { expect } from 'vitest' +import { fc } from '@fast-check/vitest' +import { fullSourceFeatures } from './ordered-lifecycle-support.js' +import type { + InitialSettlementObservation, + InitialSettlementShape, +} from './ordered-lifecycle-support.js' + +export type OrderTerm = { direction: `asc` | `desc`; nulls: `first` | `last` } +export type AcquisitionPath = `page` | `prefix` | `boundary` | `full-source` +// The controlled providerResponse gate models a provider response, not the +// loadSubset result. Both delivery schedules await establishing applied receipts +// before acquisition fulfillment; only their order around this response differs. +export type Scenario = { + acquisitionPath: AcquisitionPath + delivery: `before-provider-response` | `after-provider-response` + window: `keep` | `widen` + outcome: `resolve` | `reject` | `abort-error` + syncRun: `retain` | `restart` + barrier: `initial` | `replay` + rankOffset?: number + rankStep?: number + nullable?: { primary: OrderTerm; secondary: OrderTerm } + repair?: boolean +} + +export function compareNullable( + left: unknown, + right: unknown, + term: OrderTerm, +): number { + if (left === right) return 0 + if (left === null) return term.nulls === `first` ? -1 : 1 + if (right === null) return term.nulls === `first` ? 1 : -1 + if (typeof left !== `number` || typeof right !== `number`) + throw new Error(`numeric/null palette only`) + return (left < right ? -1 : 1) * (term.direction === `asc` ? 1 : -1) +} + +// This fixed nine-row palette is a second formulation of the nullable order. +// Primary groups are null, -2, +2; each group has secondary null, +2, -2. +// The table states the expected group order without calling compareNullable. +// Each unique ID occurs once. This checks the full ID order; the warm zero-window +// witness and lifecycle result checks own emptiness and row payloads. +export function expectedNullablePaletteIds( + primary: OrderTerm, + secondary: OrderTerm, +): Array { + const primaryGroups = { + 'first/asc': [0, 1, 2], + 'first/desc': [0, 2, 1], + 'last/asc': [1, 2, 0], + 'last/desc': [2, 1, 0], + } as const + const secondaryPositions = { + 'first/asc': [0, 2, 1], + 'first/desc': [0, 1, 2], + 'last/asc': [2, 1, 0], + 'last/desc': [1, 2, 0], + } as const + return primaryGroups[`${primary.nulls}/${primary.direction}`].flatMap( + (group) => + secondaryPositions[`${secondary.nulls}/${secondary.direction}`].map( + (position) => 3 * group + position + 1, + ), + ) +} + +/** + * The nullable reference order, compareNullable over the nine-row palette with + * ties by ID, must list IDs in the order that expectedNullablePaletteIds states + * for these term directions and null placements. The palette is written as a + * table, so this comparison is the refinement check of the reference against it. + */ +export function expectNullableOrderMatchesPalette( + primary: OrderTerm, + secondary: OrderTerm, +): void { + const rows = Array.from({ length: 9 }, (_, index) => ({ + id: index + 1, + primary: [null, -2, 2][Math.floor(index / 3)]!, + secondary: [null, 2, -2][index % 3]!, + })) + const actual = [...rows] + .sort( + (a, b) => + compareNullable(a.primary, b.primary, primary) || + compareNullable(a.secondary, b.secondary, secondary) || + a.id - b.id, + ) + .map(({ id }) => id) + expect(actual).toEqual(expectedNullablePaletteIds(primary, secondary)) +} + +export const acquisitionPaths: ReadonlyArray = [ + `page`, + `prefix`, + `boundary`, + `full-source`, +] + +export function expectedInitialSettlementObservation( + settlement: InitialSettlementShape, +): InitialSettlementObservation { + return settlement === `synchronous` + ? { rows: [1, 2], status: `ready` } + : { rows: [], status: `loading` } +} + +export function assertInitialSettlementObservation( + observed: InitialSettlementObservation, + settlement: InitialSettlementShape, +): void { + const expected = expectedInitialSettlementObservation(settlement) + if (!isDeepStrictEqual(observed, expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${JSON.stringify(observed)}`, + ) + } +} + +export const initialSettlementCells = ([`eager`, `off`] as const).flatMap( + (autoIndex) => + ([`synchronous`, `promise`] as const).map((settlement) => ({ + autoIndex, + settlement, + })), +) + +export const lifecycleProductCells: Array = acquisitionPaths.flatMap( + (acquisitionPath) => + ([`before-provider-response`, `after-provider-response`] as const).flatMap( + (delivery) => + ([`keep`, `widen`] as const).flatMap((window) => + ([`resolve`, `reject`, `abort-error`] as const).flatMap((outcome) => + ([`retain`, `restart`] as const).flatMap((syncRun) => + ([`initial`, `replay`] as const).map((barrier) => ({ + acquisitionPath, + delivery, + window, + outcome, + syncRun, + barrier, + })), + ), + ), + ), + ), +) + +export const initialFallbackCells = ([`eager`, `off`] as const).flatMap( + (autoIndex) => + ([`synchronous`, `promise`] as const).flatMap((settlement) => + ([`distinct`, `tied`] as const).map((order) => ({ + autoIndex, + settlement, + order, + })), + ), +) + +export const nullableLifecycleCells: Array = ( + [`first`, `last`] as const +) + .flatMap((primaryNulls) => + ([`first`, `last`] as const).flatMap((secondaryNulls) => + ([`prefix`, `boundary`] as const).flatMap((acquisitionPath) => + ([`resolve`, `repair`, `restart`] as const).map( + (mode) => + ({ + nullable: { + primary: { direction: `asc`, nulls: primaryNulls }, + secondary: { direction: `desc`, nulls: secondaryNulls }, + }, + acquisitionPath: + acquisitionPath === `boundary` && primaryNulls === `first` + ? `full-source` + : acquisitionPath, + delivery: `before-provider-response`, + window: `widen`, + outcome: mode === `repair` ? `reject` : `resolve`, + syncRun: mode === `restart` ? `restart` : `retain`, + barrier: mode === `repair` ? `replay` : `initial`, + repair: mode === `repair`, + }) as const, + ), + ), + ), + ) + // Null-leading completion acquires full source and retires its prefix. + // Replay can hold that full demand, but cannot target the retired prefix. + .filter( + (scenario) => + !( + scenario.nullable.primary.nulls === `first` && + scenario.acquisitionPath === `prefix` && + scenario.repair + ), + ) +export const nullableDirectionCells = ([`asc`, `desc`] as const).flatMap( + (primaryDirection) => + ([`asc`, `desc`] as const).map((secondaryDirection) => ({ + primaryDirection, + secondaryDirection, + })), +) + +export const nullableLifecycleArbitrary = fc + .record({ + scenario: fc.constantFrom(...nullableLifecycleCells), + primaryDirection: fc.constantFrom(`asc` as const, `desc` as const), + secondaryDirection: fc.constantFrom(`asc` as const, `desc` as const), + rankOffset: fc.integer({ min: -20, max: 20 }), + rankStep: fc.integer({ min: 1, max: 5 }), + }) + .map( + ({ + scenario, + primaryDirection, + secondaryDirection, + rankOffset, + rankStep, + }): Scenario => ({ + ...scenario, + rankOffset, + rankStep, + nullable: { + primary: { + ...scenario.nullable!.primary, + direction: primaryDirection, + }, + secondary: { + ...scenario.nullable!.secondary, + direction: secondaryDirection, + }, + }, + }), + ) + +export const fullSourceSettlementCells = fullSourceFeatures.flatMap((feature) => + ([`synchronous`, `promise`] as const).map((settlement) => ({ + feature, + settlement, + })), +) diff --git a/packages/db/tests/query/ordered-lifecycle-model.test.ts b/packages/db/tests/query/ordered-lifecycle-model.test.ts new file mode 100644 index 0000000000..0d0f961ca3 --- /dev/null +++ b/packages/db/tests/query/ordered-lifecycle-model.test.ts @@ -0,0 +1,138 @@ +// Grammar and model checks for ordered-lifecycle-oracle.property.test.ts. +// They run no production: each finite grammar names each cell once, the +// nullable-order model agrees with its independent palette table, the +// initial-settlement check rejects the old Promise-wrapped observation, and +// the fixed-seed nullable grammar generates every value and lifecycle +// dimension. The law and the production comparisons are in that oracle file; +// the grammars, models, and checks are in ordered-lifecycle-model-oracle.ts. +import { describe, expect, it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed } from '../oracle-config.js' +import { + assertInitialSettlementObservation, + expectNullableOrderMatchesPalette, + expectedInitialSettlementObservation, + fullSourceSettlementCells, + initialFallbackCells, + initialSettlementCells, + lifecycleProductCells, + nullableDirectionCells, + nullableLifecycleArbitrary, + nullableLifecycleCells, +} from './ordered-lifecycle-model-oracle.js' + +describe(`synchronous initial settlement refinement`, () => { + it(`enumerates every initial path and settlement cell exactly once`, () => { + expect(initialSettlementCells).toHaveLength(4) + expect( + new Set(initialSettlementCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(4) + }) + + it(`rejects the old Promise-wrapped observation at the synchronous checkpoint`, () => { + expect(() => + assertInitialSettlementObservation( + expectedInitialSettlementObservation(`promise`), + `synchronous`, + ), + ).toThrow() + }) +}) + +describe(`ordered lifecycle product`, () => { + it(`keeps all 192 declared histories distinct`, () => { + expect(lifecycleProductCells).toHaveLength(192) + expect( + new Set(lifecycleProductCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(192) + }) +}) + +describe(`initial locale-string fallback oracle`, () => { + it(`enumerates every index, settlement, and row-order cell exactly once`, () => { + expect(initialFallbackCells).toHaveLength(8) + expect( + new Set(initialFallbackCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(8) + }) +}) + +describe(`nullable multi-term lifecycle product`, () => { + it(`anchors the nullable reference and controlled provider to an independent order palette`, () => { + for (const primaryNulls of [`first`, `last`] as const) + for (const primaryDirection of [`asc`, `desc`] as const) + for (const secondaryNulls of [`first`, `last`] as const) + for (const secondaryDirection of [`asc`, `desc`] as const) { + const primary = { + nulls: primaryNulls, + direction: primaryDirection, + } + const secondary = { + nulls: secondaryNulls, + direction: secondaryDirection, + } + expectNullableOrderMatchesPalette(primary, secondary) + } + }) + + it(`enumerates every nullable lifecycle and direction-overlay cell exactly once`, () => { + expect(nullableLifecycleCells).toHaveLength(22) + expect( + new Set(nullableLifecycleCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(22) + expect( + nullableLifecycleCells.some( + (scenario) => + scenario.nullable?.primary.nulls === `first` && + scenario.acquisitionPath === `prefix` && + scenario.repair, + ), + ).toBe(false) + expect(nullableDirectionCells).toHaveLength(4) + expect( + new Set(nullableDirectionCells.map((cell) => JSON.stringify(cell))).size, + ).toBe(4) + }) + + it(`generates the nullable value and lifecycle dimensions`, () => { + const sample = fc.sample( + nullableLifecycleArbitrary, + oracleFixedSeed(93472, { numRuns: 100 }), + ) + expect(new Set(sample.map((scenario) => scenario.acquisitionPath))).toEqual( + new Set([`prefix`, `boundary`, `full-source`]), + ) + expect( + new Set(sample.map((scenario) => scenario.nullable!.primary.nulls)), + ).toEqual(new Set([`first`, `last`])) + expect( + new Set(sample.map((scenario) => scenario.nullable!.secondary.nulls)), + ).toEqual(new Set([`first`, `last`])) + expect( + new Set(sample.map((scenario) => scenario.nullable!.primary.direction)), + ).toEqual(new Set([`asc`, `desc`])) + expect( + new Set(sample.map((scenario) => scenario.nullable!.secondary.direction)), + ).toEqual(new Set([`asc`, `desc`])) + expect( + new Set( + sample.map( + (scenario) => + `${scenario.nullable!.primary.direction}/${scenario.nullable!.secondary.direction}`, + ), + ), + ).toEqual(new Set([`asc/asc`, `asc/desc`, `desc/asc`, `desc/desc`])) + expect(sample.some((scenario) => scenario.repair)).toBe(true) + expect(sample.some((scenario) => scenario.syncRun === `restart`)).toBe(true) + }) +}) + +describe(`synchronous initial full-source settlement refinement`, () => { + it(`enumerates every full-source feature and settlement cell exactly once`, () => { + expect(fullSourceSettlementCells).toHaveLength(8) + expect( + new Set(fullSourceSettlementCells.map((cell) => JSON.stringify(cell))) + .size, + ).toBe(8) + }) +}) 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 c986aa9618..39c50d261c 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { createLiveQueryCollection, @@ -13,18 +13,25 @@ import { createDeferred } from '../../src/deferred.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { evaluateReferenceExpression } from '../reference-expression-oracle.js' import { captureMicrotaskErrors, flushPromises } from '../utils.js' -import { - oracleFixedSeed, - oracleProperty, - oracleRoute, - readOracleRunConfig, -} from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty, readOracleRunConfig } from '../oracle-config.js' import { compareAt, observeComparison, recordedComparison, + rejectAt, } from './ordered-comparison-oracle.js' +import { + acquisitionPaths, + assertInitialSettlementObservation, + compareNullable, + fullSourceSettlementCells, + initialFallbackCells, + initialSettlementCells, + lifecycleProductCells, + nullableDirectionCells, + nullableLifecycleArbitrary, + nullableLifecycleCells, +} from './ordered-lifecycle-model-oracle.js' import { assertFullSourceRequest, finishOracleCleanup, @@ -38,6 +45,8 @@ import type { InitialSettlementShape, } from './ordered-lifecycle-support.js' import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' +import type { Observation } from '../oracle-config.js' +import type { Scenario } from './ordered-lifecycle-model-oracle.js' /** * # Which ordered request histories are distinct? @@ -118,99 +127,30 @@ type Row = { secondary: number | null version: number } -type OrderTerm = { direction: `asc` | `desc`; nulls: `first` | `last` } -type AcquisitionPath = `page` | `prefix` | `boundary` | `full-source` -// The controlled providerResponse gate models a provider response, not the -// loadSubset result. Both delivery schedules await establishing applied receipts -// before acquisition fulfillment; only their order around this response differs. -type Scenario = { - acquisitionPath: AcquisitionPath - delivery: `before-provider-response` | `after-provider-response` - window: `keep` | `widen` - outcome: `resolve` | `reject` | `abort-error` - syncRun: `retain` | `restart` - barrier: `initial` | `replay` - rankOffset?: number - rankStep?: number - nullable?: { primary: OrderTerm; secondary: OrderTerm } - repair?: boolean -} -function compareNullable( - left: unknown, - right: unknown, - term: OrderTerm, -): number { - if (left === right) return 0 - if (left === null) return term.nulls === `first` ? -1 : 1 - if (right === null) return term.nulls === `first` ? 1 : -1 - if (typeof left !== `number` || typeof right !== `number`) - throw new Error(`numeric/null palette only`) - return (left < right ? -1 : 1) * (term.direction === `asc` ? 1 : -1) -} +// The grammars, the nullable-order model, and the initial-settlement model and +// check live in ordered-lifecycle-model-oracle.ts; their grammar and model +// checks, which run no production, are in ordered-lifecycle-model.test.ts. -// This fixed nine-row palette is a second formulation of the nullable order. -// Primary groups are null, -2, +2; each group has secondary null, +2, -2. -// The table states the expected group order without calling compareNullable. -// Each unique ID occurs once. This checks the full ID order; the warm zero-window -// witness and lifecycle result checks own emptiness and row payloads. -function expectedNullablePaletteIds( - primary: OrderTerm, - secondary: OrderTerm, -): Array { - const primaryGroups = { - 'first/asc': [0, 1, 2], - 'first/desc': [0, 2, 1], - 'last/asc': [1, 2, 0], - 'last/desc': [2, 1, 0], - } as const - const secondaryPositions = { - 'first/asc': [0, 2, 1], - 'first/desc': [0, 1, 2], - 'last/asc': [2, 1, 0], - 'last/desc': [1, 2, 0], - } as const - return primaryGroups[`${primary.nulls}/${primary.direction}`].flatMap( - (group) => - secondaryPositions[`${secondary.nulls}/${secondary.direction}`].map( - (position) => 3 * group + position + 1, - ), - ) -} - -/** - * The nullable reference order, compareNullable over the nine-row palette with - * ties by ID, must list IDs in the order that expectedNullablePaletteIds states - * for these term directions and null placements. The palette is written as a - * table, so this comparison is the refinement check of the reference against it. - */ -function expectNullableOrderMatchesPalette( - primary: OrderTerm, - secondary: OrderTerm, -): void { - const rows = Array.from({ length: 9 }, (_, index) => ({ - id: index + 1, - primary: [null, -2, 2][Math.floor(index / 3)]!, - secondary: [null, 2, -2][index % 3]!, - })) - const actual = [...rows] - .sort( - (a, b) => - compareNullable(a.primary, b.primary, primary) || - compareNullable(a.secondary, b.secondary, secondary) || - a.id - b.id, - ) - .map(({ id }) => id) - expect(actual).toEqual(expectedNullablePaletteIds(primary, secondary)) +// Registration for the enumerated cells below. The existing judgment marks its +// comparisons with compareAt; the adapter forwards only such a comparison as +// a verdict, and keeps any other error as a driver error. Each history is +// wrapped in a plan object, which keys that judgment. +function lifecycleJudgment( + judge: (history: THistory) => Promise, +) { + return { + resolve: (history: THistory) => ({ history }), + model: ( + plan: { history: THistory }, + observations: ReadonlyArray, + ) => recordedComparison(plan, observations), + driver: ( + plan: { history: THistory }, + observe: (checkpoint: string, value: unknown) => void, + ) => observeComparison(plan, observe, () => judge(plan.history)), + } } - -const acquisitionPaths: ReadonlyArray = [ - `page`, - `prefix`, - `boundary`, - `full-source`, -] - async function observeHistory( scenario: Scenario, fault?: `secondary-order` | `null-placement`, @@ -881,26 +821,6 @@ async function assertHistory( type InitialAcquisitionPath = `page` | `prefix` -function expectedInitialSettlementObservation( - settlement: InitialSettlementShape, -): InitialSettlementObservation { - return settlement === `synchronous` - ? { rows: [1, 2], status: `ready` } - : { rows: [], status: `loading` } -} - -function assertInitialSettlementObservation( - observed: InitialSettlementObservation, - settlement: InitialSettlementShape, -): void { - const expected = expectedInitialSettlementObservation(settlement) - if (!isDeepStrictEqual(observed, expected)) { - throw new Error( - `Expected ${JSON.stringify(expected)}, received ${JSON.stringify(observed)}`, - ) - } -} - function observeInitialAcquisitionPath( requests: ReadonlyArray, ): InitialAcquisitionPath { @@ -1024,92 +944,94 @@ async function observeInitialSettlement( } describe(`synchronous initial settlement refinement`, () => { - const cells = ([`eager`, `off`] as const).flatMap((autoIndex) => - ([`synchronous`, `promise`] as const).map((settlement) => ({ - autoIndex, - settlement, - })), - ) - - 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) - }) - - 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({ - rows: [1, 2], - status: `ready`, - }) - expect(observeInitialAcquisitionPath(observed.requests)).toBe( - autoIndex === `eager` ? `page` : `prefix`, - ) - assertInitialBoundaryContinuation(observed.requests) - expect(observed.releases).toHaveLength(observed.requests.length) - observed.requests.forEach((request, index) => { - expect(observed.releases[index]).toBe(request) - }) + // The production driver of one initial-settlement cell, compared with + // `expectedInitialSettlementObservation` at the synchronous checkpoint. + async function runInitialSettlementCell({ + settlement, + autoIndex, + }: (typeof initialSettlementCells)[number]): Promise { + const observed = await observeInitialSettlement(settlement, autoIndex) + assertInitialSettlementObservation(observed.immediate, settlement) + expect(observed.settled).toEqual({ + rows: [1, 2], + status: `ready`, + }) + expect(observeInitialAcquisitionPath(observed.requests)).toBe( + autoIndex === `eager` ? `page` : `prefix`, + ) + assertInitialBoundaryContinuation(observed.requests) + expect(observed.releases).toHaveLength(observed.requests.length) + observed.requests.forEach((request, index) => { + expect(observed.releases[index]).toBe(request) + }) + } + oracleProperty({ + id: `ordered-work.lifecycle-initial-settlement`, + law: { + statement: `An initial ordered load whose adapter returns literal true installs its completed window before the initiating call stack returns; Promise settlement stays asynchronous.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The four indexed or prefix, synchronous or Promise cells.`, }, - ) - - it(`rejects the old Promise-wrapped observation at the synchronous checkpoint`, () => { - oracleRoute(`calibration`) - expect(() => - assertInitialSettlementObservation( - expectedInitialSettlementObservation(`promise`), - `synchronous`, - ), - ).toThrow() + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: initialSettlementCells, + label: ({ settlement, autoIndex }) => + `publishes the complete initial window at the ${settlement} checkpoint with ${autoIndex} indexing`, + ...lifecycleJudgment(runInitialSettlementCell), + check: (_plan, _observations, verdict) => verdict, }) }) -describe(`ordered lifecycle product`, () => { - const observed = new Set() - const cells: Array = acquisitionPaths.flatMap((acquisitionPath) => - ([`before-provider-response`, `after-provider-response`] as const).flatMap( - (delivery) => - ([`keep`, `widen`] as const).flatMap((window) => - ([`resolve`, `reject`, `abort-error`] as const).flatMap((outcome) => - ([`retain`, `restart`] as const).flatMap((syncRun) => - ([`initial`, `replay`] as const).map((barrier) => ({ - acquisitionPath, - delivery, - window, - outcome, - syncRun, - barrier, - })), - ), - ), - ), - ), - ) - declareOracle({ grammar: [cells] }) +// A product cell's coordinate tuple, in the order observeHistory reports +// the coordinates it reached. +const coordinatesOf = (scenario: Scenario): Array => [ + scenario.acquisitionPath, + scenario.delivery, + scenario.window, + scenario.outcome, + scenario.syncRun, + scenario.barrier, +] - 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)) +describe(`ordered lifecycle product`, () => { + // Every cell must perform physical work and reach terminal cleanup at its + // own coordinates. Its coordinate tuple is its reach state, so the reach + // test requires all 192 tuples of the product, as the old census of 192 + // distinct observed coordinates did. + oracleProperty({ + id: `ordered-work.lifecycle-product`, + law: { + statement: `Ordered histories match the finite lifecycle reference`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The 192-cell product of acquisition path, delivery, window change, outcome, sync run, and barrier.`, }, - ) - it(`reaches all 192 histories through physical work and terminal cleanup`, () => { - oracleRoute(`case`) - expect(observed.size).toBe(cells.length) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: { + enumerated: lifecycleProductCells, + states: lifecycleProductCells.map((cell) => + JSON.stringify(coordinatesOf(cell)), + ), + }, + label: (scenario) => + `${scenario.acquisitionPath} / ${scenario.delivery} / ${scenario.window} / ${scenario.outcome} / ${scenario.syncRun} / ${scenario.barrier}`, + resolve: (history) => ({ history }), + model: (plan, observations) => recordedComparison(plan, observations), + driver: async (plan, observe) => { + let coordinates: Array | undefined + await observeComparison(plan, observe, async () => { + const result = await assertHistory(plan.history) + coordinates = result.coordinates + }) + if (coordinates) observe(`reach`, JSON.stringify(coordinates)) + }, + check: (_plan, _observations, verdict) => verdict, }) const arbitrary = fc.record({ acquisitionPath: fc.constantFrom(...acquisitionPaths), @@ -1353,92 +1275,105 @@ async function observeInitialFallback( } describe(`initial locale-string fallback oracle`, () => { - const cells = ([`eager`, `off`] as const).flatMap((autoIndex) => - ([`synchronous`, `promise`] as const).flatMap((settlement) => - ([`distinct`, `tied`] as const).map((order) => ({ - autoIndex, - settlement, - order, - })), - ), - ) - - 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) - }) - - 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, - order, - ) - expect(observed.commitReturnedImmediately).toEqual([true, true]) - expect(observed.requestsAtCut).toHaveLength(2) - expect(observed.requestsAtCut[0]).toMatchObject({ limit: 1 }) - expect(observed.requestsAtCut[0]?.orderBy).toHaveLength(1) - expect(observed.requestsAtCut[0]?.offset).toBe( - autoIndex === `eager` ? 0 : undefined, - ) - expect(observed.requestsAtCut[1]?.orderBy).toBeUndefined() - expect(observed.requestsAtCut[1]?.limit).toBeUndefined() - expect(observed.requestsAtCut[1]?.where).toBeUndefined() - expect(observed.requestsAtCut[1]?.cursor).toBeUndefined() - expect(observed.requestsAtCut[1]?.offset).toBeUndefined() - expect(observed.atFallbackEntry).toEqual({ - rows: [], - status: `loading`, - }) - expect(observed.immediate).toEqual( - expectedInitialFallback(settlement, order), - ) - expect(observed.settled).toEqual({ - rows: [order === `tied` ? 1 : 2], - status: `ready`, - }) + async function runInitialFallbackCell({ + autoIndex, + settlement, + order, + }: (typeof initialFallbackCells)[number]): Promise { + const observed = await observeInitialFallback(autoIndex, settlement, order) + expect(observed.commitReturnedImmediately).toEqual([true, true]) + expect(observed.requestsAtCut).toHaveLength(2) + expect(observed.requestsAtCut[0]).toMatchObject({ limit: 1 }) + expect(observed.requestsAtCut[0]?.orderBy).toHaveLength(1) + expect(observed.requestsAtCut[0]?.offset).toBe( + autoIndex === `eager` ? 0 : undefined, + ) + expect(observed.requestsAtCut[1]?.orderBy).toBeUndefined() + expect(observed.requestsAtCut[1]?.limit).toBeUndefined() + expect(observed.requestsAtCut[1]?.where).toBeUndefined() + expect(observed.requestsAtCut[1]?.cursor).toBeUndefined() + expect(observed.requestsAtCut[1]?.offset).toBeUndefined() + expect(observed.atFallbackEntry).toEqual({ + rows: [], + status: `loading`, + }) + expect(observed.immediate).toEqual( + expectedInitialFallback(settlement, order), + ) + expect(observed.settled).toEqual({ + rows: [order === `tied` ? 1 : 2], + status: `ready`, + }) + } + oracleProperty({ + id: `ordered-work.lifecycle-initial-fallback`, + law: { + statement: `An ordered prefix ending at a locale string completes its initial chain with a full-source acquisition, and the public window equals the independent reference at each cut.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The eight index, settlement, and row-order cells.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: initialFallbackCells, + label: ({ autoIndex, settlement, order }) => + `publishes the ${autoIndex} ${order} locale-string singleton at the ${settlement} checkpoint`, + ...lifecycleJudgment(runInitialFallbackCell), + check: (_plan, _observations, verdict) => verdict, + }) - 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, - order, - true, - ) - expect(observed.commitReturnedImmediately).toEqual([true, true]) - const id = order === `tied` ? 1 : 2 - expect(observed.requestsAtCut).toHaveLength(2) - expect(observed.atFallbackEntry).toEqual({ - rows: [], - status: `loading`, - }) - expect(observed.immediate).toEqual( - expectedInitialFallback(settlement, order), - ) - if (settlement === `promise`) - expect(observed.publicationsAtCut).toEqual([]) - expect(observed.settled).toEqual({ rows: [id], status: `ready` }) - const winner = { id, at: `2026-10-02` } - expect(observed.publications).toEqual([ - { - changes: [{ type: `insert`, key: id, value: winner }], - rows: [winner], - afterFallbackResult: true, - }, - ]) + async function runInitialFallbackPublicationCell({ + autoIndex, + order, + settlement, + }: (typeof initialFallbackCells)[number]): Promise { + const observed = await observeInitialFallback( + autoIndex, + settlement, + order, + true, + ) + expect(observed.commitReturnedImmediately).toEqual([true, true]) + const id = order === `tied` ? 1 : 2 + expect(observed.requestsAtCut).toHaveLength(2) + expect(observed.atFallbackEntry).toEqual({ + rows: [], + status: `loading`, + }) + expect(observed.immediate).toEqual( + expectedInitialFallback(settlement, order), + ) + if (settlement === `promise`) expect(observed.publicationsAtCut).toEqual([]) + expect(observed.settled).toEqual({ rows: [id], status: `ready` }) + const winner = { id, at: `2026-10-02` } + expect(observed.publications).toEqual([ + { + changes: [{ type: `insert`, key: id, value: winner }], + rows: [winner], + afterFallbackResult: true, + }, + ]) + } + oracleProperty({ + id: `ordered-work.lifecycle-initial-fallback-publication`, + law: { + statement: `A subscriber of the locale-string fallback receives one nonempty insert of the model's final singleton after the fallback result settles.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The eight index, settlement, and row-order cells.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: initialFallbackCells, + label: ({ autoIndex, order, settlement }) => + `emits one completed ${autoIndex} ${order} initial window change with ${settlement} fallback`, + ...lifecycleJudgment(runInitialFallbackPublicationCell), + check: (_plan, _observations, verdict) => verdict, + }) }) /** @@ -2275,275 +2210,240 @@ describe(`post-publication null fallback oracle`, () => { { mutation, response: `held-promise-late-write`, childDemand: `pending` }, { mutation, response: `held-promise-late-write`, childDemand: `settled` }, ]) - 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), - ) + async function runLaterFallbackCase({ + mutation, + response, + childDemand, + }: (typeof cases)[number]): Promise { + await observeLaterFallback(mutation, response, childDemand, (observed) => + assertLaterFallbackObservation(mutation, observed), + ) + } + oracleProperty({ + id: `ordered-work.lifecycle-later-fallback`, + law: { + statement: `A null-boundary full-source fallback that starts after first publication keeps the public window and change events coherent with the independent window.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Two marker actions crossed with literal, Promise, held-receipt, held-Promise, and late-write responses, and pending or settled child demand.`, }, - ) - - it(`rejects a stale window event after an overlapping held provider result`, async () => { - oracleRoute(`calibration`) - await observeLaterFallback( - `delete-marker`, - `held-promise`, - `settled`, - (observed) => { - assertLaterFallbackObservation(`delete-marker`, observed) - const final = expectedLaterFallbackWindow( - laterFallbackMarkersAfterOverlap(`delete-marker`), - )[0]! - const stale = expectedLaterFallbackWindow( - laterFallbackMarkersAfter(`delete-marker`), - )[0]! - // This wrong result leaves the final rows correct, but publishes the - // obsolete winner and then retracts it after the overlap. - const wrong: LaterFallbackObservation = { - ...observed, - publications: [ - ...observed.publications, - { - changes: [ - { type: `delete`, key: final.key, value: final.row }, - { type: `insert`, key: stale.key, value: stale.row }, - ], - rows: [stale.row], - }, - { - changes: [ - { type: `delete`, key: stale.key, value: stale.row }, - { type: `insert`, key: final.key, value: final.row }, - ], - rows: [final.row], - }, - ], - } - expect(() => - assertLaterFallbackObservation(`delete-marker`, wrong), - ).toThrow() + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: cases, + label: ({ mutation, response, childDemand }) => + `keeps the public window and change events coherent after ${mutation} with ${response} and ${childDemand} child demand`, + ...lifecycleJudgment(runLaterFallbackCase), + check: (_plan, _observations, verdict) => verdict, + // A wrong observation that leaves the final rows correct, but publishes + // the obsolete winner and then retracts it, must fail the comparison on + // the held-promise history its old witness used. + calibrations: [ + { + name: `rejects a stale window event after an overlapping held provider result`, + law: `calibration: stale window event`, + driver: ( + plan: { history: (typeof cases)[number] }, + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(plan, observe, async () => { + if ( + !isDeepStrictEqual(plan.history, { + mutation: `delete-marker`, + response: `held-promise`, + childDemand: `settled`, + }) + ) + return runLaterFallbackCase(plan.history) + await observeLaterFallback( + `delete-marker`, + `held-promise`, + `settled`, + (observed) => { + assertLaterFallbackObservation(`delete-marker`, observed) + const final = expectedLaterFallbackWindow( + laterFallbackMarkersAfterOverlap(`delete-marker`), + )[0]! + const stale = expectedLaterFallbackWindow( + laterFallbackMarkersAfter(`delete-marker`), + )[0]! + // This wrong result leaves the final rows correct, but publishes the + // obsolete winner and then retracts it after the overlap. + const wrong: LaterFallbackObservation = { + ...observed, + publications: [ + ...observed.publications, + { + changes: [ + { type: `delete`, key: final.key, value: final.row }, + { type: `insert`, key: stale.key, value: stale.row }, + ], + rows: [stale.row], + }, + { + changes: [ + { type: `delete`, key: stale.key, value: stale.row }, + { type: `insert`, key: final.key, value: final.row }, + ], + rows: [final.row], + }, + ], + } + // Exactly the old matcher: any thrown value is the check rejecting + // the wrong observation. + try { + assertLaterFallbackObservation(`delete-marker`, wrong) + } catch (error) { + rejectAt( + `calibration: stale window event`, + error instanceof Error ? error : new Error(String(error)), + ) + } + }, + ) + }), }, - ) + ], }) - 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() - try { - await observeLaterFallback( - mutation, - `literal-true`, - `pending`, - (observed) => assertLaterFallbackObservation(mutation, observed), - true, - ) - await flushPromises() - } finally { - uncaught.restore() - } - expect(uncaught.errors.map(String)).toEqual([ - `Error: first ordered acquisition release failed`, - ]) + // The first lease's throwing release is reported uncaught, once, after the + // fallback publishes its replacement. + async function runLaterFallbackCleanupFailure( + mutation: LaterFallbackMutation, + ): Promise { + // The subscription reports the throwing release uncaught, once: it is + // an adapter bug, not a load result. + const uncaught = captureMicrotaskErrors() + try { + await observeLaterFallback( + mutation, + `literal-true`, + `pending`, + (observed) => assertLaterFallbackObservation(mutation, observed), + true, + ) + await flushPromises() + } finally { + uncaught.restore() + } + expect(uncaught.errors.map(String)).toEqual([ + `Error: first ordered acquisition release failed`, + ]) + } + oracleProperty({ + id: `ordered-work.lifecycle-later-fallback-cleanup`, + law: { + statement: `A throwing release of the first ordered lease is reported uncaught once, and the later fallback still publishes a coherent replacement window.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The delete-marker and retarget-marker actions with a literal-true response and pending child demand.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [`delete-marker`, `retarget-marker`] as const, + label: (mutation) => + `reports the first lease's cleanup failure after the ${mutation} fallback publishes its replacement`, + ...lifecycleJudgment(runLaterFallbackCleanupFailure), + check: (_plan, _observations, verdict) => verdict, + }) }) 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) - for (const secondaryDirection of [`asc`, `desc`] as const) { - const primary = { - nulls: primaryNulls, - direction: primaryDirection, - } - const secondary = { - nulls: secondaryNulls, - direction: secondaryDirection, - } - expectNullableOrderMatchesPalette(primary, secondary) - } - }) - const cells: Array = ([`first`, `last`] as const) - .flatMap((primaryNulls) => - ([`first`, `last`] as const).flatMap((secondaryNulls) => - ([`prefix`, `boundary`] as const).flatMap((acquisitionPath) => - ([`resolve`, `repair`, `restart`] as const).map( - (mode) => - ({ - nullable: { - primary: { direction: `asc`, nulls: primaryNulls }, - secondary: { direction: `desc`, nulls: secondaryNulls }, - }, - acquisitionPath: - acquisitionPath === `boundary` && primaryNulls === `first` - ? `full-source` - : acquisitionPath, - delivery: `before-provider-response`, - window: `widen`, - outcome: mode === `repair` ? `reject` : `resolve`, - syncRun: mode === `restart` ? `restart` : `retain`, - barrier: mode === `repair` ? `replay` : `initial`, - repair: mode === `repair`, - }) as const, - ), - ), - ), - ) - // Null-leading completion acquires full source and retires its prefix. - // Replay can hold that full demand, but cannot target the retired prefix. - .filter( - (scenario) => - !( - scenario.nullable.primary.nulls === `first` && - scenario.acquisitionPath === `prefix` && - scenario.repair - ), - ) - const directionCells = ([`asc`, `desc`] as const).flatMap( - (primaryDirection) => - ([`asc`, `desc`] as const).map((secondaryDirection) => ({ - primaryDirection, - secondaryDirection, - })), - ) - 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( - cells.some( - (scenario) => - scenario.nullable?.primary.nulls === `first` && - scenario.acquisitionPath === `prefix` && - scenario.repair, - ), - ).toBe(false) - expect(directionCells).toHaveLength(4) + async function runNullableCell(scenario: Scenario): Promise { + const result = await assertHistory(scenario) + expect(result.orderedRequests).toBeGreaterThan(0) expect( - new Set(directionCells.map((cell) => JSON.stringify(cell))).size, - ).toBe(4) - }) - 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( - scenario.nullable!.primary.nulls === `first` - ? result.fullSourceRequests - : result.tieRequests, - ).toBeGreaterThan(0) - expect(result.repaired).toBe(scenario.repair) + scenario.nullable!.primary.nulls === `first` + ? result.fullSourceRequests + : result.tieRequests, + ).toBeGreaterThan(0) + expect(result.repaired).toBe(scenario.repair) + } + oracleProperty({ + id: `ordered-work.lifecycle-nullable-cells`, + law: { + statement: `Nullable multi-term histories match the finite lifecycle reference, with ordered work and the tie or full-source acquisition that their null placement requires.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The 22 nullable lifecycle cells.`, }, - ) - it.each(directionCells)( - `keeps primary $primaryDirection and secondary $secondaryDirection direction independent`, - async ({ primaryDirection, secondaryDirection }) => { - oracleRoute(`case`) - const representative = cells[0]! - await assertHistory({ - ...representative, - nullable: { - primary: { - ...representative.nullable!.primary, - direction: primaryDirection, - }, - secondary: { - ...representative.nullable!.secondary, - direction: secondaryDirection, - }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: nullableLifecycleCells, + label: (scenario) => + `observes nullable tie and lifecycle coordinates: ${JSON.stringify(scenario)}`, + ...lifecycleJudgment(runNullableCell), + check: (_plan, _observations, verdict) => verdict, + // A wrong secondary order or null placement in the live loader output must + // fail an assertion on the first cell, the history its old witness used. + calibrations: ([`secondary-order`, `null-placement`] as const).map( + (fault) => ({ + name: `rejects a wrong ${fault} through live loader output`, + law: `calibration: ${fault}`, + driver: ( + plan: { history: Scenario }, + observe: (checkpoint: string, value: unknown) => void, + ) => + observeComparison(plan, observe, async () => { + if (!isDeepStrictEqual(plan.history, nullableLifecycleCells[0])) + return runNullableCell(plan.history) + await assertHistory(plan.history) + try { + await assertHistory(plan.history, fault) + } catch (error) { + // Exactly the old matcher: only an assertion failure counts as + // the check rejecting the wrong output. + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`calibration: ${fault}`, error) + throw error + } + }), + }), + ), + }) + async function runNullableDirectionCell({ + primaryDirection, + secondaryDirection, + }: (typeof nullableDirectionCells)[number]): Promise { + const representative = nullableLifecycleCells[0]! + await assertHistory({ + ...representative, + nullable: { + primary: { + ...representative.nullable!.primary, + direction: primaryDirection, }, - }) - }, - ) - 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({ - name: `AssertionError`, - }) - }, - ) - const arbitrary = fc - .record({ - scenario: fc.constantFrom(...cells), - primaryDirection: fc.constantFrom(`asc` as const, `desc` as const), - secondaryDirection: fc.constantFrom(`asc` as const, `desc` as const), - rankOffset: fc.integer({ min: -20, max: 20 }), - rankStep: fc.integer({ min: 1, max: 5 }), - }) - .map( - ({ - scenario, - primaryDirection, - secondaryDirection, - rankOffset, - rankStep, - }): Scenario => ({ - ...scenario, - rankOffset, - rankStep, - nullable: { - primary: { - ...scenario.nullable!.primary, - direction: primaryDirection, - }, - secondary: { - ...scenario.nullable!.secondary, - direction: secondaryDirection, - }, + secondary: { + ...representative.nullable!.secondary, + direction: secondaryDirection, }, - }), - ) - declareOracle({ grammar: [arbitrary] }) - - it(`generates the nullable value and lifecycle dimensions`, () => { - 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`]), - ) - expect( - new Set(sample.map((scenario) => scenario.nullable!.primary.nulls)), - ).toEqual(new Set([`first`, `last`])) - expect( - new Set(sample.map((scenario) => scenario.nullable!.secondary.nulls)), - ).toEqual(new Set([`first`, `last`])) - expect( - new Set(sample.map((scenario) => scenario.nullable!.primary.direction)), - ).toEqual(new Set([`asc`, `desc`])) - expect( - new Set(sample.map((scenario) => scenario.nullable!.secondary.direction)), - ).toEqual(new Set([`asc`, `desc`])) - expect( - new Set( - sample.map( - (scenario) => - `${scenario.nullable!.primary.direction}/${scenario.nullable!.secondary.direction}`, - ), - ), - ).toEqual(new Set([`asc/asc`, `asc/desc`, `desc/asc`, `desc/desc`])) - expect(sample.some((scenario) => scenario.repair)).toBe(true) - expect(sample.some((scenario) => scenario.syncRun === `restart`)).toBe(true) + }, + }) + } + oracleProperty({ + id: `ordered-work.lifecycle-nullable-directions`, + law: { + statement: `The primary and secondary directions of a nullable multi-term order are independent: each overlay of the first nullable cell matches the finite lifecycle reference.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The four direction overlays of the first nullable lifecycle cell.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: nullableDirectionCells, + label: ({ primaryDirection, secondaryDirection }) => + `keeps primary ${primaryDirection} and secondary ${secondaryDirection} direction independent`, + ...lifecycleJudgment(runNullableDirectionCell), + check: (_plan, _observations, verdict) => verdict, }) + const { multiplier } = readOracleRunConfig() const assertNullableHistory = async (scenario: Scenario) => { const result = await assertHistory(scenario) @@ -2575,7 +2475,11 @@ describe(`nullable multi-term lifecycle product`, () => { 'src/indexes/btree-index.ts', 'src/query/live-query-collection.ts#createLiveQueryCollection', ], - histories: { arbitrary, runs: 20, fixedSeeds: [93472] }, + histories: { + arbitrary: nullableLifecycleArbitrary, + runs: 20, + fixedSeeds: [93472], + }, model: (history, observations) => recordedComparison(history, observations), driver: async (history, observe) => observeComparison(history, observe, async () => { @@ -2589,59 +2493,64 @@ describe(`nullable multi-term lifecycle product`, () => { }) describe(`synchronous initial full-source settlement refinement`, () => { - const cells = fullSourceFeatures.flatMap((feature) => - ([`synchronous`, `promise`] as const).map((settlement) => ({ - feature, - settlement, - })), - ) - - 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) - }) - - 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) - expect(observed.settled).toEqual({ rows: [1, 2], status: `ready` }) + async function runFullSourceSettlementCell({ + feature, + settlement, + }: (typeof fullSourceSettlementCells)[number]): Promise { + const observed = await observeFullSourceSettlement(feature, settlement) + assertFullSourceRequest(observed.requests[0]) + assertInitialSettlementObservation(observed.immediate, settlement) + expect(observed.settled).toEqual({ rows: [1, 2], status: `ready` }) + } + oracleProperty({ + id: `ordered-work.lifecycle-full-source`, + law: { + statement: `A requiresFullSource plan's first full-source request completes the initial window at the synchronous checkpoint when the adapter returns literal true.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `The full-source features crossed with synchronous and Promise settlement.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: fullSourceSettlementCells, + label: ({ feature, settlement }) => + `publishes the complete initial ${feature} window at the ${settlement} checkpoint`, + ...lifecycleJudgment(runFullSourceSettlementCell), + check: (_plan, _observations, verdict) => verdict, + }) - 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), - `synchronous`, - ) + async function runEagerFullSourceSettlement( + feature: (typeof fullSourceFeatures)[number], + ): Promise { + // An eager source answers every local snapshot synchronously. + assertInitialSettlementObservation( + await observeEagerFullSourceSettlement(feature), + `synchronous`, + ) + } + oracleProperty({ + id: `ordered-work.lifecycle-eager-full-source`, + law: { + statement: `Over an eager source, every full-source feature publishes its complete initial window at the synchronous checkpoint.`, + authority: `query/live/ARCHITECTURE.md: ordered requests, continuation, and atomic window publication.`, + limits: `Each full-source feature over an eager source.`, }, - ) + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexes/btree-index.ts', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: fullSourceFeatures, + label: (feature) => + `publishes the complete initial ${feature} window over an eager source at the synchronous checkpoint`, + ...lifecycleJudgment(runEagerFullSourceSettlement), + check: (_plan, _observations, verdict) => verdict, + }) }) // 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 anchors the model to it. -declareOracle({ - check: [ - observeHistory, - assertInitialSettlementObservation, - assertLaterFallbackObservation, - ], - model: [ - compareNullable, - expectedInitialSettlementObservation, - expectedInitialFallback, - expectedLaterFallbackWindow, - ], -}) From 30385c0e53a0983b26998f3d90088709e4784df2 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:25:09 -0700 Subject: [PATCH 083/116] test(sqlite): register the multi-tab Electric storm and fixed histories through oracleProperty P2: the refetch storm cells become browser.multi-tab-refetch-storm. P1b: the named generated-grammar regressions become browser.multi-tab-reserved-history-fixed, each run through the same driver with the known-limit detector off and its host released as the afterEach hook released it. M2: both wrapper calls pass owns. The two it.fails known-limit witnesses stay as they are. The fairness fixed histories become one property each, and the persist-first calibration's own witness failures are rethrown without a cause chain, so a marked reach failure cannot pass as the check rejecting the mutant. The registry also names the ordinary-transaction properties. Co-authored-by: Isaac --- .../tests/multi-tab-electric-oracle.test.ts | 296 ++++++++++-------- .../shared-driver-fairness-oracle.test.ts | 106 ++++--- .../db/tests/converted-properties/sqlite.ts | 20 +- 3 files changed, 252 insertions(+), 170 deletions(-) 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 ccde2d2f47..a6f9c2823d 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 @@ -98,9 +98,14 @@ import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { createCollection } from '@tanstack/db' -import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { electricCollectionOptions } from '../../electric-db-collection/src/electric' import { BrowserCollectionCoordinator } from '../src/browser-coordinator' @@ -692,7 +697,6 @@ 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) { @@ -874,11 +878,26 @@ describe(`multi-tab Electric over one persisted database`, () => { } reach.assertReached() } - it.each(stormHistories)( - `commits every reserved refetch across tabs ($tabCount tabs, $syncMode, $finisher finishes first, $arrival, open $open, stop: $stop, recovery: $recovery)`, - runStormHistory, - 30_000, - ) + oracleProperty({ + id: `browser.multi-tab-refetch-storm`, + law: { + statement: `Every tab's reserved refetch commits across a refetch storm, no Collection enters error, and once every stream has delivered the new snapshot every tab shows it (Law 1); a stopped tab's recovery brings it to the head.`, + authority: `SyncPersistenceCapabilityV1 reserveCommitTurn (packages/db/src/types.ts), widened by maintainer decision on issue #2226 to every persisted hydration.`, + limits: `The enumerated storm cells: two or three tabs, eager or on-demand, each finisher, arrival, open, stop, and recovery; a controlled stream in one process.`, + }, + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], + histories: stormHistories, + label: ({ tabCount, syncMode, finisher, arrival, open, stop, recovery }) => + `commits every reserved refetch across tabs (${tabCount} tabs, ${syncMode}, ${finisher} finishes first, ${arrival}, open ${open}, stop: ${stop}, recovery: ${recovery})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`multi-tab refetch storm`, plan, observe, () => + runStormHistory(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 30_000, + }) // Law 1 and Law 3, generated histories. The enumerated grammar above fixes // one refetch storm and one stopped tab. These histories interleave, in any @@ -1826,6 +1845,7 @@ describe(`multi-tab Electric over one persisted database`, () => { sourcePrefixHistory, { id: 'browser.electric-source-prefix', + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], runs: 80, fixedSeed: 2281, beforeCampaign: () => { @@ -1866,11 +1886,6 @@ describe(`multi-tab Electric over one persisted database`, () => { // The storm driver and the generated-history driver are the checks; the // 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], - }) const createGeneratedReach = () => requiredBrowserObservations( @@ -1899,6 +1914,7 @@ describe(`multi-tab Electric over one persisted database`, () => { generatedHistory, { id: GENERATED_PROPERTY, + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], runs: GENERATED_RUNS, fixedSeed: GENERATED_FIXED_SEED, beforeCampaign: () => { @@ -1927,6 +1943,14 @@ describe(`multi-tab Electric over one persisted database`, () => { 600_000, ) + // Fixed histories replay through the same generated-history driver and + // checks, with the known-limit detector off. Each names the behavior it + // proves, and one enumerated property below runs them all. + const multiTabFixedHistories: Array<{ + name: string + history: GeneratedHistory + }> = [] + // 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-limit @@ -1957,78 +1981,69 @@ describe(`multi-tab Electric over one persisted database`, () => { // 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`, () => { - 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) + multiTabFixedHistories.push({ + name: `recovers a reset-abandoned source that fails during final catch-up`, + history: { + 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 }, + ], + }, + }) // 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`, () => { - 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) + multiTabFixedHistories.push({ + name: `recovers a reloaded source whose final commit was abandoned by a peer reset`, + history: { + 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 }, + ], + }, + }) // 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`, () => { - 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) + multiTabFixedHistories.push({ + name: `keeps cached rows visible while recovering a discontinuous source`, + history: { + 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 }, + ], + }, + }) // 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 @@ -2065,26 +2080,23 @@ describe(`multi-tab Electric over one persisted database`, () => { // rows to show the head. The source-progress repair forces a fresh snapshot. // 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`, () => { - 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) + multiTabFixedHistories.push({ + name: `keeps a durable row after both tabs catch up to it and then stop`, + history: { + 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 }, + ], + }, + }) // 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 @@ -2107,43 +2119,69 @@ 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`, () => { - oracleRoute(`fixed history`) - return runGeneratedHistory( - { - tabCount: 2, - steps: [...stalePrefix, publishCursor, reopenPeer], - }, - () => undefined, - ) - }, 60_000) + multiTabFixedHistories.push({ + name: `${behavior} after a cursor-only commit and a lagging tab reload`, + history: { + tabCount: 2, + steps: [...stalePrefix, publishCursor, reopenPeer], + }, + }) // A peer that drains its backlog supplies the missing change itself. - 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) + multiTabFixedHistories.push({ + name: `${behavior} when the lagging stream drains before reloading`, + history: { + tabCount: 2, + steps: [ + ...stalePrefix, + publishCursor, + { kind: `catch up`, tab: 1, pick: 0 }, + reopenPeer, + ], + }, + }) // 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`, () => { - oracleRoute(`fixed history`) - return runGeneratedHistory( - { tabCount: 2, steps: [...stalePrefix, reopenPeer] }, - () => undefined, - ) - }, 60_000) + multiTabFixedHistories.push({ + name: `${behavior} when the lagging tab reloads before the cursor advances`, + history: { tabCount: 2, steps: [...stalePrefix, reopenPeer] }, + }) } + + oracleProperty({ + id: `browser.multi-tab-reserved-history-fixed`, + law: { + statement: `Reserved commits stay lawful in each named multi-tab history: every open tab and the durable rows converge to the server head once the streams catch up (Law 2), and a source that a peer reset abandoned recovers before convergence is judged.`, + authority: `The coverage map's eventual-consistency note for persisted multi-tab Collections (issue #2209), and the reserved-commit capability of issue #2226.`, + limits: `The named regressions of the generated campaign, including issue #2281's six-step histories, each replayed through the same driver and checks with the known-limit detector off.`, + }, + owns: [`src/browser-coordinator.ts`, `src/browser-persistence.ts`], + histories: multiTabFixedHistories, + label: ({ name }) => name, + resolve: ({ history }) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `multi-tab fixed history`, + plan, + observe, + async () => { + // Each history releases its host, as the afterEach hook did after + // each fixed test and as the generated campaign does, so a + // determinism rerun starts from a fresh host. + let primary: { error: unknown } | undefined + try { + await runGeneratedHistory(plan.history, () => undefined) + } catch (error) { + primary = { error } + } finally { + await finishHistory(primary) + } + }, + ), + check: forwardAssertionJudgment, + timeout: 60_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 12f4c56f7b..21e42c0c7f 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 @@ -239,17 +239,6 @@ async function runFixedPersistStorm(): Promise { }) } -const fixedFairnessHistories = new Map Promise>([ - [ - `reaches cold hydration and preserves independently seeded public rows without queued persists`, - runNeutralReach, - ], - [ - `completes a pending cold hydrate before an unrelated persist backlog drains and distinguishes K=1 from K=2`, - runFixedPersistStorm, - ], -]) - // The named persist-first storm: four persists, then two hydrates. The real // scheduler must pass it; the persist-first FIFO scheduling mutant must fail // the checker on it. @@ -262,28 +251,41 @@ const persistFirstScenario = () => async function rejectPersistFirstFifo(): Promise { let rejected: ReturnType - await withNodeScenario( - createScenario( - `persist-first-fixture-fault`, - [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], - 1, - ), - (observation) => { - expectObservationReach(observation) - const violation = findSharedDriverFairnessViolation(observation) - if (!violation) { - throw new Error( - `persist-first scheduling mutant escaped the fairness checker; ` + - `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, - ) - } - expect(violation.checkpoint.completedPersistIds).toHaveLength(4) - expect(violation.checkpoint.pendingPersistCount).toBe(0) - expect(observation.cleanupFailures).toEqual([]) - rejected = violation - }, - { schedulingFault: `persist-first-fifo` }, - ) + // A failure of this witness's own checks, including the marked reach check, + // fails the calibration, as it failed the old test: it is rethrown without a + // cause chain, so the adapter cannot read it as the check rejecting the + // mutant. Only the violation found below counts as that rejection. + try { + await withNodeScenario( + createScenario( + `persist-first-fixture-fault`, + [`persist`, `persist`, `persist`, `persist`, `hydrate`, `hydrate`], + 1, + ), + (observation) => { + expectObservationReach(observation) + const violation = findSharedDriverFairnessViolation(observation) + if (!violation) { + throw new Error( + `persist-first scheduling mutant escaped the fairness checker; ` + + `cleanup diagnostics: ${JSON.stringify(observation.cleanupFailures)}`, + ) + } + expect(violation.checkpoint.completedPersistIds).toHaveLength(4) + expect(violation.checkpoint.pendingPersistCount).toBe(0) + expect(observation.cleanupFailures).toEqual([]) + rejected = violation + }, + { schedulingFault: `persist-first-fifo` }, + ) + } catch (error) { + throw Object.assign( + new Error( + `persist-first calibration witness failed: ${error instanceof Error ? error.message : String(error)}`, + ), + { witnessFailure: error }, + ) + } // The checker found the mutant's violation at the asserted checkpoint. if (rejected) rejectAt( @@ -296,18 +298,44 @@ async function rejectPersistFirstFifo(): Promise { describe(`shared BrowserWASQLiteDriver fairness oracle`, () => { oracleProperty({ - id: `browser.shared-driver-fairness.fixed`, + id: `browser.shared-driver-fairness.neutral`, law: { ...fairnessLawText, - limits: `The neutral hydrate-only history and the fixed persist storm, with the storm's checker calibrations of a K=2 answer, a preempted running persist, and swapped persist order.`, + limits: `The neutral hydrate-only history: cold hydration is reached and public rows are preserved without queued persists.`, }, owns: [`src/wa-sqlite-driver.ts`], - histories: [...fixedFairnessHistories.keys()], - label: (name) => name, - resolve: (name) => ({ run: fixedFairnessHistories.get(name)! }), + histories: [ + { + name: `reaches cold hydration and preserves independently seeded public rows without queued persists`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fairnessLaw, plan, observe, () => + runNeutralReach(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `browser.shared-driver-fairness.persist-storm`, + law: { + ...fairnessLawText, + limits: `The fixed persist storm, with its checker calibrations of a K=2 answer, a preempted running persist, and swapped persist order on the real observation.`, + }, + owns: [`src/wa-sqlite-driver.ts`], + histories: [ + { + name: `completes a pending cold hydrate before an unrelated persist backlog drains and distinguishes K=1 from K=2`, + }, + ], + label: ({ name }) => name, model: recordedAssertionJudgment, driver: (plan, observe) => - recordAssertionJudgment(fairnessLaw, plan, observe, () => plan.run()), + recordAssertionJudgment(fairnessLaw, plan, observe, () => + runFixedPersistStorm(), + ), check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index c71eb52208..74fc08e87a 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -19,11 +19,21 @@ export const properties: ReadonlyArray = [ `persisted-demand-coverage.bookkeeping`, `persisted-demand-coverage.window`, `persisted-demand-coverage.next-page`, - `browser.shared-driver-fairness.fixed`, + `browser.shared-driver-fairness.neutral`, + `browser.shared-driver-fairness.persist-storm`, `browser.shared-driver-fairness.persist-first`, `browser.shared-driver-fairness.barrier`, `browser.multi-tab-refetch-storm`, `browser.multi-tab-reserved-history-fixed`, + `sqlite-ordinary.native-values`, + `sqlite-ordinary.write-history-fixed`, + `sqlite-ordinary.independent-work-fixed`, + `sqlite-ordinary.fixed-cases`, + `sqlite-ordinary.metadata-discarding-writer`, + `sqlite-ordinary.low-call-replacement`, + `sqlite-ordinary.idempotent-position`, + `sqlite-ordinary.late-bookkeeping-fault`, + `sqlite-ordinary.later-row-write-fault`, ] export const owners: ReadonlyArray = [ @@ -55,11 +65,17 @@ export const owners: ReadonlyArray = [ [ `browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts`, `browser.shared-driver-fairness`, - `fixed persist-first barrier`, + `neutral persist-storm persist-first barrier`, ], [ `browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts`, `browser`, `multi-tab-refetch-storm multi-tab-reserved-history-fixed`, ], + [ + `db-sqlite-persistence-core/tests/sqlite-resume-snapshot-oracle.test.ts`, + `sqlite-ordinary`, + `native-values write-history-fixed independent-work-fixed fixed-cases metadata-discarding-writer low-call-replacement idempotent-position late-bookkeeping-fault later-row-write-fault`, + `db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts`, + ], ] From 5f07cafe61a6fa245bbec1bab13a1e9a36cc6475 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:26:33 -0700 Subject: [PATCH 084/116] test(sqlite): register the ordinary-transaction fixed histories and mutants through oracleProperty P2: the native-value actions become sqlite-ordinary.native-values. P1b: each fixed process, hostile-writer, idempotence, and rollback history becomes a one-history property whose driver is the unchanged test body. P3: the metadata-preservation, extra-transaction, and extra-row-call mutants become driver calibrations on new fixed-history properties, accepting exactly the old rejections. P6: the grammar censuses and the comparison calibration run no production, so they move unchanged to ordinary-transaction-work-calibration.test.ts. The module registers nothing on import, so that file imports its definitions. Co-authored-by: Isaac --- ...inary-transaction-work-calibration.test.ts | 130 +++ .../tests/ordinary-transaction-work-oracle.ts | 871 ++++++++++-------- 2 files changed, 640 insertions(+), 361 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-calibration.test.ts diff --git a/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-calibration.test.ts new file mode 100644 index 0000000000..f52bbdb193 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-calibration.test.ts @@ -0,0 +1,130 @@ +// Calibrations of the ordinary SQLite committed-transaction grammars and +// comparison in ordinary-transaction-work-oracle.ts: the generated action +// histories and independent-key work cases reconstruct their axes at the +// oracle's fixed seeds, and the complete-observation comparison rejects lost +// metadata. They run no production, so they are ordinary tests. Importing the +// oracle module registers nothing; its tests register only through +// runOrdinaryTransactionWorkOracle(). +import { fc } from '@fast-check/vitest' +import { describe, expect, it } from 'vitest' +import { oracleFixedSeed } from '../../db/tests/oracle-config' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { clone } from './ordinary-transaction-work-support' +import { + generatedHistoryArbitrary, + independentWorkArbitrary, + modelAfter, + sameObservation, + semanticCases, +} from './ordinary-transaction-work-oracle' +import type { + GeneratedScenario, + IndependentWorkCase, +} from './ordinary-transaction-work-oracle' + +describe('ordinary SQLite committed transaction oracle', () => { + it('reconstructs the bounded generated action-history axes', () => { + const samples = fc.sample( + generatedHistoryArbitrary, + oracleFixedSeed(1992, { numRuns: 120 }), + ) + oracleAssertion('existing comparison at 1045', () => + expect(new Set(samples.map((sample) => sample.scenario))).toEqual( + new Set([ + 'repeated-key', + 'delete-reinsert', + 'partial-update', + 'metadata-present-set', + 'metadata-present-delete', + 'metadata-absent-set', + 'metadata-absent-delete', + ]), + ), + ) + oracleAssertion('existing comparison at 1056', () => + expect(new Set(samples.map((sample) => sample.rollback))).toEqual( + new Set([true, false]), + ), + ) + oracleAssertion('existing comparison at 1059', () => + expect(new Set(samples.map((sample) => sample.followup))).toEqual( + new Set([true, false]), + ), + ) + oracleAssertion('existing comparison at 1062', () => + expect( + new Set( + samples.map((sample) => `${sample.scenario}:${sample.rollback}`), + ).size, + ).toBe(14), + ) + oracleAssertion('existing comparison at 1067', () => + expect(new Set(samples.map((sample) => sample.target))).toEqual( + new Set(['a', 'b']), + ), + ) + oracleAssertion('existing comparison at 1070', () => + expect( + samples.some( + (sample) => + sample.prefix.length > 0 && sample.rowMetadataPrefix.length > 0, + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1076', () => + expect( + samples.every( + (sample) => + sample.prefix.length <= 60 && + sample.rowMetadataPrefix.length <= 3 && + sample.collectionMetadata.length <= 2, + ), + ).toBe(true), + ) + oracleAssertion('existing comparison at 1084', () => + expect(samples.some((sample) => sample.prefix.length >= 25)).toBe(true), + ) + }) + it('reconstructs independent-key work shapes and host caps', () => { + const samples = fc.sample( + independentWorkArbitrary, + oracleFixedSeed(31992, { numRuns: 120 }), + ) + oracleAssertion('existing comparison at 1092', () => + expect(new Set(samples.map((sample) => sample.shape))).toEqual( + new Set([ + 'insert-later-metadata', + 'insert-inline-metadata', + 'partial-update', + 'delete', + 'row-metadata-only', + 'collection-metadata-only', + ]), + ), + ) + oracleAssertion('existing comparison at 1102', () => + expect(new Set(samples.map((sample) => sample.cap))).toEqual( + new Set([100, 999]), + ), + ) + oracleAssertion('existing comparison at 1105', () => + expect( + samples.every((sample) => sample.size >= 0 && sample.size <= 60), + ).toBe(true), + ) + }) + it('calibrates the complete-observation comparison', () => { + const expected = modelAfter([ + semanticCases[0]!.seed!, + semanticCases[0]!.candidate, + ]) + const wrong = clone(expected) + wrong.rows[0]!.metadata = undefined + oracleAssertion('existing comparison at 1199', () => + expect(sameObservation(expected, expected)).toBe(true), + ) + oracleAssertion('existing comparison at 1200', () => + expect(sameObservation(wrong, expected)).toBe(false), + ) + }) +}) 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 58eb6d53dd..95b44f5d2c 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,14 +1,13 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' -import { oracleFixedSeed } from '../../db/tests/oracle-config' +import { describe, expect, vi } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration' import { createPersistedTableName } from '../src' import { Temporal, @@ -109,7 +108,7 @@ import type { * write work and does not replace driver admission or host receiving witnesses. */ // The model replays semantic actions from their contract, never from SQL. -function modelAfter(history: ReadonlyArray): Observation { +export function modelAfter(history: ReadonlyArray): Observation { const rows = new Map< string, { @@ -351,7 +350,7 @@ workCases.push({ candidate: insertRows(10000), workSize: 10000, }) -const semanticCases: Array = [ +export const semanticCases: Array = [ { name: 'existing-insert-keeps-metadata', cap: 100, @@ -468,10 +467,13 @@ const semanticCases: Array = [ ] // A deliberate wrong answer control: a fast writer that drops prior metadata // passes a row-count check, but the complete observation comparison rejects it. -function sameObservation(actual: Observation, expected: Observation): boolean { +export function sameObservation( + actual: Observation, + expected: Observation, +): boolean { return JSON.stringify(actual) === JSON.stringify(expected) } -type GeneratedScenario = +export type GeneratedScenario = | 'repeated-key' | 'delete-reinsert' | 'partial-update' @@ -489,7 +491,7 @@ type RowMetadataAction = NonNullable[number] type CollectionMetadataAction = NonNullable< Tx['collectionMetadataMutations'] >[number] -type GeneratedHistory = { +export type GeneratedHistory = { scenario: GeneratedScenario target: 'a' | 'b' prefix: Array @@ -504,52 +506,53 @@ const generatedActionArbitrary: fc.Arbitrary = fc.record({ version: fc.integer({ min: 0, max: 9 }), metadata: fc.constantFrom('keep', 'set', 'clear'), }) -const generatedHistoryArbitrary: fc.Arbitrary = fc.record({ - scenario: fc.constantFrom( - 'repeated-key', - 'delete-reinsert', - 'partial-update', - 'metadata-present-set', - 'metadata-present-delete', - 'metadata-absent-set', - 'metadata-absent-delete', - ), - target: fc.constantFrom<'a' | 'b'>('a', 'b'), - prefix: fc.oneof( - fc.array(generatedActionArbitrary, { maxLength: 4 }), - fc.array(generatedActionArbitrary, { minLength: 5, maxLength: 24 }), - fc.array(generatedActionArbitrary, { minLength: 25, maxLength: 60 }), - ), - rowMetadataPrefix: fc.array( - fc - .record({ - type: fc.constantFrom<'set' | 'delete'>('set', 'delete'), - key: fc.constantFrom('a', 'b', 'c'), - version: fc.integer({ min: 0, max: 9 }), - }) - .map(({ type, key, version }): RowMetadataAction => - type === 'set' - ? { type, key, value: { source: `prefix-${version}` } } - : { type, key }, - ), - { maxLength: 3 }, - ), - collectionMetadata: fc.array( - fc - .record({ - type: fc.constantFrom<'set' | 'delete'>('set', 'delete'), - key: fc.constantFrom('cursor', 'mode'), - version: fc.integer({ min: 0, max: 9 }), - }) - .map(({ type, key, version }): CollectionMetadataAction => - type === 'set' ? { type, key, value: { version } } : { type, key }, - ), - { maxLength: 2 }, - ), - rollback: fc.boolean(), - followup: fc.boolean(), -}) -type IndependentWorkCase = { +export const generatedHistoryArbitrary: fc.Arbitrary = + fc.record({ + scenario: fc.constantFrom( + 'repeated-key', + 'delete-reinsert', + 'partial-update', + 'metadata-present-set', + 'metadata-present-delete', + 'metadata-absent-set', + 'metadata-absent-delete', + ), + target: fc.constantFrom<'a' | 'b'>('a', 'b'), + prefix: fc.oneof( + fc.array(generatedActionArbitrary, { maxLength: 4 }), + fc.array(generatedActionArbitrary, { minLength: 5, maxLength: 24 }), + fc.array(generatedActionArbitrary, { minLength: 25, maxLength: 60 }), + ), + rowMetadataPrefix: fc.array( + fc + .record({ + type: fc.constantFrom<'set' | 'delete'>('set', 'delete'), + key: fc.constantFrom('a', 'b', 'c'), + version: fc.integer({ min: 0, max: 9 }), + }) + .map(({ type, key, version }): RowMetadataAction => + type === 'set' + ? { type, key, value: { source: `prefix-${version}` } } + : { type, key }, + ), + { maxLength: 3 }, + ), + collectionMetadata: fc.array( + fc + .record({ + type: fc.constantFrom<'set' | 'delete'>('set', 'delete'), + key: fc.constantFrom('cursor', 'mode'), + version: fc.integer({ min: 0, max: 9 }), + }) + .map(({ type, key, version }): CollectionMetadataAction => + type === 'set' ? { type, key, value: { version } } : { type, key }, + ), + { maxLength: 2 }, + ), + rollback: fc.boolean(), + followup: fc.boolean(), + }) +export type IndependentWorkCase = { shape: | 'insert-later-metadata' | 'insert-inline-metadata' @@ -560,18 +563,19 @@ type IndependentWorkCase = { size: number cap: 100 | 999 } -const independentWorkArbitrary: fc.Arbitrary = fc.record({ - shape: fc.constantFrom( - 'insert-later-metadata', - 'insert-inline-metadata', - 'partial-update', - 'delete', - 'row-metadata-only', - 'collection-metadata-only', - ), - size: fc.integer({ min: 0, max: 60 }), - cap: fc.constantFrom<100 | 999>(100, 999), -}) +export const independentWorkArbitrary: fc.Arbitrary = + fc.record({ + shape: fc.constantFrom( + 'insert-later-metadata', + 'insert-inline-metadata', + 'partial-update', + 'delete', + 'row-metadata-only', + 'collection-metadata-only', + ), + size: fc.integer({ min: 0, max: 60 }), + cap: fc.constantFrom<100 | 999>(100, 999), + }) function independentTransactions(input: IndependentWorkCase): { seed?: Tx candidate: Tx @@ -959,321 +963,298 @@ async function assertGeneratedHistory( // then compares each durable checkpoint with modelAfter, the independent model of // 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({ - check: [assertGeneratedHistory, assertIndependentWork], - model: [modelAfter], - grammar: [generatedHistoryArbitrary, independentWorkArbitrary], -}) export function runOrdinaryTransactionWorkOracle(): void { describe('ordinary SQLite committed transaction oracle', () => { // The native dimension reuses the existing action model and work counter. // At settlement compare intrinsic kind/text; empty-object equality is not // evidence of a successful native write. No new transaction model is added. - it.each( - temporalFamilies.flatMap((family) => - [false, true].map((rollback) => ({ family, rollback })), - ), - )( - `preserves native values through repeated actions / $family.name / rollback=$rollback`, - async ({ family, rollback }) => { - vi.stubGlobal(`Temporal`, Temporal) - const host = fixture(100) - try { - const first = temporalValue(family, 0) - const last = temporalValue(family, 2) - const seed = transaction(`seed`, 1, [ - { type: `insert`, key: `a`, value: { stamp: first } }, - ]) - const candidate = transaction( - `candidate`, - 2, - [ - { - type: `update`, - key: `a`, - value: { stamp: temporalValue(family, 1) }, - metadataChanged: true, - metadata: first, - }, - { type: `update`, key: `a`, value: { stamp: last } }, - ], - [{ type: `set`, key: `a`, value: last }], - [{ type: `set`, key: `cursor`, value: first }], - ) - await host.adapter.applyCommittedTx( - { kind: `eager`, collectionId: `native-actions` }, - seed, - ) - if (rollback) host.failRunMatching(/INSERT INTO applied_tx/) - beginMeasuredWrite(host) - const pending = host.adapter.applyCommittedTx( - { kind: `eager`, collectionId: `native-actions` }, - candidate, - ) - if (rollback) - await oracleAssertion('existing comparison at 999', () => - expect(pending).rejects.toThrow( - `injected late bookkeeping failure`, - ), - ) - else await pending - const work = finishMeasuredWrite(host, `native actions`) - oracleAssertion('existing comparison at 1004', () => - expect(work.query + work.run).toBeLessThanOrEqual(30), - ) - const expected = observeTemporal( - modelAfter(rollback ? [seed] : [seed, candidate]), - ) - const actual = observeTemporal( - await observe(host.adapter, host.driver, `native-actions`), - ) - oracleAssertion('existing comparison at 1011', () => - expect(actual).toEqual(expected), - ) - oracleAssertion('existing comparison at 1012', () => - expect( - observeTemporal(structuredClone({ stamp: first })), - ).not.toEqual(observeTemporal({ stamp: first })), - ) - host.failRunMatching(undefined) - vi.stubGlobal(`Temporal`, undefined) - const invalid = transaction(`invalid`, 3, [ - { type: `update`, key: `a`, value: { stamp: first } }, - { type: `update`, key: `a`, value: { stamp: `overwritten` } }, - ]) - await oracleAssertion('existing comparison at 1021', () => - expect( - host.adapter.applyCommittedTx( - { kind: `eager`, collectionId: `native-actions` }, - invalid, - ), - ).rejects.toThrow(/Temporal/), - ) - vi.stubGlobal(`Temporal`, Temporal) - ;((observedBeforeComparison0) => - oracleAssertion('existing comparison at 1028', () => - expect(observeTemporal(observedBeforeComparison0)).toEqual( - expected, - ), - ))(await observe(host.adapter, host.driver, `native-actions`)) - } finally { - vi.unstubAllGlobals() - host.close() - } - }, - ) - it('reconstructs the bounded generated action-history axes', () => { - const samples = fc.sample( - generatedHistoryArbitrary, - oracleFixedSeed(1992, { numRuns: 120 }), - ) - oracleAssertion('existing comparison at 1045', () => - expect(new Set(samples.map((sample) => sample.scenario))).toEqual( - new Set([ - 'repeated-key', - 'delete-reinsert', - 'partial-update', - 'metadata-present-set', - 'metadata-present-delete', - 'metadata-absent-set', - 'metadata-absent-delete', - ]), - ), - ) - oracleAssertion('existing comparison at 1056', () => - expect(new Set(samples.map((sample) => sample.rollback))).toEqual( - new Set([true, false]), - ), - ) - oracleAssertion('existing comparison at 1059', () => - expect(new Set(samples.map((sample) => sample.followup))).toEqual( - new Set([true, false]), - ), - ) - oracleAssertion('existing comparison at 1062', () => - expect( - new Set( - samples.map((sample) => `${sample.scenario}:${sample.rollback}`), - ).size, - ).toBe(14), - ) - oracleAssertion('existing comparison at 1067', () => - expect(new Set(samples.map((sample) => sample.target))).toEqual( - new Set(['a', 'b']), - ), - ) - oracleAssertion('existing comparison at 1070', () => - expect( - samples.some( - (sample) => - sample.prefix.length > 0 && sample.rowMetadataPrefix.length > 0, - ), - ).toBe(true), - ) - oracleAssertion('existing comparison at 1076', () => - expect( - samples.every( - (sample) => - sample.prefix.length <= 60 && - sample.rowMetadataPrefix.length <= 3 && - sample.collectionMetadata.length <= 2, - ), - ).toBe(true), - ) - oracleAssertion('existing comparison at 1084', () => - expect(samples.some((sample) => sample.prefix.length >= 25)).toBe(true), - ) - }) - it('reconstructs independent-key work shapes and host caps', () => { - const samples = fc.sample( - independentWorkArbitrary, - oracleFixedSeed(31992, { numRuns: 120 }), - ) - oracleAssertion('existing comparison at 1092', () => - expect(new Set(samples.map((sample) => sample.shape))).toEqual( - new Set([ - 'insert-later-metadata', - 'insert-inline-metadata', - 'partial-update', - 'delete', - 'row-metadata-only', - 'collection-metadata-only', - ]), - ), - ) - oracleAssertion('existing comparison at 1102', () => - expect(new Set(samples.map((sample) => sample.cap))).toEqual( - new Set([100, 999]), + const ordinaryLaw = { + statement: `The existing independent ordinary-write model judges durable rows and metadata at every original checkpoint, with the unchanged distinct-key work bound.`, + authority: `PersistedTx semantics and the ordinary-transaction work contract stated above.`, + } + const ordinaryJudgment = `ordinary write semantics and work` + + // A calibration witness's own failure fails the calibration, as it failed + // the old test: it is rethrown without a cause chain, so the adapter cannot + // read a marked comparison inside it as the check rejecting the mutant. + const witnessFailure = (error: unknown): Error => + Object.assign( + new Error( + `calibration witness failed: ${error instanceof Error ? error.message : String(error)}`, ), + { witnessFailure: error }, ) - oracleAssertion('existing comparison at 1105', () => - expect( - samples.every((sample) => sample.size >= 0 && sample.size <= 60), - ).toBe(true), + // Exactly the old `.rejects.toThrow(message)` matcher: the mutant must + // reject, with an error whose message contains `message` when one is given. + async function expectMutantRejects( + run: () => Promise, + message?: string, + ): Promise { + try { + await run() + } catch (error) { + if ( + error instanceof Error && + (message === undefined || error.message.includes(message)) + ) + return error + throw witnessFailure(error) + } + throw witnessFailure( + `the mutant did not reject${message ? ` with "${message}"` : ``}`, ) - }) + } + + // The native dimension reuses the existing action model and work counter. + // At settlement compare intrinsic kind/text; empty-object equality is not + // evidence of a successful native write. No new transaction model is added. + async function runNativeActions({ + family, + rollback, + }: { + family: (typeof temporalFamilies)[number] + rollback: boolean + }): Promise { + vi.stubGlobal(`Temporal`, Temporal) + const host = fixture(100) + try { + const first = temporalValue(family, 0) + const last = temporalValue(family, 2) + const seed = transaction(`seed`, 1, [ + { type: `insert`, key: `a`, value: { stamp: first } }, + ]) + const candidate = transaction( + `candidate`, + 2, + [ + { + type: `update`, + key: `a`, + value: { stamp: temporalValue(family, 1) }, + metadataChanged: true, + metadata: first, + }, + { type: `update`, key: `a`, value: { stamp: last } }, + ], + [{ type: `set`, key: `a`, value: last }], + [{ type: `set`, key: `cursor`, value: first }], + ) + await host.adapter.applyCommittedTx( + { kind: `eager`, collectionId: `native-actions` }, + seed, + ) + if (rollback) host.failRunMatching(/INSERT INTO applied_tx/) + beginMeasuredWrite(host) + const pending = host.adapter.applyCommittedTx( + { kind: `eager`, collectionId: `native-actions` }, + candidate, + ) + if (rollback) + await oracleAssertion('existing comparison at 999', () => + expect(pending).rejects.toThrow( + `injected late bookkeeping failure`, + ), + ) + else await pending + const work = finishMeasuredWrite(host, `native actions`) + oracleAssertion('existing comparison at 1004', () => + expect(work.query + work.run).toBeLessThanOrEqual(30), + ) + const expected = observeTemporal( + modelAfter(rollback ? [seed] : [seed, candidate]), + ) + const actual = observeTemporal( + await observe(host.adapter, host.driver, `native-actions`), + ) + oracleAssertion('existing comparison at 1011', () => + expect(actual).toEqual(expected), + ) + oracleAssertion('existing comparison at 1012', () => + expect( + observeTemporal(structuredClone({ stamp: first })), + ).not.toEqual(observeTemporal({ stamp: first })), + ) + host.failRunMatching(undefined) + vi.stubGlobal(`Temporal`, undefined) + const invalid = transaction(`invalid`, 3, [ + { type: `update`, key: `a`, value: { stamp: first } }, + { type: `update`, key: `a`, value: { stamp: `overwritten` } }, + ]) + await oracleAssertion('existing comparison at 1021', () => + expect( + host.adapter.applyCommittedTx( + { kind: `eager`, collectionId: `native-actions` }, + invalid, + ), + ).rejects.toThrow(/Temporal/), + ) + vi.stubGlobal(`Temporal`, Temporal) + ;((observedBeforeComparison0) => + oracleAssertion('existing comparison at 1028', () => + expect(observeTemporal(observedBeforeComparison0)).toEqual( + expected, + ), + ))(await observe(host.adapter, host.driver, `native-actions`)) + } finally { + vi.unstubAllGlobals() + host.close() + } + } oracleProperty({ - id: `sqlite-ordinary.independent-work`, + id: `sqlite-ordinary.native-values`, law: { - statement: `The existing independent ordinary-write model judges durable rows and metadata at every original checkpoint, with the unchanged distinct-key work bound.`, - authority: `PersistedTx semantics and the ordinary-transaction work contract stated above.`, - limits: `The existing bounded node:sqlite history grammar; no new host, concurrency, or universal work claim.`, + ...ordinaryLaw, + limits: `The bounded Temporal families of temporal-value-oracle.ts, each committed or rolled back through repeated actions.`, }, owns: [`src/sqlite-core-adapter.ts`], - histories: { - arbitrary: independentWorkArbitrary, - runs: 24, - fixedSeeds: [31992], - }, + histories: temporalFamilies.flatMap((family) => + [false, true].map((rollback) => ({ family, rollback })), + ), + label: ({ family, rollback }) => + `preserves native values through repeated actions / ${family.name} / rollback=${rollback}`, resolve: (history) => ({ history }), model: recordedAssertionJudgment, driver: (plan, observe) => - recordAssertionJudgment( - `ordinary write semantics and work`, - plan, - observe, - () => assertIndependentWork(plan.history), + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + runNativeActions(plan.history), ), check: forwardAssertionJudgment, }) + + // The fixed generated-grammar histories that the old mutant witnesses used. + // Each runs clean through the generated driver; the calibrations run the + // driver's own wrong designs and extra-transaction faults on them. + const partialUpdate: GeneratedHistory = { + scenario: 'partial-update', + target: 'a', + prefix: [], + rowMetadataPrefix: [], + collectionMetadata: [], + rollback: false, + followup: false, + } + const partialUpdateWithFollowup: GeneratedHistory = { + ...partialUpdate, + followup: true, + } oracleProperty({ - id: `sqlite-ordinary.write-history`, + id: `sqlite-ordinary.write-history-fixed`, law: { - statement: `The existing independent ordinary-write model judges durable rows and metadata at every original checkpoint, with the unchanged distinct-key work bound.`, - authority: `PersistedTx semantics and the ordinary-transaction work contract stated above.`, - limits: `The existing bounded node:sqlite history grammar; no new host, concurrency, or universal work claim.`, + ...ordinaryLaw, + limits: `Two fixed partial-update histories of the generated grammar, with and without the dependent follow-up.`, }, owns: [`src/sqlite-core-adapter.ts`], - histories: { - arbitrary: generatedHistoryArbitrary, - runs: 24, - fixedSeeds: [1992], - }, + histories: [partialUpdate, partialUpdateWithFollowup], + label: (history) => + history.followup ? `partial-update with follow-up` : `partial-update`, resolve: (history) => ({ history }), model: recordedAssertionJudgment, driver: (plan, observe) => - recordAssertionJudgment( - `ordinary write semantics and work`, - plan, - observe, - () => assertGeneratedHistory(plan.history), + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + assertGeneratedHistory(plan.history), ), check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a wrong metadata-preservation design in the generated driver`, + law: ordinaryJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + ordinaryJudgment, + plan, + observe, + async () => { + if (plan.history.followup) return + const error = await expectMutantRejects(() => + assertGeneratedHistory(plan.history, true), + ) + rejectAt(`calibration metadata-preservation`, error) + }, + ), + }, + { + name: `rejects extra transactions at every generated write checkpoint`, + law: ordinaryJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + ordinaryJudgment, + plan, + observe, + async () => { + if (!plan.history.followup) return + for (const checkpoint of [ + 'seed', + 'candidate', + 'followup', + ] as const) { + await expectMutantRejects( + () => + assertGeneratedHistory(plan.history, false, checkpoint), + `generated ${checkpoint} checkpoint`, + ) + } + const error = await expectMutantRejects( + () => + assertGeneratedHistory( + { ...plan.history, rollback: true }, + false, + 'candidate', + ), + 'generated rollback checkpoint', + ) + rejectAt(`calibration extra transactions`, error) + }, + ), + }, + ], }) - it('rejects a wrong metadata-preservation design in the generated driver', async () => { - await oracleAssertion('existing comparison at 1139', () => - expect( - assertGeneratedHistory( - { - scenario: 'partial-update', - target: 'a', - prefix: [], - rowMetadataPrefix: [], - collectionMetadata: [], - rollback: false, - followup: false, - }, - true, - ), - ).rejects.toThrow(), - ) - }) - it('rejects extra transactions at every generated write checkpoint', async () => { - const history: GeneratedHistory = { - scenario: 'partial-update', - target: 'a', - prefix: [], - rowMetadataPrefix: [], - collectionMetadata: [], - rollback: false, - followup: true, - } - await assertGeneratedHistory(history) - for (const checkpoint of ['seed', 'candidate', 'followup'] as const) { - await oracleAssertion('existing comparison at 1167', () => - expect( - assertGeneratedHistory(history, false, checkpoint), - ).rejects.toThrow(`generated ${checkpoint} checkpoint`), - ) - } - await oracleAssertion('existing comparison at 1171', () => - expect( - assertGeneratedHistory( - { ...history, rollback: true }, - false, - 'candidate', - ), - ).rejects.toThrow('generated rollback checkpoint'), - ) - }) - it('rejects extra measured row calls at the independent-key work checkpoint', async () => { - const caseWithOneRow: IndependentWorkCase = { - shape: 'insert-later-metadata', - size: 1, - cap: 100, - } - await assertIndependentWork(caseWithOneRow) - await oracleAssertion('existing comparison at 1187', () => - expect(assertIndependentWork(caseWithOneRow, 20)).rejects.toThrow( - 'independent-key work checkpoint', + + const caseWithOneRow: IndependentWorkCase = { + shape: 'insert-later-metadata', + size: 1, + cap: 100, + } + oracleProperty({ + id: `sqlite-ordinary.independent-work-fixed`, + law: { + ...ordinaryLaw, + limits: `One fixed one-row independent-key case of the generated grammar.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [caseWithOneRow], + label: ({ shape, size, cap }) => `${shape} / ${size} / cap ${cap}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + assertIndependentWork(plan.history), ), - ) - }) - it('calibrates the complete-observation comparison', () => { - const expected = modelAfter([ - semanticCases[0]!.seed!, - semanticCases[0]!.candidate, - ]) - const wrong = clone(expected) - wrong.rows[0]!.metadata = undefined - oracleAssertion('existing comparison at 1199', () => - expect(sameObservation(expected, expected)).toBe(true), - ) - oracleAssertion('existing comparison at 1200', () => - expect(sameObservation(wrong, expected)).toBe(false), - ) + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects extra measured row calls at the independent-key work checkpoint`, + law: ordinaryJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + ordinaryJudgment, + plan, + observe, + async () => { + const error = await expectMutantRejects( + () => assertIndependentWork(plan.history, 20), + 'independent-key work checkpoint', + ) + rejectAt(`calibration extra row calls`, error) + }, + ), + }, + ], }) - it('refines durable semantics for the bounded process and design grammar; ordinary large writes are chunk-bounded', async () => { + + // The fixed process and design cases, each through the real adapter and + // the model, then the work bound over all of them, so the RED identifies + // work after every semantic comparison. + async function refineFixedCases(): Promise { const workObservations: Array<{ case: string calls: number @@ -1381,8 +1362,10 @@ export function runOrdinaryTransactionWorkOracle(): void { oracleAssertion('existing comparison at 1291', () => expect(atSize(126).maxParams).toBe(500), ) - }) - it('rejects a writer that discards existing metadata while batching', async () => { + } + // A hostile writer that discards existing metadata while batching: the + // model must reject its durable rows. + async function judgeMetadataDiscardingWriter(): Promise { const host = fixture(100) const collectionId = 'oracle-hostile-metadata' let primary: unknown @@ -1424,8 +1407,10 @@ export function runOrdinaryTransactionWorkOracle(): void { throw cleanup } if (primary !== undefined) throw primary - }) - it('rejects a low-call replacement that loses metadata', async () => { + } + // A test-only low-call replacement that loses metadata: the model must + // reject its durable rows. + async function judgeLowCallReplacement(): Promise { const host = fixture(100) const collectionId = 'oracle-hostile-batch' let primary: unknown @@ -1500,8 +1485,8 @@ export function runOrdinaryTransactionWorkOracle(): void { throw cleanup } if (primary !== undefined) throw primary - }) - it('treats a repeated committed position as an idempotent write', async () => { + } + async function judgeIdempotentPosition(): Promise { const host = fixture(100) const collectionId = 'oracle-idempotent-position' let primary: unknown @@ -1544,8 +1529,8 @@ export function runOrdinaryTransactionWorkOracle(): void { throw cleanup } if (primary !== undefined) throw primary - }) - it('rolls back a fault after row and metadata statements but before applied position', async () => { + } + async function judgeLateBookkeepingFault(): Promise { const host = fixture(100) const collectionId = 'oracle-late-bookkeeping-fault' let primary: unknown @@ -1601,8 +1586,8 @@ export function runOrdinaryTransactionWorkOracle(): void { throw cleanup } if (primary !== undefined) throw primary - }) - it('rolls back rows, metadata, evidence and position after a later row-write fault', async () => { + } + async function judgeLaterRowWriteFault(): Promise { const host = fixture(100) const collectionId = 'oracle-later-fault' let primary: unknown @@ -1674,6 +1659,170 @@ export function runOrdinaryTransactionWorkOracle(): void { throw cleanup } if (primary !== undefined) throw primary + } + oracleProperty({ + id: `sqlite-ordinary.fixed-cases`, + law: { + ...ordinaryLaw, + limits: `The fixed size, parameter-cap, and semantic cases, each through its own adapter in order, then the work bound over all of them.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { + name: `refines durable semantics for the bounded process and design grammar; ordinary large writes are chunk-bounded`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + refineFixedCases(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.metadata-discarding-writer`, + law: { + ...ordinaryLaw, + limits: `The first semantic case with a writer that discards existing metadata while batching.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { + name: `rejects a writer that discards existing metadata while batching`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + judgeMetadataDiscardingWriter(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.low-call-replacement`, + law: { + ...ordinaryLaw, + limits: `A 26-row replacement through a test-only writer that bypasses applyCommittedTx.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { name: `rejects a low-call replacement that loses metadata` }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + judgeLowCallReplacement(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.idempotent-position`, + law: { + ...ordinaryLaw, + limits: `One delete reapplied at the same committed position.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { name: `treats a repeated committed position as an idempotent write` }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + judgeIdempotentPosition(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.late-bookkeeping-fault`, + law: { + ...ordinaryLaw, + limits: `One fault after the row and metadata statements, before the applied position.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { + name: `rolls back a fault after row and metadata statements but before applied position`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + judgeLateBookkeepingFault(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.later-row-write-fault`, + law: { + ...ordinaryLaw, + limits: `One 51-row transaction that fails after 25 row writes.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: [ + { + name: `rolls back rows, metadata, evidence and position after a later row-write fault`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(ordinaryJudgment, plan, observe, () => + judgeLaterRowWriteFault(), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.independent-work`, + law: { + statement: `The existing independent ordinary-write model judges durable rows and metadata at every original checkpoint, with the unchanged distinct-key work bound.`, + authority: `PersistedTx semantics and the ordinary-transaction work contract stated above.`, + limits: `The existing bounded node:sqlite history grammar; no new host, concurrency, or universal work claim.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: { + arbitrary: independentWorkArbitrary, + runs: 24, + fixedSeeds: [31992], + }, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `ordinary write semantics and work`, + plan, + observe, + () => assertIndependentWork(plan.history), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `sqlite-ordinary.write-history`, + law: { + statement: `The existing independent ordinary-write model judges durable rows and metadata at every original checkpoint, with the unchanged distinct-key work bound.`, + authority: `PersistedTx semantics and the ordinary-transaction work contract stated above.`, + limits: `The existing bounded node:sqlite history grammar; no new host, concurrency, or universal work claim.`, + }, + owns: [`src/sqlite-core-adapter.ts`], + histories: { + arbitrary: generatedHistoryArbitrary, + runs: 24, + fixedSeeds: [1992], + }, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `ordinary write semantics and work`, + plan, + observe, + () => assertGeneratedHistory(plan.history), + ), + check: forwardAssertionJudgment, }) }) } From 90c5d722cbcfa0b84ed715fb2bcc45a910f77026 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:30:44 -0700 Subject: [PATCH 085/116] test(db): register Collection-valued includes fixed histories, cases, and calibrations through oracleProperty The exhaustive two-step domain becomes one enumerated history per initial state and action sequence. Move the facade-interval checker calibration to the ordinary Collection includes tests. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 17 + ...ncludes-collection-oracle.property.test.ts | 2267 +++++++++-------- .../tests/query/includes-collection.test.ts | 79 +- 3 files changed, 1367 insertions(+), 996 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 35055169dd..64f0e10ce3 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -55,6 +55,18 @@ export const properties: ReadonlyArray = [ `virtual-row-fields.facade-intervals`, `virtual-row-fields.nullable-facade`, `virtual-row-fields.runtime-boundary`, + `includes-collection.retired-held-facade`, + `includes-collection.delete-facade-identity`, + `includes-collection.facade-application-failure`, + `includes-collection.facade-root-publication`, + `includes-collection.shared-facade-retirement`, + `includes-collection.held-truncate-reorder`, + `includes-collection.fixed-relationship`, + `includes-collection.exhaustive-two-step`, + `includes-collection.checkpoint-failure`, + `includes-collection.held-ordered-repair`, + `includes-collection.root-event-observation`, + `includes-collection.rollback-retained-child`, ] export const owners: ReadonlyArray = [ @@ -128,4 +140,9 @@ export const owners: ReadonlyArray = [ `virtual-row-fields`, `boundary-publication projection-attribution last-spread union-branches metadata-timeline facade-intervals nullable-facade runtime-boundary`, ], + [ + `db/tests/query/includes-collection-oracle.property.test.ts`, + `includes-collection`, + `retired-held-facade delete-facade-identity facade-application-failure facade-root-publication shared-facade-retirement held-truncate-reorder fixed-relationship exhaustive-two-step checkpoint-failure held-ordered-repair root-event-observation rollback-retained-child`, + ], ] 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 92e6fe1084..7ab4043564 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -1,6 +1,12 @@ -import { fc, test as fcTest } from '@fast-check/vitest' -import { describe, expect, test } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { fc } from '@fast-check/vitest' +import { describe, expect } from 'vitest' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { @@ -13,8 +19,7 @@ import { } from '../../src/query/index.js' import { runTrace } from '../trace-runner.js' import { withHistoryCleanup } from '../optimistic-history-oracle.js' -import { oracleRoute } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { oracleProperty } from '../oracle-config.js' import { flushPromises, withExpectedRejection } from '../utils.js' import { registerIncludesProperty } from './includes-registration-oracle.js' import { @@ -329,18 +334,6 @@ function expectedOrderedRows( .map(({ id, value }) => ({ id, value })) } -// The model derives the expected parent rows from the current source Maps and -// 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], -}) - const actionArbitrary: fc.Arbitrary = fc.oneof( fc.record({ type: fc.constant(`putParent` as const), @@ -426,574 +419,1199 @@ const layoutSwapProperty = `includes-collection.layout-swap` const optimisticChildProperty = `includes-collection.optimistic-child-history` -describe(`Collection-valued includes oracle`, () => { - fcTest( - `root event observations reject missing, repeated, and corrupt changed-row events`, +const retiredHeldFacadeLaw = `Retiring a route leaves a facade that a holder still references empty and ready, with no later events.` +const deleteFacadeIdentityLaw = `A delete event carries the facade identity that the deleted parent published.` +const facadeApplicationFailureLaw = `A failed facade application restores every facade it wrote and publishes no partial state or event.` +const facadeRootPublicationLaw = `Facade events observe the root publication they belong to.` +const sharedFacadeRetirementLaw = `A shared facade stays active until its last parent route departs.` +const heldTruncateReorderLaw = `A held child truncate replay publishes the final reorder through the facade once, against the last public snapshot.` +const fixedRelationshipLaw = `Collection, toArray, and materialize results match the recomputed relationship model after every source change.` +const exhaustiveLaw = `Every two-step history in the smallest relationship domain keeps Collection, toArray, and materialize equivalent to the recomputed model.` +const checkpointFailureLaw = `An optimistic insert or delete that fails at a checkpoint rolls back once, leaves no pending work, cleans up every Collection, and keeps a rollback failure as a suppressed error.` +const heldOrderedRepairLaw = `A held ordered child repair publishes the final child row through the facade once, against the last public snapshot.` +const rootEventObservationLaw = `Root event observations match the recomputed changed-row events exactly: a missing, repeated, or corrupt event is a comparison failure.` +const rollbackRetainedChildLaw = `After an optimistic child insert rolls back, the published child rows match the recomputation without it.` + +// Named fixed relationship histories, replayed through the shared Collection +// driver and recomputation. +const fixedRelationshipHistories: Array<{ + name: string + parents: Array + children: Array + reuseChildRelation: boolean + steps: Array +}> = [ + { + name: `publishes independently compiled equivalent child relations coherently`, + parents: [{ id: 0, group: 0 }], + children: [{ id: 10, parentGroup: 0, value: 0 }], + reuseChildRelation: false, + steps: [{ type: `deleteChild`, id: 10 }], + }, + { + name: `replays a dormant bucket when its first parent route activates`, + parents: [], + children: [{ id: 10, parentGroup: 7, value: 1 }], + reuseChildRelation: true, + steps: [{ type: `putParent`, row: { id: 1, group: 7 } }], + }, + { + name: `reactivating a retired route restores its current snapshot`, + parents: [{ id: 1, group: 1 }], + children: [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 2, value: 2 }, + ], + reuseChildRelation: true, + steps: [ + { type: `putParent`, row: { id: 1, group: 2 } }, + { type: `putParent`, row: { id: 1, group: 1 } }, + ], + }, +] + +// The exhaustive two-step domain: every initial state crossed with every action +// sequence of length 0..2 over the six exhaustive actions. Each pair is one +// enumerated history, judged by the same trace driver and recomputation. +const exhaustiveInitialStates = [ + { parents: [] as Array, children: [] as Array }, + { parents: [{ id: 0, group: 0 }], children: [] as Array }, + { + parents: [] as Array, + children: [{ id: 10, parentGroup: 0, value: 0 }], + }, + { + parents: [{ id: 0, group: 0 }], + children: [{ id: 10, parentGroup: 0, value: 0 }], + }, +] +const exhaustiveHistories = exhaustiveInitialStates.flatMap((initial) => + enumerateActionSequences(exhaustiveActions, 2).map((steps) => ({ + initial, + steps, + })), +) + +// The production half of the former root-event calibration: one child value +// update, whose observation the shared comparison accepts, with one event. +async function observeChildValueUpdate( + inspect: ( + observed: CollectionObservation, + expected: CollectionObservation, + ) => void, +): Promise { + const driver = createCollectionDriver( + [{ id: 1, group: 1 }], + [{ id: 10, parentGroup: 1, value: 1 }], + ) + const context = await driver.setup() + try { + await driver.start?.(context) + await driver.apply( + { type: `putChild`, row: { id: 10, parentGroup: 1, value: 2 } }, + context, + () => undefined, + ) + const observed = collectionProjection.observe(context) + const expected = collectionProjection.recompute(context) + collectionProjection.assertEqual(observed, expected) + expect(expected.events).toHaveLength(1) + inspect(observed, expected) + } finally { + await driver.cleanup(context) + } +} + +const rootEventFaults = [ + `missing`, + `duplicate`, + `value`, + `previousValue`, + `type`, +] as const + +// The production half of the former rollback calibration: an optimistic +// child insert that rolls back, whose observation the shared comparison +// accepts. +async function observeRolledBackChild( + inspect: ( + observed: CollectionObservation, + expected: CollectionObservation, + row: ChildRow, + ) => void, +): Promise { + const row = { id: 10, parentGroup: 1, value: 2 } + const { driver } = createOptimisticCollectionDriver(1) + const context = await driver.setup() + await withHistoryCleanup( async () => { - oracleRoute(`calibration`) - const driver = createCollectionDriver( - [{ id: 1, group: 1 }], - [{ id: 10, parentGroup: 1, value: 1 }], + await driver.start?.(context) + await driver.apply( + { type: `insert`, row, settlement: `rollback` }, + context, + () => undefined, ) - const context = await driver.setup() - try { - await driver.start?.(context) - await driver.apply( - { type: `putChild`, row: { id: 10, parentGroup: 1, value: 2 } }, - context, - () => undefined, - ) - const observed = collectionProjection.observe(context) - const expected = collectionProjection.recompute(context) - collectionProjection.assertEqual(observed, expected) - expect(expected.events).toHaveLength(1) - for (const fault of [ - `missing`, - `duplicate`, - `value`, - `previousValue`, - `type`, - ] as const) { - const bad = structuredClone(observed) - const event = bad.events[0]! - if (fault === `missing`) bad.events = [] - else if (fault === `duplicate`) - bad.events.push(structuredClone(event)) - else if (fault === `value`) event.value.arrayChildren[0]!.value = -999 - else if (fault === `previousValue`) - event.previousValue!.materializedChildren[0]!.value = -999 - else event.type = `insert` - // The previous checker accepted all these payload/count corruptions. - expect(bad.rows).toEqual(expected.rows) - expect(bad.publications).toEqual(expected.publications) - expect(() => - collectionProjection.assertEqual(bad, expected), - ).toThrowError(expect.objectContaining({ name: `AssertionError` })) - } - } finally { - await driver.cleanup(context) - } + const observed = collectionProjection.observe(context) + const expected = collectionProjection.recompute(context) + collectionProjection.assertEqual(observed, expected) + inspect(observed, expected, row) }, + () => [() => driver.cleanup(context)], ) +} - for (const kind of [`insert`, `delete`] as const) { - for (const failRollback of [false, true]) { - 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, - kind === `delete` ? [row] : [], - ) - const primary: Error & { suppressed?: Array } = new Error( - `checkpoint failed`, - ) - const cleanupError = new Error(`rollback observer failed`) - let transaction: PendingOptimisticMutation[`transaction`] | undefined - let context: CollectionContext | undefined - let rollbackCalls = 0 - const cleanupCalls: Array = [] - const failureDriver: typeof driver = { - ...driver, - async setup() { - context = await driver.setup() - for (const [name, collection] of [ - [`live`, context.live], - [`parents`, context.parents.collection], - [`children`, context.children.collection], - ] as const) { - const original = collection.cleanup.bind(collection) - collection.cleanup = async () => { - cleanupCalls.push(name) - await original() - } - } - return context - }, +// The former fixed-history body, unchanged. +async function judgeRetiredHeldFacade(): Promise { + let retiredFacade: IncludedChildCollection | undefined + const heldSnapshots: Array<{ + status: string | undefined + rows: Array | undefined + }> = [] + const driver = createCollectionDriver( + [{ id: 1, group: 1 }], + [{ id: 10, parentGroup: 1, value: 1 }], + ) + const lifecycleDriver: TraceDriver = { + ...driver, + apply(action, context, checkpoint) { + retiredFacade ??= context.live.get(1)?.children + expect(retiredFacade).toBeDefined() + checkpoint() + return driver.apply(action, context, checkpoint) + }, + } + const projection: TraceProjection< + CollectionContext, + { + rows: Array + held: { status: string | undefined; rows: Array | undefined } + } + > = { + observe: (context) => { + const held = { + status: retiredFacade?.status, + rows: retiredFacade?.toArray.map(({ id, parentGroup, value }) => ({ + id, + parentGroup, + value, + })), + } + heldSnapshots.push(held) + return { rows: projectLive(context.live), held } + }, + recompute: (context) => ({ + rows: recompute(context), + held: retiredFacade + ? { + status: `ready`, + rows: + context.model.parents.size === 0 + ? [] + : [{ id: 10, parentGroup: 1, value: 1 }], } - const projection: typeof collectionProjection = { - ...collectionProjection, - assertEqual(observed, expected) { - collectionProjection.assertEqual(observed, expected) - const entry = [...pending][0] - if (entry) { - transaction = entry.transaction - expect(transaction.state).toBe(`persisting`) - const rollback = transaction.rollback.bind(transaction) - transaction.rollback = ((options) => { - rollbackCalls++ - const result = rollback(options) - if (failRollback) throw cleanupError - return result - }) as typeof transaction.rollback - throw primary - } - return undefined - }, + : { status: undefined, rows: undefined }, + }), + assertEqual(observed, expected) { + expect(observed).toEqual(expected) + return undefined + }, + } + + await runTrace({ + steps: [{ type: `deleteParent`, id: 1 }], + driver: lifecycleDriver, + projection, + }) + + expect(heldSnapshots).toHaveLength(3) + expect(heldSnapshots[1]).toEqual({ + status: `ready`, + rows: [{ id: 10, parentGroup: 1, value: 1 }], + }) + assertRetiredFacade(heldSnapshots[2]!) + expect(retiredFacade?.toArray).toEqual([]) + await expect(retiredFacade?.preload()).resolves.toBeUndefined() +} + +// The former fixed-history body, unchanged. +async function judgeDeleteFacadeIdentity(): Promise { + let publishedFacade: IncludedChildCollection | undefined + const events: Array = [] + const eventSnapshots: Array> = [] + let identitySubscription: { unsubscribe: () => void } | undefined + const driver = createCollectionDriver( + [{ id: 1, group: 1 }], + [{ id: 10, parentGroup: 1, value: 1 }], + ) + const eventDriver: TraceDriver = { + ...driver, + async start(context) { + await driver.start?.(context) + publishedFacade = context.live.get(1)?.children + expect(publishedFacade).toBeDefined() + identitySubscription = context.live.subscribeChanges( + (changes) => { + for (const change of changes) { + events.push({ + type: change.type, + key: change.key, + id: change.value.id, + matchesPublishedFacade: change.value.children === publishedFacade, + }) } - const action: OptimisticAction = - kind === `insert` - ? { type: `insert`, row, settlement: `confirm` } - : { type: `delete`, id: row.id, settlement: `confirm` } - await expect( - runTrace({ steps: [action], driver: failureDriver, projection }), - ).rejects.toBe(primary) - expect(rollbackCalls).toBe(1) - expect(transaction?.state).toBe(`failed`) - expect(pending.size).toBe(0) - expect(cleanupCalls).toEqual([`live`, `parents`, `children`]) - expect(context?.live.status).toBe(`cleaned-up`) - expect(context?.parents.collection.status).toBe(`cleaned-up`) - expect(context?.children.collection.status).toBe(`cleaned-up`) - expect(primary.suppressed).toEqual( - failRollback ? [cleanupError] : undefined, - ) }, + { includeInitialState: false }, ) - } + }, + async cleanup(context) { + try { + identitySubscription?.unsubscribe() + } finally { + await driver.cleanup(context) + } + }, + } + const projection: TraceProjection< + CollectionContext, + Array, + boolean + > = { + observe: () => { + const snapshot = events.map((event) => ({ ...event })) + eventSnapshots.push(snapshot) + return snapshot + }, + recompute: (context) => !context.model.parents.has(1), + assertEqual(observed, expected) { + assertDeleteIdentityEvents(observed, expected) + return undefined + }, } - const runRelationshipHistory = ({ - parentGroup, - childValue, - actions, - }: SampleOf) => - runTrace({ - steps: actions, - driver: createCollectionDriver( - [{ id: 0, group: parentGroup }], - [{ id: 10, parentGroup, value: childValue }], - ), - projection: collectionProjection, - }) + await runTrace({ + steps: [{ type: `deleteParent`, id: 1 }], + driver: eventDriver, + projection, + }) + expect(eventSnapshots).toHaveLength(2) + assertDeleteIdentityEvents(eventSnapshots[0]!, false) + assertDeleteIdentityEvents(eventSnapshots[1]!, true) +} - registerIncludesProperty( - [collectionScenarioArbitrary], - { property: relationshipProperty, runs: 30, fixedSeed: 1658101 }, - `keeps Collection, toArray, and materialize equivalent across fixed relationship histories`, - (...args: Parameters) => { - return runRelationshipHistory(...args) - }, +// The former fixed-history body, unchanged. +async function judgeFacadeApplicationFailure(): Promise { + const driver = createCollectionDriver( + [{ id: 1, group: 1 }], + [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 1, value: 2 }, + ], + ) + const context = await driver.setup() + await driver.start?.(context) + const facade = context.live.get(1)!.children + const changes: Array = [] + const subscription = facade.subscribeChanges( + (batch) => changes.push(...batch), + { includeInitialState: false }, ) + const originalGetKey = facade.config.getKey + facade.config.getKey = (row) => { + if (row.id === 20) throw new Error(`facade key failed`) + return originalGetKey(row) + } - 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 }, + try { + expect(() => + context.children.writeBatch([ { - parents: [] as Array, - children: [{ id: 10, parentGroup: 0, value: 0 }], + type: `update`, + value: { id: 10, parentGroup: 1, value: 10 }, }, { - parents: [{ id: 0, group: 0 }], - children: [{ id: 10, parentGroup: 0, value: 0 }], + type: `update`, + value: { id: 20, parentGroup: 1, value: 20 }, }, - ] - const histories = enumerateActionSequences(exhaustiveActions, 2) - - for (const initial of initialStates) { - for (const steps of histories) { - try { - await runTrace({ - steps, - driver: createCollectionDriver(initial.parents, initial.children), - projection: collectionProjection, - }) - } catch (cause) { - throw new Error( - `Exhaustive Collection include history failed: ${JSON.stringify({ initial, steps })}`, - { cause }, - ) - } - } - } - }, - ) + ]), + ).toThrow(`facade key failed`) + expect(projectLive(context.live)).toEqual([ + { + id: 1, + group: 1, + childrenReady: true, + children: [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 1, value: 2 }, + ], + arrayChildren: [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 1, value: 2 }, + ], + materializedChildren: [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 1, value: 2 }, + ], + }, + ]) + expect(changes).toEqual([]) + + facade.config.getKey = originalGetKey + context.parents.write(`insert`, { id: 2, group: 2 }) + await flushPromises() + expect(context.live.get(1)!.children).toBe(facade) + expect(projectLive(context.live)[0]!.children).toEqual([ + { id: 10, parentGroup: 1, value: 10 }, + { id: 20, parentGroup: 1, value: 20 }, + ]) + expect(changes).toHaveLength(2) + } finally { + facade.config.getKey = originalGetKey + subscription.unsubscribe() + await driver.cleanup(context) + } +} - fcTest( - `publishes independently compiled equivalent child relations coherently`, - () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [{ type: `deleteChild`, id: 10 }], - driver: createCollectionDriver( - [{ id: 0, group: 0 }], - [{ id: 10, parentGroup: 0, value: 0 }], - false, - ), - projection: collectionProjection, - }) - }, +// The former fixed-history body, unchanged. +async function judgeFacadeRootPublication(): Promise { + const driver = createCollectionDriver( + [{ id: 1, group: 1 }], + [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 2, value: 2 }, + ], ) - - fcTest( - `replays a dormant bucket when its first parent route activates`, + const context = await driver.setup() + await driver.start?.(context) + const oldFacade = context.live.get(1)!.children + const callbackSnapshots: Array = [] + const subscription = oldFacade.subscribeChanges( () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [{ type: `putParent`, row: { id: 1, group: 7 } }], - driver: createCollectionDriver( - [], - [{ id: 10, parentGroup: 7, value: 1 }], - ), - projection: collectionProjection, + const current = context.live.get(1) + callbackSnapshots.push({ + group: current?.group, + usesOldFacade: current?.children === oldFacade, + rows: current ? [...current.children.keys()] : [], }) }, + { includeInitialState: false }, ) - 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 - rows: Array | undefined - }> = [] - const driver = createCollectionDriver( - [{ id: 1, group: 1 }], - [{ id: 10, parentGroup: 1, value: 1 }], + try { + context.parents.write(`update`, { id: 1, group: 2 }) + assertFacadeRootSnapshots(callbackSnapshots) + } finally { + subscription.unsubscribe() + await driver.cleanup(context) + } +} + +// The former fixed-history body, unchanged. +async function judgeSharedFacadeRetirement(): Promise { + const driver = createCollectionDriver( + [ + { id: 1, group: 1 }, + { id: 2, group: 1 }, + ], + [{ id: 10, parentGroup: 1, value: 1 }], + ) + const context = await driver.setup() + await driver.start?.(context) + const sharedFacade = context.live.get(1)!.children + let intervals: FacadeIntervals = { active: new Map(), next: 0 } + const observedIntervals = new Map() + const checkIntervals = (groups: ReadonlyArray) => { + intervals = advanceFacadeIntervals(intervals, groups) + checkFacadeIntervals( + intervals, + context.live.toArray.map((row) => ({ + group: row.group, + facade: row.children, + })), + observedIntervals, ) - const lifecycleDriver: TraceDriver = { - ...driver, - apply(action, context, checkpoint) { - retiredFacade ??= context.live.get(1)?.children - expect(retiredFacade).toBeDefined() - checkpoint() - return driver.apply(action, context, checkpoint) - }, + } + + try { + checkIntervals([1, 1]) + expect(context.live.get(2)!.children).toBe(sharedFacade) + context.parents.write(`delete`, { id: 1, group: 1 }) + checkIntervals([1]) + expect(context.live.get(2)!.children).toBe(sharedFacade) + expect([...sharedFacade.keys()]).toEqual([10]) + expect(sharedFacade.status).toBe(`ready`) + + context.parents.write(`delete`, { id: 2, group: 1 }) + checkIntervals([]) + expect(context.live.toArray).toEqual([]) + expect(sharedFacade.toArray).toEqual([]) + expect(sharedFacade.status).toBe(`ready`) + + context.children.write(`insert`, { id: 20, parentGroup: 1, value: 2 }) + checkIntervals([]) + expect(context.live.toArray).toEqual([]) + expect(sharedFacade.toArray).toEqual([]) + expect(sharedFacade.status).toBe(`ready`) + + context.parents.write(`insert`, { id: 1, group: 1 }) + checkIntervals([1]) + const reactivated = context.live.get(1)!.children + expect(reactivated).not.toBe(sharedFacade) + const assertCurrent = (value: number) => { + checkIntervals([1]) + const rows = [ + { id: 10, parentGroup: 1, value: 1 }, + { id: 20, parentGroup: 1, value }, + ] + expect(projectLive(context.live)).toEqual([ + { + id: 1, + group: 1, + childrenReady: true, + children: rows, + arrayChildren: rows, + materializedChildren: rows, + }, + ]) + expect(context.live.get(1)!.children).toBe(reactivated) + expect(sharedFacade.toArray).toEqual([]) + expect(sharedFacade.status).toBe(`ready`) } - const projection: TraceProjection< - CollectionContext, - { - rows: Array - held: { status: string | undefined; rows: Array | undefined } - } - > = { - observe: (context) => { - const held = { - status: retiredFacade?.status, - rows: retiredFacade?.toArray.map(({ id, parentGroup, value }) => ({ - id, - parentGroup, - value, - })), + assertCurrent(2) + context.children.write(`update`, { id: 20, parentGroup: 1, value: 3 }) + assertCurrent(3) + } finally { + await driver.cleanup(context) + } +} + +// The former fixed-history body, unchanged. +async function judgeHeldTruncateReorder(): Promise { + type OrderedChild = { + id: number + parentGroup: number + rank: number + value: string + } + const sourceRows = new Map([ + [10, { id: 10, parentGroup: 1, rank: 1, value: `A` }], + [20, { id: 20, parentGroup: 1, rank: 2, value: `X` }], + ]) + // Rank is absent from the facade row but retained in this source model: + // two otherwise equal rows with different ranks yield different layouts. + // The provider receives copies, so it cannot mutate the expected rows. + const expectedRows = () => expectedOrderedRows(sourceRows) + + let sync!: Parameters[`sync`]>[0] + let holdReplay = false + const held: Array<() => void> = [] + let requests = 0 + const children = createCollection({ + id: `held-child-facade-replay`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: () => { + requests++ + if (requests <= 2) { + sync.begin() + for (const row of sourceRows.values()) { + sync.write({ type: `insert`, value: { ...row } }) + } + expect(sync.commit()).toBe(true) + } + return holdReplay + ? new Promise((resolve) => held.push(resolve)) + : true + }, + unloadSubset: () => {}, } - heldSnapshots.push(held) - return { rows: projectLive(context.live), held } }, - recompute: (context) => ({ - rows: recompute(context), - held: retiredFacade - ? { - status: `ready`, - rows: - context.model.parents.size === 0 - ? [] - : [{ id: 10, parentGroup: 1, value: 1 }], - } - : { status: undefined, rows: undefined }, - }), - assertEqual(observed, expected) { - expect(observed).toEqual(expected) - return undefined + }, + }) + const parents = createControlledCollection(`held-child-facade-parent`, [ + { id: 1, group: 1 }, + ]) + const live = createLiveQueryCollection((q) => + q.from({ parent: parents.collection }).select(({ parent }) => ({ + id: parent.id, + children: include( + q + .from({ child: children }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + .orderBy(({ child }) => child.rank) + .limit(2) + .select(({ child }) => ({ id: child.id, value: child.value })), + ), + })), + ) + const callbacks: Array<{ + rows: Array<{ id: number; value: string }> + changes: Array<{ type: string; key: string | number }> + }> = [] + const rootCallbacks: Array = [] + let unsubscribeFacade: (() => void) | undefined + let unsubscribeRoot: (() => void) | undefined + + await withHistoryCleanup( + async () => { + await live.preload() + const facade = live.get(1)!.children + const before = expectedRows() + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + before, + ) + const facadeSubscription = facade.subscribeChanges( + (changes) => { + callbacks.push({ + rows: facade.toArray.map(({ id, value }) => ({ id, value })), + changes: changes.map(({ type, key }) => ({ type, key })), + }) + }, + { includeInitialState: false }, + ) + unsubscribeFacade = () => facadeSubscription.unsubscribe() + const rootSubscription = live.subscribeChanges( + (changes) => rootCallbacks.push(...changes), + { includeInitialState: false }, + ) + unsubscribeRoot = () => rootSubscription.unsubscribe() + + holdReplay = true + sync.begin() + sync.truncate() + expect(sync.commit()).toBe(true) + await flushPromises() + expect(requests).toBe(2) + expect(held.length).toBeGreaterThan(0) + const write = (row: OrderedChild) => { + sourceRows.set(row.id, { ...row }) + sync.begin() + sync.write({ type: `update`, value: { ...row } }) + expect(sync.commit()).toBe(true) + } + write({ id: 10, parentGroup: 1, rank: 2.5, value: `A` }) + write({ id: 10, parentGroup: 1, rank: 2.5, value: `B` }) + write({ id: 10, parentGroup: 1, rank: 2.5, value: `A` }) + await flushPromises() + expect(held.length).toBeGreaterThan(0) + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + before, + ) + expect(callbacks).toEqual([]) + + holdReplay = false + for (const release of held.splice(0)) release() + await flushPromises() + const after = expectedRows() + expect(live.get(1)!.children).toBe(facade) + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + after, + ) + const expectedCallbacks = [{ rows: after, changes: [] }] + expect(callbacks).toEqual(expectedCallbacks) + // A facade that omits its layout callback would pass the final read. + expect(() => + expect(callbacks.slice(1)).toEqual(expectedCallbacks), + ).toThrow() + expect(rootCallbacks).toEqual([]) + }, + () => [ + () => { + holdReplay = false + for (const release of held.splice(0)) release() }, - } - - await runTrace({ - steps: [{ type: `deleteParent`, id: 1 }], - driver: lifecycleDriver, - projection, - }) + () => unsubscribeFacade?.(), + () => unsubscribeRoot?.(), + () => live.cleanup(), + () => parents.collection.cleanup(), + () => children.cleanup(), + ], + ) +} - expect(heldSnapshots).toHaveLength(3) - expect(heldSnapshots[1]).toEqual({ - status: `ready`, - rows: [{ id: 10, parentGroup: 1, value: 1 }], - }) - assertRetiredFacade(heldSnapshots[2]!) - expect(retiredFacade?.toArray).toEqual([]) - await expect(retiredFacade?.preload()).resolves.toBeUndefined() - }) +// The former case body, unchanged, for one action kind and rollback outcome. +async function judgeCheckpointFailureSettlement( + kind: `insert` | `delete`, + failRollback: boolean, +): Promise { + const row = { id: 10, parentGroup: 1, value: 1 } + const { driver, pending } = createOptimisticCollectionDriver( + 1, + kind === `delete` ? [row] : [], + ) + const primary: Error & { suppressed?: Array } = new Error( + `checkpoint failed`, + ) + const cleanupError = new Error(`rollback observer failed`) + let transaction: PendingOptimisticMutation[`transaction`] | undefined + let context: CollectionContext | undefined + let rollbackCalls = 0 + const cleanupCalls: Array = [] + const failureDriver: typeof driver = { + ...driver, + async setup() { + context = await driver.setup() + for (const [name, collection] of [ + [`live`, context.live], + [`parents`, context.parents.collection], + [`children`, context.children.collection], + ] as const) { + const original = collection.cleanup.bind(collection) + collection.cleanup = async () => { + cleanupCalls.push(name) + await original() + } + } + return context + }, + } + const projection: typeof collectionProjection = { + ...collectionProjection, + assertEqual(observed, expected) { + collectionProjection.assertEqual(observed, expected) + const entry = [...pending][0] + if (entry) { + transaction = entry.transaction + expect(transaction.state).toBe(`persisting`) + const rollback = transaction.rollback.bind(transaction) + transaction.rollback = ((options) => { + rollbackCalls++ + const result = rollback(options) + if (failRollback) throw cleanupError + return result + }) as typeof transaction.rollback + throw primary + } + return undefined + }, + } + const action: OptimisticAction = + kind === `insert` + ? { type: `insert`, row, settlement: `confirm` } + : { type: `delete`, id: row.id, settlement: `confirm` } + await expect( + runTrace({ steps: [action], driver: failureDriver, projection }), + ).rejects.toBe(primary) + expect(rollbackCalls).toBe(1) + expect(transaction?.state).toBe(`failed`) + expect(pending.size).toBe(0) + expect(cleanupCalls).toEqual([`live`, `parents`, `children`]) + expect(context?.live.status).toBe(`cleaned-up`) + expect(context?.parents.collection.status).toBe(`cleaned-up`) + expect(context?.children.collection.status).toBe(`cleaned-up`) + expect(primary.suppressed).toEqual(failRollback ? [cleanupError] : undefined) +} - fcTest(`a delete event preserves the published facade identity`, async () => { - oracleRoute(`fixed history`) - let publishedFacade: IncludedChildCollection | undefined - const events: Array = [] - const eventSnapshots: Array> = [] - let identitySubscription: { unsubscribe: () => void } | undefined - const driver = createCollectionDriver( - [{ id: 1, group: 1 }], - [{ id: 10, parentGroup: 1, value: 1 }], - ) - const eventDriver: TraceDriver = { - ...driver, - async start(context) { - await driver.start?.(context) - publishedFacade = context.live.get(1)?.children - expect(publishedFacade).toBeDefined() - identitySubscription = context.live.subscribeChanges( - (changes) => { - for (const change of changes) { - events.push({ - type: change.type, - key: change.key, - id: change.value.id, - matchesPublishedFacade: - change.value.children === publishedFacade, - }) +// The former case body, unchanged, for one intermediate and final value. +async function judgeHeldOrderedChildRepair( + intermediate: string, + final: string, +): Promise { + type OrderedChild = { + id: number + parentGroup: number + rank: number + value: string + } + const sourceRows = new Map([ + [10, { id: 10, parentGroup: 1, rank: 1, value: `A` }], + [20, { id: 20, parentGroup: 1, rank: 2, value: `X` }], + ]) + // This source Map is the reference model. The selected facade rows + // omit rank, so final row equality alone cannot prove layout delivery. + const expectedRows = () => expectedOrderedRows(sourceRows) + + let sync!: Parameters[`sync`]>[0] + let holdRepair = false + const held: Array<() => void> = [] + const requests: Array<{ limit?: number; refetch?: boolean }> = [] + const children = createCollection({ + id: `held-child-facade-repair-${intermediate}-${final}`, + getKey: ({ id }) => id, + syncMode: `on-demand`, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + sync = operations + operations.markReady() + return { + loadSubset: (options) => { + requests.push({ + limit: options.limit, + refetch: options.refetch, + }) + if (requests.length === 1) { + sync.begin() + for (const row of sourceRows.values()) { + sync.write({ type: `insert`, value: { ...row } }) + } + expect(sync.commit()).toBe(true) } + return holdRepair + ? new Promise((resolve) => held.push(resolve)) + : true }, - { includeInitialState: false }, - ) - }, - async cleanup(context) { - try { - identitySubscription?.unsubscribe() - } finally { - await driver.cleanup(context) + unloadSubset: () => {}, } }, - } - const projection: TraceProjection< - CollectionContext, - Array, - boolean - > = { - observe: () => { - const snapshot = events.map((event) => ({ ...event })) - eventSnapshots.push(snapshot) - return snapshot - }, - recompute: (context) => !context.model.parents.has(1), - assertEqual(observed, expected) { - assertDeleteIdentityEvents(observed, expected) - return undefined + }, + }) + const parents = createControlledCollection(`held-child-repair-parent`, [ + { id: 1, group: 1 }, + ]) + // The finite ordered source is itself a live-query Collection. The + // parent materializes it through a Collection-valued child include. + const orderedChildren = createLiveQueryCollection((q) => + q + .from({ child: children }) + .orderBy(({ child }) => child.rank) + .limit(2) + .select(({ child }) => ({ + id: child.id, + parentGroup: child.parentGroup, + rank: child.rank, + value: child.value, + })), + ) + orderedChildren.createIndex((child) => child.parentGroup, { + indexType: BTreeIndex, + }) + const live = createLiveQueryCollection((q) => + q.from({ parent: parents.collection }).select(({ parent }) => ({ + id: parent.id, + children: include( + q + .from({ child: orderedChildren }) + .where(({ child }) => eq(child.parentGroup, parent.group)) + .orderBy(({ child }) => child.rank) + .select(({ child }) => ({ id: child.id, value: child.value })), + ), + })), + ) + const callbacks: Array<{ + rows: Array<{ id: number; value: string }> + changes: Array<{ + type: string + key: string | number + value?: { id: number; value: string } + previousValue?: { id: number; value: string } + }> + }> = [] + const rootCallbacks: Array = [] + let unsubscribeFacade: (() => void) | undefined + let unsubscribeRoot: (() => void) | undefined + + await withHistoryCleanup( + async () => { + await live.preload() + const facade = live.get(1)!.children + const before = expectedRows() + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + before, + ) + const facadeSubscription = facade.subscribeChanges( + (changes) => { + callbacks.push({ + rows: facade.toArray.map(({ id, value }) => ({ id, value })), + changes: changes.map((change) => + change.type === `update` + ? { + type: change.type, + key: change.key, + value: { + id: change.value.id, + value: change.value.value, + }, + previousValue: change.previousValue && { + id: change.previousValue.id, + value: change.previousValue.value, + }, + } + : { type: change.type, key: change.key }, + ), + }) + }, + { includeInitialState: false }, + ) + unsubscribeFacade = () => facadeSubscription.unsubscribe() + const rootSubscription = live.subscribeChanges( + (changes) => rootCallbacks.push(...changes), + { includeInitialState: false }, + ) + unsubscribeRoot = () => rootSubscription.unsubscribe() + + holdRepair = true + const write = (value: string) => { + const row = { id: 10, parentGroup: 1, rank: 2.5, value } + sourceRows.set(row.id, row) + sync.begin() + sync.write({ type: `update`, value: { ...row } }) + expect(sync.commit()).toBe(true) + } + write(`A`) + write(intermediate) + write(final) + await flushPromises() + expect(held.length, JSON.stringify(requests)).toBeGreaterThan(0) + expect(requests[0]?.limit).toBe(2) + expect( + requests + .slice(1) + .some(({ limit, refetch }) => limit === 2 && refetch === true), + ).toBe(true) + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + before, + ) + expect(callbacks).toEqual([]) + + holdRepair = false + for (const release of held.splice(0)) release() + await flushPromises() + const after = expectedRows() + expect(live.get(1)!.children).toBe(facade) + expect(facade.toArray.map(({ id, value }) => ({ id, value }))).toEqual( + after, + ) + const expectedCallbacks = [ + { + rows: after, + changes: + final === `A` + ? [] + : [ + { + type: `update`, + key: 10, + value: { id: 10, value: final }, + previousValue: { id: 10, value: `A` }, + }, + ], + }, + ] + expect(callbacks).toEqual(expectedCallbacks) + // A stale facade can have correct final rows yet omit this + // callback; the public subscription must reject that rule. + expect(() => + expect(callbacks.slice(1)).toEqual(expectedCallbacks), + ).toThrow() + expect(rootCallbacks).toEqual([]) + }, + () => [ + () => { + holdRepair = false + for (const release of held.splice(0)) release() }, - } + () => unsubscribeFacade?.(), + () => unsubscribeRoot?.(), + () => live.cleanup(), + () => orderedChildren.cleanup(), + () => parents.collection.cleanup(), + () => children.cleanup(), + ], + ) +} - await runTrace({ - steps: [{ type: `deleteParent`, id: 1 }], - driver: eventDriver, - projection, - }) - expect(eventSnapshots).toHaveLength(2) - assertDeleteIdentityEvents(eventSnapshots[0]!, false) - assertDeleteIdentityEvents(eventSnapshots[1]!, true) +describe(`Collection-valued includes oracle`, () => { + oracleProperty({ + id: `includes-collection.root-event-observation`, + law: { + statement: rootEventObservationLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One child value update and five corruptions of its observed root event.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`publishes one changed-row event for a child value update`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(rootEventObservationLaw, plan, observe, () => + observeChildValueUpdate(() => undefined), + ), + check: forwardAssertionJudgment, + calibrations: rootEventFaults.map((fault) => ({ + name: `root event observations reject missing, repeated, and corrupt changed-row events: ${fault}`, + law: rootEventObservationLaw, + // The former calibration body for one fault. Exactly the old matcher: + // the corrupted observation keeps the expected rows and publications, and + // only an AssertionError from the shared comparison counts as rejecting + // it. Any other outcome fails this calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(rootEventObservationLaw, plan, observe, () => + observeChildValueUpdate((observed, expected) => { + const bad = structuredClone(observed) + const event = bad.events[0]! + if (fault === `missing`) bad.events = [] + else if (fault === `duplicate`) + bad.events.push(structuredClone(event)) + else if (fault === `value`) + event.value.arrayChildren[0]!.value = -999 + else if (fault === `previousValue`) + event.previousValue!.materializedChildren[0]!.value = -999 + else event.type = `insert` + // The previous checker accepted all these payload/count corruptions. + expect(bad.rows).toEqual(expected.rows) + expect(bad.publications).toEqual(expected.publications) + try { + collectionProjection.assertEqual(bad, expected) + } catch (error) { + if (error instanceof Error && error.name === `AssertionError`) + rejectAt(`root event ${fault}`, error) + throw new Error( + `the corrupted root event failed outside the expected rejection: ${String(error)}`, + ) + } + }), + ), + })), }) - 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 } }, - ], + oracleProperty({ + id: `includes-collection.checkpoint-failure`, + law: { + statement: checkpointFailureLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `The four enumerated insert/delete by rollback-failure cells.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: ([`insert`, `delete`] as const).flatMap((kind) => + [false, true].map((failRollback) => ({ kind, failRollback })), + ), + label: ({ kind, failRollback }) => + `settles ${kind} after a checkpoint failure (rollback throws=${failRollback})`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(checkpointFailureLaw, plan, observe, () => + judgeCheckpointFailureSettlement(plan.kind, plan.failRollback), + ), + check: forwardAssertionJudgment, + }) + + const runRelationshipHistory = ({ + parentGroup, + childValue, + actions, + }: SampleOf) => + runTrace({ + steps: actions, driver: createCollectionDriver( - [{ id: 1, group: 1 }], - [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 2, value: 2 }, - ], + [{ id: 0, group: parentGroup }], + [{ id: 10, parentGroup, value: childValue }], ), projection: collectionProjection, }) - }) - fcTest( - `facade application failure leaves no partial state or publication`, - async () => { - oracleRoute(`fixed history`) - const driver = createCollectionDriver( - [{ id: 1, group: 1 }], - [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 1, value: 2 }, - ], - ) - const context = await driver.setup() - await driver.start?.(context) - const facade = context.live.get(1)!.children - const changes: Array = [] - const subscription = facade.subscribeChanges( - (batch) => changes.push(...batch), - { includeInitialState: false }, - ) - const originalGetKey = facade.config.getKey - facade.config.getKey = (row) => { - if (row.id === 20) throw new Error(`facade key failed`) - return originalGetKey(row) - } - - try { - expect(() => - context.children.writeBatch([ - { - type: `update`, - value: { id: 10, parentGroup: 1, value: 10 }, - }, - { - type: `update`, - value: { id: 20, parentGroup: 1, value: 20 }, - }, - ]), - ).toThrow(`facade key failed`) - expect(projectLive(context.live)).toEqual([ - { - id: 1, - group: 1, - childrenReady: true, - children: [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 1, value: 2 }, - ], - arrayChildren: [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 1, value: 2 }, - ], - materializedChildren: [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 1, value: 2 }, - ], - }, - ]) - expect(changes).toEqual([]) - - facade.config.getKey = originalGetKey - context.parents.write(`insert`, { id: 2, group: 2 }) - await flushPromises() - expect(context.live.get(1)!.children).toBe(facade) - expect(projectLive(context.live)[0]!.children).toEqual([ - { id: 10, parentGroup: 1, value: 10 }, - { id: 20, parentGroup: 1, value: 20 }, - ]) - expect(changes).toHaveLength(2) - } finally { - facade.config.getKey = originalGetKey - subscription.unsubscribe() - await driver.cleanup(context) - } + registerIncludesProperty( + [collectionScenarioArbitrary], + { + property: relationshipProperty, + runs: 30, + fixedSeed: 1658101, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, + `keeps Collection, toArray, and materialize equivalent across fixed relationship histories`, + (...args: Parameters) => { + return runRelationshipHistory(...args) }, ) - fcTest(`facade events observe the matching root publication`, async () => { - oracleRoute(`fixed history`) - const driver = createCollectionDriver( - [{ id: 1, group: 1 }], - [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 2, value: 2 }, - ], - ) - const context = await driver.setup() - await driver.start?.(context) - const oldFacade = context.live.get(1)!.children - const callbackSnapshots: Array = [] - const subscription = oldFacade.subscribeChanges( - () => { - const current = context.live.get(1) - callbackSnapshots.push({ - group: current?.group, - usesOldFacade: current?.children === oldFacade, - rows: current ? [...current.children.keys()] : [], - }) - }, - { includeInitialState: false }, - ) + oracleProperty({ + id: `includes-collection.exhaustive-two-step`, + law: { + statement: exhaustiveLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `Every initial state of the smallest domain crossed with every zero-, one-, and two-step history over its six actions.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: exhaustiveHistories, + label: (history) => + `exhaustively matches every two-step history in the smallest relationship domain: ${JSON.stringify(history)}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(exhaustiveLaw, plan, observe, () => + runTrace({ + steps: plan.history.steps, + driver: createCollectionDriver( + plan.history.initial.parents, + plan.history.initial.children, + ), + projection: collectionProjection, + }), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `includes-collection.fixed-relationship`, + law: { + statement: fixedRelationshipLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `Three named fixed relationship histories.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: fixedRelationshipHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fixedRelationshipLaw, plan, observe, () => + runTrace({ + steps: plan.history.steps, + driver: createCollectionDriver( + plan.history.parents, + plan.history.children, + plan.history.reuseChildRelation, + ), + projection: collectionProjection, + }), + ), + check: forwardAssertionJudgment, + }) - try { - context.parents.write(`update`, { id: 1, group: 2 }) - assertFacadeRootSnapshots(callbackSnapshots) - } finally { - subscription.unsubscribe() - await driver.cleanup(context) - } + oracleProperty({ + id: `includes-collection.retired-held-facade`, + law: { + statement: retiredHeldFacadeLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`retiring a route leaves a held facade empty and ready`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(retiredHeldFacadeLaw, plan, observe, () => + judgeRetiredHeldFacade(), + ), + check: forwardAssertionJudgment, }) - fcTest( - `shared facades remain active until their last parent departs`, - async () => { - oracleRoute(`fixed history`) - const driver = createCollectionDriver( - [ - { id: 1, group: 1 }, - { id: 2, group: 1 }, - ], - [{ id: 10, parentGroup: 1, value: 1 }], - ) - const context = await driver.setup() - await driver.start?.(context) - const sharedFacade = context.live.get(1)!.children - let intervals: FacadeIntervals = { active: new Map(), next: 0 } - const observedIntervals = new Map() - const checkIntervals = (groups: ReadonlyArray) => { - intervals = advanceFacadeIntervals(intervals, groups) - checkFacadeIntervals( - intervals, - context.live.toArray.map((row) => ({ - group: row.group, - facade: row.children, - })), - observedIntervals, - ) - } + oracleProperty({ + id: `includes-collection.delete-facade-identity`, + law: { + statement: deleteFacadeIdentityLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`a delete event preserves the published facade identity`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(deleteFacadeIdentityLaw, plan, observe, () => + judgeDeleteFacadeIdentity(), + ), + check: forwardAssertionJudgment, + }) - try { - checkIntervals([1, 1]) - expect(context.live.get(2)!.children).toBe(sharedFacade) - context.parents.write(`delete`, { id: 1, group: 1 }) - checkIntervals([1]) - expect(context.live.get(2)!.children).toBe(sharedFacade) - expect([...sharedFacade.keys()]).toEqual([10]) - expect(sharedFacade.status).toBe(`ready`) - - context.parents.write(`delete`, { id: 2, group: 1 }) - checkIntervals([]) - expect(context.live.toArray).toEqual([]) - expect(sharedFacade.toArray).toEqual([]) - expect(sharedFacade.status).toBe(`ready`) - - context.children.write(`insert`, { id: 20, parentGroup: 1, value: 2 }) - checkIntervals([]) - expect(context.live.toArray).toEqual([]) - expect(sharedFacade.toArray).toEqual([]) - expect(sharedFacade.status).toBe(`ready`) - - context.parents.write(`insert`, { id: 1, group: 1 }) - checkIntervals([1]) - const reactivated = context.live.get(1)!.children - expect(reactivated).not.toBe(sharedFacade) - const assertCurrent = (value: number) => { - checkIntervals([1]) - const rows = [ - { id: 10, parentGroup: 1, value: 1 }, - { id: 20, parentGroup: 1, value }, - ] - expect(projectLive(context.live)).toEqual([ - { - id: 1, - group: 1, - childrenReady: true, - children: rows, - arrayChildren: rows, - materializedChildren: rows, - }, - ]) - expect(context.live.get(1)!.children).toBe(reactivated) - expect(sharedFacade.toArray).toEqual([]) - expect(sharedFacade.status).toBe(`ready`) - } - assertCurrent(2) - context.children.write(`update`, { id: 20, parentGroup: 1, value: 3 }) - assertCurrent(3) - } finally { - await driver.cleanup(context) - } + oracleProperty({ + id: `includes-collection.facade-application-failure`, + law: { + statement: facadeApplicationFailureLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [ + `facade application failure leaves no partial state or publication`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(facadeApplicationFailureLaw, plan, observe, () => + judgeFacadeApplicationFailure(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-collection.facade-root-publication`, + law: { + statement: facadeRootPublicationLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`facade events observe the matching root publication`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(facadeRootPublicationLaw, plan, observe, () => + judgeFacadeRootPublication(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-collection.shared-facade-retirement`, + law: { + statement: sharedFacadeRetirementLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`shared facades remain active until their last parent departs`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(sharedFacadeRetirementLaw, plan, observe, () => + judgeSharedFacadeRetirement(), + ), + check: forwardAssertionJudgment, + }) const runPublicKeyOrder = async ({ smallId, @@ -1082,7 +1700,16 @@ describe(`Collection-valued includes oracle`, () => { registerIncludesProperty( [publicKeyOrderArbitrary], - { property: publicKeyOrderProperty, runs: 20, fixedSeed: 1658102 }, + { + property: publicKeyOrderProperty, + runs: 20, + fixedSeed: 1658102, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `uses one raw public-key order across Collection and inline materializations with a fixed seed`, (...args: Parameters) => { return runPublicKeyOrder(...args) @@ -1219,382 +1846,74 @@ describe(`Collection-valued includes oracle`, () => { registerIncludesProperty( [orderSwapArbitrary], - { property: layoutSwapProperty, runs: 20, fixedSeed: 1658103 }, + { + property: layoutSwapProperty, + runs: 20, + fixedSeed: 1658103, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `propagates order-only child swaps through every materialization with a fixed seed`, (...args: Parameters) => { return runLayoutSwap(...args) }, ) - for (const { intermediate, final } of [ - { intermediate: `A`, final: `A` }, - { intermediate: `B`, final: `A` }, - { intermediate: `B`, final: `C` }, - ]) { - fcTest( + oracleProperty({ + id: `includes-collection.held-ordered-repair`, + law: { + statement: heldOrderedRepairLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `The three enumerated intermediate and final child values.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [ + { intermediate: `A`, final: `A` }, + { intermediate: `B`, final: `A` }, + { intermediate: `B`, final: `C` }, + ], + label: ({ intermediate, final }) => `a held ordered child repair publishes ${intermediate}-to-${final} through the facade`, - async () => { - oracleRoute(`case`) - type OrderedChild = { - id: number - parentGroup: number - rank: number - value: string - } - const sourceRows = new Map([ - [10, { id: 10, parentGroup: 1, rank: 1, value: `A` }], - [20, { id: 20, parentGroup: 1, rank: 2, value: `X` }], - ]) - // This source Map is the reference model. The selected facade rows - // omit rank, so final row equality alone cannot prove layout delivery. - const expectedRows = () => expectedOrderedRows(sourceRows) - - let sync!: Parameters[`sync`]>[0] - let holdRepair = false - const held: Array<() => void> = [] - const requests: Array<{ limit?: number; refetch?: boolean }> = [] - const children = createCollection({ - id: `held-child-facade-repair-${intermediate}-${final}`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: (options) => { - requests.push({ - limit: options.limit, - refetch: options.refetch, - }) - if (requests.length === 1) { - sync.begin() - for (const row of sourceRows.values()) { - sync.write({ type: `insert`, value: { ...row } }) - } - expect(sync.commit()).toBe(true) - } - return holdRepair - ? new Promise((resolve) => held.push(resolve)) - : true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const parents = createControlledCollection(`held-child-repair-parent`, [ - { id: 1, group: 1 }, - ]) - // The finite ordered source is itself a live-query Collection. The - // parent materializes it through a Collection-valued child include. - const orderedChildren = createLiveQueryCollection((q) => - q - .from({ child: children }) - .orderBy(({ child }) => child.rank) - .limit(2) - .select(({ child }) => ({ - id: child.id, - parentGroup: child.parentGroup, - rank: child.rank, - value: child.value, - })), - ) - orderedChildren.createIndex((child) => child.parentGroup, { - indexType: BTreeIndex, - }) - const live = createLiveQueryCollection((q) => - q.from({ parent: parents.collection }).select(({ parent }) => ({ - id: parent.id, - children: include( - q - .from({ child: orderedChildren }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - .orderBy(({ child }) => child.rank) - .select(({ child }) => ({ id: child.id, value: child.value })), - ), - })), - ) - const callbacks: Array<{ - rows: Array<{ id: number; value: string }> - changes: Array<{ - type: string - key: string | number - value?: { id: number; value: string } - previousValue?: { id: number; value: string } - }> - }> = [] - const rootCallbacks: Array = [] - let unsubscribeFacade: (() => void) | undefined - let unsubscribeRoot: (() => void) | undefined - - await withHistoryCleanup( - async () => { - await live.preload() - const facade = live.get(1)!.children - const before = expectedRows() - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(before) - const facadeSubscription = facade.subscribeChanges( - (changes) => { - callbacks.push({ - rows: facade.toArray.map(({ id, value }) => ({ id, value })), - changes: changes.map((change) => - change.type === `update` - ? { - type: change.type, - key: change.key, - value: { - id: change.value.id, - value: change.value.value, - }, - previousValue: change.previousValue && { - id: change.previousValue.id, - value: change.previousValue.value, - }, - } - : { type: change.type, key: change.key }, - ), - }) - }, - { includeInitialState: false }, - ) - unsubscribeFacade = () => facadeSubscription.unsubscribe() - const rootSubscription = live.subscribeChanges( - (changes) => rootCallbacks.push(...changes), - { includeInitialState: false }, - ) - unsubscribeRoot = () => rootSubscription.unsubscribe() - - holdRepair = true - const write = (value: string) => { - const row = { id: 10, parentGroup: 1, rank: 2.5, value } - sourceRows.set(row.id, row) - sync.begin() - sync.write({ type: `update`, value: { ...row } }) - expect(sync.commit()).toBe(true) - } - write(`A`) - write(intermediate) - write(final) - await flushPromises() - expect(held.length, JSON.stringify(requests)).toBeGreaterThan(0) - expect(requests[0]?.limit).toBe(2) - expect( - requests - .slice(1) - .some(({ limit, refetch }) => limit === 2 && refetch === true), - ).toBe(true) - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(before) - expect(callbacks).toEqual([]) - - holdRepair = false - for (const release of held.splice(0)) release() - await flushPromises() - const after = expectedRows() - expect(live.get(1)!.children).toBe(facade) - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(after) - const expectedCallbacks = [ - { - rows: after, - changes: - final === `A` - ? [] - : [ - { - type: `update`, - key: 10, - value: { id: 10, value: final }, - previousValue: { id: 10, value: `A` }, - }, - ], - }, - ] - expect(callbacks).toEqual(expectedCallbacks) - // A stale facade can have correct final rows yet omit this - // callback; the public subscription must reject that rule. - expect(() => - expect(callbacks.slice(1)).toEqual(expectedCallbacks), - ).toThrow() - expect(rootCallbacks).toEqual([]) - }, - () => [ - () => { - holdRepair = false - for (const release of held.splice(0)) release() - }, - () => unsubscribeFacade?.(), - () => unsubscribeRoot?.(), - () => live.cleanup(), - () => orderedChildren.cleanup(), - () => parents.collection.cleanup(), - () => children.cleanup(), - ], - ) - }, - ) - } + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(heldOrderedRepairLaw, plan, observe, () => + judgeHeldOrderedChildRepair(plan.intermediate, plan.final), + ), + check: forwardAssertionJudgment, + }) - 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 - rank: number - value: string - } - const sourceRows = new Map([ - [10, { id: 10, parentGroup: 1, rank: 1, value: `A` }], - [20, { id: 20, parentGroup: 1, rank: 2, value: `X` }], - ]) - // Rank is absent from the facade row but retained in this source model: - // two otherwise equal rows with different ranks yield different layouts. - // The provider receives copies, so it cannot mutate the expected rows. - const expectedRows = () => expectedOrderedRows(sourceRows) - - let sync!: Parameters[`sync`]>[0] - let holdReplay = false - const held: Array<() => void> = [] - let requests = 0 - const children = createCollection({ - id: `held-child-facade-replay`, - getKey: ({ id }) => id, - syncMode: `on-demand`, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - sync = operations - operations.markReady() - return { - loadSubset: () => { - requests++ - if (requests <= 2) { - sync.begin() - for (const row of sourceRows.values()) { - sync.write({ type: `insert`, value: { ...row } }) - } - expect(sync.commit()).toBe(true) - } - return holdReplay - ? new Promise((resolve) => held.push(resolve)) - : true - }, - unloadSubset: () => {}, - } - }, - }, - }) - const parents = createControlledCollection(`held-child-facade-parent`, [ - { id: 1, group: 1 }, - ]) - const live = createLiveQueryCollection((q) => - q.from({ parent: parents.collection }).select(({ parent }) => ({ - id: parent.id, - children: include( - q - .from({ child: children }) - .where(({ child }) => eq(child.parentGroup, parent.group)) - .orderBy(({ child }) => child.rank) - .limit(2) - .select(({ child }) => ({ id: child.id, value: child.value })), - ), - })), - ) - const callbacks: Array<{ - rows: Array<{ id: number; value: string }> - changes: Array<{ type: string; key: string | number }> - }> = [] - const rootCallbacks: Array = [] - let unsubscribeFacade: (() => void) | undefined - let unsubscribeRoot: (() => void) | undefined - - await withHistoryCleanup( - async () => { - await live.preload() - const facade = live.get(1)!.children - const before = expectedRows() - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(before) - const facadeSubscription = facade.subscribeChanges( - (changes) => { - callbacks.push({ - rows: facade.toArray.map(({ id, value }) => ({ id, value })), - changes: changes.map(({ type, key }) => ({ type, key })), - }) - }, - { includeInitialState: false }, - ) - unsubscribeFacade = () => facadeSubscription.unsubscribe() - const rootSubscription = live.subscribeChanges( - (changes) => rootCallbacks.push(...changes), - { includeInitialState: false }, - ) - unsubscribeRoot = () => rootSubscription.unsubscribe() - - holdReplay = true - sync.begin() - sync.truncate() - expect(sync.commit()).toBe(true) - await flushPromises() - expect(requests).toBe(2) - expect(held.length).toBeGreaterThan(0) - const write = (row: OrderedChild) => { - sourceRows.set(row.id, { ...row }) - sync.begin() - sync.write({ type: `update`, value: { ...row } }) - expect(sync.commit()).toBe(true) - } - write({ id: 10, parentGroup: 1, rank: 2.5, value: `A` }) - write({ id: 10, parentGroup: 1, rank: 2.5, value: `B` }) - write({ id: 10, parentGroup: 1, rank: 2.5, value: `A` }) - await flushPromises() - expect(held.length).toBeGreaterThan(0) - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(before) - expect(callbacks).toEqual([]) - - holdReplay = false - for (const release of held.splice(0)) release() - await flushPromises() - const after = expectedRows() - expect(live.get(1)!.children).toBe(facade) - expect( - facade.toArray.map(({ id, value }) => ({ id, value })), - ).toEqual(after) - const expectedCallbacks = [{ rows: after, changes: [] }] - expect(callbacks).toEqual(expectedCallbacks) - // A facade that omits its layout callback would pass the final read. - expect(() => - expect(callbacks.slice(1)).toEqual(expectedCallbacks), - ).toThrow() - expect(rootCallbacks).toEqual([]) - }, - () => [ - () => { - holdReplay = false - for (const release of held.splice(0)) release() - }, - () => unsubscribeFacade?.(), - () => unsubscribeRoot?.(), - () => live.cleanup(), - () => parents.collection.cleanup(), - () => children.cleanup(), - ], - ) + oracleProperty({ + id: `includes-collection.held-truncate-reorder`, + law: { + statement: heldTruncateReorderLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One fixed history over controlled source Collections.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [ + `a held child truncate replay publishes an A-to-B-to-A reorder through the facade`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(heldTruncateReorderLaw, plan, observe, () => + judgeHeldTruncateReorder(), + ), + check: forwardAssertionJudgment, + }) const runOptimisticChildHistory = async ({ group, @@ -1626,106 +1945,68 @@ 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() - await withHistoryCleanup( - async () => { - await driver.start?.(context) - await driver.apply( - { type: `insert`, row, settlement: `rollback` }, - context, - () => undefined, - ) - const observed = collectionProjection.observe(context) - const expected = collectionProjection.recompute(context) - collectionProjection.assertEqual(observed, expected) - const retained = structuredClone(observed) - retained.rows[0]!.children.push(row) - expect(() => - collectionProjection.assertEqual(retained, expected), - ).toThrow() - }, - () => [() => driver.cleanup(context)], - ) + oracleProperty({ + id: `includes-collection.rollback-retained-child`, + law: { + statement: rollbackRetainedChildLaw, + authority: `Collection facades, event coherence, and route activation in packages/db/src/query/live/ARCHITECTURE.md (Nested materialization, Coherent publication), stated at the start of this owner.`, + limits: `One rolled-back optimistic child insert and one retained-row corruption of its observation.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [`removes a rolled-back optimistic child row`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(rollbackRetainedChildLaw, plan, observe, () => + observeRolledBackChild(() => undefined), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `optimistic rollback comparison rejects a retained child row`, + law: rollbackRetainedChildLaw, + // The former calibration body. Exactly the old matcher: any failure of + // the shared comparison on the retained row counts as rejecting it; a + // comparison that accepts it fails this calibration. + driver: (plan, observe) => + recordAssertionJudgment(rollbackRetainedChildLaw, plan, observe, () => + observeRolledBackChild((observed, expected, row) => { + const retained = structuredClone(observed) + retained.rows[0]!.children.push(row) + try { + collectionProjection.assertEqual(retained, expected) + } catch (error) { + rejectAt( + `rollback retained child`, + error instanceof Error ? error : new Error(String(error)), + ) + } + }), + ), + }, + ], + }) registerIncludesProperty( [optimisticChildArbitrary], - { property: optimisticChildProperty, runs: 20, fixedSeed: 1658104 }, + { + property: optimisticChildProperty, + runs: 20, + fixedSeed: 1658104, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `matches optimistic child settlement histories with a fixed seed`, (...args: Parameters) => { return runOptimisticChildHistory(...args) }, ) }) - -// These public-handle controls replay the same active-interval model used by -// the metadata receiver. They challenge sharing, retirement and reactivation -// separately; ordinary child values remain outside this partial checker. -test.each([ - `split-shared`, - `retargeted`, - `retired-rows`, - `retired-status`, - `missing-group`, - `active-status`, -] as const)(`rejects a hostile facade interval observation: %s`, (fault) => { - oracleRoute(`calibration`) - const original: MetadataFacade = { - id: `original`, - status: `ready`, - toArray: [], - } - const replacement: MetadataFacade = { - id: `replacement`, - status: `ready`, - toArray: [], - } - const seen = new Map() - const first = advanceFacadeIntervals({ active: new Map(), next: 0 }, [ - `a`, - `a`, - ]) - checkFacadeIntervals( - first, - [ - { group: `a`, facade: original }, - { group: `a`, facade: original }, - ], - seen, - ) - if (fault === `split-shared`) { - expect(() => - checkFacadeIntervals( - first, - [ - { group: `a`, facade: original }, - { group: `a`, facade: replacement }, - ], - seen, - ), - ).toThrow() - return - } - const retired = advanceFacadeIntervals(first, []) - checkFacadeIntervals(retired, [], seen) - const returned = advanceFacadeIntervals(retired, [`a`]) - if (fault === `retargeted`) { - expect(() => - checkFacadeIntervals(returned, [{ group: `a`, facade: original }], seen), - ).toThrow() - return - } - if (fault === `active-status`) replacement.status = `cleaned-up` - if (fault === `retired-rows`) original.toArray = [{ id: `stale` }] - if (fault === `retired-status`) original.status = `cleaned-up` - const actual = - fault === `missing-group` ? [] : [{ group: `a`, facade: replacement }] - expect(() => checkFacadeIntervals(returned, actual, seen)).toThrow() -}) diff --git a/packages/db/tests/query/includes-collection.test.ts b/packages/db/tests/query/includes-collection.test.ts index 844366cca3..6beea744fc 100644 --- a/packages/db/tests/query/includes-collection.test.ts +++ b/packages/db/tests/query/includes-collection.test.ts @@ -4,7 +4,7 @@ // 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' +import { describe, expect, test } from 'vitest' import { add, caseWhen, @@ -28,9 +28,16 @@ import { createControlledCollection, expectedMaterializations, } from './includes-collection-support.js' -import { createCollectionDriver } from './includes-collection-driver-oracle.js' +import { + advanceFacadeIntervals, + checkFacadeIntervals, + createCollectionDriver, +} from './includes-collection-driver-oracle.js' import type { TraceProjection } from '../trace-runner.js' -import type { CollectionContext } from './includes-collection-driver-oracle.js' +import type { + CollectionContext, + MetadataFacade, +} from './includes-collection-driver-oracle.js' import type { ChildRow, DeleteIdentityObservation, @@ -1390,3 +1397,69 @@ describe(`Collection-valued includes oracle`, () => { await runTrace({ steps: [], driver, projection }) }) }) + +// A checker calibration of the facade-interval check that the Collection and +// metadata oracles share. It runs no production. +// These public-handle controls replay the same active-interval model used by +// the metadata receiver. They challenge sharing, retirement and reactivation +// separately; ordinary child values remain outside this partial checker. +test.each([ + `split-shared`, + `retargeted`, + `retired-rows`, + `retired-status`, + `missing-group`, + `active-status`, +] as const)(`rejects a hostile facade interval observation: %s`, (fault) => { + const original: MetadataFacade = { + id: `original`, + status: `ready`, + toArray: [], + } + const replacement: MetadataFacade = { + id: `replacement`, + status: `ready`, + toArray: [], + } + const seen = new Map() + const first = advanceFacadeIntervals({ active: new Map(), next: 0 }, [ + `a`, + `a`, + ]) + checkFacadeIntervals( + first, + [ + { group: `a`, facade: original }, + { group: `a`, facade: original }, + ], + seen, + ) + if (fault === `split-shared`) { + expect(() => + checkFacadeIntervals( + first, + [ + { group: `a`, facade: original }, + { group: `a`, facade: replacement }, + ], + seen, + ), + ).toThrow() + return + } + const retired = advanceFacadeIntervals(first, []) + checkFacadeIntervals(retired, [], seen) + const returned = advanceFacadeIntervals(retired, [`a`]) + if (fault === `retargeted`) { + expect(() => + checkFacadeIntervals(returned, [{ group: `a`, facade: original }], seen), + ).toThrow() + return + } + if (fault === `active-status`) replacement.status = `cleaned-up` + if (fault === `retired-rows`) original.toArray = [{ id: `stale` }] + if (fault === `retired-status`) original.status = `cleaned-up` + const actual = + fault === `missing-group` ? [] : [{ group: `a`, facade: replacement }] + expect(() => checkFacadeIntervals(returned, actual, seen)).toThrow() +}) From cc41cb8cb87a4057ffe9c7f77e116585984eebaf Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:31:29 -0700 Subject: [PATCH 086/116] test(db): register IndexedDB and live-query observer cases through oracleProperty Cross-tab, persistence, retirement, pending-history, value, transport, wrapper-settlement and local-write-order cases, and the live-query observer, cache and deferred-acquisition cases, become enumerated or fixed histories of properties over the files' existing drivers and checks; spy and fault calibrations become mutant drivers with the old matchers. The pending-history campaigns move into oracleProperty with the same arbitrary, budget and seed. Checker calibrations that run no production move to *-calibration.test.ts files, with the cache lifecycle model in a test-free live-query-cache-lifecycle-model-oracle.ts. Co-authored-by: Isaac --- .../db/tests/converted-properties/db-core.ts | 89 ++ .../cross-tab-boundary-oracle.test.ts | 499 +++++++---- .../indexed-db/cross-tab-calibration.test.ts | 132 +++ .../cross-tab-history-oracle.test.ts | 198 ++--- .../local-write-order-oracle.test.ts | 299 ++++--- .../indexed-db/pending-history-oracle.test.ts | 248 ++++-- .../indexed-db/persistence-oracle.test.ts | 139 +-- .../persistence-values-calibration.test.ts | 37 + .../persistence-values-oracle.test.ts | 423 ++++----- .../indexed-db/retirement-oracle.test.ts | 820 ++++++++++-------- .../tests/indexed-db/transport-oracle.test.ts | 265 +++--- .../wrapper-settlement-oracle.test.ts | 354 ++++---- ...-query-cache-lifecycle-calibration.test.ts | 38 + ...live-query-cache-lifecycle-model-oracle.ts | 53 ++ .../live-query-cache-lifecycle-oracle.test.ts | 91 +- ...ve-query-cache-options-calibration.test.ts | 63 ++ .../live-query-cache-options-oracle.test.ts | 66 +- ...-query-deferred-acquisition-oracle.test.ts | 301 ++++--- ...y-observer-history-oracle.property.test.ts | 360 +++++--- ...-observer-snapshot-oracle.property.test.ts | 283 +++--- 20 files changed, 2871 insertions(+), 1887 deletions(-) create mode 100644 packages/db/tests/indexed-db/cross-tab-calibration.test.ts create mode 100644 packages/db/tests/indexed-db/persistence-values-calibration.test.ts create mode 100644 packages/db/tests/live-query-cache-lifecycle-calibration.test.ts create mode 100644 packages/db/tests/live-query-cache-lifecycle-model-oracle.ts create mode 100644 packages/db/tests/live-query-cache-options-calibration.test.ts diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/converted-properties/db-core.ts index cb9ce5230c..f5887c49c5 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/converted-properties/db-core.ts @@ -69,6 +69,35 @@ export const properties: ReadonlyArray = [ `production-errors.sites`, `production-errors.generated-docs`, // IndexedDB and live-query observers + `indexeddb.boundary-omission`, + `indexeddb.boundary-receiver-overlap`, + `indexeddb.boundary-obsolete-receiver`, + `indexeddb.boundary-active-abort`, + `indexeddb.boundary-imported-replacement`, + `indexeddb.boundary-stale-peer`, + `indexeddb.cross-tab-history-reconstruction`, + `indexeddb.cross-tab-history-ordered-writes`, + `indexeddb.manual-acceptance`, + `indexeddb.retirement-admitted-pairs`, + `indexeddb.retirement-accepted-publication`, + `indexeddb.retirement-committed-continuation`, + `indexeddb.retirement-mixed-prefixes`, + `indexeddb.pending-history-reconstruction`, + `indexeddb.pending-history-fixed`, + `indexeddb.value-preservation`, + `indexeddb.value-capture`, + `indexeddb.transport-replacement`, + `indexeddb.transport-unversioned-reconciliation`, + `indexeddb.wrapper-observer-settlement`, + `indexeddb.wrapper-request-error`, + `indexeddb.local-write-order`, + `live-query-observer.history-every-kind`, + `live-query-observer.retained-snapshot-fixed`, + `live-query-observer.capture-paths-discard`, + `live-query-observer.capture-paths-retained`, + `live-query.deferred-acquisition`, + `live-query.deferred-pooled`, + `live-query.deferred-cleanup-survivor`, // proxies and conformance `proxy.own-property-histories`, `proxy.cycle-backedges`, @@ -157,6 +186,66 @@ export const owners: ReadonlyArray = [ `classes sites generated-docs`, ], // IndexedDB and live-query observers + [ + `db/tests/indexed-db/cross-tab-boundary-oracle.test.ts`, + `indexeddb`, + `boundary-omission boundary-receiver-overlap boundary-obsolete-receiver boundary-active-abort boundary-imported-replacement boundary-stale-peer`, + ], + [ + `db/tests/indexed-db/cross-tab-history-oracle.test.ts`, + `indexeddb`, + `cross-tab-history-reconstruction cross-tab-history-ordered-writes`, + ], + [ + `db/tests/indexed-db/persistence-oracle.test.ts`, + `indexeddb`, + `manual-acceptance`, + ], + [ + `db/tests/indexed-db/retirement-oracle.test.ts`, + `indexeddb`, + `retirement-admitted-pairs retirement-accepted-publication retirement-committed-continuation retirement-mixed-prefixes`, + ], + [ + `db/tests/indexed-db/pending-history-oracle.test.ts`, + `indexeddb`, + `pending-history-reconstruction pending-history-fixed`, + ], + [ + `db/tests/indexed-db/persistence-values-oracle.test.ts`, + `indexeddb`, + `value-preservation value-capture`, + ], + [ + `db/tests/indexed-db/transport-oracle.test.ts`, + `indexeddb`, + `transport-replacement transport-unversioned-reconciliation`, + ], + [ + `db/tests/indexed-db/wrapper-settlement-oracle.test.ts`, + `indexeddb`, + `wrapper-observer-settlement wrapper-request-error`, + ], + [ + `db/tests/indexed-db/local-write-order-oracle.test.ts`, + `indexeddb`, + `local-write-order`, + ], + [ + `db/tests/live-query-observer-history-oracle.property.test.ts`, + `live-query-observer`, + `history-every-kind`, + ], + [ + `db/tests/live-query-observer-snapshot-oracle.property.test.ts`, + `live-query-observer`, + `retained-snapshot-fixed capture-paths-discard capture-paths-retained`, + ], + [ + `db/tests/live-query-deferred-acquisition-oracle.test.ts`, + `live-query`, + `deferred-acquisition deferred-pooled deferred-cleanup-survivor`, + ], // proxies and conformance [ `db/tests/proxy-detachment-contract-oracle.test.ts`, 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 4ff5a9fd40..6bf8d54281 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 @@ -8,10 +8,15 @@ * `error` with its rows kept, which the recorder adds to the status history. Cleanup histories keep old native reads alive after retirement. * Native scheduling itself is received separately by the browser suite. */ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { createCollection, indexedDBCollectionOptions } from '../../src' -import { oracleRoute } from '../oracle-config' -import { declareOracle } from '../oracle-declaration' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter' import { Channel, seed, withHarness } from './harness' import { holdStore, observeTransactions } from './idb-driver' import { @@ -23,13 +28,9 @@ 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', - ], -}) +const boundaryLaw = { + authority: `The IndexedDB cross-tab contract and the independent cross-tab model in cross-tab-oracle.ts`, +} async function omission(fault: 'none' | 'partial' | 'error') { await withHarness(async (h) => { @@ -102,130 +103,226 @@ async function omission(fault: 'none' | 'partial' | 'error') { }) } -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', - }) +// A receiver fault is a production mutant of the omission history. Each +// calibration accepts exactly the rejection that the shared checks raise for it. +const omissionFaults = [ + { + name: `rejects partial-row application at the omission checkpoint`, + fault: `partial`, + law: `public rows`, + checkpoint: `whole-row omission`, + }, + { + name: `rejects spurious Collection error despite correct rows and fulfilled receivers`, + fault: `error`, + law: `Collection status`, + checkpoint: `receiver completion`, + }, +] as const + +oracleProperty({ + id: `indexeddb.boundary-omission`, + law: { + ...boundaryLaw, + statement: `An ordered delete and reinsert through a real provider removes the omitted whole-row field from the receiver's rows, statuses and publications.`, + limits: `One authored delete/reinsert history with a controlled notification schedule; native scheduling is received by the browser suite.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [ + `removes an omitted whole-row field after ordered delete and reinsert`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`indexeddb.boundary-omission`, plan, observe, () => + omission('none'), + ), + check: forwardAssertionJudgment, + calibrations: omissionFaults.map(({ name, fault, law, checkpoint }) => ({ + name, + law: `indexeddb.boundary-omission`, + driver: ( + plan: { name: string }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `indexeddb.boundary-omission`, + plan, + observe, + async () => { + try { + await omission(fault) + } catch (error) { + // Exactly the old matcher: the rejection names this law and cut. + if ( + error instanceof Error && + (error as { law?: unknown }).law === law && + (error as { checkpoint?: unknown }).checkpoint === checkpoint + ) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `${name}: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + }, + ), + })), }) -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', + +async function receiverOverlap(serial: boolean) { + await withHarness(async (h) => { + const writer = await h.open() + const descriptor = await h.connect() + const receiver = await h.open('items', { db: descriptor }) + const rows = [ + { id: 1, name: 'a' }, + { id: 2, name: 'b' }, + ] + await writer.utils.importData(rows) + await writer.utils.importData(rows) + const gate = holdStore(h.db.db) + h.disposers.push(gate.release) + await gate.started + const observed = observeTransactions(descriptor.db) + h.disposers.push(observed.restore) + const work = serial + ? Channel.deliver() + : (async () => { + const completions = [] + while (Channel.pending.length) + completions.push(Channel.dispatch()?.completion) + await Promise.all(completions) + })() + void work.catch(() => undefined) + try { + const pending = observed.entries.filter( + (entry) => entry.mode === 'readonly' && entry.status === 'pending', + ).length + const check = () => { + if (pending < 2) + throw new OracleMismatch( + 'receiver overlap', + 'held native transaction', + 2, + pending, + ) + } + if (serial) + expect(check).toThrow('receiver overlap at held native transaction') + else check() + } finally { + await gate.release() + await work + } + assertSnapshot(receiver.values(), rows, 'overlap completion') }) -}) +} -for (const serial of [false, true]) { - it( +oracleProperty({ + id: `indexeddb.boundary-receiver-overlap`, + law: { + ...boundaryLaw, + statement: `Dispatch admits two real receiver reads while the first is pending, and the serial fixture fails that reach control; both end with the source rows.`, + limits: `Two imports held behind one native store gate; the serial fixture is the reach control of the concurrent dispatch.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [false, true], + label: (serial) => serial ? '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() - const receiver = await h.open('items', { db: descriptor }) - const rows = [ - { id: 1, name: 'a' }, - { id: 2, name: 'b' }, - ] - await writer.utils.importData(rows) - await writer.utils.importData(rows) - const gate = holdStore(h.db.db) - h.disposers.push(gate.release) - await gate.started - const observed = observeTransactions(descriptor.db) - h.disposers.push(observed.restore) - const work = serial - ? Channel.deliver() - : (async () => { - const completions = [] - while (Channel.pending.length) - completions.push(Channel.dispatch()?.completion) - await Promise.all(completions) - })() - void work.catch(() => undefined) - try { - const pending = observed.entries.filter( - (entry) => entry.mode === 'readonly' && entry.status === 'pending', - ).length - const check = () => { - if (pending < 2) - throw new OracleMismatch( - 'receiver overlap', - 'held native transaction', - 2, - pending, - ) - } - if (serial) - expect(check).toThrow('receiver overlap at held native transaction') - else check() - } finally { - await gate.release() - await work - } - assertSnapshot(receiver.values(), rows, 'overlap completion') - }) - }, - ) -} + resolve: (serial) => ({ serial }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.boundary-receiver-overlap`, + plan, + observe, + () => receiverOverlap(plan.serial), + ), + check: forwardAssertionJudgment, +}) -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() - const receiver = await h.open('items', { db: descriptor }) - const record = recordCollection(receiver, 'receiver') - h.disposers.push(record.stop) - const row = { id: 1, name: 'durable before cleanup' } - await writer.insert(row).when(`settled`) - const gate = holdStore(h.db.db) - h.disposers.push(gate.release) - await gate.started - const observed = observeTransactions(descriptor.db) - h.disposers.push(observed.restore) - const receipt = Channel.dispatch()! - expect(observed.entries).toHaveLength(1) - await receiver.cleanup() - const startup = receiver.preload() - void startup.catch(() => undefined) - if (abort) observed.entries[0]!.transaction.abort() - await gate.release() - await receipt.completion - await startup - assertStatuses( - record.statuses, - ['ready', 'cleaned-up', 'loading'], - 'obsolete receiver completion', - ) - assertSnapshot(receiver.values(), [row], 'new sync run restore') - await writer - .update(1, (draft) => { - draft.name = 'later version' - }) - .when(`settled`) - await Channel.drain() - assertSnapshot( - receiver.values(), - [{ ...row, name: 'later version' }], - 'subsequent version update', - ) - }) +async function obsoleteReceiver(abort: boolean) { + await withHarness(async (h) => { + const writer = await h.open() + const descriptor = await h.connect() + const receiver = await h.open('items', { db: descriptor }) + const record = recordCollection(receiver, 'receiver') + h.disposers.push(record.stop) + const row = { id: 1, name: 'durable before cleanup' } + await writer.insert(row).when(`settled`) + const gate = holdStore(h.db.db) + h.disposers.push(gate.release) + await gate.started + const observed = observeTransactions(descriptor.db) + h.disposers.push(observed.restore) + const receipt = Channel.dispatch()! + expect(observed.entries).toHaveLength(1) + await receiver.cleanup() + const startup = receiver.preload() + void startup.catch(() => undefined) + if (abort) observed.entries[0]!.transaction.abort() + await gate.release() + await receipt.completion + await startup + assertStatuses( + record.statuses, + ['ready', 'cleaned-up', 'loading'], + 'obsolete receiver completion', + ) + assertSnapshot(receiver.values(), [row], 'new sync run restore') + await writer + .update(1, (draft) => { + draft.name = 'later version' + }) + .when(`settled`) + await Channel.drain() + assertSnapshot( + receiver.values(), + [{ ...row, name: 'later version' }], + 'subsequent version update', + ) }) } -it('reports an active receiver abort as the Collection error while its callback fulfills', async () => { - oracleRoute(`fixed history`) +oracleProperty({ + id: `indexeddb.boundary-obsolete-receiver`, + law: { + ...boundaryLaw, + statement: `An obsolete receiver success or failure cannot affect a restarted sync run: statuses, the restored rows and a later version update follow the new run.`, + limits: `One held receiver read across cleanup and restart, completing successfully or by abort.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [false, true], + label: (abort) => + `an obsolete receiver ${abort ? 'failure' : 'success'} cannot affect a restarted sync run`, + resolve: (abort) => ({ abort }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.boundary-obsolete-receiver`, + plan, + observe, + () => obsoleteReceiver(plan.abort), + ), + check: forwardAssertionJudgment, +}) + +async function activeReceiverAbort() { await withHarness(async (h) => { const writer = await h.open() const descriptor = await h.connect() @@ -260,10 +357,37 @@ it('reports an active receiver abort as the Collection error while its callback 'healthy sibling survives', ) }) +} + +oracleProperty({ + id: `indexeddb.boundary-active-abort`, + law: { + ...boundaryLaw, + statement: `An active receiver abort after the first ready is the Collection's error with its status and rows kept, while the receiver callback fulfills and a healthy sibling survives.`, + limits: `One held receiver read aborted natively after one durable insert.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [ + 'reports an active receiver abort as the Collection error while its callback fulfills', + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.boundary-active-abort`, + plan, + observe, + () => activeReceiverAbort(), + ), + check: forwardAssertionJudgment, }) -it('an imported replacement never publishes a union with the prior snapshot', async () => { - oracleRoute(`fixed history`) +async function importedReplacement() { await withHarness(async (h) => { const before = [{ id: 'old', name: 'old' }] const after = [ @@ -291,43 +415,96 @@ it('an imported replacement never publishes a union with the prior snapshot', as 'imported replacement', ) }) +} + +oracleProperty({ + id: `indexeddb.boundary-imported-replacement`, + law: { + ...boundaryLaw, + statement: `An imported replacement never publishes a union with the prior snapshot: the receiver publishes one replacement of the old rows by the new rows.`, + limits: `One import over one seeded row with the targeted data-changed notification delivered first.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [ + 'an imported replacement never publishes a union with the prior snapshot', + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.boundary-imported-replacement`, + plan, + observe, + () => importedReplacement(), + ), + check: forwardAssertionJudgment, }) -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]) - const a = await h.open(), - b = await h.open('items', { db: await h.connect() }) - await a.delete(0).when(`settled`) - if (deliver) { - await Channel.drain() - assertSnapshot(b.values(), [], 'delivered admission') - expect(() => - b.update(0, (row) => { - row.name = 'updated' - }), - ).toThrow() - } else { - assertSnapshot(b.values(), [before], 'stale admission') - await b - .update(0, (row) => { - row.name = 'updated' - }) - .when(`settled`) - await Channel.drain() - assertSnapshot( - a.values(), - [{ id: 0, name: 'updated' }], - 'stale update peer', - ) - } +async function stalePeer(deliver: boolean) { + await withHarness(async (h) => { + const before = { id: 0, name: 'old' } + await seed(h.db, 'items', [before]) + const a = await h.open(), + b = await h.open('items', { db: await h.connect() }) + await a.delete(0).when(`settled`) + if (deliver) { + await Channel.drain() + assertSnapshot(b.values(), [], 'delivered admission') + expect(() => + b.update(0, (row) => { + row.name = 'updated' + }), + ).toThrow() + } else { + assertSnapshot(b.values(), [before], 'stale admission') + await b + .update(0, (row) => { + row.name = 'updated' + }) + .when(`settled`) + await Channel.drain() assertSnapshot( - (await h.open()).values(), - deliver ? [] : [{ id: 0, name: 'updated' }], - 'admission restore', + a.values(), + [{ id: 0, name: 'updated' }], + 'stale update peer', ) - }) + } + assertSnapshot( + (await h.open()).values(), + deliver ? [] : [{ id: 0, name: 'updated' }], + 'admission restore', + ) }) +} + +oracleProperty({ + id: `indexeddb.boundary-stale-peer`, + law: { + ...boundaryLaw, + statement: `A stale peer can author an update before a deletion publication reaches it and rejects the update after it, and a fresh restore agrees.`, + limits: `One seeded row deleted by a peer, with the deletion notification delivered or held.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [false, true], + label: (deliver) => + `a stale peer ${deliver ? 'rejects an update after deletion publication' : 'can author an update before deletion publication'}`, + resolve: (deliver) => ({ deliver }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.boundary-stale-peer`, + plan, + observe, + () => stalePeer(plan.deliver), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/indexed-db/cross-tab-calibration.test.ts b/packages/db/tests/indexed-db/cross-tab-calibration.test.ts new file mode 100644 index 0000000000..e813fd3a9f --- /dev/null +++ b/packages/db/tests/indexed-db/cross-tab-calibration.test.ts @@ -0,0 +1,132 @@ +// Model and checker calibrations for the cross-tab model in cross-tab-oracle.ts. +// These tests run no production: each steps the model or hands a hand-built +// publication history to a shared check and requires the check to accept or +// reject it. The generated production histories are in +// cross-tab-history-oracle.test.ts. +import { expect, it } from 'vitest' +import { + CrossTabModel, + assertPublicationHistory, + assertReplay, + assertSnapshot, + assertStatuses, +} from './cross-tab-oracle' + +it('requires local admission to distinguish identical durable deletions', () => { + const row = { id: 0, name: 'old' } + const delayed = new CrossTabModel([0, 0], [[row]]) + delayed.accept(0, { rows: [], deletes: [0], replace: false }) + expect(delayed.durable[0]).toEqual([]) + expect(delayed.publicSnapshots[1]).toEqual([row]) + // A durable-only projection rejects the locally legal update: explicit + // grammar calibration, not a claimed production mutant. + expect(() => + assertSnapshot( + delayed.durable[0]!, + delayed.publicSnapshots[1]!, + 'local admission', + ), + ).toThrow('public rows at local admission') + delayed.receive(1, [{ rows: [], deletes: [0], replace: false }]) + expect(delayed.publicSnapshots[1]).toEqual([]) +}) + +it('rejects repaired partial publications and malformed raw deltas', () => { + const before = [ + { id: 0, name: 'a' }, + { id: 1, name: 'b' }, + ] + const after = [ + { id: 0, name: 'new a' }, + { id: 1, name: 'new b' }, + ] + const effect = { rows: after, deletes: [], replace: false } + const first = { + key: 0, + type: 'update' as const, + value: after[0]!, + previousValue: before[0]!, + } + const second = { + key: 1, + type: 'update' as const, + value: after[1]!, + previousValue: before[1]!, + } + expect(() => + assertPublicationHistory( + before, + [ + { changes: [first], rows: [after[0]!, before[1]!] }, + { changes: [second], rows: after }, + ], + [effect], + 'control', + ), + ).toThrow('atomic publication') + for (const corrupt of [ + { ...first, type: 'insert' as const }, + { ...first, previousValue: { id: 0, name: 'wrong' } }, + ]) + expect(() => + assertPublicationHistory( + before, + [{ changes: [corrupt, second], rows: after }], + [effect], + 'control', + ), + ).toThrow('event semantics') + assertPublicationHistory( + before, + [{ changes: [first, second], rows: after }], + [effect], + 'healthy control', + ) + expect(() => + assertStatuses(['ready', 'error', 'ready'], ['ready'], 'repaired error'), + ).toThrow('Collection status') + expect(() => + assertReplay({ + inputReconstructed: true, + premiseReached: false, + sameFailure: false, + }), + ).toThrow('failure replay') +}) + +it('rejects wrong raw event keys before folding including duplicate malformed inserts', () => { + const row = { id: 1, name: 'accepted value' } + const effect = { rows: [row], deletes: [], replace: false } + for (const key of ['wrong', '1']) { + const change = { key, type: 'insert' as const, value: row } + for (const changes of [[change], [change, change]]) + expect(() => + assertPublicationHistory( + [], + [{ changes, rows: [row] }], + [effect], + 'raw key identity', + ), + ).toThrow('event semantics') + } + expect(() => + assertPublicationHistory( + [row], + [ + { + changes: [ + { + key: 1, + type: 'update', + previousValue: row, + value: { ...row, id: '1' }, + }, + ], + rows: [{ ...row, id: '1' }], + }, + ], + [{ ...effect, rows: [{ ...row, id: '1' }] }], + 'update key identity', + ), + ).toThrow('event semantics') +}) 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 389cecd092..93002250e2 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 @@ -14,12 +14,8 @@ * scheduling, arbitrary optimistic overlap, or a same-key conflict policy. */ import { fc } from '@fast-check/vitest' -import { beforeEach, expect, it } from 'vitest' -import { - oracleProperty, - oracleReplayRequested, - oracleRoute, -} from '../oracle-config.js' +import { beforeEach, expect } from 'vitest' +import { oracleProperty, oracleReplayRequested } from '../oracle-config.js' import { nativeCampaignReceipt } from '../native-campaign-receipt' import { forwardAssertionJudgment, @@ -29,13 +25,7 @@ import { } from '../oracle-assertion-adapter' import { campaignFailure, createCampaignRecorder } from './campaign' import { runTransportHistory } from './cross-tab-driver' -import { - CrossTabModel, - assertPublicationHistory, - assertReplay, - assertSnapshot, - assertStatuses, -} from './cross-tab-oracle' +import { assertSnapshot } from './cross-tab-oracle' import type { TransportStep } from './cross-tab-driver' const kinds = [ @@ -133,8 +123,9 @@ oracleProperty({ check: forwardAssertionJudgment, }) -it('reconstructs every action and overlapping receiver reads', async () => { - oracleRoute(`fixed history`) +// The fixed reconstruction and the finite two-write template replay through +// the transport driver outside the generated grammar's recorder. +async function reconstructEveryAction() { const reached = await runTransportHistory([ action('write', 0), action('delete', 0), @@ -147,10 +138,9 @@ it('reconstructs every action and overlapping receiver reads', async () => { ]) for (const kind of [...kinds, 'overlapping receivers']) expect(reached.has(kind), kind).toBe(true) -}) +} -it('enumerates two ordered writes with every intermediate delivery choice', async () => { - oracleRoute(`case`) +async function enumerateOrderedWrites() { let explored = 0 // 2 first writers × 2 second writers × 4 delivery subsets = 16 schedules. for (const first of [0, 1]) @@ -164,126 +154,64 @@ it('enumerates two ordered writes with every intermediate delivery choice', asyn explored++ } expect(explored).toBe(16) -}, 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 }) - expect(delayed.durable[0]).toEqual([]) - expect(delayed.publicSnapshots[1]).toEqual([row]) - // A durable-only projection rejects the locally legal update: explicit - // grammar calibration, not a claimed production mutant. - expect(() => - assertSnapshot( - delayed.durable[0]!, - delayed.publicSnapshots[1]!, - 'local admission', - ), - ).toThrow('public rows at local admission') - delayed.receive(1, [{ rows: [], deletes: [0], replace: false }]) - expect(delayed.publicSnapshots[1]).toEqual([]) -}) +} -it('rejects repaired partial publications and malformed raw deltas', () => { - oracleRoute(`calibration`) - const before = [ - { id: 0, name: 'a' }, - { id: 1, name: 'b' }, - ] - const after = [ - { id: 0, name: 'new a' }, - { id: 1, name: 'new b' }, - ] - const effect = { rows: after, deletes: [], replace: false } - const first = { - key: 0, - type: 'update' as const, - value: after[0]!, - previousValue: before[0]!, - } - const second = { - key: 1, - type: 'update' as const, - value: after[1]!, - previousValue: before[1]!, - } - expect(() => - assertPublicationHistory( - before, - [ - { changes: [first], rows: [after[0]!, before[1]!] }, - { changes: [second], rows: after }, - ], - [effect], - 'control', +oracleProperty({ + id: `indexeddb.cross-tab-history-reconstruction`, + law: { + statement: + 'Each cross-tab publication refines one coherent history of whole effects at its existing public row, event and status checkpoints.', + authority: + 'The independent per-Collection model and public IndexedDB contract in cross-tab-oracle.ts.', + limits: + 'One fixed history that reconstructs every action and overlapping receiver reads through the transport driver.', + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: ['reconstructs every action and overlapping receiver reads'], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.cross-tab-history-reconstruction`, + plan, + observe, + () => reconstructEveryAction(), ), - ).toThrow('atomic publication') - for (const corrupt of [ - { ...first, type: 'insert' as const }, - { ...first, previousValue: { id: 0, name: 'wrong' } }, - ]) - expect(() => - assertPublicationHistory( - before, - [{ changes: [corrupt, second], rows: after }], - [effect], - 'control', - ), - ).toThrow('event semantics') - assertPublicationHistory( - before, - [{ changes: [first, second], rows: after }], - [effect], - 'healthy control', - ) - expect(() => - assertStatuses(['ready', 'error', 'ready'], ['ready'], 'repaired error'), - ).toThrow('Collection status') - expect(() => - assertReplay({ - inputReconstructed: true, - premiseReached: false, - sameFailure: false, - }), - ).toThrow('failure replay') + check: forwardAssertionJudgment, }) -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']) { - const change = { key, type: 'insert' as const, value: row } - for (const changes of [[change], [change, change]]) - expect(() => - assertPublicationHistory( - [], - [{ changes, rows: [row] }], - [effect], - 'raw key identity', - ), - ).toThrow('event semantics') - } - expect(() => - assertPublicationHistory( - [row], - [ - { - changes: [ - { - key: 1, - type: 'update', - previousValue: row, - value: { ...row, id: '1' }, - }, - ], - rows: [{ ...row, id: '1' }], - }, - ], - [{ ...effect, rows: [{ ...row, id: '1' }] }], - 'update key identity', +oracleProperty({ + id: `indexeddb.cross-tab-history-ordered-writes`, + law: { + statement: + 'Each cross-tab publication refines one coherent history of whole effects at its existing public row, event and status checkpoints.', + authority: + 'The independent per-Collection model and public IndexedDB contract in cross-tab-oracle.ts.', + limits: + 'The finite template of two ordered writes with every sender and intermediate delivery choice (16 schedules).', + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [ + 'enumerates two ordered writes with every intermediate delivery choice', + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.cross-tab-history-ordered-writes`, + plan, + observe, + () => enumerateOrderedWrites(), ), - ).toThrow('event semantics') + check: forwardAssertionJudgment, }) 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 efbbe8d7a2..d23910afe8 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 @@ -17,9 +17,13 @@ * Cross-Collection and manual acceptance ordering are not * specified here. harness.ts controls handlers and observes raw durable rows. */ -import { expect, it } from 'vitest' -import { declareOracle } from '../oracle-declaration' -import { oracleRoute } from '../oracle-config' +import { expect } from 'vitest' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter' import { initial } from './local-write-support' import { Channel, @@ -69,137 +73,170 @@ 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({ + +type WriteOrderHistory = { + kind: string + peerAction: 'none' | 'update' | 'delete' | 'replace' + order: Array + mask: number +} + +async function persistInMutationOrder({ + kind, + peerAction, + order, + mask, +}: WriteOrderHistory) { + const effects = grammars[kind]! + await withHarness(async (h) => { + await seed(h.db, 'items', initial) + const gates = effects.map(() => deferred()), + entered = effects.map(() => deferred()) + let slot = 0 + const handler = () => { + const index = slot++ + entered[index]!.resolve() + return gates[index]!.promise + } + const c = await h.open('items', { + onInsert: handler, + onUpdate: handler, + onDelete: handler, + }) + const peer = await h.open('items', { db: await h.connect() }) + const outcomes = [] + const succeeded = new Set() + for (const [index, effect] of effects.entries()) { + const tx = effect.row + ? c.has(effect.key) + ? c.update(effect.key, (row) => { + row.name = effect.row!.name + }) + : c.insert({ ...effect.row }) + : c.delete(effect.key) + outcomes.push( + tx.when(`settled`).then( + () => { + succeeded.add(index) + return true + }, + () => false, + ), + ) + await entered[index]!.promise + } + // Peer persistence completes while all local handlers are held. Its + // authored effect is therefore the durable base for ordered local + // acceptance. This composes source work with several pending intents + // without assigning a winner to unordered cross-Collection writes. + const peerRow = { id: 1, name: 'peer' } + const beforeLocal = + peerAction === 'replace' + ? [peerRow] + : peerAction === 'delete' + ? initial.slice(1) + : peerAction === 'update' + ? [peerRow, initial[1]!] + : initial + if (peerAction === 'update') + await peer + .update(1, (row) => { + row.name = 'peer' + }) + .when(`settled`) + if (peerAction === 'delete') await peer.delete(1).when(`settled`) + if (peerAction === 'replace') await peer.utils.importData([peerRow]) + await Channel.deliver() + assertRows( + (await readStore(h.db, 'items')).rows, + beforeLocal, + 'peer persistence before local decisions', + ) + const decided = new Set() + try { + for (const index of order) { + if (mask & (1 << index)) gates[index]!.resolve() + else gates[index]!.reject(new Error(`reject ${index}`)) + decided.add(index) + // This is an author-prefix computation, not a second queue. The + // first undecided position bounds which effects may be durable. + let prefix = 0 + while (decided.has(prefix)) prefix++ + await Promise.all(outcomes.slice(0, prefix)) + const durable = await readStore(h.db, 'items') + const expected = expectedRows( + effects.slice(0, prefix), + mask, + beforeLocal, + ) + const cut = `decision ${index}, decided prefix ${prefix}` + assertRows(durable.rows, expected, cut + ': durable') + expect( + [...succeeded].every((position) => position < prefix), + cut + ': no later success', + ).toBe(true) + await Channel.deliver() + assertRows(peer.values(), expected, cut + ': peer') + const restored = await h.open() + assertRows(restored.values(), expected, cut + ': fresh restore') + await restored.cleanup() + } + } finally { + // A hostile implementation may fail at an intermediate cut. Release + // the remaining handlers without losing that primary assertion. + gates.forEach((gate) => gate.resolve()) + await Promise.all(outcomes) + } + expect(await Promise.all(outcomes)).toEqual( + effects.map((_, index) => !!(mask & (1 << index))), + ) + await Channel.deliver() + const expected = expectedRows(effects, mask, beforeLocal) + assertRows( + (await readStore(h.db, 'items')).rows, + expected, + 'all handlers settled: durable', + ) + for (const subject of [c, peer, await h.open()]) + assertRows( + subject.values(), + expected, + 'all handlers settled: public and restore', + ) + }) +} + +oracleProperty({ + id: `indexeddb.local-write-order`, + law: { + statement: `Automatic writes from one Collection persist in mutation order, independently of handler completion order; rejected handlers contribute no durable effect and do not stop later accepted writes.`, + authority: `The maintainer-approved local ordering law stated above`, + limits: `Three edit grammars of three effects, four peer actions, all six handler completion orders and all eight accept/reject masks; cross-Collection and manual acceptance ordering are not specified.`, + }, owns: [ 'src/collection/index.ts#createCollection', 'src/indexed-db.ts#createIndexedDB', 'src/indexed-db.ts#indexedDBCollectionOptions', ], - model: [expectedRows], + histories: Object.keys(grammars).flatMap((kind) => + (['none', 'update', 'delete', 'replace'] as const).flatMap((peerAction) => + orders.flatMap((order) => + Array.from({ length: 8 }, (_, mask): WriteOrderHistory => ({ + kind, + peerAction, + order, + mask, + })), + ), + ), + ), + label: ({ kind, peerAction, order, mask }) => + `${kind} persists accepted effects after peer ${peerAction} in mutation order (${order.join(',')}; mask ${mask})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`indexeddb.local-write-order`, plan, observe, () => + persistInMutationOrder(plan.history), + ), + check: forwardAssertionJudgment, }) - -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()), - entered = effects.map(() => deferred()) - let slot = 0 - const handler = () => { - const index = slot++ - entered[index]!.resolve() - return gates[index]!.promise - } - const c = await h.open('items', { - onInsert: handler, - onUpdate: handler, - onDelete: handler, - }) - const peer = await h.open('items', { db: await h.connect() }) - const outcomes = [] - const succeeded = new Set() - for (const [index, effect] of effects.entries()) { - const tx = effect.row - ? c.has(effect.key) - ? c.update(effect.key, (row) => { - row.name = effect.row!.name - }) - : c.insert({ ...effect.row }) - : c.delete(effect.key) - outcomes.push( - tx.when(`settled`).then( - () => { - succeeded.add(index) - return true - }, - () => false, - ), - ) - await entered[index]!.promise - } - // Peer persistence completes while all local handlers are held. Its - // authored effect is therefore the durable base for ordered local - // acceptance. This composes source work with several pending intents - // without assigning a winner to unordered cross-Collection writes. - const peerRow = { id: 1, name: 'peer' } - const beforeLocal = - peerAction === 'replace' - ? [peerRow] - : peerAction === 'delete' - ? initial.slice(1) - : peerAction === 'update' - ? [peerRow, initial[1]!] - : initial - if (peerAction === 'update') - await peer - .update(1, (row) => { - row.name = 'peer' - }) - .when(`settled`) - if (peerAction === 'delete') await peer.delete(1).when(`settled`) - if (peerAction === 'replace') await peer.utils.importData([peerRow]) - await Channel.deliver() - assertRows( - (await readStore(h.db, 'items')).rows, - beforeLocal, - 'peer persistence before local decisions', - ) - const decided = new Set() - try { - for (const index of order) { - if (mask & (1 << index)) gates[index]!.resolve() - else gates[index]!.reject(new Error(`reject ${index}`)) - decided.add(index) - // This is an author-prefix computation, not a second queue. The - // first undecided position bounds which effects may be durable. - let prefix = 0 - while (decided.has(prefix)) prefix++ - await Promise.all(outcomes.slice(0, prefix)) - const durable = await readStore(h.db, 'items') - const expected = expectedRows( - effects.slice(0, prefix), - mask, - beforeLocal, - ) - const cut = `decision ${index}, decided prefix ${prefix}` - assertRows(durable.rows, expected, cut + ': durable') - expect( - [...succeeded].every((position) => position < prefix), - cut + ': no later success', - ).toBe(true) - await Channel.deliver() - assertRows(peer.values(), expected, cut + ': peer') - const restored = await h.open() - assertRows(restored.values(), expected, cut + ': fresh restore') - await restored.cleanup() - } - } finally { - // A hostile implementation may fail at an intermediate cut. Release - // the remaining handlers without losing that primary assertion. - gates.forEach((gate) => gate.resolve()) - await Promise.all(outcomes) - } - expect(await Promise.all(outcomes)).toEqual( - effects.map((_, index) => !!(mask & (1 << index))), - ) - await Channel.deliver() - const expected = expectedRows(effects, mask, beforeLocal) - assertRows( - (await readStore(h.db, 'items')).rows, - expected, - 'all handlers settled: durable', - ) - for (const subject of [c, peer, await h.open()]) - assertRows( - subject.values(), - expected, - 'all handlers settled: public and restore', - ) - }) - }) - } -} 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 6e2b49ab9f..80a2218223 100644 --- a/packages/db/tests/indexed-db/pending-history-oracle.test.ts +++ b/packages/db/tests/indexed-db/pending-history-oracle.test.ts @@ -19,16 +19,16 @@ * every boundary. Fixed/fresh histories use the same generator and checker. */ import { fc } from '@fast-check/vitest' -import { expect, it as vitestIt } from 'vitest' +import { beforeEach, expect } from 'vitest' import { createLiveQueryCollection } from '../../src' +import { oracleProperty, oracleReplayRequested } from '../oracle-config' +import { nativeCampaignReceipt } from '../native-campaign-receipt' import { - oraclePropertyOptions, - oracleReplayTest, - oracleRoute, - oracleRouteOf, - oracleRuns, -} from '../oracle-config' -import { declareOracle } from '../oracle-declaration' + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter' import { Channel, deferred, @@ -45,26 +45,16 @@ import { snapshot, } from './cross-tab-oracle' import { recordCollection } from './recorder' -import { runCampaign } from './campaign' +import { campaignFailure, createCampaignRecorder } 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 // reseeds the fresh campaign. const FIXED_SEED = 11792027 const PENDING_HISTORY = `indexeddb.pending-history` -const it = oracleReplayTest(vitestIt) type PeerStep = { kind: 'write' | 'delete' | 'clear' | 'import' @@ -403,8 +393,7 @@ async function run( }) } -it('reconstructs held multi-row CRUD crossed with every peer action and decision', async () => { - oracleRoute(`case`) +async function reconstructEveryBoundary() { const reach = new Set() for (const local of ['insert', 'update', 'delete'] as const) for (const kind of ['write', 'delete', 'clear', 'import'] as const) @@ -420,64 +409,175 @@ it('reconstructs held multi-row CRUD crossed with every peer action and decision ) for (const operation of ['insert', 'update', 'delete', 'clear', 'import']) 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, - count: 1, - peer: [{ kind: 'import', overlap: true, value: 0 }], - } - await run(history) - // Same production path, deliberately wrong observation at settlement. The - // old lifetime-acknowledgement model accepted this remote origin instead. - await expect(run(history, new Set(), 'origin')).rejects.toThrow( - 'settlement metadata 0', - ) - // Final production rows remain correct while the captured promise cut is - // wrong. Sampling after peer delivery must not erase this failure. - await expect(run(history, new Set(), 'rows')).rejects.toThrow( - 'public rows at settlement', - ) +} + +oracleProperty({ + id: `indexeddb.pending-history-reconstruction`, + law: { + statement: `One held whole-row intent crossed with independently accepted peer work refines the pending-history reference at handler entry, every peer read and publication, settlement, the ordinary suffix and restore.`, + authority: `optimistic-history-oracle.ts and the pending-history reference stated above`, + limits: `Fixed held multi-row CRUD crossed with every peer action and both decisions; every peer operation must be reached.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: [ + 'reconstructs held multi-row CRUD crossed with every peer action and decision', + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.pending-history-reconstruction`, + plan, + observe, + () => reconstructEveryBoundary(), + ), + check: forwardAssertionJudgment, + timeout: 60_000, }) -it('a peer no-op cannot spend another source acknowledgement', async () => { - oracleRoute(`fixed history`) - await run({ - local: 'insert', - accept: false, - count: 1, - peer: [ - { kind: 'import', overlap: true, value: 1 }, - { kind: 'write', overlap: true, value: 1 }, - ], - }) +// A replacement before the queued confirmation must not take its origin. +const replacedConfirmation: History = { + local: 'insert', + accept: true, + count: 1, + peer: [{ kind: 'import', overlap: true, value: 0 }], +} +const pendingFixedHistories: Array<{ name: string; history: History }> = [ + { + name: 'attributes the queued confirmation after an earlier replacement', + history: replacedConfirmation, + }, + { + name: 'a peer no-op cannot spend another source acknowledgement', + history: { + local: 'insert', + accept: false, + count: 1, + peer: [ + { kind: 'import', overlap: true, value: 1 }, + { kind: 'write', overlap: true, value: 1 }, + ], + }, + }, +] +// Same production path, deliberately wrong observation at settlement. The old +// lifetime-acknowledgement model accepted the remote origin; the rows fault +// keeps final production rows correct while the captured promise cut is wrong, +// so sampling after peer delivery must not erase it. +const settlementFaults = [ + { fault: 'origin', message: 'settlement metadata 0' }, + { fault: 'rows', message: 'public rows at settlement' }, +] as const + +oracleProperty({ + id: `indexeddb.pending-history-fixed`, + law: { + statement: `One held whole-row intent crossed with independently accepted peer work refines the pending-history reference at handler entry, every peer read and publication, settlement, the ordinary suffix and restore.`, + authority: `optimistic-history-oracle.ts and the pending-history reference stated above`, + limits: `Named fixed histories replayed through the same driver and checks; the settlement calibrations corrupt the observation at the settlement cut.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: pendingFixedHistories, + label: ({ name }) => name, + resolve: ({ history }) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.pending-history-fixed`, + plan, + observe, + () => run(plan.history), + ), + check: forwardAssertionJudgment, + calibrations: settlementFaults.map(({ fault, message }) => ({ + name: `attributes the queued confirmation after an earlier replacement: rejects a wrong ${fault} observation at settlement`, + law: `indexeddb.pending-history-fixed`, + driver: ( + plan: { history: History }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `indexeddb.pending-history-fixed`, + plan, + observe, + async () => { + try { + await run(replacedConfirmation, new Set(), fault) + } catch (error) { + // Exactly the old matcher: an error whose message names this cut. + if (error instanceof Error && error.message.includes(message)) + rejectAt(`calibration ${fault}`, error) + throw new Error( + `settlement ${fault} calibration: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + }, + ), + })), }) 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, - (history, receipt) => run(history, receipt.reach), - { runs: oracleRuns(runs), seed: FIXED_SEED }, +// A recorder belongs to one native test. Its unchanged shrink predicate keeps +// the original law, checkpoint and every premise the first failure reached; +// its completion receipt writes the campaign report and checks the budget. +let recorder: ReturnType> +let replayCalls = 0 +beforeEach(() => { + replayCalls = 0 + recorder = createCampaignRecorder('pending', histories, (history, receipt) => + run(history, receipt.reach), ) -}, 120_000) -oracleReplayTest(vitestIt, PENDING_HISTORY)( - `refines held mutations through fresh peer histories`, - async () => { - const parameters = oraclePropertyOptions(runs, PENDING_HISTORY) - oracleRoute(oracleRouteOf(parameters)) - const { numRuns, seed, path } = parameters - await runCampaign( - 'pending', - histories, - (history, receipt) => run(history, receipt.reach), - { runs: numRuns, seed, path }, - ) - }, - 120_000, +}) +nativeCampaignReceipt((result) => + recorder.complete(result, { + runs: result.runConfiguration.numRuns ?? runs, + ...(oracleReplayRequested() ? { path: result.runConfiguration.path } : {}), + }), ) +oracleProperty({ + id: PENDING_HISTORY, + timeout: 120_000, + law: { + statement: `One held whole-row intent crossed with independently accepted peer work refines the pending-history reference at handler entry, every peer read and publication, settlement, the ordinary suffix and restore.`, + authority: `optimistic-history-oracle.ts and the pending-history reference stated above`, + limits: `The bounded peer-history grammar of one held local intent over fake IndexedDB and a controlled Channel; no simultaneous same-key writers, multiple local intents or cleanup's pending-caller policy.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/query/live-query-collection.ts#createLiveQueryCollection', + ], + histories: { arbitrary: histories, runs, fixedSeeds: [FIXED_SEED] }, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(PENDING_HISTORY, plan, observe, async () => { + if (oracleReplayRequested() && replayCalls++ > 0) return + try { + await recorder.run(plan.history) + } catch (error) { + // The recorder rethrows only the same failed law/checkpoint with the + // first history's full premise set; preserve every other attempt in its report. + rejectAt( + campaignFailure(error).checkpoint, + error instanceof Error + ? error + : new Error(String(error), { cause: error }), + ) + } + }), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/indexed-db/persistence-oracle.test.ts b/packages/db/tests/indexed-db/persistence-oracle.test.ts index a216b6be00..4b3ced9b11 100644 --- a/packages/db/tests/indexed-db/persistence-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-oracle.test.ts @@ -21,10 +21,9 @@ * in-flight work. See ORACLE.md and the repository coverage map. */ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createTransaction } from '../../src' -import { oracleProperty, oracleRoute } from '../oracle-config' -import { declareOracle } from '../oracle-declaration' +import { oracleProperty } from '../oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -266,52 +265,22 @@ async function runHistory(steps: Array) { }) } -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 }, - { kind, slot: 1, key: (i + 1) % 4, value: 0, optional: false }, - ]), - ) - }) - 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, - key: 0, - value: 1, - optional: true, - } - await runHistory([ - replacement, - replacement, - { ...replacement, optional: false }, - ]) - }) - it('preserves rows through authored no-op updates', async () => { - oracleRoute(`fixed history`) - const repeated: Step = { - kind: 'write', - slot: 0, - key: 0, - value: 0, - optional: false, - } - await runHistory([repeated, repeated]) - }) +const importReplacement: Step = { + kind: 'import', + slot: 0, + key: 0, + value: 1, + optional: true, +} +const repeatedWrite: Step = { + kind: 'write', + slot: 0, + key: 0, + value: 0, + optional: false, +} +describe('settled histories', () => { // A fixed campaign and a random campaign share one grammar and budget. A path // replay of this property selects only its random campaign. const settledProperty = `indexeddb.settled-history` @@ -328,7 +297,41 @@ describe('settled histories', () => { 'src/indexed-db.ts#indexedDBCollectionOptions', 'src/transactions.ts#createTransaction', ], - histories: { arbitrary: history, runs: 30, fixedSeeds: [1179001] }, + histories: { + arbitrary: history, + runs: 30, + fixedSeeds: [1179001], + fixed: [ + // Reconstruction witnesses preserve all action kinds even if random + // sampling or shrinking selects an empty history. + { + name: 'reconstructs replacement, ownership, typed keys and restart histories', + history: kinds.flatMap((kind, i): Array => [ + { kind, slot: 0, key: i % 4, value: 1, optional: true }, + { kind, slot: 1, key: (i + 1) % 4, value: 0, optional: false }, + ]), + }, + { + name: 'clears a previously confirmed update without retaining optimistic data', + history: [ + { kind: 'write', slot: 0, key: 0, value: 0, optional: false }, + { kind: 'clear', slot: 0, key: 0, value: 0, optional: false }, + ], + }, + { + name: 'refreshes changed imported rows while allowing identical replacements', + history: [ + importReplacement, + importReplacement, + { ...importReplacement, optional: false }, + ], + }, + { + name: 'preserves rows through authored no-op updates', + history: [repeatedWrite, repeatedWrite], + }, + ], + }, resolve: (steps) => ({ steps }), model: recordedAssertionJudgment, driver: (plan, observe) => @@ -355,23 +358,13 @@ function acceptOwnerInModel( ] } -// runHistory is the production driver and refinement check for the settled -// history law: each checkpoint compares public, subscription, durable, export, -// 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({ - check: [runHistory], - model: [acceptOwnerInModel], -}) - /** Three distinct Collection identities receive one mixed manual payload. * The ledger changes only for the selected owner's acceptance, in each of six * orders. Same-store owners use disjoint keys; the sibling store reuses a key. * No assertion invents atomicity across those three native transactions or a * 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`) +async function manualAcceptance() { for (const order of [ [0, 1, 2], [0, 2, 1], @@ -481,4 +474,30 @@ it('manual acceptance partitions mixed payloads across three Collections in ever ) }) } +} + +oracleProperty({ + id: `indexeddb.manual-acceptance`, + law: { + statement: `Manual acceptance of one mixed payload changes only the accepting owner's rows and versions, in every acceptance order, and peers and a fresh restore converge on the model.`, + authority: `IndexedDB CRUD/persistence, store isolation, clear/import/export and cross-tab APIs; Collection optimistic settlement and cleanup`, + limits: `Three Collection identities over two stores in all six acceptance orders; no atomicity across their three native transactions or same-ID Collection policy.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + 'src/transactions.ts#createTransaction', + ], + histories: [ + 'manual acceptance partitions mixed payloads across three Collections in every order', + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`indexeddb.manual-acceptance`, plan, observe, () => + manualAcceptance(), + ), + check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/indexed-db/persistence-values-calibration.test.ts b/packages/db/tests/indexed-db/persistence-values-calibration.test.ts new file mode 100644 index 0000000000..f887516d91 --- /dev/null +++ b/packages/db/tests/indexed-db/persistence-values-calibration.test.ts @@ -0,0 +1,37 @@ +// @vitest-environment node +// Checker calibration of the value observation in structured-clone-oracle.ts. +// It runs no production and no storage: hand-built rows must match or differ +// from the authored value descriptions. The production histories that use the +// same observation are in persistence-values-oracle.test.ts. +import { expect, it } from 'vitest' +import { + createValue, + expectedValueRows, + observeValueRows, + valueKinds, +} from './structured-clone-oracle' + +// 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 () => { + const row = (value: unknown) => [ + { id: 1, name: 'initial', nested: { value } }, + ] + for (const kind of valueKinds) { + expect(await observeValueRows(row(createValue(kind)))).toEqual( + expectedValueRows(kind, 'initial'), + ) + expect(await observeValueRows(row({}))).not.toEqual( + expectedValueRows(kind, 'initial'), + ) + } + expect(await observeValueRows(row(new Uint8Array([17, 0, 255])))).not.toEqual( + expectedValueRows('uint8', 'initial'), + ) + expect( + await observeValueRows(row(new Uint8Array([3, 17, 1, 255, 8]).buffer)), + ).not.toEqual(expectedValueRows('buffer', 'initial')) + expect(await observeValueRows([])).not.toEqual( + expectedValueRows('date', 'initial'), + ) +}) 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 e8b8b3d70b..60869a3e04 100644 --- a/packages/db/tests/indexed-db/persistence-values-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-values-oracle.test.ts @@ -11,15 +11,19 @@ * Every checkpoint compares public, peer, export and raw durable values, plus * version records after rejection. Native receiving lives in e2e/indexed-db/value-witness.spec.ts. */ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { SerializationError, createCollection, createIndexedDB, indexedDBCollectionOptions, } from '../../src' -import { oracleRoute } from '../oracle-config' -import { declareOracle } from '../oracle-declaration' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter' import { Channel, deferred, readStore, withHarness } from './harness' import { createValue, @@ -30,141 +34,128 @@ 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) => { - const collection = createCollection( - indexedDBCollectionOptions({ - db, - name: 'items', - getKey: (row) => row.id, - }), +async function preserveValue(kind: (typeof valueKinds)[number]) { + await withHarness(async (h) => { + const descriptors = [h.db, await h.connect()] + const make = (db: typeof h.db) => { + const collection = createCollection( + indexedDBCollectionOptions({ + db, + name: 'items', + getKey: (row) => row.id, + }), + ) + h.disposers.push(() => collection.cleanup()) + return collection + } + const writer = make(descriptors[0]!) + const peer = make(descriptors[1]!) + await Promise.all([writer.preload(), peer.preload()]) + let expected = expectedValueRows(kind, 'initial') + async function checkpoint(cut: string) { + await Channel.deliver() + expect(writer.status, cut).toBe('ready') + expect(peer.status, cut).toBe('ready') + for (const [path, rows] of [ + ['writer', [...writer.values()]], + ['peer', [...peer.values()]], + ['export', await writer.utils.exportData()], + ['raw', (await readStore(h.db, 'items')).rows], + ] as const) + expect(await observeValueRows(rows), cut + ': ' + path).toEqual( + expected, ) - h.disposers.push(() => collection.cleanup()) - return collection - } - const writer = make(descriptors[0]!) - const peer = make(descriptors[1]!) - await Promise.all([writer.preload(), peer.preload()]) - let expected = expectedValueRows(kind, 'initial') - async function checkpoint(cut: string) { - await Channel.deliver() - expect(writer.status, cut).toBe('ready') - expect(peer.status, cut).toBe('ready') - for (const [path, rows] of [ - ['writer', [...writer.values()]], - ['peer', [...peer.values()]], - ['export', await writer.utils.exportData()], - ['raw', (await readStore(h.db, 'items')).rows], - ] as const) - expect(await observeValueRows(rows), cut + ': ' + path).toEqual( - expected, - ) - } - await writer - .insert({ - id: 1, - name: 'initial', - nested: { value: createValue(kind) }, - }) - .when(`settled`) - await checkpoint('insert') - await writer - .update(1, (draft) => { - draft.name = 'updated' - }) - .when(`settled`) - expected = expectedValueRows(kind, 'updated') - await checkpoint('unrelated update') - const exported = await writer.utils.exportData() - await writer.utils.clearObjectStore() - expected = [] - await checkpoint('clear') - await writer.utils.importData(exported) - expected = expectedValueRows(kind, 'updated') - await checkpoint('restore exported values') - // A valid prefix and suffix surround each clone failure. Neither native - // rows nor metadata may retain a prefix, including replacement deletion. - for (const entry of ['insert', 'import'] as const) { - for (const badIndex of [0, 1, 2]) { - const before = await readStore(h.db, '_versions') - const batch = [2, 3, 4].map((id, index) => ({ - id, - name: 'rejected', - nested: { - value: index === badIndex ? () => undefined : createValue(kind), - }, - })) - const outcome = - entry === 'insert' - ? writer.insert(batch).when(`settled`) - : writer.utils.importData(batch) - await expect(outcome).rejects.toBeInstanceOf(SerializationError) - await checkpoint(`${entry} rejection at ${badIndex}`) - expect( - await readStore(h.db, '_versions'), - 'rejected batch preserves metadata', - ).toEqual(before) - } + } + await writer + .insert({ + id: 1, + name: 'initial', + nested: { value: createValue(kind) }, + }) + .when(`settled`) + await checkpoint('insert') + await writer + .update(1, (draft) => { + draft.name = 'updated' + }) + .when(`settled`) + expected = expectedValueRows(kind, 'updated') + await checkpoint('unrelated update') + const exported = await writer.utils.exportData() + await writer.utils.clearObjectStore() + expected = [] + await checkpoint('clear') + await writer.utils.importData(exported) + expected = expectedValueRows(kind, 'updated') + await checkpoint('restore exported values') + // A valid prefix and suffix surround each clone failure. Neither native + // rows nor metadata may retain a prefix, including replacement deletion. + for (const entry of ['insert', 'import'] as const) { + for (const badIndex of [0, 1, 2]) { + const before = await readStore(h.db, '_versions') + const batch = [2, 3, 4].map((id, index) => ({ + id, + name: 'rejected', + nested: { + value: index === badIndex ? () => undefined : createValue(kind), + }, + })) + const outcome = + entry === 'insert' + ? writer.insert(batch).when(`settled`) + : writer.utils.importData(batch) + await expect(outcome).rejects.toBeInstanceOf(SerializationError) + await checkpoint(`${entry} rejection at ${badIndex}`) + expect( + await readStore(h.db, '_versions'), + 'rejected batch preserves metadata', + ).toEqual(before) } - await writer - .update(1, (draft) => { - draft.name = 'suffix' - }) - .when(`settled`) - expected = expectedValueRows(kind, 'suffix') - await checkpoint('successful suffix') - const freshDb = await createIndexedDB({ - name: h.db.name, - version: 1, - stores: ['items', 'other'], + } + await writer + .update(1, (draft) => { + draft.name = 'suffix' }) - h.descriptors.push(freshDb) - const restored = make(freshDb) - await restored.preload() - expect( - await observeValueRows(restored.values()), - 'fresh descriptor restore', - ).toEqual(expected) + .when(`settled`) + expected = expectedValueRows(kind, 'suffix') + await checkpoint('successful suffix') + const freshDb = await createIndexedDB({ + name: h.db.name, + version: 1, + stores: ['items', 'other'], }) + h.descriptors.push(freshDb) + const restored = make(freshDb) + await restored.preload() + expect( + await observeValueRows(restored.values()), + 'fresh descriptor restore', + ).toEqual(expected) }) } -// 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 } }, - ] - for (const kind of valueKinds) { - expect(await observeValueRows(row(createValue(kind)))).toEqual( - expectedValueRows(kind, 'initial'), - ) - expect(await observeValueRows(row({}))).not.toEqual( - expectedValueRows(kind, 'initial'), - ) - } - expect(await observeValueRows(row(new Uint8Array([17, 0, 255])))).not.toEqual( - expectedValueRows('uint8', 'initial'), - ) - expect( - await observeValueRows(row(new Uint8Array([3, 17, 1, 255, 8]).buffer)), - ).not.toEqual(expectedValueRows('buffer', 'initial')) - expect(await observeValueRows([])).not.toEqual( - expectedValueRows('date', 'initial'), - ) +oracleProperty({ + id: `indexeddb.value-preservation`, + law: { + statement: `An untouched nested structured-clone value survives a scalar update, peer read, export/import replacement and fresh descriptor restore, and an uncloneable batch is rejected atomically as a SerializationError.`, + authority: `The IndexedDB structured-clone persistence contract and the authored value model in structured-clone-oracle.ts`, + limits: `One finite history per value kind; rejection crosses insert/import and first/middle/last uncloneable nested values.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: valueKinds, + label: (kind) => + `preserves ${kind} through updates, peers, export/import and restore`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`indexeddb.value-preservation`, plan, observe, () => + preserveValue(plan.kind), + ), + check: forwardAssertionJudgment, }) // Value capture: update follows core's documented callback-return snapshot; @@ -175,74 +166,104 @@ it('rejects flattened values, lost offsets, corrupt bytes and absent rows', asyn // Blob is immutable; custom classes and mutation of public Collection rows are // outside this law. The same source reference is intentionally shared with the // submitted row, never with the expected descriptions. -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()) - const make = (db: typeof h.db) => { - const c = createCollection( - indexedDBCollectionOptions({ - db, - name: 'items', - getKey: (row) => row.id, - onUpdate: () => handler.promise, - }), - ) - h.disposers.push(() => c.cleanup()) - return c - } - const c = make(h.db) - const peer = make(await h.connect()) - await Promise.all([c.preload(), peer.preload()]) - await c - .insert({ id: 1, name: 'before', nested: { value: null } }) - .when(`settled`) - await Channel.deliver() - const gate = holdStore(h.db.db) - h.disposers.push(() => gate.release()) - await gate.started - const value = createValue(kind) - const row = { id: 1, name: 'captured', nested: { value } } - const pending = - entry === 'import' - ? c.utils.importData([row]) - : c - .update(1, (draft) => { - draft.name = row.name - draft.nested = row.nested - }) - .when(`settled`) - // Date, buffers, views and nested containers each have a native mutation - // that preserves their shape but changes content. A shape-only check - // would miss the bug; the independent byte/timestamp description cannot. - function mutate(input: unknown): void { - if (input instanceof Date) input.setTime(0) - else if (input instanceof ArrayBuffer) new Uint8Array(input).fill(99) - else if (ArrayBuffer.isView(input)) - new Uint8Array(input.buffer).fill(99) - else if (Array.isArray(input)) input.forEach(mutate) - } - row.name = 'caller changed' - mutate(value) - handler.resolve() - await gate.release() - await pending - await Channel.deliver() - const expected = expectedValueRows(kind, 'captured') - for (const rows of [ - c.values(), - peer.values(), - await c.utils.exportData(), - (await readStore(h.db, 'items')).rows, - ]) - expect( - await observeValueRows(rows), - 'captured value survives delayed native put', - ).toEqual(expected) - }) - }) - } +type CaptureHistory = { + kind: (typeof valueKinds)[number] + entry: 'update' | 'import' +} + +async function captureValue({ kind, entry }: CaptureHistory) { + await withHarness(async (h) => { + const handler = deferred() + h.disposers.push(() => handler.resolve()) + const make = (db: typeof h.db) => { + const c = createCollection( + indexedDBCollectionOptions({ + db, + name: 'items', + getKey: (row) => row.id, + onUpdate: () => handler.promise, + }), + ) + h.disposers.push(() => c.cleanup()) + return c + } + const c = make(h.db) + const peer = make(await h.connect()) + await Promise.all([c.preload(), peer.preload()]) + await c + .insert({ id: 1, name: 'before', nested: { value: null } }) + .when(`settled`) + await Channel.deliver() + const gate = holdStore(h.db.db) + h.disposers.push(() => gate.release()) + await gate.started + const value = createValue(kind) + const row = { id: 1, name: 'captured', nested: { value } } + const pending = + entry === 'import' + ? c.utils.importData([row]) + : c + .update(1, (draft) => { + draft.name = row.name + draft.nested = row.nested + }) + .when(`settled`) + // Date, buffers, views and nested containers each have a native mutation + // that preserves their shape but changes content. A shape-only check + // would miss the bug; the independent byte/timestamp description cannot. + function mutate(input: unknown): void { + if (input instanceof Date) input.setTime(0) + else if (input instanceof ArrayBuffer) new Uint8Array(input).fill(99) + else if (ArrayBuffer.isView(input)) new Uint8Array(input.buffer).fill(99) + else if (Array.isArray(input)) input.forEach(mutate) + } + row.name = 'caller changed' + mutate(value) + handler.resolve() + await gate.release() + await pending + await Channel.deliver() + const expected = expectedValueRows(kind, 'captured') + for (const rows of [ + c.values(), + peer.values(), + await c.utils.exportData(), + (await readStore(h.db, 'items')).rows, + ]) + expect( + await observeValueRows(rows), + 'captured value survives delayed native put', + ).toEqual(expected) + }) } + +oracleProperty({ + id: `indexeddb.value-capture`, + law: { + statement: `Update and import capture the authored value before delayed native persistence: a later caller mutation cannot rewrite the public, peer, export or raw stored value.`, + authority: `The IndexedDB structured-clone persistence contract, core's documented callback-return snapshot, and the authored value model in structured-clone-oracle.ts`, + limits: `Every non-Blob value kind through update and import with a held handler and native store; custom classes and mutation of public Collection rows are outside this law.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: valueKinds + .filter((value) => value !== 'blob') + .flatMap((kind) => + (['update', 'import'] as const).map((entry): CaptureHistory => ({ + kind, + entry, + })), + ), + label: ({ kind, entry }) => + `captures ${kind} before delayed ${entry} persistence`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`indexeddb.value-capture`, plan, observe, () => + captureValue(plan.history), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/indexed-db/retirement-oracle.test.ts b/packages/db/tests/indexed-db/retirement-oracle.test.ts index f9daac3e87..f92cd1ee41 100644 --- a/packages/db/tests/indexed-db/retirement-oracle.test.ts +++ b/packages/db/tests/indexed-db/retirement-oracle.test.ts @@ -18,10 +18,14 @@ * Native gates issue real requests; no IndexedDB transaction awaits a test gate. * These finite schedules cover the semantic cuts, not every TLC interleaving. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createTransaction } from '../../src' -import { declareOracle } from '../oracle-declaration' -import { oracleRoute } from '../oracle-config' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter' import { assertRows, deferred, @@ -57,17 +61,6 @@ function apply(rows: Array, kind: Kind, owner: number): Array { return [...rows.filter((value) => value.id !== row.id), row] } -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) => reasons.flatMap((reason) => @@ -75,235 +68,317 @@ 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 [ - [true, true], - [true, false], - [false, true], - [false, false], - ]) { - await withHarness(async (h) => { - await seed(h.db, 'items', initial) - const subjects = [await h.open(), await h.open()] - const statuses = subjects.map(recordStatus) - const gate = holdStore(h.db.db) - h.disposers.push(() => gate.release()) - await gate.started - const native = observeTransactions(h.db.db) - h.disposers.push(() => native.restore()) - const order = reverse ? [1, 0] : [0, 1] - const operations = [first, second] - const publications: Array<{ - slot: number - rows: Array - native: string - }> = [] - subjects.forEach((c, slot) => { - const subscription = c.subscribeChanges(() => { - publications.push({ - slot, - rows: sorted(c.base.values()), - native: - native.entries[order.indexOf(slot)]?.status ?? 'notAdmitted', - }) +async function admittedPair({ + first, + second, + reason, + reverse, +}: (typeof pairCells)[number]) { + // Both queued aborts and FIFO commits are reachable, including abort of the + // later transaction before the earlier transaction finishes. + for (const outcomes of [ + [true, true], + [true, false], + [false, true], + [false, false], + ]) { + await withHarness(async (h) => { + await seed(h.db, 'items', initial) + const subjects = [await h.open(), await h.open()] + const statuses = subjects.map(recordStatus) + const gate = holdStore(h.db.db) + h.disposers.push(() => gate.release()) + await gate.started + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + const order = reverse ? [1, 0] : [0, 1] + const operations = [first, second] + const publications: Array<{ + slot: number + rows: Array + native: string + }> = [] + subjects.forEach((c, slot) => { + const subscription = c.subscribeChanges(() => { + publications.push({ + slot, + rows: sorted(c.base.values()), + native: + native.entries[order.indexOf(slot)]?.status ?? 'notAdmitted', }) - h.disposers.push(() => subscription.unsubscribe()) }) - const callers: Array> = [] - const nativeAtCaller: Array = [] - for (const slot of order) { - callers[slot] = observe( - start(subjects[slot]!, operations[slot]!, slot + 1), - () => { - nativeAtCaller[slot] = native.entries[order.indexOf(slot)]!.status - return sorted(subjects[slot]!.base.values()) - }, - ) - await vi.waitFor(() => - expect(native.entries.length).toBe(callers.filter(Boolean).length), - ) - } - expect( - native.entries.every((entry) => entry.status === 'pending'), - ).toBe(true) - expect(callers.map((c) => c.result.state)).toEqual([ - 'pending', - 'pending', - ]) - const inspector = await close(h, reason) - subjects.forEach((c, i) => closed(c, statuses[i]!)) - for (const slot of [...order].reverse()) { - if (!outcomes[slot]) - native.entries[order.indexOf(slot)]!.transaction.abort() - } - await gate.release() - await Promise.all(callers.map((c) => c.done)) - let durable = initial - for (const slot of order) - if (outcomes[slot]) - durable = apply(durable, operations[slot]!, slot + 1) - for (const slot of [0, 1]) { - const expected = outcomes[slot] - ? apply(initial, operations[slot]!, slot + 1) - : initial - expect( - callers[slot]!.result.state, - 'caller follows native commit/abort', - ).toBe(outcomes[slot] ? 'fulfilled' : 'rejected') - // abort() makes rollback irreversible before the asynchronous abort - // event. A callback rejection may therefore precede that event. - if (outcomes[slot]) - expect( - nativeAtCaller[slot], - 'native commit at caller success', - ).toBe('complete') - else - expect( - nativeAtCaller[slot], - 'rejected caller cannot report a commit', - ).not.toBe('complete') - expect(native.entries[order.indexOf(slot)]!.status).toBe( - outcomes[slot] ? 'complete' : 'abort', - ) - assertRows( - callers[slot]!.snapshot()!, - expected, - 'source at caller settlement', - ) - assertRows( - subjects[slot]!.base.values(), - expected, - 'confirmation before caller success', - ) - assertRows( - subjects[slot]!.values(), - expected, - 'optimistic state settled', - ) - closed(subjects[slot]!, statuses[slot]!) - } - assertRows( - (await readStore(inspector, 'items')).rows, - durable, - 'native FIFO fold', - ) - for (const publication of publications) { - assertRows( - publication.rows, - publication.native === 'complete' - ? apply( - initial, - operations[publication.slot]!, - publication.slot + 1, - ) - : initial, - 'only committed writes publish authoritative base', - ) - } - expect(native.entries).toHaveLength(2) + h.disposers.push(() => subscription.unsubscribe()) }) - } - }) -}) - -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() - const statuses = recordStatus(c) - const accepted = deferred(), - finish = deferred() - h.disposers.push(() => finish.resolve()) - const tx = createTransaction({ - mutationFn: async ({ transaction }) => { - await c.utils.acceptMutations(transaction) - accepted.resolve() - await finish.promise + const callers: Array> = [] + const nativeAtCaller: Array = [] + for (const slot of order) { + callers[slot] = observe( + start(subjects[slot]!, operations[slot]!, slot + 1), + () => { + nativeAtCaller[slot] = native.entries[order.indexOf(slot)]!.status + return sorted(subjects[slot]!.base.values()) }, - }) - tx.mutate(() => { - void start(c, kind, 1) - }) - const caller = observe(tx.when(`settled`), () => - sorted(c.base.values()), ) - await accepted.promise - assertRows(c.base.values(), initial, 'accepted source remains queued') - expect(caller.result.state).toBe('pending') - const inspector = await close(h, reason) + await vi.waitFor(() => + expect(native.entries.length).toBe(callers.filter(Boolean).length), + ) + } + expect(native.entries.every((entry) => entry.status === 'pending')).toBe( + true, + ) + expect(callers.map((c) => c.result.state)).toEqual(['pending', 'pending']) + const inspector = await close(h, reason) + subjects.forEach((c, i) => closed(c, statuses[i]!)) + for (const slot of [...order].reverse()) { + if (!outcomes[slot]) + native.entries[order.indexOf(slot)]!.transaction.abort() + } + await gate.release() + await Promise.all(callers.map((c) => c.done)) + let durable = initial + for (const slot of order) + if (outcomes[slot]) + durable = apply(durable, operations[slot]!, slot + 1) + for (const slot of [0, 1]) { + const expected = outcomes[slot] + ? apply(initial, operations[slot]!, slot + 1) + : initial + expect( + callers[slot]!.result.state, + 'caller follows native commit/abort', + ).toBe(outcomes[slot] ? 'fulfilled' : 'rejected') + // abort() makes rollback irreversible before the asynchronous abort + // event. A callback rejection may therefore precede that event. + if (outcomes[slot]) + expect(nativeAtCaller[slot], 'native commit at caller success').toBe( + 'complete', + ) + else + expect( + nativeAtCaller[slot], + 'rejected caller cannot report a commit', + ).not.toBe('complete') + expect(native.entries[order.indexOf(slot)]!.status).toBe( + outcomes[slot] ? 'complete' : 'abort', + ) assertRows( - (await readStore(inspector, 'items')).rows, - apply(initial, kind, 1), - 'native already committed', + callers[slot]!.snapshot()!, + expected, + 'source at caller settlement', ) - closed(c, statuses) - finish.resolve() - await caller.done - expect(caller.result.state).toBe('fulfilled') assertRows( - caller.snapshot()!, - apply(initial, kind, 1), - 'accepted work precedes caller', + subjects[slot]!.base.values(), + expected, + 'confirmation before caller success', ) assertRows( - c.base.values(), - apply(initial, kind, 1), - 'accepted work survives closure', + subjects[slot]!.values(), + expected, + 'optimistic state settled', ) + closed(subjects[slot]!, statuses[slot]!) + } + assertRows( + (await readStore(inspector, 'items')).rows, + durable, + 'native FIFO fold', + ) + for (const publication of publications) { assertRows( - c.values(), - apply(initial, kind, 1), - 'confirmation and optimistic drop', + publication.rows, + publication.native === 'complete' + ? apply( + initial, + operations[publication.slot]!, + publication.slot + 1, + ) + : initial, + 'only committed writes publish authoritative base', ) - closed(c, statuses) - }) + } + expect(native.entries).toHaveLength(2) }) } } -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() - const statuses = recordStatus(c) - const raw = h.db.db.transaction.bind(h.db.db) - // A real native complete listener, installed before the wrapper listener, - // can call the public close method. No arbitrary task/microtask gap is - // invented between an automatic handler and its continuation. - vi.spyOn(h.db.db, 'transaction').mockImplementation((...args) => { - const tx = raw(...args) - if (tx.mode === 'readwrite') - tx.addEventListener('complete', () => h.db.close(), { once: true }) - return tx - }) - await start(c, kind, 1) - assertRows( - c.base.values(), - apply(initial, kind, 1), - 'finish committed confirmation', - ) - closed(c, statuses) - const freshDb = await h.connect() - assertRows( - (await readStore(freshDb, 'items')).rows, - apply(initial, kind, 1), - 'committed disk', - ) - const fresh = await h.open('items', { db: freshDb }) - assertRows(fresh.values(), apply(initial, kind, 1), 'fresh restore') +describe('admitted native writes retain their actual outcome after closure', () => { + oracleProperty({ + id: `indexeddb.retirement-admitted-pairs`, + law: { + statement: `Admitted native writes retain their actual native outcome after closure: callers, sources, publications and disk follow commit or abort in native FIFO order.`, + authority: `The finish policy of review-evidence/deletion-tla/retirement_oracle.tla and the IndexedDB connection-closure contract stated above`, + limits: `Every pair of the five operations, three close reasons and both admission orders, each with all four native outcome pairs.`, + }, + 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', + ], + histories: pairCells, + label: (cell) => JSON.stringify(cell), + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `indexeddb.retirement-admitted-pairs`, + plan, + record, + () => admittedPair(plan.cell), + ), + check: forwardAssertionJudgment, + }) +}) + +async function acceptedBeforeSettlement({ + reason, + kind, +}: (typeof acceptedCells)[number]) { + await withHarness(async (h) => { + await seed(h.db, 'items', initial) + const c = await h.open() + const statuses = recordStatus(c) + const accepted = deferred(), + finish = deferred() + h.disposers.push(() => finish.resolve()) + const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + await c.utils.acceptMutations(transaction) + accepted.resolve() + await finish.promise + }, + }) + tx.mutate(() => { + void start(c, kind, 1) }) + const caller = observe(tx.when(`settled`), () => sorted(c.base.values())) + await accepted.promise + assertRows(c.base.values(), initial, 'accepted source remains queued') + expect(caller.result.state).toBe('pending') + const inspector = await close(h, reason) + assertRows( + (await readStore(inspector, 'items')).rows, + apply(initial, kind, 1), + 'native already committed', + ) + closed(c, statuses) + finish.resolve() + await caller.done + expect(caller.result.state).toBe('fulfilled') + assertRows( + caller.snapshot()!, + apply(initial, kind, 1), + 'accepted work precedes caller', + ) + assertRows( + c.base.values(), + apply(initial, kind, 1), + 'accepted work survives closure', + ) + assertRows( + c.values(), + apply(initial, kind, 1), + 'confirmation and optimistic drop', + ) + closed(c, statuses) }) } +const acceptedCells = reasons.flatMap((reason) => + (['insert', 'update', 'delete'] as const).map((kind) => ({ reason, kind })), +) +oracleProperty({ + id: `indexeddb.retirement-accepted-publication`, + law: { + statement: `Sync work accepted before closure publishes before caller settlement and survives closure, while the closed Collection stays errored.`, + authority: `The finish policy of review-evidence/deletion-tla/retirement_oracle.tla and the IndexedDB connection-closure contract stated above`, + limits: `Insert, update and delete accepted by a manual handler, for each of the three close reasons.`, + }, + 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', + ], + histories: acceptedCells, + label: ({ reason, kind }) => + `publishes accepted ${kind} before caller settlement after ${reason}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `indexeddb.retirement-accepted-publication`, + plan, + record, + () => acceptedBeforeSettlement(plan.cell), + ), + check: forwardAssertionJudgment, +}) + +async function committedBeforeContinuation(kind: Kind) { + await withHarness(async (h) => { + await seed(h.db, 'items', initial) + const c = await h.open() + const statuses = recordStatus(c) + const raw = h.db.db.transaction.bind(h.db.db) + // A real native complete listener, installed before the wrapper listener, + // can call the public close method. No arbitrary task/microtask gap is + // invented between an automatic handler and its continuation. + vi.spyOn(h.db.db, 'transaction').mockImplementation((...args) => { + const tx = raw(...args) + if (tx.mode === 'readwrite') + tx.addEventListener('complete', () => h.db.close(), { once: true }) + return tx + }) + await start(c, kind, 1) + assertRows( + c.base.values(), + apply(initial, kind, 1), + 'finish committed confirmation', + ) + closed(c, statuses) + const freshDb = await h.connect() + assertRows( + (await readStore(freshDb, 'items')).rows, + apply(initial, kind, 1), + 'committed disk', + ) + const fresh = await h.open('items', { db: freshDb }) + assertRows(fresh.values(), apply(initial, kind, 1), 'fresh restore') + }) +} + +oracleProperty({ + id: `indexeddb.retirement-committed-continuation`, + law: { + statement: `A native-committed write is confirmed, durable and restored even when an explicit close precedes its continuation.`, + authority: `The finish policy of review-evidence/deletion-tla/retirement_oracle.tla and the IndexedDB connection-closure contract stated above`, + limits: `Each of the five operations with a native complete listener that closes the connection.`, + }, + 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', + ], + histories: kinds, + label: (kind) => + `confirms native-committed ${kind} if explicit close precedes its continuation`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `indexeddb.retirement-committed-continuation`, + plan, + record, + () => committedBeforeContinuation(plan.kind), + ), + check: forwardAssertionJudgment, +}) + // Asymmetric prefixes retain the model's per-operation independence. One // Collection may already have accepted/settled while another is deciding or // natively pending. Utilities have no asynchronous application handler and no @@ -328,148 +403,175 @@ 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] - const decisions = [deferred(), deferred()] - const finishes = [deferred(), deferred()] - h.disposers.push(() => { - decisions.forEach((g) => g.resolve()) - finishes.forEach((g) => g.resolve()) - }) - const subjects = await Promise.all( - operations.map((op, slot) => { - const handler = async () => { - if (op.prefix === 'deciding') await decisions[slot]!.promise - if (op.prefix === 'rejected') throw new Error('authored rejection') - } - return h.open('items', { - onInsert: handler, - onUpdate: handler, - onDelete: handler, - }) - }), - ) - const statuses = subjects.map(recordStatus) - const native = observeTransactions(h.db.db) - h.disposers.push(() => native.restore()) - const callers: Array> = [] - let durable = initial - const capture = (slot: number) => () => - sorted(subjects[slot]!.base.values()) - // Complete native work first; the later gate must not block a requested - // pre-closure native commit. Reverse owner order in half the cells to avoid - // equating Collection identity with storage ordering. - const order = a.kind < b.kind ? [0, 1] : [1, 0] - for (const slot of order) { - const op = operations[slot]!, - c = subjects[slot]! - if (op.prefix === 'settled' || op.prefix === 'rejected') { - callers[slot] = observe(start(c, op.kind, slot + 1), capture(slot)) - await callers[slot].done - expect(callers[slot].result.state).toBe( - op.prefix === 'settled' ? 'fulfilled' : 'rejected', - ) - if (op.prefix === 'settled') - durable = apply(durable, op.kind, slot + 1) - } else if (op.prefix === 'accepted') { - const accepted = deferred() - const tx = createTransaction({ - mutationFn: async ({ transaction }) => { - await c.utils.acceptMutations(transaction) - accepted.resolve() - await finishes[slot]!.promise - }, - }) - tx.mutate(() => { - void start(c, op.kind, slot + 1) - }) - callers[slot] = observe(tx.when(`settled`), capture(slot)) - await accepted.promise - durable = apply(durable, op.kind, slot + 1) - assertRows( - c.base.values(), - initial, - 'accepted work remains queued before closure', - ) +async function independentPrefixes({ + a, + b, + reason, + abort, +}: (typeof mixedCells)[number]) { + await withHarness(async (h) => { + await seed(h.db, 'items', initial) + const operations = [a, b] + const decisions = [deferred(), deferred()] + const finishes = [deferred(), deferred()] + h.disposers.push(() => { + decisions.forEach((g) => g.resolve()) + finishes.forEach((g) => g.resolve()) + }) + const subjects = await Promise.all( + operations.map((op, slot) => { + const handler = async () => { + if (op.prefix === 'deciding') await decisions[slot]!.promise + if (op.prefix === 'rejected') throw new Error('authored rejection') } - } - const gate = holdStore(h.db.db) - h.disposers.push(() => gate.release()) - await gate.started - const beforeAdmission = native.entries.length - for (const slot of order) { - const op = operations[slot]! - if (op.prefix === 'active' || op.prefix === 'deciding') - callers[slot] = observe( - start(subjects[slot]!, op.kind, slot + 1), - capture(slot), - ) - } - const activeCount = operations.filter( - (op) => op.prefix === 'active', - ).length - await vi.waitFor(() => - expect(native.entries.length).toBe(beforeAdmission + activeCount), - ) - for (const slot of [0, 1]) - expect(callers[slot]!.result.state).toBe( - operations[slot]!.prefix === 'settled' - ? 'fulfilled' - : operations[slot]!.prefix === 'rejected' - ? 'rejected' - : 'pending', - ) - const inspector = await close(h, reason) - if (abort) - for (const entry of native.entries.slice(beforeAdmission)) - entry.transaction.abort() - subjects.forEach((c, slot) => closed(c, statuses[slot]!)) - // Release a later caller first as well as an earlier one. The sibling's - // application handler does not own this Collection's accepted source work. - finishes[1]!.resolve() - finishes[0]!.resolve() - if (b.prefix === 'deciding') - decisions[1]!.reject(new Error('application rejection')) - else decisions[1]!.resolve() - decisions[0]!.resolve() - await gate.release() - await Promise.all(callers.map((caller) => caller.done)) - for (const slot of order) - if (operations[slot]!.prefix === 'active' && !abort) - durable = apply(durable, operations[slot]!.kind, slot + 1) - for (const slot of [0, 1]) { - const op = operations[slot]! - const success = - op.prefix !== 'deciding' && - op.prefix !== 'rejected' && - !(op.prefix === 'active' && abort) - expect(callers[slot]!.result.state).toBe( - success ? 'fulfilled' : 'rejected', - ) - const rows = success ? apply(initial, op.kind, slot + 1) : initial - assertRows( - callers[slot]!.snapshot()!, - rows, - 'independent confirmation at caller settlement', + return h.open('items', { + onInsert: handler, + onUpdate: handler, + onDelete: handler, + }) + }), + ) + const statuses = subjects.map(recordStatus) + const native = observeTransactions(h.db.db) + h.disposers.push(() => native.restore()) + const callers: Array> = [] + let durable = initial + const capture = (slot: number) => () => + sorted(subjects[slot]!.base.values()) + // Complete native work first; the later gate must not block a requested + // pre-closure native commit. Reverse owner order in half the cells to avoid + // equating Collection identity with storage ordering. + const order = a.kind < b.kind ? [0, 1] : [1, 0] + for (const slot of order) { + const op = operations[slot]!, + c = subjects[slot]! + if (op.prefix === 'settled' || op.prefix === 'rejected') { + callers[slot] = observe(start(c, op.kind, slot + 1), capture(slot)) + await callers[slot].done + expect(callers[slot].result.state).toBe( + op.prefix === 'settled' ? 'fulfilled' : 'rejected', ) + if (op.prefix === 'settled') durable = apply(durable, op.kind, slot + 1) + } else if (op.prefix === 'accepted') { + const accepted = deferred() + const tx = createTransaction({ + mutationFn: async ({ transaction }) => { + await c.utils.acceptMutations(transaction) + accepted.resolve() + await finishes[slot]!.promise + }, + }) + tx.mutate(() => { + void start(c, op.kind, slot + 1) + }) + callers[slot] = observe(tx.when(`settled`), capture(slot)) + await accepted.promise + durable = apply(durable, op.kind, slot + 1) assertRows( - subjects[slot]!.values(), - rows, - 'independent final public snapshot', + c.base.values(), + initial, + 'accepted work remains queued before closure', ) - closed(subjects[slot]!, statuses[slot]!) } + } + const gate = holdStore(h.db.db) + h.disposers.push(() => gate.release()) + await gate.started + const beforeAdmission = native.entries.length + for (const slot of order) { + const op = operations[slot]! + if (op.prefix === 'active' || op.prefix === 'deciding') + callers[slot] = observe( + start(subjects[slot]!, op.kind, slot + 1), + capture(slot), + ) + } + const activeCount = operations.filter((op) => op.prefix === 'active').length + await vi.waitFor(() => + expect(native.entries.length).toBe(beforeAdmission + activeCount), + ) + for (const slot of [0, 1]) + expect(callers[slot]!.result.state).toBe( + operations[slot]!.prefix === 'settled' + ? 'fulfilled' + : operations[slot]!.prefix === 'rejected' + ? 'rejected' + : 'pending', + ) + const inspector = await close(h, reason) + if (abort) + for (const entry of native.entries.slice(beforeAdmission)) + entry.transaction.abort() + subjects.forEach((c, slot) => closed(c, statuses[slot]!)) + // Release a later caller first as well as an earlier one. The sibling's + // application handler does not own this Collection's accepted source work. + finishes[1]!.resolve() + finishes[0]!.resolve() + if (b.prefix === 'deciding') + decisions[1]!.reject(new Error('application rejection')) + else decisions[1]!.resolve() + decisions[0]!.resolve() + await gate.release() + await Promise.all(callers.map((caller) => caller.done)) + for (const slot of order) + if (operations[slot]!.prefix === 'active' && !abort) + durable = apply(durable, operations[slot]!.kind, slot + 1) + for (const slot of [0, 1]) { + const op = operations[slot]! + const success = + op.prefix !== 'deciding' && + op.prefix !== 'rejected' && + !(op.prefix === 'active' && abort) + expect(callers[slot]!.result.state).toBe( + success ? 'fulfilled' : 'rejected', + ) + const rows = success ? apply(initial, op.kind, slot + 1) : initial assertRows( - (await readStore(inspector, 'items')).rows, - durable, - 'mixed native FIFO fold', + callers[slot]!.snapshot()!, + rows, + 'independent confirmation at caller settlement', ) - expect(native.entries).toHaveLength(beforeAdmission + activeCount) - }) + assertRows( + subjects[slot]!.values(), + rows, + 'independent final public snapshot', + ) + closed(subjects[slot]!, statuses[slot]!) + } + assertRows( + (await readStore(inspector, 'items')).rows, + durable, + 'mixed native FIFO fold', + ) + expect(native.entries).toHaveLength(beforeAdmission + activeCount) + }) +} + +oracleProperty({ + id: `indexeddb.retirement-mixed-prefixes`, + law: { + statement: `Each operation keeps its own prefix outcome at closure: confirmations, public snapshots and the native FIFO fold follow each Collection's own operation.`, + authority: `The finish policy of review-evidence/deletion-tla/retirement_oracle.tla and the IndexedDB connection-closure contract stated above`, + limits: `Every ordered pair of operation prefixes (deciding, rejected, active, accepted, settled) per kind, with each close reason, and native abort when a prefix is active.`, }, -) + 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', + ], + histories: mixedCells, + label: (cell) => + `retains independent operation prefixes at closure: ${JSON.stringify(cell)}`, + resolve: (cell) => ({ cell }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `indexeddb.retirement-mixed-prefixes`, + plan, + record, + () => independentPrefixes(plan.cell), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/indexed-db/transport-oracle.test.ts b/packages/db/tests/indexed-db/transport-oracle.test.ts index 7d0e2592bf..b333f99bd5 100644 --- a/packages/db/tests/indexed-db/transport-oracle.test.ts +++ b/packages/db/tests/indexed-db/transport-oracle.test.ts @@ -17,10 +17,14 @@ * Collections without replacing their rows; reuse requires a fresh descriptor. * These are bounded controlled histories, not browser scheduling evidence. */ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { executeTransaction } from '../../src' -import { declareOracle } from '../oracle-declaration' -import { oracleRoute } from '../oracle-config' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter' import { Channel, assertRows, readStore, seed, withHarness } from './harness' import type { Row } from './harness' @@ -40,58 +44,86 @@ function expectedAfterReplacement( return replacement === 'clear' ? [inserted] : [imported, inserted] } -for (const replacement of ['clear', 'import'] as const) { - for (const delivery of ['FIFO', 'reverse', 'duplicate'] as const) { - it( - 'retains a later local insert after ' + - replacement + - ' with ' + - delivery + - ' notifications', - async () => { - oracleRoute(`case`) - await withHarness(async (h) => { - await seed(h.db, 'items', [{ id: 'old', name: 'replaced' }]) - const source = await h.open() - const receiver = await h.open() - const imported = { id: 'imported', name: 'replacement' } - if (replacement === 'clear') await source.utils.clearObjectStore() - else await source.utils.importData([imported]) - const inserted = { id: 'later', name: 'accepted after replacement' } - await receiver.insert(inserted).when('settled') - const expected = expectedAfterReplacement( - replacement, - imported, - inserted, - ) - const pending = Channel.pending.length - expect( - pending, - 'held replacement and insertion notifications', - ).toBeGreaterThan(1) - if (delivery === 'reverse') Channel.pending.reverse() - if (delivery === 'duplicate') Channel.pending.push(...Channel.pending) - expect(await Channel.deliver(), 'receiver work completed').toBe( - delivery === 'duplicate' ? pending * 2 : pending, - ) - assertRows( - (await readStore(h.db, 'items')).rows, - expected, - 'authored durable operation order', - ) - assertRows((await h.open()).values(), expected, 'fresh restore') - assertRows(source.values(), expected, 'first writer convergence') - assertRows( - receiver.values(), - expected, - 'later writer survives delayed replacement', - ) - }) - }, +type ReplacementHistory = { + replacement: 'clear' | 'import' + delivery: 'FIFO' | 'reverse' | 'duplicate' +} + +async function laterInsertAfterReplacement({ + replacement, + delivery, +}: ReplacementHistory) { + await withHarness(async (h) => { + await seed(h.db, 'items', [{ id: 'old', name: 'replaced' }]) + const source = await h.open() + const receiver = await h.open() + const imported = { id: 'imported', name: 'replacement' } + if (replacement === 'clear') await source.utils.clearObjectStore() + else await source.utils.importData([imported]) + const inserted = { id: 'later', name: 'accepted after replacement' } + await receiver.insert(inserted).when('settled') + const expected = expectedAfterReplacement(replacement, imported, inserted) + const pending = Channel.pending.length + expect( + pending, + 'held replacement and insertion notifications', + ).toBeGreaterThan(1) + if (delivery === 'reverse') Channel.pending.reverse() + if (delivery === 'duplicate') Channel.pending.push(...Channel.pending) + expect(await Channel.deliver(), 'receiver work completed').toBe( + delivery === 'duplicate' ? pending * 2 : pending, + ) + assertRows( + (await readStore(h.db, 'items')).rows, + expected, + 'authored durable operation order', ) - } + assertRows((await h.open()).values(), expected, 'fresh restore') + assertRows(source.values(), expected, 'first writer convergence') + assertRows( + receiver.values(), + expected, + 'later writer survives delayed replacement', + ) + }) } +oracleProperty({ + id: `indexeddb.transport-replacement`, + law: { + statement: `A disjoint write that commits after a replacement stays authoritative: after every notification is delivered, durable rows, a fresh restore and both writers equal the authored operation order.`, + authority: `The controlled IndexedDB transport contract stated above; the authored reference array, not the adapter's version cache`, + limits: `Clear and import replacements crossed with FIFO, reverse and duplicate controlled delivery; not native BroadcastChannel scheduling.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db-wrapper.ts#executeTransaction', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: (['clear', 'import'] as const).flatMap((replacement) => + (['FIFO', 'reverse', 'duplicate'] as const).map( + (delivery): ReplacementHistory => ({ replacement, delivery }), + ), + ), + label: ({ replacement, delivery }) => + 'retains a later local insert after ' + + replacement + + ' with ' + + delivery + + ' notifications', + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.transport-replacement`, + plan, + observe, + () => laterInsertAfterReplacement(plan.history), + ), + check: forwardAssertionJudgment, +}) + // Peer edit model. The reference applies one authored peer update or delete to the // initial rows; metadata completeness does not change this membership. function expectedAfterPeerEdit( @@ -104,66 +136,83 @@ function expectedAfterPeerEdit( : initial.slice(1) } -declareOracle({ +// Restored source membership does not depend on adapter metadata. The model +// applies an authored peer edit to initial rows; metadata completeness is an +// independent grammar axis. Raw wrapper rows reach the production restore path. +type PeerEditHistory = { + metadata: 'none' | 'mixed' | 'all' + key: number | string + action: 'update' | 'delete' +} + +async function reconcilePeerEdit({ metadata, key, action }: PeerEditHistory) { + await withHarness(async (h) => { + const initial = [ + { id: key, name: 'legacy' }, + { id: 'anchor', name: 'untouched' }, + ] + await executeTransaction(h.db.db, 'items', 'readwrite', (_, stores) => { + for (const row of initial) stores.items!.put(row, row.id) + }) + if (metadata !== 'none') + await seed(h.db, 'items', metadata === 'all' ? initial : initial.slice(1)) + const receiver = await h.open() + const sender = await h.open('items', { db: await h.connect() }) + if (action === 'update') + await sender + .update(key, (row) => { + row.name = 'new' + }) + .when(`settled`) + else await sender.delete(key).when(`settled`) + // Repeat delivery: missing versions cannot turn an update into a second + // insert, and repeated deletes must leave the independent anchor intact. + Channel.pending.push(...Channel.pending) + await Channel.deliver() + const expected = expectedAfterPeerEdit(initial, key, action) + expect(receiver.status).toBe('ready') + for (const subject of [receiver, sender, await h.open()]) + assertRows(subject.values(), expected, 'unversioned reconciliation') + assertRows( + (await readStore(h.db, 'items')).rows, + expected, + 'unversioned durable', + ) + }) +} + +oracleProperty({ + id: `indexeddb.transport-unversioned-reconciliation`, + law: { + statement: `Restored source membership does not depend on adapter metadata: after repeated delivery of one peer update or delete, every Collection, a fresh restore and the durable rows equal the authored edit of the initial rows.`, + authority: `The controlled IndexedDB transport contract stated above; the authored reference array, not the adapter's version cache`, + limits: `No, mixed and complete initial version records, number and string keys, and one peer update or delete with duplicated notifications.`, + }, 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], + histories: (['none', 'mixed', 'all'] as const).flatMap((metadata) => + [1, '1'].flatMap((key) => + (['update', 'delete'] as const).map((action): PeerEditHistory => ({ + metadata, + key, + action, + })), + ), + ), + label: ({ metadata, key, action }) => + `reconciles ${action} of ${typeof key} keys with ${metadata} initial versions`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.transport-unversioned-reconciliation`, + plan, + observe, + () => reconcilePeerEdit(plan.history), + ), + check: forwardAssertionJudgment, }) - -// Restored source membership does not depend on adapter metadata. The model -// applies an authored peer edit to initial rows; metadata completeness is an -// independent grammar axis. Raw wrapper rows reach the production restore path. -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' }, - { id: 'anchor', name: 'untouched' }, - ] - await executeTransaction( - h.db.db, - 'items', - 'readwrite', - (_, stores) => { - for (const row of initial) stores.items!.put(row, row.id) - }, - ) - if (metadata !== 'none') - await seed( - h.db, - 'items', - metadata === 'all' ? initial : initial.slice(1), - ) - const receiver = await h.open() - const sender = await h.open('items', { db: await h.connect() }) - if (action === 'update') - await sender - .update(key, (row) => { - row.name = 'new' - }) - .when(`settled`) - else await sender.delete(key).when(`settled`) - // Repeat delivery: missing versions cannot turn an update into a second - // insert, and repeated deletes must leave the independent anchor intact. - Channel.pending.push(...Channel.pending) - await Channel.deliver() - const expected = expectedAfterPeerEdit(initial, key, action) - expect(receiver.status).toBe('ready') - for (const subject of [receiver, sender, await h.open()]) - assertRows(subject.values(), expected, 'unversioned reconciliation') - assertRows( - (await readStore(h.db, 'items')).rows, - expected, - 'unversioned durable', - ) - }) - }) - } -} 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 3f8944ce95..f73a428cd2 100644 --- a/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts +++ b/packages/db/tests/indexed-db/wrapper-settlement-oracle.test.ts @@ -19,10 +19,14 @@ * crash, browser scheduling, or callback-rejection policy claim; wrapper.test.ts * separately owns callback failure identity and late rejection after commit. */ -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { executeTransaction } from '../../src' -import { declareOracle } from '../oracle-declaration' -import { oracleRoute } from '../oracle-config' +import { oracleProperty } from '../oracle-config' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter' import { deferred, readStore, withHarness } from './harness' type NativeOutcome = 'complete' | 'abort' @@ -37,164 +41,216 @@ function expectedCaller( if (native === 'abort') return 'rejected' return callbackFulfilled ? 'fulfilled' : 'pending' } -declareOracle({ + +const registrations = [ + 'none', + 'additive', + 'property', + 'replace-property', + 'clear-property', +] as const +type SettlementHistory = { + native: NativeOutcome + registration: (typeof registrations)[number] + heldCallback: boolean +} + +async function observerSettlement({ + native, + registration, + heldCallback, +}: SettlementHistory) { + await withHarness(async (h) => { + const row = { id: 1, name: 'authored' } + const callback = deferred() + const terminal = deferred() + const userEvents: Array = [] + const caller: { state: CallerOutcome; result?: unknown } = { + state: 'pending', + } + const outcome = executeTransaction( + h.db.db, + 'items', + 'readwrite', + (tx, stores) => { + tx.addEventListener(native, () => terminal.resolve()) + const observe = (event: Event) => userEvents.push(event.type) + if (registration === 'additive') { + tx.addEventListener('complete', observe) + tx.addEventListener('abort', observe) + } else if (registration !== 'none') { + tx.oncomplete = observe + tx.onabort = observe + if (registration === 'replace-property') { + tx.oncomplete = () => userEvents.push('replacement-complete') + tx.onabort = () => userEvents.push('replacement-abort') + } else if (registration === 'clear-property') { + tx.oncomplete = null + tx.onabort = null + } + } + stores.items!.put(row, row.id) + if (native === 'abort') tx.abort() + return heldCallback ? callback.promise : 'callback result' + }, + ).then( + (value) => { + caller.state = 'fulfilled' + caller.result = value + }, + (error: unknown) => { + caller.state = 'rejected' + caller.result = error + }, + ) + try { + await terminal.promise + const durable = await readStore(h.db, 'items') + expect(durable.rows, 'native outcome determines durable rows').toEqual( + native === 'complete' ? [row] : [], + ) + expect(caller.state, 'native terminal checkpoint').toBe( + expectedCaller(native, !heldCallback), + ) + expect(userEvents, 'wrapper preserves caller observers').toEqual( + registration === 'none' || registration === 'clear-property' + ? [] + : [ + registration === 'replace-property' + ? `replacement-${native}` + : native, + ], + ) + callback.resolve('callback result') + await readStore(h.db, 'items') + expect(caller.state, 'both obligations settled checkpoint').toBe( + expectedCaller(native, true), + ) + await outcome + if (native === 'complete') expect(caller.result).toBe('callback result') + else expect(caller.result).toBeInstanceOf(Error) + } finally { + callback.resolve('callback result') + } + }) +} + +oracleProperty({ + id: `indexeddb.wrapper-observer-settlement`, + law: { + statement: `Native abort rejects and native completion with callback fulfillment succeeds, whatever event observers the callback installs, replaces or clears; user observers run once for the terminal event unless cleared.`, + authority: `The exported executeTransaction wrapper contract stated above`, + limits: `2 native outcomes x 5 observer registrations x 2 callback timings over fake IndexedDB; no abnormal-close, crash, browser scheduling or callback-rejection policy claim.`, + }, owns: [ 'src/collection/index.ts#createCollection', 'src/indexed-db-wrapper.ts#executeTransaction', 'src/indexed-db.ts#createIndexedDB', 'src/indexed-db.ts#indexedDBCollectionOptions', ], - model: [expectedCaller], + histories: (['complete', 'abort'] as const).flatMap((native) => + registrations.flatMap((registration) => + [false, true].map((heldCallback): SettlementHistory => ({ + native, + registration, + heldCallback, + })), + ), + ), + label: ({ native, registration, heldCallback }) => + `preserves ${native} settlement with ${registration} observers and held callback ${heldCallback}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, record) => + recordAssertionJudgment( + `indexeddb.wrapper-observer-settlement`, + plan, + record, + () => observerSettlement(plan.history), + ), + check: forwardAssertionJudgment, }) -for (const native of ['complete', 'abort'] as const) { - for (const registration of [ - 'none', - 'additive', - 'property', - 'replace-property', - 'clear-property', - ] 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() - const terminal = deferred() - const userEvents: Array = [] - const caller: { state: CallerOutcome; result?: unknown } = { - state: 'pending', - } - const outcome = executeTransaction( - h.db.db, - 'items', - 'readwrite', - (tx, stores) => { - tx.addEventListener(native, () => terminal.resolve()) - const observe = (event: Event) => userEvents.push(event.type) - if (registration === 'additive') { - tx.addEventListener('complete', observe) - tx.addEventListener('abort', observe) - } else if (registration !== 'none') { - tx.oncomplete = observe - tx.onabort = observe - if (registration === 'replace-property') { - tx.oncomplete = () => userEvents.push('replacement-complete') - tx.onabort = () => userEvents.push('replacement-abort') - } else if (registration === 'clear-property') { - tx.oncomplete = null - tx.onabort = null - } - } - stores.items!.put(row, row.id) - if (native === 'abort') tx.abort() - return heldCallback ? callback.promise : 'callback result' - }, - ).then( - (value) => { - caller.state = 'fulfilled' - caller.result = value - }, - (error: unknown) => { - caller.state = 'rejected' - caller.result = error - }, - ) - try { - await terminal.promise - const durable = await readStore(h.db, 'items') - expect( - durable.rows, - 'native outcome determines durable rows', - ).toEqual(native === 'complete' ? [row] : []) - expect(caller.state, 'native terminal checkpoint').toBe( - expectedCaller(native, !heldCallback), - ) - expect(userEvents, 'wrapper preserves caller observers').toEqual( - registration === 'none' || registration === 'clear-property' - ? [] - : [ - registration === 'replace-property' - ? `replacement-${native}` - : native, - ], - ) - callback.resolve('callback result') - await readStore(h.db, 'items') - expect(caller.state, 'both obligations settled checkpoint').toBe( - expectedCaller(native, true), - ) - await outcome - if (native === 'complete') - expect(caller.result).toBe('callback result') - else expect(caller.result).toBeInstanceOf(Error) - } finally { - callback.resolve('callback result') - } - }) - }) - } - } -} - // A canceled native request error permits the transaction to commit. This // neighboring history rejects copying idb's error-event rejection policy into // this callback API: only terminal complete/abort decides the native obligation. // 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' } - await executeTransaction(h.db.db, 'items', 'readwrite', (_, stores) => { - stores.items!.put(seed, seed.id) - }) - const native: NativeOutcome = cancelRequestError ? 'complete' : 'abort' - const terminal = deferred() - const errors: Array = [] - const caller: { state: CallerOutcome; result?: unknown } = { - state: 'pending', - } - const outcome = executeTransaction( - h.db.db, - 'items', - 'readwrite', - (tx, stores) => { - tx.addEventListener(native, () => terminal.resolve()) - const duplicate = stores.items!.add(seed, seed.id) - duplicate.addEventListener('error', (event) => { - errors.push(duplicate.error!.name) - if (cancelRequestError) event.preventDefault() - }) - stores.items!.put(sibling, sibling.id) - return 'callback result' - }, - ).then( - (value) => { - caller.state = 'fulfilled' - caller.result = value - }, - (error: unknown) => { - caller.state = 'rejected' - caller.result = error - }, - ) - await terminal.promise - const durable = await readStore(h.db, 'items') - expect(errors, 'native request-error premise').toEqual([ - 'ConstraintError', - ]) - expect(durable.rows, 'native terminal durability').toEqual( - cancelRequestError ? [seed, sibling] : [seed], - ) - expect(caller.state, 'terminal outcome controls caller settlement').toBe( - expectedCaller(native, true), - ) - await outcome - if (cancelRequestError) expect(caller.result).toBe('callback result') - else expect(caller.result).toMatchObject({ name: 'ConstraintError' }) +async function terminalAfterRequestError(cancelRequestError: boolean) { + await withHarness(async (h) => { + const seed = { id: 1, name: 'retained' } + const sibling = { id: 2, name: 'valid sibling' } + await executeTransaction(h.db.db, 'items', 'readwrite', (_, stores) => { + stores.items!.put(seed, seed.id) }) + const native: NativeOutcome = cancelRequestError ? 'complete' : 'abort' + const terminal = deferred() + const errors: Array = [] + const caller: { state: CallerOutcome; result?: unknown } = { + state: 'pending', + } + const outcome = executeTransaction( + h.db.db, + 'items', + 'readwrite', + (tx, stores) => { + tx.addEventListener(native, () => terminal.resolve()) + const duplicate = stores.items!.add(seed, seed.id) + duplicate.addEventListener('error', (event) => { + errors.push(duplicate.error!.name) + if (cancelRequestError) event.preventDefault() + }) + stores.items!.put(sibling, sibling.id) + return 'callback result' + }, + ).then( + (value) => { + caller.state = 'fulfilled' + caller.result = value + }, + (error: unknown) => { + caller.state = 'rejected' + caller.result = error + }, + ) + await terminal.promise + const durable = await readStore(h.db, 'items') + expect(errors, 'native request-error premise').toEqual(['ConstraintError']) + expect(durable.rows, 'native terminal durability').toEqual( + cancelRequestError ? [seed, sibling] : [seed], + ) + expect(caller.state, 'terminal outcome controls caller settlement').toBe( + expectedCaller(native, true), + ) + await outcome + if (cancelRequestError) expect(caller.result).toBe('callback result') + else expect(caller.result).toMatchObject({ name: 'ConstraintError' }) }) } + +oracleProperty({ + id: `indexeddb.wrapper-request-error`, + law: { + statement: `Only the terminal complete or abort decides the native obligation after a request error, whether or not the error event was canceled.`, + authority: `The exported executeTransaction wrapper contract stated above`, + limits: `One duplicate-key request error, canceled or not, beside a valid sibling write over fake IndexedDB.`, + }, + owns: [ + 'src/collection/index.ts#createCollection', + 'src/indexed-db-wrapper.ts#executeTransaction', + 'src/indexed-db.ts#createIndexedDB', + 'src/indexed-db.ts#indexedDBCollectionOptions', + ], + histories: [false, true], + label: (cancelRequestError) => + `uses terminal outcome after a request error canceled ${cancelRequestError}`, + resolve: (cancelRequestError) => ({ cancelRequestError }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `indexeddb.wrapper-request-error`, + plan, + observe, + () => terminalAfterRequestError(plan.cancelRequestError), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/db/tests/live-query-cache-lifecycle-calibration.test.ts b/packages/db/tests/live-query-cache-lifecycle-calibration.test.ts new file mode 100644 index 0000000000..f141e99006 --- /dev/null +++ b/packages/db/tests/live-query-cache-lifecycle-calibration.test.ts @@ -0,0 +1,38 @@ +// Checker calibrations for the claim-lifecycle checks in +// live-query-cache-lifecycle-model-oracle.ts. These tests run no production: +// each hands a deliberately wrong answer to a shared check and requires it to +// reject. The production histories that use the same checks are in +// live-query-cache-lifecycle-oracle.test.ts. +import { expect, test } from 'vitest' +import { + cacheOptionHistories, + conflictingCacheOptions, +} from './live-query-cache-options-oracle' +import { + checkLifecycleAdmission, + checkPortableCacheState, + claimSurvives, + phases, +} from './live-query-cache-lifecycle-model-oracle' + +// Transport must not inherit a local claim merely because a serializer would +// discard it. Deliberately leaking either kind of callable fails this judgment. +test.each([`getKey`, `schema`] as const)( + `portable cache rejects a leaked %s function`, + (option) => { + expect(() => checkPortableCacheState({ [option]: () => {} })).toThrow() + }, +) + +// A wrong forever-claim policy rejects retired results; a no-claim policy +// accepts active conflicts. Both are judged by the same lifecycle comparison. +test.each(phases)( + `lifecycle checker rejects the opposite claim policy at %s`, + (phase) => { + const history = cacheOptionHistories.find(conflictingCacheOptions)! + const wrong = () => { + if (!claimSurvives(phase)) throw new Error(`getKey conflict`) + } + expect(() => checkLifecycleAdmission(wrong, history, phase)).toThrow() + }, +) diff --git a/packages/db/tests/live-query-cache-lifecycle-model-oracle.ts b/packages/db/tests/live-query-cache-lifecycle-model-oracle.ts new file mode 100644 index 0000000000..ad4f96f66a --- /dev/null +++ b/packages/db/tests/live-query-cache-lifecycle-model-oracle.ts @@ -0,0 +1,53 @@ +// The claim-lifecycle model and checks of the live-query cache-lifecycle +// oracle. live-query-cache-lifecycle-oracle.test.ts drives production against +// them, and live-query-cache-lifecycle-calibration.test.ts calibrates the +// checks with deliberately wrong answers. This module registers no tests and +// has no side effects. +import { expect } from 'vitest' +import { oracleAssertion } from './oracle-assertion-adapter' +import { conflictingCacheOptions } from './live-query-cache-options-oracle' +import type { CacheOptionHistory } from './live-query-cache-options-oracle' + +export type Phase = `active` | `replaced` | `failed` | `consumed` +// A portable result has no local claim. Admission claims it, transport updates +// do not end reuse, and terminal failure or live consumption ends reuse. +export function claimSurvives(phase: Phase): boolean { + return phase === `active` || phase === `replaced` +} +export function checkLifecycleAdmission( + action: () => unknown, + history: CacheOptionHistory, + phase: Phase, +) { + if (claimSurvives(phase) && conflictingCacheOptions(history)) + oracleAssertion(`checkLifecycleAdmission:61`, () => + expect(action, `active local claim survives ${phase}`).toThrow( + /getKey|schema/, + ), + ) + else + oracleAssertion(`checkLifecycleAdmission:64`, () => + expect(action, `retired or compatible claim at ${phase}`).not.toThrow(), + ) +} +// Raw transport is checked before JSON can silently erase a leaked function. +export function checkPortableCacheState( + value: unknown, + seen = new Set(), +): void { + oracleAssertion(`checkPortableCacheState:71`, () => + expect( + typeof value, + `portable hydration has no function reference`, + ).not.toBe(`function`), + ) + if (!value || typeof value !== `object` || seen.has(value)) return + seen.add(value) + for (const child of Object.values(value)) checkPortableCacheState(child, seen) +} +export const phases: ReadonlyArray = [ + `active`, + `replaced`, + `failed`, + `consumed`, +] 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 5ade49d715..c8d29fe891 100644 --- a/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts +++ b/packages/db/tests/live-query-cache-lifecycle-oracle.test.ts @@ -12,7 +12,7 @@ * local keys/ordinary values after live handoff are public comparison cuts. * No arbitrary transport serializer, hydrateRoot, or schema transform is claimed. */ -import { afterAll, expect, test, vi } from 'vitest' +import { afterAll, expect, vi } from 'vitest' import { DbClient, Query, @@ -21,8 +21,6 @@ import { getLiveQueryHash, resolveLiveQueryValue, } from '../src' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' import { oracleProperty } from './oracle-property' import { forwardAssertionJudgment, @@ -39,57 +37,17 @@ import { checkCacheOptionRows, conflictingCacheOptions, } from './live-query-cache-options-oracle' +import { + checkLifecycleAdmission, + checkPortableCacheState, + claimSurvives, + phases, +} from './live-query-cache-lifecycle-model-oracle' import type { Verdict } from './oracle-property' -import type { - CacheOptionHistory, - CacheRow, -} from './live-query-cache-options-oracle' +import type { CacheRow } from './live-query-cache-options-oracle' import type { DehydratedLiveQueryResult } from '../src/client' -type Phase = `active` | `replaced` | `failed` | `consumed` -// A portable result has no local claim. Admission claims it, transport updates -// do not end reuse, and terminal failure or live consumption ends reuse. -function claimSurvives(phase: Phase): boolean { - return phase === `active` || phase === `replaced` -} -function checkLifecycleAdmission( - action: () => unknown, - history: CacheOptionHistory, - phase: Phase, -) { - if (claimSurvives(phase) && conflictingCacheOptions(history)) - oracleAssertion(`checkLifecycleAdmission:61`, () => - expect(action, `active local claim survives ${phase}`).toThrow( - /getKey|schema/, - ), - ) - else - oracleAssertion(`checkLifecycleAdmission:64`, () => - expect(action, `retired or compatible claim at ${phase}`).not.toThrow(), - ) -} -// Raw transport is checked before JSON can silently erase a leaked function. -function checkPortableCacheState( - value: unknown, - seen = new Set(), -): void { - oracleAssertion(`checkPortableCacheState:71`, () => - expect( - typeof value, - `portable hydration has no function reference`, - ).not.toBe(`function`), - ) - if (!value || typeof value !== `object` || seen.has(value)) return - seen.add(value) - for (const child of Object.values(value)) checkPortableCacheState(child, seen) -} const claimants = [`preload`, `observer-preload`, `server-read`] as const -const phases: ReadonlyArray = [ - `active`, - `replaced`, - `failed`, - `consumed`, -] const reaches = Object.fromEntries( claimants.map((claimant) => [ claimant, @@ -135,15 +93,6 @@ afterAll(() => { ), ) }) -declareOracle({ - check: [ - checkLifecycleAdmission, - checkCacheOptionRows, - checkPortableCacheState, - ], - model: [claimSurvives, conflictingCacheOptions], - grammar: [cacheOptionHistories], -}) // This is the same exhaustive finite language. Native registration owns // execution and judgment transport; the existing per-route reach gate below @@ -550,27 +499,3 @@ oracleProperty({ }, check: forwardAssertionJudgment, }) - -// Transport must not inherit a local claim merely because a serializer would -// discard it. Deliberately leaking either kind of callable fails this judgment. -test.each([`getKey`, `schema`] as const)( - `portable cache rejects a leaked %s function`, - (option) => { - oracleRoute(`calibration`) - expect(() => checkPortableCacheState({ [option]: () => {} })).toThrow() - }, -) - -// A wrong forever-claim policy rejects retired results; a no-claim policy -// accepts active conflicts. Both are judged by the same lifecycle comparison. -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`) - } - expect(() => checkLifecycleAdmission(wrong, history, phase)).toThrow() - }, -) diff --git a/packages/db/tests/live-query-cache-options-calibration.test.ts b/packages/db/tests/live-query-cache-options-calibration.test.ts new file mode 100644 index 0000000000..7b1c273971 --- /dev/null +++ b/packages/db/tests/live-query-cache-options-calibration.test.ts @@ -0,0 +1,63 @@ +// Checker calibrations for the cache-option checks in +// live-query-cache-options-oracle.ts. These tests run no production: each +// hands a deliberately wrong observation, or a hand-built admission promise, +// to a shared check and requires it to reject. The production histories that +// use the same checks are in live-query-cache-options-oracle.test.ts. +import { expect, test } from 'vitest' +import { + cacheOptionHistories, + checkCacheOptionAdmission, + checkCacheOptionPromise, + checkCacheOptionPublications, + checkCacheOptionRows, + conflictingCacheOptions, +} from './live-query-cache-options-oracle' + +// Deliberately wrong observations use the same judgment as every driver. The +// all-options-differ policy and claim-overwrite policy would accept these +// conflicts; checking both attempts is what distinguishes claim corruption. +test.each( + cacheOptionHistories.filter((history) => conflictingCacheOptions(history)), +)(`cache admission rejects hostile acceptance %j`, (history) => { + 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) => { + const rows = [{ id: `one`, label: `authored` }] + const correct = [{ key: `${key}-one`, value: rows[0] }] + const wrong = [{ key: `borrowed`, value: rows[0] }] + checkCacheOptionRows(correct, rows, key) + expect(() => checkCacheOptionRows(wrong, rows, key)).toThrow() + expect(() => + checkCacheOptionPublications([wrong, correct], rows, key), + ).toThrow() + }, +) + +// Admission calibration includes a promise that never settles. The check must +// reject it at the pre-delivery cut, allowing its driver's cleanup to proceed. +test.each([`pending`, `fulfilled`, `unrelated-error`, `conflict`] as const)( + `rejects wrong pre-delivery admission outcome: %s`, + async (outcome) => { + const history = cacheOptionHistories.find(conflictingCacheOptions)! + const result = + outcome === `pending` + ? new Promise(() => {}) + : outcome === `fulfilled` + ? Promise.resolve() + : Promise.reject( + new Error( + outcome === `conflict` + ? `getKey/schema conflict` + : `unrelated failure`, + ), + ) + const check = checkCacheOptionPromise(result, history, { + deliveryHeld: true, + }) + if (outcome === `conflict`) await check + else await expect(check).rejects.toThrow() + }, +) 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 c9e2416929..9c7e8e7e68 100644 --- a/packages/db/tests/live-query-cache-options-oracle.test.ts +++ b/packages/db/tests/live-query-cache-options-oracle.test.ts @@ -14,7 +14,7 @@ * are the observation cuts. React supplies client/server receiving paths using * the same judgment. Failure/consumption retirement is a separate lifecycle grammar. */ -import { afterAll, expect, test, vi } from 'vitest' +import { afterAll, expect, vi } from 'vitest' import { DbClient, Query, @@ -23,8 +23,6 @@ import { getLiveQueryHash, resolveLiveQueryValue, } from '../src' -import { oracleRoute } from './oracle-config' -import { declareOracle } from './oracle-declaration' import { oracleProperty } from './oracle-property' import { forwardAssertionJudgment, @@ -93,16 +91,6 @@ afterAll(() => { ), ) }) -declareOracle({ - check: [ - checkCacheOptionAdmission, - checkCacheOptionPromise, - checkCacheOptionRows, - checkCacheOptionPublications, - ], - model: [conflictingCacheOptions], - grammar: [cacheOptionHistories], -}) // This is the same exhaustive finite language. Native registration owns // execution and judgment transport; the existing per-route reach gate below @@ -468,55 +456,3 @@ oracleProperty({ }, check: forwardAssertionJudgment, }) - -// Deliberately wrong observations use the same judgment as every driver. The -// all-options-differ policy and claim-overwrite policy would accept these -// conflicts; checking both attempts is what distinguishes claim corruption. -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] }] - checkCacheOptionRows(correct, rows, key) - expect(() => checkCacheOptionRows(wrong, rows, key)).toThrow() - expect(() => - checkCacheOptionPublications([wrong, correct], rows, key), - ).toThrow() - }, -) - -// Admission calibration includes a promise that never settles. The check must -// reject it at the pre-delivery cut, allowing its driver's cleanup to proceed. -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` - ? new Promise(() => {}) - : outcome === `fulfilled` - ? Promise.resolve() - : Promise.reject( - new Error( - outcome === `conflict` - ? `getKey/schema conflict` - : `unrelated failure`, - ), - ) - const check = checkCacheOptionPromise(result, history, { - deliveryHeld: true, - }) - if (outcome === `conflict`) await check - else await expect(check).rejects.toThrow() - }, -) 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 fa1fbe0ba1..274ac56492 100644 --- a/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts +++ b/packages/db/tests/live-query-deferred-acquisition-oracle.test.ts @@ -51,7 +51,7 @@ * `DbClient` stream preloads, a subscriber that outlives cleanup, and reads * that wait for readiness. */ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { BTreeIndex, @@ -64,7 +64,6 @@ 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 { oracleRoute } from './oracle-config.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, @@ -72,7 +71,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from './oracle-assertion-adapter.js' -import { declareOracle } from './oracle-declaration.js' import { EXPECTED_IDS, QUERY_SHAPES, @@ -420,19 +418,55 @@ async function runAcquisitionHistory( } } +type AcquisitionCase = { + state: SourceState + shape: QueryShape + depth: 1 | 2 + name: string +} + describe(`live-query deferred acquisition`, () => { - for (const state of SOURCE_STATES) { - for (const shape of QUERY_SHAPES) { - 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) - }) - } - } - } - } + oracleProperty({ + id: `live-query.deferred-acquisition`, + law: { + statement: `A live-query Collection starts no provider work of its own: its source subscriptions defer acquisition until it has a subscriber that asks for data or a preload, and readiness and rows follow the model after every command.`, + authority: `The deferred-acquisition contract and independent command model stated above`, + limits: `Every source state, query shape, nesting depth 1 or 2, and named command history.`, + }, + 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`, + ], + histories: SOURCE_STATES.flatMap((state) => + QUERY_SHAPES.flatMap((shape) => + ([1, 2] as const).flatMap((depth) => + Object.keys(HISTORIES).map((name): AcquisitionCase => ({ + state, + shape, + depth, + name, + })), + ), + ), + ), + label: ({ state, shape, depth, name }) => + `${state}, ${shape}, depth ${depth}: ${name}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `live-query.deferred-acquisition`, + plan, + observe, + () => { + const { state, shape, depth, name } = plan.history + return runAcquisitionHistory(state, shape, depth, HISTORIES[name]!) + }, + ), + check: forwardAssertionJudgment, + }) }) /** @@ -450,18 +484,6 @@ 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({ - check: [runAcquisitionHistory, runPooledHistory, runDeferredSiblingHistory], - model: [ - initialModel, - applyModel, - expectedReplacementRun, - deferredSiblingRows, - ], -}) - async function runPooledHistory( state: SourceState, commands: ReadonlyArray, @@ -526,14 +548,34 @@ describe(`pooled live-query deferred acquisition`, () => { 'subscribe after build': [`start`, `read`, `subscribe`], 'preload after build': [`start`, `preload`], } - 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) - }) - } - } + oracleProperty({ + id: `live-query.deferred-pooled`, + law: { + statement: `A pooled view's partition subscription defers acquisition until the view has a subscriber or a preload, and readiness and rows follow the model after every command.`, + authority: `The deferred-acquisition contract and independent command model stated above`, + limits: `Eager idle and running sources with the three pooled histories without cleanup.`, + }, + 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`, + ], + histories: ([`eager-idle`, `eager-running`] as const).flatMap((state) => + Object.keys(pooledHistories).map((name) => ({ state, name })), + ), + label: ({ state, name }) => `${state}: ${name}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`live-query.deferred-pooled`, plan, observe, () => + runPooledHistory( + plan.history.state, + pooledHistories[plan.history.name]!, + ), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -555,6 +597,92 @@ function expectedReplacementRun( } } +type SurvivorCase = { + state: `eager-idle` | `on-demand-idle` + shape: QueryShape + depth: 1 | 2 + reader: `ordinary` | `resumed` | `deferred` + renderGate: boolean + cleanupAt: `loading` | `ready` + restartFromCleanup: boolean +} + +async function survivorAfterCleanup({ + state, + shape, + depth, + reader, + renderGate, + cleanupAt, + restartFromCleanup, +}: SurvivorCase) { + const expected = expectedReplacementRun(state, reader) + const { collection: source, counts } = makeSource(state) + const { outer, collections } = makeLiveQuery(source, shape, depth) + if (renderGate) expect(outer._deferSyncStart()).toBe(true) + let subscription: ReturnType | undefined + if (reader !== `ordinary`) { + subscription = outer.subscribeChanges(() => {}, { + deferAcquisition: true, + }) + } + let pending: Promise | undefined + let reached = false + const retiredStatuses: Array = [] + const stopTarget = outer.on(`status:change`, ({ status }) => { + if (reached) { + retiredStatuses.push(status) + if (restartFromCleanup && status === `cleaned-up`) + outer.startSyncImmediate() + } + }) + const stopSource = source.on(`status:change`, ({ status }) => { + if (status === cleanupAt && !reached) { + reached = true + pending = outer.cleanup() + } + }) + try { + if (reader === `ordinary`) subscription = outer.subscribeChanges(() => {}) + else if (reader === `resumed`) subscription!.resumeDeferredAcquisition() + else source.startSyncImmediate() + expect(reached, `source startup reached cleanup`).toBe(true) + await pending + if (!restartFromCleanup) { + expect(outer.status, `cleanup must not retry admission`).toBe( + `cleaned-up`, + ) + expect(retiredStatuses).toEqual([`cleaned-up`]) + } else { + expect(retiredStatuses[0]).toBe(`cleaned-up`) + expect( + retiredStatuses.filter((status) => status === `loading`), + ).toHaveLength(1) + } + expect(outer.subscriberCount).toBe(expected.acquiring) + stopSource() + stopTarget() + outer.startSyncImmediate() + await settle() + expect(counts.acquisitions > 0, `replacement has acquisition`).toBe( + expected.acquires, + ) + expect(outer.status === `ready`, `replacement readiness`).toBe( + expected.ready, + ) + expect(outer.toArray.map((row) => row.id)).toEqual( + expected.ready ? EXPECTED_IDS[shape] : [], + ) + expect(outer.subscriberCount).toBe(expected.acquiring) + } finally { + stopSource() + stopTarget() + subscription?.unsubscribe() + for (const collection of collections) await collection.cleanup() + await source.cleanup() + } +} + describe(`a subscriber that outlives cleanup`, () => { // Admission may still be transferring ownership when a source status callback // cleans up the live-query Collection. Cleanup retires that start, not the @@ -562,8 +690,20 @@ describe(`a subscriber that outlives cleanup`, () => { // The model counts a returned acquiring handle as demand for the next run; // a still-deferred handle contributes none. This extends the settled-cleanup // histories below with the construction boundary, both shapes and depths. - it.each( - ([`eager-idle`, `on-demand-idle`] as const).flatMap((state) => + oracleProperty({ + id: `live-query.deferred-cleanup-survivor`, + law: { + statement: `Cleanup during admission retires that start, not the surviving logical subscription: the replacement run's subscribers, readiness and acquisition follow from how the surviving subscriber read.`, + authority: `The deferred-acquisition contract and independent command model stated above`, + limits: `Eager and on-demand idle sources, every query shape, depth 1 or 2, three reader kinds, with and without a render gate, cleanup at loading or ready, with and without a restart from cleanup.`, + }, + 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`, + ], + histories: ([`eager-idle`, `on-demand-idle`] as const).flatMap((state) => QUERY_SHAPES.flatMap((shape) => ([1, 2] as const).flatMap((depth) => ([`ordinary`, `resumed`, `deferred`] as const).flatMap((reader) => @@ -584,9 +724,7 @@ describe(`a subscriber that outlives cleanup`, () => { ), ), ), - )( - `$state $shape depth=$depth $reader gate=$renderGate survives $cleanupAt cleanup during admission (restart=$restartFromCleanup)`, - async ({ + label: ({ state, shape, depth, @@ -594,76 +732,19 @@ describe(`a subscriber that outlives cleanup`, () => { renderGate, cleanupAt, restartFromCleanup, - }) => { - oracleRoute(`case`) - const expected = expectedReplacementRun(state, reader) - const { collection: source, counts } = makeSource(state) - const { outer, collections } = makeLiveQuery(source, shape, depth) - if (renderGate) expect(outer._deferSyncStart()).toBe(true) - let subscription: ReturnType | undefined - if (reader !== `ordinary`) { - subscription = outer.subscribeChanges(() => {}, { - deferAcquisition: true, - }) - } - let pending: Promise | undefined - let reached = false - const retiredStatuses: Array = [] - const stopTarget = outer.on(`status:change`, ({ status }) => { - if (reached) { - retiredStatuses.push(status) - if (restartFromCleanup && status === `cleaned-up`) - outer.startSyncImmediate() - } - }) - const stopSource = source.on(`status:change`, ({ status }) => { - if (status === cleanupAt && !reached) { - reached = true - pending = outer.cleanup() - } - }) - try { - if (reader === `ordinary`) - subscription = outer.subscribeChanges(() => {}) - else if (reader === `resumed`) subscription!.resumeDeferredAcquisition() - else source.startSyncImmediate() - expect(reached, `source startup reached cleanup`).toBe(true) - await pending - if (!restartFromCleanup) { - expect(outer.status, `cleanup must not retry admission`).toBe( - `cleaned-up`, - ) - expect(retiredStatuses).toEqual([`cleaned-up`]) - } else { - expect(retiredStatuses[0]).toBe(`cleaned-up`) - expect( - retiredStatuses.filter((status) => status === `loading`), - ).toHaveLength(1) - } - expect(outer.subscriberCount).toBe(expected.acquiring) - stopSource() - stopTarget() - outer.startSyncImmediate() - await settle() - expect(counts.acquisitions > 0, `replacement has acquisition`).toBe( - expected.acquires, - ) - expect(outer.status === `ready`, `replacement readiness`).toBe( - expected.ready, - ) - expect(outer.toArray.map((row) => row.id)).toEqual( - expected.ready ? EXPECTED_IDS[shape] : [], - ) - expect(outer.subscriberCount).toBe(expected.acquiring) - } finally { - stopSource() - stopTarget() - subscription?.unsubscribe() - for (const collection of collections) await collection.cleanup() - await source.cleanup() - } - }, - ) + }) => + `${state} ${shape} depth=${depth} ${reader} gate=${renderGate} survives ${cleanupAt} cleanup during admission (restart=${restartFromCleanup})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `live-query.deferred-cleanup-survivor`, + plan, + observe, + () => survivorAfterCleanup(plan.history), + ), + check: forwardAssertionJudgment, + }) }) /** 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 fb55c3d9d4..b68378852f 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 @@ -1,10 +1,8 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, 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 { oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { finishHistory } from './observer-history-cleanup.js' import { withOracleCleanup } from './utils.js' import { differs, oracleProperty, pass } from './oracle-property.js' @@ -13,6 +11,7 @@ import { oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import type { Verdict } from './oracle-property.js' import type { ChangeMessage } from '../src/types.js' @@ -1253,12 +1252,6 @@ function reactionHistory(reaction: Reaction): Array { ] } -declareOracle({ - check: [runHistory, assertHistory], - model: [subscribeModel, unsubscribeModel, publishModel, disposeModel], - grammar: [command, listener], -}) - // Registration delegates to runHistory's existing independent eligibility // model and comparisons. The adapter preserves its assertion object and never // converts setup, production, or cleanup failures into comparison verdicts. @@ -1269,6 +1262,178 @@ const listenerReachStates = [ `command:dispose`, ...reactions.map((reaction) => `reaction:${reaction}`), ] +// A calibration runs a production mutant of runHistory and accepts exactly the +// old rejection: an AssertionError from the eligibility ledger's comparison. +// It returns that rejection, so the caller can check its premises first. +async function mutantRejection( + name: string, + mutantRun: () => Promise, +): Promise { + try { + await mutantRun() + } catch (error) { + if (error instanceof Error && error.name === `AssertionError`) return error + throw new Error( + `${name}: the check rejected with an error the old matcher did not accept: ${String(error)}`, + ) + } + return undefined +} + +const joiningHistory: Array = [ + { type: `subscribe`, listener: 0 }, + { + type: `publish`, + version: 1, + nestedVersion: 0, + reactor: 0, + peer: 1, + reaction: `addPeer`, + }, + { + type: `publish`, + version: 2, + nestedVersion: 0, + reactor: 0, + peer: 0, + reaction: `none`, + }, +] + +const duplicateBootstrapHistory: Array = [ + { + type: `publish`, + version: 1, + nestedVersion: 0, + reactor: 0, + peer: 0, + reaction: `none`, + }, + { type: `subscribe`, listener: 0 }, +] + +const versionMarginHistory: Array = [ + { + type: `publish`, + version: -20, + nestedVersion: -20, + reactor: 0, + peer: 0, + reaction: `none`, + }, + { type: `subscribe`, listener: 0 }, + { + type: `publish`, + version: 20, + nestedVersion: 20, + reactor: 0, + peer: 0, + reaction: `nested`, + }, + { + type: `publish`, + version: 20, + nestedVersion: 20, + reactor: 0, + peer: 0, + reaction: `none`, + }, +] + +type CalibrationPlan = Array +type CalibrationObserve = (checkpoint: string, value: unknown) => void + +// Each old calibration test runs whole as a mutant driver: its clean premise +// run, its exact reach premise, and the mutant that the ledger must reject. +function historyCalibrations(mode: `granular` | `wholesale`) { + const calibration = ( + name: string, + body: () => Promise, + ): { + name: string + law: string + driver: ( + plan: CalibrationPlan, + observe: CalibrationObserve, + ) => Promise + } => ({ + name, + law: `listener eligibility ledger`, + driver: (plan, observe) => + recordAssertionJudgment( + `listener eligibility ledger`, + plan, + observe, + body, + ), + }) + return [ + calibration( + `${mode} listeners joining during dispatch start with the next eligible publication`, + async () => { + const history = joiningHistory + const reach = emptyReach() + await runHistory(mode, history, { reach }) + expect(reach.reactions.addPeer).toBe(1) + const rejection = await mutantRejection(`early peer delivery`, () => + runHistory(mode, history, { mutant: `earlyPeerDelivery` }), + ) + if (rejection) rejectAt(`calibration: early peer delivery`, rejection) + }, + ), + calibration( + `rejects dropped peer delivery after a throwing listener through the ${mode} production path`, + async () => { + const history = reactionHistory(`throw`) + await runHistory(mode, history) + const reach = emptyReach() + const rejection = await mutantRejection( + `dropped peer after throw`, + () => + runHistory(mode, history, { mutant: `dropPeerAfterThrow`, reach }), + ) + expect(reach.reactions.throw).toBe(1) + if (rejection) + rejectAt(`calibration: dropped peer after throw`, rejection) + }, + ), + ...(mode === `granular` + ? [ + calibration( + `rejects a duplicate granular bootstrap through the production seed path`, + async () => { + const history = duplicateBootstrapHistory + await runHistory(`granular`, history) + const rejection = await mutantRejection( + `duplicate bootstrap`, + () => + runHistory(`granular`, history, { + mutant: `duplicateBootstrap`, + }), + ) + if (rejection) + rejectAt(`calibration: duplicate bootstrap`, rejection) + }, + ), + ] + : []), + calibration( + `rejects a reentrant publication delivered out of FIFO order through the ${mode} production path`, + async () => { + const history = reactionHistory(`nested`) + await runHistory(mode, history) + const reach = emptyReach() + const rejection = await mutantRejection(`reentrant out of order`, () => + runHistory(mode, history, { mutant: `reentrantOutOfOrder`, reach }), + ) + expect(reach.reactions.nested).toBe(1) + if (rejection) + rejectAt(`calibration: reentrant out of order`, rejection) + }, + ), + ] +} + for (const [mode, id] of [ [`granular`, `live-query-observer.granular-history`], [`wholesale`, `live-query-observer.wholesale-history`], @@ -1286,6 +1451,12 @@ for (const [mode, id] of [ runs: 75, fixedSeeds: [1813, -1359260562], states: listenerReachStates, + fixed: [ + { + name: `${mode} one-row version margins retain the no-change boundary`, + history: versionMarginHistory, + }, + ], constructed: [ { name: `subscription ownership commands`, @@ -1328,144 +1499,53 @@ for (const [mode, id] of [ if (count > 0) observe(`reach`, `reaction:${reaction}`) }, check: forwardAssertionJudgment, + calibrations: historyCalibrations(mode), }) } -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 = [ +async function executeEveryKind() { + const reach = emptyReach() + for (const mode of [`granular`, `wholesale`] as const) { + await runHistory( + mode, + [ { type: `subscribe`, listener: 0 }, - { - type: `publish`, - version: 1, - nestedVersion: 0, - reactor: 0, - peer: 1, - reaction: `addPeer`, - }, - { - type: `publish`, - version: 2, - nestedVersion: 0, - reactor: 0, - peer: 0, - reaction: `none`, - }, - ] - const reach = emptyReach() - await runHistory(mode, history, { reach }) - expect(reach.reactions.addPeer).toBe(1) - await expect( - runHistory(mode, history, { mutant: `earlyPeerDelivery` }), - ).rejects.toMatchObject({ name: `AssertionError` }) - }, - ) - - 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( - mode, - [ - { type: `subscribe`, listener: 0 }, - { type: `unsubscribe`, listener: 0 }, - { type: `dispose` }, - ], - { reach }, - ) - for (const reaction of reactions) { - await runHistory(mode, reactionHistory(reaction), { reach }) - } - } - - expect(Object.values(reach.commands).every((count) => count > 0)).toBe(true) - expect(Object.values(reach.reactions).every((count) => count > 0)).toBe( - true, + { type: `unsubscribe`, listener: 0 }, + { type: `dispose` }, + ], + { reach }, ) - }) + for (const reaction of reactions) { + await runHistory(mode, reactionHistory(reaction), { reach }) + } + } - 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`, - version: -20, - nestedVersion: -20, - reactor: 0, - peer: 0, - reaction: `none`, - }, - { type: `subscribe`, listener: 0 }, - { - type: `publish`, - version: 20, - nestedVersion: 20, - reactor: 0, - peer: 0, - reaction: `nested`, - }, - { - type: `publish`, - version: 20, - nestedVersion: 20, - reactor: 0, - peer: 0, - reaction: `none`, - }, - ]) - }, - ) + expect(Object.values(reach.commands).every((count) => count > 0)).toBe(true) + expect(Object.values(reach.reactions).every((count) => count > 0)).toBe(true) +} - 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() - await expect( - runHistory(mode, history, { mutant: `dropPeerAfterThrow`, reach }), - ).rejects.toMatchObject({ name: `AssertionError` }) - expect(reach.reactions.throw).toBe(1) +describe(`LiveQueryObserver generated histories`, () => { + oracleProperty({ + id: `live-query-observer.history-every-kind`, + law: { + statement: `Listeners receive each one-row publication in eligibility order, with the established bootstrap, reentry, disposal, and first-error rules.`, + authority: `The existing independent eligibility ledger and contract table in this file.`, + limits: `Fixed production histories in both modes that execute every command and reaction kind; each must be reached.`, }, - ) - - it(`rejects a duplicate granular bootstrap through the production seed path`, async () => { - oracleRoute(`calibration`) - const history: Array = [ - { - type: `publish`, - version: 1, - nestedVersion: 0, - reactor: 0, - peer: 0, - reaction: `none`, - }, - { type: `subscribe`, listener: 0 }, - ] - await runHistory(`granular`, history) - await expect( - runHistory(`granular`, history, { mutant: `duplicateBootstrap` }), - ).rejects.toMatchObject({ name: `AssertionError` }) + owns: [`src/live-query-observer.ts`], + histories: [ + `executes every command and reaction kind in fixed production histories`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `listener eligibility ledger`, + plan, + observe, + () => executeEveryKind(), + ), + check: forwardAssertionJudgment, }) - - 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() - await expect( - runHistory(mode, history, { mutant: `reentrantOutOfOrder`, reach }), - ).rejects.toMatchObject({ name: `AssertionError` }) - expect(reach.reactions.nested).toBe(1) - }, - ) }) 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 39453878ed..ff05d58eba 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 @@ -1,5 +1,5 @@ import { fc } from '@fast-check/vitest' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createCollection } from '../src/collection/index.js' import { DbClient } from '../src/client.js' import { getLiveQueryHash } from '../src/live-query-options.js' @@ -7,14 +7,13 @@ import { localOnlyCollectionOptions } from '../src/local-only.js' 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 { oracleRoute } from './oracle-config.js' -import { declareOracle } from './oracle-declaration.js' import { oracleProperty } from './oracle-property.js' import { forwardAssertionJudgment, oracleAssertion, recordAssertionJudgment, recordedAssertionJudgment, + rejectAt, } from './oracle-assertion-adapter.js' import { finishHistory } from './observer-history-cleanup.js' import { @@ -375,17 +374,76 @@ async function checkHistory( } } -declareOracle({ - check: [checkRead, checkHistory, checkHealthHistory], - model: [modelRows], -}) - const history = fc.array(command, { minLength: 1, maxLength: 24 }) const reads = fc.array( fc.record({ snapshot: fc.nat({ max: 30 }), dataFirst: fc.boolean() }), { maxLength: 30 }, ) +async function readOlderSnapshot() { + const commands: Array = [ + { type: `upsert`, id: `a`, version: 1 }, + { type: `upsert`, id: `b`, version: 1 }, + { type: `capture` }, + { type: `upsert`, id: `a`, version: 2 }, + { type: `capture` }, + { type: `delete`, id: `b` }, + { type: `capture` }, + { type: `upsert`, id: `c`, version: 3 }, + { type: `capture` }, + ] + for (const shape of [`ordered`, `single`] as const) { + for (const dataFirst of [true, false]) { + await checkHistory(shape, true, commands, [ + { snapshot: 1, dataFirst }, + { snapshot: 0, dataFirst: !dataFirst }, + ]) + } + } +} + +// Calibration: the #1542 design. Its getters read the live query when first +// accessed instead of the rows captured at snapshot time. +async function checkLiveFirstAccess() { + const { observer, query, source } = setup(`ordered`) + try { + source.insert({ id: `a`, version: 1 }) + const captured = observer.getSnapshot() + const hostile = Object.create(captured, { + data: { get: () => [...query.values()] }, + state: { get: () => new Map(query.entries()) }, + }) as Snapshot + source.update(`a`, (draft) => { + draft.version = 2 + }) + let rejection: unknown + try { + checkRead( + `ordered`, + { snapshot: hostile, rows: [{ id: `a`, version: 1 }] }, + true, + `hostile`, + ) + } catch (error) { + rejection = error + } + // Exactly the old matcher: only a data-rows or state-rows rejection counts. + if ( + rejection instanceof Error && + /data rows|state rows/.test(rejection.message) + ) + rejectAt(`calibration: live first-access getters`, rejection) + if (rejection !== undefined) + throw new Error( + `the check rejected the live first-access getters with an error the old matcher did not accept: ${String(rejection)}`, + ) + } finally { + observer.dispose() + await query.cleanup() + await source.cleanup() + } +} + describe(`retained live-query snapshots are point-in-time`, () => { // The tuple preserves the old two-arbitrary input. Every generated input // still runs all four shape/attachment cells at the original per-cell budget. @@ -459,57 +517,39 @@ describe(`retained live-query snapshots are point-in-time`, () => { check: forwardAssertionJudgment, }) - 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 }, - { type: `capture` }, - { type: `upsert`, id: `a`, version: 2 }, - { type: `capture` }, - { type: `delete`, id: `b` }, - { type: `capture` }, - { type: `upsert`, id: `c`, version: 3 }, - { type: `capture` }, - ] - for (const shape of [`ordered`, `single`] as const) { - for (const dataFirst of [true, false]) { - await checkHistory(shape, true, commands, [ - { snapshot: 1, dataFirst }, - { snapshot: 0, dataFirst: !dataFirst }, - ]) - } - } - }) - - 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`) - try { - source.insert({ id: `a`, version: 1 }) - const captured = observer.getSnapshot() - const hostile = Object.create(captured, { - data: { get: () => [...query.values()] }, - state: { get: () => new Map(query.entries()) }, - }) as Snapshot - source.update(`a`, (draft) => { - draft.version = 2 - }) - expect(() => - checkRead( - `ordered`, - { snapshot: hostile, rows: [{ id: `a`, version: 1 }] }, - true, - `hostile`, - ), - ).toThrow(/data rows|state rows/) - } finally { - observer.dispose() - await query.cleanup() - await source.cleanup() - } + oracleProperty({ + id: `live-query-observer.retained-snapshot-fixed`, + law: { + statement: `First and later reads of a retained snapshot preserve capture-time rows and container identity across both shapes and attachment cuts.`, + authority: `The point-in-time snapshot contract and ownership table above.`, + limits: `One fixed history that reads an older snapshot after several newer snapshots were built, in both shapes and both read orders; the calibration feeds the checker live first-access getters.`, + }, + owns: [`src/live-query-observer.ts`], + histories: [`reads a snapshot after several newer snapshots were built`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `retained snapshot rows and identity`, + plan, + observe, + () => readOlderSnapshot(), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects a snapshot that reads the live Collection on first access`, + law: `retained snapshot rows and identity`, + driver: (plan, observe) => + recordAssertionJudgment( + `retained snapshot rows and identity`, + plan, + observe, + () => checkLiveFirstAccess(), + ), + }, + ], }) }) @@ -769,6 +809,20 @@ describe(`a snapshot shows the current health tree`, () => { runs: 80, fixedSeeds: [695], states: healthStates, + fixed: [ + { + // Replay of the reported trace: a framework renders (detached + // read), a source reports a problem before commit, the framework + // commits (attach), then reads again. + name: `shows a health-only change made between a detached read and attach`, + history: [ + { type: `read` }, + { type: `report` }, + { type: `subscribe` }, + { type: `read` }, + ], + }, + ], // Five separately checked constructions preserve the old minimum of // five reached histories per state, now in each campaign independently. constructed: Array.from({ length: 5 }, (_, index) => ({ @@ -797,19 +851,6 @@ describe(`a snapshot shows the current health tree`, () => { ), check: forwardAssertionJudgment, }) - - 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. - await checkHealthHistory([ - { type: `read` }, - { type: `report` }, - { type: `subscribe` }, - { type: `read` }, - ]) - }) }) /** @@ -1078,8 +1119,6 @@ async function checkContainerHistory( ) } -declareOracle({ check: [checkContainerHistory], grammar: [containerHistory] }) - describe(`metadata snapshots share immutable row containers`, () => { const examples: Array<[ContainerHistory]> = ( [`granular`, `wholesale`] as const @@ -1548,38 +1587,51 @@ async function checkCapturePathHistory( ) } -declareOracle({ - check: [checkCapturePathHistory], - grammar: [capturePathHistory], -}) - describe(`snapshot capture paths preserve row and layout laws`, () => { // These replay the same driver/check before the broad campaign so a value // 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 = snapshotReach([ - `ordered-granular`, - `initial-detached-granular`, - `discarded-write-granular-detached`, - `discarded-move-granular-detached`, - `layout-only-granular-detached`, - ]) - await checkCapturePathHistory( - { - shape: `ordered`, - mode: `granular`, - attached: false, - hydrated: false, - count: 2, - deferralDepth: 1, - dataFirst: false, - commands: [action], - }, - reach, - ) - }) + const readCommittedRows = async ( + action: `discard-write` | `discard-move`, + ) => { + const reach = snapshotReach([ + `ordered-granular`, + `initial-detached-granular`, + `discarded-write-granular-detached`, + `discarded-move-granular-detached`, + `layout-only-granular-detached`, + ]) + await checkCapturePathHistory( + { + shape: `ordered`, + mode: `granular`, + attached: false, + hydrated: false, + count: 2, + deferralDepth: 1, + dataFirst: false, + commands: [action], + }, + reach, + ) + } + oracleProperty({ + id: `live-query-observer.capture-paths-discard`, + law: { + statement: `Retained snapshots preserve committed row values and layout through projection, findOne, hydration, and discarded publication deferrals.`, + authority: `The retained-snapshot contract and Collection deferred-publication contract described above.`, + limits: `One ordered granular detached history per discarded deferral kind, with only its five premise states declared.`, + }, + owns: [`src/live-query-observer.ts`], + histories: [`discard-write`, `discard-move`] as const, + label: (action) => `reads committed rows after ${action}`, + resolve: (action) => ({ action }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`snapshot capture paths`, plan, observe, () => + readCommittedRows(plan.action), + ), + check: forwardAssertionJudgment, + }) const examples: Array<[CapturePathHistory]> = ( [`ordered`, `single`] as const ).flatMap((shape) => @@ -1648,13 +1700,24 @@ describe(`snapshot capture paths preserve row and layout laws`, () => { ] // These original 13-command regressions deliberately remain direct replays: // the generated language still has its original maximum of 12 commands. - it.each(examples)( - `replays the retained capture-path history %#`, - async (scenario) => { - oracleRoute(`fixed history`) - await checkCapturePathHistory(scenario, snapshotReach(captureStates)) + oracleProperty({ + id: `live-query-observer.capture-paths-retained`, + law: { + statement: `Retained snapshots preserve committed row values and layout through projection, findOne, hydration, and discarded publication deferrals.`, + authority: `The retained-snapshot contract and Collection deferred-publication contract described above.`, + limits: `The sixteen original 13-command regressions over both shapes, modes, attachments and hydration; the generated language has at most 12 commands.`, }, - ) + owns: [`src/live-query-observer.ts`], + histories: examples.map((_, index) => index), + label: (index) => `replays the retained capture-path history ${index}`, + resolve: (index) => ({ scenario: examples[index]![0] }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`snapshot capture paths`, plan, observe, () => + checkCapturePathHistory(plan.scenario, snapshotReach(captureStates)), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: `live-query-observer.capture-paths`, law: { @@ -1999,7 +2062,6 @@ async function checkPayloadHistory( failure, ) } -declareOracle({ check: [checkPayloadHistory], grammar: [payloadHistory] }) describe(`publications preserve nested record shape`, () => { const examples: Array<[PayloadHistory]> = ( [`granular`, `wholesale`] as const @@ -2314,7 +2376,6 @@ async function checkSparsePublication( failure, ) } -declareOracle({ check: [checkSparsePublication] }) describe(`array value publication`, () => { oracleProperty({ id: `live-query-observer.sparse-array`, From 41f0c581f46de53fd5c64c70d187ecfe5f11146c Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:27:54 -0700 Subject: [PATCH 087/116] test(db): register alias-shadowing oracle cells and fixed histories through oracleProperty Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 26 + .../includes-alias-shadowing-oracle.test.ts | 4502 ++++++++++------- 2 files changed, 2602 insertions(+), 1926 deletions(-) diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 64f0e10ce3..419c24b699 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -67,6 +67,27 @@ export const properties: ReadonlyArray = [ `includes-collection.held-ordered-repair`, `includes-collection.root-event-observation`, `includes-collection.rollback-retained-child`, + `includes-alias-shadowing.captured-scope`, + `includes-alias-shadowing.two-include-positions`, + `includes-alias-shadowing.shared-sibling-builder`, + `includes-alias-shadowing.fresh-inner-queryref`, + `includes-alias-shadowing.local-equality-shadow`, + `includes-alias-shadowing.grandparent-capture`, + `includes-alias-shadowing.outer-filter-roles`, + `includes-alias-shadowing.captured-projection-pushdown`, + `includes-alias-shadowing.same-path-group-keys`, + `includes-alias-shadowing.nested-filter-group-keys`, + `includes-alias-shadowing.redundant-wrappers`, + `includes-alias-shadowing.lazy-join-demand`, + `includes-alias-shadowing.recursive-join-bindings`, + `includes-alias-shadowing.nested-source-placement`, + `includes-alias-shadowing.outer-join-absence`, + `includes-alias-shadowing.chained-join-absence`, + `includes-alias-shadowing.joined-queryref-lazy-targets`, + `includes-alias-shadowing.mixed-outer-join-chains`, + `includes-alias-shadowing.captured-join-operands`, + `includes-alias-shadowing.grouped-ancestor-references`, + `includes-alias-shadowing.joined-subquery-ancestor`, ] export const owners: ReadonlyArray = [ @@ -145,4 +166,9 @@ export const owners: ReadonlyArray = [ `includes-collection`, `retired-held-facade delete-facade-identity facade-application-failure facade-root-publication shared-facade-retirement held-truncate-reorder fixed-relationship exhaustive-two-step checkpoint-failure held-ordered-repair root-event-observation rollback-retained-child`, ], + [ + `db/tests/query/includes-alias-shadowing-oracle.test.ts`, + `includes-alias-shadowing`, + `captured-scope two-include-positions shared-sibling-builder fresh-inner-queryref local-equality-shadow grandparent-capture outer-filter-roles captured-projection-pushdown same-path-group-keys nested-filter-group-keys redundant-wrappers lazy-join-demand recursive-join-bindings nested-source-placement outer-join-absence chained-join-absence joined-queryref-lazy-targets mixed-outer-join-chains captured-join-operands grouped-ancestor-references joined-subquery-ancestor`, + ], ] 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 53ef111e06..15cf956506 100644 --- a/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts +++ b/packages/db/tests/query/includes-alias-shadowing-oracle.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test } from 'vitest' +import { describe, expect } from 'vitest' import { Query, add, @@ -16,8 +16,12 @@ import { optimizeQuery } from '../../src/query/optimizer.js' 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 { oracleProperty } from '../oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../oracle-assertion-adapter.js' import { createScopedSource } from './includes-scope-identity-oracle.js' import { initialLocks, @@ -179,21 +183,6 @@ 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], -}) - /** The driver builds through the public Query and live Collection entry points. */ function buildQuery( form: Form, @@ -343,94 +332,119 @@ function expectedLockChildren( }) } -declareOracle({ - model: [ - expectedVoteIds, - expectedReports, - expectedChildIdsByParent, - expectedLockChildren, - ], -}) - describe(`captured alias scope oracle`, () => { - for (const form of [ - `direct`, - `queryRef`, - `union`, - `joinedImplicit`, - ] as const) { - for (const alias of [`lock`, `vote`] as const) { - 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, - `eager`, - ) - const votes = createScopedSource(`shadow-votes`, initialVotes, mode) - const modelLocks = new Map(initialLocks.map((row) => [row.id, row])) - const modelVotes = new Map(initialVotes.map((row) => [row.id, row])) - const live = createLiveQueryCollection({ - query: buildQuery(form, alias, order, locks, votes), - }) - await withHistoryCleanup( - async () => { - await live.preload() - const check = (checkpoint: string) => - expectRowsFollowModel( - live, - modelLocks, - modelVotes, - form, - alias, - checkpoint, - ) - check(`initial publication`) - - const addedVote = { id: 13, lockId: 2, lockName: `A` } - votes.put(addedVote) - modelVotes.set(addedVote.id, addedVote) - check(`child insert`) - - const addedLock = { id: 3, name: `C` } - locks.put(addedLock) - modelLocks.set(addedLock.id, addedLock) - check(`parent insert`) - - const newChild = { id: 14, lockId: 3, lockName: `C` } - votes.put(newChild) - modelVotes.set(newChild.id, newChild) - check(`child insert after parent insert`) - - const movedVote = { id: 11, lockId: 2, lockName: `B` } - votes.put(movedVote) - modelVotes.set(movedVote.id, movedVote) - check(`child moves between parents`) - - votes.remove(12) - modelVotes.delete(12) - check(`child delete`) - - locks.remove(1) - modelLocks.delete(1) - check(`parent delete`) - }, - () => [ - () => live.cleanup(), - () => locks.collection.cleanup(), - () => votes.collection.cleanup(), - ], - ) - }) - } - } - } + const capturedScopeCells = ( + [`direct`, `queryRef`, `union`, `joinedImplicit`] as const + ).flatMap((form) => + ([`lock`, `vote`] as const).flatMap((alias) => + ([`childFirst`, `parentFirst`] as const).flatMap((order) => + ([`eager`, `onDemand`] as const).map((mode) => ({ + form, + alias, + order, + mode, + })), + ), + ), + ) + + // The former test body, unchanged. + async function judgeCapturedScope({ + form, + alias, + order, + mode, + }: (typeof capturedScopeCells)[number]): Promise { + const locks = createScopedSource(`shadow-locks`, initialLocks, `eager`) + const votes = createScopedSource(`shadow-votes`, initialVotes, mode) + const modelLocks = new Map(initialLocks.map((row) => [row.id, row])) + const modelVotes = new Map(initialVotes.map((row) => [row.id, row])) + const live = createLiveQueryCollection({ + query: buildQuery(form, alias, order, locks, votes), + }) + await withHistoryCleanup( + async () => { + await live.preload() + const check = (checkpoint: string) => + expectRowsFollowModel( + live, + modelLocks, + modelVotes, + form, + alias, + checkpoint, + ) + check(`initial publication`) + + const addedVote = { id: 13, lockId: 2, lockName: `A` } + votes.put(addedVote) + modelVotes.set(addedVote.id, addedVote) + check(`child insert`) + + const addedLock = { id: 3, name: `C` } + locks.put(addedLock) + modelLocks.set(addedLock.id, addedLock) + check(`parent insert`) + + const newChild = { id: 14, lockId: 3, lockName: `C` } + votes.put(newChild) + modelVotes.set(newChild.id, newChild) + check(`child insert after parent insert`) + + const movedVote = { id: 11, lockId: 2, lockName: `B` } + votes.put(movedVote) + modelVotes.set(movedVote.id, movedVote) + check(`child moves between parents`) + + votes.remove(12) + modelVotes.delete(12) + check(`child delete`) + + locks.remove(1) + modelLocks.delete(1) + check(`parent delete`) + }, + () => [ + () => live.cleanup(), + () => locks.collection.cleanup(), + () => votes.collection.cleanup(), + ], + ) } - test(`one captured child plan remains correct in two include positions`, async () => { - oracleRoute(`fixed history`) + const capturedScopeLaw = `Exact rows follow source roles for every captured-alias form, alias, write order, and loading mode.` + + oracleProperty({ + id: `includes-alias-shadowing.captured-scope`, + law: { + statement: capturedScopeLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: capturedScopeCells, + label: ({ form, alias, order, mode }) => + `${form}, ${alias}, ${order}, ${mode}: exact rows follow source roles`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(capturedScopeLaw, plan, observe, () => + judgeCapturedScope(plan), + ), + check: forwardAssertionJudgment, + }) + + // The former test body, unchanged. + async function judgeTwoIncludePositions(): Promise { const locks = createScopedSource(`reuse-locks`, initialLocks, `eager`) const votes = createScopedSource(`reuse-votes`, initialVotes, `eager`) const live = createLiveQueryCollection({ @@ -478,6 +492,38 @@ describe(`captured alias scope oracle`, () => { () => votes.collection.cleanup(), ], ) + } + + const twoIncludePositionsLaw = `One captured child plan remains correct in two include positions.` + + oracleProperty({ + id: `includes-alias-shadowing.two-include-positions`, + law: { + statement: twoIncludePositionsLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [ + `one captured child plan remains correct in two include positions`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(twoIncludePositionsLaw, plan, observe, () => + judgeTwoIncludePositions(), + ), + check: forwardAssertionJudgment, }) /** @@ -494,120 +540,154 @@ describe(`captured alias scope oracle`, () => { * a managerId update must publish exact report IDs. The adjacent rejection * cells below retain the actual ancestor/descendant boundary. */ - 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 }], - [2, { id: 2, managerId: 1 }], - [3, { id: 3, managerId: 1 }], - ]) - const employees = createScopedSource( - `sibling-employee-${placement}-${mode}`, - [...employeeRows.values()], - mode, - ) - const extra = createScopedSource( - `sibling-extra-${placement}-${mode}`, - [{ id: 4, managerId: 0 }], - mode, - ) - const managers = createScopedSource( - `sibling-manager-${placement}-${mode}`, - [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 99 }], - `eager`, - ) - employees.collection.createIndex((row) => row.managerId, { - indexType: BasicIndex, - }) - const shared = new Query() - .from({ employee: employees.collection }) - .select(({ employee }) => ({ - id: employee.id, - managerId: employee.managerId, + const sharedSiblingBuilderCells = ( + [`queryRef`, `union`, `joined`] as const + ).flatMap((placement) => + ([`eager`, `onDemand`] as const).map((mode) => ({ placement, mode })), + ) + + // The former test body, unchanged. + async function judgeSharedSiblingBuilder({ + placement, + mode, + }: (typeof sharedSiblingBuilderCells)[number]): Promise { + type Employee = { id: number; managerId: number } + const employeeRows = new Map([ + [1, { id: 1, managerId: 0 }], + [2, { id: 2, managerId: 1 }], + [3, { id: 3, managerId: 1 }], + ]) + const employees = createScopedSource( + `sibling-employee-${placement}-${mode}`, + [...employeeRows.values()], + mode, + ) + const extra = createScopedSource( + `sibling-extra-${placement}-${mode}`, + [{ id: 4, managerId: 0 }], + mode, + ) + const managers = createScopedSource( + `sibling-manager-${placement}-${mode}`, + [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 99 }], + `eager`, + ) + employees.collection.createIndex((row) => row.managerId, { + indexType: BasicIndex, + }) + const shared = new Query() + .from({ employee: employees.collection }) + .select(({ employee }) => ({ + id: employee.id, + managerId: employee.managerId, + })) + const reportsFor = (manager: { id: number }) => ({ + id: manager.id, + reports: toArray( + new Query() + .from({ report: shared }) + .where(({ report }) => eq(report.managerId, manager.id)) + .select(({ report }) => ({ id: report.id })), + ), + }) + const build = () => { + if (placement === `union`) { + const other = new Query() + .from({ other: extra.collection }) + .select(({ other: row }) => ({ + id: row.id, + managerId: row.managerId, })) - const reportsFor = (manager: { id: number }) => ({ - id: manager.id, - reports: toArray( - new Query() - .from({ report: shared }) - .where(({ report }) => eq(report.managerId, manager.id)) - .select(({ report }) => ({ id: report.id })), - ), + return createLiveQueryCollection({ + query: new Query() + .from({ manager: new Query().unionAll(shared, other) }) + .select(({ manager }) => + reportsFor(manager as unknown as Employee), + ), }) - const build = () => { - if (placement === `union`) { - const other = new Query() - .from({ other: extra.collection }) - .select(({ other: row }) => ({ - id: row.id, - managerId: row.managerId, - })) - return createLiveQueryCollection({ - query: new Query() - .from({ manager: new Query().unionAll(shared, other) }) - .select(({ manager }) => - reportsFor(manager as unknown as Employee), - ), - }) - } - if (placement === `joined`) { - return createLiveQueryCollection({ - query: new Query() - .from({ manager: managers.collection }) - .innerJoin({ helper: shared }, ({ manager, helper }) => - eq(manager.id, helper.id), - ) - .select(({ manager }) => - reportsFor(manager as unknown as Employee), - ), - }) - } - return createLiveQueryCollection({ - query: new Query() - .from({ manager: shared }) - .select(({ manager }) => - reportsFor(manager as unknown as Employee), - ), - }) - } - - const observe = (live: ReturnType) => - live.toArray - .map(({ id, reports }) => ({ - id, - reports: reports.map(({ id: reportId }) => reportId).sort(), - })) - .sort((a, b) => a.id - b.id) - const model = () => expectedReports(employeeRows, placement) - let live: ReturnType | undefined - await withHistoryCleanup( - async () => { - expect(() => { - live = build() - }).not.toThrow() - await live!.preload() - expect(observe(live!), `initial publication`).toEqual(model()) - - const moved = { id: 3, managerId: 2 } - employeeRows.set(3, moved) - employees.put(moved) - await flushPromises() - expect(observe(live!), `employee changes manager`).toEqual(model()) - }, - () => [ - () => live?.cleanup(), - () => employees.collection.cleanup(), - () => extra.collection.cleanup(), - () => managers.collection.cleanup(), - ], - ) + } + if (placement === `joined`) { + return createLiveQueryCollection({ + query: new Query() + .from({ manager: managers.collection }) + .innerJoin({ helper: shared }, ({ manager, helper }) => + eq(manager.id, helper.id), + ) + .select(({ manager }) => + reportsFor(manager as unknown as Employee), + ), + }) + } + return createLiveQueryCollection({ + query: new Query() + .from({ manager: shared }) + .select(({ manager }) => reportsFor(manager as unknown as Employee)), }) } + + const observe = (live: ReturnType) => + live.toArray + .map(({ id, reports }) => ({ + id, + reports: reports.map(({ id: reportId }) => reportId).sort(), + })) + .sort((a, b) => a.id - b.id) + const model = () => expectedReports(employeeRows, placement) + let live: ReturnType | undefined + await withHistoryCleanup( + async () => { + expect(() => { + live = build() + }).not.toThrow() + await live!.preload() + expect(observe(live!), `initial publication`).toEqual(model()) + + const moved = { id: 3, managerId: 2 } + employeeRows.set(3, moved) + employees.put(moved) + await flushPromises() + expect(observe(live!), `employee changes manager`).toEqual(model()) + }, + () => [ + () => live?.cleanup(), + () => employees.collection.cleanup(), + () => extra.collection.cleanup(), + () => managers.collection.cleanup(), + ], + ) } + const sharedSiblingBuilderLaw = `A shared QueryRef, union, or joined builder remains legal and correct in a sibling include.` + + oracleProperty({ + id: `includes-alias-shadowing.shared-sibling-builder`, + law: { + statement: sharedSiblingBuilderLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: sharedSiblingBuilderCells, + label: ({ placement, mode }) => + `a shared ${placement} builder remains legal in a sibling include with ${mode} sources`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(sharedSiblingBuilderLaw, plan, observe, () => + judgeSharedSiblingBuilder(plan), + ), + check: forwardAssertionJudgment, + }) + /** * The inner QueryRef owns a different lexical scope from the include that * contains it. Its declarations cannot classify the captured parent ref as @@ -615,8 +695,8 @@ describe(`captured alias scope oracle`, () => { * The plain model groups each current row by parentId after preload and a * 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`) + // The former test body, unchanged. + async function judgeFreshInnerQueryref(): Promise { const rows = [ { id: 1, parentId: 1 }, { id: 2, parentId: 1 }, @@ -661,10 +741,42 @@ describe(`captured alias scope oracle`, () => { }, () => [() => live.cleanup(), () => source.collection.cleanup()], ) + } + + const freshInnerQueryrefLaw = `Fresh inner QueryRef declarations do not impersonate a captured parent.` + + oracleProperty({ + id: `includes-alias-shadowing.fresh-inner-queryref`, + law: { + statement: freshInnerQueryrefLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [ + `fresh inner QueryRef declarations do not impersonate a captured parent`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(freshInnerQueryrefLaw, plan, observe, () => + judgeFreshInnerQueryref(), + ), + check: forwardAssertionJudgment, }) - test(`a local child equality cannot impersonate a shadowed correlation`, async () => { - oracleRoute(`fixed history`) + // The former test body, unchanged. + async function judgeLocalEqualityShadow(): Promise { const locks = createScopedSource( `local-equality-locks`, initialLocks, @@ -714,10 +826,42 @@ describe(`captured alias scope oracle`, () => { () => votes.collection.cleanup(), ], ) + } + + const localEqualityShadowLaw = `A local child equality cannot impersonate a shadowed correlation.` + + oracleProperty({ + id: `includes-alias-shadowing.local-equality-shadow`, + law: { + statement: localEqualityShadowLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [ + `a local child equality cannot impersonate a shadowed correlation`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(localEqualityShadowLaw, plan, observe, () => + judgeLocalEqualityShadow(), + ), + check: forwardAssertionJudgment, }) - test(`a nested include can capture its grandparent beside a shadowed child`, async () => { - oracleRoute(`fixed history`) + // The former test body, unchanged. + async function judgeGrandparentCapture(): Promise { const locks = createScopedSource(`nested-locks`, initialLocks, `eager`) const votes = createScopedSource(`nested-votes`, initialVotes, `eager`) const currentVotes = new Map(initialVotes.map((vote) => [vote.id, vote])) @@ -774,6 +918,38 @@ describe(`captured alias scope oracle`, () => { () => votes.collection.cleanup(), ], ) + } + + const grandparentCaptureLaw = `A nested include can capture its grandparent beside a shadowed child.` + + oracleProperty({ + id: `includes-alias-shadowing.grandparent-capture`, + law: { + statement: grandparentCaptureLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [ + `a nested include can capture its grandparent beside a shadowed child`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(grandparentCaptureLaw, plan, observe, () => + judgeGrandparentCapture(), + ), + check: forwardAssertionJudgment, }) /** @@ -819,112 +995,143 @@ describe(`outer filters stay with their source under alias shadowing`, () => { }, ) - declareOracle({ model: [model] }) + const outerFilterRolesCells = ([`join`, `include`] as const).flatMap((form) => + ([`eager`, `onDemand`] as const).flatMap((mode) => + ([1, 2] as const).map((childId) => ({ form, mode, childId })), + ), + ) - for (const form of [`join`, `include`] as const) { - 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( - form, - new Map([[parentRow.id, parentRow]]), - new Map([[childRow.id, childRow]]), - ) - const cases = ([`u`, `v`] as const).map((alias) => { - const parents = createScopedSource( - `outer-filter-parent-${form}-${mode}-${childId}-${alias}`, - [parentRow], - mode, - ) - const children = createScopedSource( - `outer-filter-child-${form}-${mode}-${childId}-${alias}`, - [childRow], - mode, - ) - children.collection.createIndex((child) => child.id, { - indexType: BasicIndex, + // The former test body, unchanged. + async function judgeOuterFilterRoles({ + form, + mode, + childId, + }: (typeof outerFilterRolesCells)[number]): Promise { + const parentRow = { id: 1, linkId: childId } + const childRow = { id: childId, linkId: 1 } + const expected = model( + form, + new Map([[parentRow.id, parentRow]]), + new Map([[childRow.id, childRow]]), + ) + const cases = ([`u`, `v`] as const).map((alias) => { + const parents = createScopedSource( + `outer-filter-parent-${form}-${mode}-${childId}-${alias}`, + [parentRow], + mode, + ) + const children = createScopedSource( + `outer-filter-child-${form}-${mode}-${childId}-${alias}`, + [childRow], + mode, + ) + children.collection.createIndex((child) => child.id, { + indexType: BasicIndex, + }) + const childQuery = new Query() + .from({ [alias]: children.collection }) + .select((context: Context) => ({ + id: (context[alias] as Child).id, + linkId: (context[alias] as Child).linkId, + })) + const root = new Query() + .from({ u: parents.collection }) + .where(({ u }) => eq(u.id, 1)) + const live = + form === `join` + ? createLiveQueryCollection({ + query: root + .leftJoin({ w: childQuery }, ({ u, w }) => eq(u.linkId, w.id)) + .select(({ u, w }) => ({ id: u.id, joinedId: w.id })), }) - const childQuery = new Query() - .from({ [alias]: children.collection }) - .select((context: Context) => ({ - id: (context[alias] as Child).id, - linkId: (context[alias] as Child).linkId, - })) - const root = new Query() - .from({ u: parents.collection }) - .where(({ u }) => eq(u.id, 1)) - const live = - form === `join` - ? createLiveQueryCollection({ - query: root - .leftJoin({ w: childQuery }, ({ u, w }) => - eq(u.linkId, w.id), - ) - .select(({ u, w }) => ({ id: u.id, joinedId: w.id })), - }) - : createLiveQueryCollection({ - query: root.select(({ u: parent }) => ({ - id: parent.id, - children: toArray( - new Query() - .from({ [alias]: children.collection }) - .where((context: Context) => - eq((context[alias] as Child).linkId, parent.id), - ) - .select((context: Context) => ({ - id: (context[alias] as Child).id, - })), - ), + : createLiveQueryCollection({ + query: root.select(({ u: parent }) => ({ + id: parent.id, + children: toArray( + new Query() + .from({ [alias]: children.collection }) + .where((context: Context) => + eq((context[alias] as Child).linkId, parent.id), + ) + .select((context: Context) => ({ + id: (context[alias] as Child).id, })), - }) - return { - alias, - parents, - children, - live, - } - }) + ), + })), + }) + return { + alias, + parents, + children, + live, + } + }) - await withHistoryCleanup( - async () => { - for (const entry of cases) { - await entry.live.preload() - const rows = [...entry.live.toArray].map((row) => - form === `join` - ? { - id: row.id, - joinedId: (row as { joinedId?: number }).joinedId, - } - : { - id: row.id, - children: ( - row as { children: Array<{ id: number }> } - ).children.map((child) => child.id), - }, - ) - expect(rows, `${entry.alias}: public rows`).toEqual(expected) - if (mode === `onDemand`) { - expect( - entry.children.collection.toArray.map((child) => child.id), - `${entry.alias}: child provider admission`, - ).toEqual([childId]) + await withHistoryCleanup( + async () => { + for (const entry of cases) { + await entry.live.preload() + const rows = [...entry.live.toArray].map((row) => + form === `join` + ? { + id: row.id, + joinedId: (row as { joinedId?: number }).joinedId, } - } - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.children.collection.cleanup(), - ]), + : { + id: row.id, + children: ( + row as { children: Array<{ id: number }> } + ).children.map((child) => child.id), + }, ) - }) - } - } + expect(rows, `${entry.alias}: public rows`).toEqual(expected) + if (mode === `onDemand`) { + expect( + entry.children.collection.toArray.map((child) => child.id), + `${entry.alias}: child provider admission`, + ).toEqual([childId]) + } + } + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.children.collection.cleanup(), + ]), + ) } + + const outerFilterRolesLaw = `Outer filters stay with their source under alias shadowing, so projected rows follow source roles.` + + oracleProperty({ + id: `includes-alias-shadowing.outer-filter-roles`, + law: { + statement: outerFilterRolesLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: outerFilterRolesCells, + label: ({ form, mode, childId }) => + `${form}, ${mode}, child ID ${childId}: projected rows follow source roles`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(outerFilterRolesLaw, plan, observe, () => + judgeOuterFilterRoles(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -959,123 +1166,159 @@ describe(`captured projections keep their binding during predicate pushdown`, () : [], })) - declareOracle({ model: [model] }) - - 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 } - const parentsModel = new Map([[parentRow.id, parentRow]]) - const childrenModel = new Map([[childRow.id, childRow]]) - const anchorsModel = new Map([[anchorRow.id, anchorRow]]) - const cases = ([`p`, `c`] as const).map((alias) => { - const parents = createScopedSource( - `projected-parent-${mode}-${alias}`, - [parentRow], - mode, - ) - const children = createScopedSource( - `projected-child-${mode}-${alias}`, - [childRow], - mode, - ) - const anchors = createScopedSource( - `projected-anchor-${mode}-${alias}`, - [anchorRow], - mode, - ) - children.collection.createIndex((child) => child.id, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((anchor) => anchor.id, { - indexType: BasicIndex, - }) - const query = new Query() - .from({ p: parents.collection }) - .select(({ p: parent }) => { - const inner = new Query() - .from({ [alias]: children.collection }) - .select((context: Context) => { - const child = context[alias] as Child - return { - id: child.id, - parentId: child.parentId, - rank: parent.rank, - } - }) - return { - id: parent.id, - matches: toArray( - new Query() - .from({ q: inner }) - .innerJoin({ a: anchors.collection }, ({ q, a }) => - eq(q.id, a.id), - ) - .where(({ q }) => eq(q.parentId, parent.id)) - .where(({ q }) => eq(q.rank, `A`)) - .select(({ q }) => ({ id: q.id })), - ), - } - }) - return { - alias, - parents, - children, - anchors, - live: createLiveQueryCollection({ query }), - } + const capturedProjectionPushdownCells = ([`eager`, `onDemand`] as const).map( + (mode) => ({ mode }), + ) + + // The former test body, unchanged. + async function judgeCapturedProjectionPushdown({ + mode, + }: (typeof capturedProjectionPushdownCells)[number]): Promise { + const parentRow = { id: 1, rank: `A` } + const childRow = { id: 10, parentId: 1, rank: `Z` } + const anchorRow = { id: 10 } + const parentsModel = new Map([[parentRow.id, parentRow]]) + const childrenModel = new Map([[childRow.id, childRow]]) + const anchorsModel = new Map([[anchorRow.id, anchorRow]]) + const cases = ([`p`, `c`] as const).map((alias) => { + const parents = createScopedSource( + `projected-parent-${mode}-${alias}`, + [parentRow], + mode, + ) + const children = createScopedSource( + `projected-child-${mode}-${alias}`, + [childRow], + mode, + ) + const anchors = createScopedSource( + `projected-anchor-${mode}-${alias}`, + [anchorRow], + mode, + ) + children.collection.createIndex((child) => child.id, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((anchor) => anchor.id, { + indexType: BasicIndex, }) + const query = new Query() + .from({ p: parents.collection }) + .select(({ p: parent }) => { + const inner = new Query() + .from({ [alias]: children.collection }) + .select((context: Context) => { + const child = context[alias] as Child + return { + id: child.id, + parentId: child.parentId, + rank: parent.rank, + } + }) + return { + id: parent.id, + matches: toArray( + new Query() + .from({ q: inner }) + .innerJoin({ a: anchors.collection }, ({ q, a }) => + eq(q.id, a.id), + ) + .where(({ q }) => eq(q.parentId, parent.id)) + .where(({ q }) => eq(q.rank, `A`)) + .select(({ q }) => ({ id: q.id })), + ), + } + }) + return { + alias, + parents, + children, + anchors, + live: createLiveQueryCollection({ query }), + } + }) - const check = (checkpoint: string) => { - const expected = model(parentsModel, childrenModel, anchorsModel) - for (const entry of cases) { - expect( - entry.live.toArray.map(({ id, matches }) => ({ - id, - matches: matches.map((match) => match.id), - })), - `${checkpoint}: ${entry.alias} public rows`, - ).toEqual(expected) - } + const check = (checkpoint: string) => { + const expected = model(parentsModel, childrenModel, anchorsModel) + for (const entry of cases) { + expect( + entry.live.toArray.map(({ id, matches }) => ({ + id, + matches: matches.map((match) => match.id), + })), + `${checkpoint}: ${entry.alias} public rows`, + ).toEqual(expected) } + } - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - check(`initial`) - if (mode === `onDemand`) { - for (const entry of cases) { - expect( - entry.children.collection.toArray.map((child) => child.id), - `${entry.alias}: initial child provider admission`, - ).toEqual([10]) - } + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + check(`initial`) + if (mode === `onDemand`) { + for (const entry of cases) { + expect( + entry.children.collection.toArray.map((child) => child.id), + `${entry.alias}: initial child provider admission`, + ).toEqual([10]) } + } - const changedChild = { ...childRow, rank: `A` } - childrenModel.set(changedChild.id, changedChild) - for (const entry of cases) entry.children.put(changedChild) - await flushPromises() - check(`child rank changed`) - - const changedParent = { ...parentRow, rank: `Z` } - parentsModel.set(changedParent.id, changedParent) - for (const entry of cases) entry.parents.put(changedParent) - await flushPromises() - check(`parent rank changed`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.children.collection.cleanup(), - () => entry.anchors.collection.cleanup(), - ]), - ) - }) + const changedChild = { ...childRow, rank: `A` } + childrenModel.set(changedChild.id, changedChild) + for (const entry of cases) entry.children.put(changedChild) + await flushPromises() + check(`child rank changed`) + + const changedParent = { ...parentRow, rank: `Z` } + parentsModel.set(changedParent.id, changedParent) + for (const entry of cases) entry.parents.put(changedParent) + await flushPromises() + check(`parent rank changed`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.children.collection.cleanup(), + () => entry.anchors.collection.cleanup(), + ]), + ) } + + const capturedProjectionPushdownLaw = `Captured projections keep their binding during predicate pushdown, so the parent rank controls the child result under both child aliases.` + + oracleProperty({ + id: `includes-alias-shadowing.captured-projection-pushdown`, + law: { + statement: capturedProjectionPushdownLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: capturedProjectionPushdownCells, + label: ({ mode }) => + `${mode}: parent rank controls the child result under both child aliases`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + capturedProjectionPushdownLaw, + plan, + observe, + () => judgeCapturedProjectionPushdown(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -1136,94 +1379,126 @@ describe(`same-path group keys across alias scopes`, () => { }) .sort((a, b) => a.id - b.id) - declareOracle({ model: [modelRowsForRanks] }) + const samePathGroupKeysCells = ([`parent`, `child`] as const).flatMap( + (alias) => + ([`parentFirst`, `childFirst`] as const).map((order) => ({ + alias, + order, + })), + ) - 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( - `rank-parents`, - initialParents, - `eager`, - ) - const children = createScopedSource( - `rank-children`, - initialChildren, - `eager`, - ) - const childAt = (context: Context) => context[alias] as RankChild - const live = createLiveQueryCollection({ - query: new Query() - .from({ parent: parents.collection }) - .select(({ parent }) => ({ - id: parent.id, - groups: toArray( - new Query() - .from({ [alias]: children.collection }) - .where((context: Context) => - eq(childAt(context).parentId, parent.id), - ) - .groupBy((context: Context) => - order === `parentFirst` - ? [parent.rank, childAt(context).rank] - : [childAt(context).rank, parent.rank], - ) - .select((context: Context) => ({ - parentRank: parent.rank, - childRank: childAt(context).rank, - total: count(childAt(context).id), - })), - ), - })), - }) + // The former test body, unchanged. + async function judgeSamePathGroupKeys({ + alias, + order, + }: (typeof samePathGroupKeysCells)[number]): Promise { + const parentRows = new Map(initialParents.map((row) => [row.id, row])) + const childRows = new Map(initialChildren.map((row) => [row.id, row])) + const parents = createScopedSource(`rank-parents`, initialParents, `eager`) + const children = createScopedSource( + `rank-children`, + initialChildren, + `eager`, + ) + const childAt = (context: Context) => context[alias] as RankChild + const live = createLiveQueryCollection({ + query: new Query() + .from({ parent: parents.collection }) + .select(({ parent }) => ({ + id: parent.id, + groups: toArray( + new Query() + .from({ [alias]: children.collection }) + .where((context: Context) => + eq(childAt(context).parentId, parent.id), + ) + .groupBy((context: Context) => + order === `parentFirst` + ? [parent.rank, childAt(context).rank] + : [childAt(context).rank, parent.rank], + ) + .select((context: Context) => ({ + parentRank: parent.rank, + childRank: childAt(context).rank, + total: count(childAt(context).id), + })), + ), + })), + }) - // Observation: order between groups is unspecified, while each - // selected value and group multiplicity is part of the public row. - const check = (checkpoint: string) => { - const observed = live.toArray - .map(({ id, groups }) => ({ - id, - groups: groups - .map(({ parentRank, childRank, total }) => ({ - parentRank, - childRank, - total, - })) - .sort((a, b) => a.childRank.localeCompare(b.childRank)), + // Observation: order between groups is unspecified, while each + // selected value and group multiplicity is part of the public row. + const check = (checkpoint: string) => { + const observed = live.toArray + .map(({ id, groups }) => ({ + id, + groups: groups + .map(({ parentRank, childRank, total }) => ({ + parentRank, + childRank, + total, })) - .sort((a, b) => a.id - b.id) - expect(observed, checkpoint).toEqual( - modelRowsForRanks(parentRows, childRows), - ) - } - - await withHistoryCleanup( - async () => { - await live.preload() - check(`after preload`) - const changedChild = { id: 11, parentId: 1, rank: `child-X` } - childRows.set(changedChild.id, changedChild) - children.put(changedChild) - await flushPromises() - check(`after child rank change`) - const changedParent = { id: 1, rank: `parent-A2` } - parentRows.set(changedParent.id, changedParent) - parents.put(changedParent) - await flushPromises() - check(`after parent rank change`) - }, - () => [ - () => live.cleanup(), - () => parents.collection.cleanup(), - () => children.collection.cleanup(), - ], - ) - }) + .sort((a, b) => a.childRank.localeCompare(b.childRank)), + })) + .sort((a, b) => a.id - b.id) + expect(observed, checkpoint).toEqual( + modelRowsForRanks(parentRows, childRows), + ) } + + await withHistoryCleanup( + async () => { + await live.preload() + check(`after preload`) + const changedChild = { id: 11, parentId: 1, rank: `child-X` } + childRows.set(changedChild.id, changedChild) + children.put(changedChild) + await flushPromises() + check(`after child rank change`) + const changedParent = { id: 1, rank: `parent-A2` } + parentRows.set(changedParent.id, changedParent) + parents.put(changedParent) + await flushPromises() + check(`after parent rank change`) + }, + () => [ + () => live.cleanup(), + () => parents.collection.cleanup(), + () => children.collection.cleanup(), + ], + ) } + + const samePathGroupKeysLaw = `Same-path group keys across alias scopes retain their own child binding.` + + oracleProperty({ + id: `includes-alias-shadowing.same-path-group-keys`, + law: { + statement: samePathGroupKeysLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: samePathGroupKeysCells, + label: ({ alias, order }) => + `group values retain ${alias} child binding with ${order} keys`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(samePathGroupKeysLaw, plan, observe, () => + judgeSamePathGroupKeys(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -1271,95 +1546,126 @@ describe(`group keys through nested source filtering`, () => { ) return total ? [{ category: `A`, total }] : [] } - declareOracle({ model: [expectedCategoryTotals] }) - - 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( - `group-rewrite-source-${innerAlias}-${mode}`, - initialSources, - mode, - ) - const tags = createScopedSource( - `group-rewrite-tags-${innerAlias}-${mode}`, - initialTags, - mode, - ) + const nestedFilterGroupKeysCells = ([`x`, `u`] as const).flatMap( + (innerAlias) => + ([`eager`, `onDemand`] as const).map((mode) => ({ innerAlias, mode })), + ) - const inner = new Query() - .from({ [innerAlias]: source.collection }) - .select((context: Context) => ({ - id: (context[innerAlias] as Source).id, - category: (context[innerAlias] as Source).category, - })) - const live = createLiveQueryCollection({ - query: new Query() - .from({ x: inner }) - .innerJoin({ t: tags.collection }, ({ x, t }) => - eq(x.id, t.sourceId), - ) - .where(({ x }) => eq(x.category, `A`)) - .groupBy(({ x }) => x.category) - .select(({ x }) => ({ - category: x.category, - total: count(x.id), - })), - }) + // The former test body, unchanged. + async function judgeNestedFilterGroupKeys({ + innerAlias, + mode, + }: (typeof nestedFilterGroupKeysCells)[number]): Promise { + const sourceRows = new Map(initialSources.map((row) => [row.id, row])) + const tagRows = new Map(initialTags.map((row) => [row.id, row])) + const source = createScopedSource( + `group-rewrite-source-${innerAlias}-${mode}`, + initialSources, + mode, + ) + const tags = createScopedSource( + `group-rewrite-tags-${innerAlias}-${mode}`, + initialTags, + mode, + ) - // The model counts joined matches afresh at each observation cut. It does - // not inspect the optimized plan or derive expectations from it. - const expected = () => expectedCategoryTotals(sourceRows, tagRows) - const check = (cut: string) => - expect( - live.toArray.map(({ category, total }) => ({ category, total })), - cut, - ).toEqual(expected()) - - await withHistoryCleanup( - async () => { - await live.preload() - check(`after preload`) - if (mode === `onDemand`) { - expect(source.requests.length).toBeGreaterThan(0) - expect(tags.requests.length).toBeGreaterThan(0) - } + const inner = new Query() + .from({ [innerAlias]: source.collection }) + .select((context: Context) => ({ + id: (context[innerAlias] as Source).id, + category: (context[innerAlias] as Source).category, + })) + const live = createLiveQueryCollection({ + query: new Query() + .from({ x: inner }) + .innerJoin({ t: tags.collection }, ({ x, t }) => eq(x.id, t.sourceId)) + .where(({ x }) => eq(x.category, `A`)) + .groupBy(({ x }) => x.category) + .select(({ x }) => ({ + category: x.category, + total: count(x.id), + })), + }) - const added = { id: 3, category: `A` } - sourceRows.set(added.id, added) - source.put(added) - await flushPromises() - check(`after source enters the selected group`) - - tagRows.delete(2) - tags.remove(2) - await flushPromises() - check(`after a joined tag is removed`) - - const repeated = { id: 4, sourceId: 3 } - tagRows.set(repeated.id, repeated) - tags.put(repeated) - await flushPromises() - check(`after a second tag joins one source`) - - const removed = { id: 1, category: `B` } - sourceRows.set(removed.id, removed) - source.put(removed) - await flushPromises() - check(`after source leaves the selected group`) - }, - () => [ - () => live.cleanup(), - () => source.collection.cleanup(), - () => tags.collection.cleanup(), - ], - ) - }) - } + // The model counts joined matches afresh at each observation cut. It does + // not inspect the optimized plan or derive expectations from it. + const expected = () => expectedCategoryTotals(sourceRows, tagRows) + const check = (cut: string) => + expect( + live.toArray.map(({ category, total }) => ({ category, total })), + cut, + ).toEqual(expected()) + + await withHistoryCleanup( + async () => { + await live.preload() + check(`after preload`) + if (mode === `onDemand`) { + expect(source.requests.length).toBeGreaterThan(0) + expect(tags.requests.length).toBeGreaterThan(0) + } + + const added = { id: 3, category: `A` } + sourceRows.set(added.id, added) + source.put(added) + await flushPromises() + check(`after source enters the selected group`) + + tagRows.delete(2) + tags.remove(2) + await flushPromises() + check(`after a joined tag is removed`) + + const repeated = { id: 4, sourceId: 3 } + tagRows.set(repeated.id, repeated) + tags.put(repeated) + await flushPromises() + check(`after a second tag joins one source`) + + const removed = { id: 1, category: `B` } + sourceRows.set(removed.id, removed) + source.put(removed) + await flushPromises() + check(`after source leaves the selected group`) + }, + () => [ + () => live.cleanup(), + () => source.collection.cleanup(), + () => tags.collection.cleanup(), + ], + ) } + + const nestedFilterGroupKeysLaw = `Group keys through nested source filtering preserve their source through filtered joins and writes.` + + oracleProperty({ + id: `includes-alias-shadowing.nested-filter-group-keys`, + law: { + statement: nestedFilterGroupKeysLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: nestedFilterGroupKeysCells, + label: ({ innerAlias, mode }) => + `${mode} groups preserve the ${innerAlias} source through filtered joins and writes`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(nestedFilterGroupKeysLaw, plan, observe, () => + judgeNestedFilterGroupKeys(plan), + ), + check: forwardAssertionJudgment, + }) }) function expectedWrapperRows( @@ -1369,8 +1675,6 @@ function expectedWrapperRows( .map(({ id, label }) => ({ id, label })) .sort((a, b) => a.id - b.id) } -declareOracle({ model: [expectedWrapperRows] }) - /** * # A redundant wrapper does not change alpha-normalized identity * @@ -1385,8 +1689,8 @@ declareOracle({ model: [expectedWrapperRows] }) * This cell does not assert that every semantically equivalent query shape * has the same identity. */ -test(`optimized redundant wrappers erase alias spelling`, async () => { - oracleRoute(`fixed history`) +// The former test body, unchanged. +async function judgeRedundantWrappers(): Promise { const initial = { id: 1, label: `first` } const source = createScopedSource(`wrapper-alpha`, [initial], `eager`) const model = new Map([[initial.id, initial]]) @@ -1435,6 +1739,36 @@ test(`optimized redundant wrappers erase alias spelling`, async () => { () => source.collection.cleanup(), ], ) +} + +const redundantWrappersLaw = `Optimized redundant wrappers erase alias spelling from query identity and results.` + +oracleProperty({ + id: `includes-alias-shadowing.redundant-wrappers`, + law: { + statement: redundantWrappersLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [`optimized redundant wrappers erase alias spelling`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(redundantWrappersLaw, plan, observe, () => + judgeRedundantWrappers(), + ), + check: forwardAssertionJudgment, }) /** @@ -1536,8 +1870,6 @@ describe(`lazy join demand across nested alias scopes`, () => { }) .sort((a, b) => a.anchorId - b.anchorId) - declareOracle({ model: [modelJoinedRows, requestMatches] }) - const buildCase = (postAlias: PostAlias, placement: PostPlacement) => { const anchors = createScopedSource( `lazy-alias-anchors-${postAlias}-${placement}`, @@ -1590,8 +1922,8 @@ describe(`lazy join demand across nested alias scopes`, () => { return { anchors, users, posts, live, requestCount: 0 } } - test(`direct and wrapped shadowed includes preserve keyed user demand through writes`, async () => { - oracleRoute(`fixed history`) + // The former test body, unchanged. + async function judgeLazyJoinDemand(): Promise { 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 @@ -1659,6 +1991,38 @@ describe(`lazy join demand across nested alias scopes`, () => { () => entry.posts.collection.cleanup(), ]), ) + } + + const lazyJoinDemandLaw = `Lazy join demand across nested alias scopes preserves keyed user demand through writes.` + + oracleProperty({ + id: `includes-alias-shadowing.lazy-join-demand`, + law: { + statement: lazyJoinDemandLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: [ + `direct and wrapped shadowed includes preserve keyed user demand through writes`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(lazyJoinDemandLaw, plan, observe, () => + judgeLazyJoinDemand(), + ), + check: forwardAssertionJudgment, }) }) @@ -1710,104 +2074,136 @@ describe(`lexical bindings through recursive joins and selections`, () => { }) .sort((a, b) => (a.userId ?? Infinity) - (b.userId ?? Infinity)) - declareOracle({ model: [expectedManagerRows] }) - - 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 = [ - { id: 1, name: `one` }, - { id: 2, name: `two` }, - ] - const userRows: Array = [ - { id: 10, managerId: 1, name: `A` }, - { id: 11, managerId: 1, name: `B` }, - { id: 12, managerId: 2, name: `C` }, - ] - const managers = createScopedSource( - `recursive-managers`, - managerRows, - `eager`, - ) - const users = createScopedSource(`recursive-users`, userRows, mode) - const modelUsers = new Map(userRows.map((row) => [row.id, row])) - users.collection.createIndex((user) => user.managerId, { - indexType: BasicIndex, + const recursiveJoinBindingsCells = ([`eager`, `onDemand`] as const).map( + (mode) => ({ mode }), + ) + + // The former test body, unchanged. + async function judgeRecursiveJoinBindings({ + mode, + }: (typeof recursiveJoinBindingsCells)[number]): Promise { + type Manager = { id: number; name: string } + type User = { id: number; managerId: number; name: string } + const managerRows: Array = [ + { id: 1, name: `one` }, + { id: 2, name: `two` }, + ] + const userRows: Array = [ + { id: 10, managerId: 1, name: `A` }, + { id: 11, managerId: 1, name: `B` }, + { id: 12, managerId: 2, name: `C` }, + ] + const managers = createScopedSource( + `recursive-managers`, + managerRows, + `eager`, + ) + const users = createScopedSource(`recursive-users`, userRows, mode) + const modelUsers = new Map(userRows.map((row) => [row.id, row])) + users.collection.createIndex((user) => user.managerId, { + indexType: BasicIndex, + }) + + const build = (innerAlias: `user` | `member`) => { + const inner = new Query() + .from({ [innerAlias]: managers.collection }) + .select((context: Context) => ({ + id: (context[innerAlias] as Manager).id, + })) + return createLiveQueryCollection({ + query: new Query() + .from({ manager: inner }) + .leftJoin({ user: users.collection }, ({ manager, user }) => + eq(manager.id, user.managerId), + ) + .select(({ manager, user }) => ({ + managerId: manager.id, + userId: user.id, + userName: user.name, + })), }) + } + const shadowed = build(`user`) + const renamed = build(`member`) - const build = (innerAlias: `user` | `member`) => { - const inner = new Query() - .from({ [innerAlias]: managers.collection }) - .select((context: Context) => ({ - id: (context[innerAlias] as Manager).id, - })) - return createLiveQueryCollection({ - query: new Query() - .from({ manager: inner }) - .leftJoin({ user: users.collection }, ({ manager, user }) => - eq(manager.id, user.managerId), - ) - .select(({ manager, user }) => ({ - managerId: manager.id, - userId: user.id, - userName: user.name, - })), - }) + // Model: each manager joins its matching users, regardless of spelling. + const expected = () => expectedManagerRows(managerRows, modelUsers) + const check = (checkpoint: string) => { + for (const live of [shadowed, renamed]) { + expect( + live.toArray + .map(({ managerId, userId, userName }) => ({ + managerId, + userId, + userName, + })) + .sort((a, b) => (a.userId ?? Infinity) - (b.userId ?? Infinity)), + checkpoint, + ).toEqual(expected()) } - const shadowed = build(`user`) - const renamed = build(`member`) + } - // Model: each manager joins its matching users, regardless of spelling. - const expected = () => expectedManagerRows(managerRows, modelUsers) - const check = (checkpoint: string) => { - for (const live of [shadowed, renamed]) { - expect( - live.toArray - .map(({ managerId, userId, userName }) => ({ - managerId, - userId, - userName, - })) - .sort((a, b) => (a.userId ?? Infinity) - (b.userId ?? Infinity)), - checkpoint, - ).toEqual(expected()) - } - } + await withHistoryCleanup( + async () => { + await shadowed.preload() + // On-demand sources are shared; check shadowing before the renamed + // control can supply rows that conceal a missed source request. + expect( + shadowed.toArray + .map(({ managerId, userId, userName }) => ({ + managerId, + userId, + userName, + })) + .sort((a, b) => (a.userId ?? Infinity) - (b.userId ?? Infinity)), + ).toEqual(expected()) + await renamed.preload() + check(`after preload`) + const changed = { id: 12, managerId: 1, name: `C2` } + modelUsers.set(changed.id, changed) + users.put(changed) + await flushPromises() + check(`after user moves between managers`) + }, + () => [ + () => shadowed.cleanup(), + () => renamed.cleanup(), + () => managers.collection.cleanup(), + () => users.collection.cleanup(), + ], + ) + } - await withHistoryCleanup( - async () => { - await shadowed.preload() - // On-demand sources are shared; check shadowing before the renamed - // control can supply rows that conceal a missed source request. - expect( - shadowed.toArray - .map(({ managerId, userId, userName }) => ({ - managerId, - userId, - userName, - })) - .sort((a, b) => (a.userId ?? Infinity) - (b.userId ?? Infinity)), - ).toEqual(expected()) - await renamed.preload() - check(`after preload`) - const changed = { id: 12, managerId: 1, name: `C2` } - modelUsers.set(changed.id, changed) - users.put(changed) - await flushPromises() - check(`after user moves between managers`) - }, - () => [ - () => shadowed.cleanup(), - () => renamed.cleanup(), - () => managers.collection.cleanup(), - () => users.collection.cleanup(), - ], - ) + const recursiveJoinBindingsLaw = `Lexical bindings survive recursive joins and selections: a shadowed FROM subquery preserves joined rows.` + + oracleProperty({ + id: `includes-alias-shadowing.recursive-join-bindings`, + law: { + statement: recursiveJoinBindingsLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, }, - ) + 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`, + ], + histories: recursiveJoinBindingsCells, + label: ({ mode }) => + `a shadowed FROM subquery preserves joined rows with ${mode} loading`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(recursiveJoinBindingsLaw, plan, observe, () => + judgeRecursiveJoinBindings(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -1885,222 +2281,247 @@ describe(`nested source placement preserves captured outer rows`, () => { }) .sort((a, b) => a.managerId - b.managerId) - declareOracle({ model: [model] }) + const nestedSourcePlacementCells = ([`from`, `join`] as const).flatMap( + (placement) => + ([`fields`, `spread`] as const).flatMap((capture) => + ([`eager`, `onDemand`] as const).map((mode) => ({ + placement, + capture, + mode, + })), + ), + ) - for (const placement of [`from`, `join`] as const) { - 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]), - ) - const assignmentsModel = new Map( - initialAssignments.map((row) => [row.id, row]), - ) - const detailsModel = new Map( - initialDetails.map((row) => [row.id, row]), + // The former test body, unchanged. + async function judgeNestedSourcePlacement({ + placement, + capture, + mode, + }: (typeof nestedSourcePlacementCells)[number]): Promise { + const managersModel = new Map(initialManagers.map((row) => [row.id, row])) + const assignmentsModel = new Map( + initialAssignments.map((row) => [row.id, row]), + ) + const detailsModel = new Map(initialDetails.map((row) => [row.id, row])) + const cases = ([`user`, `detail`] as const).map((alias) => { + const managers = createScopedSource( + `placement-managers-${placement}-${capture}-${mode}-${alias}`, + initialManagers, + `eager`, + ) + const assignments = createScopedSource( + `placement-assignments-${placement}-${capture}-${mode}-${alias}`, + initialAssignments, + mode, + ) + const details = createScopedSource( + `placement-details-${placement}-${capture}-${mode}-${alias}`, + initialDetails, + mode, + ) + const anchors = createScopedSource( + `placement-anchors-${placement}-${capture}-${mode}-${alias}`, + initialManagers.map(({ id }) => ({ id })), + `eager`, + ) + managers.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + assignments.collection.createIndex((row) => row.managerId, { + indexType: BasicIndex, + }) + details.collection.createIndex((row) => row.managerId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ manager: managers.collection }) + .innerJoin({ user: assignments.collection }, ({ manager, user }) => + eq(manager.id, user.managerId), ) - const cases = ([`user`, `detail`] as const).map((alias) => { - const managers = createScopedSource( - `placement-managers-${placement}-${capture}-${mode}-${alias}`, - initialManagers, - `eager`, - ) - const assignments = createScopedSource( - `placement-assignments-${placement}-${capture}-${mode}-${alias}`, - initialAssignments, - mode, - ) - const details = createScopedSource( - `placement-details-${placement}-${capture}-${mode}-${alias}`, - initialDetails, - mode, - ) - const anchors = createScopedSource( - `placement-anchors-${placement}-${capture}-${mode}-${alias}`, - initialManagers.map(({ id }) => ({ id })), - `eager`, - ) - managers.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - assignments.collection.createIndex((row) => row.managerId, { - indexType: BasicIndex, - }) - details.collection.createIndex((row) => row.managerId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ manager: managers.collection }) - .innerJoin( - { user: assignments.collection }, - ({ manager, user }) => eq(manager.id, user.managerId), - ) - .select(({ manager, user }) => { - const project = (context: Context) => { - const detail = context[alias] as Detail - return capture === `fields` - ? { - outerAssignmentId: user.id, - detailId: detail.id, - label: detail.label, - } - : { ...user, ...detail } - } - const child = - placement === `from` - ? new Query() - .from({ [alias]: details.collection }) - .where((context: Context) => - eq( - (context[alias] as Detail).managerId, - manager.id, - ), - ) - .select(project) - : new Query() - .from({ anchor: anchors.collection }) - .innerJoin( - { [alias]: details.collection }, - (context: Context) => - eq( - (context[alias] as Detail).managerId, - (context.anchor as { id: number }).id, - ), - ) - .where((context: Context) => - eq( - (context.anchor as { id: number }).id, - manager.id, - ), - ) - .select(project) - return { - managerId: manager.id, - managerName: manager.name, - assignmentId: user.id, - details: toArray(child), - } - }), - }) - return { alias, managers, assignments, details, anchors, live } - }) - - const observe = (live: (typeof cases)[number][`live`]) => - live.toArray - .map((row) => ({ - managerId: row.managerId, - managerName: row.managerName, - assignmentId: row.assignmentId, - details: (row.details as Array>) - .map((detail) => - capture === `fields` - ? { - outerAssignmentId: detail.outerAssignmentId as number, - detailId: detail.detailId as number, - label: detail.label as string, - } - : { - id: detail.id as number, - managerId: detail.managerId as number, - ownerTag: detail.ownerTag as string, - label: detail.label as string, - }, - ) - .sort((a, b) => (a.detailId ?? a.id) - (b.detailId ?? b.id)), - })) - .sort((a, b) => a.managerId - b.managerId) - - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - const expected = model( - managersModel, - assignmentsModel, - detailsModel, - capture, - ) - for (const entry of cases) { - expect( - observe(entry.live), - `${entry.alias} at ${cut}`, - ).toEqual(expected) - } - if (mode === `onDemand`) { - for (const source of [`assignments`, `details`] as const) { - expect(cases[0]![source].requests.length).toBeGreaterThan(0) - expect( - [...cases[0]![source].requests].sort(), - `${source} requests at ${cut}`, - ).toEqual([...cases[1]![source].requests].sort()) + .select(({ manager, user }) => { + const project = (context: Context) => { + const detail = context[alias] as Detail + return capture === `fields` + ? { + outerAssignmentId: user.id, + detailId: detail.id, + label: detail.label, } - } - } - check(`initial publication`) - - detailsModel.delete(201) - for (const entry of cases) entry.details.remove(201) - await flushPromises() - check(`detail removal`) - - const movedDetail = { id: 202, managerId: 1, label: `B2` } - detailsModel.set(movedDetail.id, movedDetail) - for (const entry of cases) entry.details.put(movedDetail) - await flushPromises() - check(`detail moves between managers`) - - const changedAssignment = { - id: 101, - managerId: 1, - ownerTag: `OUTER2`, - } - assignmentsModel.set(changedAssignment.id, changedAssignment) - for (const entry of cases) - entry.assignments.put(changedAssignment) - await flushPromises() - check(`captured assignment changes`) - - const movedAssignment = { - id: 101, - managerId: 2, - ownerTag: `OUTER2`, - } - assignmentsModel.set(movedAssignment.id, movedAssignment) - for (const entry of cases) entry.assignments.put(movedAssignment) - await flushPromises() - check(`outer joined row moves`) - - const returnedDetail = { id: 202, managerId: 2, label: `B3` } - detailsModel.set(returnedDetail.id, returnedDetail) - for (const entry of cases) entry.details.put(returnedDetail) - await flushPromises() - check(`detail follows moved assignment`) - - const renamedManager = { id: 2, name: `TWO2` } - managersModel.set(renamedManager.id, renamedManager) - for (const entry of cases) entry.managers.put(renamedManager) - await flushPromises() - check(`captured outer row changes`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.managers.collection.cleanup(), - () => entry.assignments.collection.cleanup(), - () => entry.details.collection.cleanup(), - () => entry.anchors.collection.cleanup(), - ]), + : { ...user, ...detail } + } + const child = + placement === `from` + ? new Query() + .from({ [alias]: details.collection }) + .where((context: Context) => + eq((context[alias] as Detail).managerId, manager.id), + ) + .select(project) + : new Query() + .from({ anchor: anchors.collection }) + .innerJoin( + { [alias]: details.collection }, + (context: Context) => + eq( + (context[alias] as Detail).managerId, + (context.anchor as { id: number }).id, + ), + ) + .where((context: Context) => + eq((context.anchor as { id: number }).id, manager.id), + ) + .select(project) + return { + managerId: manager.id, + managerName: manager.name, + assignmentId: user.id, + details: toArray(child), + } + }), + }) + return { alias, managers, assignments, details, anchors, live } + }) + + const observe = (live: (typeof cases)[number][`live`]) => + live.toArray + .map((row) => ({ + managerId: row.managerId, + managerName: row.managerName, + assignmentId: row.assignmentId, + details: (row.details as Array>) + .map((detail) => + capture === `fields` + ? { + outerAssignmentId: detail.outerAssignmentId as number, + detailId: detail.detailId as number, + label: detail.label as string, + } + : { + id: detail.id as number, + managerId: detail.managerId as number, + ownerTag: detail.ownerTag as string, + label: detail.label as string, + }, + ) + .sort((a, b) => (a.detailId ?? a.id) - (b.detailId ?? b.id)), + })) + .sort((a, b) => a.managerId - b.managerId) + + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { + const expected = model( + managersModel, + assignmentsModel, + detailsModel, + capture, ) - }) - } - } + for (const entry of cases) { + expect(observe(entry.live), `${entry.alias} at ${cut}`).toEqual( + expected, + ) + } + if (mode === `onDemand`) { + for (const source of [`assignments`, `details`] as const) { + expect(cases[0]![source].requests.length).toBeGreaterThan(0) + expect( + [...cases[0]![source].requests].sort(), + `${source} requests at ${cut}`, + ).toEqual([...cases[1]![source].requests].sort()) + } + } + } + check(`initial publication`) + + detailsModel.delete(201) + for (const entry of cases) entry.details.remove(201) + await flushPromises() + check(`detail removal`) + + const movedDetail = { id: 202, managerId: 1, label: `B2` } + detailsModel.set(movedDetail.id, movedDetail) + for (const entry of cases) entry.details.put(movedDetail) + await flushPromises() + check(`detail moves between managers`) + + const changedAssignment = { + id: 101, + managerId: 1, + ownerTag: `OUTER2`, + } + assignmentsModel.set(changedAssignment.id, changedAssignment) + for (const entry of cases) entry.assignments.put(changedAssignment) + await flushPromises() + check(`captured assignment changes`) + + const movedAssignment = { + id: 101, + managerId: 2, + ownerTag: `OUTER2`, + } + assignmentsModel.set(movedAssignment.id, movedAssignment) + for (const entry of cases) entry.assignments.put(movedAssignment) + await flushPromises() + check(`outer joined row moves`) + + const returnedDetail = { id: 202, managerId: 2, label: `B3` } + detailsModel.set(returnedDetail.id, returnedDetail) + for (const entry of cases) entry.details.put(returnedDetail) + await flushPromises() + check(`detail follows moved assignment`) + + const renamedManager = { id: 2, name: `TWO2` } + managersModel.set(renamedManager.id, renamedManager) + for (const entry of cases) entry.managers.put(renamedManager) + await flushPromises() + check(`captured outer row changes`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.managers.collection.cleanup(), + () => entry.assignments.collection.cleanup(), + () => entry.details.collection.cleanup(), + () => entry.anchors.collection.cleanup(), + ]), + ) } + + const nestedSourcePlacementLaw = `Nested source placement preserves captured outer rows, and shadowing and renaming agree at every write cut.` + + oracleProperty({ + id: `includes-alias-shadowing.nested-source-placement`, + law: { + statement: nestedSourcePlacementLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: nestedSourcePlacementCells, + label: ({ placement, capture, mode }) => + `${placement}, ${capture}, ${mode}: shadowing and renaming agree at every write cut`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(nestedSourcePlacementLaw, plan, observe, () => + judgeNestedSourcePlacement(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -2196,204 +2617,241 @@ describe(`outer joins preserve absent source bindings under shadowing`, () => { ) } - declareOracle({ model: [model] }) - - for (const [kind, correlationSide] of [ - [`right`, `joined`], - [`right`, `main`], - [`full`, `joined`], - [`full`, `main`], - ] 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` }], - ]) - const mainRows = new Map([ - [10, { id: 10, parentId: 1, anchorId: 7, label: `MATCH` }], - [11, { id: 11, parentId: 1, anchorId: 9, label: `MAIN ONLY` }], - [21, { id: 21, parentId: 2, anchorId: 20, label: `OTHER MATCH` }], - ]) - const anchorRows = new Map([ - [7, { id: 7, parentId: 1 }], - [8, { id: 8, parentId: 1 }], - [20, { id: 20, parentId: 2 }], - ]) - const cases = ([`queryRef`, `direct`] as const).flatMap((sourceForm) => - ([`issue`, `child`] as const).map((alias) => { - const parents = createScopedSource( - `outer-parent-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, - [...parentRows.values()], - `eager`, - ) - const mains = createScopedSource( - `outer-main-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, - [...mainRows.values()], - mode, - ) - const anchors = createScopedSource( - `outer-anchor-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, - [...anchorRows.values()], - mode, - ) - mains.collection.createIndex((row) => row.anchorId, { - indexType: BasicIndex, - }) - mains.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parents.collection }) - .select(({ issue: parent }) => { - const main = new Query().from({ [alias]: mains.collection }) - const scopedAnchors = new Query() - .from({ source: anchors.collection }) - .where(({ source }) => eq(source.parentId, parent.id)) - .select(({ source }) => ({ - id: source.id, - parentId: source.parentId, - })) - const on = (context: Context) => - eq( - (context[alias] as Main).anchorId, - (context.anchor as Anchor).id, - ) - const anchorSource = - sourceForm === `direct` ? anchors.collection : scopedAnchors - const joined = main.join({ anchor: anchorSource }, on, kind) - expect(getQueryIR(joined).join?.[0]?.from.type).toBe( - sourceForm === `direct` ? `collectionRef` : `queryRef`, - ) - return { - id: parent.id, - rows: toArray( - joined - .where((context: Context) => - correlationSide === `joined` - ? eq((context.anchor as Anchor).parentId, parent.id) - : eq((context[alias] as Main).parentId, parent.id), - ) - .select((context: Context) => ({ - mainId: (context[alias] as Main).id, - anchorId: (context.anchor as Anchor).id, - label: (context[alias] as Main).label, - marker: parent.marker, - missingMain: isUndefined((context[alias] as Main).id), - missingAnchor: isUndefined( - (context.anchor as Anchor).id, - ), - })), - ), - } - }), - }) - return { sourceForm, alias, parents, mains, anchors, live } - }), - ) - - /** Compare every public result after preload and each committed write. */ - const observe = (live: (typeof cases)[number][`live`]) => - sortRows( - live.toArray.flatMap(({ id, rows }) => - rows.map((row) => ({ - parentId: id, - mainId: row.mainId, - anchorId: row.anchorId, - label: row.label, - marker: row.marker, - missingMain: row.missingMain, - missingAnchor: row.missingAnchor, - })), - ), - ) + const outerJoinAbsenceCells = ( + [ + [`right`, `joined`], + [`right`, `main`], + [`full`, `joined`], + [`full`, `main`], + ] as const + ).flatMap(([kind, correlationSide]) => + ([`eager`, `onDemand`] as const).map((mode) => ({ + kind, + correlationSide, + mode, + })), + ) - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - const expected = model( - parentRows, - mainRows, - anchorRows, - kind, - correlationSide, + // The former test body, unchanged. + async function judgeOuterJoinAbsence({ + kind, + correlationSide, + mode, + }: (typeof outerJoinAbsenceCells)[number]): Promise { + const parentRows = new Map([ + [1, { id: 1, marker: `PARENT` }], + [2, { id: 2, marker: `OTHER` }], + ]) + const mainRows = new Map([ + [10, { id: 10, parentId: 1, anchorId: 7, label: `MATCH` }], + [11, { id: 11, parentId: 1, anchorId: 9, label: `MAIN ONLY` }], + [21, { id: 21, parentId: 2, anchorId: 20, label: `OTHER MATCH` }], + ]) + const anchorRows = new Map([ + [7, { id: 7, parentId: 1 }], + [8, { id: 8, parentId: 1 }], + [20, { id: 20, parentId: 2 }], + ]) + const cases = ([`queryRef`, `direct`] as const).flatMap((sourceForm) => + ([`issue`, `child`] as const).map((alias) => { + const parents = createScopedSource( + `outer-parent-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, + [...parentRows.values()], + `eager`, + ) + const mains = createScopedSource( + `outer-main-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, + [...mainRows.values()], + mode, + ) + const anchors = createScopedSource( + `outer-anchor-${kind}-${correlationSide}-${mode}-${sourceForm}-${alias}`, + [...anchorRows.values()], + mode, + ) + mains.collection.createIndex((row) => row.anchorId, { + indexType: BasicIndex, + }) + mains.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parents.collection }) + .select(({ issue: parent }) => { + const main = new Query().from({ [alias]: mains.collection }) + const scopedAnchors = new Query() + .from({ source: anchors.collection }) + .where(({ source }) => eq(source.parentId, parent.id)) + .select(({ source }) => ({ + id: source.id, + parentId: source.parentId, + })) + const on = (context: Context) => + eq( + (context[alias] as Main).anchorId, + (context.anchor as Anchor).id, + ) + const anchorSource = + sourceForm === `direct` ? anchors.collection : scopedAnchors + const joined = main.join({ anchor: anchorSource }, on, kind) + expect(getQueryIR(joined).join?.[0]?.from.type).toBe( + sourceForm === `direct` ? `collectionRef` : `queryRef`, ) - for (const entry of cases) { - expect( - observe(entry.live), - `${entry.sourceForm}, ${entry.alias} at ${cut}`, - ).toEqual(expected) - if (mode === `onDemand`) { - expect(entry.mains.requests.length).toBeGreaterThan(0) - expect(entry.anchors.requests.length).toBeGreaterThan(0) - } + return { + id: parent.id, + rows: toArray( + joined + .where((context: Context) => + correlationSide === `joined` + ? eq((context.anchor as Anchor).parentId, parent.id) + : eq((context[alias] as Main).parentId, parent.id), + ) + .select((context: Context) => ({ + mainId: (context[alias] as Main).id, + anchorId: (context.anchor as Anchor).id, + label: (context[alias] as Main).label, + marker: parent.marker, + missingMain: isUndefined((context[alias] as Main).id), + missingAnchor: isUndefined((context.anchor as Anchor).id), + })), + ), } - } - check(`initial publication`) + }), + }) + return { sourceForm, alias, parents, mains, anchors, live } + }), + ) - const changedMain = { - id: 10, - parentId: 1, - anchorId: 7, - label: `UPDATED`, - } - mainRows.set(10, changedMain) - for (const entry of cases) entry.mains.put(changedMain) - await flushPromises() - check(`matched main update`) - - mainRows.delete(10) - for (const entry of cases) entry.mains.remove(10) - await flushPromises() - check(`main becomes absent`) - - mainRows.set(10, changedMain) - for (const entry of cases) entry.mains.put(changedMain) - await flushPromises() - check(`main returns`) - - const secondMatch = { - id: 12, - parentId: 1, - anchorId: 8, - label: `SECOND`, + /** Compare every public result after preload and each committed write. */ + const observe = (live: (typeof cases)[number][`live`]) => + sortRows( + live.toArray.flatMap(({ id, rows }) => + rows.map((row) => ({ + parentId: id, + mainId: row.mainId, + anchorId: row.anchorId, + label: row.label, + marker: row.marker, + missingMain: row.missingMain, + missingAnchor: row.missingAnchor, + })), + ), + ) + + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { + const expected = model( + parentRows, + mainRows, + anchorRows, + kind, + correlationSide, + ) + for (const entry of cases) { + expect( + observe(entry.live), + `${entry.sourceForm}, ${entry.alias} at ${cut}`, + ).toEqual(expected) + if (mode === `onDemand`) { + expect(entry.mains.requests.length).toBeGreaterThan(0) + expect(entry.anchors.requests.length).toBeGreaterThan(0) } - mainRows.set(12, secondMatch) - for (const entry of cases) entry.mains.put(secondMatch) - await flushPromises() - check(`second main arrives`) - - anchorRows.delete(7) - for (const entry of cases) entry.anchors.remove(7) - await flushPromises() - check(`joined side becomes absent`) - - const changedParent = { id: 1, marker: `PARENT2` } - parentRows.set(1, changedParent) - for (const entry of cases) entry.parents.put(changedParent) - await flushPromises() - check(`ancestor updates`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.mains.collection.cleanup(), - () => entry.anchors.collection.cleanup(), - ]), - ) - }) - } + } + } + check(`initial publication`) + + const changedMain = { + id: 10, + parentId: 1, + anchorId: 7, + label: `UPDATED`, + } + mainRows.set(10, changedMain) + for (const entry of cases) entry.mains.put(changedMain) + await flushPromises() + check(`matched main update`) + + mainRows.delete(10) + for (const entry of cases) entry.mains.remove(10) + await flushPromises() + check(`main becomes absent`) + + mainRows.set(10, changedMain) + for (const entry of cases) entry.mains.put(changedMain) + await flushPromises() + check(`main returns`) + + const secondMatch = { + id: 12, + parentId: 1, + anchorId: 8, + label: `SECOND`, + } + mainRows.set(12, secondMatch) + for (const entry of cases) entry.mains.put(secondMatch) + await flushPromises() + check(`second main arrives`) + + anchorRows.delete(7) + for (const entry of cases) entry.anchors.remove(7) + await flushPromises() + check(`joined side becomes absent`) + + const changedParent = { id: 1, marker: `PARENT2` } + parentRows.set(1, changedParent) + for (const entry of cases) entry.parents.put(changedParent) + await flushPromises() + check(`ancestor updates`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.mains.collection.cleanup(), + () => entry.anchors.collection.cleanup(), + ]), + ) } + + const outerJoinAbsenceLaw = `Outer joins preserve absent source bindings under shadowing.` + + oracleProperty({ + id: `includes-alias-shadowing.outer-join-absence`, + law: { + statement: outerJoinAbsenceLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: outerJoinAbsenceCells, + label: ({ kind, correlationSide, mode }) => + `${kind} with ${correlationSide} correlation and ${mode} sources keeps absent sides absent`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(outerJoinAbsenceLaw, plan, observe, () => + judgeOuterJoinAbsence(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -2465,192 +2923,221 @@ describe(`chained joins keep an absent local source absent`, () => { ) .sort((a, b) => a.parentId - b.parentId || a.commentId - b.commentId) - declareOracle({ model: [model] }) - - 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` }], - ]) - const commentRows = new Map([ - [10, { id: 10, issueId: 1, localIssueId: 99, tagId: 7 }], - [20, { id: 20, issueId: 2, localIssueId: 2, tagId: 8 }], - ]) - const localIssueRows = new Map([ - [2, { id: 2, label: `local issue` }], - ]) - const tagRows = new Map([ - [7, { id: 7, issueId: 1 }], - [8, { id: 8, issueId: 2 }], - ]) - const cases = ([`issue`, `localIssue`] as const).flatMap((alias) => - ([`capturedOperand`, `queryRef`] as const).map((routeCause) => { - const parents = createScopedSource( - `chain-parent-${mode}-${alias}-${routeCause}`, - [...parentRows.values()], - `eager`, - ) - const comments = createScopedSource( - `chain-comment-${mode}-${alias}-${routeCause}`, - [...commentRows.values()], - mode, - ) - const localIssues = createScopedSource( - `chain-local-${mode}-${alias}-${routeCause}`, - [...localIssueRows.values()], - mode, - ) - const tags = createScopedSource( - `chain-tag-${mode}-${alias}-${routeCause}`, - [...tagRows.values()], - mode, - ) - comments.collection.createIndex((row) => row.issueId, { - indexType: BasicIndex, - }) - localIssues.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - tags.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parents.collection }) - .select(({ issue: parent }) => { - const scopedTags = new Query() - .from({ source: tags.collection }) - .where(({ source }) => eq(source.issueId, parent.id)) - .select(({ source }) => ({ - id: source.id, - issueId: source.issueId, - })) - const tagSource = - routeCause === `queryRef` ? scopedTags : tags.collection - return { - id: parent.id, - rows: toArray( - new Query() - .from({ comment: comments.collection }) - .leftJoin( - { [alias]: localIssues.collection }, - (context: Context) => - eq( - (context.comment as Comment).localIssueId, - (context[alias] as Issue).id, - ), - ) - .leftJoin({ tag: tagSource }, (context: Context) => - routeCause === `queryRef` - ? eq( - (context.comment as Comment).tagId, - (context.tag as Tag).id, - ) - : eq( - add( - (context.comment as Comment).tagId, - parent.id, - ), - add((context.tag as Tag).id, parent.id), - ), - ) - .where((context: Context) => - eq((context.comment as Comment).issueId, parent.id), - ) - .select((context: Context) => ({ - parentLabel: parent.label, - commentId: (context.comment as Comment).id, - localIssueId: (context[alias] as Issue).id, - tagId: (context.tag as Tag).id, - missingLocalIssue: isUndefined( + const chainedJoinAbsenceCells = ([`eager`, `onDemand`] as const).map( + (mode) => ({ mode }), + ) + + // The former test body, unchanged. + async function judgeChainedJoinAbsence({ + mode, + }: (typeof chainedJoinAbsenceCells)[number]): Promise { + const parentRows = new Map([ + [1, { id: 1, label: `first parent` }], + [2, { id: 2, label: `second parent` }], + ]) + const commentRows = new Map([ + [10, { id: 10, issueId: 1, localIssueId: 99, tagId: 7 }], + [20, { id: 20, issueId: 2, localIssueId: 2, tagId: 8 }], + ]) + const localIssueRows = new Map([ + [2, { id: 2, label: `local issue` }], + ]) + const tagRows = new Map([ + [7, { id: 7, issueId: 1 }], + [8, { id: 8, issueId: 2 }], + ]) + const cases = ([`issue`, `localIssue`] as const).flatMap((alias) => + ([`capturedOperand`, `queryRef`] as const).map((routeCause) => { + const parents = createScopedSource( + `chain-parent-${mode}-${alias}-${routeCause}`, + [...parentRows.values()], + `eager`, + ) + const comments = createScopedSource( + `chain-comment-${mode}-${alias}-${routeCause}`, + [...commentRows.values()], + mode, + ) + const localIssues = createScopedSource( + `chain-local-${mode}-${alias}-${routeCause}`, + [...localIssueRows.values()], + mode, + ) + const tags = createScopedSource( + `chain-tag-${mode}-${alias}-${routeCause}`, + [...tagRows.values()], + mode, + ) + comments.collection.createIndex((row) => row.issueId, { + indexType: BasicIndex, + }) + localIssues.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + tags.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parents.collection }) + .select(({ issue: parent }) => { + const scopedTags = new Query() + .from({ source: tags.collection }) + .where(({ source }) => eq(source.issueId, parent.id)) + .select(({ source }) => ({ + id: source.id, + issueId: source.issueId, + })) + const tagSource = + routeCause === `queryRef` ? scopedTags : tags.collection + return { + id: parent.id, + rows: toArray( + new Query() + .from({ comment: comments.collection }) + .leftJoin( + { [alias]: localIssues.collection }, + (context: Context) => + eq( + (context.comment as Comment).localIssueId, (context[alias] as Issue).id, ), - })), - ), - } - }), - }) - return { - alias, - routeCause, - parents, - comments, - localIssues, - tags, - live, - } - }), - ) + ) + .leftJoin({ tag: tagSource }, (context: Context) => + routeCause === `queryRef` + ? eq( + (context.comment as Comment).tagId, + (context.tag as Tag).id, + ) + : eq( + add((context.comment as Comment).tagId, parent.id), + add((context.tag as Tag).id, parent.id), + ), + ) + .where((context: Context) => + eq((context.comment as Comment).issueId, parent.id), + ) + .select((context: Context) => ({ + parentLabel: parent.label, + commentId: (context.comment as Comment).id, + localIssueId: (context[alias] as Issue).id, + tagId: (context.tag as Tag).id, + missingLocalIssue: isUndefined( + (context[alias] as Issue).id, + ), + })), + ), + } + }), + }) + return { + alias, + routeCause, + parents, + comments, + localIssues, + tags, + live, + } + }), + ) - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - for (const entry of cases) { - const expected = model( - parentRows, - commentRows, - localIssueRows, - tagRows, - entry.routeCause, + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { + for (const entry of cases) { + const expected = model( + parentRows, + commentRows, + localIssueRows, + tagRows, + entry.routeCause, + ) + const actual = entry.live.toArray + .flatMap(({ id, rows }) => + rows.map((row) => ({ parentId: id, ...row })), ) - const actual = entry.live.toArray - .flatMap(({ id, rows }) => - rows.map((row) => ({ parentId: id, ...row })), - ) - .sort( - (a, b) => - a.parentId - b.parentId || a.commentId - b.commentId, - ) - expect( - actual, - `${entry.alias}, ${entry.routeCause} at ${cut}`, - ).toEqual(expected) - } + .sort( + (a, b) => a.parentId - b.parentId || a.commentId - b.commentId, + ) + expect( + actual, + `${entry.alias}, ${entry.routeCause} at ${cut}`, + ).toEqual(expected) } - check(`initial publication`) - - const changedParent = { id: 2, label: `changed parent` } - parentRows.set(2, changedParent) - for (const entry of cases) entry.parents.put(changedParent) - await flushPromises() - check(`parent label changes`) - - const matchedIssue = { id: 99, label: `new local issue` } - localIssueRows.set(99, matchedIssue) - for (const entry of cases) entry.localIssues.put(matchedIssue) - await flushPromises() - check(`local issue arrives`) - - localIssueRows.delete(99) - for (const entry of cases) entry.localIssues.remove(99) - await flushPromises() - check(`local issue disappears`) - - tagRows.delete(7) - for (const entry of cases) entry.tags.remove(7) - await flushPromises() - check(`second joined side disappears`) - - const returnedTag = { id: 7, issueId: 1 } - tagRows.set(7, returnedTag) - for (const entry of cases) entry.tags.put(returnedTag) - await flushPromises() - check(`second joined side returns`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.comments.collection.cleanup(), - () => entry.localIssues.collection.cleanup(), - () => entry.tags.collection.cleanup(), - ]), - ) - }) + } + check(`initial publication`) + + const changedParent = { id: 2, label: `changed parent` } + parentRows.set(2, changedParent) + for (const entry of cases) entry.parents.put(changedParent) + await flushPromises() + check(`parent label changes`) + + const matchedIssue = { id: 99, label: `new local issue` } + localIssueRows.set(99, matchedIssue) + for (const entry of cases) entry.localIssues.put(matchedIssue) + await flushPromises() + check(`local issue arrives`) + + localIssueRows.delete(99) + for (const entry of cases) entry.localIssues.remove(99) + await flushPromises() + check(`local issue disappears`) + + tagRows.delete(7) + for (const entry of cases) entry.tags.remove(7) + await flushPromises() + check(`second joined side disappears`) + + const returnedTag = { id: 7, issueId: 1 } + tagRows.set(7, returnedTag) + for (const entry of cases) entry.tags.put(returnedTag) + await flushPromises() + check(`second joined side returns`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.comments.collection.cleanup(), + () => entry.localIssues.collection.cleanup(), + () => entry.tags.collection.cleanup(), + ]), + ) } + + const chainedJoinAbsenceLaw = `Chained joins keep an absent local source absent under shadowing and renaming.` + + oracleProperty({ + id: `includes-alias-shadowing.chained-join-absence`, + law: { + statement: chainedJoinAbsenceLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: chainedJoinAbsenceCells, + label: ({ mode }) => + `${mode} chained joins preserve absence under shadowing and renaming`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(chainedJoinAbsenceLaw, plan, observe, () => + judgeChainedJoinAbsence(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -2697,116 +3184,149 @@ describe(`joined QueryRef lazy targets retain lexical source identity`, () => { } }) - declareOracle({ model: [model] }) - - 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` }], - ]) - const postRows = new Map([ - [10, { id: 10, tagId: 7, title: `POST` }], - [11, { id: 11, tagId: 8, title: `OTHER POST` }], - ]) - const tagRows = new Map([ - [7, { id: 7, label: `MATCH` }], - [8, { id: 8, label: `OTHER` }], - ]) - const users = createScopedSource( - `lazy-joined-users-${alias}`, - [...userRows.values()], - `onDemand`, - ) - const posts = createScopedSource( - `lazy-joined-posts-${alias}`, - [...postRows.values()], - `onDemand`, - ) - const tags = createScopedSource( - `lazy-joined-tags-${alias}`, - [...tagRows.values()], - `onDemand`, - ) - users.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - posts.collection.createIndex((row) => row.tagId, { - indexType: BasicIndex, - }) - tags.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const joined = new Query() - .from({ [alias]: posts.collection }) - .innerJoin({ tag: tags.collection }, (context: Context) => - eq((context[alias] as Post).tagId, (context.tag as Tag).id), - ) - .select((context: Context) => ({ - tagId: (context.tag as Tag).id, - title: (context[alias] as Post).title, - label: (context.tag as Tag).label, - })) - const live = createLiveQueryCollection({ - query: new Query() - .from({ u: users.collection }) - .leftJoin({ p: joined }, ({ u, p }) => eq(u.tagId, p.tagId)) - .select(({ u, p }) => ({ - id: u.id, - name: u.name, - title: p.title, - label: p.label, - })), - }) - - await withHistoryCleanup( - async () => { - const check = ( - cut: string, - expectedPosts: Array, - expectedTags: Array, - ) => { - expect( - live.toArray.map(({ id, name, title, label }) => ({ - id, - name, - title, - label, - })), - `${alias} at ${cut}: public rows`, - ).toEqual(model(userRows, postRows, tagRows)) - expect( - posts.collection.toArray.map((row) => row.id).sort(), - `${alias} at ${cut}: installed posts`, - ).toEqual(expectedPosts) - expect( - tags.collection.toArray.map((row) => row.id).sort(), - `${alias} at ${cut}: installed tags`, - ).toEqual(expectedTags) - } + const joinedQueryrefLazyTargetsCells = ([`u`, `post`] as const).map( + (alias) => ({ alias }), + ) - await live.preload() - check(`preload`, [10], [7]) - const moved = { id: 1, tagId: 8, name: `USER` } - userRows.set(1, moved) - users.put(moved) - await flushPromises() - check(`user changes tag`, [10, 11], [7, 8]) - const changedTag = { id: 8, label: `CHANGED` } - tagRows.set(8, changedTag) - tags.put(changedTag) - await flushPromises() - check(`tag changes label`, [10, 11], [7, 8]) - }, - () => [ - () => live.cleanup(), - () => users.collection.cleanup(), - () => posts.collection.cleanup(), - () => tags.collection.cleanup(), - ], + // The former test body, unchanged. + async function judgeJoinedQueryrefLazyTargets({ + alias, + }: (typeof joinedQueryrefLazyTargetsCells)[number]): Promise { + const userRows = new Map([ + [1, { id: 1, tagId: 7, name: `USER` }], + ]) + const postRows = new Map([ + [10, { id: 10, tagId: 7, title: `POST` }], + [11, { id: 11, tagId: 8, title: `OTHER POST` }], + ]) + const tagRows = new Map([ + [7, { id: 7, label: `MATCH` }], + [8, { id: 8, label: `OTHER` }], + ]) + const users = createScopedSource( + `lazy-joined-users-${alias}`, + [...userRows.values()], + `onDemand`, + ) + const posts = createScopedSource( + `lazy-joined-posts-${alias}`, + [...postRows.values()], + `onDemand`, + ) + const tags = createScopedSource( + `lazy-joined-tags-${alias}`, + [...tagRows.values()], + `onDemand`, + ) + users.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + posts.collection.createIndex((row) => row.tagId, { + indexType: BasicIndex, + }) + tags.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const joined = new Query() + .from({ [alias]: posts.collection }) + .innerJoin({ tag: tags.collection }, (context: Context) => + eq((context[alias] as Post).tagId, (context.tag as Tag).id), ) + .select((context: Context) => ({ + tagId: (context.tag as Tag).id, + title: (context[alias] as Post).title, + label: (context.tag as Tag).label, + })) + const live = createLiveQueryCollection({ + query: new Query() + .from({ u: users.collection }) + .leftJoin({ p: joined }, ({ u, p }) => eq(u.tagId, p.tagId)) + .select(({ u, p }) => ({ + id: u.id, + name: u.name, + title: p.title, + label: p.label, + })), }) + + await withHistoryCleanup( + async () => { + const check = ( + cut: string, + expectedPosts: Array, + expectedTags: Array, + ) => { + expect( + live.toArray.map(({ id, name, title, label }) => ({ + id, + name, + title, + label, + })), + `${alias} at ${cut}: public rows`, + ).toEqual(model(userRows, postRows, tagRows)) + expect( + posts.collection.toArray.map((row) => row.id).sort(), + `${alias} at ${cut}: installed posts`, + ).toEqual(expectedPosts) + expect( + tags.collection.toArray.map((row) => row.id).sort(), + `${alias} at ${cut}: installed tags`, + ).toEqual(expectedTags) + } + + await live.preload() + check(`preload`, [10], [7]) + const moved = { id: 1, tagId: 8, name: `USER` } + userRows.set(1, moved) + users.put(moved) + await flushPromises() + check(`user changes tag`, [10, 11], [7, 8]) + const changedTag = { id: 8, label: `CHANGED` } + tagRows.set(8, changedTag) + tags.put(changedTag) + await flushPromises() + check(`tag changes label`, [10, 11], [7, 8]) + }, + () => [ + () => live.cleanup(), + () => users.collection.cleanup(), + () => posts.collection.cleanup(), + () => tags.collection.cleanup(), + ], + ) } + + const joinedQueryrefLazyTargetsLaw = `Joined QueryRef lazy targets retain lexical source identity, so the projected tag drives on-demand rows.` + + oracleProperty({ + id: `includes-alias-shadowing.joined-queryref-lazy-targets`, + law: { + statement: joinedQueryrefLazyTargetsLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: joinedQueryrefLazyTargetsCells, + label: ({ alias }) => + `${alias} post alias: projected tag drives on-demand rows`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(joinedQueryrefLazyTargetsLaw, plan, observe, () => + judgeJoinedQueryrefLazyTargets(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -2927,200 +3447,235 @@ describe(`mixed outer-join chains retain absent source roles`, () => { ) } - declareOracle({ model: [model] }) - - 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` }], - ]) - const mainRows = new Map([ - [10, { id: 10, parentId: 1, anchorId: 7 }], - [11, { id: 11, parentId: 1, anchorId: 99 }], - [20, { id: 20, parentId: 2, anchorId: 20 }], - ]) - const anchorRows = new Map([ - [7, { id: 7, parentId: 1 }], - [8, { id: 8, parentId: 1 }], - [20, { id: 20, parentId: 2 }], - ]) - const tagRows = new Map([ - [70, { id: 70, anchorId: 7, parentId: 1 }], - [80, { id: 80, anchorId: 8, parentId: 1 }], - [90, { id: 90, anchorId: 90, parentId: 1 }], - [200, { id: 200, anchorId: 20, parentId: 2 }], - ]) - const cases = ([`shadowed`, `renamed`] as const).map((spelling) => { - const mainAlias = - correlation !== `main` && spelling === `shadowed` ? `issue` : `main` - const anchorAlias = - correlation === `main` && spelling === `shadowed` - ? `issue` - : `anchor` - const name = `mixed-chain-${first}-${second}-${correlation}-${mode}-${spelling}` - const parents = createScopedSource( - `${name}-parent`, - [...parentRows.values()], - `eager`, - ) - const mains = createScopedSource( - `${name}-main`, - [...mainRows.values()], - mode, - ) - const anchors = createScopedSource( - `${name}-anchor`, - [...anchorRows.values()], - mode, - ) - const tags = createScopedSource( - `${name}-tag`, - [...tagRows.values()], - mode, - ) - mains.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - mains.collection.createIndex((row) => row.anchorId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - anchors.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - tags.collection.createIndex((row) => row.anchorId, { - indexType: BasicIndex, - }) - tags.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parents.collection }) - .select(({ issue: parent }) => ({ - id: parent.id, - rows: toArray( - new Query() - .from({ [mainAlias]: mains.collection }) - .join( - { [anchorAlias]: anchors.collection }, - (context: Context) => - eq( - (context[mainAlias] as Main).anchorId, - (context[anchorAlias] as Anchor).id, - ), - first, - ) - .join( - { tag: tags.collection }, - (context: Context) => - eq( - (context[anchorAlias] as Anchor).id, - (context.tag as Tag).anchorId, - ), - second, - ) - .where((context: Context) => - correlation === `main` - ? eq((context[mainAlias] as Main).parentId, parent.id) - : correlation === `joined` - ? eq( - (context[anchorAlias] as Anchor).parentId, - parent.id, - ) - : eq((context.tag as Tag).parentId, parent.id), - ) - .select((context: Context) => ({ - mainId: (context[mainAlias] as Main).id, - anchorId: (context[anchorAlias] as Anchor).id, - tagId: (context.tag as Tag).id, - marker: parent.marker, - missingMain: isUndefined((context[mainAlias] as Main).id), - missingAnchor: isUndefined( - (context[anchorAlias] as Anchor).id, - ), - missingTag: isUndefined((context.tag as Tag).id), - })), - ), - })), - }) - return { spelling, parents, mains, anchors, tags, live } - }) + const mixedOuterJoinChainsCells = chains.flatMap( + ({ first, second, correlation }) => + ([`eager`, `onDemand`] as const).map((mode) => ({ + first, + second, + correlation, + mode, + })), + ) - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - const expected = model( - parentRows, - mainRows, - anchorRows, - tagRows, - first, - second, - correlation, - ) - for (const entry of cases) { - const actual = sortRows( - entry.live.toArray.flatMap(({ id, rows }) => - rows.map((row) => ({ parentId: id, ...row })), - ), + // The former test body, unchanged. + async function judgeMixedOuterJoinChains({ + first, + second, + correlation, + mode, + }: (typeof mixedOuterJoinChainsCells)[number]): Promise { + const parentRows = new Map([ + [1, { id: 1, marker: `ONE` }], + [2, { id: 2, marker: `TWO` }], + ]) + const mainRows = new Map([ + [10, { id: 10, parentId: 1, anchorId: 7 }], + [11, { id: 11, parentId: 1, anchorId: 99 }], + [20, { id: 20, parentId: 2, anchorId: 20 }], + ]) + const anchorRows = new Map([ + [7, { id: 7, parentId: 1 }], + [8, { id: 8, parentId: 1 }], + [20, { id: 20, parentId: 2 }], + ]) + const tagRows = new Map([ + [70, { id: 70, anchorId: 7, parentId: 1 }], + [80, { id: 80, anchorId: 8, parentId: 1 }], + [90, { id: 90, anchorId: 90, parentId: 1 }], + [200, { id: 200, anchorId: 20, parentId: 2 }], + ]) + const cases = ([`shadowed`, `renamed`] as const).map((spelling) => { + const mainAlias = + correlation !== `main` && spelling === `shadowed` ? `issue` : `main` + const anchorAlias = + correlation === `main` && spelling === `shadowed` ? `issue` : `anchor` + const name = `mixed-chain-${first}-${second}-${correlation}-${mode}-${spelling}` + const parents = createScopedSource( + `${name}-parent`, + [...parentRows.values()], + `eager`, + ) + const mains = createScopedSource( + `${name}-main`, + [...mainRows.values()], + mode, + ) + const anchors = createScopedSource( + `${name}-anchor`, + [...anchorRows.values()], + mode, + ) + const tags = createScopedSource( + `${name}-tag`, + [...tagRows.values()], + mode, + ) + mains.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + mains.collection.createIndex((row) => row.anchorId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + anchors.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + tags.collection.createIndex((row) => row.anchorId, { + indexType: BasicIndex, + }) + tags.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parents.collection }) + .select(({ issue: parent }) => ({ + id: parent.id, + rows: toArray( + new Query() + .from({ [mainAlias]: mains.collection }) + .join( + { [anchorAlias]: anchors.collection }, + (context: Context) => + eq( + (context[mainAlias] as Main).anchorId, + (context[anchorAlias] as Anchor).id, + ), + first, ) - expect(actual, `${entry.spelling} at ${cut}`).toEqual(expected) - } - } - check(`initial publication`) - - tagRows.delete(80) - for (const entry of cases) entry.tags.remove(80) - await flushPromises() - check(`second joined source disappears`) - - const newMain = { id: 12, parentId: 1, anchorId: 8 } - mainRows.set(12, newMain) - for (const entry of cases) entry.mains.put(newMain) - await flushPromises() - check(`first joined source gains a match`) - - mainRows.delete(12) - for (const entry of cases) entry.mains.remove(12) - await flushPromises() - check(`first joined source loses its match`) - - const restoredTag = { id: 80, anchorId: 8, parentId: 1 } - tagRows.set(80, restoredTag) - for (const entry of cases) entry.tags.put(restoredTag) - await flushPromises() - check(`second joined source returns`) - - anchorRows.delete(7) - for (const entry of cases) entry.anchors.remove(7) - await flushPromises() - check(`first joined source disappears`) - - const changedParent = { id: 1, marker: `CHANGED` } - parentRows.set(1, changedParent) - for (const entry of cases) entry.parents.put(changedParent) - await flushPromises() - check(`ancestor value changes`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.mains.collection.cleanup(), - () => entry.anchors.collection.cleanup(), - () => entry.tags.collection.cleanup(), - ]), - ) + .join( + { tag: tags.collection }, + (context: Context) => + eq( + (context[anchorAlias] as Anchor).id, + (context.tag as Tag).anchorId, + ), + second, + ) + .where((context: Context) => + correlation === `main` + ? eq((context[mainAlias] as Main).parentId, parent.id) + : correlation === `joined` + ? eq((context[anchorAlias] as Anchor).parentId, parent.id) + : eq((context.tag as Tag).parentId, parent.id), + ) + .select((context: Context) => ({ + mainId: (context[mainAlias] as Main).id, + anchorId: (context[anchorAlias] as Anchor).id, + tagId: (context.tag as Tag).id, + marker: parent.marker, + missingMain: isUndefined((context[mainAlias] as Main).id), + missingAnchor: isUndefined( + (context[anchorAlias] as Anchor).id, + ), + missingTag: isUndefined((context.tag as Tag).id), + })), + ), + })), }) - } + return { spelling, parents, mains, anchors, tags, live } + }) + + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { + const expected = model( + parentRows, + mainRows, + anchorRows, + tagRows, + first, + second, + correlation, + ) + for (const entry of cases) { + const actual = sortRows( + entry.live.toArray.flatMap(({ id, rows }) => + rows.map((row) => ({ parentId: id, ...row })), + ), + ) + expect(actual, `${entry.spelling} at ${cut}`).toEqual(expected) + } + } + check(`initial publication`) + + tagRows.delete(80) + for (const entry of cases) entry.tags.remove(80) + await flushPromises() + check(`second joined source disappears`) + + const newMain = { id: 12, parentId: 1, anchorId: 8 } + mainRows.set(12, newMain) + for (const entry of cases) entry.mains.put(newMain) + await flushPromises() + check(`first joined source gains a match`) + + mainRows.delete(12) + for (const entry of cases) entry.mains.remove(12) + await flushPromises() + check(`first joined source loses its match`) + + const restoredTag = { id: 80, anchorId: 8, parentId: 1 } + tagRows.set(80, restoredTag) + for (const entry of cases) entry.tags.put(restoredTag) + await flushPromises() + check(`second joined source returns`) + + anchorRows.delete(7) + for (const entry of cases) entry.anchors.remove(7) + await flushPromises() + check(`first joined source disappears`) + + const changedParent = { id: 1, marker: `CHANGED` } + parentRows.set(1, changedParent) + for (const entry of cases) entry.parents.put(changedParent) + await flushPromises() + check(`ancestor value changes`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.mains.collection.cleanup(), + () => entry.anchors.collection.cleanup(), + () => entry.tags.collection.cleanup(), + ]), + ) } + + const mixedOuterJoinChainsLaw = `Mixed outer-join chains retain absent source roles.` + + oracleProperty({ + id: `includes-alias-shadowing.mixed-outer-join-chains`, + law: { + statement: mixedOuterJoinChainsLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: mixedOuterJoinChainsCells, + label: ({ first, second, correlation, mode }) => + `${first} then ${second}, ${correlation} correlation, ${mode} sources`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(mixedOuterJoinChainsLaw, plan, observe, () => + judgeMixedOuterJoinChains(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -3171,165 +3726,198 @@ describe(`captured ancestor join operands retain their bindings`, () => { })) .sort((a, b) => a.id - b.id) - declareOracle({ model: [model] }) - - 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 }], - ]) - const targetRows = new Map([ - [1, { id: 1 }], - [2, { id: 2 }], - [3, { id: 3 }], - [4, { id: 4 }], - [5, { id: 5 }], - [7, { id: 7 }], - [8, { id: 8 }], - ]) - const cases = ([`issue`, `target`] as const).flatMap((alias) => - ([`childFirst`, `joinedFirst`] as const).flatMap((order) => - ([`main`, `joined`, `soleMain`] as const).map((ancestorSide) => { - const parents = createScopedSource( - `operand-parent-${mode}-${alias}-${order}-${ancestorSide}`, - [...parentRows.values()], - `eager`, - ) - const children = createScopedSource( - `operand-child-${mode}-${alias}-${order}-${ancestorSide}`, - [...childRows.values()], - mode, - ) - const targets = createScopedSource( - `operand-target-${mode}-${alias}-${order}-${ancestorSide}`, - [...targetRows.values()], - mode, - ) - children.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - targets.collection.createIndex((row) => row.id, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parents.collection }) - .select(({ issue: ancestor }) => ({ - id: ancestor.id, - rows: toArray( - new Query() - .from({ child: children.collection }) - .innerJoin( - { [alias]: targets.collection }, - (context: Context) => { - const child = context.child as Child - const target = context[alias] as Target - const mainKey = - ancestorSide === `main` - ? add(child.base, ancestor.offset) - : ancestorSide === `soleMain` - ? ancestor.offset - : child.base - const joinedKey = - ancestorSide === `joined` - ? add(target.id, ancestor.offset) - : target.id - return order === `childFirst` - ? eq(mainKey, joinedKey) - : eq(joinedKey, mainKey) - }, - ) - .where((context: Context) => - eq((context.child as Child).parentId, ancestor.id), - ) - .select((context: Context) => ({ - parentId: ancestor.id, - childId: (context.child as Child).id, - targetId: (context[alias] as Target).id, - })), - ), - })), - }) - return { - alias, - order, - ancestorSide, - parents, - children, - targets, - live, - } - }), - ), - ) - - const observe = (live: (typeof cases)[number][`live`]) => - live.toArray - .map(({ id, rows }) => ({ - id, - rows: rows - .map(({ parentId, childId, targetId }) => ({ - parentId, - childId, - targetId, - })) - .sort((a, b) => a.childId - b.childId), - })) - .sort((a, b) => a.id - b.id) + const capturedJoinOperandsCells = ([`eager`, `onDemand`] as const).map( + (mode) => ({ mode }), + ) - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - for (const entry of cases) { - expect( - observe(entry.live), - `${entry.alias}, ${entry.order}, ${entry.ancestorSide} at ${cut}`, - ).toEqual( - model(parentRows, childRows, targetRows, entry.ancestorSide), - ) - } + // The former test body, unchanged. + async function judgeCapturedJoinOperands({ + mode, + }: (typeof capturedJoinOperandsCells)[number]): Promise { + const parentRows = new Map([[1, { id: 1, offset: 1 }]]) + const childRows = new Map([ + [10, { id: 10, parentId: 1, base: 6 }], + ]) + const targetRows = new Map([ + [1, { id: 1 }], + [2, { id: 2 }], + [3, { id: 3 }], + [4, { id: 4 }], + [5, { id: 5 }], + [7, { id: 7 }], + [8, { id: 8 }], + ]) + const cases = ([`issue`, `target`] as const).flatMap((alias) => + ([`childFirst`, `joinedFirst`] as const).flatMap((order) => + ([`main`, `joined`, `soleMain`] as const).map((ancestorSide) => { + const parents = createScopedSource( + `operand-parent-${mode}-${alias}-${order}-${ancestorSide}`, + [...parentRows.values()], + `eager`, + ) + const children = createScopedSource( + `operand-child-${mode}-${alias}-${order}-${ancestorSide}`, + [...childRows.values()], + mode, + ) + const targets = createScopedSource( + `operand-target-${mode}-${alias}-${order}-${ancestorSide}`, + [...targetRows.values()], + mode, + ) + children.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + targets.collection.createIndex((row) => row.id, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parents.collection }) + .select(({ issue: ancestor }) => ({ + id: ancestor.id, + rows: toArray( + new Query() + .from({ child: children.collection }) + .innerJoin( + { [alias]: targets.collection }, + (context: Context) => { + const child = context.child as Child + const target = context[alias] as Target + const mainKey = + ancestorSide === `main` + ? add(child.base, ancestor.offset) + : ancestorSide === `soleMain` + ? ancestor.offset + : child.base + const joinedKey = + ancestorSide === `joined` + ? add(target.id, ancestor.offset) + : target.id + return order === `childFirst` + ? eq(mainKey, joinedKey) + : eq(joinedKey, mainKey) + }, + ) + .where((context: Context) => + eq((context.child as Child).parentId, ancestor.id), + ) + .select((context: Context) => ({ + parentId: ancestor.id, + childId: (context.child as Child).id, + targetId: (context[alias] as Target).id, + })), + ), + })), + }) + return { + alias, + order, + ancestorSide, + parents, + children, + targets, + live, } - check(`initial publication`) - - const changedParent = { id: 1, offset: 2 } - parentRows.set(1, changedParent) - for (const entry of cases) entry.parents.put(changedParent) - await flushPromises() - check(`ancestor key update`) + }), + ), + ) - const changedChild = { id: 10, parentId: 1, base: 5 } - childRows.set(10, changedChild) - for (const entry of cases) entry.children.put(changedChild) - await flushPromises() - check(`child key update`) + const observe = (live: (typeof cases)[number][`live`]) => + live.toArray + .map(({ id, rows }) => ({ + id, + rows: rows + .map(({ parentId, childId, targetId }) => ({ + parentId, + childId, + targetId, + })) + .sort((a, b) => a.childId - b.childId), + })) + .sort((a, b) => a.id - b.id) - for (const id of [2, 3, 7]) targetRows.delete(id) + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { for (const entry of cases) { - for (const id of [2, 3, 7]) entry.targets.remove(id) + expect( + observe(entry.live), + `${entry.alias}, ${entry.order}, ${entry.ancestorSide} at ${cut}`, + ).toEqual( + model(parentRows, childRows, targetRows, entry.ancestorSide), + ) } - await flushPromises() - check(`joined row removal`) + } + check(`initial publication`) - for (const id of [2, 3, 7]) targetRows.set(id, { id }) - for (const entry of cases) { - for (const id of [2, 3, 7]) entry.targets.put({ id }) - } - await flushPromises() - check(`joined row return`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.children.collection.cleanup(), - () => entry.targets.collection.cleanup(), - ]), - ) - }) + const changedParent = { id: 1, offset: 2 } + parentRows.set(1, changedParent) + for (const entry of cases) entry.parents.put(changedParent) + await flushPromises() + check(`ancestor key update`) + + const changedChild = { id: 10, parentId: 1, base: 5 } + childRows.set(10, changedChild) + for (const entry of cases) entry.children.put(changedChild) + await flushPromises() + check(`child key update`) + + for (const id of [2, 3, 7]) targetRows.delete(id) + for (const entry of cases) { + for (const id of [2, 3, 7]) entry.targets.remove(id) + } + await flushPromises() + check(`joined row removal`) + + for (const id of [2, 3, 7]) targetRows.set(id, { id }) + for (const entry of cases) { + for (const id of [2, 3, 7]) entry.targets.put({ id }) + } + await flushPromises() + check(`joined row return`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.children.collection.cleanup(), + () => entry.targets.collection.cleanup(), + ]), + ) } + + const capturedJoinOperandsLaw = `Captured ancestor join operands retain their bindings across alias and equality order.` + + oracleProperty({ + id: `includes-alias-shadowing.captured-join-operands`, + law: { + statement: capturedJoinOperandsLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: capturedJoinOperandsCells, + label: ({ mode }) => + `${mode} sources preserve captured operands across alias and equality order`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(capturedJoinOperandsLaw, plan, observe, () => + judgeCapturedJoinOperands(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -3372,118 +3960,149 @@ describe(`grouped includes keep valid ancestor references`, () => { }) .sort((a, b) => a.id - b.id) - declareOracle({ model: [model] }) - - 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 }], - ]) - const childRows = new Map([ - [10, { id: 10, parentId: 1, score: 1 }], - [11, { id: 11, parentId: 1, score: 3 }], - [20, { id: 20, parentId: 2, score: 1 }], - [21, { id: 21, parentId: 2, score: 1 }], - [22, { id: 22, parentId: 2, score: 2 }], - ]) - const cases = ([`issue`, `child`] as const).map((alias) => { - const name = `group-ancestor-${mode}-${alias}` - const parents = createScopedSource( - `${name}-parent`, - [...parentRows.values()], - `eager`, - ) - const children = createScopedSource( - `${name}-child`, - [...childRows.values()], - mode, - ) - children.collection.createIndex((row) => row.parentId, { - indexType: BasicIndex, - }) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parents.collection }) - .select(({ issue: parent }) => ({ - id: parent.id, - rows: toArray( - new Query() - .from({ [alias]: children.collection }) - .where((context: Context) => - eq((context[alias] as Child).parentId, parent.id), - ) - .groupBy( - (context: Context) => (context[alias] as Child).score, - ) - .select((context: Context) => ({ - score: (context[alias] as Child).score, - total: count((context[alias] as Child).id), - })) - .having((context: Context) => - gt(count((context[alias] as Child).id), parent.threshold), - ) - .orderBy( - (context: Context) => - multiply( - (context.$selected as Group).score, - parent.direction, - ), - `asc`, - ), - ), - })), - }) - return { alias, parents, children, live } - }) + const groupedAncestorReferencesCells = ([`eager`, `onDemand`] as const).map( + (mode) => ({ mode }), + ) - await withHistoryCleanup( - async () => { - for (const entry of cases) await entry.live.preload() - const check = (cut: string) => { - const expected = model(parentRows, childRows) - for (const entry of cases) { - expect( - entry.live.toArray - .map(({ id, rows }) => ({ - id, - rows: rows.map(({ score, total }) => ({ score, total })), - })) - .sort((a, b) => a.id - b.id), - `${entry.alias} at ${cut}`, - ).toEqual(expected) - } - } - check(`initial publication`) - - const stricterParent = { id: 2, threshold: 1, direction: -1 } - parentRows.set(2, stricterParent) - for (const entry of cases) entry.parents.put(stricterParent) - await flushPromises() - check(`ancestor threshold changes`) - - const changedChild = { id: 22, parentId: 2, score: 1 } - childRows.set(22, changedChild) - for (const entry of cases) entry.children.put(changedChild) - await flushPromises() - check(`child changes group`) - - const reversedParent = { id: 1, threshold: 0, direction: -1 } - parentRows.set(1, reversedParent) - for (const entry of cases) entry.parents.put(reversedParent) - await flushPromises() - check(`ancestor sort direction changes`) - }, - () => - cases.flatMap((entry) => [ - () => entry.live.cleanup(), - () => entry.parents.collection.cleanup(), - () => entry.children.collection.cleanup(), - ]), + // The former test body, unchanged. + async function judgeGroupedAncestorReferences({ + mode, + }: (typeof groupedAncestorReferencesCells)[number]): Promise { + const parentRows = new Map([ + [1, { id: 1, threshold: 0, direction: 1 }], + [2, { id: 2, threshold: 0, direction: -1 }], + ]) + const childRows = new Map([ + [10, { id: 10, parentId: 1, score: 1 }], + [11, { id: 11, parentId: 1, score: 3 }], + [20, { id: 20, parentId: 2, score: 1 }], + [21, { id: 21, parentId: 2, score: 1 }], + [22, { id: 22, parentId: 2, score: 2 }], + ]) + const cases = ([`issue`, `child`] as const).map((alias) => { + const name = `group-ancestor-${mode}-${alias}` + const parents = createScopedSource( + `${name}-parent`, + [...parentRows.values()], + `eager`, ) + const children = createScopedSource( + `${name}-child`, + [...childRows.values()], + mode, + ) + children.collection.createIndex((row) => row.parentId, { + indexType: BasicIndex, + }) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parents.collection }) + .select(({ issue: parent }) => ({ + id: parent.id, + rows: toArray( + new Query() + .from({ [alias]: children.collection }) + .where((context: Context) => + eq((context[alias] as Child).parentId, parent.id), + ) + .groupBy((context: Context) => (context[alias] as Child).score) + .select((context: Context) => ({ + score: (context[alias] as Child).score, + total: count((context[alias] as Child).id), + })) + .having((context: Context) => + gt(count((context[alias] as Child).id), parent.threshold), + ) + .orderBy( + (context: Context) => + multiply( + (context.$selected as Group).score, + parent.direction, + ), + `asc`, + ), + ), + })), + }) + return { alias, parents, children, live } }) + + await withHistoryCleanup( + async () => { + for (const entry of cases) await entry.live.preload() + const check = (cut: string) => { + const expected = model(parentRows, childRows) + for (const entry of cases) { + expect( + entry.live.toArray + .map(({ id, rows }) => ({ + id, + rows: rows.map(({ score, total }) => ({ score, total })), + })) + .sort((a, b) => a.id - b.id), + `${entry.alias} at ${cut}`, + ).toEqual(expected) + } + } + check(`initial publication`) + + const stricterParent = { id: 2, threshold: 1, direction: -1 } + parentRows.set(2, stricterParent) + for (const entry of cases) entry.parents.put(stricterParent) + await flushPromises() + check(`ancestor threshold changes`) + + const changedChild = { id: 22, parentId: 2, score: 1 } + childRows.set(22, changedChild) + for (const entry of cases) entry.children.put(changedChild) + await flushPromises() + check(`child changes group`) + + const reversedParent = { id: 1, threshold: 0, direction: -1 } + parentRows.set(1, reversedParent) + for (const entry of cases) entry.parents.put(reversedParent) + await flushPromises() + check(`ancestor sort direction changes`) + }, + () => + cases.flatMap((entry) => [ + () => entry.live.cleanup(), + () => entry.parents.collection.cleanup(), + () => entry.children.collection.cleanup(), + ]), + ) } + + const groupedAncestorReferencesLaw = `Grouped includes keep valid ancestor references across shadowing and updates.` + + oracleProperty({ + id: `includes-alias-shadowing.grouped-ancestor-references`, + law: { + statement: groupedAncestorReferencesLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: groupedAncestorReferencesCells, + label: ({ mode }) => + `${mode} grouped includes use ancestors across shadowing and updates`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(groupedAncestorReferencesLaw, plan, observe, () => + judgeGroupedAncestorReferences(plan), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -3526,101 +4145,132 @@ describe(`joined subqueries reject captured sibling sources`, () => { ), })) - declareOracle({ model: [expectedAncestorRows] }) - - 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 }] - const probeRows = [{ id: 1 }, { id: 2 }] - const expected = expectedAncestorRows( - parentRows, - mainRows, - innerRows, - probeRows, - ) - const parent = createScopedSource( - `sibling-control-parent-${mode}-${alias}`, - parentRows, - `eager`, - ) - const main = createScopedSource( - `sibling-control-main-${mode}-${alias}`, - mainRows, - mode, - ) - const inner = createScopedSource( - `sibling-control-inner-${mode}-${alias}`, - innerRows, - mode, - ) - const probe = createScopedSource( - `sibling-control-probe-${mode}-${alias}`, - probeRows, - mode, - ) - const live = createLiveQueryCollection({ - query: new Query() - .from({ issue: parent.collection }) - .select(({ issue: ancestor }) => { - const joinedQuery = new Query() - .from({ [alias]: inner.collection }) - .where((context: Context) => - eq((context[alias] as Row).id, ancestor.id), - ) - .innerJoin({ probe: probe.collection }, ({ probe: row }) => - eq(ancestor.id, row.id), - ) - .select((context: Context) => ({ - id: (context[alias] as Row).id, - ancestorId: ancestor.id, - })) - return { - id: ancestor.id, - rows: toArray( - new Query() - .from({ local: main.collection }) - .innerJoin( - { nested: joinedQuery }, - ({ local: row, nested }) => eq(row.id, nested.id), - ) - .where(({ local: row }) => eq(row.id, ancestor.id)) - .select(({ local: row, nested }) => ({ - localId: row.id, - joinedId: nested.id, - ancestorId: nested.ancestorId, - })), - ), - } - }), - }) + const joinedSubqueryAncestorCells = ([`eager`, `onDemand`] as const).flatMap( + (mode) => ([`issue`, `inner`] as const).map((alias) => ({ mode, alias })), + ) - await withHistoryCleanup( - async () => { - await live.preload() - expect( - live.toArray.map(({ id, rows }) => ({ - id, - rows: rows.map(({ localId, joinedId, ancestorId }) => ({ - localId, - joinedId, - ancestorId, + // The former test body, unchanged. + async function judgeJoinedSubqueryAncestor({ + mode, + alias, + }: (typeof joinedSubqueryAncestorCells)[number]): Promise { + const parentRows = [{ id: 1 }, { id: 2 }] + const mainRows = [{ id: 1 }, { id: 2 }] + const innerRows = [{ id: 1 }, { id: 2 }] + const probeRows = [{ id: 1 }, { id: 2 }] + const expected = expectedAncestorRows( + parentRows, + mainRows, + innerRows, + probeRows, + ) + const parent = createScopedSource( + `sibling-control-parent-${mode}-${alias}`, + parentRows, + `eager`, + ) + const main = createScopedSource( + `sibling-control-main-${mode}-${alias}`, + mainRows, + mode, + ) + const inner = createScopedSource( + `sibling-control-inner-${mode}-${alias}`, + innerRows, + mode, + ) + const probe = createScopedSource( + `sibling-control-probe-${mode}-${alias}`, + probeRows, + mode, + ) + const live = createLiveQueryCollection({ + query: new Query() + .from({ issue: parent.collection }) + .select(({ issue: ancestor }) => { + const joinedQuery = new Query() + .from({ [alias]: inner.collection }) + .where((context: Context) => + eq((context[alias] as Row).id, ancestor.id), + ) + .innerJoin({ probe: probe.collection }, ({ probe: row }) => + eq(ancestor.id, row.id), + ) + .select((context: Context) => ({ + id: (context[alias] as Row).id, + ancestorId: ancestor.id, + })) + return { + id: ancestor.id, + rows: toArray( + new Query() + .from({ local: main.collection }) + .innerJoin({ nested: joinedQuery }, ({ local: row, nested }) => + eq(row.id, nested.id), + ) + .where(({ local: row }) => eq(row.id, ancestor.id)) + .select(({ local: row, nested }) => ({ + localId: row.id, + joinedId: nested.id, + ancestorId: nested.ancestorId, })), - })), - ).toEqual(expected) - }, - () => [ - () => live.cleanup(), - () => parent.collection.cleanup(), - () => main.collection.cleanup(), - () => inner.collection.cleanup(), - () => probe.collection.cleanup(), - ], - ) - }) - } + ), + } + }), + }) + + await withHistoryCleanup( + async () => { + await live.preload() + expect( + live.toArray.map(({ id, rows }) => ({ + id, + rows: rows.map(({ localId, joinedId, ancestorId }) => ({ + localId, + joinedId, + ancestorId, + })), + })), + ).toEqual(expected) + }, + () => [ + () => live.cleanup(), + () => parent.collection.cleanup(), + () => main.collection.cleanup(), + () => inner.collection.cleanup(), + () => probe.collection.cleanup(), + ], + ) } + + const joinedSubqueryAncestorLaw = `A joined subquery may capture an actual ancestor, whatever its inner alias.` + + oracleProperty({ + id: `includes-alias-shadowing.joined-subquery-ancestor`, + law: { + statement: joinedSubqueryAncestorLaw, + authority: `The Identity section and normative law 1 (alpha-renaming) of packages/db/src/query/live/ARCHITECTURE.md: aliases are lexical names, a captured reference keeps its binding when a nested query shadows its alias, and source identity is preserved through plan rewrites, as stated at the start of this owner.`, + limits: `The enumerated cells of this fixture over controlled source Collections, as stated beside it; no other query shape or provider timing.`, + }, + 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`, + ], + histories: joinedSubqueryAncestorCells, + label: ({ mode, alias }) => + `${mode} joined subquery may capture an actual ancestor with inner alias ${alias}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(joinedSubqueryAncestorLaw, plan, observe, () => + judgeJoinedSubqueryAncestor(plan), + ), + check: forwardAssertionJudgment, + }) }) From 8113fa8b6665c666f2b894660b4ea466fbc9b4a4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:33:37 -0700 Subject: [PATCH 088/116] test(sqlite): register the per-collection coordinator fixed histories through oracleProperty P1b: each fixed history becomes a one-history property whose driver is the unchanged test body, and the real-tab follower cells become an enumerated property. P6: the seven checker calibrations build wrong answers by hand, with no production, so they move unchanged to per-collection-coordinator-calibration.test.ts, with the checks and the lease ledger model in the companion per-collection-coordinator-checks-oracle.ts. M2: both wrapper calls pass owns. The collection-routing fixed history stays as it is: it relies on the file's afterEach to dispose its coordinators, and the determinism test's second run in one test saw the first run's state. Co-authored-by: Isaac --- ...collection-coordinator-calibration.test.ts | 273 ++++++ ...er-collection-coordinator-checks-oracle.ts | 413 ++++++++ .../per-collection-coordinator-oracle.test.ts | 883 ++++-------------- .../db/tests/converted-properties/sqlite.ts | 13 + 4 files changed, 902 insertions(+), 680 deletions(-) create mode 100644 packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-calibration.test.ts create mode 100644 packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-checks-oracle.ts diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-calibration.test.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-calibration.test.ts new file mode 100644 index 0000000000..0051d6fc07 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-calibration.test.ts @@ -0,0 +1,273 @@ +/** + * Calibrations of the per-collection coordinator checks and models in + * per-collection-coordinator-checks-oracle.ts against hand-built wrong answers: + * clone safety, subset acknowledgement, collection-scoped acquisitions, the + * lease takeover check, SQLite-state and fresh-registry checks, collection-key + * routing, and write ownership. They run no production, so they are ordinary + * tests. The oracle that drives production is + * per-collection-coordinator-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { + complexSubsetDemand, + semanticValue, + subsetSemantics, +} from './per-collection-coordinator-support' +import { + collectionScopedLeaseViolations, + coordinatorRouteViolations, + expectedRemoteLeaseTakeoverTrace, + findUnownedWrites, + freshInitViolations, + rawSnapshotViolations, + remoteLeaseTakeoverViolations, + routeViolationDiscriminant, + routeViolations, + subsetAckViolations, + subsetWireViolations, +} from './per-collection-coordinator-checks-oracle' +import type { + CollectionScopedLeaseSnapshot, + CoordinatorRouteObservation, + FreshInitOutcome, + RawCollectionSnapshot, + RemoteLeaseTakeoverTrace, + RoutedApply, + SubsetAckTrace, + SubsetWireTrace, +} from './per-collection-coordinator-checks-oracle' + +describe(`per-collection coordinator oracle calibrations`, () => { + it(`calibrates the clone checker against safe and function-valued payloads`, () => { + const safe = { + collectionId: `alpha`, + where: { type: `eq`, field: `group`, value: `kept` }, + limit: 2, + } + const unsafe = { ...safe, callback: () => {} } + + expect(structuredClone(safe)).toEqual(safe) + expect(() => structuredClone(unsafe)).toThrowError( + expect.objectContaining({ name: `DataCloneError` }), + ) + + const expected = subsetSemantics(complexSubsetDemand(false)) + const allowed: SubsetWireTrace = { + outcome: { ok: true }, + decoded: [expected], + expected, + } + expect(subsetWireViolations(allowed)).toEqual([]) + expect( + subsetWireViolations({ + ...allowed, + outcome: { ok: false, errorName: `DataCloneError` }, + }), + ).toContain(`subset-transport-failed`) + expect( + subsetWireViolations({ ...allowed, decoded: [semanticValue({})] }), + ).toContain(`subset-semantics-mismatch`) + }) + + it(`calibrates acknowledgement checks against entry-only and false-success faults`, () => { + const expected = subsetSemantics(complexSubsetDemand(false)) + const allowed: SubsetAckTrace = { + ownerInvocation: `coordinator`, + decoded: expected, + expected, + prematureResponses: 0, + acknowledgedBeforeResponseDelivery: false, + ownerCompletedBeforeResponse: true, + acknowledgedAfterResponseDelivery: true, + } + expect(subsetAckViolations(allowed)).toEqual([]) + expect( + subsetAckViolations({ + ...allowed, + ownerCompletedBeforeResponse: false, + }), + ).toContain(`response-before-owner-completion-checkpoint`) + expect( + subsetAckViolations({ + ...allowed, + ownerInvocation: `fixture-fallback`, + prematureResponses: 1, + }), + ).toEqual([ + `leader-owner-not-invoked-by-coordinator`, + `response-before-owner-completion`, + ]) + }) + + it(`calibrates collection-scoped acquisition identity against a global-id mutant`, () => { + const expected: CollectionScopedLeaseSnapshot = { + alpha: { loads: 1, unloads: 0, active: 1 }, + beta: { loads: 1, unloads: 0, active: 1 }, + } + expect(collectionScopedLeaseViolations(expected, expected)).toEqual([]) + expect( + collectionScopedLeaseViolations( + { + alpha: { loads: 2, unloads: 0, active: 2 }, + beta: { loads: 1, unloads: 0, active: 1 }, + }, + expected, + ), + ).toEqual([`alpha:loads`, `alpha:active`]) + }) + + it(`calibrates the lease checker against leak, missing replay, and inexact release mutants`, () => { + expect( + remoteLeaseTakeoverViolations(expectedRemoteLeaseTakeoverTrace), + ).toEqual([]) + const mutant: RemoteLeaseTakeoverTrace = structuredClone( + expectedRemoteLeaseTakeoverTrace, + ) + mutant.retiredOwnerAfterTakeover = { + loads: 1, + unloads: 0, + active: 1, + exactUnloads: true, + } + mutant.newOwnerAfterTakeover = { + loads: 0, + unloads: 0, + active: 0, + exactUnloads: true, + } + mutant.newOwnerAfterRelease.exactUnloads = false + expect( + remoteLeaseTakeoverViolations(mutant).map( + ({ checkpoint, field }) => `${checkpoint}:${field}`, + ), + ).toEqual([ + `retiredOwnerAfterTakeover:unloads`, + `retiredOwnerAfterTakeover:active`, + `newOwnerAfterTakeover:loads`, + `newOwnerAfterTakeover:active`, + `newOwnerAfterRelease:exactUnloads`, + ]) + }) + + it(`calibrates SQLite-state and fresh-registry checks against mutant results`, () => { + const expected: RawCollectionSnapshot = { + registryRowCount: 1, + schemaVersion: 11, + resetEpoch: 0, + rowCount: 1, + } + expect(rawSnapshotViolations(expected, expected)).toEqual([]) + expect( + rawSnapshotViolations( + { + registryRowCount: 0, + schemaVersion: 22, + resetEpoch: 1, + rowCount: 0, + }, + expected, + ).map(({ field }) => field), + ).toEqual([`registryRowCount`, `schemaVersion`, `resetEpoch`, `rowCount`]) + + const allowed: Array = [ + { status: `fulfilled` }, + { status: `fulfilled` }, + ] + expect(freshInitViolations(allowed)).toEqual([]) + expect( + freshInitViolations([ + allowed[0]!, + { + status: `rejected`, + error: `UNIQUE constraint failed: collection_registry`, + }, + ]), + ).toEqual([ + { + kind: `rejected`, + index: 1, + error: `UNIQUE constraint failed: collection_registry`, + }, + ]) + expect(freshInitViolations([allowed[0]!])).toEqual([ + { kind: `missing-outcome`, index: 1 }, + ]) + }) + + it(`calibrates collection-key comparison against one peer-routed apply`, () => { + const expected: Array = [ + { + phase: `initial-owner`, + collectionId: `alpha`, + adapterId: `leader-alpha`, + }, + { + phase: `initial-owner`, + collectionId: `beta`, + adapterId: `leader-beta`, + }, + ] + const misrouted: Array = [ + expected[0]!, + { ...expected[1]!, adapterId: `leader-alpha` }, + ] + + expect(routeViolations(expected, expected)).toEqual([]) + const [violation] = routeViolations(misrouted, expected) + expect(violation).toEqual({ + kind: `misrouted-apply`, + checkpoint: `initial-owner`, + collectionId: `beta`, + expectedAdapterId: `leader-beta`, + actualAdapterIds: [`leader-alpha`], + }) + expect(routeViolationDiscriminant(violation!)).toBe( + `misrouted-apply:initial-owner:beta`, + ) + + const routedCell: CoordinatorRouteObservation = { + phase: `initial-owner`, + operation: `index`, + collectionId: `alpha`, + calls: [ + { + adapterId: `leader-alpha`, + collectionId: `alpha`, + argument: semanticValue({ signature: `alpha-index` }), + }, + ], + result: semanticValue({ status: `fulfilled` }), + } + expect(coordinatorRouteViolations([routedCell], [routedCell])).toEqual([]) + expect( + coordinatorRouteViolations( + [{ ...routedCell, calls: [] }], + [routedCell], + ).map(({ operation, checkpoint, collectionId, field }) => ({ + operation, + checkpoint, + collectionId, + field, + })), + ).toEqual([ + { + operation: `index`, + checkpoint: `initial-owner`, + collectionId: `alpha`, + field: `calls`, + }, + ]) + }) + + it(`calibrates the owner checker against one bypass and one owned write`, () => { + expect( + findUnownedWrites( + [ + { collectionId: `alpha`, ownerAtCall: `tab-b` }, + { collectionId: `beta` }, + ], + `tab-b`, + ), + ).toEqual([{ collectionId: `beta` }]) + }) +}) diff --git a/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-checks-oracle.ts b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-checks-oracle.ts new file mode 100644 index 0000000000..2a8cd0e2d0 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-checks-oracle.ts @@ -0,0 +1,413 @@ +/** + * The checks of the per-collection coordinator oracle in + * per-collection-coordinator-oracle.test.ts: subset wire and acknowledgement + * checks, the remote lease takeover model and checks, the raw SQLite-state and + * fresh-registry checks, the collection-route checks, and the write-owner + * check. Owns nothing and registers no tests: the oracle drives production and + * applies them, and per-collection-coordinator-calibration.test.ts calibrates + * them against hand-built wrong answers. + */ +import { + semanticValue, + subsetSemantics, +} from './per-collection-coordinator-support' +import type { TransportedLoadSubsetOptions } from '@tanstack/db-sqlite-persistence-core' +import type { LoadSubsetOptions } from '@tanstack/db' +import type { + CollectionName, + SemanticValue, +} from './per-collection-coordinator-support' + +export type SubsetAckTrace = { + ownerInvocation: `coordinator` | `fixture-fallback` + decoded: SemanticValue | undefined + expected: SemanticValue + prematureResponses: number + acknowledgedBeforeResponseDelivery: boolean + ownerCompletedBeforeResponse: boolean + acknowledgedAfterResponseDelivery: boolean +} + +export type SubsetWireTrace = { + outcome: { ok: true } | { ok: false; errorName: string } | undefined + decoded: ReadonlyArray + expected: SemanticValue +} + +// Clone-safe projection may remove live ownership callbacks. It must preserve +// every supported request-data value that determines subset semantics. +export function subsetWireViolations(trace: SubsetWireTrace): Array { + const violations: Array = [] + if (trace.outcome?.ok !== true) violations.push(`subset-transport-failed`) + if ( + trace.decoded.length !== 1 || + JSON.stringify(trace.decoded[0]) !== JSON.stringify(trace.expected) + ) { + violations.push(`subset-semantics-mismatch`) + } + return violations +} + +// A follower acknowledgement is valid only after the registered leader owner +// has accepted and completed the exact remote subset acquisition attempt. +export function subsetAckViolations(trace: SubsetAckTrace): Array { + const violations: Array = [] + if (trace.ownerInvocation !== `coordinator`) { + violations.push(`leader-owner-not-invoked-by-coordinator`) + } + if (JSON.stringify(trace.decoded) !== JSON.stringify(trace.expected)) { + violations.push(`decoded-subset-mismatch`) + } + if (trace.prematureResponses !== 0) { + violations.push(`response-before-owner-completion`) + } + if (trace.acknowledgedBeforeResponseDelivery) { + violations.push(`ack-before-response-delivery`) + } + if (!trace.ownerCompletedBeforeResponse) { + violations.push(`response-before-owner-completion-checkpoint`) + } + if (!trace.acknowledgedAfterResponseDelivery) { + violations.push(`missing-ack-after-response`) + } + return violations +} + +export type RemoteLeaseEvent = { + phase: `load` | `unload` + token: number + semantics: SemanticValue + exactLoadedObject?: boolean +} + +export class RemoteLeaseOwnerLedger { + private nextToken = 0 + private readonly tokens = new WeakMap() + private readonly loaded = new WeakSet() + readonly active = new Set() + readonly events: Array = [] + + private token(options: TransportedLoadSubsetOptions): number { + let token = this.tokens.get(options) + if (token === undefined) { + token = ++this.nextToken + this.tokens.set(options, token) + } + return token + } + + readonly loadSubset = async ( + options: TransportedLoadSubsetOptions, + ): Promise => { + const token = this.token(options) + this.loaded.add(options) + this.active.add(options) + this.events.push({ + phase: `load`, + token, + semantics: subsetSemantics(options as unknown as LoadSubsetOptions), + }) + } + + readonly unloadSubset = (options: TransportedLoadSubsetOptions): void => { + const token = this.token(options) + const exactLoadedObject = this.loaded.has(options) + this.active.delete(options) + this.events.push({ + phase: `unload`, + token, + semantics: subsetSemantics(options as unknown as LoadSubsetOptions), + exactLoadedObject, + }) + } + + /** Callable for today's owner boundary; properties are the crash-only owner. */ + readonly boundary = Object.assign( + (options: TransportedLoadSubsetOptions) => this.loadSubset(options), + { + loadSubset: this.loadSubset, + unloadSubset: this.unloadSubset, + onError: () => {}, + }, + ) + + snapshot() { + return { + loads: this.events.filter(({ phase }) => phase === `load`).length, + unloads: this.events.filter(({ phase }) => phase === `unload`).length, + active: this.active.size, + exactUnloads: this.events + .filter( + (event): event is RemoteLeaseEvent & { phase: `unload` } => + event.phase === `unload`, + ) + .every(({ exactLoadedObject }) => exactLoadedObject === true), + } + } +} + +export type RemoteLeaseTakeoverTrace = { + initialOwnerAfterAcquire: ReturnType + retiredOwnerAfterTakeover: ReturnType + newOwnerAfterTakeover: ReturnType + newOwnerAfterRelease: ReturnType +} + +export type RemoteLeaseTakeoverViolation = { + checkpoint: string + field: keyof ReturnType + expected: number | boolean + actual: number | boolean +} + +export const expectedRemoteLeaseTakeoverTrace: RemoteLeaseTakeoverTrace = { + initialOwnerAfterAcquire: { + loads: 1, + unloads: 0, + active: 1, + exactUnloads: true, + }, + retiredOwnerAfterTakeover: { + loads: 1, + unloads: 1, + active: 0, + exactUnloads: true, + }, + newOwnerAfterTakeover: { + loads: 1, + unloads: 0, + active: 1, + exactUnloads: true, + }, + newOwnerAfterRelease: { + loads: 1, + unloads: 1, + active: 0, + exactUnloads: true, + }, +} + +export function remoteLeaseTakeoverViolations( + actual: RemoteLeaseTakeoverTrace, +): Array { + const violations: Array = [] + for (const checkpoint of Object.keys( + expectedRemoteLeaseTakeoverTrace, + ) as Array) { + const expected = expectedRemoteLeaseTakeoverTrace[checkpoint] + for (const field of Object.keys(expected) as Array) { + if (actual[checkpoint][field] !== expected[field]) { + violations.push({ + checkpoint, + field, + expected: expected[field], + actual: actual[checkpoint][field], + }) + } + } + } + return violations +} + +export type CollectionScopedLeaseSnapshot = Record< + `alpha` | `beta`, + { loads: number; unloads: number; active: number } +> + +export function collectionScopedLeaseViolations( + actual: CollectionScopedLeaseSnapshot, + expected: CollectionScopedLeaseSnapshot, +): Array { + const violations: Array = [] + for (const collectionId of [`alpha`, `beta`] as const) { + for (const field of [`loads`, `unloads`, `active`] as const) { + if (actual[collectionId][field] !== expected[collectionId][field]) { + violations.push(`${collectionId}:${field}`) + } + } + } + return violations +} + +export type RawCollectionSnapshot = { + registryRowCount: number + schemaVersion: number + resetEpoch: number + rowCount: number +} + +export type RawSnapshotViolation = { + field: keyof RawCollectionSnapshot + expected: number + actual: number +} + +export function rawSnapshotViolations( + actual: RawCollectionSnapshot, + expected: RawCollectionSnapshot, +): Array { + return ( + [`registryRowCount`, `schemaVersion`, `resetEpoch`, `rowCount`] as const + ).flatMap((field) => + actual[field] === expected[field] + ? [] + : [{ field, expected: expected[field], actual: actual[field] }], + ) +} + +export type FreshInitOutcome = + { status: `fulfilled` } | { status: `rejected`; error: string } + +export type FreshInitViolation = + | { kind: `missing-outcome`; index: number } + | { kind: `unexpected-outcome`; index: number } + | { kind: `rejected`; index: number; error: string } + +export function freshInitViolations( + outcomes: ReadonlyArray, +): Array { + const violations: Array = [] + for (let index = 0; index < 2; index++) { + const outcome = outcomes[index] + if (!outcome) { + violations.push({ kind: `missing-outcome`, index }) + } else if (outcome.status === `rejected`) { + violations.push({ kind: `rejected`, index, error: outcome.error }) + } + } + for (let index = 2; index < outcomes.length; index++) { + violations.push({ kind: `unexpected-outcome`, index }) + } + return violations +} + +export type RoutedApply = { + phase: `initial-owner` | `after-takeover` + collectionId: CollectionName + adapterId: string +} + +export type RouteViolation = { + kind: `missing-apply` | `duplicate-apply` | `misrouted-apply` + checkpoint: RoutedApply[`phase`] + collectionId: CollectionName + expectedAdapterId: string + actualAdapterIds: Array +} + +export type CoordinatorRouteObservation = { + phase: RoutedApply[`phase`] + operation: `remote-subset` | `index` | `pull` + collectionId: CollectionName + calls: Array<{ + adapterId: string + collectionId: string + argument?: SemanticValue + }> + result: SemanticValue +} + +export type CoordinatorRouteViolation = { + operation: CoordinatorRouteObservation[`operation`] + checkpoint: CoordinatorRouteObservation[`phase`] + collectionId: CollectionName + field: `calls` | `result` + expected: SemanticValue + actual: SemanticValue +} + +export function coordinatorRouteViolations( + observed: ReadonlyArray, + expected: ReadonlyArray, +): Array { + const violations: Array = [] + for (const expectedCell of expected) { + const actualCell = observed.find( + ({ phase, operation, collectionId }) => + phase === expectedCell.phase && + operation === expectedCell.operation && + collectionId === expectedCell.collectionId, + ) + for (const field of [`calls`, `result`] as const) { + const expectedValue = semanticValue(expectedCell[field]) + const actualValue = semanticValue(actualCell?.[field] ?? []) + if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { + violations.push({ + operation: expectedCell.operation, + checkpoint: expectedCell.phase, + collectionId: expectedCell.collectionId, + field, + expected: expectedValue, + actual: actualValue, + }) + } + } + } + return violations +} + +export function routeViolationDiscriminant(violation: RouteViolation): string { + return `${violation.kind}:${violation.checkpoint}:${violation.collectionId}` +} + +export function routeViolations( + observed: ReadonlyArray, + expected: ReadonlyArray, +): Array { + const violations: Array = [] + for (const expectedRoute of expected) { + const actualAdapterIds = observed + .filter( + ({ phase, collectionId }) => + phase === expectedRoute.phase && + collectionId === expectedRoute.collectionId, + ) + .map(({ adapterId }) => adapterId) + if (actualAdapterIds.length === 0) { + violations.push({ + kind: `missing-apply`, + checkpoint: expectedRoute.phase, + collectionId: expectedRoute.collectionId, + expectedAdapterId: expectedRoute.adapterId, + actualAdapterIds, + }) + continue + } + if (actualAdapterIds.length !== 1) { + violations.push({ + kind: `duplicate-apply`, + checkpoint: expectedRoute.phase, + collectionId: expectedRoute.collectionId, + expectedAdapterId: expectedRoute.adapterId, + actualAdapterIds, + }) + continue + } + if (actualAdapterIds[0] !== expectedRoute.adapterId) { + violations.push({ + kind: `misrouted-apply`, + checkpoint: expectedRoute.phase, + collectionId: expectedRoute.collectionId, + expectedAdapterId: expectedRoute.adapterId, + actualAdapterIds, + }) + } + } + return violations +} + +export type OwnershipObservation = { + collectionId: string + ownerAtCall?: string +} + +// A source commit may apply only while the elected persistence owner holds the +// writer boundary. Collection visibility alone does not establish ownership. +export function findUnownedWrites( + observations: ReadonlyArray, + electedOwner: string, +): Array { + return observations.filter( + (observation) => observation.ownerAtCall !== electedOwner, + ) +} 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 8e72001660..2f2f3482e1 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 @@ -11,15 +11,18 @@ import { } from '@tanstack/db' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { + forwardAssertionJudgment, oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, rejectAt, } from '../../db/tests/oracle-assertion-adapter' +import { oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' import { createBrowserWASQLitePersistence, persistedCollectionOptions, } from '../src' -import { oracleRoute } from '../../db/tests/oracle-config' -import { declareOracle } from '../../db/tests/oracle-declaration' import { browserReplayAlias, registerBrowserProperty, @@ -56,12 +59,35 @@ import { withFailurePreservingCleanup, withNativeBrowserTransport, } from './per-collection-coordinator-support' +import { + RemoteLeaseOwnerLedger, + collectionScopedLeaseViolations, + coordinatorRouteViolations, + findUnownedWrites, + freshInitViolations, + rawSnapshotViolations, + remoteLeaseTakeoverViolations, + routeViolationDiscriminant, + routeViolations, + subsetAckViolations, + subsetWireViolations, +} from './per-collection-coordinator-checks-oracle' +import type { + CollectionScopedLeaseSnapshot, + CoordinatorRouteObservation, + FreshInitOutcome, + RawCollectionSnapshot, + RemoteLeaseTakeoverTrace, + RemoteLeaseTakeoverViolation, + RouteViolation, + RoutedApply, + SubsetAckTrace, +} from './per-collection-coordinator-checks-oracle' import type { BrowserCollectionCoordinator } from '../src/browser-coordinator' import type { PersistedCollectionCoordinator, PersistedMutationEnvelope, PersistenceAdapter, - TransportedLoadSubsetOptions, } from '@tanstack/db-sqlite-persistence-core' import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' import type { BrowserWASQLiteDatabase } from '../src' @@ -83,6 +109,17 @@ import type { const FIXED_SEED = 165902 const { runs: RUNS, replay: REPLAY } = readCoordinatorOracleConfig() + +// Each fixed history below runs its unchanged driver, which applies this +// file's checks and models; the adapter transports that judgment. The checks +// and the lease model live in per-collection-coordinator-checks-oracle.ts, and +// their calibrations, which run no production, in +// per-collection-coordinator-calibration.test.ts. +const coordinatorLaw = { + statement: `The browser coordinator keeps subset transport, acknowledgement, acquisition leases, SQLite state, adapter routing, and write ownership per collection, before and after leadership changes, as this file's checks and models require.`, + authority: `RFC #1659 Workstream 2, for BrowserCollectionCoordinator, browser persistence resolution, and the persisted sync wrapper.`, +} +const coordinatorJudgment = `per-collection coordinator` browserReplayAlias( REPLAY && { property: 'browser-coordinator.' + REPLAY.property, @@ -226,61 +263,6 @@ afterEach(async () => { expect.soft(cleanupDiagnostics).toEqual([]) }) -type SubsetAckTrace = { - ownerInvocation: `coordinator` | `fixture-fallback` - decoded: SemanticValue | undefined - expected: SemanticValue - prematureResponses: number - acknowledgedBeforeResponseDelivery: boolean - ownerCompletedBeforeResponse: boolean - acknowledgedAfterResponseDelivery: boolean -} - -type SubsetWireTrace = { - outcome: { ok: true } | { ok: false; errorName: string } | undefined - decoded: ReadonlyArray - expected: SemanticValue -} - -// Clone-safe projection may remove live ownership callbacks. It must preserve -// every supported request-data value that determines subset semantics. -function subsetWireViolations(trace: SubsetWireTrace): Array { - const violations: Array = [] - if (trace.outcome?.ok !== true) violations.push(`subset-transport-failed`) - if ( - trace.decoded.length !== 1 || - JSON.stringify(trace.decoded[0]) !== JSON.stringify(trace.expected) - ) { - violations.push(`subset-semantics-mismatch`) - } - return violations -} - -// A follower acknowledgement is valid only after the registered leader owner -// has accepted and completed the exact remote subset acquisition attempt. -function subsetAckViolations(trace: SubsetAckTrace): Array { - const violations: Array = [] - if (trace.ownerInvocation !== `coordinator`) { - violations.push(`leader-owner-not-invoked-by-coordinator`) - } - if (JSON.stringify(trace.decoded) !== JSON.stringify(trace.expected)) { - violations.push(`decoded-subset-mismatch`) - } - if (trace.prematureResponses !== 0) { - violations.push(`response-before-owner-completion`) - } - if (trace.acknowledgedBeforeResponseDelivery) { - violations.push(`ack-before-response-delivery`) - } - if (!trace.ownerCompletedBeforeResponse) { - violations.push(`response-before-owner-completion-checkpoint`) - } - if (!trace.acknowledgedAfterResponseDelivery) { - violations.push(`missing-ack-after-response`) - } - return violations -} - async function observeOwnerEntryOrResponse( ownerEntered: () => boolean, ): Promise<`owner-entered` | `response-queued` | `no-progress`> { @@ -299,8 +281,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`) + async function runCloneSafeSubsetWitness(): Promise { const decoded: Array = [] const leaderAdapter = createRecordingAdapter({ id: `leader`, @@ -391,73 +372,29 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { }, ], ]) + } + oracleProperty({ + id: `browser-coordinator.subset-wire`, + law: { + ...coordinatorLaw, + limits: `The callback-bearing subset witness, posted from a follower over the controlled BroadcastChannel.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `posts the callback-bearing subset witness as clone-safe wire data`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runCloneSafeSubsetWitness(), + ), + check: forwardAssertionJudgment, }) - it(`calibrates the clone checker against safe and function-valued payloads`, () => { - oracleRoute(`calibration`) - const safe = { - collectionId: `alpha`, - where: { type: `eq`, field: `group`, value: `kept` }, - limit: 2, - } - const unsafe = { ...safe, callback: () => {} } - - expect(structuredClone(safe)).toEqual(safe) - expect(() => structuredClone(unsafe)).toThrowError( - expect.objectContaining({ name: `DataCloneError` }), - ) - - const expected = subsetSemantics(complexSubsetDemand(false)) - const allowed: SubsetWireTrace = { - outcome: { ok: true }, - decoded: [expected], - expected, - } - expect(subsetWireViolations(allowed)).toEqual([]) - expect( - subsetWireViolations({ - ...allowed, - outcome: { ok: false, errorName: `DataCloneError` }, - }), - ).toContain(`subset-transport-failed`) - expect( - subsetWireViolations({ ...allowed, decoded: [semanticValue({})] }), - ).toContain(`subset-semantics-mismatch`) - }) - - it(`calibrates acknowledgement checks against entry-only and false-success faults`, () => { - oracleRoute(`calibration`) - const expected = subsetSemantics(complexSubsetDemand(false)) - const allowed: SubsetAckTrace = { - ownerInvocation: `coordinator`, - decoded: expected, - expected, - prematureResponses: 0, - acknowledgedBeforeResponseDelivery: false, - ownerCompletedBeforeResponse: true, - acknowledgedAfterResponseDelivery: true, - } - expect(subsetAckViolations(allowed)).toEqual([]) - expect( - subsetAckViolations({ - ...allowed, - ownerCompletedBeforeResponse: false, - }), - ).toContain(`response-before-owner-completion-checkpoint`) - expect( - subsetAckViolations({ - ...allowed, - ownerInvocation: `fixture-fallback`, - prematureResponses: 1, - }), - ).toEqual([ - `leader-owner-not-invoked-by-coordinator`, - `response-before-owner-completion`, - ]) - }) - - it(`acknowledges a follower subset only after the leader source completed it`, async () => { - oracleRoute(`fixed history`) + async function runFollowerSubsetAcknowledgement(): Promise { const upstreamEvents: Array<{ phase: `entered` | `completed` semantics: SemanticValue @@ -656,164 +593,28 @@ describe(`per-collection coordinator wire and acknowledgement oracle`, () => { }, ], ]) - }) -}) - -type RemoteLeaseEvent = { - phase: `load` | `unload` - token: number - semantics: SemanticValue - exactLoadedObject?: boolean -} - -class RemoteLeaseOwnerLedger { - private nextToken = 0 - private readonly tokens = new WeakMap() - private readonly loaded = new WeakSet() - readonly active = new Set() - readonly events: Array = [] - - private token(options: TransportedLoadSubsetOptions): number { - let token = this.tokens.get(options) - if (token === undefined) { - token = ++this.nextToken - this.tokens.set(options, token) - } - return token - } - - readonly loadSubset = async ( - options: TransportedLoadSubsetOptions, - ): Promise => { - const token = this.token(options) - this.loaded.add(options) - this.active.add(options) - this.events.push({ - phase: `load`, - token, - semantics: subsetSemantics(options as unknown as LoadSubsetOptions), - }) } - - readonly unloadSubset = (options: TransportedLoadSubsetOptions): void => { - const token = this.token(options) - const exactLoadedObject = this.loaded.has(options) - this.active.delete(options) - this.events.push({ - phase: `unload`, - token, - semantics: subsetSemantics(options as unknown as LoadSubsetOptions), - exactLoadedObject, - }) - } - - /** Callable for today's owner boundary; properties are the crash-only owner. */ - readonly boundary = Object.assign( - (options: TransportedLoadSubsetOptions) => this.loadSubset(options), - { - loadSubset: this.loadSubset, - unloadSubset: this.unloadSubset, - onError: () => {}, + oracleProperty({ + id: `browser-coordinator.subset-ack`, + law: { + ...coordinatorLaw, + limits: `One follower subset whose leader source completion is held, observed at the owner-entry and response checkpoints.`, }, - ) - - snapshot() { - return { - loads: this.events.filter(({ phase }) => phase === `load`).length, - unloads: this.events.filter(({ phase }) => phase === `unload`).length, - active: this.active.size, - exactUnloads: this.events - .filter( - (event): event is RemoteLeaseEvent & { phase: `unload` } => - event.phase === `unload`, - ) - .every(({ exactLoadedObject }) => exactLoadedObject === true), - } - } -} - -type RemoteLeaseTakeoverTrace = { - initialOwnerAfterAcquire: ReturnType - retiredOwnerAfterTakeover: ReturnType - newOwnerAfterTakeover: ReturnType - newOwnerAfterRelease: ReturnType -} - -type RemoteLeaseTakeoverViolation = { - checkpoint: string - field: keyof ReturnType - expected: number | boolean - actual: number | boolean -} - -const expectedRemoteLeaseTakeoverTrace: RemoteLeaseTakeoverTrace = { - initialOwnerAfterAcquire: { - loads: 1, - unloads: 0, - active: 1, - exactUnloads: true, - }, - retiredOwnerAfterTakeover: { - loads: 1, - unloads: 1, - active: 0, - exactUnloads: true, - }, - newOwnerAfterTakeover: { - loads: 1, - unloads: 0, - active: 1, - exactUnloads: true, - }, - newOwnerAfterRelease: { - loads: 1, - unloads: 1, - active: 0, - exactUnloads: true, - }, -} - -function remoteLeaseTakeoverViolations( - actual: RemoteLeaseTakeoverTrace, -): Array { - const violations: Array = [] - for (const checkpoint of Object.keys( - expectedRemoteLeaseTakeoverTrace, - ) as Array) { - const expected = expectedRemoteLeaseTakeoverTrace[checkpoint] - for (const field of Object.keys(expected) as Array) { - if (actual[checkpoint][field] !== expected[field]) { - violations.push({ - checkpoint, - field, - expected: expected[field], - actual: actual[checkpoint][field], - }) - } - } - } - return violations -} - -type CollectionScopedLeaseSnapshot = Record< - `alpha` | `beta`, - { loads: number; unloads: number; active: number } -> - -function collectionScopedLeaseViolations( - actual: CollectionScopedLeaseSnapshot, - expected: CollectionScopedLeaseSnapshot, -): Array { - const violations: Array = [] - for (const collectionId of [`alpha`, `beta`] as const) { - for (const field of [`loads`, `unloads`, `active`] as const) { - if (actual[collectionId][field] !== expected[collectionId][field]) { - violations.push(`${collectionId}:${field}`) - } - } - } - return violations -} + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `acknowledges a follower subset only after the leader source completed it`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runFollowerSubsetAcknowledgement(), + ), + check: forwardAssertionJudgment, + }) +}) type RemoteLeaseHistoryTrace = { firstAfterAcquire: ReturnType @@ -899,26 +700,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 }, - } - expect(collectionScopedLeaseViolations(expected, expected)).toEqual([]) - expect( - collectionScopedLeaseViolations( - { - alpha: { loads: 2, unloads: 0, active: 2 }, - beta: { loads: 1, unloads: 0, active: 1 }, - }, - expected, - ), - ).toEqual([`alpha:loads`, `alpha:active`]) - }) - - it(`keeps reused option identities independent per collection across bounded histories`, async () => { - oracleRoute(`fixed history`) + async function runReusedOptionIdentities(): Promise { type Action = { kind: `ensure` | `release` collectionId: `alpha` | `beta` @@ -1057,10 +839,29 @@ describe(`remote subset ownership lease oracle`, () => { ], ]) } + } + oracleProperty({ + id: `browser-coordinator.collection-scoped-lease`, + law: { + ...coordinatorLaw, + limits: `The bounded reused-option-identity histories across two collections.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `keeps reused option identities independent per collection across bounded histories`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runReusedOptionIdentities(), + ), + check: forwardAssertionJudgment, }) - it(`retains the outer Collection acquisition across leader takeover and releases both owner acquisition leases exactly`, async () => { - oracleRoute(`fixed history`) + async function runRemoteLeaseTakeover(): Promise { type Todo = { id: string; title: string } const firstOwner = new RemoteLeaseOwnerLedger() const secondOwner = new RemoteLeaseOwnerLedger() @@ -1190,40 +991,26 @@ 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 mutant: RemoteLeaseTakeoverTrace = structuredClone( - expectedRemoteLeaseTakeoverTrace, - ) - mutant.retiredOwnerAfterTakeover = { - loads: 1, - unloads: 0, - active: 1, - exactUnloads: true, - } - mutant.newOwnerAfterTakeover = { - loads: 0, - unloads: 0, - active: 0, - exactUnloads: true, - } - mutant.newOwnerAfterRelease.exactUnloads = false - expect( - remoteLeaseTakeoverViolations(mutant).map( - ({ checkpoint, field }) => `${checkpoint}:${field}`, + } + oracleProperty({ + id: `browser-coordinator.remote-lease-takeover`, + law: { + ...coordinatorLaw, + limits: `One leader takeover with a surviving outer Collection acquisition.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `retains the outer Collection acquisition across leader takeover and releases both owner acquisition leases exactly`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runRemoteLeaseTakeover(), ), - ).toEqual([ - `retiredOwnerAfterTakeover:unloads`, - `retiredOwnerAfterTakeover:active`, - `newOwnerAfterTakeover:loads`, - `newOwnerAfterTakeover:active`, - `newOwnerAfterRelease:exactUnloads`, - ]) + check: forwardAssertionJudgment, }) function createRemoteLeaseJudgment() { @@ -1455,6 +1242,7 @@ describe(`remote subset ownership lease oracle`, () => { remoteLeaseHistoryArbitrary, { id: 'browser-coordinator.remote-lease', + owns: [`src/browser-coordinator.ts`], runs: RUNS, fixedSeed: FIXED_SEED, examples: [ @@ -1476,58 +1264,6 @@ describe(`remote subset ownership lease oracle`, () => { } }) -type RawCollectionSnapshot = { - registryRowCount: number - schemaVersion: number - resetEpoch: number - rowCount: number -} - -type RawSnapshotViolation = { - field: keyof RawCollectionSnapshot - expected: number - actual: number -} - -function rawSnapshotViolations( - actual: RawCollectionSnapshot, - expected: RawCollectionSnapshot, -): Array { - return ( - [`registryRowCount`, `schemaVersion`, `resetEpoch`, `rowCount`] as const - ).flatMap((field) => - actual[field] === expected[field] - ? [] - : [{ field, expected: expected[field], actual: actual[field] }], - ) -} - -type FreshInitOutcome = - { status: `fulfilled` } | { status: `rejected`; error: string } - -type FreshInitViolation = - | { kind: `missing-outcome`; index: number } - | { kind: `unexpected-outcome`; index: number } - | { kind: `rejected`; index: number; error: string } - -function freshInitViolations( - outcomes: ReadonlyArray, -): Array { - const violations: Array = [] - for (let index = 0; index < 2; index++) { - const outcome = outcomes[index] - if (!outcome) { - violations.push({ kind: `missing-outcome`, index }) - } else if (outcome.status === `rejected`) { - violations.push({ kind: `rejected`, index, error: outcome.error }) - } - } - for (let index = 2; index < outcomes.length; index++) { - violations.push({ kind: `unexpected-outcome`, index }) - } - return violations -} - async function readRawCollectionSnapshot( database: BrowserWASQLiteDatabase, collectionId: string, @@ -1558,8 +1294,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`) + async function runSecondAdapterRegistration(): Promise { let directory: string | undefined let database: BrowserWASQLiteDatabase | undefined let coordinator: BrowserCollectionCoordinator | undefined @@ -1652,10 +1387,29 @@ describe(`per-collection adapter and SQLite-state oracle`, () => { }, ], ]) + } + oracleProperty({ + id: `browser-coordinator.adapter-isolation`, + law: { + ...coordinatorLaw, + limits: `Two collections in one wa-sqlite database, the second registering a different adapter.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `preserves alpha rows, schema, and reset epoch when beta registers a different adapter`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runSecondAdapterRegistration(), + ), + check: forwardAssertionJudgment, }) - it(`initializes one fresh collection safely across matching adapter instances`, async () => { - oracleRoute(`fixed history`) + async function runFreshCollectionInit(): Promise { let directory: string | undefined let database: BrowserWASQLiteDatabase | undefined @@ -1727,170 +1481,29 @@ describe(`per-collection adapter and SQLite-state oracle`, () => { }, ], ]) - }) - - it(`calibrates SQLite-state and fresh-registry checks against mutant results`, () => { - oracleRoute(`calibration`) - const expected: RawCollectionSnapshot = { - registryRowCount: 1, - schemaVersion: 11, - resetEpoch: 0, - rowCount: 1, - } - expect(rawSnapshotViolations(expected, expected)).toEqual([]) - expect( - rawSnapshotViolations( - { - registryRowCount: 0, - schemaVersion: 22, - resetEpoch: 1, - rowCount: 0, - }, - expected, - ).map(({ field }) => field), - ).toEqual([`registryRowCount`, `schemaVersion`, `resetEpoch`, `rowCount`]) - - const allowed: Array = [ - { status: `fulfilled` }, - { status: `fulfilled` }, - ] - expect(freshInitViolations(allowed)).toEqual([]) - expect( - freshInitViolations([ - allowed[0]!, - { - status: `rejected`, - error: `UNIQUE constraint failed: collection_registry`, - }, - ]), - ).toEqual([ + } + oracleProperty({ + id: `browser-coordinator.fresh-init`, + law: { + ...coordinatorLaw, + limits: `One fresh collection initialized concurrently through matching adapter instances.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ { - kind: `rejected`, - index: 1, - error: `UNIQUE constraint failed: collection_registry`, + name: `initializes one fresh collection safely across matching adapter instances`, }, - ]) - expect(freshInitViolations([allowed[0]!])).toEqual([ - { kind: `missing-outcome`, index: 1 }, - ]) + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runFreshCollectionInit(), + ), + check: forwardAssertionJudgment, }) }) -type RoutedApply = { - phase: `initial-owner` | `after-takeover` - collectionId: CollectionName - adapterId: string -} - -type RouteViolation = { - kind: `missing-apply` | `duplicate-apply` | `misrouted-apply` - checkpoint: RoutedApply[`phase`] - collectionId: CollectionName - expectedAdapterId: string - actualAdapterIds: Array -} - -type CoordinatorRouteObservation = { - phase: RoutedApply[`phase`] - operation: `remote-subset` | `index` | `pull` - collectionId: CollectionName - calls: Array<{ - adapterId: string - collectionId: string - argument?: SemanticValue - }> - result: SemanticValue -} - -type CoordinatorRouteViolation = { - operation: CoordinatorRouteObservation[`operation`] - checkpoint: CoordinatorRouteObservation[`phase`] - collectionId: CollectionName - field: `calls` | `result` - expected: SemanticValue - actual: SemanticValue -} - -function coordinatorRouteViolations( - observed: ReadonlyArray, - expected: ReadonlyArray, -): Array { - const violations: Array = [] - for (const expectedCell of expected) { - const actualCell = observed.find( - ({ phase, operation, collectionId }) => - phase === expectedCell.phase && - operation === expectedCell.operation && - collectionId === expectedCell.collectionId, - ) - for (const field of [`calls`, `result`] as const) { - const expectedValue = semanticValue(expectedCell[field]) - const actualValue = semanticValue(actualCell?.[field] ?? []) - if (JSON.stringify(actualValue) !== JSON.stringify(expectedValue)) { - violations.push({ - operation: expectedCell.operation, - checkpoint: expectedCell.phase, - collectionId: expectedCell.collectionId, - field, - expected: expectedValue, - actual: actualValue, - }) - } - } - } - return violations -} - -function routeViolationDiscriminant(violation: RouteViolation): string { - return `${violation.kind}:${violation.checkpoint}:${violation.collectionId}` -} - -function routeViolations( - observed: ReadonlyArray, - expected: ReadonlyArray, -): Array { - const violations: Array = [] - for (const expectedRoute of expected) { - const actualAdapterIds = observed - .filter( - ({ phase, collectionId }) => - phase === expectedRoute.phase && - collectionId === expectedRoute.collectionId, - ) - .map(({ adapterId }) => adapterId) - if (actualAdapterIds.length === 0) { - violations.push({ - kind: `missing-apply`, - checkpoint: expectedRoute.phase, - collectionId: expectedRoute.collectionId, - expectedAdapterId: expectedRoute.adapterId, - actualAdapterIds, - }) - continue - } - if (actualAdapterIds.length !== 1) { - violations.push({ - kind: `duplicate-apply`, - checkpoint: expectedRoute.phase, - collectionId: expectedRoute.collectionId, - expectedAdapterId: expectedRoute.adapterId, - actualAdapterIds, - }) - continue - } - if (actualAdapterIds[0] !== expectedRoute.adapterId) { - violations.push({ - kind: `misrouted-apply`, - checkpoint: expectedRoute.phase, - collectionId: expectedRoute.collectionId, - expectedAdapterId: expectedRoute.adapterId, - actualAdapterIds, - }) - } - } - return violations -} - // Collection identity and elected ownership jointly select the adapter. The // model deliberately uses Maps instead of the coordinator's routing storage. class PerCollectionRouteOracle { @@ -2003,72 +1616,6 @@ 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`, - collectionId: `alpha`, - adapterId: `leader-alpha`, - }, - { - phase: `initial-owner`, - collectionId: `beta`, - adapterId: `leader-beta`, - }, - ] - const misrouted: Array = [ - expected[0]!, - { ...expected[1]!, adapterId: `leader-alpha` }, - ] - - expect(routeViolations(expected, expected)).toEqual([]) - const [violation] = routeViolations(misrouted, expected) - expect(violation).toEqual({ - kind: `misrouted-apply`, - checkpoint: `initial-owner`, - collectionId: `beta`, - expectedAdapterId: `leader-beta`, - actualAdapterIds: [`leader-alpha`], - }) - expect(routeViolationDiscriminant(violation!)).toBe( - `misrouted-apply:initial-owner:beta`, - ) - - const routedCell: CoordinatorRouteObservation = { - phase: `initial-owner`, - operation: `index`, - collectionId: `alpha`, - calls: [ - { - adapterId: `leader-alpha`, - collectionId: `alpha`, - argument: semanticValue({ signature: `alpha-index` }), - }, - ], - result: semanticValue({ status: `fulfilled` }), - } - expect(coordinatorRouteViolations([routedCell], [routedCell])).toEqual([]) - expect( - coordinatorRouteViolations( - [{ ...routedCell, calls: [] }], - [routedCell], - ).map(({ operation, checkpoint, collectionId, field }) => ({ - operation, - checkpoint, - collectionId, - field, - })), - ).toEqual([ - { - operation: `index`, - checkpoint: `initial-owner`, - collectionId: `alpha`, - field: `calls`, - }, - ]) - }) - it(`routes every adapter-bound RPC by collection before and after leadership transfer`, async () => { oracleRoute(`fixed history`) const makePullResult = ( @@ -2564,6 +2111,7 @@ describe(`generated collection-route histories`, () => { routingHistoryArbitrary, { id: 'browser-coordinator.routing', + owns: [`src/browser-coordinator.ts`], runs: RUNS, fixedSeed: FIXED_SEED, beforeCampaign: () => { @@ -2576,25 +2124,8 @@ describe(`generated collection-route histories`, () => { } }) -type OwnershipObservation = { - collectionId: string - ownerAtCall?: string -} - -// A source commit may apply only while the elected persistence owner holds the -// writer boundary. Collection visibility alone does not establish ownership. -function findUnownedWrites( - observations: ReadonlyArray, - electedOwner: string, -): Array { - return observations.filter( - (observation) => observation.ownerAtCall !== electedOwner, - ) -} - describe(`sync-ingested write ownership oracle`, () => { - it(`applies a source commit through the elected persistence owner after leadership changes`, async () => { - oracleRoute(`fixed history`) + async function runOwnedSourceCommit(): Promise { type Todo = { id: string; title: string } type SourceParams = Parameters[`sync`]>[0] @@ -2704,19 +2235,26 @@ describe(`sync-ingested write ownership oracle`, () => { }, ], ]) - }) - - it(`calibrates the owner checker against one bypass and one owned write`, () => { - oracleRoute(`calibration`) - expect( - findUnownedWrites( - [ - { collectionId: `alpha`, ownerAtCall: `tab-b` }, - { collectionId: `beta` }, - ], - `tab-b`, + } + oracleProperty({ + id: `browser-coordinator.write-ownership`, + law: { + ...coordinatorLaw, + limits: `One source commit after leadership changes, with every adapter write observed.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `applies a source commit through the elected persistence owner after leadership changes`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + runOwnedSourceCommit(), ), - ).toEqual([{ collectionId: `beta` }]) + check: forwardAssertionJudgment, }) }) @@ -3002,38 +2540,23 @@ describe(`follower remote-demand settlement across real tabs`, () => { for (const apply of [`ok`, `fails`] as const) for (const consumer of [`live-query`, `load`] as const) histories.push({ source, placement, apply, consumer }) - it.each(histories)( - `settles a follower $consumer with the owner's rows: $source source, $placement, apply $apply`, - (history) => { - oracleRoute(`case`) - return assertRealTabFollowerHistory(history) + oracleProperty({ + id: `browser-coordinator.real-tab-follower`, + law: { + ...coordinatorLaw, + limits: `The 16 source, placement, apply, and consumer histories across real tabs.`, }, - 20_000, - ) -}) - -// The parts this file defines. The checks compare production observations -// with the expectations that the models and grammars produce; the models are -// 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, - remoteLeaseTakeoverViolations, - collectionScopedLeaseViolations, - remoteLeaseHistoryViolations, - rawSnapshotViolations, - freshInitViolations, - coordinatorRouteViolations, - routeViolations, - findUnownedWrites, - runRealTabFollowerHistory, - ], - model: [ - RemoteLeaseOwnerLedger, - PerCollectionRouteOracle, - expectedRealTabFollower, - ], + owns: [`src/browser-coordinator.ts`], + histories, + label: ({ source, placement, apply, consumer }) => + `settles a follower ${consumer} with the owner's rows: ${source} source, ${placement}, apply ${apply}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, () => + assertRealTabFollowerHistory(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 20_000, + }) }) diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 74fc08e87a..2f14e43076 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -34,6 +34,14 @@ export const properties: ReadonlyArray = [ `sqlite-ordinary.idempotent-position`, `sqlite-ordinary.late-bookkeeping-fault`, `sqlite-ordinary.later-row-write-fault`, + `browser-coordinator.subset-wire`, + `browser-coordinator.subset-ack`, + `browser-coordinator.collection-scoped-lease`, + `browser-coordinator.remote-lease-takeover`, + `browser-coordinator.adapter-isolation`, + `browser-coordinator.fresh-init`, + `browser-coordinator.write-ownership`, + `browser-coordinator.real-tab-follower`, ] export const owners: ReadonlyArray = [ @@ -78,4 +86,9 @@ export const owners: ReadonlyArray = [ `native-values write-history-fixed independent-work-fixed fixed-cases metadata-discarding-writer low-call-replacement idempotent-position late-bookkeeping-fault later-row-write-fault`, `db-sqlite-persistence-core/tests/ordinary-transaction-work-oracle.ts`, ], + [ + `browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts`, + `browser-coordinator`, + `subset-wire subset-ack collection-scoped-lease remote-lease-takeover adapter-isolation fresh-init write-ownership real-tab-follower`, + ], ] From 47c777dcfd489b2751cd0df6af77716b5bd2a498 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:35:05 -0700 Subject: [PATCH 089/116] test(db): fix the type inference of converted calibrations and move the outer-join key grammar beside its model Co-authored-by: Isaac --- .../includes-collection-oracle.property.test.ts | 5 ++++- .../includes-context-transport-model-oracle.ts | 14 ++++++++++++++ .../includes-context-transport-oracle.test.ts | 15 +-------------- .../query/virtual-row-fields-types-oracle.test.ts | 5 ++++- 4 files changed, 23 insertions(+), 16 deletions(-) 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 7ab4043564..7cc3ac5333 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -1347,7 +1347,10 @@ describe(`Collection-valued includes oracle`, () => { // the corrupted observation keeps the expected rows and publications, and // only an AssertionError from the shared comparison counts as rejecting // it. Any other outcome fails this calibration, as it failed the old test. - driver: (plan, observe) => + driver: ( + plan: { name: string }, + observe: (checkpoint: string, value: unknown) => void, + ) => recordAssertionJudgment(rootEventObservationLaw, plan, observe, () => observeChildValueUpdate((observed, expected) => { const bad = structuredClone(observed) diff --git a/packages/db/tests/query/includes-context-transport-model-oracle.ts b/packages/db/tests/query/includes-context-transport-model-oracle.ts index 4e2096007c..ee9f9eced6 100644 --- a/packages/db/tests/query/includes-context-transport-model-oracle.ts +++ b/packages/db/tests/query/includes-context-transport-model-oracle.ts @@ -349,6 +349,20 @@ export function checkMaterializedInputRows( expect(actual, checkpoint).toEqual(expected) } +export const outerJoinKeyGrammar = ( + [`inner`, `left`, `right`, `full`] as const +).flatMap((join) => + [false, true].flatMap((mainRef) => + [false, true].flatMap((joinedRef) => + ([`numbers`, `strings`, `mixed`] as const).map((keys) => ({ + join, + mainRef, + joinedRef, + keys, + })), + ), + ), +) export type OuterKeyRow = { id: number; link: number; parentGroup: number } export function expectedOuterJoinKeyRows( mains: ReadonlyArray, 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 ee5ce01a3a..54416bdb58 100644 --- a/packages/db/tests/query/includes-context-transport-oracle.test.ts +++ b/packages/db/tests/query/includes-context-transport-oracle.test.ts @@ -41,6 +41,7 @@ import { grammarCells, materializedInputCells, materializedInputGrammar, + outerJoinKeyGrammar, } from './includes-context-transport-model-oracle.js' import { createControlledCollection } from './includes-oracle-helpers.js' import { @@ -2647,20 +2648,6 @@ describe(`parent route after inherited inline input materialization`, () => { * flat independent join. It judges rows and stable distinct membership, without * prescribing generated key serialization or callback timing. */ -const outerJoinKeyGrammar = ( - [`inner`, `left`, `right`, `full`] as const -).flatMap((join) => - [false, true].flatMap((mainRef) => - [false, true].flatMap((joinedRef) => - ([`numbers`, `strings`, `mixed`] as const).map((keys) => ({ - join, - mainRef, - joinedRef, - keys, - })), - ), - ), -) // The former case body, unchanged, for one join, reference, and key cell. async function judgeOuterJoinKeyCell({ join, 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 1f7110bd82..cb61d2f327 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 @@ -1107,7 +1107,10 @@ oracleProperty({ // whose message names this fault's admission error counts as the check // rejecting the illegal construction; any other outcome fails this // calibration, as it failed the old test. - driver: (plan, observe) => + driver: ( + plan: { consumer: ReturnType }, + observe: (checkpoint: string, value: unknown) => void, + ) => recordAssertionJudgment(admissionLaw, plan, observe, async () => { const { consumer } = plan const wrong = From 82e783aa431b4407cf9624d612cab83c9daaf79f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:31:35 -0700 Subject: [PATCH 090/116] test(db): register includes work-counter fixed histories and calibrations through oracleProperty The transient top-K controls become native calibrations, one per replay. Move the window grammar census and the payload checker calibration to the ordinary work-counter tests, with the window grammar, its run budget, and the payload model and check in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 10 + .../includes-work-counter-model-oracle.ts | 177 +++++ .../includes-work-counter-oracle.test.ts | 672 ++++++++---------- .../tests/query/includes-work-counter.test.ts | 75 ++ 4 files changed, 554 insertions(+), 380 deletions(-) create mode 100644 packages/db/tests/query/includes-work-counter-model-oracle.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 419c24b699..7a78ee15ac 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -88,6 +88,11 @@ export const properties: ReadonlyArray = [ `includes-alias-shadowing.captured-join-operands`, `includes-alias-shadowing.grouped-ancestor-references`, `includes-alias-shadowing.joined-subquery-ancestor`, + `includes-work.correlated-links-fixed`, + `includes-work.join-targets-fixed`, + `includes-work.join-free-fixed`, + `includes-work.ordered-window-fixed`, + `includes-work.transient-top-k`, ] export const owners: ReadonlyArray = [ @@ -171,4 +176,9 @@ export const owners: ReadonlyArray = [ `includes-alias-shadowing`, `captured-scope two-include-positions shared-sibling-builder fresh-inner-queryref local-equality-shadow grandparent-capture outer-filter-roles captured-projection-pushdown same-path-group-keys nested-filter-group-keys redundant-wrappers lazy-join-demand recursive-join-bindings nested-source-placement outer-join-absence chained-join-absence joined-queryref-lazy-targets mixed-outer-join-chains captured-join-operands grouped-ancestor-references joined-subquery-ancestor`, ], + [ + `db/tests/query/includes-work-counter-oracle.test.ts`, + `includes-work`, + `correlated-links-fixed join-targets-fixed join-free-fixed ordered-window-fixed transient-top-k`, + ], ] diff --git a/packages/db/tests/query/includes-work-counter-model-oracle.ts b/packages/db/tests/query/includes-work-counter-model-oracle.ts new file mode 100644 index 0000000000..4f48bcc4a3 --- /dev/null +++ b/packages/db/tests/query/includes-work-counter-model-oracle.ts @@ -0,0 +1,177 @@ +import { fc } from '@fast-check/vitest' +import { expect } from 'vitest' +import { oracleAssertion } from '../oracle-assertion-adapter.js' + +// The parent-window history grammar and its run budget, and the +// projection-payload model and value check of the includes work-counter +// oracle, moved here unchanged from includes-work-counter-oracle.test.ts. The +// oracle drives production through them; the ordinary grammar census and +// payload checker calibration in includes-work-counter.test.ts import them +// without registering the oracle's tests again. This module registers no +// tests. + +export type WindowParent = { id: string; rank: number; group: string } +export type WindowChild = { id: string; group: string; n: number } +export type WindowGrandchild = { id: string; childId: string } +export type ChildForm = + | `materialize` + | `collection` + | `ordered` + | `limited` + | `findOne` + | `nested` + | `limited-nested` +export type ParentForm = `page` | `findOne` | `orderOnly` + +export type WindowCommand = + | { type: `insert-parent`; copy: number | null } + | { type: `delete-parent`; pick: number } + | { type: `rank`; pick: number; up: boolean } + | { type: `insert-child`; pick: number } + | { type: `delete-child`; pick: number } + | { type: `move`; offset: number; limit: number } + +export type WindowCase = { + parents: number + groups: number + // Parents that copy an earlier parent's rank, and its group when `group`. + copies: Array<{ pick: number; group: boolean }> + childrenPerGroup: number + childForm: ChildForm + childSource: `local` | `on-demand` + parentForm: ParentForm + // Groups a static parent filter keeps, by index; one uses `eq`. + parentFilter: Array | null + direction: `asc` | `desc` + distinct: boolean + offset: number + limit: number + history: Array + // A replay of a known transient top-K row: the extra child rows it may load + // at a checkpoint where the parent's ordered acquisition had not completed. + overshoot?: number + // A `findOne` parent skips its `offset` when false. Omitted means true. + findOneOffset?: boolean + // A window `setWindow` requests before the live query starts. + startWindow?: { offset: number; limit: number } | null + // A static child filter on the grandchild correlation field (the child + // id), by index into the initial children; the include keeps only those. + childFilter?: Array | null +} + +export const windowCommand: fc.Arbitrary = fc.oneof( + fc.record({ + type: fc.constant(`insert-parent` as const), + copy: fc.option(fc.nat(), { nil: null }), + }), + fc.record({ type: fc.constant(`delete-parent` as const), pick: fc.nat() }), + fc.record({ + type: fc.constant(`rank` as const), + pick: fc.nat(), + up: fc.boolean(), + }), + fc.record({ type: fc.constant(`insert-child` as const), pick: fc.nat() }), + fc.record({ type: fc.constant(`delete-child` as const), pick: fc.nat() }), + fc.record({ + type: fc.constant(`move` as const), + offset: fc.integer({ min: 0, max: 20 }), + limit: fc.integer({ min: 1, max: 4 }), + }), +) + +export const windowCase: fc.Arbitrary = fc + .integer({ min: 8, max: 40 }) + .chain((parents) => + fc.record({ + parents: fc.constant(parents), + // At least half as many groups as parents, so most rows own a group. + groups: fc.integer({ min: Math.ceil(parents / 2), max: parents }), + copies: fc.array(fc.record({ pick: fc.nat(), group: fc.boolean() }), { + maxLength: 4, + }), + childrenPerGroup: fc.integer({ min: 1, max: 3 }), + childForm: fc.constantFrom( + `materialize` as const, + `collection` as const, + `ordered` as const, + `limited` as const, + `findOne` as const, + `nested` as const, + `limited-nested` as const, + ), + childSource: fc.constantFrom(`local` as const, `on-demand` as const), + parentForm: fc.constantFrom( + `page` as const, + `findOne` as const, + `orderOnly` as const, + ), + parentFilter: fc.option( + fc.uniqueArray(fc.nat({ max: parents - 1 }), { + minLength: 1, + maxLength: 6, + }), + { nil: null }, + ), + direction: fc.constantFrom(`asc` as const, `desc` as const), + distinct: fc.boolean(), + offset: fc.integer({ min: 0, max: 20 }), + limit: fc.integer({ min: 1, max: 4 }), + history: fc.array(windowCommand, { maxLength: 8 }), + findOneOffset: fc.boolean(), + startWindow: fc.option( + fc.record({ + offset: fc.integer({ min: 0, max: 20 }), + limit: fc.integer({ min: 1, max: 4 }), + }), + { nil: null }, + ), + childFilter: fc.option( + fc.uniqueArray(fc.nat({ max: 200 }), { minLength: 1, maxLength: 4 }), + { nil: null }, + ), + }), + ) + // A findOne parent has one result row; `distinct` would not change it. + .map((testCase) => + testCase.parentForm === `findOne` + ? { ...testCase, distinct: false } + : testCase, + ) + +// Enough runs that each dimension appears at the default multiplier; the +// reconstruction check below counts them. +export const WINDOW_RUNS = 60 + +// The source payload must still contain its authored fields at publication. +// Reconstructing them from the history inputs prevents an in-place production +// mutation from changing the expected value along with the observation. +export function expectedPayloadDocument(blockCount: number, tick: number) { + return { + blocks: Array.from({ length: blockCount }, (_, id) => ({ + id, + text: `block ${id} at ${tick}`, + attrs: { indent: id % 3, tick }, + })), + tick, + } +} + +export function expectPayloadValue( + observed: ReadonlyArray<{ id: number; document: unknown }>, + sourceDocument: unknown, + blockCount: number, + tick: number, +): void { + oracleAssertion('includes work-counter-oracle comparison 1532', () => + expect(observed.map(({ id, document }) => ({ id, document }))).toEqual([ + { id: 1, document: expectedPayloadDocument(blockCount, tick) }, + ]), + ) + oracleAssertion('includes work-counter-oracle comparison 1535', () => + expect(observed[0]!.document).toBe(sourceDocument), + ) +} + +// The retained payload block counts: the oracle replays them, and the checker +// calibration corrupts each one. +export const payloadExamples: Array<[number]> = [[0], [1], [20], [40]] 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 a5b9dbe091..207ced3876 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,13 @@ import { fc } from '@fast-check/vitest' -import { beforeAll, describe, expect, it, vi } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' -import { oracleFixedSeed, oracleRoute, oracleRuns } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' +import { beforeAll, describe, expect, vi } from 'vitest' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' +import { oracleProperty, oracleRuns } from '../oracle-config.js' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { @@ -32,6 +37,19 @@ import { observeWork, runCleanups, } from './includes-work-counter-support.js' +import { + WINDOW_RUNS, + expectPayloadValue, + payloadExamples, + windowCase, +} from './includes-work-counter-model-oracle.js' +import type { + ChildForm, + WindowCase, + WindowChild, + WindowGrandchild, + WindowParent, +} from './includes-work-counter-model-oracle.js' import type { Collection } from '../../src/collection/index.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { Context, QueryBuilder } from '../../src/query/index.js' @@ -174,6 +192,10 @@ function campaigns(fixedSeed: number, property: string, runs = 6) { return [{ name: `fixed and random`, options: { runs, property, fixedSeed } }] } +const correlatedLinksLaw = `A join preserves correlated source pushdown: unrelated joined-link fillers do not increase source work beyond the baseline bound.` +const joinTargetsLaw = `Indexed join-target growth keeps correlated source work flat.` +const joinFreeLaw = `Join-free correlated includes keep source work flat as unrelated fillers grow.` + describe(`includes deterministic work-counter oracle`, () => { beforeAll(async () => { const [joinedBaseline, joinFreeBaseline] = await Promise.all([ @@ -184,36 +206,96 @@ describe(`includes deterministic work-counter oracle`, () => { joinFreeBaselineObservation = joinFreeBaseline }) - it.each([1, 2, 3])( - `checks the work bound at the small filler boundary (%i)`, - (...args: Parameters) => { - oracleRoute(`case`) - return expectCorrelatedJoinWorkBound(...args) + oracleProperty({ + id: `includes-work.correlated-links-fixed`, + law: { + statement: correlatedLinksLaw, + authority: `Law 12 (work) and the physical planning and work contract in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The small filler boundary counts 1 to 3 and the joined-link range endpoint 24.`, }, - ) - it(`checks the joined-link filler range endpoint`, () => { - oracleRoute(`fixed history`) - return expectCorrelatedJoinWorkBound(24) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [ + ...[1, 2, 3].map((fillers) => ({ + name: `checks the work bound at the small filler boundary (${fillers})`, + fillers, + })), + { name: `checks the joined-link filler range endpoint`, fillers: 24 }, + ], + label: ({ name }) => name, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(correlatedLinksLaw, plan, observe, () => + expectCorrelatedJoinWorkBound(plan.fillers), + ), + check: forwardAssertionJudgment, }) - it.each([1, 24])( - `checks join-target growth at filler count %i`, - (...args: Parameters) => { - oracleRoute(`case`) - return expectJoinTargetWorkBound(...args) + + oracleProperty({ + id: `includes-work.join-targets-fixed`, + law: { + statement: joinTargetsLaw, + authority: `Law 12 (work) and the physical planning and work contract in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The join-target filler range endpoints 1 and 24.`, }, - ) - it.each([1, 24])( - `checks join-free growth at filler count %i`, - (...args: Parameters) => { - oracleRoute(`case`) - return expectJoinFreeWorkBound(...args) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [1, 24], + label: (fillers) => `checks join-target growth at filler count ${fillers}`, + resolve: (fillers) => ({ fillers }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(joinTargetsLaw, plan, observe, () => + expectJoinTargetWorkBound(plan.fillers), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes-work.join-free-fixed`, + law: { + statement: joinFreeLaw, + authority: `Law 12 (work) and the physical planning and work contract in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The join-free filler range endpoints 1 and 24.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: [1, 24], + label: (fillers) => `checks join-free growth at filler count ${fillers}`, + resolve: (fillers) => ({ fillers }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(joinFreeLaw, plan, observe, () => + expectJoinFreeWorkBound(plan.fillers), + ), + check: forwardAssertionJudgment, + }) for (const campaign of campaigns(1709, `includes-work.correlated-links`)) { registerIncludesProperty( [fc.integer({ min: 1, max: 24 })], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `a join preserves correlated source pushdown (${campaign.name})`, (...args: Parameters) => { return expectCorrelatedJoinWorkBound(...args) @@ -224,7 +306,15 @@ describe(`includes deterministic work-counter oracle`, () => { for (const campaign of campaigns(170_900, `includes-work.join-targets`)) { registerIncludesProperty( [fc.integer({ min: 1, max: 24 })], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `indexed join-target growth keeps source work flat (${campaign.name})`, (...args: Parameters) => { return expectJoinTargetWorkBound(...args) @@ -235,7 +325,15 @@ describe(`includes deterministic work-counter oracle`, () => { for (const campaign of campaigns(17_090, `includes-work.join-free`)) { registerIncludesProperty( [fc.integer({ min: 1, max: 24 })], - campaign.options, + { + ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + }, `join-free correlated includes keep source work flat (${campaign.name})`, (...args: Parameters) => { return expectJoinFreeWorkBound(...args) @@ -339,134 +437,6 @@ describe(`includes deterministic work-counter oracle`, () => { * Indexes exist before the live query starts, and every step completes before * it is checked. */ -type WindowParent = { id: string; rank: number; group: string } -type WindowChild = { id: string; group: string; n: number } -type WindowGrandchild = { id: string; childId: string } -type ChildForm = - | `materialize` - | `collection` - | `ordered` - | `limited` - | `findOne` - | `nested` - | `limited-nested` -type ParentForm = `page` | `findOne` | `orderOnly` - -type WindowCommand = - | { type: `insert-parent`; copy: number | null } - | { type: `delete-parent`; pick: number } - | { type: `rank`; pick: number; up: boolean } - | { type: `insert-child`; pick: number } - | { type: `delete-child`; pick: number } - | { type: `move`; offset: number; limit: number } - -type WindowCase = { - parents: number - groups: number - // Parents that copy an earlier parent's rank, and its group when `group`. - copies: Array<{ pick: number; group: boolean }> - childrenPerGroup: number - childForm: ChildForm - childSource: `local` | `on-demand` - parentForm: ParentForm - // Groups a static parent filter keeps, by index; one uses `eq`. - parentFilter: Array | null - direction: `asc` | `desc` - distinct: boolean - offset: number - limit: number - history: Array - // A replay of a known transient top-K row: the extra child rows it may load - // at a checkpoint where the parent's ordered acquisition had not completed. - overshoot?: number - // A `findOne` parent skips its `offset` when false. Omitted means true. - findOneOffset?: boolean - // A window `setWindow` requests before the live query starts. - startWindow?: { offset: number; limit: number } | null - // A static child filter on the grandchild correlation field (the child - // id), by index into the initial children; the include keeps only those. - childFilter?: Array | null -} - -const windowCommand: fc.Arbitrary = fc.oneof( - fc.record({ - type: fc.constant(`insert-parent` as const), - copy: fc.option(fc.nat(), { nil: null }), - }), - fc.record({ type: fc.constant(`delete-parent` as const), pick: fc.nat() }), - fc.record({ - type: fc.constant(`rank` as const), - pick: fc.nat(), - up: fc.boolean(), - }), - fc.record({ type: fc.constant(`insert-child` as const), pick: fc.nat() }), - fc.record({ type: fc.constant(`delete-child` as const), pick: fc.nat() }), - fc.record({ - type: fc.constant(`move` as const), - offset: fc.integer({ min: 0, max: 20 }), - limit: fc.integer({ min: 1, max: 4 }), - }), -) - -const windowCase: fc.Arbitrary = fc - .integer({ min: 8, max: 40 }) - .chain((parents) => - fc.record({ - parents: fc.constant(parents), - // At least half as many groups as parents, so most rows own a group. - groups: fc.integer({ min: Math.ceil(parents / 2), max: parents }), - copies: fc.array(fc.record({ pick: fc.nat(), group: fc.boolean() }), { - maxLength: 4, - }), - childrenPerGroup: fc.integer({ min: 1, max: 3 }), - childForm: fc.constantFrom( - `materialize` as const, - `collection` as const, - `ordered` as const, - `limited` as const, - `findOne` as const, - `nested` as const, - `limited-nested` as const, - ), - childSource: fc.constantFrom(`local` as const, `on-demand` as const), - parentForm: fc.constantFrom( - `page` as const, - `findOne` as const, - `orderOnly` as const, - ), - parentFilter: fc.option( - fc.uniqueArray(fc.nat({ max: parents - 1 }), { - minLength: 1, - maxLength: 6, - }), - { nil: null }, - ), - direction: fc.constantFrom(`asc` as const, `desc` as const), - distinct: fc.boolean(), - offset: fc.integer({ min: 0, max: 20 }), - limit: fc.integer({ min: 1, max: 4 }), - history: fc.array(windowCommand, { maxLength: 8 }), - findOneOffset: fc.boolean(), - startWindow: fc.option( - fc.record({ - offset: fc.integer({ min: 0, max: 20 }), - limit: fc.integer({ min: 1, max: 4 }), - }), - { nil: null }, - ), - childFilter: fc.option( - fc.uniqueArray(fc.nat({ max: 200 }), { minLength: 1, maxLength: 4 }), - { nil: null }, - ), - }), - ) - // A findOne parent has one result row; `distinct` would not change it. - .map((testCase) => - testCase.parentForm === `findOne` - ? { ...testCase, distinct: false } - : testCase, - ) - type WindowUnit = { key: string; rank: number; group: string } type ShownRow = { key: string; rank: number; group: string; kids: unknown } type ObservedKid = { id: string; grand?: Iterable<{ id: string }> } @@ -1119,27 +1089,6 @@ async function checkWindowedIncludeWork(testCase: WindowCase): Promise { // the window model and compares the shown rows with it. The three metamorphic // 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, - expectJoinTargetWorkBound, - expectJoinFreeWorkBound, - ], - model: [windowUnits, modelKids], - grammar: [windowCase], -}) - -// Enough runs that each dimension appears at the default multiplier; the -// reconstruction check below counts them. -const WINDOW_RUNS = 60 - /** A fixed history: its strict forward-move tally is not reach evidence. */ async function expectWindowedIncludeWork(testCase: WindowCase): Promise { await checkWindowedIncludeWork(testCase) @@ -1161,39 +1110,33 @@ const fixedWindowCase: WindowCase = { history: [], } -describe(`includes work under an ordered parent window`, () => { - it.each([false, true])( - `loads children only for a late page's parents (distinct=%s)`, - (distinct) => { - oracleRoute(`case`) - return expectWindowedIncludeWork({ - ...fixedWindowCase, - parents: 40, - groups: 40, - childrenPerGroup: 3, - direction: `desc`, - distinct, - offset: 20, - history: [{ type: `move`, offset: 10, limit: 3 }], - }) +// The fixed parent-window histories, each replayed through the shared window +// check. Their strict forward-move tally is not reach evidence. +const windowFixedHistories: Array<{ name: string; testCase: WindowCase }> = [ + ...[false, true].map((distinct) => ({ + name: `loads children only for a late page's parents (distinct=${distinct})`, + testCase: { + ...fixedWindowCase, + parents: 40, + groups: 40, + childrenPerGroup: 3, + direction: `desc`, + 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) => { - oracleRoute(`case`) - return expectWindowedIncludeWork({ - ...fixedWindowCase, - childForm, - history: [{ type: `move`, offset: 3, limit: 2 }], - }) + })), + ...([`ordered`, `limited`, `limited-nested`] as const).map((childForm) => ({ + name: `moves the parent window, not the ${childForm} child's`, + testCase: { + ...fixedWindowCase, + childForm, + history: [{ type: `move`, offset: 3, limit: 2 }], }, - ) - - it(`collapses parents whose selected values match under distinct`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + })), + { + name: `collapses parents whose selected values match under distinct`, + testCase: { ...fixedWindowCase, copies: [ { pick: 1, group: true }, @@ -1202,25 +1145,23 @@ 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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + }, + }, + { + name: `creates no static child demand from a static parent filter`, + testCase: { ...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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + { + name: `stops routing rows that setWindow drops from an orderBy-only parent`, + testCase: { ...fixedWindowCase, childrenPerGroup: 3, childSource: `on-demand`, @@ -1229,38 +1170,35 @@ 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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + }, + }, + { + name: `loads one page of children for a findOne parent`, + testCase: { ...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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + { + name: `creates no static child demand under a window requested before start`, + testCase: { ...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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + { + name: `creates no static grandchild demand below a windowed parent`, + testCase: { ...fixedWindowCase, parents: 10, groups: 10, @@ -1268,15 +1206,14 @@ describe(`includes work under an ordered parent window`, () => { 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`, () => { - oracleRoute(`fixed history`) - return expectWindowedIncludeWork({ + { + name: `creates no static child demand for a findOne moved past its named value before start`, + testCase: { ...fixedWindowCase, parents: 21, groups: 11, @@ -1285,36 +1222,38 @@ 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. - it.each([ - [`local`, false], - [`local`, true], - [`on-demand`, false], - [`on-demand`, true], - ] as const)( - `moves the window forward strictly (%s children, distinct=%s)`, - (childSource, distinct) => { - oracleRoute(`case`) - return expectWindowedIncludeWork({ - ...fixedWindowCase, - parents: 40, - groups: 40, - childrenPerGroup: 2, - childSource, - distinct, - history: [ - { type: `move`, offset: 20, limit: 2 }, - { type: `move`, offset: 30, limit: 2 }, - ], - overshoot: 0, - }) + ...( + [ + [`local`, false], + [`local`, true], + [`on-demand`, false], + [`on-demand`, true], + ] as const + ).map(([childSource, distinct]) => ({ + name: `moves the window forward strictly (${childSource} children, distinct=${distinct})`, + testCase: { + ...fixedWindowCase, + parents: 40, + groups: 40, + childrenPerGroup: 2, + childSource, + distinct, + history: [ + { type: `move`, offset: 20, limit: 2 }, + { type: `move`, offset: 30, limit: 2 }, + ], + overshoot: 0, }, - ) + })), +] + +const orderedWindowLaw = `A parent window bounds child loading: only the rows the window shows activate routes and create child demand.` +describe(`includes work under an ordered parent window`, () => { // Replays of transient top-K rows (#2151). Each shows the correct // rows and loads one child row beyond the bound, at a checkpoint where the // parent's ordered acquisition had not completed: a held row moved past every @@ -1411,67 +1350,80 @@ describe(`includes work under an ordered parent window`, () => { }, ], ] - 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/) + oracleProperty({ + id: `includes-work.ordered-window-fixed`, + law: { + statement: orderedWindowLaw, + authority: `Law 12 (work) and the physical planning and work contract in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The named fixed parent-window histories, including strict forward moves that the generated campaign cannot check strictly.`, }, - ) + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: windowFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(orderedWindowLaw, plan, observe, () => + expectWindowedIncludeWork(plan.testCase), + ), + check: forwardAssertionJudgment, + }) - // 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), - oracleFixedSeed(2137, { numRuns: oracleRuns(WINDOW_RUNS) }), - ) - .map(([testCase]) => testCase) - const count = (premise: (testCase: WindowCase) => boolean) => - cases.filter(premise).length - for (const [premise, reached] of [ - [`on-demand child`, count((c) => c.childSource === `on-demand`)], - [`static filter`, count((c) => c.parentFilter !== null)], - [`limited-nested`, count((c) => c.childForm === `limited-nested`)], - [`page parent`, count((c) => c.parentForm === `page`)], - [`findOne parent`, count((c) => c.parentForm === `findOne`)], - [ - `orderBy-only parent moved by setWindow`, - count( - (c) => - c.parentForm === `orderOnly` && - c.history.some((command) => command.type === `move`), - ), - ], - [ - `on-demand static filter`, - count((c) => c.childSource === `on-demand` && c.parentFilter !== null), - ], - [ - `findOne parent without offset`, - count((c) => c.parentForm === `findOne` && c.findOneOffset === false), - ], - [ - `orderBy-only parent with a static filter`, - count((c) => c.parentForm === `orderOnly` && c.parentFilter !== null), - ], - [ - `window requested before start`, - count((c) => c.parentForm !== `findOne` && c.startWindow !== null), - ], - ] as const) { - expect(reached, premise).toBeGreaterThanOrEqual(3) - } + oracleProperty({ + id: `includes-work.transient-top-k`, + law: { + statement: orderedWindowLaw, + authority: `Law 12 (work) and the physical planning and work contract in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The six replays of transient top-K rows (a recorded limit, #2151), each allowed one extra loaded child row, and their zero-overshoot calibrations.`, + }, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], + histories: transient.map(([name, testCase]) => ({ name, testCase })), + label: ({ name }) => `bounds a transient top-K row: ${name}`, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(orderedWindowLaw, plan, observe, () => + expectWindowedIncludeWork({ ...plan.testCase, overshoot: 1 }), + ), + check: forwardAssertionJudgment, + calibrations: transient.map(([name]) => ({ + name: `reaches a transient top-K row: ${name}`, + law: orderedWindowLaw, + // The former calibration body: the same replay fails when no overshoot is + // allowed, so it reaches a transient row and the loader-activity + // observation sees it. It applies to its own replay only; on another + // replay it reports no rejection, so the framework tries the next one. + // Exactly the old matcher: only a rejection whose message names the + // loaded child rows counts; any other failure is rethrown without the + // shared check's marking and fails this calibration. + driver: (plan, observe) => + recordAssertionJudgment(orderedWindowLaw, plan, observe, async () => { + if (plan.name !== name) return + try { + await checkWindowedIncludeWork({ ...plan.testCase, overshoot: 0 }) + } catch (error) { + if ( + error instanceof Error && + /child rows loaded by/.test(error.message) + ) + rejectAt(`transient top-K ${name}`, error) + throw new Error( + `the zero-overshoot replay failed outside the expected rejection: ${String(error)}`, + ) + } + }), + })), }) for (const campaign of campaigns( @@ -1488,6 +1440,12 @@ describe(`includes work under an ordered parent window`, () => { [windowCase], { ...campaign.options, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], beforeCampaign: () => { strictForwardMoves = 0 }, @@ -1549,42 +1507,6 @@ const payloadBoundaries = [ `correlated-group`, ] as const -declareOracle({ - check: [expectPayloadWork, expectPayloadValue], - model: [expectedPayloadDocument], - grammar: [payloadBoundaries], -}) - -// The source payload must still contain its authored fields at publication. -// Reconstructing them from the history inputs prevents an in-place production -// mutation from changing the expected value along with the observation. -function expectedPayloadDocument(blockCount: number, tick: number) { - return { - blocks: Array.from({ length: blockCount }, (_, id) => ({ - id, - text: `block ${id} at ${tick}`, - attrs: { indent: id % 3, tick }, - })), - tick, - } -} - -function expectPayloadValue( - observed: ReadonlyArray<{ id: number; document: unknown }>, - sourceDocument: unknown, - blockCount: number, - tick: number, -): void { - oracleAssertion('includes work-counter-oracle comparison 1532', () => - expect(observed.map(({ id, document }) => ({ id, document }))).toEqual([ - { id: 1, document: expectedPayloadDocument(blockCount, tick) }, - ]), - ) - oracleAssertion('includes work-counter-oracle comparison 1535', () => - expect(observed[0]!.document).toBe(sourceDocument), - ) -} - async function expectPayloadWork(blockCount: number): Promise { const reach = requiredIncludesObservations( `projection-payload`, @@ -1809,22 +1731,6 @@ async function expectPayloadWork(blockCount: number): Promise { } const payloadProperty = `includes-work.projection-payload` -const payloadExamples: Array<[number]> = [[0], [1], [20], [40]] - -// This wrong observation preserves source identity while corrupting content. -// The same comparison used at every public checkpoint must reject it, including -// an empty payload whose only changed field is its root tick. -it.each(payloadExamples)( - `rejects a source payload mutated in place (%i blocks)`, - (blockCount) => { - oracleRoute(`calibration`) - const document = expectedPayloadDocument(blockCount, 0) - document.tick = -1 - expect(() => - expectPayloadValue([{ id: 1, document }], document, blockCount, 0), - ).toThrow(/to deeply equal/) - }, -) registerIncludesProperty( [fc.integer({ min: 0, max: 40 })], @@ -1833,6 +1739,12 @@ registerIncludesProperty( runs: 8, fixedSeed: 2355, examples: payloadExamples, + owns: [ + `src/collection/index.ts#createCollection`, + `src/indexes/btree-index.ts`, + `src/local-only.ts#localOnlyCollectionOptions`, + `src/query/live-query-collection.ts#createLiveQueryCollection`, + ], }, `nested source payloads need no metadata descriptor scans (fixed and random)`, (blockCount) => { diff --git a/packages/db/tests/query/includes-work-counter.test.ts b/packages/db/tests/query/includes-work-counter.test.ts index 6776bea24d..f67d71de67 100644 --- a/packages/db/tests/query/includes-work-counter.test.ts +++ b/packages/db/tests/query/includes-work-counter.test.ts @@ -4,6 +4,7 @@ // that the work bound states is owned by includes-work-counter-oracle.test.ts, // and the shared fixtures live in includes-work-counter-support.ts. +import { fc } from '@fast-check/vitest' import { beforeAll, describe, expect, it } from 'vitest' import { createCollection } from '../../src/collection/index.js' import { @@ -15,6 +16,7 @@ import { eq, toArray, } from '../../src/query/index.js' +import { oracleFixedSeed, oracleRuns } from '../oracle-config.js' import { createSourceCollection, expectSourceWorkBound, @@ -24,6 +26,14 @@ import { observeWork, runCleanups, } from './includes-work-counter-support.js' +import { + WINDOW_RUNS, + expectPayloadValue, + expectedPayloadDocument, + payloadExamples, + windowCase, +} from './includes-work-counter-model-oracle.js' +import type { WindowCase } from './includes-work-counter-model-oracle.js' import type { SourceWork, WorkObservation, @@ -434,3 +444,68 @@ describe(`static include demand and setWindow ownership`, () => { } }) }) + +// A grammar census and a checker calibration for the includes work-counter +// oracle. They run no production: the census samples the fixed campaign's +// parent-window cases and counts each premise, and the calibration feeds a +// mutated payload to the oracle's value check. +describe(`includes work-counter grammar and checks`, () => { + // Reconstruction: the fixed campaign's executed cases reach each premise. + it(`reaches every window dimension in the executed fixed cases`, () => { + const cases = fc + .sample( + fc.tuple(windowCase), + oracleFixedSeed(2137, { numRuns: oracleRuns(WINDOW_RUNS) }), + ) + .map(([testCase]) => testCase) + const count = (premise: (testCase: WindowCase) => boolean) => + cases.filter(premise).length + for (const [premise, reached] of [ + [`on-demand child`, count((c) => c.childSource === `on-demand`)], + [`static filter`, count((c) => c.parentFilter !== null)], + [`limited-nested`, count((c) => c.childForm === `limited-nested`)], + [`page parent`, count((c) => c.parentForm === `page`)], + [`findOne parent`, count((c) => c.parentForm === `findOne`)], + [ + `orderBy-only parent moved by setWindow`, + count( + (c) => + c.parentForm === `orderOnly` && + c.history.some((command) => command.type === `move`), + ), + ], + [ + `on-demand static filter`, + count((c) => c.childSource === `on-demand` && c.parentFilter !== null), + ], + [ + `findOne parent without offset`, + count((c) => c.parentForm === `findOne` && c.findOneOffset === false), + ], + [ + `orderBy-only parent with a static filter`, + count((c) => c.parentForm === `orderOnly` && c.parentFilter !== null), + ], + [ + `window requested before start`, + count((c) => c.parentForm !== `findOne` && c.startWindow !== null), + ], + ] as const) { + expect(reached, premise).toBeGreaterThanOrEqual(3) + } + }) +}) + +// This wrong observation preserves source identity while corrupting content. +// The same comparison used at every public checkpoint must reject it, including +// an empty payload whose only changed field is its root tick. +it.each(payloadExamples)( + `rejects a source payload mutated in place (%i blocks)`, + (blockCount) => { + const document = expectedPayloadDocument(blockCount, 0) + document.tick = -1 + expect(() => + expectPayloadValue([{ id: 1, document }], document, blockCount, 0), + ).toThrow(/to deeply equal/) + }, +) From d3069b4ecc56b185c32b3879f4c6c3a2a8b9c6a7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:37:28 -0700 Subject: [PATCH 091/116] test(db): type the work-counter window histories and calibration plans Co-authored-by: Isaac --- .../includes-work-counter-oracle.test.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) 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 207ced3876..9dd822a918 100644 --- a/packages/db/tests/query/includes-work-counter-oracle.test.ts +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -1112,8 +1112,9 @@ const fixedWindowCase: WindowCase = { // The fixed parent-window histories, each replayed through the shared window // check. Their strict forward-move tally is not reach evidence. -const windowFixedHistories: Array<{ name: string; testCase: WindowCase }> = [ - ...[false, true].map((distinct) => ({ +type WindowFixedHistory = { name: string; testCase: WindowCase } +const windowFixedHistories: Array = [ + ...[false, true].map((distinct): WindowFixedHistory => ({ name: `loads children only for a late page's parents (distinct=${distinct})`, testCase: { ...fixedWindowCase, @@ -1126,14 +1127,16 @@ const windowFixedHistories: Array<{ name: string; testCase: WindowCase }> = [ history: [{ type: `move`, offset: 10, limit: 3 }], }, })), - ...([`ordered`, `limited`, `limited-nested`] as const).map((childForm) => ({ - name: `moves the parent window, not the ${childForm} child's`, - testCase: { - ...fixedWindowCase, - childForm, - history: [{ type: `move`, offset: 3, limit: 2 }], - }, - })), + ...([`ordered`, `limited`, `limited-nested`] as const).map( + (childForm): WindowFixedHistory => ({ + name: `moves the parent window, not the ${childForm} child's`, + testCase: { + ...fixedWindowCase, + childForm, + history: [{ type: `move`, offset: 3, limit: 2 }], + }, + }), + ), { name: `collapses parents whose selected values match under distinct`, testCase: { @@ -1233,7 +1236,7 @@ const windowFixedHistories: Array<{ name: string; testCase: WindowCase }> = [ [`on-demand`, false], [`on-demand`, true], ] as const - ).map(([childSource, distinct]) => ({ + ).map(([childSource, distinct]): WindowFixedHistory => ({ name: `moves the window forward strictly (${childSource} children, distinct=${distinct})`, testCase: { ...fixedWindowCase, @@ -1407,7 +1410,10 @@ describe(`includes work under an ordered parent window`, () => { // Exactly the old matcher: only a rejection whose message names the // loaded child rows counts; any other failure is rethrown without the // shared check's marking and fails this calibration. - driver: (plan, observe) => + driver: ( + plan: WindowFixedHistory, + observe: (checkpoint: string, value: unknown) => void, + ) => recordAssertionJudgment(orderedWindowLaw, plan, observe, async () => { if (plan.name !== name) return try { From bef135281e25d2b70f599c7e4d43db798f8c89c8 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:35:57 -0700 Subject: [PATCH 092/116] test(db): register includes recompute fixed histories and cases through oracleProperty Fold the named full-row, shared-route pair, incremental, materialization, and alpha-renaming witnesses into enumerated properties over the shared checks, turn the omitted-child control into a native calibration, and move the grammar and model checks to includes-history-grammar.test.ts with the grammar and model in a test-free companion module. Co-authored-by: Isaac --- .../converted-properties/db-query-includes.ts | 17 + .../query/includes-history-grammar-oracle.ts | 1615 +++++++ .../query/includes-history-grammar.test.ts | 415 ++ .../query/includes-oracle.property.test.ts | 3918 +++++------------ 4 files changed, 3271 insertions(+), 2694 deletions(-) create mode 100644 packages/db/tests/query/includes-history-grammar-oracle.ts create mode 100644 packages/db/tests/query/includes-history-grammar.test.ts diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/converted-properties/db-query-includes.ts index 7a78ee15ac..7c352d8967 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/converted-properties/db-query-includes.ts @@ -93,6 +93,18 @@ export const properties: ReadonlyArray = [ `includes-work.join-free-fixed`, `includes-work.ordered-window-fixed`, `includes-work.transient-top-k`, + `includes.optimistic-cuts`, + `includes.apply-checkpoints`, + `includes.omitted-child`, + `includes.failed-optimistic-cleanup`, + `includes.composed-transitions`, + `includes.materialize-reference-update`, + `includes.full-row-fixed`, + `includes.history-pair-fixed`, + `includes.flat-materialization-cases`, + `includes.incremental-fixed`, + `includes.materialize-fixed`, + `includes.scoped-alpha-fixed`, ] export const owners: ReadonlyArray = [ @@ -181,4 +193,9 @@ export const owners: ReadonlyArray = [ `includes-work`, `correlated-links-fixed join-targets-fixed join-free-fixed ordered-window-fixed transient-top-k`, ], + [ + `db/tests/query/includes-oracle.property.test.ts`, + `includes`, + `optimistic-cuts apply-checkpoints omitted-child failed-optimistic-cleanup composed-transitions materialize-reference-update full-row-fixed history-pair-fixed flat-materialization-cases incremental-fixed materialize-fixed scoped-alpha-fixed`, + ], ] diff --git a/packages/db/tests/query/includes-history-grammar-oracle.ts b/packages/db/tests/query/includes-history-grammar-oracle.ts new file mode 100644 index 0000000000..ba5e381a92 --- /dev/null +++ b/packages/db/tests/query/includes-history-grammar-oracle.ts @@ -0,0 +1,1615 @@ +import { fc } from '@fast-check/vitest' +import { expect } from 'vitest' +import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + assertDisjointRelationshipKeys, + batchChild, + batchRoot, + createConnectedBatchBranches, + createRouteLifecycleScenario, + createVisibleRelationshipScenario, + otherBranch, + rowAt, + transitionHistoryBranches, + updateFullRowBatchModels, +} from './includes-route-grammar-oracle.js' +import type { + ConnectedBranch, + FullRowBatchScenario, + FullRowBatchStep, + IncludeDepth, + RouteLifecycleScenario, + RouteTransitionDescriptor, + VisibleRelationshipScenario, + VisibleRelationshipScenarioOptions, + VisibleScalarNoise, +} from './includes-route-grammar-oracle.js' +import type { ChildRow, RootRow } from './includes-structural-support.js' +import type { OracleSyncChange as SyncChange } from './includes-oracle-helpers.js' + +// The relationship model and the route-history grammar of the includes +// recompute oracle, moved here unchanged from includes-oracle.property.test.ts: +// recompute, its full-row batch replay, the relationship projection, and the +// transition, destination, batch-shape, rekey, and moved-child scenario +// constructors and arbitraries. The oracle drives production through them; the +// ordinary grammar checks in includes-history-grammar.test.ts step the model +// over them. This module registers no tests. + +export type HistoryAction = { + type: `put` | `delete` | `optimisticConfirm` | `optimisticRollback` + level: 0 | IncludeDepth + id: number + parentGroup: number + group: number + value: number + position: number +} + +export function levelArbitrary( + depth: IncludeDepth, +): fc.Arbitrary { + switch (depth) { + case 1: + return fc.constantFrom(0, 1) + case 2: + return fc.constantFrom(0, 1, 2) + case 3: + return fc.constantFrom(0, 1, 2, 3) + case 4: + return fc.constantFrom(0, 1, 2, 3, 4) + } +} + +export type OracleNode = RootRow & { + children?: Array +} + +export type RelationshipNode = { + id: number + value?: unknown + children?: unknown +} + +export function isRelationshipNode(value: unknown): value is RelationshipNode { + return ( + typeof value === `object` && + value !== null && + `id` in value && + typeof value.id === `number` + ) +} + +export function findRelationshipNode( + value: unknown, + id: number, +): RelationshipNode | undefined { + if (!Array.isArray(value)) return undefined + + for (const entry of value) { + if (!isRelationshipNode(entry)) continue + if (entry.id === id) return entry + const nested = findRelationshipNode(entry.children, id) + if (nested) return nested + } + return undefined +} + +export function hasDirectChild( + value: unknown, + parentId: number, + childId: number, +) { + const parent = findRelationshipNode(value, parentId) + return ( + Array.isArray(parent?.children) && + parent.children.some( + (child) => isRelationshipNode(child) && child.id === childId, + ) + ) +} + +export type RelationshipProjectionNode = { + id: number + children?: Array +} + +export function relationshipOnly( + nodes: ReadonlyArray, +): Array { + return nodes.map(({ id, children }) => ({ + id, + ...(children ? { children: relationshipOnly(children) } : {}), + })) +} + +export function compareRows(left: RootRow, right: RootRow): number { + return left.position - right.position || left.id - right.id +} + +// This is intentionally independent of the live-query implementation. It is +// the simple, full-recompute semantics reference for the incremental system. +export function recompute( + roots: Map, + levels: Array>, + depth: IncludeDepth, +): Array { + const materializeLevel = ( + level: number, + parentGroup: number, + ): Array => + [...levels[level]!.values()] + .filter((row) => row.parentGroup === parentGroup) + .sort(compareRows) + .map((row) => { + const node: OracleNode = { + id: row.id, + group: row.group, + value: row.value, + position: row.position, + } + if (level + 1 < depth) { + node.children = materializeLevel(level + 1, row.group) + } + return node + }) + + return [...roots.values()].sort(compareRows).map((root) => ({ + id: root.id, + group: root.group, + value: root.value, + position: root.position, + children: materializeLevel(0, root.group), + })) +} + +export type VisibleRelationshipTransition = `reparent` | `rekey` +export type BranchDeliveryOrder = `forward` | `reverse` +export const branchDeliveryOrders: ReadonlyArray = [ + `forward`, + `reverse`, +] + +export function deliverBranches( + branches: readonly [ConnectedBranch, ConnectedBranch], + order: BranchDeliveryOrder, +): readonly [ConnectedBranch, ConnectedBranch] { + return order === `forward` ? branches : [branches[1], branches[0]] +} + +export function deliveredBranchIndex( + branch: 0 | 1, + order: BranchDeliveryOrder, +): 0 | 1 { + return order === `forward` ? branch : otherBranch(branch) +} + +export type VisibleRelationshipScenarios = { + transitionOnly: VisibleRelationshipScenario + stateful: VisibleRelationshipScenario +} + +export function visibleRelationshipScenarioArbitrary( + depth: IncludeDepth, + transition: VisibleRelationshipTransition, + targetLevel: IncludeDepth, +): fc.Arbitrary { + const branchArbitrary = fc.constantFrom<0 | 1>(0, 1) + const scalarNoiseArbitrary = ( + side: VisibleScalarNoise[`side`], + branch: fc.Arbitrary<0 | 1>, + ): fc.Arbitrary => + fc.record({ + side: fc.constant(side), + level: levelArbitrary(depth), + branch, + value: fc.integer({ min: -3, max: 3 }), + position: fc.integer({ min: -2, max: 2 }), + }) + + return fc + .record({ + sourceBranch: branchArbitrary, + leftIdBase: fc.integer({ min: 100, max: 500 }), + leftGroupBase: fc.integer({ min: 600, max: 1_000 }), + rightIdBase: fc.integer({ min: 1_100, max: 1_500 }), + rightGroupBase: fc.integer({ min: 1_600, max: 2_000 }), + rekeyGroup: fc.integer({ min: 2_100, max: 2_500 }), + beforeNoise: scalarNoiseArbitrary(`before`, branchArbitrary), + extraBeforeNoise: fc.array( + scalarNoiseArbitrary(`before`, branchArbitrary), + { maxLength: 4 }, + ), + afterValues: fc.array(scalarNoiseArbitrary(`after`, branchArbitrary), { + minLength: 1, + maxLength: 5, + }), + }) + .map( + ({ + sourceBranch, + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + rekeyGroup, + beforeNoise, + extraBeforeNoise, + afterValues, + }) => { + const connectedNoise: Array = [ + beforeNoise, + ...extraBeforeNoise, + ...afterValues, + ] + const options = { + depth, + targetLevel, + sourceBranch, + branches: [ + { idBase: leftIdBase, groupBase: leftGroupBase }, + { idBase: rightIdBase, groupBase: rightGroupBase }, + ], + ...(transition === `rekey` + ? { transition, rekeyGroup } + : { transition }), + } satisfies Omit + + return { + transitionOnly: createVisibleRelationshipScenario({ + ...options, + noise: [], + }), + stateful: createVisibleRelationshipScenario({ + ...options, + noise: connectedNoise, + }), + } + }, + ) +} + +export type GeneratedBranchOptions = { + leftIdBase: number + leftGroupBase: number + rightIdBase: number + rightGroupBase: number +} + +export const generatedBranchArbitraries = { + leftIdBase: fc.integer({ min: 100, max: 500 }), + leftGroupBase: fc.integer({ min: 600, max: 1_000 }), + rightIdBase: fc.integer({ min: 1_100, max: 1_500 }), + rightGroupBase: fc.integer({ min: 1_600, max: 2_000 }), +} + +export function createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, +}: GeneratedBranchOptions): readonly [ConnectedBranch, ConnectedBranch] { + return [ + { idBase: leftIdBase, groupBase: leftGroupBase }, + { idBase: rightIdBase, groupBase: rightGroupBase }, + ] +} + +export type TransitionHistoryScenario = FullRowBatchScenario & { + historyStartStepIndex: number +} + +export type TransitionHistoryScenarioOptions = { + depth: IncludeDepth + targetLevel: IncludeDepth + firstTransition: VisibleRelationshipTransition + secondTransition: VisibleRelationshipTransition + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + rekeyGroups: readonly [number, number] + insertedLevels: readonly [IncludeDepth, IncludeDepth] + insertedValues: readonly [number, number] + insertedPositions: readonly [number, number] +} + +export function createTransitionHistoryScenario({ + depth, + targetLevel, + firstTransition, + secondTransition, + sourceBranch, + branches, + rekeyGroups, + insertedLevels, + insertedValues, + insertedPositions, +}: TransitionHistoryScenarioOptions): TransitionHistoryScenario { + // Fresh keys keep this matrix green through both transitions. Separate + // state-aware families below reuse retired routes and replace existing rows, + // so those histories remain shrinkable without masking later steps. + const insertedRows = [ + { + id: 3_000, + group: 2_700, + value: insertedValues[0], + position: insertedPositions[0], + }, + { + id: 3_001, + group: 2_800, + value: insertedValues[1], + position: insertedPositions[1], + }, + ] as const + assertDisjointRelationshipKeys(depth, branches, { + extraIds: insertedRows.map(({ id }) => id), + extraGroups: [...rekeyGroups, ...insertedRows.map(({ group }) => group)], + }) + + const steps = createConnectedBatchBranches(depth, branches) + const historyStartStepIndex = steps.length + const source = branches[sourceBranch] + let current = { + ...batchChild( + source.idBase + targetLevel, + source.groupBase + targetLevel - 1, + source.idBase + targetLevel, + 0, + ), + group: source.groupBase + targetLevel, + } + const appendStep = ( + level: IncludeDepth, + changes: Array>, + ): void => { + steps.push({ level, changes }) + } + const insertRow = ( + row: (typeof insertedRows)[number], + level: IncludeDepth, + ): ChildRow => { + if (level !== targetLevel && level !== targetLevel + 1) { + throw new Error(`History inserts must touch the target or its child`) + } + return { + ...row, + parentGroup: level === targetLevel ? current.parentGroup : current.group, + } + } + const transition = ( + kind: VisibleRelationshipTransition, + rekeyGroup: number, + ): void => { + const currentParentBranch = branches.findIndex( + ({ groupBase }) => current.parentGroup === groupBase + targetLevel - 1, + ) + if (currentParentBranch !== 0 && currentParentBranch !== 1) { + throw new Error(`Transition target has no visible parent branch`) + } + const destination = branches[otherBranch(currentParentBranch)] + current = { + ...current, + parentGroup: + kind === `reparent` + ? destination.groupBase + targetLevel - 1 + : current.parentGroup, + group: kind === `rekey` ? rekeyGroup : current.group, + } + appendStep(targetLevel, [{ type: `update`, value: current }]) + } + const interleaveTransition = ( + kind: VisibleRelationshipTransition, + rekeyGroup: number, + row: (typeof insertedRows)[number], + level: IncludeDepth, + ): void => { + if (kind === `rekey`) { + if (level !== targetLevel + 1) { + throw new Error(`Rekey histories must seed the new child route`) + } + transition(kind, rekeyGroup) + appendStep(level, [{ type: `insert`, value: insertRow(row, level) }]) + return + } + + const inserted = insertRow(row, level) + appendStep(level, [{ type: `insert`, value: inserted }]) + transition(kind, rekeyGroup) + appendStep(level, [{ type: `delete`, value: inserted }]) + } + + interleaveTransition( + firstTransition, + rekeyGroups[0], + insertedRows[0], + insertedLevels[0], + ) + interleaveTransition( + secondTransition, + rekeyGroups[1], + insertedRows[1], + insertedLevels[1], + ) + + return { + depth, + steps, + historyStartStepIndex, + } +} + +export function transitionHistoryPlacements( + depth: IncludeDepth, + firstTransition: VisibleRelationshipTransition, + secondTransition: VisibleRelationshipTransition, +): Array<{ + targetLevel: IncludeDepth + insertedLevels: readonly [IncludeDepth, IncludeDepth] +}> { + // A rekey needs one child level so it changes relationship membership. It + // also fails when two descendant levels remain. The single-transition matrix + // covers that failure and will turn red when this continuation can be widened. + const minimumTargetLevel = + firstTransition === `rekey` || secondTransition === `rekey` + ? Math.max(1, depth - 1) + : 1 + const maximumTargetLevel = + firstTransition === `rekey` || secondTransition === `rekey` + ? depth - 1 + : depth + const placements: Array<{ + targetLevel: IncludeDepth + insertedLevels: readonly [IncludeDepth, IncludeDepth] + }> = [] + + for (let level = minimumTargetLevel; level <= maximumTargetLevel; level++) { + const targetLevel = level as IncludeDepth + const insertedLevels = ( + transition: VisibleRelationshipTransition, + ): ReadonlyArray => + transition === `rekey` + ? [(targetLevel + 1) as IncludeDepth] + : targetLevel < depth + ? [targetLevel, (targetLevel + 1) as IncludeDepth] + : [targetLevel] + + for (const firstInsertedLevel of insertedLevels(firstTransition)) { + for (const secondInsertedLevel of insertedLevels(secondTransition)) { + placements.push({ + targetLevel, + insertedLevels: [firstInsertedLevel, secondInsertedLevel], + }) + } + } + } + + return placements +} + +export function transitionHistoryScenariosArbitrary( + depth: IncludeDepth, + firstTransition: VisibleRelationshipTransition, + secondTransition: VisibleRelationshipTransition, + sourceBranch: 0 | 1, +): fc.Arbitrary> { + const placements = transitionHistoryPlacements( + depth, + firstTransition, + secondTransition, + ) + + return fc + .record({ + ...generatedBranchArbitraries, + firstRekeyGroup: fc.integer({ min: 2_100, max: 2_300 }), + secondRekeyGroup: fc.integer({ min: 2_400, max: 2_600 }), + firstInsertedValue: fc.integer({ min: -3, max: 3 }), + secondInsertedValue: fc.integer({ min: -3, max: 3 }), + firstInsertedPosition: fc.integer({ min: -2, max: 2 }), + secondInsertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + firstRekeyGroup, + secondRekeyGroup, + firstInsertedValue, + secondInsertedValue, + firstInsertedPosition, + secondInsertedPosition, + }) => + placements.map(({ targetLevel, insertedLevels }) => + createTransitionHistoryScenario({ + depth, + targetLevel, + firstTransition, + secondTransition, + sourceBranch, + branches: createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }), + rekeyGroups: [firstRekeyGroup, secondRekeyGroup], + insertedLevels, + insertedValues: [firstInsertedValue, secondInsertedValue], + insertedPositions: [firstInsertedPosition, secondInsertedPosition], + }), + ), + ) +} + +export function expectEveryHistoryStepVisible( + scenario: TransitionHistoryScenario, +): void { + for ( + let stepIndex = scenario.historyStartStepIndex; + stepIndex < scenario.steps.length; + stepIndex++ + ) { + const before = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex), + ) + const after = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ) + oracleAssertion('includes oracle.property comparison 1610', () => + expect(after).not.toEqual(before), + ) + } +} + +export function expectEveryRouteTransitionVisible( + scenario: RouteLifecycleScenario, +): void { + for (const stepIndex of scenario.transitionStepIndexes) { + const before = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex), + ) + const after = relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ) + oracleAssertion('includes oracle.property comparison 1624', () => + expect(after).not.toEqual(before), + ) + } +} + +export const independentTransitionShapes = [ + `ancestor-descendant`, + `descendant-ancestor`, + `sibling`, + `cross-branch`, + `root`, +] as const + +export type IndependentTransitionShape = + (typeof independentTransitionShapes)[number] + +export function independentFreshRoutesArbitrary( + shape: IndependentTransitionShape, +): fc.Arbitrary { + const first = fc.integer({ min: 2_100, max: 2_400 }) + if (shape === `sibling`) { + return fc.tuple(first, fc.integer({ min: 2_500, max: 2_800 })) + } + if (shape === `ancestor-descendant` || shape === `root`) { + return first.map((route) => [route, 2_500] as const) + } + return fc.constant([2_100, 2_500] as const) +} + +export function independentTransitionDescriptors( + shape: IndependentTransitionShape, + branches: readonly [ConnectedBranch, ConnectedBranch], + freshRoutes: readonly [number, number], +): ReadonlyArray { + switch (shape) { + case `ancestor-descendant`: + return [ + { + kind: `reparent`, + level: 1, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase, + }, + }, + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + ] + case `descendant-ancestor`: + return [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + { + kind: `reparent`, + level: 1, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase, + }, + }, + ] + case `sibling`: + return [ + { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + { + kind: `rekey`, + level: 2, + row: 1, + destination: { strategy: `fresh`, route: freshRoutes[1] }, + }, + ] + case `cross-branch`: + return [ + { + kind: `reparent`, + level: 2, + row: 0, + destination: { + strategy: `merge`, + route: branches[1].groupBase + 1, + }, + }, + { + kind: `reparent`, + level: 2, + row: 1, + destination: { + strategy: `merge`, + route: branches[0].groupBase + 1, + }, + }, + ] + case `root`: + return [ + { + kind: `rekey`, + level: 0, + row: 0, + destination: { strategy: `fresh`, route: freshRoutes[0] }, + }, + { + kind: `reparent`, + level: 1, + row: 1, + destination: { strategy: `merge`, route: freshRoutes[0] }, + }, + ] + } +} + +export function independentTransitionPrefix( + shape: IndependentTransitionShape, + branches: readonly [ConnectedBranch, ConnectedBranch], +): Array { + const prefix = createConnectedBatchBranches(3, branches) + if (shape !== `sibling`) return prefix + + const secondSiblingId = rowAt(branches, 1, 2) + return prefix.map((step) => + step.level === 2 + ? { + ...step, + changes: step.changes.map((change) => + change.value.id === secondSiblingId + ? { + ...change, + value: { + ...change.value, + parentGroup: branches[0].groupBase + 1, + }, + } + : change, + ), + } + : step, + ) +} + +export function independentTransitionScenarioArbitrary( + shape: IndependentTransitionShape, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + freshRoutes: independentFreshRoutesArbitrary(shape), + }) + .map(({ freshRoutes, ...branchOptions }) => { + const branches = createGeneratedBranches(branchOptions) + return createRouteLifecycleScenario({ + depth: 3, + branches, + prefixSteps: independentTransitionPrefix(shape, branches), + descriptors: independentTransitionDescriptors( + shape, + branches, + freshRoutes, + ), + }) + }) +} + +export const destinationHistories = [ + `fresh`, + `restore`, + `merge-split`, + `retired`, +] as const + +export type DestinationHistory = (typeof destinationHistories)[number] + +export function destinationHistoryDescriptors( + history: DestinationHistory, + branches: readonly [ConnectedBranch, ConnectedBranch], + freshRoute: number, +): ReadonlyArray { + const originalRoute = branches[0].groupBase + 2 + const sharedRoute = branches[1].groupBase + 2 + const first: RouteTransitionDescriptor = { + kind: `rekey`, + level: 2, + row: 0, + destination: { strategy: `fresh`, route: freshRoute }, + } + + switch (history) { + case `fresh`: + return [first] + case `restore`: + return [ + first, + { + ...first, + destination: { strategy: `restore`, route: originalRoute }, + }, + ] + case `merge-split`: + return [ + { + ...first, + destination: { strategy: `merge`, route: sharedRoute }, + }, + { + ...first, + destination: { strategy: `split`, route: freshRoute }, + }, + ] + case `retired`: + return [ + first, + { + kind: `rekey`, + level: 2, + row: 1, + destination: { strategy: `retired`, route: originalRoute }, + }, + ] + } +} + +export function destinationHistoryScenarioArbitrary( + history: DestinationHistory, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + freshRoute: fc.integer({ min: 2_100, max: 2_400 }), + }) + .map(({ freshRoute, ...branchOptions }) => { + const branches = createGeneratedBranches(branchOptions) + return createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: destinationHistoryDescriptors( + history, + branches, + freshRoute, + ), + }) + }) +} + +export function createInitiallySharedRoutePrefix( + depth: IncludeDepth, + parentLevel: 0 | 1 | 2, + branches: readonly [ConnectedBranch, ConnectedBranch], + enteringRow: 0 | 1 = 1, +): Array { + const enteringId = rowAt(branches, enteringRow, parentLevel) + const sharedRoute = branches[otherBranch(enteringRow)].groupBase + parentLevel + return createConnectedBatchBranches(depth, branches).map((step) => { + if (step.level !== parentLevel) return step + + if (step.level === 0) { + return { + level: 0, + changes: step.changes.map((change) => + change.value.id === enteringId + ? { ...change, value: { ...change.value, group: sharedRoute } } + : change, + ), + } + } + + return { + level: step.level, + changes: step.changes.map((change) => + change.value.id === enteringId + ? { ...change, value: { ...change.value, group: sharedRoute } } + : change, + ), + } + }) +} + +export function createMergeIntoSharedRouteScenarios( + parentLevel: 0 | 1 | 2, + enteringRow: 0 | 1, +): HistoryScenarioPair { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const existingRow = otherBranch(enteringRow) + const sharedRoute = branches[existingRow].groupBase + parentLevel + const candidate = createRouteLifecycleScenario({ + depth, + branches, + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: enteringRow, + destination: { strategy: `merge`, route: sharedRoute }, + }, + ], + }) + expectEveryRouteTransitionVisible(candidate) + + return { + control: { + depth, + steps: createInitiallySharedRoutePrefix( + depth, + parentLevel, + branches, + enteringRow, + ), + }, + candidate, + } +} + +export function createSharedRouteLastSubscriberScenario( + parentLevel: 0 | 1 | 2, +): FullRowBatchScenario { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const sharedRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, sharedRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const descriptors: ReadonlyArray = [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `split`, route: 2_100 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `fresh`, route: 2_200 + parentLevel }, + }, + ] + const scenario = createRouteLifecycleScenario({ + depth, + branches, + descriptors, + prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), + trailingSteps: [ + { + level: childLevel, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ], + }) + expectEveryRouteTransitionVisible(scenario) + return scenario +} + +export function createSnapshotOnResubscribeScenarios( + parentLevel: 0 | 1 | 2, +): HistoryScenarioPair { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const originalRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, originalRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const updatedChild = { ...child, value: child.value + 1 } + const prefix = createConnectedBatchBranches(depth, branches) + const childUpdate: FullRowBatchStep = { + level: childLevel, + changes: [{ type: `update`, value: updatedChild }], + } + const candidate = createRouteLifecycleScenario({ + depth, + branches, + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `fresh`, route: 2_300 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `restore`, route: originalRoute }, + stepsBefore: [childUpdate], + }, + ], + }) + expectEveryRouteTransitionVisible(candidate) + const control: FullRowBatchScenario = { + depth, + steps: [...prefix, childUpdate], + } + + return { + control, + candidate, + } +} + +export function createInitiallySharedRouteResubscribeScenario( + parentLevel: 0 | 1 | 2, +): HistoryScenarioPair { + const depth = (parentLevel + 1) as IncludeDepth + const branches = transitionHistoryBranches + const childLevel = depth + const sharedRoute = branches[0].groupBase + parentLevel + const childId = rowAt(branches, 0, childLevel) + const child = { + ...batchChild(childId, sharedRoute, childId, 0), + group: branches[0].groupBase + childLevel, + } + const scenario = createRouteLifecycleScenario({ + depth, + branches, + prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), + descriptors: [ + { + kind: `rekey`, + level: parentLevel, + row: 0, + destination: { strategy: `split`, route: 2_100 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `fresh`, route: 2_200 + parentLevel }, + }, + { + kind: `rekey`, + level: parentLevel, + row: 1, + destination: { strategy: `restore`, route: sharedRoute }, + stepsBefore: [ + { + level: childLevel, + changes: [ + { + type: `update`, + value: { ...child, value: child.value + 1 }, + }, + ], + }, + ], + }, + ], + }) + expectEveryRouteTransitionVisible(scenario) + return { + control: createSharedRouteLastSubscriberScenario(parentLevel), + candidate: scenario, + } +} + +export type HistoryScenarioPair = { + control: FullRowBatchScenario + greenVariants?: ReadonlyArray + candidate: FullRowBatchScenario +} + +export type RekeyRouteReuseOptions = { + depth: 2 | 3 | 4 + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + rekeyGroup: number + insertedId: number + insertedValue: number + insertedPosition: number +} + +export function createRekeyRouteReuseFixture({ + depth, + sourceBranch, + branches, + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, +}: RekeyRouteReuseOptions): { + prefix: Array + rekey: FullRowBatchStep + reuse: FullRowBatchStep +} { + const targetLevel = (depth - 1) as IncludeDepth + assertDisjointRelationshipKeys(depth, branches, { + extraIds: [insertedId], + extraGroups: [rekeyGroup], + }) + + const prefix = createConnectedBatchBranches(depth, branches) + const source = branches[sourceBranch] + const parentGroup = source.groupBase + targetLevel - 1 + const oldGroup = source.groupBase + targetLevel + const inserted = { + ...batchChild(insertedId, parentGroup, insertedValue, insertedPosition), + group: oldGroup, + } + const insertOldRoute: FullRowBatchStep = { + level: targetLevel, + changes: [{ type: `insert`, value: inserted }], + } + const rekey: FullRowBatchStep = { + level: targetLevel, + changes: [ + { + type: `update`, + value: { + ...batchChild( + source.idBase + targetLevel, + parentGroup, + source.idBase + targetLevel, + 0, + ), + group: rekeyGroup, + }, + }, + ], + } + + return { + prefix, + rekey, + reuse: insertOldRoute, + } +} + +export function createRekeyRouteReuseScenarios( + options: RekeyRouteReuseOptions, +): HistoryScenarioPair { + const { depth } = options + const { prefix, rekey, reuse } = createRekeyRouteReuseFixture(options) + + return { + control: { depth, steps: [...prefix, reuse] }, + candidate: { depth, steps: [...prefix, rekey, reuse] }, + } +} + +export function createIntraBatchRekeyRouteReuseScenarios( + options: RekeyRouteReuseOptions, +): HistoryScenarioPair { + const { depth } = options + const { prefix, rekey, reuse } = createRekeyRouteReuseFixture(options) + if (rekey.level === 0 || rekey.level !== reuse.level) { + throw new Error(`Intra-batch route reuse must share a child level`) + } + + return { + control: { + depth, + steps: [ + ...prefix, + { level: rekey.level, changes: [...reuse.changes, ...rekey.changes] }, + ], + }, + candidate: { + depth, + steps: [ + ...prefix, + { level: rekey.level, changes: [...rekey.changes, ...reuse.changes] }, + ], + }, + } +} + +export function rekeyRouteReuseScenarioArbitrary( + depth: 2 | 3 | 4, + sourceBranch: 0 | 1, + createScenario: ( + options: RekeyRouteReuseOptions, + ) => HistoryScenarioPair = createRekeyRouteReuseScenarios, +): fc.Arbitrary { + return fc + .record({ + ...generatedBranchArbitraries, + rekeyGroup: fc.integer({ min: 2_100, max: 2_600 }), + insertedId: fc.integer({ min: 3_000, max: 3_200 }), + insertedValue: fc.integer({ min: -3, max: 3 }), + insertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, + }) => + createScenario({ + depth, + sourceBranch, + branches: createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }), + rekeyGroup, + insertedId, + insertedValue, + insertedPosition, + }), + ) +} + +export type MovedChildReplacementOptions = { + depth: 3 | 4 + targetLevel: IncludeDepth + sourceBranch: 0 | 1 + branches: readonly [ConnectedBranch, ConnectedBranch] + insertedId: number + insertedValue: number + insertedPosition: number +} + +export function createMovedChildReplacementScenarios({ + depth, + targetLevel, + sourceBranch, + branches, + insertedId, + insertedValue, + insertedPosition, +}: MovedChildReplacementOptions): HistoryScenarioPair { + if (targetLevel + 2 > depth) { + throw new Error(`Child replacement needs a visible grandchild`) + } + assertDisjointRelationshipKeys(depth, branches, { + extraIds: [insertedId], + }) + + const prefix = createConnectedBatchBranches(depth, branches) + const source = branches[sourceBranch] + const destination = branches[otherBranch(sourceBranch)] + const targetGroup = source.groupBase + targetLevel + const childLevel = (targetLevel + 1) as IncludeDepth + const childGroup = source.groupBase + childLevel + const existingChild = { + ...batchChild( + source.idBase + childLevel, + targetGroup, + source.idBase + childLevel, + 0, + ), + group: childGroup, + } + const replacementChild = { + ...batchChild(insertedId, targetGroup, insertedValue, insertedPosition), + group: childGroup, + } + const replaceChild: Array = [ + { + level: childLevel, + changes: [{ type: `delete`, value: existingChild }], + }, + { + level: childLevel, + changes: [{ type: `insert`, value: replacementChild }], + }, + ] + const reparent: FullRowBatchStep = { + level: targetLevel, + changes: [ + { + type: `update`, + value: { + ...batchChild( + source.idBase + targetLevel, + destination.groupBase + targetLevel - 1, + source.idBase + targetLevel, + 0, + ), + group: targetGroup, + }, + }, + ], + } + const atomicReplacement = ( + order: `delete-first` | `insert-first`, + ): FullRowBatchScenario => ({ + depth, + steps: [ + ...prefix, + reparent, + { + level: childLevel, + changes: + order === `delete-first` + ? [ + { type: `delete`, value: existingChild }, + { type: `insert`, value: replacementChild }, + ] + : [ + { type: `insert`, value: replacementChild }, + { type: `delete`, value: existingChild }, + ], + }, + ], + }) + return { + control: { depth, steps: [...prefix, ...replaceChild] }, + greenVariants: [ + atomicReplacement(`delete-first`), + atomicReplacement(`insert-first`), + ], + candidate: { depth, steps: [...prefix, reparent, ...replaceChild] }, + } +} + +export function movedChildReplacementMirrorsArbitrary( + depth: 3 | 4, + targetLevel: IncludeDepth, + sourceBranch: 0 | 1, +): fc.Arbitrary> { + return fc + .record({ + ...generatedBranchArbitraries, + insertedId: fc.integer({ min: 3_000, max: 3_200 }), + insertedValue: fc.integer({ min: -3, max: 3 }), + insertedPosition: fc.integer({ min: -2, max: 2 }), + }) + .map( + ({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + insertedId, + insertedValue, + insertedPosition, + }) => { + const branches = createGeneratedBranches({ + leftIdBase, + leftGroupBase, + rightIdBase, + rightGroupBase, + }) + const createMirror = (deliveryOrder: BranchDeliveryOrder) => + createMovedChildReplacementScenarios({ + depth, + targetLevel, + sourceBranch: deliveredBranchIndex(sourceBranch, deliveryOrder), + branches: deliverBranches(branches, deliveryOrder), + insertedId, + insertedValue, + insertedPosition, + }) + + return { + forward: createMirror(`forward`), + reverse: createMirror(`reverse`), + } + }, + ) +} + +export type RelationshipBatchDelivery = `split` | `atomic` +export type RelationshipBatchOrder = `delete-insert` | `insert-delete` +export type ReplacementPublicId = `same` | `new` +export type ReplacementRoute = `handoff` | `fresh` +export type AncestorUpdateShape = `route-only` | `route-and-position` + +export type RelationshipBatchShape = { + delivery: RelationshipBatchDelivery + order: RelationshipBatchOrder + publicId: ReplacementPublicId + route: ReplacementRoute + ancestorUpdate: AncestorUpdateShape +} + +export type RelationshipBatchShapeOptions = GeneratedBranchOptions & { + shape: RelationshipBatchShape + replacementId: number + replacementGroup: number + replacementValue: number + replacementPosition: number + movedPosition: number +} + +export function createRelationshipBatchShapeScenarios({ + shape, + replacementId, + replacementGroup, + replacementValue, + replacementPosition, + movedPosition, + ...branchOptions +}: RelationshipBatchShapeOptions): HistoryScenarioPair { + const depth = 3 + const branches = createGeneratedBranches(branchOptions) + assertDisjointRelationshipKeys(depth, branches, { + extraIds: shape.publicId === `new` ? [replacementId] : [], + extraGroups: shape.route === `fresh` ? [replacementGroup] : [], + }) + + const source = branches[0] + const destination = branches[1] + const prefix = createConnectedBatchBranches(depth, branches) + const targetLevel = 1 + const childLevel = 2 + const targetId = source.idBase + targetLevel + const childId = source.idBase + childLevel + const targetGroup = source.groupBase + targetLevel + const childGroup = source.groupBase + childLevel + const currentTarget = { + ...batchChild(targetId, source.groupBase, targetId, 0), + group: targetGroup, + } + const movedTarget = { + ...currentTarget, + parentGroup: destination.groupBase, + position: + shape.ancestorUpdate === `route-and-position` + ? movedPosition + : currentTarget.position, + } + const currentChild = { + ...batchChild(childId, targetGroup, childId, 0), + group: childGroup, + } + const replacementChild = { + ...currentChild, + id: shape.publicId === `same` ? childId : replacementId, + group: shape.route === `handoff` ? childGroup : replacementGroup, + value: replacementValue, + position: replacementPosition, + } + const replacementChanges: Array> = + shape.order === `delete-insert` + ? [ + { type: `delete`, value: currentChild }, + { type: `insert`, value: replacementChild }, + ] + : [ + { type: `insert`, value: replacementChild }, + { type: `delete`, value: currentChild }, + ] + const replacementSteps: Array = + shape.delivery === `atomic` + ? [{ level: childLevel, changes: replacementChanges }] + : replacementChanges.map((change): FullRowBatchStep => ({ + level: childLevel, + changes: [change], + })) + const positionOnlyTarget = { + ...currentTarget, + position: movedTarget.position, + } + const controlMoveSteps: Array = + shape.ancestorUpdate === `route-and-position` + ? [ + { + level: targetLevel, + changes: [{ type: `update`, value: positionOnlyTarget }], + }, + ] + : [] + + return { + // The same replacement history without the relationship move is the + // adjacent control for every shape cell. Preserve the position change so + // the only candidate difference is route membership. + control: { + depth, + steps: [...prefix, ...controlMoveSteps, ...replacementSteps], + }, + candidate: { + depth, + steps: [ + ...prefix, + { + level: targetLevel, + changes: [{ type: `update`, value: movedTarget }], + }, + ...replacementSteps, + ], + }, + } +} + +export const relationshipBatchFixtureArbitrary: fc.Arbitrary< + Omit +> = fc.record({ + ...generatedBranchArbitraries, + replacementId: fc.integer({ min: 3_000, max: 3_200 }), + replacementGroup: fc.integer({ min: 2_700, max: 2_900 }), + replacementValue: fc.integer({ min: -3, max: 3 }), + replacementPosition: fc.integer({ min: -2, max: 2 }), + movedPosition: fc.constantFrom(-2, -1, 1, 2), +}) + +export type RelationshipBatchShapeCell = { + shape: RelationshipBatchShape + scenarios: HistoryScenarioPair +} + +export function createRelationshipBatchShapeMatrix( + fixture: Omit, + publicId: ReplacementPublicId, + route: ReplacementRoute, + ancestorUpdate: AncestorUpdateShape, +): Array { + const cells: Array = [] + + for (const delivery of [`split`, `atomic`] as const) { + for (const order of [`delete-insert`, `insert-delete`] as const) { + // Inserting the same public id before the batch retires its existing row is a + // duplicate-key error, not a valid alternate delivery of the same final + // state. The new-id cells exercise insert-before-delete in both forms. + if (publicId === `same` && order === `insert-delete`) continue + const shape = { + delivery, + order, + publicId, + route, + ancestorUpdate, + } satisfies RelationshipBatchShape + cells.push({ + shape, + scenarios: createRelationshipBatchShapeScenarios({ + ...fixture, + shape, + }), + }) + } + } + + return cells +} + +export function createSharedRouteLifetimeScenarios( + parentLevel: 0 | 1, +): HistoryScenarioPair { + if (parentLevel === 0) { + const departed = batchRoot(100, 600, 100, 0) + const remaining = batchRoot(1_100, 600, 1_100, 1) + const child = { ...batchChild(101, 600, 101, 0), group: 601 } + const controlSteps: Array = [ + { level: 0, changes: [{ type: `insert`, value: departed }] }, + { level: 1, changes: [{ type: `insert`, value: child }] }, + { + level: 0, + changes: [{ type: `update`, value: { ...departed, group: 700 } }], + }, + { + level: 1, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ] + const candidateSteps: Array = [ + { + level: 0, + changes: [ + { type: `insert`, value: departed }, + { type: `insert`, value: remaining }, + ], + }, + ...controlSteps.slice(1), + ] + + return { + control: { depth: 1, steps: controlSteps }, + candidate: { depth: 1, steps: candidateSteps }, + } + } + + const root = batchRoot(100, 600, 100, 0) + const departed = { ...batchChild(101, 600, 101, 0), group: 601 } + const remaining = { ...batchChild(1_101, 600, 1_101, 1), group: 601 } + const child = { ...batchChild(102, 601, 102, 0), group: 602 } + const controlSteps: Array = [ + { level: 0, changes: [{ type: `insert`, value: root }] }, + { level: 1, changes: [{ type: `insert`, value: departed }] }, + { level: 2, changes: [{ type: `insert`, value: child }] }, + { + level: 1, + changes: [{ type: `update`, value: { ...departed, group: 700 } }], + }, + { + level: 2, + changes: [ + { type: `update`, value: { ...child, value: child.value + 1 } }, + ], + }, + ] + const candidateSteps: Array = [ + controlSteps[0]!, + { + level: 1, + changes: [ + { type: `insert`, value: departed }, + { type: `insert`, value: remaining }, + ], + }, + ...controlSteps.slice(2), + ] + + return { + control: { depth: 2, steps: controlSteps }, + candidate: { depth: 2, steps: candidateSteps }, + } +} + +export function recomputeFullRowBatchScenario( + { depth, steps }: FullRowBatchScenario, + stepCount: number, +): Array { + const roots = new Map() + const levels = Array.from({ length: 4 }, () => new Map()) + + for (const step of steps.slice(0, stepCount)) { + updateFullRowBatchModels(step, roots, levels) + } + + return recompute(roots, levels, depth) +} diff --git a/packages/db/tests/query/includes-history-grammar.test.ts b/packages/db/tests/query/includes-history-grammar.test.ts new file mode 100644 index 0000000000..7d985b26b2 --- /dev/null +++ b/packages/db/tests/query/includes-history-grammar.test.ts @@ -0,0 +1,415 @@ +// Grammar and model checks for the includes recompute oracle in +// includes-oracle.property.test.ts. They run no production: each builds +// route-history scenarios from the grammar and steps the independent +// relationship model over them, so they test the oracle's own grammar. +import { fc } from '@fast-check/vitest' +import { describe, expect, test } from 'vitest' +import { oracleFixedSeed } from '../oracle-config.js' +import { + createRouteLifecycleScenario, + rowAt, + transitionHistoryBranches, +} from './includes-route-grammar-oracle.js' +import { + branchDeliveryOrders, + createIntraBatchRekeyRouteReuseScenarios, + createMovedChildReplacementScenarios, + createRelationshipBatchShapeMatrix, + createRelationshipBatchShapeScenarios, + createTransitionHistoryScenario, + destinationHistories, + destinationHistoryDescriptors, + expectEveryHistoryStepVisible, + expectEveryRouteTransitionVisible, + hasDirectChild, + independentTransitionDescriptors, + independentTransitionPrefix, + independentTransitionShapes, + movedChildReplacementMirrorsArbitrary, + recomputeFullRowBatchScenario, + rekeyRouteReuseScenarioArbitrary, + relationshipOnly, + transitionHistoryPlacements, + transitionHistoryScenariosArbitrary, +} from './includes-history-grammar-oracle.js' +import type { FullRowBatchScenario } from './includes-route-grammar-oracle.js' +import type { RelationshipBatchShape } from './includes-history-grammar-oracle.js' + +describe(`includes recompute oracle grammar`, () => { + test(`the sibling topology targets rows under one parent`, () => { + const branches = transitionHistoryBranches + const prefix = independentTransitionPrefix(`sibling`, branches) + const levelTwo = prefix.find((step) => step.level === 2) + if (!levelTwo || levelTwo.level !== 2) throw new Error(`Missing level 2`) + const first = levelTwo.changes.find( + (change) => change.value.id === rowAt(branches, 0, 2), + ) + const second = levelTwo.changes.find( + (change) => change.value.id === rowAt(branches, 1, 2), + ) + if (!first || !second) throw new Error(`Missing sibling targets`) + + expect(first.value.parentGroup).toBe(second.value.parentGroup) + }) + + test(`the descendant remains attached before its ancestor moves`, () => { + const branches = transitionHistoryBranches + const scenario = createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors: independentTransitionDescriptors( + `descendant-ancestor`, + branches, + [2_100, 2_500], + ), + }) + const ancestorTransitionStep = scenario.transitionStepIndexes[1] + if (ancestorTransitionStep === undefined) { + throw new Error(`Missing ancestor transition`) + } + const beforeAncestorMove = recomputeFullRowBatchScenario( + scenario, + ancestorTransitionStep, + ) + + expect( + hasDirectChild( + beforeAncestorMove, + rowAt(branches, 0, 1), + rowAt(branches, 0, 2), + ), + ).toBe(true) + }) + + test(`reconstructs every supported two-transition placement`, () => { + // 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 + // composed witnesses at [depth, target] = [3, 1] and [4, 2], but is + // outside this generated green family. + const expectedCounts = { + reparent: { reparent: [1, 5, 9, 13], rekey: [0, 2, 2, 2] }, + rekey: { reparent: [0, 2, 2, 2], rekey: [0, 1, 1, 1] }, + } as const + for (const depth of [1, 2, 3, 4] as const) { + for (const first of [`reparent`, `rekey`] as const) { + for (const second of [`reparent`, `rekey`] as const) { + const placements = transitionHistoryPlacements(depth, first, second) + expect(placements).toHaveLength( + expectedCounts[first][second][depth - 1]!, + ) + const [generated] = fc.sample( + transitionHistoryScenariosArbitrary(depth, first, second, 0), + oracleFixedSeed(1725, { numRuns: 1 }), + ) + expect(generated).toHaveLength(placements.length) + for (const [index, scenario] of generated!.entries()) { + const placement = placements[index]! + const insertedLevels = scenario.steps + .slice(scenario.historyStartStepIndex) + .filter((step) => + step.changes.some((change) => change.type === `insert`), + ) + .map((step) => step.level) + expect(insertedLevels).toEqual(placement.insertedLevels) + expectEveryHistoryStepVisible(scenario) + } + } + } + } + + // A nearby invalid history attempts to rekey without seeding the child + // route. The constructor rejects it before the production driver runs. + expect(() => + createTransitionHistoryScenario({ + depth: 3, + targetLevel: 2, + firstTransition: `rekey`, + secondTransition: `reparent`, + sourceBranch: 0, + branches: transitionHistoryBranches, + rekeyGroups: [2_100, 2_400], + insertedLevels: [2, 2], + insertedValues: [1, 2], + insertedPositions: [-2, 2], + }), + ).toThrow(/Rekey histories must seed the new child route/) + }) + + test(`keeps each route-history topology and destination in the grammar`, () => { + expect(independentTransitionShapes).toEqual([ + `ancestor-descendant`, + `descendant-ancestor`, + `sibling`, + `cross-branch`, + `root`, + ]) + expect(destinationHistories).toEqual([ + `fresh`, + `restore`, + `merge-split`, + `retired`, + ]) + const branches = transitionHistoryBranches + const shapeTraces = independentTransitionShapes.map((shape) => { + const descriptors = independentTransitionDescriptors( + shape, + branches, + [2_100, 2_500], + ) + const scenario = createRouteLifecycleScenario({ + depth: 3, + branches, + prefixSteps: independentTransitionPrefix(shape, branches), + descriptors, + }) + expectEveryRouteTransitionVisible(scenario) + return JSON.stringify( + scenario.transitionStepIndexes.map((stepIndex) => + relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ), + ), + ) + }) + // Removing one named shape drops one distinct public relationship trace. + expect(new Set(shapeTraces).size).toBe(5) + + const destinationTraces = destinationHistories.map((history) => { + const descriptors = destinationHistoryDescriptors( + history, + branches, + 2_100, + ) + const scenario = createRouteLifecycleScenario({ + depth: 3, + branches, + descriptors, + }) + expectEveryRouteTransitionVisible(scenario) + return JSON.stringify( + scenario.transitionStepIndexes.map((stepIndex) => + relationshipOnly( + recomputeFullRowBatchScenario(scenario, stepIndex + 1), + ), + ), + ) + }) + expect(new Set(destinationTraces).size).toBe(4) + + // The fixture bands admit both endpoints without overlapping route keys. + for (const [idBase, groupBase, otherIdBase, otherGroupBase, fresh] of [ + [100, 600, 1_100, 1_600, 2_100], + [500, 1_000, 1_500, 2_000, 2_400], + ] as const) { + const edgeBranches = [ + { idBase, groupBase }, + { idBase: otherIdBase, groupBase: otherGroupBase }, + ] as const + for (const history of destinationHistories) { + const scenario = createRouteLifecycleScenario({ + depth: 3, + branches: edgeBranches, + descriptors: destinationHistoryDescriptors( + history, + edgeBranches, + fresh, + ), + }) + expectEveryRouteTransitionVisible(scenario) + } + } + }) + + test(`reconstructs and distinguishes the split/atomic batch matrix`, () => { + const fixture = { + leftIdBase: 100, + leftGroupBase: 600, + rightIdBase: 1_100, + rightGroupBase: 1_600, + replacementId: 3_000, + replacementGroup: 2_700, + replacementValue: -3, + replacementPosition: -2, + movedPosition: 2, + } + const candidate = ( + change: Partial = {}, + ): FullRowBatchScenario => + createRelationshipBatchShapeScenarios({ + ...fixture, + shape: { + delivery: `split`, + order: `delete-insert`, + publicId: `new`, + route: `handoff`, + ancestorUpdate: `route-only`, + ...change, + }, + }).candidate + const final = (scenario: FullRowBatchScenario) => + recomputeFullRowBatchScenario(scenario, scenario.steps.length) + + let cells = 0 + for (const publicId of [`same`, `new`] as const) { + for (const route of [`handoff`, `fresh`] as const) { + for (const ancestorUpdate of [ + `route-only`, + `route-and-position`, + ] as const) { + const matrix = createRelationshipBatchShapeMatrix( + fixture, + publicId, + route, + ancestorUpdate, + ) + expect(matrix).toHaveLength(publicId === `same` ? 2 : 4) + expect(matrix.map(({ shape }) => shape.delivery)).toContain(`split`) + expect(matrix.map(({ shape }) => shape.delivery)).toContain(`atomic`) + if (publicId === `same`) + expect( + matrix.every(({ shape }) => shape.order === `delete-insert`), + ).toBe(true) + else + expect(matrix.map(({ shape }) => shape.order)).toContain( + `insert-delete`, + ) + cells += matrix.length + } + } + } + expect(cells).toBe(24) + + // Ablating any semantic axis removes a distinct public observation or + // checkpoint. Delivery and order agree only at the terminal state: their + // intermediate cuts, which the production driver checks, differ. + const baseline = candidate() + expect(final(candidate({ publicId: `same` }))).not.toEqual(final(baseline)) + expect(final(candidate({ route: `fresh` }))).not.toEqual(final(baseline)) + expect( + final(candidate({ ancestorUpdate: `route-and-position` })), + ).not.toEqual(final(baseline)) + const atomic = candidate({ delivery: `atomic` }) + expect(atomic.steps).toHaveLength(baseline.steps.length - 1) + expect(final(atomic)).toEqual(final(baseline)) + const insertFirst = candidate({ order: `insert-delete` }) + expect(final(insertFirst)).toEqual(final(baseline)) + expect( + recomputeFullRowBatchScenario(insertFirst, insertFirst.steps.length - 1), + ).not.toEqual( + recomputeFullRowBatchScenario(baseline, baseline.steps.length - 1), + ) + + // Same-id insert-before-delete is excluded from this grammar because its + // intermediate cut can hold two rows with one public key. This check + // proves the generator omits that schedule; it does not test a product + // rejection. The fixture extremes stay within disjoint key bands. + for (const edgeFixture of [ + fixture, + { + ...fixture, + leftIdBase: 500, + leftGroupBase: 1_000, + rightIdBase: 1_500, + rightGroupBase: 2_000, + replacementId: 3_200, + replacementGroup: 2_900, + replacementValue: 3, + replacementPosition: 2, + movedPosition: -2, + }, + ]) { + const matrix = createRelationshipBatchShapeMatrix( + edgeFixture, + `same`, + `handoff`, + `route-only`, + ) + expect(matrix.some(({ shape }) => shape.order === `insert-delete`)).toBe( + false, + ) + for (const { scenarios } of matrix) { + expect(final(scenarios.candidate)).not.toEqual(final(scenarios.control)) + } + } + }) + + test(`keeps retired-route and moved-child matrix cuts distinct`, () => { + for (const depth of [2, 3, 4] as const) { + for (const sourceBranch of [0, 1] as const) { + const [split] = fc.sample( + rekeyRouteReuseScenarioArbitrary(depth, sourceBranch), + oracleFixedSeed(1726, { numRuns: 1 }), + ) + const [atomic] = fc.sample( + rekeyRouteReuseScenarioArbitrary( + depth, + sourceBranch, + createIntraBatchRekeyRouteReuseScenarios, + ), + oracleFixedSeed(1733, { numRuns: 1 }), + ) + expect(split).toBeDefined() + expect(atomic).toBeDefined() + const outcome = (scenario: FullRowBatchScenario) => + relationshipOnly( + recomputeFullRowBatchScenario(scenario, scenario.steps.length), + ) + expect(outcome(split!.candidate)).not.toEqual(outcome(split!.control)) + expect(split!.candidate.steps).toHaveLength( + split!.control.steps.length + 1, + ) + expect(outcome(atomic!.candidate)).toEqual(outcome(atomic!.control)) + const controlLast = atomic!.control.steps.at(-1)! + const candidateLast = atomic!.candidate.steps.at(-1)! + expect(controlLast.changes.map(({ type }) => type)).toEqual([ + `insert`, + `update`, + ]) + expect(candidateLast.changes.map(({ type }) => type)).toEqual([ + `update`, + `insert`, + ]) + } + } + + // Replacing a moved child needs a grandchild that can expose the route. + // This is the depth/target-level range of the generated mirror family. + for (const [depth, targetLevel] of [ + [3, 1], + [4, 1], + [4, 2], + ] as const) { + for (const sourceBranch of [0, 1] as const) { + const [mirrors] = fc.sample( + movedChildReplacementMirrorsArbitrary( + depth, + targetLevel, + sourceBranch, + ), + oracleFixedSeed(1727, { numRuns: 1 }), + ) + for (const order of branchDeliveryOrders) { + const pair = mirrors![order] + const outcome = (scenario: FullRowBatchScenario) => + recomputeFullRowBatchScenario(scenario, scenario.steps.length) + expect(pair.greenVariants).toHaveLength(2) + expect(outcome(pair.candidate)).not.toEqual(outcome(pair.control)) + for (const atomic of pair.greenVariants!) + expect(outcome(atomic)).toEqual(outcome(pair.candidate)) + } + } + } + expect(() => + createMovedChildReplacementScenarios({ + depth: 3, + targetLevel: 2, + sourceBranch: 0, + branches: transitionHistoryBranches, + insertedId: 3_000, + insertedValue: 0, + insertedPosition: 0, + }), + ).toThrow(/needs a visible grandchild/) + }) +}) diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index c72725acdf..0fef93df81 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -1,6 +1,12 @@ -import { fc, test as fcTest } from '@fast-check/vitest' +import { fc } from '@fast-check/vitest' import { describe, expect } from 'vitest' -import { oracleAssertion } from '../oracle-assertion-adapter.js' +import { + forwardAssertionJudgment, + oracleAssertion, + recordAssertionJudgment, + recordedAssertionJudgment, + rejectAt, +} from '../oracle-assertion-adapter.js' import { concat, createLiveQueryCollection, @@ -16,13 +22,11 @@ import { withOracleCleanup, } from '../utils.js' import { - oracleFixedSeed, + oracleProperty, oraclePropertyOptions, oracleReplayRequested, - oracleRoute, oracleRuns, } from '../oracle-config.js' -import { declareOracle } from '../oracle-declaration.js' import { hash } from '../../../db-ivm/src/hashing/hash.js' import { DISTINCT_REACH_STATES, @@ -45,14 +49,10 @@ import { withPendingWorkCleanup, } from './includes-structural-support.js' import { - assertDisjointRelationshipKeys, batchChild, batchRoot, createConnectedBatchBranches, - createRouteLifecycleScenario, createVisibleRelationshipScenario, - otherBranch, - rowAt, transitionHistoryBranches, updateFullRowBatchModels, updateModel, @@ -69,16 +69,40 @@ import { scopedScenarioArbitrary, sortedRequests, } from './includes-scope-identity-oracle.js' +import { + branchDeliveryOrders, + compareRows, + createInitiallySharedRouteResubscribeScenario, + createIntraBatchRekeyRouteReuseScenarios, + createMergeIntoSharedRouteScenarios, + createMovedChildReplacementScenarios, + createRekeyRouteReuseScenarios, + createRelationshipBatchShapeMatrix, + createSharedRouteLastSubscriberScenario, + createSharedRouteLifetimeScenarios, + createSnapshotOnResubscribeScenarios, + createTransitionHistoryScenario, + destinationHistories, + destinationHistoryScenarioArbitrary, + expectEveryHistoryStepVisible, + expectEveryRouteTransitionVisible, + independentTransitionScenarioArbitrary, + independentTransitionShapes, + levelArbitrary, + movedChildReplacementMirrorsArbitrary, + recompute, + recomputeFullRowBatchScenario, + rekeyRouteReuseScenarioArbitrary, + relationshipBatchFixtureArbitrary, + transitionHistoryPlacements, + transitionHistoryScenariosArbitrary, + visibleRelationshipScenarioArbitrary, +} from './includes-history-grammar-oracle.js' import type { - ConnectedBranch, FullRowBatchScenario, FullRowBatchStep, IncludeDepth, - RouteLifecycleScenario, - RouteTransitionDescriptor, VisibleRelationshipScenario, - VisibleRelationshipScenarioOptions, - VisibleScalarNoise, } from './includes-route-grammar-oracle.js' import type { ChildRow, @@ -102,79 +126,18 @@ import type { ScopedShape, } from './includes-scope-identity-oracle.js' -type HistoryAction = { - type: `put` | `delete` | `optimisticConfirm` | `optimisticRollback` - level: 0 | IncludeDepth - id: number - parentGroup: number - group: number - value: number - position: number -} +import type { + HistoryAction, + HistoryScenarioPair, + OracleNode, + VisibleRelationshipTransition, +} from './includes-history-grammar-oracle.js' type Scenario = { depth: IncludeDepth history: Array } -type OracleNode = RootRow & { - children?: Array -} - -type RelationshipNode = { - id: number - value?: unknown - children?: unknown -} - -function isRelationshipNode(value: unknown): value is RelationshipNode { - return ( - typeof value === `object` && - value !== null && - `id` in value && - typeof value.id === `number` - ) -} - -function findRelationshipNode( - value: unknown, - id: number, -): RelationshipNode | undefined { - if (!Array.isArray(value)) return undefined - - for (const entry of value) { - if (!isRelationshipNode(entry)) continue - if (entry.id === id) return entry - const nested = findRelationshipNode(entry.children, id) - if (nested) return nested - } - return undefined -} - -function hasDirectChild(value: unknown, parentId: number, childId: number) { - const parent = findRelationshipNode(value, parentId) - return ( - Array.isArray(parent?.children) && - parent.children.some( - (child) => isRelationshipNode(child) && child.id === childId, - ) - ) -} - -type RelationshipProjectionNode = { - id: number - children?: Array -} - -function relationshipOnly( - nodes: ReadonlyArray, -): Array { - return nodes.map(({ id, children }) => ({ - id, - ...(children ? { children: relationshipOnly(children) } : {}), - })) -} - type MaterializeRoot = { id: number; middleId: number } type MaterializeMiddle = { id: number; sharedId: number } type MaterializeShared = { id: number; leafId: number } @@ -221,21 +184,6 @@ function generatedCampaigns(runs: number, property: string, fixedSeed: number) { return [{ label: `fixed and random`, options: { runs, property, fixedSeed } }] } -function levelArbitrary( - depth: IncludeDepth, -): fc.Arbitrary { - switch (depth) { - case 1: - return fc.constantFrom(0, 1) - case 2: - return fc.constantFrom(0, 1, 2) - case 3: - return fc.constantFrom(0, 1, 2, 3) - case 4: - return fc.constantFrom(0, 1, 2, 3, 4) - } -} - function actionArbitrary(depth: IncludeDepth): fc.Arbitrary { return levelArbitrary(depth).chain((level) => fc.record({ @@ -444,73 +392,6 @@ const confirmedChildReorderSeed: Scenario = { ], } -function compareRows(left: RootRow, right: RootRow): number { - return left.position - right.position || left.id - right.id -} - -// This is intentionally independent of the live-query implementation. It is -// the simple, full-recompute semantics reference for the incremental system. -function recompute( - roots: Map, - levels: Array>, - depth: IncludeDepth, -): Array { - const materializeLevel = ( - level: number, - parentGroup: number, - ): Array => - [...levels[level]!.values()] - .filter((row) => row.parentGroup === parentGroup) - .sort(compareRows) - .map((row) => { - const node: OracleNode = { - id: row.id, - group: row.group, - value: row.value, - position: row.position, - } - if (level + 1 < depth) { - node.children = materializeLevel(level + 1, row.group) - } - return node - }) - - return [...roots.values()].sort(compareRows).map((root) => ({ - id: root.id, - group: root.group, - value: root.value, - position: root.position, - children: materializeLevel(0, root.group), - })) -} - -// 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, - expectFlatMaterializationScenarioMatches, - expectMaterializeScenarioMatches, - ], - model: [ - recompute, - recomputeFullRowBatchScenario, - recomputeFlatMaterialization, - recomputeMaterialize, - ], - - grammar: [independentTransitionPrefix], -}) - function createIncrementalQuery(depth: IncludeDepth, sources: Sources) { const { roots } = sources const [level1, level2, level3, level4] = sources.levels @@ -1212,1599 +1093,148 @@ function fullRowBatchScenarioAtDepthArbitrary( }) } -type VisibleRelationshipTransition = `reparent` | `rekey` -type BranchDeliveryOrder = `forward` | `reverse` -const branchDeliveryOrders: ReadonlyArray = [ - `forward`, - `reverse`, -] - -function deliverBranches( - branches: readonly [ConnectedBranch, ConnectedBranch], - order: BranchDeliveryOrder, -): readonly [ConnectedBranch, ConnectedBranch] { - return order === `forward` ? branches : [branches[1], branches[0]] -} - -function deliveredBranchIndex( - branch: 0 | 1, - order: BranchDeliveryOrder, -): 0 | 1 { - return order === `forward` ? branch : otherBranch(branch) +async function expectFullRowBatchScenarioMatches({ + depth, + steps, +}: FullRowBatchScenario): Promise { + await runTrace({ + steps, + driver: createFullRowBatchTraceDriver(depth), + projection: structuralProjection, + }) } -type VisibleRelationshipScenarios = { - transitionOnly: VisibleRelationshipScenario - stateful: VisibleRelationshipScenario +async function expectHistoryScenarioPairMatches({ + control, + greenVariants = [], + candidate, +}: HistoryScenarioPair): Promise { + await expectFullRowBatchScenarioMatches(control) + for (const greenVariant of greenVariants) { + await expectFullRowBatchScenarioMatches(greenVariant) + } + await expectFullRowBatchScenarioMatches(candidate) } -function visibleRelationshipScenarioArbitrary( - depth: IncludeDepth, - transition: VisibleRelationshipTransition, - targetLevel: IncludeDepth, -): fc.Arbitrary { - const branchArbitrary = fc.constantFrom<0 | 1>(0, 1) - const scalarNoiseArbitrary = ( - side: VisibleScalarNoise[`side`], - branch: fc.Arbitrary<0 | 1>, - ): fc.Arbitrary => - fc.record({ - side: fc.constant(side), - level: levelArbitrary(depth), - branch, - value: fc.integer({ min: -3, max: 3 }), - position: fc.integer({ min: -2, max: 2 }), - }) - - return fc - .record({ - sourceBranch: branchArbitrary, - leftIdBase: fc.integer({ min: 100, max: 500 }), - leftGroupBase: fc.integer({ min: 600, max: 1_000 }), - rightIdBase: fc.integer({ min: 1_100, max: 1_500 }), - rightGroupBase: fc.integer({ min: 1_600, max: 2_000 }), - rekeyGroup: fc.integer({ min: 2_100, max: 2_500 }), - beforeNoise: scalarNoiseArbitrary(`before`, branchArbitrary), - extraBeforeNoise: fc.array( - scalarNoiseArbitrary(`before`, branchArbitrary), - { maxLength: 4 }, - ), - afterValues: fc.array(scalarNoiseArbitrary(`after`, branchArbitrary), { - minLength: 1, - maxLength: 5, - }), - }) - .map( - ({ - sourceBranch, - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - rekeyGroup, - beforeNoise, - extraBeforeNoise, - afterValues, - }) => { - const connectedNoise: Array = [ - beforeNoise, - ...extraBeforeNoise, - ...afterValues, - ] - const options = { - depth, - targetLevel, - sourceBranch, - branches: [ - { idBase: leftIdBase, groupBase: leftGroupBase }, - { idBase: rightIdBase, groupBase: rightGroupBase }, - ], - ...(transition === `rekey` - ? { transition, rekeyGroup } - : { transition }), - } satisfies Omit +type FlatMaterialization = `array` | `concat` - return { - transitionOnly: createVisibleRelationshipScenario({ - ...options, - noise: [], - }), - stateful: createVisibleRelationshipScenario({ - ...options, - noise: connectedNoise, - }), - } - }, +function createFlatMaterializationQuery( + materialization: FlatMaterialization, + sources: Sources, +) { + if (materialization === `array`) { + return createLiveQueryCollection((q) => + q + .from({ root: sources.roots.collection }) + .orderBy(({ root }) => root.position) + .orderBy(({ root }) => root.id) + .select(({ root }) => ({ + id: root.id, + group: root.group, + children: materialize( + q + .from({ child: sources.levels[0].collection }) + .where(({ child }) => eq(child.parentGroup, root.group)) + .orderBy(({ child }) => child.position) + .orderBy(({ child }) => child.id) + .select(({ child }) => ({ id: child.id, value: child.value })), + ), + })), ) -} - -type GeneratedBranchOptions = { - leftIdBase: number - leftGroupBase: number - rightIdBase: number - rightGroupBase: number -} - -const generatedBranchArbitraries = { - leftIdBase: fc.integer({ min: 100, max: 500 }), - leftGroupBase: fc.integer({ min: 600, max: 1_000 }), - rightIdBase: fc.integer({ min: 1_100, max: 1_500 }), - rightGroupBase: fc.integer({ min: 1_600, max: 2_000 }), -} - -function createGeneratedBranches({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, -}: GeneratedBranchOptions): readonly [ConnectedBranch, ConnectedBranch] { - return [ - { idBase: leftIdBase, groupBase: leftGroupBase }, - { idBase: rightIdBase, groupBase: rightGroupBase }, - ] -} + } -type TransitionHistoryScenario = FullRowBatchScenario & { - historyStartStepIndex: number + return createLiveQueryCollection((q) => + q + .from({ root: sources.roots.collection }) + .orderBy(({ root }) => root.position) + .orderBy(({ root }) => root.id) + .select(({ root }) => ({ + id: root.id, + group: root.group, + content: concat( + toArray( + q + .from({ child: sources.levels[0].collection }) + .where(({ child }) => eq(child.parentGroup, root.group)) + .orderBy(({ child }) => child.position) + .orderBy(({ child }) => child.id) + .select(({ child }) => child.value), + ), + ), + })), + ) } -type TransitionHistoryScenarioOptions = { - depth: IncludeDepth - targetLevel: IncludeDepth - firstTransition: VisibleRelationshipTransition - secondTransition: VisibleRelationshipTransition - sourceBranch: 0 | 1 - branches: readonly [ConnectedBranch, ConnectedBranch] - rekeyGroups: readonly [number, number] - insertedLevels: readonly [IncludeDepth, IncludeDepth] - insertedValues: readonly [number, number] - insertedPositions: readonly [number, number] +type FlatMaterializationContext = Omit< + StructuralTraceContext, + `incremental` +> & { + incremental: ReturnType } -function createTransitionHistoryScenario({ - depth, - targetLevel, - firstTransition, - secondTransition, - sourceBranch, - branches, - rekeyGroups, - insertedLevels, - insertedValues, - insertedPositions, -}: TransitionHistoryScenarioOptions): TransitionHistoryScenario { - // Fresh keys keep this matrix green through both transitions. Separate - // state-aware families below reuse retired routes and replace existing rows, - // so those histories remain shrinkable without masking later steps. - const insertedRows = [ - { - id: 3_000, - group: 2_700, - value: insertedValues[0], - position: insertedPositions[0], - }, - { - id: 3_001, - group: 2_800, - value: insertedValues[1], - position: insertedPositions[1], +function createFlatMaterializationDriver( + materialization: FlatMaterialization, +): TraceDriver { + return { + setup: () => { + const sources = createSources(`full`) + return { + depth: 1, + sources, + incremental: createFlatMaterializationQuery(materialization, sources), + roots: new Map(), + levels: Array.from({ length: 4 }, () => new Map()), + pendingWork: new Set(), + } }, - ] as const - assertDisjointRelationshipKeys(depth, branches, { - extraIds: insertedRows.map(({ id }) => id), - extraGroups: [...rekeyGroups, ...insertedRows.map(({ group }) => group)], - }) - - const steps = createConnectedBatchBranches(depth, branches) - const historyStartStepIndex = steps.length - const source = branches[sourceBranch] - let current = { - ...batchChild( - source.idBase + targetLevel, - source.groupBase + targetLevel - 1, - source.idBase + targetLevel, - 0, - ), - group: source.groupBase + targetLevel, - } - const appendStep = ( - level: IncludeDepth, - changes: Array>, - ): void => { - steps.push({ level, changes }) - } - const insertRow = ( - row: (typeof insertedRows)[number], - level: IncludeDepth, - ): ChildRow => { - if (level !== targetLevel && level !== targetLevel + 1) { - throw new Error(`History inserts must touch the target or its child`) - } - return { - ...row, - parentGroup: level === targetLevel ? current.parentGroup : current.group, - } - } - const transition = ( - kind: VisibleRelationshipTransition, - rekeyGroup: number, - ): void => { - const currentParentBranch = branches.findIndex( - ({ groupBase }) => current.parentGroup === groupBase + targetLevel - 1, - ) - if (currentParentBranch !== 0 && currentParentBranch !== 1) { - throw new Error(`Transition target has no visible parent branch`) - } - const destination = branches[otherBranch(currentParentBranch)] - current = { - ...current, - parentGroup: - kind === `reparent` - ? destination.groupBase + targetLevel - 1 - : current.parentGroup, - group: kind === `rekey` ? rekeyGroup : current.group, - } - appendStep(targetLevel, [{ type: `update`, value: current }]) - } - const interleaveTransition = ( - kind: VisibleRelationshipTransition, - rekeyGroup: number, - row: (typeof insertedRows)[number], - level: IncludeDepth, - ): void => { - if (kind === `rekey`) { - if (level !== targetLevel + 1) { - throw new Error(`Rekey histories must seed the new child route`) + start: ({ incremental }) => incremental.preload(), + apply: (step, { sources, roots, levels }) => { + if (step.level === 0) { + sources.roots.writeBatch(step.changes) + updateModel(roots, step.changes) + return } - transition(kind, rekeyGroup) - appendStep(level, [{ type: `insert`, value: insertRow(row, level) }]) - return - } - - const inserted = insertRow(row, level) - appendStep(level, [{ type: `insert`, value: inserted }]) - transition(kind, rekeyGroup) - appendStep(level, [{ type: `delete`, value: inserted }]) - } - - interleaveTransition( - firstTransition, - rekeyGroups[0], - insertedRows[0], - insertedLevels[0], - ) - interleaveTransition( - secondTransition, - rekeyGroups[1], - insertedRows[1], - insertedLevels[1], - ) - return { - depth, - steps, - historyStartStepIndex, + if (step.level !== 1) { + throw new Error(`Flat materialization only supports depth 1`) + } + sources.levels[0].writeBatch(step.changes) + updateModel(levels[0]!, step.changes) + }, + cleanup: cleanupStructuralTrace, } } -function transitionHistoryPlacements( - depth: IncludeDepth, - firstTransition: VisibleRelationshipTransition, - secondTransition: VisibleRelationshipTransition, -): Array<{ - targetLevel: IncludeDepth - insertedLevels: readonly [IncludeDepth, IncludeDepth] -}> { - // A rekey needs one child level so it changes relationship membership. It - // also fails when two descendant levels remain. The single-transition matrix - // covers that failure and will turn red when this continuation can be widened. - const minimumTargetLevel = - firstTransition === `rekey` || secondTransition === `rekey` - ? Math.max(1, depth - 1) - : 1 - const maximumTargetLevel = - firstTransition === `rekey` || secondTransition === `rekey` - ? depth - 1 - : depth - const placements: Array<{ - targetLevel: IncludeDepth - insertedLevels: readonly [IncludeDepth, IncludeDepth] - }> = [] - - for (let level = minimumTargetLevel; level <= maximumTargetLevel; level++) { - const targetLevel = level as IncludeDepth - const insertedLevels = ( - transition: VisibleRelationshipTransition, - ): ReadonlyArray => - transition === `rekey` - ? [(targetLevel + 1) as IncludeDepth] - : targetLevel < depth - ? [targetLevel, (targetLevel + 1) as IncludeDepth] - : [targetLevel] - - for (const firstInsertedLevel of insertedLevels(firstTransition)) { - for (const secondInsertedLevel of insertedLevels(secondTransition)) { - placements.push({ - targetLevel, - insertedLevels: [firstInsertedLevel, secondInsertedLevel], - }) - } +type FlatMaterializationResult = Array< + | { + id: number + group: number + children: Array<{ id: number; value: number }> } - } + | { id: number; group: number; content: string } +> - return placements -} - -function transitionHistoryScenariosArbitrary( - depth: IncludeDepth, - firstTransition: VisibleRelationshipTransition, - secondTransition: VisibleRelationshipTransition, - sourceBranch: 0 | 1, -): fc.Arbitrary> { - const placements = transitionHistoryPlacements( - depth, - firstTransition, - secondTransition, - ) - - return fc - .record({ - ...generatedBranchArbitraries, - firstRekeyGroup: fc.integer({ min: 2_100, max: 2_300 }), - secondRekeyGroup: fc.integer({ min: 2_400, max: 2_600 }), - firstInsertedValue: fc.integer({ min: -3, max: 3 }), - secondInsertedValue: fc.integer({ min: -3, max: 3 }), - firstInsertedPosition: fc.integer({ min: -2, max: 2 }), - secondInsertedPosition: fc.integer({ min: -2, max: 2 }), - }) - .map( - ({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - firstRekeyGroup, - secondRekeyGroup, - firstInsertedValue, - secondInsertedValue, - firstInsertedPosition, - secondInsertedPosition, - }) => - placements.map(({ targetLevel, insertedLevels }) => - createTransitionHistoryScenario({ - depth, - targetLevel, - firstTransition, - secondTransition, - sourceBranch, - branches: createGeneratedBranches({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - }), - rekeyGroups: [firstRekeyGroup, secondRekeyGroup], - insertedLevels, - insertedValues: [firstInsertedValue, secondInsertedValue], - insertedPositions: [firstInsertedPosition, secondInsertedPosition], - }), - ), - ) -} - -function expectEveryHistoryStepVisible( - scenario: TransitionHistoryScenario, -): void { - for ( - let stepIndex = scenario.historyStartStepIndex; - stepIndex < scenario.steps.length; - stepIndex++ - ) { - const before = relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex), - ) - const after = relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex + 1), - ) - oracleAssertion('includes oracle.property comparison 1610', () => - expect(after).not.toEqual(before), - ) - } -} - -function expectEveryRouteTransitionVisible( - scenario: RouteLifecycleScenario, -): void { - for (const stepIndex of scenario.transitionStepIndexes) { - const before = relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex), - ) - const after = relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex + 1), - ) - oracleAssertion('includes oracle.property comparison 1624', () => - expect(after).not.toEqual(before), - ) - } -} - -const independentTransitionShapes = [ - `ancestor-descendant`, - `descendant-ancestor`, - `sibling`, - `cross-branch`, - `root`, -] as const - -type IndependentTransitionShape = (typeof independentTransitionShapes)[number] - -function independentFreshRoutesArbitrary( - shape: IndependentTransitionShape, -): fc.Arbitrary { - const first = fc.integer({ min: 2_100, max: 2_400 }) - if (shape === `sibling`) { - return fc.tuple(first, fc.integer({ min: 2_500, max: 2_800 })) - } - if (shape === `ancestor-descendant` || shape === `root`) { - return first.map((route) => [route, 2_500] as const) - } - return fc.constant([2_100, 2_500] as const) -} - -function independentTransitionDescriptors( - shape: IndependentTransitionShape, - branches: readonly [ConnectedBranch, ConnectedBranch], - freshRoutes: readonly [number, number], -): ReadonlyArray { - switch (shape) { - case `ancestor-descendant`: - return [ - { - kind: `reparent`, - level: 1, - row: 0, - destination: { - strategy: `merge`, - route: branches[1].groupBase, - }, - }, - { - kind: `rekey`, - level: 2, - row: 0, - destination: { strategy: `fresh`, route: freshRoutes[0] }, - }, - ] - case `descendant-ancestor`: - return [ - { - kind: `rekey`, - level: 2, - row: 0, - destination: { strategy: `fresh`, route: freshRoutes[0] }, - }, - { - kind: `reparent`, - level: 1, - row: 0, - destination: { - strategy: `merge`, - route: branches[1].groupBase, - }, - }, - ] - case `sibling`: - return [ - { - kind: `rekey`, - level: 2, - row: 0, - destination: { strategy: `fresh`, route: freshRoutes[0] }, - }, - { - kind: `rekey`, - level: 2, - row: 1, - destination: { strategy: `fresh`, route: freshRoutes[1] }, - }, - ] - case `cross-branch`: - return [ - { - kind: `reparent`, - level: 2, - row: 0, - destination: { - strategy: `merge`, - route: branches[1].groupBase + 1, - }, - }, - { - kind: `reparent`, - level: 2, - row: 1, - destination: { - strategy: `merge`, - route: branches[0].groupBase + 1, - }, - }, - ] - case `root`: - return [ - { - kind: `rekey`, - level: 0, - row: 0, - destination: { strategy: `fresh`, route: freshRoutes[0] }, - }, - { - kind: `reparent`, - level: 1, - row: 1, - destination: { strategy: `merge`, route: freshRoutes[0] }, - }, - ] - } -} - -function independentTransitionPrefix( - shape: IndependentTransitionShape, - branches: readonly [ConnectedBranch, ConnectedBranch], -): Array { - const prefix = createConnectedBatchBranches(3, branches) - if (shape !== `sibling`) return prefix - - const secondSiblingId = rowAt(branches, 1, 2) - return prefix.map((step) => - step.level === 2 - ? { - ...step, - changes: step.changes.map((change) => - change.value.id === secondSiblingId - ? { - ...change, - value: { - ...change.value, - parentGroup: branches[0].groupBase + 1, - }, - } - : change, - ), - } - : step, - ) -} - -function independentTransitionScenarioArbitrary( - shape: IndependentTransitionShape, -): fc.Arbitrary { - return fc - .record({ - ...generatedBranchArbitraries, - freshRoutes: independentFreshRoutesArbitrary(shape), - }) - .map(({ freshRoutes, ...branchOptions }) => { - const branches = createGeneratedBranches(branchOptions) - return createRouteLifecycleScenario({ - depth: 3, - branches, - prefixSteps: independentTransitionPrefix(shape, branches), - descriptors: independentTransitionDescriptors( - shape, - branches, - freshRoutes, - ), - }) - }) -} - -const destinationHistories = [ - `fresh`, - `restore`, - `merge-split`, - `retired`, -] as const - -type DestinationHistory = (typeof destinationHistories)[number] - -function destinationHistoryDescriptors( - history: DestinationHistory, - branches: readonly [ConnectedBranch, ConnectedBranch], - freshRoute: number, -): ReadonlyArray { - const originalRoute = branches[0].groupBase + 2 - const sharedRoute = branches[1].groupBase + 2 - const first: RouteTransitionDescriptor = { - kind: `rekey`, - level: 2, - row: 0, - destination: { strategy: `fresh`, route: freshRoute }, - } - - switch (history) { - case `fresh`: - return [first] - case `restore`: - return [ - first, - { - ...first, - destination: { strategy: `restore`, route: originalRoute }, - }, - ] - case `merge-split`: - return [ - { - ...first, - destination: { strategy: `merge`, route: sharedRoute }, - }, - { - ...first, - destination: { strategy: `split`, route: freshRoute }, - }, - ] - case `retired`: - return [ - first, - { - kind: `rekey`, - level: 2, - row: 1, - destination: { strategy: `retired`, route: originalRoute }, - }, - ] - } -} - -function destinationHistoryScenarioArbitrary( - history: DestinationHistory, -): fc.Arbitrary { - return fc - .record({ - ...generatedBranchArbitraries, - freshRoute: fc.integer({ min: 2_100, max: 2_400 }), - }) - .map(({ freshRoute, ...branchOptions }) => { - const branches = createGeneratedBranches(branchOptions) - return createRouteLifecycleScenario({ - depth: 3, - branches, - descriptors: destinationHistoryDescriptors( - history, - branches, - freshRoute, - ), - }) - }) -} - -function createInitiallySharedRoutePrefix( - depth: IncludeDepth, - parentLevel: 0 | 1 | 2, - branches: readonly [ConnectedBranch, ConnectedBranch], - enteringRow: 0 | 1 = 1, -): Array { - const enteringId = rowAt(branches, enteringRow, parentLevel) - const sharedRoute = branches[otherBranch(enteringRow)].groupBase + parentLevel - return createConnectedBatchBranches(depth, branches).map((step) => { - if (step.level !== parentLevel) return step - - if (step.level === 0) { - return { - level: 0, - changes: step.changes.map((change) => - change.value.id === enteringId - ? { ...change, value: { ...change.value, group: sharedRoute } } - : change, - ), - } - } - - return { - level: step.level, - changes: step.changes.map((change) => - change.value.id === enteringId - ? { ...change, value: { ...change.value, group: sharedRoute } } - : change, - ), - } - }) -} - -function createMergeIntoSharedRouteScenarios( - parentLevel: 0 | 1 | 2, - enteringRow: 0 | 1, -): HistoryScenarioPair { - const depth = (parentLevel + 1) as IncludeDepth - const branches = transitionHistoryBranches - const existingRow = otherBranch(enteringRow) - const sharedRoute = branches[existingRow].groupBase + parentLevel - const candidate = createRouteLifecycleScenario({ - depth, - branches, - descriptors: [ - { - kind: `rekey`, - level: parentLevel, - row: enteringRow, - destination: { strategy: `merge`, route: sharedRoute }, - }, - ], - }) - expectEveryRouteTransitionVisible(candidate) - - return { - control: { - depth, - steps: createInitiallySharedRoutePrefix( - depth, - parentLevel, - branches, - enteringRow, - ), - }, - candidate, - } -} - -function createSharedRouteLastSubscriberScenario( - parentLevel: 0 | 1 | 2, -): FullRowBatchScenario { - const depth = (parentLevel + 1) as IncludeDepth - const branches = transitionHistoryBranches - const childLevel = depth - const sharedRoute = branches[0].groupBase + parentLevel - const childId = rowAt(branches, 0, childLevel) - const child = { - ...batchChild(childId, sharedRoute, childId, 0), - group: branches[0].groupBase + childLevel, - } - const descriptors: ReadonlyArray = [ - { - kind: `rekey`, - level: parentLevel, - row: 0, - destination: { strategy: `split`, route: 2_100 + parentLevel }, - }, - { - kind: `rekey`, - level: parentLevel, - row: 1, - destination: { strategy: `fresh`, route: 2_200 + parentLevel }, - }, - ] - const scenario = createRouteLifecycleScenario({ - depth, - branches, - descriptors, - prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), - trailingSteps: [ - { - level: childLevel, - changes: [ - { type: `update`, value: { ...child, value: child.value + 1 } }, - ], - }, - ], - }) - expectEveryRouteTransitionVisible(scenario) - return scenario -} - -function createSnapshotOnResubscribeScenarios( - parentLevel: 0 | 1 | 2, -): HistoryScenarioPair { - const depth = (parentLevel + 1) as IncludeDepth - const branches = transitionHistoryBranches - const childLevel = depth - const originalRoute = branches[0].groupBase + parentLevel - const childId = rowAt(branches, 0, childLevel) - const child = { - ...batchChild(childId, originalRoute, childId, 0), - group: branches[0].groupBase + childLevel, - } - const updatedChild = { ...child, value: child.value + 1 } - const prefix = createConnectedBatchBranches(depth, branches) - const childUpdate: FullRowBatchStep = { - level: childLevel, - changes: [{ type: `update`, value: updatedChild }], - } - const candidate = createRouteLifecycleScenario({ - depth, - branches, - descriptors: [ - { - kind: `rekey`, - level: parentLevel, - row: 0, - destination: { strategy: `fresh`, route: 2_300 + parentLevel }, - }, - { - kind: `rekey`, - level: parentLevel, - row: 0, - destination: { strategy: `restore`, route: originalRoute }, - stepsBefore: [childUpdate], - }, - ], - }) - expectEveryRouteTransitionVisible(candidate) - const control: FullRowBatchScenario = { - depth, - steps: [...prefix, childUpdate], - } - - return { - control, - candidate, - } -} - -function createInitiallySharedRouteResubscribeScenario( - parentLevel: 0 | 1 | 2, -): HistoryScenarioPair { - const depth = (parentLevel + 1) as IncludeDepth - const branches = transitionHistoryBranches - const childLevel = depth - const sharedRoute = branches[0].groupBase + parentLevel - const childId = rowAt(branches, 0, childLevel) - const child = { - ...batchChild(childId, sharedRoute, childId, 0), - group: branches[0].groupBase + childLevel, - } - const scenario = createRouteLifecycleScenario({ - depth, - branches, - prefixSteps: createInitiallySharedRoutePrefix(depth, parentLevel, branches), - descriptors: [ - { - kind: `rekey`, - level: parentLevel, - row: 0, - destination: { strategy: `split`, route: 2_100 + parentLevel }, - }, - { - kind: `rekey`, - level: parentLevel, - row: 1, - destination: { strategy: `fresh`, route: 2_200 + parentLevel }, - }, - { - kind: `rekey`, - level: parentLevel, - row: 1, - destination: { strategy: `restore`, route: sharedRoute }, - stepsBefore: [ - { - level: childLevel, - changes: [ - { - type: `update`, - value: { ...child, value: child.value + 1 }, - }, - ], - }, - ], - }, - ], - }) - expectEveryRouteTransitionVisible(scenario) - return { - control: createSharedRouteLastSubscriberScenario(parentLevel), - candidate: scenario, - } -} - -type HistoryScenarioPair = { - control: FullRowBatchScenario - greenVariants?: ReadonlyArray - candidate: FullRowBatchScenario -} - -type RekeyRouteReuseOptions = { - depth: 2 | 3 | 4 - sourceBranch: 0 | 1 - branches: readonly [ConnectedBranch, ConnectedBranch] - rekeyGroup: number - insertedId: number - insertedValue: number - insertedPosition: number -} - -function createRekeyRouteReuseFixture({ - depth, - sourceBranch, - branches, - rekeyGroup, - insertedId, - insertedValue, - insertedPosition, -}: RekeyRouteReuseOptions): { - prefix: Array - rekey: FullRowBatchStep - reuse: FullRowBatchStep -} { - const targetLevel = (depth - 1) as IncludeDepth - assertDisjointRelationshipKeys(depth, branches, { - extraIds: [insertedId], - extraGroups: [rekeyGroup], - }) - - const prefix = createConnectedBatchBranches(depth, branches) - const source = branches[sourceBranch] - const parentGroup = source.groupBase + targetLevel - 1 - const oldGroup = source.groupBase + targetLevel - const inserted = { - ...batchChild(insertedId, parentGroup, insertedValue, insertedPosition), - group: oldGroup, - } - const insertOldRoute: FullRowBatchStep = { - level: targetLevel, - changes: [{ type: `insert`, value: inserted }], - } - const rekey: FullRowBatchStep = { - level: targetLevel, - changes: [ - { - type: `update`, - value: { - ...batchChild( - source.idBase + targetLevel, - parentGroup, - source.idBase + targetLevel, - 0, - ), - group: rekeyGroup, - }, - }, - ], - } - - return { - prefix, - rekey, - reuse: insertOldRoute, - } -} - -function createRekeyRouteReuseScenarios( - options: RekeyRouteReuseOptions, -): HistoryScenarioPair { - const { depth } = options - const { prefix, rekey, reuse } = createRekeyRouteReuseFixture(options) - - return { - control: { depth, steps: [...prefix, reuse] }, - candidate: { depth, steps: [...prefix, rekey, reuse] }, - } -} - -function createIntraBatchRekeyRouteReuseScenarios( - options: RekeyRouteReuseOptions, -): HistoryScenarioPair { - const { depth } = options - const { prefix, rekey, reuse } = createRekeyRouteReuseFixture(options) - if (rekey.level === 0 || rekey.level !== reuse.level) { - throw new Error(`Intra-batch route reuse must share a child level`) - } - - return { - control: { - depth, - steps: [ - ...prefix, - { level: rekey.level, changes: [...reuse.changes, ...rekey.changes] }, - ], - }, - candidate: { - depth, - steps: [ - ...prefix, - { level: rekey.level, changes: [...rekey.changes, ...reuse.changes] }, - ], - }, - } -} - -function rekeyRouteReuseScenarioArbitrary( - depth: 2 | 3 | 4, - sourceBranch: 0 | 1, - createScenario: ( - options: RekeyRouteReuseOptions, - ) => HistoryScenarioPair = createRekeyRouteReuseScenarios, -): fc.Arbitrary { - return fc - .record({ - ...generatedBranchArbitraries, - rekeyGroup: fc.integer({ min: 2_100, max: 2_600 }), - insertedId: fc.integer({ min: 3_000, max: 3_200 }), - insertedValue: fc.integer({ min: -3, max: 3 }), - insertedPosition: fc.integer({ min: -2, max: 2 }), - }) - .map( - ({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - rekeyGroup, - insertedId, - insertedValue, - insertedPosition, - }) => - createScenario({ - depth, - sourceBranch, - branches: createGeneratedBranches({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - }), - rekeyGroup, - insertedId, - insertedValue, - insertedPosition, - }), - ) -} - -type MovedChildReplacementOptions = { - depth: 3 | 4 - targetLevel: IncludeDepth - sourceBranch: 0 | 1 - branches: readonly [ConnectedBranch, ConnectedBranch] - insertedId: number - insertedValue: number - insertedPosition: number -} - -function createMovedChildReplacementScenarios({ - depth, - targetLevel, - sourceBranch, - branches, - insertedId, - insertedValue, - insertedPosition, -}: MovedChildReplacementOptions): HistoryScenarioPair { - if (targetLevel + 2 > depth) { - throw new Error(`Child replacement needs a visible grandchild`) - } - assertDisjointRelationshipKeys(depth, branches, { - extraIds: [insertedId], - }) - - const prefix = createConnectedBatchBranches(depth, branches) - const source = branches[sourceBranch] - const destination = branches[otherBranch(sourceBranch)] - const targetGroup = source.groupBase + targetLevel - const childLevel = (targetLevel + 1) as IncludeDepth - const childGroup = source.groupBase + childLevel - const existingChild = { - ...batchChild( - source.idBase + childLevel, - targetGroup, - source.idBase + childLevel, - 0, - ), - group: childGroup, - } - const replacementChild = { - ...batchChild(insertedId, targetGroup, insertedValue, insertedPosition), - group: childGroup, - } - const replaceChild: Array = [ - { - level: childLevel, - changes: [{ type: `delete`, value: existingChild }], - }, - { - level: childLevel, - changes: [{ type: `insert`, value: replacementChild }], - }, - ] - const reparent: FullRowBatchStep = { - level: targetLevel, - changes: [ - { - type: `update`, - value: { - ...batchChild( - source.idBase + targetLevel, - destination.groupBase + targetLevel - 1, - source.idBase + targetLevel, - 0, - ), - group: targetGroup, - }, - }, - ], - } - const atomicReplacement = ( - order: `delete-first` | `insert-first`, - ): FullRowBatchScenario => ({ - depth, - steps: [ - ...prefix, - reparent, - { - level: childLevel, - changes: - order === `delete-first` - ? [ - { type: `delete`, value: existingChild }, - { type: `insert`, value: replacementChild }, - ] - : [ - { type: `insert`, value: replacementChild }, - { type: `delete`, value: existingChild }, - ], - }, - ], - }) - return { - control: { depth, steps: [...prefix, ...replaceChild] }, - greenVariants: [ - atomicReplacement(`delete-first`), - atomicReplacement(`insert-first`), - ], - candidate: { depth, steps: [...prefix, reparent, ...replaceChild] }, - } -} - -function movedChildReplacementMirrorsArbitrary( - depth: 3 | 4, - targetLevel: IncludeDepth, - sourceBranch: 0 | 1, -): fc.Arbitrary> { - return fc - .record({ - ...generatedBranchArbitraries, - insertedId: fc.integer({ min: 3_000, max: 3_200 }), - insertedValue: fc.integer({ min: -3, max: 3 }), - insertedPosition: fc.integer({ min: -2, max: 2 }), - }) - .map( - ({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - insertedId, - insertedValue, - insertedPosition, - }) => { - const branches = createGeneratedBranches({ - leftIdBase, - leftGroupBase, - rightIdBase, - rightGroupBase, - }) - const createMirror = (deliveryOrder: BranchDeliveryOrder) => - createMovedChildReplacementScenarios({ - depth, - targetLevel, - sourceBranch: deliveredBranchIndex(sourceBranch, deliveryOrder), - branches: deliverBranches(branches, deliveryOrder), - insertedId, - insertedValue, - insertedPosition, - }) - - return { - forward: createMirror(`forward`), - reverse: createMirror(`reverse`), - } - }, - ) -} - -type RelationshipBatchDelivery = `split` | `atomic` -type RelationshipBatchOrder = `delete-insert` | `insert-delete` -type ReplacementPublicId = `same` | `new` -type ReplacementRoute = `handoff` | `fresh` -type AncestorUpdateShape = `route-only` | `route-and-position` - -type RelationshipBatchShape = { - delivery: RelationshipBatchDelivery - order: RelationshipBatchOrder - publicId: ReplacementPublicId - route: ReplacementRoute - ancestorUpdate: AncestorUpdateShape -} - -type RelationshipBatchShapeOptions = GeneratedBranchOptions & { - shape: RelationshipBatchShape - replacementId: number - replacementGroup: number - replacementValue: number - replacementPosition: number - movedPosition: number -} - -function createRelationshipBatchShapeScenarios({ - shape, - replacementId, - replacementGroup, - replacementValue, - replacementPosition, - movedPosition, - ...branchOptions -}: RelationshipBatchShapeOptions): HistoryScenarioPair { - const depth = 3 - const branches = createGeneratedBranches(branchOptions) - assertDisjointRelationshipKeys(depth, branches, { - extraIds: shape.publicId === `new` ? [replacementId] : [], - extraGroups: shape.route === `fresh` ? [replacementGroup] : [], - }) - - const source = branches[0] - const destination = branches[1] - const prefix = createConnectedBatchBranches(depth, branches) - const targetLevel = 1 - const childLevel = 2 - const targetId = source.idBase + targetLevel - const childId = source.idBase + childLevel - const targetGroup = source.groupBase + targetLevel - const childGroup = source.groupBase + childLevel - const currentTarget = { - ...batchChild(targetId, source.groupBase, targetId, 0), - group: targetGroup, - } - const movedTarget = { - ...currentTarget, - parentGroup: destination.groupBase, - position: - shape.ancestorUpdate === `route-and-position` - ? movedPosition - : currentTarget.position, - } - const currentChild = { - ...batchChild(childId, targetGroup, childId, 0), - group: childGroup, - } - const replacementChild = { - ...currentChild, - id: shape.publicId === `same` ? childId : replacementId, - group: shape.route === `handoff` ? childGroup : replacementGroup, - value: replacementValue, - position: replacementPosition, - } - const replacementChanges: Array> = - shape.order === `delete-insert` - ? [ - { type: `delete`, value: currentChild }, - { type: `insert`, value: replacementChild }, - ] - : [ - { type: `insert`, value: replacementChild }, - { type: `delete`, value: currentChild }, - ] - const replacementSteps: Array = - shape.delivery === `atomic` - ? [{ level: childLevel, changes: replacementChanges }] - : replacementChanges.map((change): FullRowBatchStep => ({ - level: childLevel, - changes: [change], - })) - const positionOnlyTarget = { - ...currentTarget, - position: movedTarget.position, - } - const controlMoveSteps: Array = - shape.ancestorUpdate === `route-and-position` - ? [ - { - level: targetLevel, - changes: [{ type: `update`, value: positionOnlyTarget }], - }, - ] - : [] - - return { - // The same replacement history without the relationship move is the - // adjacent control for every shape cell. Preserve the position change so - // the only candidate difference is route membership. - control: { - depth, - steps: [...prefix, ...controlMoveSteps, ...replacementSteps], - }, - candidate: { - depth, - steps: [ - ...prefix, - { - level: targetLevel, - changes: [{ type: `update`, value: movedTarget }], - }, - ...replacementSteps, - ], - }, - } -} - -const relationshipBatchFixtureArbitrary: fc.Arbitrary< - Omit -> = fc.record({ - ...generatedBranchArbitraries, - replacementId: fc.integer({ min: 3_000, max: 3_200 }), - replacementGroup: fc.integer({ min: 2_700, max: 2_900 }), - replacementValue: fc.integer({ min: -3, max: 3 }), - replacementPosition: fc.integer({ min: -2, max: 2 }), - movedPosition: fc.constantFrom(-2, -1, 1, 2), -}) - -type RelationshipBatchShapeCell = { - shape: RelationshipBatchShape - scenarios: HistoryScenarioPair -} - -function createRelationshipBatchShapeMatrix( - fixture: Omit, - publicId: ReplacementPublicId, - route: ReplacementRoute, - ancestorUpdate: AncestorUpdateShape, -): Array { - const cells: Array = [] - - for (const delivery of [`split`, `atomic`] as const) { - for (const order of [`delete-insert`, `insert-delete`] as const) { - // Inserting the same public id before the batch retires its existing row is a - // duplicate-key error, not a valid alternate delivery of the same final - // state. The new-id cells exercise insert-before-delete in both forms. - if (publicId === `same` && order === `insert-delete`) continue - const shape = { - delivery, - order, - publicId, - route, - ancestorUpdate, - } satisfies RelationshipBatchShape - cells.push({ - shape, - scenarios: createRelationshipBatchShapeScenarios({ - ...fixture, - shape, - }), - }) - } - } - - return cells -} - -function createSharedRouteLifetimeScenarios( - parentLevel: 0 | 1, -): HistoryScenarioPair { - if (parentLevel === 0) { - const departed = batchRoot(100, 600, 100, 0) - const remaining = batchRoot(1_100, 600, 1_100, 1) - const child = { ...batchChild(101, 600, 101, 0), group: 601 } - const controlSteps: Array = [ - { level: 0, changes: [{ type: `insert`, value: departed }] }, - { level: 1, changes: [{ type: `insert`, value: child }] }, - { - level: 0, - changes: [{ type: `update`, value: { ...departed, group: 700 } }], - }, - { - level: 1, - changes: [ - { type: `update`, value: { ...child, value: child.value + 1 } }, - ], - }, - ] - const candidateSteps: Array = [ - { - level: 0, - changes: [ - { type: `insert`, value: departed }, - { type: `insert`, value: remaining }, - ], - }, - ...controlSteps.slice(1), - ] - - return { - control: { depth: 1, steps: controlSteps }, - candidate: { depth: 1, steps: candidateSteps }, - } - } - - const root = batchRoot(100, 600, 100, 0) - const departed = { ...batchChild(101, 600, 101, 0), group: 601 } - const remaining = { ...batchChild(1_101, 600, 1_101, 1), group: 601 } - const child = { ...batchChild(102, 601, 102, 0), group: 602 } - const controlSteps: Array = [ - { level: 0, changes: [{ type: `insert`, value: root }] }, - { level: 1, changes: [{ type: `insert`, value: departed }] }, - { level: 2, changes: [{ type: `insert`, value: child }] }, - { - level: 1, - changes: [{ type: `update`, value: { ...departed, group: 700 } }], - }, - { - level: 2, - changes: [ - { type: `update`, value: { ...child, value: child.value + 1 } }, - ], - }, - ] - const candidateSteps: Array = [ - controlSteps[0]!, - { - level: 1, - changes: [ - { type: `insert`, value: departed }, - { type: `insert`, value: remaining }, - ], - }, - ...controlSteps.slice(2), - ] - - return { - control: { depth: 2, steps: controlSteps }, - candidate: { depth: 2, steps: candidateSteps }, - } -} - -async function expectFullRowBatchScenarioMatches({ - depth, - steps, -}: FullRowBatchScenario): Promise { - await runTrace({ - steps, - driver: createFullRowBatchTraceDriver(depth), - projection: structuralProjection, - }) -} - -async function expectHistoryScenarioPairMatches({ - control, - greenVariants = [], - candidate, -}: HistoryScenarioPair): Promise { - await expectFullRowBatchScenarioMatches(control) - for (const greenVariant of greenVariants) { - await expectFullRowBatchScenarioMatches(greenVariant) - } - await expectFullRowBatchScenarioMatches(candidate) -} - -function recomputeFullRowBatchScenario( - { depth, steps }: FullRowBatchScenario, - stepCount: number, -): Array { - const roots = new Map() - const levels = Array.from({ length: 4 }, () => new Map()) - - for (const step of steps.slice(0, stepCount)) { - updateFullRowBatchModels(step, roots, levels) - } - - return recompute(roots, levels, depth) -} - -type FlatMaterialization = `array` | `concat` - -function createFlatMaterializationQuery( - materialization: FlatMaterialization, - sources: Sources, -) { - if (materialization === `array`) { - return createLiveQueryCollection((q) => - q - .from({ root: sources.roots.collection }) - .orderBy(({ root }) => root.position) - .orderBy(({ root }) => root.id) - .select(({ root }) => ({ - id: root.id, - group: root.group, - children: materialize( - q - .from({ child: sources.levels[0].collection }) - .where(({ child }) => eq(child.parentGroup, root.group)) - .orderBy(({ child }) => child.position) - .orderBy(({ child }) => child.id) - .select(({ child }) => ({ id: child.id, value: child.value })), - ), - })), - ) - } - - return createLiveQueryCollection((q) => - q - .from({ root: sources.roots.collection }) - .orderBy(({ root }) => root.position) - .orderBy(({ root }) => root.id) - .select(({ root }) => ({ - id: root.id, - group: root.group, - content: concat( - toArray( - q - .from({ child: sources.levels[0].collection }) - .where(({ child }) => eq(child.parentGroup, root.group)) - .orderBy(({ child }) => child.position) - .orderBy(({ child }) => child.id) - .select(({ child }) => child.value), - ), - ), - })), - ) -} - -type FlatMaterializationContext = Omit< - StructuralTraceContext, - `incremental` -> & { - incremental: ReturnType -} - -function createFlatMaterializationDriver( - materialization: FlatMaterialization, -): TraceDriver { - return { - setup: () => { - const sources = createSources(`full`) - return { - depth: 1, - sources, - incremental: createFlatMaterializationQuery(materialization, sources), - roots: new Map(), - levels: Array.from({ length: 4 }, () => new Map()), - pendingWork: new Set(), - } - }, - start: ({ incremental }) => incremental.preload(), - apply: (step, { sources, roots, levels }) => { - if (step.level === 0) { - sources.roots.writeBatch(step.changes) - updateModel(roots, step.changes) - return - } - - if (step.level !== 1) { - throw new Error(`Flat materialization only supports depth 1`) - } - sources.levels[0].writeBatch(step.changes) - updateModel(levels[0]!, step.changes) - }, - cleanup: cleanupStructuralTrace, - } -} - -type FlatMaterializationResult = Array< - | { - id: number - group: number - children: Array<{ id: number; value: number }> - } - | { id: number; group: number; content: string } -> - -function recomputeFlatMaterialization( - materialization: FlatMaterialization, - roots: Map, - children: Map, -): FlatMaterializationResult { - return [...roots.values()].sort(compareRows).map((root) => { - const matching = [...children.values()] - .filter((child) => child.parentGroup === root.group) - .sort(compareRows) - return materialization === `array` - ? { - id: root.id, - group: root.group, - children: matching.map(({ id, value }) => ({ id, value })), - } - : { - id: root.id, - group: root.group, - content: matching.map(({ value }) => String(value)).join(``), - } - }) +function recomputeFlatMaterialization( + materialization: FlatMaterialization, + roots: Map, + children: Map, +): FlatMaterializationResult { + return [...roots.values()].sort(compareRows).map((root) => { + const matching = [...children.values()] + .filter((child) => child.parentGroup === root.group) + .sort(compareRows) + return materialization === `array` + ? { + id: root.id, + group: root.group, + children: matching.map(({ id, value }) => ({ id, value })), + } + : { + id: root.id, + group: root.group, + content: matching.map(({ value }) => String(value)).join(``), + } + }) } function flatMaterializationProjection( @@ -3138,715 +1568,865 @@ const intraBatchChildHandOffScenario: FullRowBatchScenario = { ], } -function createReparentedSubtreeUpdateScenario( - depth: 3 | 4, - targetLevel: 1 | 2, -): VisibleRelationshipScenario { - return createVisibleRelationshipScenario({ +function createReparentedSubtreeUpdateScenario( + depth: 3 | 4, + targetLevel: 1 | 2, +): VisibleRelationshipScenario { + return createVisibleRelationshipScenario({ + depth, + transition: `reparent`, + targetLevel, + sourceBranch: 0, + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, + ], + noise: [targetLevel + 1, targetLevel + 2].map((level) => ({ + side: `after`, + level: level as IncludeDepth, + branch: 0, + value: 1, + position: 0, + })), + }) +} + +const minimalRekeyScenario = createVisibleRelationshipScenario({ + depth: 3, + transition: `rekey`, + targetLevel: 1, + sourceBranch: 0, + branches: [ + { idBase: 100, groupBase: 600 }, + { idBase: 1_100, groupBase: 1_600 }, + ], + rekeyGroup: 2_100, + noise: [], +}) + +const { + control: rekeyRouteReuseControl, + candidate: rekeyRouteResurrectionScenario, +} = createRekeyRouteReuseScenarios({ + depth: 2, + sourceBranch: 0, + branches: transitionHistoryBranches, + rekeyGroup: 2_100, + insertedId: 3_000, + insertedValue: 0, + insertedPosition: 0, +}) + +const { + control: childReplacementControl, + candidate: movedSubtreeChildReplacementScenario, +} = createMovedChildReplacementScenarios({ + depth: 3, + targetLevel: 1, + sourceBranch: 0, + branches: transitionHistoryBranches, + insertedId: 3_000, + insertedValue: 0, + insertedPosition: 0, +}) + +const optimisticCutsLaw = `An optimistic rollback publishes checkpoints only when it is delivered and changes a route; a rollback of an absent row or a no-op publishes none, and every settled cut restores the synced rows.` +const applyCheckpointsLaw = `The structural trace driver checks every ordinary root and child change at one synchronous checkpoint before apply yields.` +const omittedChildLaw = `After each source change, the live include rows equal the recomputed relationship tree at that public checkpoint.` +const failedOptimisticCleanupLaw = `A failed optimistic checkpoint fails its transaction, leaves no pending work, and cleans up every Collection, keeping the checkpoint failure and suppressing a later cleanup failure.` +const composedTransitionLaw = `Composed rekey and reparent transitions with two descendant levels match recomputation at every visible step.` +const materializeReferenceUpdateLaw = `Nested scalar materialization follows a reference update to the redirected shared row.` +const incrementalLaw = `The live include rows equal a naive recomputation after every incremental change.` +const fullRowBatchLaw = `Visible multi-row source batches publish the recomputed relationship tree at every checkpoint.` +const historyPairLaw = `A shared-route lifecycle history and its controls each publish the recomputed relationship tree at every checkpoint.` +const flatMaterializationLaw = `Flat array and concat materializations follow an intra-batch child hand-off.` +const materializeLaw = `Nested scalar materialization publishes the recomputed deepest row.` + +const composedTransitionCells = ( + [ + [3, 1], + [4, 2], + ] as const +).flatMap(([depth, targetLevel]) => + ([0, 1] as const).flatMap((sourceBranch) => + ( + [ + [`rekey`, `rekey`], + [`rekey`, `reparent`], + [`reparent`, `rekey`], + ] as const + ).map(([firstTransition, secondTransition]) => ({ + depth, + targetLevel, + sourceBranch, + firstTransition, + secondTransition, + })), + ), +) + +// The former fixed-history body, unchanged. +async function judgeOptimisticCuts(): Promise { + const driver = createStructuralTraceDriver(1) + const row = { id: 1, parentGroup: 1, group: 1, value: 1, position: 0 } + const history: Array = [ + { ...row, type: `put`, level: 0 }, + { ...row, type: `put`, level: 1 }, + { ...row, type: `optimisticRollback`, level: 0, id: 999 }, + { ...row, type: `optimisticRollback`, level: 0 }, + { ...row, type: `optimisticRollback`, level: 0, group: 2 }, + { ...row, type: `optimisticRollback`, level: 0 }, + ] + expect(classifyScenarioSyntax({ depth: 1, history })).toEqual([ + `depth=1`, + `syntactic-route-changes=many`, + `has-optimistic-syntax=true`, + `has-delete-syntax=false`, + ]) + const observations: Array<{ + cuts: Array<{ group: number | undefined; childIds: Array }> + settledGroup: number | undefined + settledChildIds: Array + absentRoot: boolean + }> = [] + await runTrace({ + steps: history, + driver: { + ...driver, + apply: async (action, context, checkpoint) => { + const observe = () => ({ + group: context.sources.roots.collection.get(1)?.group, + childIds: + [...context.incremental.values()][0]?.children.map( + (child) => child.id, + ) ?? [], + }) + const cuts: Array> = [] + await driver.apply(action, context, () => { + checkpoint() + cuts.push(observe()) + }) + const settled = observe() + observations.push({ + cuts, + settledGroup: settled.group, + settledChildIds: settled.childIds, + absentRoot: !context.sources.roots.collection.has(999), + }) + }, + }, + projection: structuralProjection, + }) + const restored = { + settledGroup: 1, + settledChildIds: [1], + absentRoot: true, + } + expect(observations.slice(2)).toEqual([ + { ...restored, cuts: [] }, + { ...restored, cuts: [] }, + { + ...restored, + cuts: [ + { group: 2, childIds: [] }, + { group: 1, childIds: [1] }, + ], + }, + { ...restored, cuts: [] }, + ]) +} + +// The former fixed-history body, unchanged. +async function judgeApplyCheckpoints(): Promise { + const driver = createStructuralTraceDriver(1) + const row = { id: 1, parentGroup: 1, group: 1, value: 1, position: 0 } + const history: Array = [ + { ...row, type: `put`, level: 0 }, + { ...row, type: `put`, level: 1 }, + { ...row, type: `delete`, level: 1 }, + { ...row, type: `delete`, level: 0 }, + ] + const beforeAwait: Array = [] + await runTrace({ + steps: history, + driver: { + ...driver, + apply: (action, context, checkpoint) => { + let checkpoints = 0 + const result = driver.apply(action, context, () => { + checkpoints++ + return checkpoint() + }) + beforeAwait.push(checkpoints) + return result + }, + }, + projection: structuralProjection, + }) + expect(beforeAwait).toEqual([1, 1, 1, 1]) +} + +// The history that the omitted-child calibration corrupts. +const omittedChildRow = { + id: 1, + parentGroup: 1, + group: 1, + value: 1, + position: 0, +} +const omittedChildHistories = [ + { + row: omittedChildRow, + steps: [ + { ...omittedChildRow, type: `put`, level: 0 }, + { ...omittedChildRow, type: `put`, level: 1 }, + ] as Array, + }, +] + +// The former case body, unchanged, for one level and cleanup-failure cell. +async function judgeFailedOptimisticCleanup( + level: 0 | 1, + cleanupFails: boolean, +): Promise { + const driver = createStructuralTraceDriver(1) + const failure = new Error(`Core checkpoint failed`) + const cleanupFailure = new Error(`Core live cleanup failed after disposal`) + let context: StructuralTraceContext | undefined + let transaction: PendingTransaction | undefined + const put = (target: 0 | 1): HistoryAction => ({ + type: `put`, + level: target, + id: 1, + parentGroup: 1, + group: 1, + value: 1, + position: 0, + }) + await expect( + runTrace({ + steps: [ + put(0), + put(1), + { ...put(level), type: `optimisticConfirm`, value: 2 }, + ], + driver: { + ...driver, + setup: () => { + const current = createStructuralTraceContext(1) + context = current + if (cleanupFails) { + const cleanup = current.incremental.cleanup.bind( + current.incremental, + ) + current.incremental.cleanup = async () => { + await cleanup() + throw cleanupFailure + } + } + return current + }, + }, + projection: { + ...structuralProjection, + assertEqual: (actual, expected) => { + structuralProjection.assertEqual(actual, expected) + const entry = [...context!.pendingWork][0] + if (entry) { + transaction = entry.transaction + throw failure + } + }, + }, + }), + ).rejects.toBe(failure) + expect( + (failure as Error & { suppressed?: Array }).suppressed, + ).toEqual(cleanupFails ? [cleanupFailure] : undefined) + if (!context) throw new Error(`Expected core trace context`) + expect(transaction?.state).toBe(`failed`) + expect(context.pendingWork.size).toBe(0) + for (const collection of [ + context.incremental, + context.sources.roots.collection, + ...context.sources.levels.map((source) => source.collection), + ]) + expect(collection.status).toBe(`cleaned-up`) +} + +// The former fixed history's scenario, built unchanged; the shared full-row +// batch check replays it. +function createRootEnteringLiveRouteScenario(): FullRowBatchScenario { + const branches = transitionHistoryBranches + const prefix = createConnectedBatchBranches(1, branches) + const childStep = prefix.find((step) => step.level === 1) + if (!childStep || childStep.level !== 1) throw new Error(`Missing children`) + const scenario: FullRowBatchScenario = { + depth: 1, + steps: [ + prefix[0]!, + { + level: 1, + changes: [ + ...childStep.changes, + { + type: `insert`, + value: { + ...batchChild(3_000, branches[0].groupBase, 3_000, -1), + group: 2_500, + }, + }, + ], + }, + { + level: 0, + changes: [ + { + type: `update`, + value: batchRoot( + branches[1].idBase, + branches[0].groupBase, + branches[1].idBase, + 0, + ), + }, + ], + }, + ], + } + return scenario +} + +// The former case body, unchanged, for one composed-transition cell. +async function judgeComposedTransition({ + depth, + targetLevel, + sourceBranch, + firstTransition, + secondTransition, +}: (typeof composedTransitionCells)[number]): Promise { + const childLevel = (targetLevel + 1) as IncludeDepth + const scenario = createTransitionHistoryScenario({ depth, - transition: `reparent`, targetLevel, - sourceBranch: 0, - branches: [ - { idBase: 100, groupBase: 600 }, - { idBase: 1_100, groupBase: 1_600 }, - ], - noise: [targetLevel + 1, targetLevel + 2].map((level) => ({ - side: `after`, - level: level as IncludeDepth, - branch: 0, - value: 1, - position: 0, - })), + firstTransition, + secondTransition, + sourceBranch, + branches: transitionHistoryBranches, + rekeyGroups: [2_100, 2_400], + insertedLevels: [childLevel, childLevel], + insertedValues: [31, 47], + insertedPositions: [1, -1], }) + expectEveryHistoryStepVisible(scenario) + await expectFullRowBatchScenarioMatches(scenario) } -const minimalRekeyScenario = createVisibleRelationshipScenario({ - depth: 3, - transition: `rekey`, - targetLevel: 1, - sourceBranch: 0, - branches: [ - { idBase: 100, groupBase: 600 }, - { idBase: 1_100, groupBase: 1_600 }, - ], - rekeyGroup: 2_100, - noise: [], -}) - -const { - control: rekeyRouteReuseControl, - candidate: rekeyRouteResurrectionScenario, -} = createRekeyRouteReuseScenarios({ - depth: 2, - sourceBranch: 0, - branches: transitionHistoryBranches, - rekeyGroup: 2_100, - insertedId: 3_000, - insertedValue: 0, - insertedPosition: 0, -}) - -const { - control: childReplacementControl, - candidate: movedSubtreeChildReplacementScenario, -} = createMovedChildReplacementScenarios({ - depth: 3, - targetLevel: 1, - sourceBranch: 0, - branches: transitionHistoryBranches, - insertedId: 3_000, - insertedValue: 0, - insertedPosition: 0, -}) +const materializeReferenceUpdateHistories = [ + { + name: `nested scalar materialization follows a reference update`, + steps: [ + { type: `insert`, insert: `root-1` }, + { type: `insert`, insert: `middle-1` }, + { type: `insert`, insert: `shared-1` }, + { type: `insert`, insert: `leaf-1` }, + { type: `redirectMiddle`, id: 1, sharedId: 2 }, + ] as Array, + }, +] -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 = [ - { ...row, type: `put`, level: 0 }, - { ...row, type: `put`, level: 1 }, - { ...row, type: `optimisticRollback`, level: 0, id: 999 }, - { ...row, type: `optimisticRollback`, level: 0 }, - { ...row, type: `optimisticRollback`, level: 0, group: 2 }, - { ...row, type: `optimisticRollback`, level: 0 }, - ] - expect(classifyScenarioSyntax({ depth: 1, history })).toEqual([ - `depth=1`, - `syntactic-route-changes=many`, - `has-optimistic-syntax=true`, - `has-delete-syntax=false`, - ]) - const observations: Array<{ - cuts: Array<{ group: number | undefined; childIds: Array }> - settledGroup: number | undefined - settledChildIds: Array - absentRoot: boolean - }> = [] - await runTrace({ - steps: history, - driver: { - ...driver, - apply: async (action, context, checkpoint) => { - const observe = () => ({ - group: context.sources.roots.collection.get(1)?.group, - childIds: - [...context.incremental.values()][0]?.children.map( - (child) => child.id, - ) ?? [], - }) - const cuts: Array> = [] - await driver.apply(action, context, () => { - checkpoint() - cuts.push(observe()) - }) - const settled = observe() - observations.push({ - cuts, - settledGroup: settled.group, - settledChildIds: settled.childIds, - absentRoot: !context.sources.roots.collection.has(999), - }) - }, +// Named regressions of the incremental-history grammar, replayed through its +// shared recomputation check. +const incrementalFixedHistories: Array<{ name: string; scenario: Scenario }> = [ + { + name: `supports repeated optimistic rollbacks in one history`, + scenario: { + depth: 1, + history: [ + { + type: `put`, + level: 0, + id: 0, + parentGroup: 0, + group: 0, + value: 0, + position: 0, }, - projection: structuralProjection, - }) - const restored = { - settledGroup: 1, - settledChildIds: [1], - absentRoot: true, - } - expect(observations.slice(2)).toEqual([ - { ...restored, cuts: [] }, - { ...restored, cuts: [] }, { - ...restored, - cuts: [ - { group: 2, childIds: [] }, - { group: 1, childIds: [1] }, - ], + type: `put`, + level: 1, + id: 0, + parentGroup: 0, + group: 0, + value: 0, + position: 0, + }, + { + type: `optimisticRollback`, + level: 1, + id: 0, + parentGroup: 0, + group: 0, + value: 1, + position: 0, + }, + { + type: `optimisticRollback`, + level: 1, + id: 0, + parentGroup: 0, + group: 0, + value: 2, + position: 0, }, - { ...restored, cuts: [] }, - ]) + ], }, - ) + }, + { + name: `confirmed child reorder matches recomputation`, + scenario: confirmedChildReorderSeed, + }, +] - 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 = [ - { ...row, type: `put`, level: 0 }, - { ...row, type: `put`, level: 1 }, - { ...row, type: `delete`, level: 1 }, - { ...row, type: `delete`, level: 0 }, - ] - const beforeAwait: Array = [] - await runTrace({ - steps: history, - driver: { - ...driver, - apply: (action, context, checkpoint) => { - let checkpoints = 0 - const result = driver.apply(action, context, () => { - checkpoints++ - return checkpoint() - }) - beforeAwait.push(checkpoints) - return result - }, - }, - projection: structuralProjection, - }) - expect(beforeAwait).toEqual([1, 1, 1, 1]) +// Named full-row batch histories, replayed through the shared full-row batch +// check. +const fullRowFixedHistories: Array<{ + name: string + scenario: FullRowBatchScenario +}> = [ + { + name: `a root entering a live route receives its ordered snapshot`, + scenario: createRootEnteringLiveRouteScenario(), + }, + ...([0, 1, 2] as const).map((parentLevel) => ({ + name: `matches recomputation when the last level-${parentLevel} shared-route subscriber leaves`, + scenario: createSharedRouteLastSubscriberScenario(parentLevel), + })), + ...( + [ + [3, 1], + [4, 1], + [4, 2], + ] as const + ).map(([depth, targetLevel]) => ({ + name: `later updates propagate through a reparented subtree at depth ${depth}, level ${targetLevel}`, + scenario: createReparentedSubtreeUpdateScenario(depth, targetLevel), + })), + { + name: `rekeying a row detaches two descendant levels`, + scenario: minimalRekeyScenario, + }, + { + name: `reusing a rekeyed row's old route does not resurrect its child`, + scenario: rekeyRouteResurrectionScenario, + }, + { + name: `matches recomputation when sharing a route without rekeying its existing row`, + scenario: rekeyRouteReuseControl, + }, + { + name: `replacing a moved subtree child retains its grandchild`, + scenario: movedSubtreeChildReplacementScenario, + }, + { + name: `matches recomputation when replacing a child without reparenting its ancestor`, + scenario: childReplacementControl, + }, + { + name: `matches recomputation for full-row sync batches`, + scenario: { depth: 1, steps: fullRowBatchTrace }, + }, + { + name: `a reinserted parent drops its old shared route`, + scenario: fullRowSharedRoutingSeed, + }, +] + +// Named shared-route lifecycle pairs, replayed through the shared pair check. +const historyPairFixedHistories: Array<{ + name: string + pair: HistoryScenarioPair +}> = [ + ...([0, 1, 2] as const).flatMap((parentLevel) => [ + ...([0, 1] as const).map((enteringRow) => ({ + name: `matches recomputation when level-${parentLevel} row ${enteringRow} enters a live shared route`, + pair: createMergeIntoSharedRouteScenarios(parentLevel, enteringRow), + })), + { + name: `matches recomputation after a level-${parentLevel} route resubscribes`, + pair: createSnapshotOnResubscribeScenarios(parentLevel), }, - ) + { + name: `matches recomputation when an initially shared level-${parentLevel} route retires, changes, and resubscribes`, + pair: createInitiallySharedRouteResubscribeScenario(parentLevel), + }, + ]), + ...([0, 1] as const).map((parentLevel) => ({ + name: `a departed level-${parentLevel} shared-route subscriber ignores later child updates`, + pair: createSharedRouteLifetimeScenarios(parentLevel), + })), +] + +describe(`includes recompute oracle`, () => { + oracleProperty({ + id: `includes.optimistic-cuts`, + law: { + statement: optimisticCutsLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `One fixed depth-1 history of optimistic rollbacks.`, + }, + 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`, + ], + histories: [ + `distinguishes optimistic syntax from delivered rollback and no-op cuts`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(optimisticCutsLaw, plan, observe, () => + judgeOptimisticCuts(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes.apply-checkpoints`, + law: { + statement: applyCheckpointsLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `One fixed depth-1 put and delete history.`, + }, + 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`, + ], + histories: [ + `checks ordinary root and child changes before yielding from apply`, + ], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(applyCheckpointsLaw, plan, observe, () => + judgeApplyCheckpoints(), + ), + check: forwardAssertionJudgment, + }) - 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( + oracleProperty({ + id: `includes.omitted-child`, + law: { + statement: omittedChildLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `One fixed depth-1 root and child put history, replayed through the structural driver and recomputation, and its omitted-child calibration.`, + }, + 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`, + ], + histories: omittedChildHistories, + label: () => `matches recomputation after a root and its child are put`, + resolve: (history) => ({ ...history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(omittedChildLaw, plan, observe, () => runTrace({ - steps: [ - { ...row, type: `put`, level: 0 }, - { ...row, type: `put`, level: 1 }, - ] as Array, + steps: plan.steps, driver: createStructuralTraceDriver(1), - projection: { - ...structuralProjection, - observe: (context) => { - const actual = structuralProjection.observe(context) - if (!context.levels[0]!.has(row.id)) return actual - // First verify the real query result. Then inject one plausible - // wrong public result to calibrate the comparison at this cut. - structuralProjection.assertEqual( - actual, - structuralProjection.recompute(context), - ) - return [{ id: 1, group: 1, value: 1, position: 0, children: [] }] - }, - }, + projection: structuralProjection, }), - ).rejects.toMatchObject({ checkpoint: 3 }) - }, - ) - - for (const level of [0, 1] as const) { - for (const cleanupFails of [false, true]) { - 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( - `Core live cleanup failed after disposal`, - ) - let context: StructuralTraceContext | undefined - let transaction: PendingTransaction | undefined - const put = (target: 0 | 1): HistoryAction => ({ - type: `put`, - level: target, - id: 1, - parentGroup: 1, - group: 1, - value: 1, - position: 0, - }) - await expect( - runTrace({ - steps: [ - put(0), - put(1), - { ...put(level), type: `optimisticConfirm`, value: 2 }, - ], - driver: { - ...driver, - setup: () => { - const current = createStructuralTraceContext(1) - context = current - if (cleanupFails) { - const cleanup = current.incremental.cleanup.bind( - current.incremental, + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `rejects an omitted child at its first public checkpoint`, + law: omittedChildLaw, + // The former calibration body. After verifying the real query result, + // the projection injects one plausible wrong public result. Exactly the + // old matcher: only a failure at checkpoint 3 counts as the check + // rejecting it. The shared structural check marks its own comparison + // failures, so any other failure is rethrown without that marking and + // fails this calibration, as it failed the old test. + driver: (plan, observe) => + recordAssertionJudgment(omittedChildLaw, plan, observe, async () => { + const row = plan.row + try { + await runTrace({ + steps: plan.steps, + driver: createStructuralTraceDriver(1), + projection: { + ...structuralProjection, + observe: (context) => { + const actual = structuralProjection.observe(context) + if (!context.levels[0]!.has(row.id)) return actual + structuralProjection.assertEqual( + actual, + structuralProjection.recompute(context), ) - current.incremental.cleanup = async () => { - await cleanup() - throw cleanupFailure - } - } - return current - }, - }, - projection: { - ...structuralProjection, - assertEqual: (actual, expected) => { - structuralProjection.assertEqual(actual, expected) - const entry = [...context!.pendingWork][0] - if (entry) { - transaction = entry.transaction - throw failure - } + return [ + { id: 1, group: 1, value: 1, position: 0, children: [] }, + ] + }, }, - }, - }), - ).rejects.toBe(failure) - expect( - (failure as Error & { suppressed?: Array }).suppressed, - ).toEqual(cleanupFails ? [cleanupFailure] : undefined) - if (!context) throw new Error(`Expected core trace context`) - expect(transaction?.state).toBe(`failed`) - expect(context.pendingWork.size).toBe(0) - for (const collection of [ - context.incremental, - context.sources.roots.collection, - ...context.sources.levels.map((source) => source.collection), - ]) - expect(collection.status).toBe(`cleaned-up`) - }, - ) - } - } - - 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) - if (!levelTwo || levelTwo.level !== 2) throw new Error(`Missing level 2`) - const first = levelTwo.changes.find( - (change) => change.value.id === rowAt(branches, 0, 2), - ) - const second = levelTwo.changes.find( - (change) => change.value.id === rowAt(branches, 1, 2), - ) - if (!first || !second) throw new Error(`Missing sibling targets`) - - expect(first.value.parentGroup).toBe(second.value.parentGroup) + }) + } catch (error) { + if ( + error instanceof Error && + (error as Error & { checkpoint?: unknown }).checkpoint === 3 + ) + rejectAt(`omitted child calibration`, error) + throw new Error( + `the omitted-child corruption failed outside the expected rejection: ${String(error)}`, + ) + } + }), + }, + ], }) - fcTest(`the descendant remains attached before its ancestor moves`, () => { - oracleRoute(`fixed history`) - const branches = transitionHistoryBranches - const scenario = createRouteLifecycleScenario({ - depth: 3, - branches, - descriptors: independentTransitionDescriptors( - `descendant-ancestor`, - branches, - [2_100, 2_500], - ), - }) - const ancestorTransitionStep = scenario.transitionStepIndexes[1] - if (ancestorTransitionStep === undefined) { - throw new Error(`Missing ancestor transition`) - } - const beforeAncestorMove = recomputeFullRowBatchScenario( - scenario, - ancestorTransitionStep, - ) - - expect( - hasDirectChild( - beforeAncestorMove, - rowAt(branches, 0, 1), - rowAt(branches, 0, 2), + oracleProperty({ + id: `includes.failed-optimistic-cleanup`, + law: { + statement: failedOptimisticCleanupLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The four enumerated level and cleanup-failure cells over one depth-1 history.`, + }, + 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`, + ], + histories: ([0, 1] as const).flatMap((level) => + [false, true].map((cleanupFails) => ({ level, cleanupFails })), + ), + label: ({ level, cleanupFails }) => + `cleans failed optimistic checkpoint at level ${level} with cleanup failure=${cleanupFails}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(failedOptimisticCleanupLaw, plan, observe, () => + judgeFailedOptimisticCleanup(plan.level, plan.cleanupFails), ), - ).toBe(true) + check: forwardAssertionJudgment, }) - 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 - // composed witnesses at [depth, target] = [3, 1] and [4, 2], but is - // outside this generated green family. - const expectedCounts = { - reparent: { reparent: [1, 5, 9, 13], rekey: [0, 2, 2, 2] }, - rekey: { reparent: [0, 2, 2, 2], rekey: [0, 1, 1, 1] }, - } as const - for (const depth of [1, 2, 3, 4] as const) { - for (const first of [`reparent`, `rekey`] as const) { - for (const second of [`reparent`, `rekey`] as const) { - const placements = transitionHistoryPlacements(depth, first, second) - expect(placements).toHaveLength( - expectedCounts[first][second][depth - 1]!, - ) - const [generated] = fc.sample( - transitionHistoryScenariosArbitrary(depth, first, second, 0), - oracleFixedSeed(1725, { numRuns: 1 }), - ) - expect(generated).toHaveLength(placements.length) - for (const [index, scenario] of generated!.entries()) { - const placement = placements[index]! - const insertedLevels = scenario.steps - .slice(scenario.historyStartStepIndex) - .filter((step) => - step.changes.some((change) => change.type === `insert`), - ) - .map((step) => step.level) - expect(insertedLevels).toEqual(placement.insertedLevels) - expectEveryHistoryStepVisible(scenario) - } - } - } - } - - // A nearby invalid history attempts to rekey without seeding the child - // route. The constructor rejects it before the production driver runs. - expect(() => - createTransitionHistoryScenario({ - depth: 3, - targetLevel: 2, - firstTransition: `rekey`, - secondTransition: `reparent`, - sourceBranch: 0, - branches: transitionHistoryBranches, - rekeyGroups: [2_100, 2_400], - insertedLevels: [2, 2], - insertedValues: [1, 2], - insertedPositions: [-2, 2], - }), - ).toThrow(/Rekey histories must seed the new child route/) + oracleProperty({ + id: `includes.composed-transitions`, + law: { + statement: composedTransitionLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The twelve enumerated depth, level, source-branch, and transition-pair cells with two descendant levels.`, + }, + 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`, + ], + histories: composedTransitionCells, + label: ({ depth, sourceBranch, firstTransition, secondTransition }) => + `matches composed ${firstTransition} → ${secondTransition} with two descendant levels at depth ${depth}, branch ${sourceBranch}`, + resolve: (cell) => ({ ...cell }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(composedTransitionLaw, plan, observe, () => + judgeComposedTransition(plan), + ), + check: forwardAssertionJudgment, }) - fcTest( - `keeps each route-history topology and destination in the grammar`, - () => { - oracleRoute(`fixed history`) - expect(independentTransitionShapes).toEqual([ - `ancestor-descendant`, - `descendant-ancestor`, - `sibling`, - `cross-branch`, - `root`, - ]) - expect(destinationHistories).toEqual([ - `fresh`, - `restore`, - `merge-split`, - `retired`, - ]) - const branches = transitionHistoryBranches - const shapeTraces = independentTransitionShapes.map((shape) => { - const descriptors = independentTransitionDescriptors( - shape, - branches, - [2_100, 2_500], - ) - const scenario = createRouteLifecycleScenario({ - depth: 3, - branches, - prefixSteps: independentTransitionPrefix(shape, branches), - descriptors, - }) - expectEveryRouteTransitionVisible(scenario) - return JSON.stringify( - scenario.transitionStepIndexes.map((stepIndex) => - relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex + 1), - ), - ), - ) - }) - // Removing one named shape drops one distinct public relationship trace. - expect(new Set(shapeTraces).size).toBe(5) - - const destinationTraces = destinationHistories.map((history) => { - const descriptors = destinationHistoryDescriptors( - history, - branches, - 2_100, - ) - const scenario = createRouteLifecycleScenario({ - depth: 3, - branches, - descriptors, - }) - expectEveryRouteTransitionVisible(scenario) - return JSON.stringify( - scenario.transitionStepIndexes.map((stepIndex) => - relationshipOnly( - recomputeFullRowBatchScenario(scenario, stepIndex + 1), - ), - ), - ) - }) - expect(new Set(destinationTraces).size).toBe(4) - - // The fixture bands admit both endpoints without overlapping route keys. - for (const [idBase, groupBase, otherIdBase, otherGroupBase, fresh] of [ - [100, 600, 1_100, 1_600, 2_100], - [500, 1_000, 1_500, 2_000, 2_400], - ] as const) { - const edgeBranches = [ - { idBase, groupBase }, - { idBase: otherIdBase, groupBase: otherGroupBase }, - ] as const - for (const history of destinationHistories) { - const scenario = createRouteLifecycleScenario({ - depth: 3, - branches: edgeBranches, - descriptors: destinationHistoryDescriptors( - history, - edgeBranches, - fresh, - ), - }) - expectEveryRouteTransitionVisible(scenario) - } - } + oracleProperty({ + id: `includes.materialize-reference-update`, + law: { + statement: materializeReferenceUpdateLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `One fixed insert-and-redirect history over distinct intermediates.`, }, - ) - - fcTest(`reconstructs and distinguishes the split/atomic batch matrix`, () => { - oracleRoute(`fixed history`) - const fixture = { - leftIdBase: 100, - leftGroupBase: 600, - rightIdBase: 1_100, - rightGroupBase: 1_600, - replacementId: 3_000, - replacementGroup: 2_700, - replacementValue: -3, - replacementPosition: -2, - movedPosition: 2, - } - const candidate = ( - change: Partial = {}, - ): FullRowBatchScenario => - createRelationshipBatchShapeScenarios({ - ...fixture, - shape: { - delivery: `split`, - order: `delete-insert`, - publicId: `new`, - route: `handoff`, - ancestorUpdate: `route-only`, - ...change, - }, - }).candidate - const final = (scenario: FullRowBatchScenario) => - recomputeFullRowBatchScenario(scenario, scenario.steps.length) - - let cells = 0 - for (const publicId of [`same`, `new`] as const) { - for (const route of [`handoff`, `fresh`] as const) { - for (const ancestorUpdate of [ - `route-only`, - `route-and-position`, - ] as const) { - const matrix = createRelationshipBatchShapeMatrix( - fixture, - publicId, - route, - ancestorUpdate, - ) - expect(matrix).toHaveLength(publicId === `same` ? 2 : 4) - expect(matrix.map(({ shape }) => shape.delivery)).toContain(`split`) - expect(matrix.map(({ shape }) => shape.delivery)).toContain(`atomic`) - if (publicId === `same`) - expect( - matrix.every(({ shape }) => shape.order === `delete-insert`), - ).toBe(true) - else - expect(matrix.map(({ shape }) => shape.order)).toContain( - `insert-delete`, - ) - cells += matrix.length - } - } - } - expect(cells).toBe(24) - - // Ablating any semantic axis removes a distinct public observation or - // checkpoint. Delivery and order agree only at the terminal state: their - // intermediate cuts, which the production driver checks, differ. - const baseline = candidate() - expect(final(candidate({ publicId: `same` }))).not.toEqual(final(baseline)) - expect(final(candidate({ route: `fresh` }))).not.toEqual(final(baseline)) - expect( - final(candidate({ ancestorUpdate: `route-and-position` })), - ).not.toEqual(final(baseline)) - const atomic = candidate({ delivery: `atomic` }) - expect(atomic.steps).toHaveLength(baseline.steps.length - 1) - expect(final(atomic)).toEqual(final(baseline)) - const insertFirst = candidate({ order: `insert-delete` }) - expect(final(insertFirst)).toEqual(final(baseline)) - expect( - recomputeFullRowBatchScenario(insertFirst, insertFirst.steps.length - 1), - ).not.toEqual( - recomputeFullRowBatchScenario(baseline, baseline.steps.length - 1), - ) - - // Same-id insert-before-delete is excluded from this grammar because its - // intermediate cut can hold two rows with one public key. This check - // proves the generator omits that schedule; it does not test a product - // rejection. The fixture extremes stay within disjoint key bands. - for (const edgeFixture of [ - fixture, - { - ...fixture, - leftIdBase: 500, - leftGroupBase: 1_000, - rightIdBase: 1_500, - rightGroupBase: 2_000, - replacementId: 3_200, - replacementGroup: 2_900, - replacementValue: 3, - replacementPosition: 2, - movedPosition: -2, - }, - ]) { - const matrix = createRelationshipBatchShapeMatrix( - edgeFixture, - `same`, - `handoff`, - `route-only`, - ) - expect(matrix.some(({ shape }) => shape.order === `insert-delete`)).toBe( - false, - ) - for (const { scenarios } of matrix) { - expect(final(scenarios.candidate)).not.toEqual(final(scenarios.control)) - } - } + 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`, + ], + histories: materializeReferenceUpdateHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + materializeReferenceUpdateLaw, + plan, + observe, + () => + runTrace({ + steps: plan.history.steps, + driver: createMaterializeTraceDriver(false), + projection: materializeProjection, + }), + ), + check: forwardAssertionJudgment, }) - 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), - oracleFixedSeed(1726, { numRuns: 1 }), - ) - const [atomic] = fc.sample( - rekeyRouteReuseScenarioArbitrary( - depth, - sourceBranch, - createIntraBatchRekeyRouteReuseScenarios, - ), - oracleFixedSeed(1733, { numRuns: 1 }), - ) - expect(split).toBeDefined() - expect(atomic).toBeDefined() - const outcome = (scenario: FullRowBatchScenario) => - relationshipOnly( - recomputeFullRowBatchScenario(scenario, scenario.steps.length), - ) - expect(outcome(split!.candidate)).not.toEqual(outcome(split!.control)) - expect(split!.candidate.steps).toHaveLength( - split!.control.steps.length + 1, - ) - expect(outcome(atomic!.candidate)).toEqual(outcome(atomic!.control)) - const controlLast = atomic!.control.steps.at(-1)! - const candidateLast = atomic!.candidate.steps.at(-1)! - expect(controlLast.changes.map(({ type }) => type)).toEqual([ - `insert`, - `update`, - ]) - expect(candidateLast.changes.map(({ type }) => type)).toEqual([ - `update`, - `insert`, - ]) - } - } + oracleProperty({ + id: `includes.full-row-fixed`, + law: { + statement: fullRowBatchLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `Named full-row batch histories that the generated matrices do not take back, replayed through the shared full-row batch check.`, + }, + 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`, + ], + histories: fullRowFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(fullRowBatchLaw, plan, observe, () => + expectFullRowBatchScenarioMatches(plan.history.scenario), + ), + check: forwardAssertionJudgment, + }) - // Replacing a moved child needs a grandchild that can expose the route. - // This is the depth/target-level range of the generated mirror family. - for (const [depth, targetLevel] of [ - [3, 1], - [4, 1], - [4, 2], - ] as const) { - for (const sourceBranch of [0, 1] as const) { - const [mirrors] = fc.sample( - movedChildReplacementMirrorsArbitrary( - depth, - targetLevel, - sourceBranch, - ), - oracleFixedSeed(1727, { numRuns: 1 }), - ) - for (const order of branchDeliveryOrders) { - const pair = mirrors![order] - const outcome = (scenario: FullRowBatchScenario) => - recomputeFullRowBatchScenario(scenario, scenario.steps.length) - expect(pair.greenVariants).toHaveLength(2) - expect(outcome(pair.candidate)).not.toEqual(outcome(pair.control)) - for (const atomic of pair.greenVariants!) - expect(outcome(atomic)).toEqual(outcome(pair.candidate)) - } - } - } - expect(() => - createMovedChildReplacementScenarios({ - depth: 3, - targetLevel: 2, - sourceBranch: 0, - branches: transitionHistoryBranches, - insertedId: 3_000, - insertedValue: 0, - insertedPosition: 0, - }), - ).toThrow(/needs a visible grandchild/) + oracleProperty({ + id: `includes.history-pair-fixed`, + law: { + statement: historyPairLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `Named shared-route merge, resubscribe, retirement, and departure pairs, replayed through the shared pair check.`, + }, + 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`, + ], + histories: historyPairFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(historyPairLaw, plan, observe, () => + expectHistoryScenarioPairMatches(plan.history.pair), + ), + check: forwardAssertionJudgment, }) - 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) - if (!childStep || childStep.level !== 1) - throw new Error(`Missing children`) - const scenario: FullRowBatchScenario = { - depth: 1, - steps: [ - prefix[0]!, - { - level: 1, - changes: [ - ...childStep.changes, - { - type: `insert`, - value: { - ...batchChild(3_000, branches[0].groupBase, 3_000, -1), - group: 2_500, - }, - }, - ], - }, - { - level: 0, - changes: [ - { - type: `update`, - value: batchRoot( - branches[1].idBase, - branches[0].groupBase, - branches[1].idBase, - 0, - ), - }, - ], - }, - ], - } + oracleProperty({ + id: `includes.flat-materialization-cases`, + law: { + statement: flatMaterializationLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The two enumerated flat materializations over one intra-batch hand-off scenario.`, + }, + 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`, + ], + histories: [`array`, `concat`] as Array, + label: (materialization) => + `${materialization} follows an intra-batch child hand-off`, + resolve: (materialization) => ({ materialization }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(flatMaterializationLaw, plan, observe, () => + expectFlatMaterializationScenarioMatches( + plan.materialization, + intraBatchChildHandOffScenario, + ), + ), + check: forwardAssertionJudgment, + }) - await expectFullRowBatchScenarioMatches(scenario) + oracleProperty({ + id: `includes.incremental-fixed`, + law: { + statement: incrementalLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `Two named regressions of the incremental-history grammar.`, }, - ) + 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`, + ], + histories: incrementalFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(incrementalLaw, plan, observe, () => + expectScenarioMatches(plan.history.scenario), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `includes.materialize-fixed`, + law: { + statement: materializeLaw, + authority: `The state-equivalence, route-lifecycle, transition-history, and batch-partition laws (normative laws 2–5 and 8) in packages/db/src/query/live/ARCHITECTURE.md, stated at the start of this owner.`, + limits: `The named shared-intermediate materialization seed.`, + }, + 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`, + ], + histories: [ + { + name: `shared scalar materialization preserves the deepest row`, + scenario: sharedMaterializeSeed, + }, + ], + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(materializeLaw, plan, observe, () => + expectMaterializeScenarioMatches(plan.history.scenario), + ), + check: forwardAssertionJudgment, + }) for (const [shapeIndex, shape] of independentTransitionShapes.entries()) { const property = `includes.matrix.independent-${shape}` @@ -3858,7 +2438,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [arbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for independent ${shape} relationship targets [${property}, ${label}]`, async (scenario) => { expectEveryRouteTransitionVisible(scenario) @@ -3878,7 +2468,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [arbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for the ${history} route destination history [${property}, ${label}]`, async (scenario) => { expectEveryRouteTransitionVisible(scenario) @@ -3888,112 +2488,6 @@ describe(`includes recompute oracle`, () => { } } - for (const parentLevel of [0, 1, 2] as const) { - for (const enteringRow of [0, 1] as const) { - fcTest( - `matches recomputation when level-${parentLevel} row ${enteringRow} enters a live shared route`, - () => { - oracleRoute(`case`) - return expectHistoryScenarioPairMatches( - createMergeIntoSharedRouteScenarios(parentLevel, enteringRow), - ) - }, - ) - } - - fcTest( - `matches recomputation when the last level-${parentLevel} shared-route subscriber leaves`, - () => { - oracleRoute(`case`) - return expectFullRowBatchScenarioMatches( - createSharedRouteLastSubscriberScenario(parentLevel), - ) - }, - ) - - fcTest( - `matches recomputation after a level-${parentLevel} route resubscribes`, - () => { - oracleRoute(`case`) - return expectHistoryScenarioPairMatches( - createSnapshotOnResubscribeScenarios(parentLevel), - ) - }, - ) - - fcTest( - `matches recomputation when an initially shared level-${parentLevel} route retires, changes, and resubscribes`, - () => { - oracleRoute(`case`) - return expectHistoryScenarioPairMatches( - createInitiallySharedRouteResubscribeScenario(parentLevel), - ) - }, - ) - } - - for (const materialization of [`array`, `concat`] as const) { - fcTest(`${materialization} follows an intra-batch child hand-off`, () => { - oracleRoute(`case`) - return expectFlatMaterializationScenarioMatches( - materialization, - intraBatchChildHandOffScenario, - ) - }) - } - - for (const [depth, targetLevel] of [ - [3, 1], - [4, 1], - [4, 2], - ] as const) { - fcTest( - `later updates propagate through a reparented subtree at depth ${depth}, level ${targetLevel}`, - () => { - oracleRoute(`case`) - return expectFullRowBatchScenarioMatches( - createReparentedSubtreeUpdateScenario(depth, targetLevel), - ) - }, - ) - } - - 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`, - () => { - oracleRoute(`fixed history`) - return expectFullRowBatchScenarioMatches(rekeyRouteResurrectionScenario) - }, - ) - - fcTest( - `matches recomputation when sharing a route without rekeying its existing row`, - () => { - oracleRoute(`fixed history`) - return expectFullRowBatchScenarioMatches(rekeyRouteReuseControl) - }, - ) - - 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`, - () => { - oracleRoute(`fixed history`) - return expectFullRowBatchScenarioMatches(childReplacementControl) - }, - ) - for (const depth of [2, 3, 4] as const) { for (const sourceBranch of [0, 1] as const) { const retiredProperty = `includes.matrix.retired-route-${depth}-${sourceBranch}` @@ -4008,7 +2502,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [retiredArbitrary], - options, + { + ...options, + 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`, + ], + }, `reuses a retired route at depth ${depth}, branch ${sourceBranch} [${retiredProperty}, ${label}]`, (...args: Parameters) => { return expectHistoryScenarioPairMatches(...args) @@ -4029,7 +2533,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [intraBatchArbitrary], - options, + { + ...options, + 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`, + ], + }, `handles intra-batch rekey then retired-route reuse at depth ${depth}, branch ${sourceBranch} [${intraBatchProperty}, ${label}]`, (...args: Parameters) => { return expectHistoryScenarioPairMatches(...args) @@ -4039,18 +2553,6 @@ describe(`includes recompute oracle`, () => { } } - for (const parentLevel of [0, 1] as const) { - fcTest( - `a departed level-${parentLevel} shared-route subscriber ignores later child updates`, - () => { - oracleRoute(`case`) - return expectHistoryScenarioPairMatches( - createSharedRouteLifetimeScenarios(parentLevel), - ) - }, - ) - } - for (const depth of [3, 4] as const) { for (let targetLevel = 1; targetLevel <= depth - 2; targetLevel++) { for (const sourceBranch of [0, 1] as const) { @@ -4067,7 +2569,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [arbitrary], - options, + { + ...options, + 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`, + ], + }, `matches forward/reverse delivery mirrors when replacing a moved child at depth ${depth}, level ${targetLevel}, source ${sourceBranch} [${property}, ${label}]`, async (scenarios) => { for (const deliveryOrder of branchDeliveryOrders) { @@ -4097,7 +2609,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [relationshipBatchFixtureArbitrary], - options, + { + ...options, + 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`, + ], + }, `matches the split/atomic replacement matrix for ${publicId} public id, ${route} route, ${ancestorUpdate} [${property}, ${label}]`, async (fixture) => { const cells = createRelationshipBatchShapeMatrix( @@ -4153,7 +2675,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( flatMaterializationArbitraries, - options, + { + ...options, + 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`, + ], + }, `matches recomputation for flat materializations [${flatMaterializationProperty}, ${label}]`, (kind, scenario) => { return expectFlatMaterializationScenarioMatches(kind, scenario) @@ -4171,7 +2703,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [arbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for visible multi-row batches at depth ${depth} [${property}, ${label}]`, (...args: Parameters) => { return expectFullRowBatchScenarioMatches(...args) @@ -4198,7 +2740,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [visibleArbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for a visible ${transition} at depth ${depth}, level ${targetLevel} [${visibleProperty}, ${label}]`, async (scenarios) => { for (const scenario of [ @@ -4259,7 +2811,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [arbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for ${firstTransition} → ${secondTransition} histories at depth ${depth}, branch ${sourceBranch} [${property}, ${label}]`, async (scenarios) => { for (const scenario of scenarios) { @@ -4274,115 +2836,6 @@ describe(`includes recompute oracle`, () => { } } - for (const [depth, targetLevel] of [ - [3, 1], - [4, 2], - ] as const) { - for (const sourceBranch of [0, 1] as const) { - for (const [firstTransition, secondTransition] of [ - [`rekey`, `rekey`], - [`rekey`, `reparent`], - [`reparent`, `rekey`], - ] as const) { - 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, - targetLevel, - firstTransition, - secondTransition, - sourceBranch, - branches: transitionHistoryBranches, - rekeyGroups: [2_100, 2_400], - insertedLevels: [childLevel, childLevel], - insertedValues: [31, 47], - insertedPositions: [1, -1], - }) - expectEveryHistoryStepVisible(scenario) - await expectFullRowBatchScenarioMatches(scenario) - }, - ) - } - } - } - - fcTest(`nested scalar materialization follows a reference update`, () => { - oracleRoute(`fixed history`) - return runTrace({ - steps: [ - { type: `insert`, insert: `root-1` }, - { type: `insert`, insert: `middle-1` }, - { type: `insert`, insert: `shared-1` }, - { type: `insert`, insert: `leaf-1` }, - { type: `redirectMiddle`, id: 1, sharedId: 2 }, - ], - driver: createMaterializeTraceDriver(false), - projection: materializeProjection, - }) - }) - - fcTest(`matches recomputation for full-row sync batches`, async () => { - oracleRoute(`fixed history`) - await runTrace({ - steps: fullRowBatchTrace, - driver: createFullRowBatchTraceDriver(1), - projection: structuralProjection, - }) - }) - - 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: [ - { - type: `put`, - level: 0, - id: 0, - parentGroup: 0, - group: 0, - value: 0, - position: 0, - }, - { - type: `put`, - level: 1, - id: 0, - parentGroup: 0, - group: 0, - value: 0, - position: 0, - }, - { - type: `optimisticRollback`, - level: 1, - id: 0, - parentGroup: 0, - group: 0, - value: 1, - position: 0, - }, - { - type: `optimisticRollback`, - level: 1, - id: 0, - parentGroup: 0, - group: 0, - value: 2, - position: 0, - }, - ], - }) - }) - for (const { label, options } of generatedCampaigns( 40, `includes.incremental-history`, @@ -4390,7 +2843,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [scenarioArbitrary], - options, + { + ...options, + 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`, + ], + }, `matches naive recomputation after every incremental change [${label}]`, (...args: Parameters) => { return expectScenarioMatches(...args) @@ -4408,7 +2871,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [distinctMaterializationArbitrary], - options, + { + ...options, + 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`, + ], + }, `matches recomputation for nested scalar materialization [${label}]`, (...args: Parameters) => { return expectMaterializeScenarioMatches(...args) @@ -4447,7 +2920,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( alphaArbitraries, - options, + { + ...options, + 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`, + ], + }, `is unchanged by alpha-renaming, sibling declaration order, or an unrelated sibling [${label}]`, async (rootRows, childRows) => { const roots = createControlledCollection( @@ -4579,7 +3062,17 @@ describe(`includes recompute oracle`, () => { )) { registerIncludesProperty( [confirmedValueArbitrary], - options, + { + ...options, + 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`, + ], + }, `optimistic updates converge to confirmed-only state [${label}]`, async (confirmedValue) => { const roots = createControlledCollection(`convergence-roots`, [ @@ -4652,16 +3145,6 @@ describe(`includes recompute oracle`, () => { }, ) } - - fcTest(`confirmed child reorder matches recomputation`, () => { - oracleRoute(`fixed history`) - return expectScenarioMatches(confirmedChildReorderSeed) - }) - - fcTest(`shared scalar materialization preserves the deepest row`, () => { - oracleRoute(`fixed history`) - return expectMaterializeScenarioMatches(sharedMaterializeSeed) - }) }) /** @@ -4994,39 +3477,68 @@ const fixedScopeWitnesses: Array<[string, ScopedScenario]> = [ ], ] -describe(`includes alpha-renaming across lexical scopes`, () => { - fcTest( - `an include that reuses a joined subquery alias keeps the reported rows`, +const scopedAlphaFixedHistories: Array<{ + name: string + scenario: ScopedScenario + expected?: Array +}> = [ + { + name: `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. - () => { - oracleRoute(`fixed history`) - return expectScopedAlphaRenamingHolds( - { - ...fixedScopeWitnesses[0]![1], - parts: [ - { id: 1, active: true }, - { id: 2, active: true }, - ], - refs: [ - { id: 10, partId: 1, clientId: 1 }, - { id: 20, partId: 2, clientId: 2 }, - ], - notes: [], - }, - [{ id: 1, active: true, members: [{ id: 10, clientId: 1 }] }], - ) + scenario: { + ...fixedScopeWitnesses[0]![1], + parts: [ + { id: 1, active: true }, + { id: 2, active: true }, + ], + refs: [ + { id: 10, partId: 1, clientId: 1 }, + { id: 20, partId: 2, clientId: 2 }, + ], + notes: [], }, - ) + expected: [{ id: 1, active: true, members: [{ id: 10, clientId: 1 }] }], + }, + ...fixedScopeWitnesses.flatMap(([name, scenario]) => + ([`eager`, `onDemand`] as const).map((mode) => ({ + name: `${name} [${mode}]`, + scenario: { ...scenario, mode }, + })), + ), +] - for (const [name, scenario] of fixedScopeWitnesses) { - for (const mode of [`eager`, `onDemand`] as const) { - fcTest(`${name} [${mode}]`, () => { - oracleRoute(`case`) - return expectScopedAlphaRenamingHolds({ ...scenario, mode }) - }) - } - } +const scopedAlphaLaw = `Changing accepted aliases to other legal names in a structured plan does not change an explicitly projected result, its source requests, or its rows when lexical scopes reuse names.` + +describe(`includes alpha-renaming across lexical scopes`, () => { + oracleProperty({ + id: `includes.scoped-alpha-fixed`, + law: { + statement: scopedAlphaLaw, + authority: `ARCHITECTURE.md normative law 1 (alpha-renaming) and the Identity section, stated above.`, + limits: `The reported regression and each fixed optimizer witness in eager and on-demand modes.`, + }, + 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`, + ], + histories: scopedAlphaFixedHistories, + label: ({ name }) => name, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(scopedAlphaLaw, plan, observe, () => + expectScopedAlphaRenamingHolds( + plan.history.scenario, + plan.history.expected, + ), + ), + check: forwardAssertionJudgment, + }) for (const { label, options } of generatedCampaigns( 80, @@ -5035,7 +3547,17 @@ describe(`includes alpha-renaming across lexical scopes`, () => { )) { registerIncludesProperty( [scopedScenarioArbitrary], - options, + { + ...options, + 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`, + ], + }, `is unchanged when lexical scopes reuse alias names [${label}]`, (scenario) => { return expectScopedAlphaRenamingHolds(scenario) @@ -5080,6 +3602,14 @@ for (const campaign of generatedCampaigns( [distinctHistory], { ...campaign.options, + 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`, + ], beforeCampaign: () => { groups = collidingRouteGroups() for (const state of counts.keys()) counts.set(state, 0) From 3a874db44b9ca29999c80211492dbee6b109b273 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:38:23 -0700 Subject: [PATCH 093/116] test(db): the shared infinite-query suite is an ordinary module; its laws stay oracle-named infinite-suite-oracle.ts registered 34 scenario bodies through it, which D2 reports. Each framework runs the suite from its ordinary infinite-query-conformance test file, so the scenarios were never oracle properties. The suite moves unchanged to infinite-suite.ts. Its model (expectedOverlappingWindow) and its publication law (expectReadyObservationsMatchSource) move with the other expectation helpers to infinite-suite-laws-oracle.ts. The scenarios and their comparisons do not change; the React, Vue, Svelte, and Preact suites and the calibration pass. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 4 +- .../conformance-infinite-calibration.test.ts | 2 +- .../tests/conformance/infinite-suite-cases.ts | 2 +- .../conformance/infinite-suite-harness.ts | 2 +- .../conformance/infinite-suite-laws-oracle.ts | 135 ++++++++++++++++ ...nite-suite-oracle.ts => infinite-suite.ts} | 149 ++---------------- .../tests/infinite-query-conformance.test.ts | 2 +- .../tests/infinite-query-conformance.test.tsx | 2 +- .../infinite-query-conformance.svelte.test.ts | 2 +- .../tests/infinite-query-conformance.test.ts | 2 +- 10 files changed, 156 insertions(+), 146 deletions(-) create mode 100644 packages/db/tests/conformance/infinite-suite-laws-oracle.ts rename packages/db/tests/conformance/{infinite-suite-oracle.ts => infinite-suite.ts} (93%) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index afd4f57c11..1cd55d8442 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2128,8 +2128,8 @@ generic visitor behavior outside the named collectors. | 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. Witness files (maintainer decision, 2026-11-11: witnesses for runtimes that are not Vitest on Node): `predicates-witness.suite.ts`, `pagination-witness.suite.ts`, `joins-witness.suite.ts`, and `live-updates-witness.suite.ts` keep their 84 plain tests and their existing checks and models (`withPredicateRows`, `createWindowOracle`, `createJoinOracle`, `expectNotificationHistory`) without `oracleProperty`, because `persisted-collection-conformance-contract.ts` also bundles them into the Capacitor and Tauri WebView apps, and the framework's main entry, which `oracleProperty` needs, is Node-only (`node:async_hooks`, `node:fs`, `node:util`). Their native runs check the exact 113 test names in `persisted-conformance-manifest.ts`. `eslint.config.js` applies every oracle rule to them except `oracle/no-unit-test`. Owner: this row. The move suite, which only `electric.e2e.test.ts` runs on Node, registers `collection-e2e.moves.{eager,on-demand,progressive}` through `oracleProperty`. 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. | +| Window-controller overlap | [shared infinite-query conformance](https://github.com/TanStack/db/blob/main/packages/db/tests/conformance/infinite-suite.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-laws-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 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. 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. | diff --git a/packages/db/tests/conformance-infinite-calibration.test.ts b/packages/db/tests/conformance-infinite-calibration.test.ts index dc6b106988..9cbbca11c6 100644 --- a/packages/db/tests/conformance-infinite-calibration.test.ts +++ b/packages/db/tests/conformance-infinite-calibration.test.ts @@ -1,5 +1,5 @@ import { vi } from 'vitest' -import { runInfiniteQuerySuite } from './conformance/infinite-suite-oracle' +import { runInfiniteQuerySuite } from './conformance/infinite-suite' import { registerInfiniteQueryUnitScenarios } from './conformance/infinite-suite-cases' import type { InfiniteQueryDriver } from './conformance/infinite-contract' import type * as Vitest from 'vitest' diff --git a/packages/db/tests/conformance/infinite-suite-cases.ts b/packages/db/tests/conformance/infinite-suite-cases.ts index f9de9a4ea4..e41ca8b6e3 100644 --- a/packages/db/tests/conformance/infinite-suite-cases.ts +++ b/packages/db/tests/conformance/infinite-suite-cases.ts @@ -3,7 +3,7 @@ * a value that a person wrote down: a rejection message, an error the test * created itself, a subscriber count of zero, or a fetch order recorded as * booleans. None compares with the source rows or with a model, so none is - * oracle coverage. The page-ledger laws are in infinite-suite-oracle.ts, and + * oracle coverage. The page-ledger laws are in infinite-suite.ts, and * the shared machinery is in infinite-suite-harness.ts. */ import { expect, it } from 'vitest' diff --git a/packages/db/tests/conformance/infinite-suite-harness.ts b/packages/db/tests/conformance/infinite-suite-harness.ts index 90567968b7..2034f40eb3 100644 --- a/packages/db/tests/conformance/infinite-suite-harness.ts +++ b/packages/db/tests/conformance/infinite-suite-harness.ts @@ -5,7 +5,7 @@ * * This module declares no oracle part. Its registry self-test checks the * harness's own bookkeeping, not a law. The laws are in - * infinite-suite-oracle.ts, and the hand-written scenarios are in + * infinite-suite.ts, and the hand-written scenarios are in * infinite-suite-cases.ts. */ import { expect, it } from 'vitest' diff --git a/packages/db/tests/conformance/infinite-suite-laws-oracle.ts b/packages/db/tests/conformance/infinite-suite-laws-oracle.ts new file mode 100644 index 0000000000..be229327a6 --- /dev/null +++ b/packages/db/tests/conformance/infinite-suite-laws-oracle.ts @@ -0,0 +1,135 @@ +/** + * The laws that the shared infinite-query suite (infinite-suite.ts) compares + * production with: the overlapping-window model, which predicts the committed + * prefix, its continuation, and the retained error after overlapping window + * outcomes, and the publication law, which checks every ready value against + * the source prefix for its page count. Each expectation comes from the + * immutable source rows and page size, never from the controller. The suite + * runs under each framework's ordinary conformance test file. + */ +import { expect } from 'vitest' +import type { InfiniteRow } from './infinite-suite-harness' +import type { InfiniteQueryHandle } from './infinite-contract' + +/** + * A fulfilled page fetch advances the committed prefix. A failed request leaves + * that prefix and the `ready` status alone; its rejection reaches the caller, + * and a failed source load behind it is a request error in the health tree. + * Source length decides whether the committed prefix has a continuation, + * including after later source writes. + */ +export function expectedOverlappingWindow( + source: ReadonlyArray, + pageSize: number, + pageSucceeded: boolean, +) { + const pageCount = pageSucceeded ? 2 : 1 + const visible = source.slice(0, pageCount * pageSize) + return { + ids: visible.map((row) => row.id), + pages: Array.from({ length: pageCount }, (_, page) => + visible + .slice(page * pageSize, (page + 1) * pageSize) + .map((row) => row.id), + ), + pageParams: Array.from({ length: pageCount }, (_, page) => page), + hasNextPage: source.length > pageCount * pageSize, + isFetchingNextPage: !pageSucceeded, + } +} + +/** + * Publication law for a fixed source and query: every ready value the binding + * published equals the source prefix for that value's own page count. The + * expectation comes from the immutable source rows and page size, not from the + * controller, so a ready value that holds fewer rows than its pages, splits + * them at the wrong boundary, or reports a false end of list fails here even + * when the settled state later recovers. + */ +export function expectReadyObservationsMatchSource( + handle: InfiniteQueryHandle, + source: ReadonlyArray, + pageSize: number, +): void { + const ready = handle + .observations() + .filter((observation) => observation.status === `ready`) + expect(ready.length).toBeGreaterThan(0) + for (const observation of ready) { + const pageCount = observation.pages.length + const visible = source.slice(0, pageCount * pageSize).map((row) => row.id) + expect(observation).toEqual({ + status: `ready`, + ids: visible, + pages: Array.from({ length: pageCount }, (_, page) => + visible.slice(page * pageSize, (page + 1) * pageSize), + ), + hasNextPage: source.length > pageCount * pageSize, + }) + } +} + +/** + * A live-query Collection sends no on-demand request until it has a + * subscriber or a preload. A framework that publishes a value before its hook + * subscribes (a React render, Svelte construction) therefore shows a value + * with no rows first; one whose first value follows the subscription (Vue + * setup) shows the ready first page. Both are legal. Any other first value, + * such as an error, an idle value, or one holding rows before it is ready, is + * not. + */ +export function expectFirstOnDemandValue( + handle: InfiniteQueryHandle, + firstPage: Array, + where: string, + // `loading` when the hook started the collection before subscribing; + // `idle` for a supplied window the hook adjusts before starting it. + notReady: `loading` | `idle` = `loading`, +): void { + const first = handle.observations()[0] + if (first?.status === `ready`) { + expect(first, `${where}: a ready first value`).toEqual({ + status: `ready`, + ids: firstPage, + pages: [firstPage], + hasNextPage: true, + }) + } else { + expect(first, `${where}: a first value before the request`).toEqual({ + status: notReady, + ids: [], + pages: [[]], + hasNextPage: false, + }) + } +} + +/** A rejected input throws at mount, reconfiguration, flush, or read. */ +export async function captureRejection( + run: () => Promise | unknown, +): Promise { + try { + await run() + return undefined + } catch (error) { + return error + } +} + +/** + * After a rejection, every read throws or holds no rows. Two reads catch a + * memo that throws once and then serves its previous value. No current driver + * can fail this check (see `contract.ts`); it guards a driver whose binding + * stays mounted after a rejection. + */ +export function expectNoPagesAfterRejection(handle: InfiniteQueryHandle): void { + for (let read = 0; read < 2; read++) { + let data: unknown + try { + data = handle.current().data + } catch { + continue + } + expect(data).toEqual([]) + } +} diff --git a/packages/db/tests/conformance/infinite-suite-oracle.ts b/packages/db/tests/conformance/infinite-suite.ts similarity index 93% rename from packages/db/tests/conformance/infinite-suite-oracle.ts rename to packages/db/tests/conformance/infinite-suite.ts index 6d461b0467..319e5e53ee 100644 --- a/packages/db/tests/conformance/infinite-suite-oracle.ts +++ b/packages/db/tests/conformance/infinite-suite.ts @@ -16,12 +16,13 @@ * history instead of test-runner housekeeping. * * This module keeps the scenarios that compare production with the source rows - * or with a model. Scenarios whose only comparison is a value written down by + * or with a model; the model and the publication law are in + * infinite-suite-laws-oracle.ts. Each framework's ordinary conformance test file + * runs this suite, so its scenarios are ordinary tests, not oracle properties. Scenarios whose only comparison is a value written down by * hand are in infinite-suite-cases.ts. The shared machinery is in * infinite-suite-harness.ts. */ import { describe, expect, it, vi } from 'vitest' -import { oracleRoute } from '../oracle-config' import { captureError, createScenarioState, @@ -33,139 +34,20 @@ import { waitForAsync, wrapDriver, } from './infinite-suite-harness' +import { + captureRejection, + expectFirstOnDemandValue, + expectNoPagesAfterRejection, + expectReadyObservationsMatchSource, + expectedOverlappingWindow, +} from './infinite-suite-laws-oracle' import { expectPageRows } from './page-laws-oracle' import { scenarioRegistry } from './registration' import type { InfiniteQueryContext, InfiniteRow, } from './infinite-suite-harness' -import type { - InfiniteQueryDriver, - InfiniteQueryHandle, -} from './infinite-contract' - -/** - * A fulfilled page fetch advances the committed prefix. A failed request leaves - * that prefix and the `ready` status alone; its rejection reaches the caller, - * and a failed source load behind it is a request error in the health tree. - * Source length decides whether the committed prefix has a continuation, - * including after later source writes. - */ -function expectedOverlappingWindow( - source: ReadonlyArray, - pageSize: number, - pageSucceeded: boolean, -) { - const pageCount = pageSucceeded ? 2 : 1 - const visible = source.slice(0, pageCount * pageSize) - return { - ids: visible.map((row) => row.id), - pages: Array.from({ length: pageCount }, (_, page) => - visible - .slice(page * pageSize, (page + 1) * pageSize) - .map((row) => row.id), - ), - pageParams: Array.from({ length: pageCount }, (_, page) => page), - hasNextPage: source.length > pageCount * pageSize, - isFetchingNextPage: !pageSucceeded, - } -} - -/** - * Publication law for a fixed source and query: every ready value the binding - * published equals the source prefix for that value's own page count. The - * expectation comes from the immutable source rows and page size, not from the - * controller, so a ready value that holds fewer rows than its pages, splits - * them at the wrong boundary, or reports a false end of list fails here even - * when the settled state later recovers. - */ -function expectReadyObservationsMatchSource( - handle: InfiniteQueryHandle, - source: ReadonlyArray, - pageSize: number, -): void { - const ready = handle - .observations() - .filter((observation) => observation.status === `ready`) - expect(ready.length).toBeGreaterThan(0) - for (const observation of ready) { - const pageCount = observation.pages.length - const visible = source.slice(0, pageCount * pageSize).map((row) => row.id) - expect(observation).toEqual({ - status: `ready`, - ids: visible, - pages: Array.from({ length: pageCount }, (_, page) => - visible.slice(page * pageSize, (page + 1) * pageSize), - ), - hasNextPage: source.length > pageCount * pageSize, - }) - } -} - -/** - * A live-query Collection sends no on-demand request until it has a - * subscriber or a preload. A framework that publishes a value before its hook - * subscribes (a React render, Svelte construction) therefore shows a value - * with no rows first; one whose first value follows the subscription (Vue - * setup) shows the ready first page. Both are legal. Any other first value, - * such as an error, an idle value, or one holding rows before it is ready, is - * not. - */ -function expectFirstOnDemandValue( - handle: InfiniteQueryHandle, - firstPage: Array, - where: string, - // `loading` when the hook started the collection before subscribing; - // `idle` for a supplied window the hook adjusts before starting it. - notReady: `loading` | `idle` = `loading`, -): void { - const first = handle.observations()[0] - if (first?.status === `ready`) { - expect(first, `${where}: a ready first value`).toEqual({ - status: `ready`, - ids: firstPage, - pages: [firstPage], - hasNextPage: true, - }) - } else { - expect(first, `${where}: a first value before the request`).toEqual({ - status: notReady, - ids: [], - pages: [[]], - hasNextPage: false, - }) - } -} - -/** A rejected input throws at mount, reconfiguration, flush, or read. */ -async function captureRejection( - run: () => Promise | unknown, -): Promise { - try { - await run() - return undefined - } catch (error) { - return error - } -} - -/** - * After a rejection, every read throws or holds no rows. Two reads catch a - * memo that throws once and then serves its previous value. No current driver - * can fail this check (see `contract.ts`); it guards a driver whose binding - * stays mounted after a rejection. - */ -function expectNoPagesAfterRejection(handle: InfiniteQueryHandle): void { - for (let read = 0; read < 2; read++) { - let data: unknown - try { - data = handle.current().data - } catch { - continue - } - expect(data).toEqual([]) - } -} +import type { InfiniteQueryDriver } from './infinite-contract' /** * The two laws that this suite's scenarios compare with. The publication law @@ -192,14 +74,7 @@ export function runInfiniteQuerySuite( ) => { registry.register(key) const label = `[${key}] ${name}` - it( - label, - () => { - oracleRoute(`case`) - return runScenario(state, fn) - }, - timeoutMs, - ) + it(label, () => runScenario(state, fn), timeoutMs) } describe(`infinite-query conformance :: ${driver.name}`, () => { diff --git a/packages/preact-db/tests/infinite-query-conformance.test.ts b/packages/preact-db/tests/infinite-query-conformance.test.ts index e972e3ad9a..60625ba9f2 100644 --- a/packages/preact-db/tests/infinite-query-conformance.test.ts +++ b/packages/preact-db/tests/infinite-query-conformance.test.ts @@ -27,7 +27,7 @@ import { gt, } from '@tanstack/db' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' import { registerInfiniteQueryUnitScenarios } from '../../db/tests/conformance/infinite-suite-cases' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index 1c53ae12f8..e15a0749d6 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -18,7 +18,7 @@ import { gt, } from '@tanstack/db' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' import { registerInfiniteQueryUnitScenarios } from '../../db/tests/conformance/infinite-suite-cases' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { useLiveInfiniteQuery } from '../src/useLiveInfiniteQuery' 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 39d95dde83..79415c4ca4 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -17,7 +17,7 @@ import { import { flushSync, untrack } from 'svelte' import { describe, expect, it } from 'vitest' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' import { registerInfiniteQueryUnitScenarios } from '../../db/tests/conformance/infinite-suite-cases' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index d44803a874..e9f17000db 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -24,7 +24,7 @@ import { } from 'vue' import { describe, expect, it } from 'vitest' import { mockSyncCollectionOptions } from '../../db/tests/utils' -import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite-oracle' +import { runInfiniteQuerySuite } from '../../db/tests/conformance/infinite-suite' import { registerInfiniteQueryUnitScenarios } from '../../db/tests/conformance/infinite-suite-cases' import { makeInfiniteOnDemandSource } from '../../db/tests/conformance/infinite-on-demand' import { withScopeSetup } from '../../db/tests/conformance/scope-setup' From b1ff291c9b3a87aca502d780b5ad0d6cada66955 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:39:13 -0700 Subject: [PATCH 094/116] chore(eslint): apply the oracle rules other than no-unit-test to every receiving witness Co-authored-by: Isaac --- eslint.config.js | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/eslint.config.js b/eslint.config.js index 35de1c992e..bdd8d4dbfc 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -58,14 +58,20 @@ export default [ { settings: oracleLintSettings }, oraclePlugin.configs.recommended, oraclePlugin.configs.production, - // Witnesses for runtimes that are not Vitest on Node (maintainer decision, - // 2026-11-11). These shared suites also run inside the Capacitor and Tauri - // WebView apps, which bundle them and cannot load the framework's Node-only - // main entry, so they keep their plain tests and are not oracle files. Every - // other oracle rule still applies to them. + // Receiving witnesses for runtimes that are not Vitest on Node (maintainer + // decision, 2026-11-11). oracleProperty registers through Vitest and its + // main entry is Node-only, so Playwright specs and the shared suites that + // the Capacitor and Tauri WebView apps bundle keep their plain tests. Each + // compares production with the shared check of its Vitest oracle owner. + // They are not oracle files, so oracle/no-unit-test does not apply; every + // other oracle rule does. { - name: `db/collection-e2e-witnesses`, - files: [`packages/db-collection-e2e/src/suites/*-witness.suite.ts`], + name: `db/receiving-witnesses`, + files: [ + `packages/db-collection-e2e/src/suites/*-witness.suite.ts`, + `packages/db/e2e/**/*-witness.spec.ts`, + `packages/browser-db-sqlite-persistence/e2e/*-witness.*.ts`, + ], plugins: { oracle: oraclePlugin }, linterOptions: { noInlineConfig: true }, rules: { From 3c0899c4486961b3ffa310a911b64add7ef3425d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:41:07 -0700 Subject: [PATCH 095/116] test(sqlite): register the persisted oracle's plain tests through oracleProperty M1: the five declareOracle calls are deleted. P2 and P1b: each inline case, table, and fixed history becomes an enumerated property whose driver is the unchanged body; the 12 non-rotated follower remote-demand rows join that property's fixed histories (P1a). P6: the grammar reconstruction, named wrong answers, append-only judgment, and evidence-coupled mutant calibrations run no production, so they move unchanged to persisted-model-calibration.test.ts. The it.fails witnesses of known limit #2139 move unchanged to persisted-known-defects.test.ts. The models, checks, grammar, and follower drivers they need move unchanged to the companions persisted-durability-model-oracle.ts and persisted-follower-recovery-oracle.ts. Co-authored-by: Isaac --- .../persisted-durability-model-oracle.ts | 545 ++ .../persisted-follower-recovery-oracle.ts | 1022 +++ .../tests/persisted-known-defects.test.ts | 147 + .../tests/persisted-model-calibration.test.ts | 273 + .../tests/persisted-oracle.test.ts | 7099 +++++++---------- .../db/tests/converted-properties/sqlite.ts | 23 + 6 files changed, 4835 insertions(+), 4274 deletions(-) create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-durability-model-oracle.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-follower-recovery-oracle.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-model-calibration.test.ts diff --git a/packages/db-sqlite-persistence-core/tests/persisted-durability-model-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-durability-model-oracle.ts new file mode 100644 index 0000000000..92ceba1d0b --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-durability-model-oracle.ts @@ -0,0 +1,545 @@ +/** + * The models, checks, and generated grammar of the durability laws in + * persisted-oracle.test.ts: the scoped and targeted snapshot models, the + * on-demand evidence law, the append-only durability ledger, the observation + * checks of the source-order, abort-graph, owner-isolation, open-transaction, + * and request-local-failure laws, and the generated persistence grammars with + * their reconstruction. Owns nothing and registers no tests: the oracle drives + * production with them, and persisted-model-calibration.test.ts calibrates them. + */ +import { expect } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed, oracleRuns } from '../../db/tests/oracle-config.js' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import type { Todo } from './persisted-support' + +// A complete durable row is a value, including its exact set of fields. The +// model deliberately uses only Maps and authored input rows; it does not +// classify production sync writes or copy their update/replace algorithm. +// Active complete demand admits every durable key. Without demand, a notice +// may validate previously public keys, but cannot publish an unseen key. +// DbClient hydration seeds remain source-owned until confirmed by the source. +// Other rows in this model came from an earlier durable read; accepted source +// rows belong to the separate source-precedence model below. Copy authored +// values so a subject mutation cannot alter the expected result by aliasing. +export function expectedScopedSnapshot( + previouslyPublic: ReadonlyMap, + durable: ReadonlyMap, + protectedSeeds: ReadonlyMap, + completeDemand: boolean, +): Map { + const expected = completeDemand + ? new Map( + Array.from( + durable, + ([key, row]) => [key, structuredClone(row)] as const, + ), + ) + : new Map( + Array.from(previouslyPublic.keys()) + .filter((key) => durable.has(key)) + .map((key) => [key, structuredClone(durable.get(key)!)] as const), + ) + for (const [key, row] of protectedSeeds) + expected.set(key, structuredClone(row)) + return expected +} + +// Targeted notices are changes to the durable relation, not complete rows in +// the message. An active predicate admits the durable rows it matches; after +// demand retirement, only keys that were public remain eligible for checking. +// This simple title predicate is the model's authored scope, independent of +// the production predicate compiler and targeted-invalidation classifier. +export function expectedTargetedSnapshot( + previouslyPublic: ReadonlyMap, + durable: ReadonlyMap, + activeTitle: string | undefined, +): Map { + return new Map( + Array.from(durable) + .filter(([key, row]) => + activeTitle === undefined + ? previouslyPublic.has(key) + : row.title === activeTitle, + ) + .map(([key, row]) => [key, structuredClone(row)] as const), + ) +} + +export const persistedKeySetEvidenceStatuses = [ + `consistent`, + `unknown`, + `incompatible`, +] as const + +export type OnDemandEvidenceObservation = { + status: (typeof persistedKeySetEvidenceStatuses)[number] + route: `loadSubset` | `forceReloadSubset` + baselineVisible: boolean | `not-observed` + onDemandVisible: boolean +} + +export function expectOnDemandEvidenceLaw( + observation: OnDemandEvidenceObservation, +): void { + try { + oracleAssertion('existing comparison at 207', () => + expect(observation).toEqual({ + status: observation.status, + route: observation.route, + baselineVisible: + observation.route === `loadSubset` + ? observation.status !== `incompatible` + : `not-observed`, + onDemandVisible: true, + }), + ) + } catch (cause) { + throw new Error( + `on-demand rows must not inherit baseline evidence rejection`, + { cause }, + ) + } +} + +export type DurabilityLedgerEvent = + | { type: `begin`; transactionId: string } + | { type: `write`; transactionId: string; row: Todo } + | { type: `commit`; transactionId: string } + | { type: `abort`; transactionId: string } + +// RFC #1659 durability law: a source commit remains one append-only obligation +// across a persistence hydration boundary. The independent fold deliberately +// has no hydration phase, queue, generation, or production transaction helper. +// These fixtures exercise the core adapter contract with in-memory storage; +// they do not claim native SQLite host or device execution. +export function foldDurabilityLedger( + events: ReadonlyArray, +): { + committedRows: Map + commitOrder: Array +} { + const open = new Map>() + const committedRows = new Map() + const commitOrder: Array = [] + + for (const event of events) { + if (event.type === `begin`) { + open.set(event.transactionId, []) + } else if (event.type === `write`) { + open.get(event.transactionId)?.push(structuredClone(event.row)) + } else if (event.type === `abort`) { + open.delete(event.transactionId) + } else { + const rows = open.get(event.transactionId) + if (!rows) continue + for (const row of rows) committedRows.set(row.id, row) + commitOrder.push(event.transactionId) + open.delete(event.transactionId) + } + } + + return { committedRows, commitOrder } +} + +export type SourceFifoOrderingObservation = { + publicRows: Array + durableRows: Array + status: string + pendingMarkers: number +} + +export function expectSourceFifoOrderingObservation( + actual: SourceFifoOrderingObservation, + expectedRows: ReadonlyArray, +): void { + oracleAssertion('existing comparison at 279', () => + expect(actual).toEqual({ + publicRows: expectedRows, + durableRows: expectedRows, + status: `ready`, + pendingMarkers: 0, + }), + ) +} + +export type AbortGraphObservation = { + predecessorStatus: `fulfilled` | `rejected` + predecessorName: string | undefined + successorStatus: `fulfilled` | `rejected` + sameFailure: boolean + status: string + publicSuccessor: Todo | undefined + durableSuccessor: Todo | undefined + pendingMarkers: number +} + +export function expectAbortGraphObservation( + actual: AbortGraphObservation, + relationship: `independent` | `same-key`, + successorKey: string, + successorTitle: string, +): void { + oracleAssertion('existing comparison at 304', () => + expect(actual).toEqual( + relationship === `same-key` + ? { + predecessorStatus: `rejected`, + predecessorName: `AbortError`, + successorStatus: `rejected`, + sameFailure: true, + status: `crashed`, + publicSuccessor: undefined, + durableSuccessor: undefined, + pendingMarkers: 0, + } + : { + predecessorStatus: `rejected`, + predecessorName: `AbortError`, + successorStatus: `fulfilled`, + sameFailure: false, + status: `ready`, + publicSuccessor: { id: successorKey, title: successorTitle }, + durableSuccessor: { id: successorKey, title: successorTitle }, + pendingMarkers: 0, + }, + ), + ) +} + +export type OwnerIsolationAdmissionObservation = { + exactError: boolean + strayVisible: boolean +} + +export function expectOwnerIsolationAdmission( + actual: OwnerIsolationAdmissionObservation, +): void { + oracleAssertion('existing comparison at 337', () => + expect(actual).toEqual({ exactError: true, strayVisible: false }), + ) +} + +export type OpenTransactionBoundaryObservation = { + status: string + pendingMarkers: number +} + +export type RequestLocalFailureObservation = { + receiptStatus: `pending` | `fulfilled` | `rejected` + publicRows: Array + durableRows: Array + status: string + publicError: unknown +} + +export function expectOpenTransactionBoundaryObservation( + actual: OpenTransactionBoundaryObservation, + boundary: `abort` | `cleanup` | `terminal-failure`, +): void { + oracleAssertion('existing comparison at 357', () => + expect(actual).toEqual({ + status: + boundary === `cleanup` + ? `cleaned-up` + : boundary === `abort` + ? `ready` + : `crashed`, + pendingMarkers: 0, + }), + ) +} + +export function expectRequestLocalFailureObservation( + actual: RequestLocalFailureObservation, + expectedRows: ReadonlyArray, +): void { + oracleAssertion('existing comparison at 372', () => + expect(actual).toEqual({ + receiptStatus: `fulfilled`, + publicRows: expectedRows, + durableRows: expectedRows, + status: `ready`, + publicError: undefined, + }), + ) +} + +export type GeneratedPersistenceHistory = { + axes: Array + titles: Array +} + +export type GeneratedPersistenceGrammar = { + property: string + axes: ReadonlyArray + axisContribution: Readonly> + titleCount: number + witness: GeneratedPersistenceHistory +} + +export const sourceFifoOrderingAxes = [`same-key`, `disjoint`] as const +export const abortGraphAxes = [`independent`, `same-key`] as const +export const ownerIsolationAxes = [`write`, `commit`, `truncate`] as const +export const openTransactionBoundaryAxes = [ + `abort`, + `cleanup`, + `terminal-failure`, +] as const +export const requestLocalFailureAxes = [ + `partial-update`, + `delete`, + `insert`, +] as const +export const generatedTitleMaxLength = 12 +export const generatedPersistenceFixedSeeds = { + sourceFifoOrdering: 18_530_101, + abortGraph: 18_530_102, + ownerIsolation: 18_530_103, + openTransactionBoundary: 18_530_104, + requestLocalFailure: 18_530_105, +} as const + +export const generatedPersistenceGrammars: ReadonlyArray = + [ + { + property: `sqlite-persistence.source-fifo-order`, + axes: sourceFifoOrderingAxes, + axisContribution: { + [`same-key`]: `the later source value wins in source order`, + disjoint: `both unrelated values and receipts survive`, + }, + titleCount: 2, + witness: { + axes: [`same-key`, `disjoint`], + titles: [`older`, `newer`], + }, + }, + { + property: `sqlite-persistence.abort-graph`, + axes: abortGraphAxes, + axisContribution: { + independent: `an unrelated sibling survives the abort`, + [`same-key`]: `a dependent suffix shares the predecessor failure`, + }, + titleCount: 2, + witness: { + axes: [`independent`, `same-key`], + titles: [`predecessor`, `successor`], + }, + }, + { + property: `sqlite-persistence.owner-isolation`, + axes: ownerIsolationAxes, + axisContribution: { + write: `bare write rejects without publishing a stray row`, + commit: `bare commit rejects without settling another owner`, + truncate: `bare truncate rejects without clearing another owner`, + }, + titleCount: 1, + witness: { + axes: [`write`, `commit`, `truncate`], + titles: [`queued`], + }, + }, + { + property: `sqlite-persistence.open-transaction-boundary`, + axes: openTransactionBoundaryAxes, + axisContribution: { + abort: `explicit abort leaves no core transaction`, + cleanup: `cleanup leaves no core transaction`, + [`terminal-failure`]: `fail-stop leaves no core transaction`, + }, + titleCount: 1, + witness: { + axes: [`abort`, `cleanup`, `terminal-failure`], + titles: [`hydrated`], + }, + }, + { + property: `sqlite-persistence.request-local-failure-buffered-source`, + axes: requestLocalFailureAxes, + axisContribution: { + [`partial-update`]: `an unseen persisted baseline is reconstructed`, + delete: `a buffered delete settles without resurrection`, + insert: `a complete buffered insert settles without duplication`, + }, + titleCount: 2, + witness: { + axes: [`partial-update`, `delete`, `insert`], + titles: [`baseline`, `source`], + }, + }, + ] + +export function reconstructGeneratedPersistenceHistory( + grammar: GeneratedPersistenceGrammar, + history: GeneratedPersistenceHistory, +): GeneratedPersistenceHistory { + const requiredAxes = new Set(grammar.axes) + const axesAreExact = + history.axes.length === grammar.axes.length && + new Set(history.axes).size === grammar.axes.length && + history.axes.every((axis) => requiredAxes.has(axis)) + const titlesAreInRange = + history.titles.length === grammar.titleCount && + history.titles.every( + (title) => [...title].length <= generatedTitleMaxLength, + ) + if (!axesAreExact || !titlesAreInRange) { + throw new Error( + `invalid generated persistence history: ${grammar.property}`, + ) + } + return { + axes: [...history.axes], + titles: [...history.titles], + } +} + +export function fullAxisOrder( + axes: ReadonlyArray, +): fc.Arbitrary> { + return fc.shuffledSubarray([...axes], { + minLength: axes.length, + maxLength: axes.length, + }) +} + +export const generatedTitle = fc.string({ maxLength: generatedTitleMaxLength }) + +export type SourceFifoOrderingHistory = { + relations: Array<`same-key` | `disjoint`> + titles: [string, string] +} + +export type AbortGraphHistory = { + relationships: Array<`independent` | `same-key`> + titles: [string, string] +} + +export type OwnerIsolationHistory = { + operations: Array<`write` | `commit` | `truncate`> + queuedTitle: string +} + +export type OpenTransactionBoundaryHistory = { + boundaries: Array<`abort` | `cleanup` | `terminal-failure`> + hydratedTitle: string +} + +export type RequestLocalFailureOperation = + `partial-update` | `delete` | `insert` + +export type RequestLocalFailureHistory = { + operations: Array + titles: [string, string] +} + +export const sourceFifoOrderingHistory: fc.Arbitrary = + fc.record({ + relations: fullAxisOrder(sourceFifoOrderingAxes), + titles: fc.tuple(generatedTitle, generatedTitle), + }) + +export const abortGraphHistory: fc.Arbitrary = fc.record({ + relationships: fullAxisOrder(abortGraphAxes), + titles: fc.tuple(generatedTitle, generatedTitle), +}) + +export const ownerIsolationHistory: fc.Arbitrary = + fc.record({ + operations: fullAxisOrder(ownerIsolationAxes), + queuedTitle: generatedTitle, + }) + +export const openTransactionBoundaryHistory: fc.Arbitrary = + fc.record({ + boundaries: fullAxisOrder(openTransactionBoundaryAxes), + hydratedTitle: generatedTitle, + }) + +export const requestLocalFailureHistory: fc.Arbitrary = + fc.record({ + operations: fullAxisOrder(requestLocalFailureAxes), + titles: fc.tuple(generatedTitle, generatedTitle), + }) + +export function generatedPersistenceGrammarSamples(): ReadonlyArray<{ + grammar: GeneratedPersistenceGrammar + histories: Array +}> { + const grammar = (property: string): GeneratedPersistenceGrammar => { + const match = generatedPersistenceGrammars.find( + (candidate) => candidate.property === property, + ) + if (match === undefined) throw new Error(`missing grammar: ${property}`) + return match + } + const sampleOptions = (seed: number) => + oracleFixedSeed(seed, { numRuns: oracleRuns(4) }) + return [ + { + grammar: grammar(`sqlite-persistence.source-fifo-order`), + histories: fc + .sample( + sourceFifoOrderingHistory, + sampleOptions(generatedPersistenceFixedSeeds.sourceFifoOrdering), + ) + .map((history) => ({ + axes: history.relations, + titles: [...history.titles], + })), + }, + { + grammar: grammar(`sqlite-persistence.abort-graph`), + histories: fc + .sample( + abortGraphHistory, + sampleOptions(generatedPersistenceFixedSeeds.abortGraph), + ) + .map((history) => ({ + axes: history.relationships, + titles: [...history.titles], + })), + }, + { + grammar: grammar(`sqlite-persistence.owner-isolation`), + histories: fc + .sample( + ownerIsolationHistory, + sampleOptions(generatedPersistenceFixedSeeds.ownerIsolation), + ) + .map((history) => ({ + axes: history.operations, + titles: [history.queuedTitle], + })), + }, + { + grammar: grammar(`sqlite-persistence.open-transaction-boundary`), + histories: fc + .sample( + openTransactionBoundaryHistory, + sampleOptions(generatedPersistenceFixedSeeds.openTransactionBoundary), + ) + .map((history) => ({ + axes: history.boundaries, + titles: [history.hydratedTitle], + })), + }, + { + grammar: grammar( + `sqlite-persistence.request-local-failure-buffered-source`, + ), + histories: fc + .sample( + requestLocalFailureHistory, + sampleOptions(generatedPersistenceFixedSeeds.requestLocalFailure), + ) + .map((history) => ({ + axes: history.operations, + titles: [...history.titles], + })), + }, + ] +} diff --git a/packages/db-sqlite-persistence-core/tests/persisted-follower-recovery-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-follower-recovery-oracle.ts new file mode 100644 index 0000000000..1dca90caf3 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-follower-recovery-oracle.ts @@ -0,0 +1,1022 @@ +/** + * The follower generation-notice, remote-demand, and rotated-reacquisition + * drivers and models of persisted-oracle.test.ts, with their histories. Owns + * nothing and registers no tests: the oracle runs the histories that + * production satisfies, and persisted-known-defects.test.ts runs the known + * limit #2139 witnesses through the same drivers. + */ +import { expect, vi } from 'vitest' +import { fc } from '@fast-check/vitest' +import { + IR, + createCollection, + createLiveQueryCollection, + eq, +} from '@tanstack/db' +import { persistedCollectionOptions } from '../src' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { + atPersistedOracleCheckpoint, + cleanupPersistedOracle, + createDeferred, + createFollowerTab, + expectExactSnapshot, + flushAsyncWork, + recordedStorageId, + stripVirtualProps, + within, +} from './persisted-support' +import { expectedScopedSnapshot } from './persisted-durability-model-oracle' +import type { + PersistedCollectionCoordinator, + PersistedStorageTarget, + PersistenceAdapter, + ProtocolEnvelope, + TxCommitted, +} from '../src' +import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' +import type { + FollowerRemoteDemandHistory, + FollowerRow, + Todo, + TodoSyncParams, +} from './persisted-support' + +/** + * A follower has no local source. It routes demand to the elected owner, + * and the owner's commits reach it only as durable rows plus a commit + * notice. After a cache rotation the follower is in scoped recovery, where + * durable rows cannot replace source rows. Rotation gives the follower a + * fresh cache generation, and the follower asks the owner to reacquire + * every active demand into it. Once those reacquisitions settle, the new + * generation holds exactly the owner's rows for that demand, so durable + * rows are authoritative again. The law is therefore: while the + * reacquisitions are in flight, the follower holds the generation's commit + * notices; when they settle, scoped recovery ends for that generation and + * the held notices, and every later one, apply through the normal path. + * No sender is special: a peer's notice after recovery is an ordinary + * invalidation that rereads durable rows. + * + * The independent model is `expectedScopedSnapshot` with complete demand + * over the authored durable rows of the current generation. It knows + * nothing about notices or scoped recovery; the driver applies it only at + * checkpoints where the owner's evidence has arrived. The ensure log is a + * separate observation: one acquisition and one reacquisition after + * rotation, and no other ensure. A bounded leader that re-commits on every + * ensure makes the alternative design (answer every notice during scoped + * recovery with a refetch) loop, and the bound turns that loop into an + * assertion failure. + * + * The grammar crosses when the owner first commits the row (before or + * after the rotation) with a later broadcast for the same key: none, an + * owner update, or a peer notice that claims a different value. An owner + * update's durable read either succeeds or fails. Recovery has ended, so a + * failed read of it is a real failure: the follower becomes terminal and + * keeps the rows it showed, instead of silently ignoring the failure as if + * a new recovery had started during the read. + * The + * observations are the follower's rows when its first load settles, after + * the rotation's reacquisition, and after the later broadcast, plus the + * ensure log at quiescence. The coordinator is a controlled double of an + * elected owner; it does not prove the browser transport. + * + * 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. + */ +export type FollowerGenerationNoticeHistory = { + ownerCommit: `before-rotation` | `after-rotation` + later: `none` | `owner` | `peer` + // Applies only to a later owner update. + ownerRead: `ok` | `fails` + // Control: an owner notice's read is in flight when rotation starts, and + // then fails. Recovery owns that read, so its failure is ignored. + readFailsAsRotationStarts?: boolean + // How the owner's notices in the rotated generation reach the follower: + // in order, after a gap in the stream position (the follower's position + // restarts at zero when it rotates), or as an exact-ID reconciliation + // reload. Each must publish the same rows once recovery ends. + noticeShape?: `plain` | `gap` | `reconciliation` +} +export const assertFollowerGenerationNoticeHistory = async ({ + ownerCommit, + later, + ownerRead, + readFailsAsRotationStarts = false, + noticeShape = `plain`, +}: FollowerGenerationNoticeHistory): Promise => { + const readFailure = new Error(`owner update read failed`) + let failNextRead = false + let holdNextRead: + { promise: Promise; reject: (e: unknown) => void } | undefined + const oldStorageId = `follower-notice-old` + const newStorageId = `follower-notice-new` + const ownerId = `elected-owner` + const v1: Todo = { id: `remote`, title: `Owner row` } + const v2: Todo = { id: `remote`, title: `Owner update` } + const durable = new Map>([ + [oldStorageId, new Map()], + [newStorageId, new Map()], + ]) + let storageId = oldStorageId + // The owner's upstream row: present from the start, or only once the + // owner fetched it after the rotation began. + let upstream: Todo | undefined = + ownerCommit === `before-rotation` ? v1 : undefined + const ensures: Array<{ id: string; refetch: boolean }> = [] + const subscribers = new Map< + string, + (message: ProtocolEnvelope) => void + >() + const versions = new Map() + // Enough commits for every legal history; a refetch loop exceeds it. + const commitBudget = 8 + let commits = 0 + const notify = (id: string, row: Todo, senderId: string) => { + // A gap skips one stream position in the rotated generation. + const skip = + noticeShape === `gap` && id === newStorageId && !versions.has(id) ? 1 : 0 + const version = (versions.get(id) ?? 0) + 1 + skip + versions.set(id, version) + const reconciliation = + noticeShape === `reconciliation` && id === newStorageId + subscribers.get(id)?.({ + v: 1, + dbName: `test-db`, + collectionId: id, + senderId, + ts: Date.now(), + payload: { + type: `tx:committed`, + term: 1, + seq: version, + txId: `${senderId}-${id}-${version}`, + latestRowVersion: version, + requiresFullReload: reconciliation, + ...(reconciliation ? { reconciliationReload: true } : {}), + changedRows: [{ key: row.id, value: { ...row } }], + deletedKeys: [], + } satisfies TxCommitted, + }) + } + const ownerCommits = (id: string, row: Todo) => { + if (commits++ >= commitBudget) return + durable.get(id)!.set(row.id, structuredClone(row)) + notify(id, row, ownerId) + } + const adapter: PersistenceAdapter = { + loadSubset: async (target) => { + const hold = holdNextRead + if (hold) { + holdNextRead = undefined + await hold.promise + } + if (failNextRead) { + failNextRead = false + throw readFailure + } + return Array.from( + durable.get(recordedStorageId(target))!.values(), + (value) => ({ + key: value.id, + value: structuredClone(value), + }), + ) + }, + claimCacheGeneration: async () => ({ + storageCollectionId: oldStorageId, + claimId: `follower-notice-claim`, + expiresAtMs: Date.now() + 60_000, + }), + renewCacheGenerationClaim: async () => Date.now() + 60_000, + rotateCacheGeneration: async () => { + storageId = newStorageId + if (ownerCommit === `after-rotation`) upstream = v1 + return { + storageCollectionId: newStorageId, + claimId: `follower-notice-claim`, + expiresAtMs: Date.now() + 60_000, + } + }, + releaseCacheGenerationClaim: async () => {}, + loadResumeSnapshot: async () => ({ + rows: [], + keySet: { status: `consistent` }, + collectionMetadata: [], + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + resetEpoch: 0, + }), + applyCommittedTx: async () => {}, + ensureIndex: async () => {}, + } + const coordinator: PersistedCollectionCoordinator = { + getNodeId: () => `follower-node`, + subscribe: (id, onMessage) => { + subscribers.set(id, onMessage) + return () => { + if (subscribers.get(id) === onMessage) subscribers.delete(id) + } + }, + publish: () => {}, + isLeader: () => false, + ensureLeadership: async () => {}, + requestEnsurePersistedIndex: async () => {}, + // The elected owner loads its upstream and commits it into the + // requested generation before it replies, on every ensure. + requestEnsureRemoteSubset: async (id, options) => { + ensures.push({ id, refetch: options.refetch === true }) + if (upstream) ownerCommits(id, upstream) + }, + requestReleaseRemoteSubset: async () => {}, + registerRemoteSubsetOwner: () => () => {}, + requestApplyCommittedTx: (_id, tx) => + Promise.resolve({ + type: `rpc:applyCommittedTx:res`, + rpcId: tx.txId, + ok: true, + term: tx.term, + seq: tx.seq, + latestRowVersion: tx.rowVersion, + }), + pullSince: () => + Promise.resolve({ + type: `rpc:pullSince:res`, + rpcId: `follower-notice-pull`, + ok: true, + latestTerm: 1, + latestSeq: 0, + latestRowVersion: 0, + requiresFullReload: false, + changedKeys: [], + deletedKeys: [], + }), + } + let source!: TodoSyncParams + const collection = createCollection( + persistedCollectionOptions({ + id: `follower-generation-notice`, + syncMode: `on-demand`, + getKey: (row) => row.id, + sync: { + sync: (params) => { + source = params + params.markReady() + return { + loadSubset: async () => {}, + restartAfterScopedRecovery: async (cacheRotated) => { + await cacheRotated + }, + } + }, + }, + persistence: { adapter, coordinator }, + }), + ) + const demand: LoadSubsetOptions = { limit: 10 } + const publicRows = () => + new Map( + Array.from(collection.state, ([key, row]) => [ + String(key), + stripVirtualProps(row) as Todo, + ]), + ) + // A checkpoint after the follower has processed every delivered notice: + // the scan crosses the same apply mutex the notices use. + const settled = async (label: string) => { + await flushAsyncWork() + await atPersistedOracleCheckpoint( + source.metadata!.persistence!.scanPersistedRows(), + label, + ) + await flushAsyncWork() + } + const expectOwnerEvidence = (previous: ReadonlyMap) => { + const expected = expectedScopedSnapshot( + previous, + durable.get(storageId)!, + new Map(), + true, + ) + expectExactSnapshot(publicRows(), expected) + return expected + } + let hasPrimaryFailure = false + try { + collection.startSyncImmediate() + await collection.stateWhenReady() + await atPersistedOracleCheckpoint( + Promise.resolve(collection._sync.loadSubset(demand)), + `follower demand settled`, + ) + await settled(`first-ready notices processed`) + let visible = expectOwnerEvidence(new Map()) + + const persistence = source.metadata?.persistence + if (!persistence?.startScopedRecovery) { + throw new Error(`Expected scoped recovery capability`) + } + let rotation: Promise + if (readFailsAsRotationStarts) { + let reject!: (error: unknown) => void + const promise = new Promise((_resolve, rejectRead) => { + reject = rejectRead + }) + void promise.catch(() => undefined) + holdNextRead = { promise, reject } + ownerCommits(oldStorageId, { + id: `remote`, + title: `Pre-rotation update`, + }) + await flushAsyncWork() + rotation = persistence.startScopedRecovery() + reject(readFailure) + } else { + rotation = persistence.startScopedRecovery() + } + await atPersistedOracleCheckpoint( + rotation, + `follower cache rotation settled`, + ) + await atPersistedOracleCheckpoint( + vi.waitFor(() => + oracleAssertion('existing comparison at 3796', () => + expect(ensures.map(({ id }) => id)).toContain(newStorageId), + ), + ), + `reacquisition reached the owner`, + ) + await settled(`reacquisition notices processed`) + // Answering the owner's notice with another refetch would make the + // owner commit again, and the follower would refetch forever. + oracleAssertion('existing comparison at 3803', () => + expect.soft(ensures).toHaveLength(2), + ) + visible = expectOwnerEvidence(visible) + + if (later === `owner`) { + upstream = v2 + failNextRead = ownerRead === `fails` + ownerCommits(newStorageId, v2) + if (ownerRead === `fails`) { + // Not settled(): a terminal runtime rejects the checkpoint scan. + await flushAsyncWork() + await vi.waitFor(() => + oracleAssertion('existing comparison at 3813', () => + expect(collection.status).toBe(`crashed`), + ), + ) + expectExactSnapshot(publicRows(), visible) + } else { + await settled(`owner update processed`) + expectOwnerEvidence(visible) + } + } else if (later === `peer`) { + // A peer's notice after recovery is an ordinary invalidation: the + // follower rereads durable rows, which the peer did not change, so + // its rows stay as they were and it sends no other ensure. + notify(newStorageId, { id: `remote`, title: `Peer claim` }, `peer-node`) + await settled(`peer notice processed`) + expectOwnerEvidence(visible) + } + + oracleAssertion('existing comparison at 3828', () => + expect(ensures).toEqual([ + { id: oldStorageId, refetch: false }, + { id: newStorageId, refetch: true }, + ]), + ) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + await cleanupPersistedOracle( + [() => collection.cleanup()], + hasPrimaryFailure, + ) + } +} + +export const followerGenerationNoticeHistory = fc.record({ + ownerCommit: fc.constantFrom( + `before-rotation`, + `after-rotation`, + ), + later: fc.constantFrom( + `none`, + `owner`, + `peer`, + ), + ownerRead: fc.constantFrom( + `ok`, + `fails`, + ), +}) + +export const expectedFollowerRemoteDemand = ( + history: FollowerRemoteDemandHistory, +) => { + const applied = history.apply === `ok` + return { + // While the owner's commit has not applied here, nothing has settled. + whileApplyHeld: { settled: false, rows: undefined }, + settled: + history.consumer === `live-query` + ? applied + ? { outcome: `fulfilled`, rows: [`remote`], status: `ready` } + : { outcome: `rejected`, rows: undefined, status: `crashed` } + : applied + ? { + outcome: `fulfilled`, + rows: [`remote`], + status: undefined, + releases: 0, + } + : { + outcome: `rejected`, + rows: undefined, + status: `crashed`, + releases: 1, + }, + } +} + +export const assertFollowerRemoteDemandHistory = async ( + history: FollowerRemoteDemandHistory, +) => { + const expected = expectedFollowerRemoteDemand(history) + const errors = vi.spyOn(console, `error`).mockImplementation(() => {}) + const warnings = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const tab = createFollowerTab(history) + const groupQuery = (group: string) => + createLiveQueryCollection((q) => + q.from({ row: tab.source }).where(({ row }) => eq(row.group, group)), + ) + const query = groupQuery(`a`) + const other = groupQuery(`b`) + try { + // The consumer's observation: its outcome, and the demanded rows it + // sees when it settles. + const seen: { + outcome: `pending` | `fulfilled` | `rejected` + rows?: Array + } = { outcome: `pending` } + const demandedRows = () => + Array.from(tab.source.values()) + .filter((row) => row.group === `a`) + .map((row) => row.id) + .sort() + let settled: Promise + if (history.consumer === `live-query`) { + query.onFirstReady(() => { + seen.rows = query.toArray.map((row) => (row as FollowerRow).id).sort() + }) + settled = query.preload().then( + () => (seen.outcome = `fulfilled`), + () => (seen.outcome = `rejected`), + ) + } else { + settled = Promise.resolve( + tab.source._sync.loadSubset({ + where: new IR.Func(`eq`, [ + new IR.PropRef([`group`]), + new IR.Value(`a`), + ]), + }), + ).then( + () => { + seen.outcome = `fulfilled` + seen.rows = demandedRows() + }, + () => (seen.outcome = `rejected`), + ) + } + if (history.placement === `queued`) void other.preload().catch(() => {}) + + if (history.placement === `queued`) { + // The owner's broadcast arrives while this hydration holds the apply + // mutex, so it waits in the queue until the hydration ends. + await within(tab.otherHydrationStarted) + await new Promise((resolve) => setTimeout(resolve, 0)) + tab.releaseOtherHydration() + } + const applyStarted = await within(tab.applyReadStarted) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Checkpoint: the owner has replied, but its row has not applied here. + oracleAssertion('existing comparison at 4037', () => + expect({ + applyStarted, + settled: seen.outcome !== `pending`, + rows: seen.rows, + }).toEqual({ applyStarted: true, ...expected.whileApplyHeld }), + ) + + tab.releaseApply() + await within(settled) + await new Promise((resolve) => setTimeout(resolve, 0)) + // Checkpoint: the apply settled. + const releasesAtSettle = tab.releases() + oracleAssertion('existing comparison at 4048', () => + expect({ + outcome: seen.outcome, + rows: seen.rows, + ...(`releases` in expected.settled + ? { releases: releasesAtSettle } + : {}), + status: + expected.settled.status === undefined + ? undefined + : history.consumer === `live-query` && + expected.settled.status === `ready` + ? query.status + : expected.settled.status === `crashed` + ? history.consumer === `live-query` + ? `${query.status}/${tab.source.status}` + : tab.source.status + : tab.source.status, + }).toEqual({ + outcome: expected.settled.outcome, + rows: expected.settled.rows, + ...(`releases` in expected.settled + ? { releases: expected.settled.releases } + : {}), + status: + expected.settled.status === `crashed` && + history.consumer === `live-query` + ? `crashed/crashed` + : expected.settled.status, + }), + ) + } finally { + await query.cleanup() + await other.cleanup() + await tab.source.cleanup() + errors.mockRestore() + warnings.mockRestore() + } +} + +export const followerRemoteDemandHistories: Array = + ([`direct`, `queued`, `rotated`, `expired`] as const).flatMap((placement) => + ([`ok`, `fails`] as const).flatMap((apply) => + ([`live-query`, `load`] as const).map((consumer) => ({ + placement, + apply, + consumer, + })), + ), + ) +// Scoped recovery means durable rows cannot replace source rows. Owner +// evidence is an exception only in a rotated generation, which holds +// nothing but the owner's writes. Without a managed generation the old +// durable cache stays in place, so the model predicts that an owner notice +// during that recovery republishes none of the untrusted durable rows. +/** + * After a cache rotation, a follower reacquires every active demand into + * the new generation through the owner. The owner commits each demand's + * rows into that generation and broadcasts them before it replies, so the + * follower holds those notices. Scoped recovery may end only once every + * reacquisition has succeeded: before that, the new generation is still + * incomplete and its durable rows have no authority. Starting scoped + * recovery withdraws the old generation's rows. + * + * The independent model knows each demand's row version per generation: + * the owner wrote version 1 into the old generation and writes version 2 + * into the new one when it reacquires the demand. Until every + * reacquisition has settled, neither demand shows a row. If all succeed, + * both show the new generation's rows, including an owner update that + * arrived while the reacquisitions were settling. If any fails, the + * follower stays in scoped recovery, shows no row from the incomplete + * generation, and the source reports the failure as soon as that + * reacquisition settles. A reacquisition is a remote request, so the + * failure is the ready source's failure reason (`reportError()`: the source + * keeps syncing), never a crash; a crash is only for local storage or an + * internal invariant (glossary "crash"; maintainer decision for PR #2168). + * + * A demand that the caller releases or aborts while it reacquires no longer + * needs the new generation's rows: its aborted ensure is neither a failure + * nor a reported error, and the other demands decide the outcome. Releasing + * it twice changes nothing. A load that fails on its own still counts as a + * failure even though the failed load gives up its demand. + * + * A new demand loaded while the reacquisitions are in flight settles only + * once they all have: with the rows of the new generation if recovery + * ended, or rejected if it did not. Its own ensure answering early does not + * make its held rows visible. + * + * Only a recovery that rotated can end this way. A recovery that keeps the + * old generation, or one in which this node is the leader, leaves scoped + * recovery in place, and a newer rotation supersedes an older one's + * reacquisition. + * + * 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 fixed histories that fail. + * + * Limits. A failed reacquisition is not retried; the follower stays in + * scoped recovery until a later recovery rotates again. + * + * The grammar crosses the order in which two demands' reacquisitions + * settle, whether the owner updates demand a's row (version 3) between + * the first and last settlement, which reacquisitions fail or are released, + * and whether a new demand loads during reacquisition. Separate cells start + * rotation from two reads that find the claim lost, supersede reacquisition + * by a rotation as leader, and recover without rotating. The driver + * observes the rows, the new load's outcome, and the source status after + * every settlement, plus the ensure count, which a refetch loop would + * exceed. + */ +export type RotatedReacquisitionHistory = { + order: [`a`, `b`] | [`b`, `a`] + noticeBetween: boolean + // Which reacquisitions fail. `released` means demand b is released + // while it reacquires, so its ensure aborts; that is not a failure. + fails: `none` | `a` | `b` | `both` | `released` + // A new demand, c, loads while the reacquisitions are in flight. + newDemand?: boolean + // While reacquisition is in flight, the node is elected leader and a + // second rotation supersedes the first; it then follows again, and a new + // demand c loads. With nothing reacquiring, the node stays in scoped + // recovery: held notices must still be handled, not queued forever. + supersededAsLeader?: boolean + // How rotation starts: the source asks for scoped recovery after both + // demands loaded, or both demands' first reads are in flight when the + // cache claim is found lost. + trigger?: `source` | `claim-lost-during-reads` +} +export type VersionedRow = { id: string; group: string; v: number } +export type ReacquisitionObservation = { + rows: Record<`a` | `b` | `c`, number | undefined> + // The new demand's load, when the history has one. + newLoad?: `pending` | `fulfilled` | `rejected` + // `retrying`: ready, with a failure reason. + status: `crashed` | `retrying` | `healthy` +} +export const expectedReacquisition = ( + history: RotatedReacquisitionHistory, + settled: number, +): ReacquisitionObservation => { + const fails = (group: `a` | `b`) => + history.fails === group || history.fails === `both` + const allSettled = settled === 2 + const ended = allSettled && !fails(`a`) && !fails(`b`) + // A failed reacquisition is reported when it settles, and the next + // remote request that succeeds clears it (maintainer decision: a coarse + // clear). A released demand's aborted ensure is neither. + let failed = false + for (const group of history.order.slice(0, settled)) + if (fails(group)) failed = true + else if (!(group === `b` && history.fails === `released`)) failed = false + return { + rows: ended + ? { + a: history.noticeBetween ? 3 : 2, + // A released demand's held notice still applies once recovery + // ends; the new generation's rows are trusted then. + b: 2, + c: history.newDemand ? 2 : undefined, + } + : { a: undefined, b: undefined, c: undefined }, + ...(history.newDemand + ? { + newLoad: !allSettled + ? (`pending` as const) + : ended + ? (`fulfilled` as const) + : (`rejected` as const), + } + : {}), + status: failed ? `retrying` : `healthy`, + } +} + +export const assertRotatedReacquisition = async ( + history: RotatedReacquisitionHistory, +) => { + const errors = vi.spyOn(console, `error`).mockImplementation(() => {}) + const warnings = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const oldStorageId = `reacquire-old` + const newStorageId = `reacquire-new` + const durable = new Map>([ + [oldStorageId, new Map()], + [newStorageId, new Map()], + ]) + let subscriber: ((message: ProtocolEnvelope) => void) | undefined + const seqs = new Map() + const ownerCommits = (storageId: string, row: VersionedRow) => { + const seq = (seqs.get(storageId) ?? 0) + 1 + seqs.set(storageId, seq) + durable.get(storageId)!.set(row.id, row) + const payload: TxCommitted = { + type: `tx:committed`, + term: 1, + seq, + txId: `owner-${storageId}-${seq}`, + latestRowVersion: seq, + requiresFullReload: false, + changedRows: [{ key: row.id, value: row }], + deletedKeys: [], + } + subscriber?.({ + v: 1, + dbName: `test-db`, + collectionId: storageId, + senderId: `owner`, + ts: Date.now(), + payload, + }) + } + type Reply = { + resolve: () => void + reject: (error: unknown) => void + } + const replies = new Map() + let ensures = 0 + let cEnsures = 0 + const groupOf = (options: LoadSubsetOptions) => { + const where = options.where as IR.Func | undefined + const value = where?.args[1] as IR.Value | undefined + return value?.value as `a` | `b` | `c` | undefined + } + const duringReads = history.trigger === `claim-lost-during-reads` + const firstReads = createDeferred() + let reads = 0 + let claimLost = false + const adapter = { + loadSubset: async ( + target: PersistedStorageTarget, + ): Promise> => { + if (duringReads && ++reads <= 2) { + await firstReads.promise + claimLost = true + } + return Array.from( + durable.get(recordedStorageId(target))!.values(), + (value) => ({ key: value.id, value: { ...value } }), + ) + }, + claimCacheGeneration: async () => ({ + storageCollectionId: oldStorageId, + claimId: `reacquire-claim`, + expiresAtMs: Date.now() + 60_000, + }), + renewCacheGenerationClaim: async () => + claimLost ? undefined : Date.now() + 60_000, + rotateCacheGeneration: async () => { + claimLost = false + return { + storageCollectionId: newStorageId, + claimId: `reacquire-claim-2`, + expiresAtMs: Date.now() + 60_000, + } + }, + releaseCacheGenerationClaim: async () => {}, + loadResumeSnapshot: () => + Promise.resolve({ + rows: [], + keySet: { status: `consistent` }, + collectionMetadata: [], + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + resetEpoch: 0, + }), + applyCommittedTx: async () => {}, + ensureIndex: async () => {}, + } as unknown as PersistenceAdapter + let leader = false + const coordinator = { + getNodeId: () => `follower`, + subscribe: ( + _collectionId: string, + onMessage: (message: ProtocolEnvelope) => void, + ) => { + subscriber = onMessage + return () => { + subscriber = undefined + } + }, + publish: () => {}, + isLeader: () => leader, + ensureLeadership: async () => {}, + requestEnsurePersistedIndex: async () => {}, + // The owner commits the demand's row into the requesting generation + // and broadcasts it before it replies. In the new generation the + // driver releases the reply; a later refetch finds nothing new. + requestEnsureRemoteSubset: ( + storageId: string, + options: LoadSubsetOptions, + ) => { + ensures++ + const group = groupOf(options) + if (!group) return Promise.resolve() + if (storageId === oldStorageId) { + ownerCommits(oldStorageId, { id: `${group}-row`, group, v: 1 }) + return Promise.resolve() + } + if (group === `c`) cEnsures++ + if (replies.has(group) || (group === `c` && cEnsures > 1)) + return Promise.resolve() + ownerCommits(newStorageId, { id: `${group}-row`, group, v: 2 }) + // The new demand's ensure answers at once; the follower must still + // hold its rows until reacquisition ends. + if (group === `c`) return Promise.resolve() + return new Promise((resolve, reject) => { + replies.set(group, { resolve, reject }) + }) + }, + requestReleaseRemoteSubset: async () => {}, + registerRemoteSubsetOwner: () => () => {}, + requestApplyCommittedTx: (_collectionId: string, tx: TxCommitted) => + Promise.resolve({ + type: `rpc:applyCommittedTx:res`, + rpcId: tx.txId, + ok: true, + term: tx.term, + seq: tx.seq, + latestRowVersion: tx.seq, + }), + pullSince: (storageId: string) => + Promise.resolve({ + type: `rpc:pullSince:res`, + rpcId: `reacquire-pull`, + ok: true, + latestTerm: 1, + latestSeq: seqs.get(storageId) ?? 0, + latestRowVersion: seqs.get(storageId) ?? 0, + requiresFullReload: true, + changedKeys: [], + deletedKeys: [], + }), + } as unknown as PersistedCollectionCoordinator + let sourceParams: SyncConfig[`sync`] extends ( + params: infer P, + ) => unknown + ? P + : never + const source = createCollection( + persistedCollectionOptions({ + id: `follower-reacquire-${Math.random().toString(36).slice(2)}`, + getKey: (row) => row.id, + persistence: { adapter, coordinator }, + syncMode: `on-demand`, + sync: { + sync: (params) => { + sourceParams = params + // A ready source: only a ready Collection can carry the failed + // reacquisition as its failure reason. + params.markReady() + return { + loadSubset: () => Promise.resolve(), + restartAfterScopedRecovery: () => {}, + } + }, + }, + }), + ) + source.startSyncImmediate() + let newLoad: `pending` | `fulfilled` | `rejected` = `pending` + const observe = (): ReacquisitionObservation => ({ + rows: { + a: source.get(`a-row`)?.v, + b: source.get(`b-row`)?.v, + c: source.get(`c-row`)?.v, + }, + ...(history.newDemand ? { newLoad } : {}), + status: + source.status === `crashed` + ? `crashed` + : source.health.failureReason + ? `retrying` + : `healthy`, + }) + const turn = () => new Promise((resolve) => setTimeout(resolve, 0)) + const settle = async () => { + for (let i = 0; i < 8; i++) await turn() + } + const demand = (group: `a` | `b` | `c`): LoadSubsetOptions => ({ + where: new IR.Func(`eq`, [new IR.PropRef([`group`]), new IR.Value(group)]), + }) + const demands = { a: demand(`a`), b: demand(`b`), c: demand(`c`) } + const load = (group: `a` | `b` | `c`) => + source._sync.loadSubset(demands[group]) + try { + if (duringReads) { + // Both first reads are in flight when the claim is found lost. + void Promise.resolve(load(`a`)).catch(() => undefined) + void Promise.resolve(load(`b`)).catch(() => undefined) + await settle() + firstReads.resolve() + } else { + for (const group of [`a`, `b`] as const) await load(group) + await settle() + oracleAssertion('existing comparison at 4476', () => + expect(observe().rows).toEqual({ a: 1, b: 1 }), + ) + void sourceParams!.metadata!.persistence!.startScopedRecovery!() + } + await vi.waitFor( + () => + oracleAssertion('existing comparison at 4479', () => + expect(replies.size).toBe(2), + ), + { timeout: 2_000 }, + ) + await settle() + if (history.newDemand) { + void Promise.resolve(load(`c`)).then( + () => (newLoad = `fulfilled`), + () => (newLoad = `rejected`), + ) + } + if (history.fails === `released`) { + // Releasing the same demand twice must be idempotent. + source._sync.unloadSubset(demands.b) + source._sync.unloadSubset(demands.b) + } + await settle() + // Checkpoint: both reacquisitions are in flight. + oracleAssertion('existing comparison at 4494', () => + expect(observe()).toEqual(expectedReacquisition(history, 0)), + ) + if (history.supersededAsLeader) { + leader = true + await sourceParams!.metadata!.persistence!.startScopedRecovery!() + await settle() + leader = false + let outcome: `pending` | `fulfilled` | `rejected` = `pending` + void Promise.resolve(load(`c`)).then( + () => (outcome = `fulfilled`), + () => (outcome = `rejected`), + ) + await settle() + // Checkpoint: the superseded reacquisition no longer holds notices. + // The node stays in scoped recovery with nothing reacquiring, so the + // owner's notice for c is handled by one refetch instead of waiting + // in the queue forever; the untrusted generation shows no row. + oracleAssertion('existing comparison at 4510', () => + expect({ outcome, cEnsures, c: source.get(`c-row`)?.v }).toEqual({ + outcome: `fulfilled`, + cEnsures: 2, + c: undefined, + }), + ) + for (const reply of replies.values()) reply.resolve() + return + } + for (const [index, group] of history.order.entries()) { + if (index === 1 && history.noticeBetween) { + ownerCommits(newStorageId, { id: `a-row`, group: `a`, v: 3 }) + await settle() + } + if (history.fails === `released` && group === `b`) { + const aborted = new Error(`released`) + aborted.name = `AbortError` + replies.get(group)!.reject(aborted) + } else if (history.fails === group || history.fails === `both`) + replies.get(group)!.reject(new Error(`reacquire ${group} failed`)) + else replies.get(group)!.resolve() + await settle() + // Checkpoint: after each settlement. + oracleAssertion('existing comparison at 4532', () => + expect(observe()).toEqual(expectedReacquisition(history, index + 1)), + ) + } + // Two acquisitions and two reacquisitions, a new demand's ensure, plus + // one refresh per held notice when recovery stays; a refetch loop + // exceeds the bound. + oracleAssertion('existing comparison at 4537', () => + expect(ensures).toBeLessThanOrEqual(16), + ) + } finally { + await source.cleanup() + errors.mockRestore() + warnings.mockRestore() + } +} + +export const rotatedReacquisitionHistories: Array = + ( + [ + [`a`, `b`], + [`b`, `a`], + ] as const + ).flatMap((order) => + [false, true].flatMap((noticeBetween) => + ([`none`, `a`, `b`, `both`, `released`] as const).flatMap((fails) => + [false, true].map((newDemand) => ({ + order: [...order], + noticeBetween, + fails, + newDemand, + })), + ), + ), + ) +// 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. +export const reacquisitionLimit = ({ + fails, + newDemand, +}: RotatedReacquisitionHistory) => + newDemand === true || fails === `none` || fails === `released` diff --git a/packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts new file mode 100644 index 0000000000..adc0380f73 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts @@ -0,0 +1,147 @@ +/** + * Known-defect witnesses of the follower recovery laws in + * persisted-oracle.test.ts. Known limit #2139: production does not end a + * rotated follower's scoped recovery, so these fixed histories fail today + * through the same drivers and models, in + * persisted-follower-recovery-oracle.ts. Each is `it.fails` until the fix for + * #2139 makes it pass; then it returns to the oracle. + */ +import { it } from 'vitest' +import { fc } from '@fast-check/vitest' +import { oracleFixedSeed, oracleRuns } from '../../db/tests/oracle-config.js' +import { describeUnlessOracleReplay } from './persisted-support' + +import { + assertFollowerGenerationNoticeHistory, + assertFollowerRemoteDemandHistory, + assertRotatedReacquisition, + followerGenerationNoticeHistory, + followerRemoteDemandHistories, + reacquisitionLimit, + rotatedReacquisitionHistories, +} from './persisted-follower-recovery-oracle' + +describeUnlessOracleReplay(`persistedCollectionOptions`, () => { + // 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` }, + { ownerCommit: `after-rotation`, later: `none` }, + { ownerCommit: `after-rotation`, later: `owner` }, + { ownerCommit: `before-rotation`, later: `peer` }, + ] as const)( + `publishes an elected owner's rows to a follower after rotation: commit $ownerCommit, later $later`, + (history) => { + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `ok`, + }) + }, + ) + + // 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) => + ([`before-rotation`, `after-rotation`] as const).flatMap((ownerCommit) => + ([`none`, `owner`] as const).map((later) => ({ + ownerCommit, + later, + noticeShape, + })), + ), + ), + )( + `publishes an elected owner's rows after rotation when its notice is a $noticeShape: commit $ownerCommit, later $later`, + (history) => { + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `ok`, + }) + }, + ) + + // 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) => { + return assertFollowerGenerationNoticeHistory({ + ...history, + ownerRead: `fails`, + }) + }, + ) + + // 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`, + () => { + return assertFollowerGenerationNoticeHistory({ + ownerCommit: `before-rotation`, + later: `none`, + ownerRead: `ok`, + readFailsAsRotationStarts: true, + }) + }, + ) + + // 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 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)`, + () => { + // Preserve the known-limit fixed-seed corpus and its expected failure. + // This is supplemental characterization, not a passing native property. + return (async () => { + for (const history of fc.sample( + followerGenerationNoticeHistory, + oracleFixedSeed(2128, { numRuns: oracleRuns(12) }), + )) + await assertFollowerGenerationNoticeHistory(history) + })() + }, + ) + + // 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( + ({ placement }) => placement === `rotated`, + ), + )( + `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, + (...args: Parameters) => { + return assertFollowerRemoteDemandHistory(...args) + }, + ) + + 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`, + (...args: Parameters) => { + return assertRotatedReacquisition(...args) + }, + 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'`, + () => { + return assertRotatedReacquisition({ + order: [`a`, `b`], + noticeBetween: false, + fails: `none`, + trigger: `claim-lost-during-reads`, + }) + }, + 10_000, + ) +}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-model-calibration.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-model-calibration.test.ts new file mode 100644 index 0000000000..e08d4c0864 --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-model-calibration.test.ts @@ -0,0 +1,273 @@ +/** + * Calibrations of the durability models, checks, and grammar in + * persisted-durability-model-oracle.ts: the generated persistence grammars + * reconstruct their samples and reject ablated, out-of-range, and foreign + * histories; each observation check rejects a named wrong answer; the + * append-only ledger rejects omission and reordering; and the on-demand + * evidence law rejects an evidence-coupled mutant. They run no production, so + * they are ordinary tests. The oracle that drives production is + * persisted-oracle.test.ts. + */ +import { describe, expect, it } from 'vitest' +import { oracleReplayRequested } from '../../db/tests/oracle-config.js' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { describeUnlessOracleReplay } from './persisted-support' + +import { + expectAbortGraphObservation, + expectOnDemandEvidenceLaw, + expectOpenTransactionBoundaryObservation, + expectOwnerIsolationAdmission, + expectRequestLocalFailureObservation, + expectSourceFifoOrderingObservation, + foldDurabilityLedger, + generatedPersistenceGrammarSamples, + generatedPersistenceGrammars, + generatedTitleMaxLength, + reconstructGeneratedPersistenceHistory, +} from './persisted-durability-model-oracle' +import type { DurabilityLedgerEvent } from './persisted-durability-model-oracle' + +describe(`generated persistence durability oracles`, () => { + if (!oracleReplayRequested()) { + it(`reconstructs the full grammar and rejects ablated, out-of-range, and foreign histories`, () => { + for (const { + grammar, + histories, + } of generatedPersistenceGrammarSamples()) { + for (const history of histories) { + oracleAssertion('existing comparison at 1490', () => + expect( + reconstructGeneratedPersistenceHistory(grammar, history), + ).toEqual(history), + ) + } + } + + for (const grammar of generatedPersistenceGrammars) { + oracleAssertion('existing comparison at 1497', () => + expect(Object.keys(grammar.axisContribution).sort()).toEqual( + [...grammar.axes].sort(), + ), + ) + for (const contribution of Object.values(grammar.axisContribution)) { + oracleAssertion('existing comparison at 1501', () => + expect(contribution).not.toBe(``), + ) + } + oracleAssertion('existing comparison at 1503', () => + expect( + reconstructGeneratedPersistenceHistory(grammar, grammar.witness), + ).toEqual(grammar.witness), + ) + + for (const omittedAxis of grammar.axes) { + oracleAssertion('existing comparison at 1508', () => + expect(() => + reconstructGeneratedPersistenceHistory(grammar, { + ...grammar.witness, + axes: grammar.witness.axes.filter( + (axis) => axis !== omittedAxis, + ), + }), + ).toThrow(`invalid generated persistence history`), + ) + } + + oracleAssertion('existing comparison at 1516', () => + expect( + reconstructGeneratedPersistenceHistory(grammar, { + axes: [...grammar.axes], + titles: Array.from({ length: grammar.titleCount }, () => + `x`.repeat(generatedTitleMaxLength), + ), + }).titles, + ).toEqual( + Array.from({ length: grammar.titleCount }, () => + `x`.repeat(generatedTitleMaxLength), + ), + ), + ) + oracleAssertion('existing comparison at 1528', () => + expect( + reconstructGeneratedPersistenceHistory(grammar, { + axes: [...grammar.axes], + titles: Array.from({ length: grammar.titleCount }, () => ``), + }).titles, + ).toEqual(Array.from({ length: grammar.titleCount }, () => ``)), + ) + oracleAssertion('existing comparison at 1534', () => + expect(() => + reconstructGeneratedPersistenceHistory(grammar, { + axes: [...grammar.axes], + titles: [ + `x`.repeat(generatedTitleMaxLength + 1), + ...Array.from( + { length: Math.max(0, grammar.titleCount - 1) }, + () => ``, + ), + ], + }), + ).toThrow(`invalid generated persistence history`), + ) + oracleAssertion('existing comparison at 1546', () => + expect(() => + reconstructGeneratedPersistenceHistory(grammar, { + ...grammar.witness, + axes: grammar.axes.map((axis, index) => + index === grammar.axes.length - 1 ? grammar.axes[0]! : axis, + ), + }), + ).toThrow(`invalid generated persistence history`), + ) + oracleAssertion('existing comparison at 1554', () => + expect(() => + reconstructGeneratedPersistenceHistory(grammar, { + ...grammar.witness, + axes: [...grammar.axes.slice(0, -1), `foreign-axis`], + }), + ).toThrow(`invalid generated persistence history`), + ) + } + }) + + it.each([ + { + name: `source-fifo-order older same-key value wins`, + reject: () => + expectSourceFifoOrderingObservation( + { + publicRows: [{ id: `shared`, title: `older` }], + durableRows: [{ id: `shared`, title: `older` }], + status: `ready`, + pendingMarkers: 0, + }, + [{ id: `shared`, title: `newer` }], + ), + }, + { + name: `abort-graph independent sibling inherits the abort`, + reject: () => + expectAbortGraphObservation( + { + predecessorStatus: `rejected`, + predecessorName: `AbortError`, + successorStatus: `rejected`, + sameFailure: true, + status: `crashed`, + publicSuccessor: undefined, + durableSuccessor: undefined, + pendingMarkers: 0, + }, + `independent`, + `survivor`, + `must survive`, + ), + }, + { + name: `owner-isolation bare write steals and publishes a queued marker`, + reject: () => + expectOwnerIsolationAdmission({ + exactError: false, + strayVisible: true, + }), + }, + { + name: `open-transaction boundary leaks one core marker`, + reject: () => + expectOpenTransactionBoundaryObservation( + { status: `crashed`, pendingMarkers: 1 }, + `terminal-failure`, + ), + }, + { + name: `request-local partial update drops the unseen baseline field`, + reject: () => + expectRequestLocalFailureObservation( + { + receiptStatus: `fulfilled`, + publicRows: [{ id: `shared`, title: `source` }], + durableRows: [ + { + id: `shared`, + title: `source`, + detail: `required baseline detail`, + }, + ], + status: `ready`, + publicError: undefined, + }, + [ + { + id: `shared`, + title: `source`, + detail: `required baseline detail`, + }, + ], + ), + }, + ])(`rejects named wrong answer: $name`, ({ reject }) => { + oracleAssertion('existing comparison at 1640', () => + expect(reject).toThrow(), + ) + }) + } +}) + +describeUnlessOracleReplay(`persistedCollectionOptions`, () => { + it(`append-only transaction judgment rejects omission and reordering`, () => { + const events: Array = [ + { type: `begin`, transactionId: `first` }, + { + type: `write`, + transactionId: `first`, + row: { id: `shared`, title: `first` }, + }, + { type: `commit`, transactionId: `first` }, + { type: `begin`, transactionId: `second` }, + { + type: `write`, + transactionId: `second`, + row: { id: `shared`, title: `second` }, + }, + { type: `commit`, transactionId: `second` }, + ] + const expected = foldDurabilityLedger(events) + + oracleAssertion('existing comparison at 2042', () => + expect(expected.commitOrder).toEqual([`first`, `second`]), + ) + oracleAssertion('existing comparison at 2043', () => + expect(expected.commitOrder).not.toEqual([`second`]), + ) + oracleAssertion('existing comparison at 2044', () => + expect(expected.commitOrder).not.toEqual([`second`, `first`]), + ) + oracleAssertion('existing comparison at 2045', () => + expect(expected.committedRows.get(`shared`)).toEqual({ + id: `shared`, + title: `second`, + }), + ) + }) + + it(`rejects an evidence-coupled on-demand hydration mutant`, () => { + oracleAssertion('existing comparison at 4747', () => + expect(() => + expectOnDemandEvidenceLaw({ + status: `incompatible`, + route: `loadSubset`, + baselineVisible: false, + onDemandVisible: false, + }), + ).toThrow(`on-demand rows must not inherit baseline evidence rejection`), + ) + + expectOnDemandEvidenceLaw({ + status: `incompatible`, + route: `loadSubset`, + baselineVisible: false, + onDemandVisible: true, + }) + }) +}) 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 5a453abe92..2e8a15f1d8 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-oracle.test.ts @@ -10,20 +10,12 @@ import { SyncTransactionAbortedError, collectionOptions, createCollection, - createLiveQueryCollection, - eq, whenSyncAccepted, } from '@tanstack/db' import { PersistedCollectionDurabilityError, persistedCollectionOptions, } from '../src' -import { - oracleFixedSeed, - oracleReplayRequested, - oracleReplayTest, - oracleRuns, -} from '../../db/tests/oracle-config.js' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -31,7 +23,6 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { declareOracle } from '../../db/tests/oracle-declaration.js' import { assertGenerationNoticeHistory, atPersistedOracleCheckpoint, @@ -41,35 +32,55 @@ import { createDeferred, createEventGate, createFailStopCoordinatorHarness, - createFollowerTab, createRecordingAdapter, - describeUnlessAnyOracleReplay, - describeUnlessOracleReplay, expectExactSnapshot, expectHydrationReceiptsPending, flushAsyncWork, generationNoticeHistory, hydrationScopeModes, installHydrationRoutingProbe, - itUnlessAnyOracleReplay, observeSettlement, overrideBaselineRows, - recordedStorageId, stripVirtualProps, - within, } from './persisted-support' +import { + abortGraphHistory, + expectAbortGraphObservation, + expectOnDemandEvidenceLaw, + expectOpenTransactionBoundaryObservation, + expectOwnerIsolationAdmission, + expectRequestLocalFailureObservation, + expectSourceFifoOrderingObservation, + expectedScopedSnapshot, + expectedTargetedSnapshot, + foldDurabilityLedger, + generatedPersistenceFixedSeeds, + openTransactionBoundaryHistory, + ownerIsolationHistory, + persistedKeySetEvidenceStatuses, + requestLocalFailureHistory, + sourceFifoOrderingHistory, +} from './persisted-durability-model-oracle' +import { + assertFollowerRemoteDemandHistory, + assertRotatedReacquisition, + followerRemoteDemandHistories, + reacquisitionLimit, + rotatedReacquisitionHistories, +} from './persisted-follower-recovery-oracle' import type { - PersistedCollectionCoordinator, - PersistedStorageTarget, - PersistedTx, - PersistenceAdapter, - ProtocolEnvelope, - TxCommitted, -} from '../src' -import type { Collection, LoadSubsetOptions, SyncConfig } from '@tanstack/db' + AbortGraphHistory, + DurabilityLedgerEvent, + OpenTransactionBoundaryHistory, + OwnerIsolationHistory, + RequestLocalFailureHistory, + RequestLocalFailureOperation, + SourceFifoOrderingHistory, +} from './persisted-durability-model-oracle' +import type { PersistedTx, PersistenceAdapter } from '../src' +import type { Collection, LoadSubsetOptions } from '@tanstack/db' import type { FollowerRemoteDemandHistory, - FollowerRow, Todo, TodoSyncParams, } from './persisted-support' @@ -124,6 +135,13 @@ import type { * challenge the model's judgment. Focused Browser and Electron suites own the * multiprocess transport and host-specific replay partitions. * + * The durability models, checks, and grammar live in + * persisted-durability-model-oracle.ts, and their calibrations, which run no + * production, in persisted-model-calibration.test.ts. The follower recovery + * drivers and models live in persisted-follower-recovery-oracle.ts; the + * histories that known limit #2139 makes fail today are known-defect witnesses + * in persisted-known-defects.test.ts. + * * Known omissions: driver SQL behavior, native host ownership, and the shared * conformance portfolio have separate owners. This file proves the role * partition for non-single-process remote demand: an ownerless elected node @@ -144,262 +162,12 @@ import type { * their titles and literal expectations and are not oracle evidence. */ -// A complete durable row is a value, including its exact set of fields. The -// model deliberately uses only Maps and authored input rows; it does not -// classify production sync writes or copy their update/replace algorithm. -// Active complete demand admits every durable key. Without demand, a notice -// may validate previously public keys, but cannot publish an unseen key. -// DbClient hydration seeds remain source-owned until confirmed by the source. -// Other rows in this model came from an earlier durable read; accepted source -// rows belong to the separate source-precedence model below. Copy authored -// values so a subject mutation cannot alter the expected result by aliasing. -function expectedScopedSnapshot( - previouslyPublic: ReadonlyMap, - durable: ReadonlyMap, - protectedSeeds: ReadonlyMap, - completeDemand: boolean, -): Map { - const expected = completeDemand - ? new Map( - Array.from( - durable, - ([key, row]) => [key, structuredClone(row)] as const, - ), - ) - : new Map( - Array.from(previouslyPublic.keys()) - .filter((key) => durable.has(key)) - .map((key) => [key, structuredClone(durable.get(key)!)] as const), - ) - for (const [key, row] of protectedSeeds) - expected.set(key, structuredClone(row)) - return expected -} - -// Targeted notices are changes to the durable relation, not complete rows in -// the message. An active predicate admits the durable rows it matches; after -// demand retirement, only keys that were public remain eligible for checking. -// This simple title predicate is the model's authored scope, independent of -// the production predicate compiler and targeted-invalidation classifier. -function expectedTargetedSnapshot( - previouslyPublic: ReadonlyMap, - durable: ReadonlyMap, - activeTitle: string | undefined, -): Map { - return new Map( - Array.from(durable) - .filter(([key, row]) => - activeTitle === undefined - ? previouslyPublic.has(key) - : row.title === activeTitle, - ) - .map(([key, row]) => [key, structuredClone(row)] as const), - ) -} - -const persistedKeySetEvidenceStatuses = [ - `consistent`, - `unknown`, - `incompatible`, -] as const - -type OnDemandEvidenceObservation = { - status: (typeof persistedKeySetEvidenceStatuses)[number] - route: `loadSubset` | `forceReloadSubset` - baselineVisible: boolean | `not-observed` - onDemandVisible: boolean -} - -function expectOnDemandEvidenceLaw( - observation: OnDemandEvidenceObservation, -): void { - try { - oracleAssertion('existing comparison at 207', () => - expect(observation).toEqual({ - status: observation.status, - route: observation.route, - baselineVisible: - observation.route === `loadSubset` - ? observation.status !== `incompatible` - : `not-observed`, - onDemandVisible: true, - }), - ) - } catch (cause) { - throw new Error( - `on-demand rows must not inherit baseline evidence rejection`, - { cause }, - ) - } -} - -type DurabilityLedgerEvent = - | { type: `begin`; transactionId: string } - | { type: `write`; transactionId: string; row: Todo } - | { type: `commit`; transactionId: string } - | { type: `abort`; transactionId: string } - -// RFC #1659 durability law: a source commit remains one append-only obligation -// across a persistence hydration boundary. The independent fold deliberately -// has no hydration phase, queue, generation, or production transaction helper. -// These fixtures exercise the core adapter contract with in-memory storage; -// they do not claim native SQLite host or device execution. -function foldDurabilityLedger(events: ReadonlyArray): { - committedRows: Map - commitOrder: Array -} { - const open = new Map>() - const committedRows = new Map() - const commitOrder: Array = [] - - for (const event of events) { - if (event.type === `begin`) { - open.set(event.transactionId, []) - } else if (event.type === `write`) { - open.get(event.transactionId)?.push(structuredClone(event.row)) - } else if (event.type === `abort`) { - open.delete(event.transactionId) - } else { - const rows = open.get(event.transactionId) - if (!rows) continue - for (const row of rows) committedRows.set(row.id, row) - commitOrder.push(event.transactionId) - open.delete(event.transactionId) - } - } - - return { committedRows, commitOrder } -} - let generatedOwnershipHistoryId = 0 function sortedTodoRows(rows: Iterable): Array { return Array.from(rows).sort((left, right) => left.id.localeCompare(right.id)) } -type SourceFifoOrderingObservation = { - publicRows: Array - durableRows: Array - status: string - pendingMarkers: number -} - -function expectSourceFifoOrderingObservation( - actual: SourceFifoOrderingObservation, - expectedRows: ReadonlyArray, -): void { - oracleAssertion('existing comparison at 279', () => - expect(actual).toEqual({ - publicRows: expectedRows, - durableRows: expectedRows, - status: `ready`, - pendingMarkers: 0, - }), - ) -} - -type AbortGraphObservation = { - predecessorStatus: `fulfilled` | `rejected` - predecessorName: string | undefined - successorStatus: `fulfilled` | `rejected` - sameFailure: boolean - status: string - publicSuccessor: Todo | undefined - durableSuccessor: Todo | undefined - pendingMarkers: number -} - -function expectAbortGraphObservation( - actual: AbortGraphObservation, - relationship: `independent` | `same-key`, - successorKey: string, - successorTitle: string, -): void { - oracleAssertion('existing comparison at 304', () => - expect(actual).toEqual( - relationship === `same-key` - ? { - predecessorStatus: `rejected`, - predecessorName: `AbortError`, - successorStatus: `rejected`, - sameFailure: true, - status: `crashed`, - publicSuccessor: undefined, - durableSuccessor: undefined, - pendingMarkers: 0, - } - : { - predecessorStatus: `rejected`, - predecessorName: `AbortError`, - successorStatus: `fulfilled`, - sameFailure: false, - status: `ready`, - publicSuccessor: { id: successorKey, title: successorTitle }, - durableSuccessor: { id: successorKey, title: successorTitle }, - pendingMarkers: 0, - }, - ), - ) -} - -type OwnerIsolationAdmissionObservation = { - exactError: boolean - strayVisible: boolean -} - -function expectOwnerIsolationAdmission( - actual: OwnerIsolationAdmissionObservation, -): void { - oracleAssertion('existing comparison at 337', () => - expect(actual).toEqual({ exactError: true, strayVisible: false }), - ) -} - -type OpenTransactionBoundaryObservation = { - status: string - pendingMarkers: number -} - -type RequestLocalFailureObservation = { - receiptStatus: `pending` | `fulfilled` | `rejected` - publicRows: Array - durableRows: Array - status: string - publicError: unknown -} - -function expectOpenTransactionBoundaryObservation( - actual: OpenTransactionBoundaryObservation, - boundary: `abort` | `cleanup` | `terminal-failure`, -): void { - oracleAssertion('existing comparison at 357', () => - expect(actual).toEqual({ - status: - boundary === `cleanup` - ? `cleaned-up` - : boundary === `abort` - ? `ready` - : `crashed`, - pendingMarkers: 0, - }), - ) -} - -function expectRequestLocalFailureObservation( - actual: RequestLocalFailureObservation, - expectedRows: ReadonlyArray, -): void { - oracleAssertion('existing comparison at 372', () => - expect(actual).toEqual({ - receiptStatus: `fulfilled`, - publicRows: expectedRows, - durableRows: expectedRows, - status: `ready`, - publicError: undefined, - }), - ) -} - async function runSourceFifoOrderingLaw( relation: `same-key` | `disjoint`, olderTitle: string, @@ -968,8 +736,6 @@ async function runInternalOpenTransactionBoundaryLaw( } } -type RequestLocalFailureOperation = `partial-update` | `delete` | `insert` - async function runRequestLocalFailureOperationLaw( operation: RequestLocalFailureOperation, baselineTitle: string, @@ -1122,277 +888,6 @@ async function runRequestLocalFailureOperationLaw( } } -type GeneratedPersistenceHistory = { - axes: Array - titles: Array -} - -type GeneratedPersistenceGrammar = { - property: string - axes: ReadonlyArray - axisContribution: Readonly> - titleCount: number - witness: GeneratedPersistenceHistory -} - -const sourceFifoOrderingAxes = [`same-key`, `disjoint`] as const -const abortGraphAxes = [`independent`, `same-key`] as const -const ownerIsolationAxes = [`write`, `commit`, `truncate`] as const -const openTransactionBoundaryAxes = [ - `abort`, - `cleanup`, - `terminal-failure`, -] as const -const requestLocalFailureAxes = [`partial-update`, `delete`, `insert`] as const -const generatedTitleMaxLength = 12 -const generatedPersistenceFixedSeeds = { - sourceFifoOrdering: 18_530_101, - abortGraph: 18_530_102, - ownerIsolation: 18_530_103, - openTransactionBoundary: 18_530_104, - requestLocalFailure: 18_530_105, -} as const - -const generatedPersistenceGrammars: ReadonlyArray = - [ - { - property: `sqlite-persistence.source-fifo-order`, - axes: sourceFifoOrderingAxes, - axisContribution: { - [`same-key`]: `the later source value wins in source order`, - disjoint: `both unrelated values and receipts survive`, - }, - titleCount: 2, - witness: { - axes: [`same-key`, `disjoint`], - titles: [`older`, `newer`], - }, - }, - { - property: `sqlite-persistence.abort-graph`, - axes: abortGraphAxes, - axisContribution: { - independent: `an unrelated sibling survives the abort`, - [`same-key`]: `a dependent suffix shares the predecessor failure`, - }, - titleCount: 2, - witness: { - axes: [`independent`, `same-key`], - titles: [`predecessor`, `successor`], - }, - }, - { - property: `sqlite-persistence.owner-isolation`, - axes: ownerIsolationAxes, - axisContribution: { - write: `bare write rejects without publishing a stray row`, - commit: `bare commit rejects without settling another owner`, - truncate: `bare truncate rejects without clearing another owner`, - }, - titleCount: 1, - witness: { - axes: [`write`, `commit`, `truncate`], - titles: [`queued`], - }, - }, - { - property: `sqlite-persistence.open-transaction-boundary`, - axes: openTransactionBoundaryAxes, - axisContribution: { - abort: `explicit abort leaves no core transaction`, - cleanup: `cleanup leaves no core transaction`, - [`terminal-failure`]: `fail-stop leaves no core transaction`, - }, - titleCount: 1, - witness: { - axes: [`abort`, `cleanup`, `terminal-failure`], - titles: [`hydrated`], - }, - }, - { - property: `sqlite-persistence.request-local-failure-buffered-source`, - axes: requestLocalFailureAxes, - axisContribution: { - [`partial-update`]: `an unseen persisted baseline is reconstructed`, - delete: `a buffered delete settles without resurrection`, - insert: `a complete buffered insert settles without duplication`, - }, - titleCount: 2, - witness: { - axes: [`partial-update`, `delete`, `insert`], - titles: [`baseline`, `source`], - }, - }, - ] - -function reconstructGeneratedPersistenceHistory( - grammar: GeneratedPersistenceGrammar, - history: GeneratedPersistenceHistory, -): GeneratedPersistenceHistory { - const requiredAxes = new Set(grammar.axes) - const axesAreExact = - history.axes.length === grammar.axes.length && - new Set(history.axes).size === grammar.axes.length && - history.axes.every((axis) => requiredAxes.has(axis)) - const titlesAreInRange = - history.titles.length === grammar.titleCount && - history.titles.every( - (title) => [...title].length <= generatedTitleMaxLength, - ) - if (!axesAreExact || !titlesAreInRange) { - throw new Error( - `invalid generated persistence history: ${grammar.property}`, - ) - } - return { - axes: [...history.axes], - titles: [...history.titles], - } -} - -function fullAxisOrder( - axes: ReadonlyArray, -): fc.Arbitrary> { - return fc.shuffledSubarray([...axes], { - minLength: axes.length, - maxLength: axes.length, - }) -} - -const generatedTitle = fc.string({ maxLength: generatedTitleMaxLength }) - -type SourceFifoOrderingHistory = { - relations: Array<`same-key` | `disjoint`> - titles: [string, string] -} - -type AbortGraphHistory = { - relationships: Array<`independent` | `same-key`> - titles: [string, string] -} - -type OwnerIsolationHistory = { - operations: Array<`write` | `commit` | `truncate`> - queuedTitle: string -} - -type OpenTransactionBoundaryHistory = { - boundaries: Array<`abort` | `cleanup` | `terminal-failure`> - hydratedTitle: string -} - -type RequestLocalFailureHistory = { - operations: Array - titles: [string, string] -} - -const sourceFifoOrderingHistory: fc.Arbitrary = - fc.record({ - relations: fullAxisOrder(sourceFifoOrderingAxes), - titles: fc.tuple(generatedTitle, generatedTitle), - }) - -const abortGraphHistory: fc.Arbitrary = fc.record({ - relationships: fullAxisOrder(abortGraphAxes), - titles: fc.tuple(generatedTitle, generatedTitle), -}) - -const ownerIsolationHistory: fc.Arbitrary = fc.record({ - operations: fullAxisOrder(ownerIsolationAxes), - queuedTitle: generatedTitle, -}) - -const openTransactionBoundaryHistory: fc.Arbitrary = - fc.record({ - boundaries: fullAxisOrder(openTransactionBoundaryAxes), - hydratedTitle: generatedTitle, - }) - -const requestLocalFailureHistory: fc.Arbitrary = - fc.record({ - operations: fullAxisOrder(requestLocalFailureAxes), - titles: fc.tuple(generatedTitle, generatedTitle), - }) - -function generatedPersistenceGrammarSamples(): ReadonlyArray<{ - grammar: GeneratedPersistenceGrammar - histories: Array -}> { - const grammar = (property: string): GeneratedPersistenceGrammar => { - const match = generatedPersistenceGrammars.find( - (candidate) => candidate.property === property, - ) - if (match === undefined) throw new Error(`missing grammar: ${property}`) - return match - } - const sampleOptions = (seed: number) => - oracleFixedSeed(seed, { numRuns: oracleRuns(4) }) - return [ - { - grammar: grammar(`sqlite-persistence.source-fifo-order`), - histories: fc - .sample( - sourceFifoOrderingHistory, - sampleOptions(generatedPersistenceFixedSeeds.sourceFifoOrdering), - ) - .map((history) => ({ - axes: history.relations, - titles: [...history.titles], - })), - }, - { - grammar: grammar(`sqlite-persistence.abort-graph`), - histories: fc - .sample( - abortGraphHistory, - sampleOptions(generatedPersistenceFixedSeeds.abortGraph), - ) - .map((history) => ({ - axes: history.relationships, - titles: [...history.titles], - })), - }, - { - grammar: grammar(`sqlite-persistence.owner-isolation`), - histories: fc - .sample( - ownerIsolationHistory, - sampleOptions(generatedPersistenceFixedSeeds.ownerIsolation), - ) - .map((history) => ({ - axes: history.operations, - titles: [history.queuedTitle], - })), - }, - { - grammar: grammar(`sqlite-persistence.open-transaction-boundary`), - histories: fc - .sample( - openTransactionBoundaryHistory, - sampleOptions(generatedPersistenceFixedSeeds.openTransactionBoundary), - ) - .map((history) => ({ - axes: history.boundaries, - titles: [history.hydratedTitle], - })), - }, - { - grammar: grammar( - `sqlite-persistence.request-local-failure-buffered-source`, - ), - histories: fc - .sample( - requestLocalFailureHistory, - sampleOptions(generatedPersistenceFixedSeeds.requestLocalFailure), - ) - .map((history) => ({ - axes: history.operations, - titles: [...history.titles], - })), - }, - ] -} - async function runGeneratedSourceFifoOrderingHistory( history: SourceFifoOrderingHistory, ): Promise { @@ -1493,242 +988,10 @@ function registerGeneratedPersistenceProperty(options: { ) } -// The durability drivers run generated histories through the laws in this -// file. The models predict public rows, durable rows, and metadata from the -// authored inputs; the check drivers compare production observations with -// them, and the grammar reaches the generated histories. -declareOracle({ - check: [ - runGeneratedSourceFifoOrderingHistory, - runGeneratedAbortGraphHistory, - runGeneratedOwnerIsolationHistory, - runGeneratedOpenTransactionBoundaryHistory, - runGeneratedRequestLocalFailureHistory, - expectOnDemandEvidenceLaw, - ], - model: [ - expectedScopedSnapshot, - expectedTargetedSnapshot, - foldDurabilityLedger, - expectedSourceFifoOrderingRows, - ], - grammar: [ - generatedPersistenceGrammars, - sourceFifoOrderingHistory, - abortGraphHistory, - ownerIsolationHistory, - openTransactionBoundaryHistory, - requestLocalFailureHistory, - ], -}) - -// Each check compares an observation with the law that its own inputs fix: -// the abort relationship, the owner-isolation admission, or the open-transaction -// boundary outcome. The models recompute the hydrated source snapshot from the -// authored edit log and the transactions that reach durable admission. -declareOracle({ - check: [ - expectAbortGraphObservation, - expectOwnerIsolationAdmission, - expectOpenTransactionBoundaryObservation, - ], - model: [hydrationSourceSnapshot, expectedDurableHydrationTransactions], -}) - -const describeDurabilityOracles = oracleReplayTest( - describe, - `sqlite-persistence.source-fifo-order`, - `sqlite-persistence.abort-graph`, - `sqlite-persistence.owner-isolation`, - `sqlite-persistence.open-transaction-boundary`, - `sqlite-persistence.request-local-failure-buffered-source`, -) - -describeDurabilityOracles(`generated persistence durability oracles`, () => { - if (!oracleReplayRequested()) { - it(`reconstructs the full grammar and rejects ablated, out-of-range, and foreign histories`, () => { - for (const { - grammar, - histories, - } of generatedPersistenceGrammarSamples()) { - for (const history of histories) { - oracleAssertion('existing comparison at 1490', () => - expect( - reconstructGeneratedPersistenceHistory(grammar, history), - ).toEqual(history), - ) - } - } - - for (const grammar of generatedPersistenceGrammars) { - oracleAssertion('existing comparison at 1497', () => - expect(Object.keys(grammar.axisContribution).sort()).toEqual( - [...grammar.axes].sort(), - ), - ) - for (const contribution of Object.values(grammar.axisContribution)) { - oracleAssertion('existing comparison at 1501', () => - expect(contribution).not.toBe(``), - ) - } - oracleAssertion('existing comparison at 1503', () => - expect( - reconstructGeneratedPersistenceHistory(grammar, grammar.witness), - ).toEqual(grammar.witness), - ) - - for (const omittedAxis of grammar.axes) { - oracleAssertion('existing comparison at 1508', () => - expect(() => - reconstructGeneratedPersistenceHistory(grammar, { - ...grammar.witness, - axes: grammar.witness.axes.filter( - (axis) => axis !== omittedAxis, - ), - }), - ).toThrow(`invalid generated persistence history`), - ) - } - - oracleAssertion('existing comparison at 1516', () => - expect( - reconstructGeneratedPersistenceHistory(grammar, { - axes: [...grammar.axes], - titles: Array.from({ length: grammar.titleCount }, () => - `x`.repeat(generatedTitleMaxLength), - ), - }).titles, - ).toEqual( - Array.from({ length: grammar.titleCount }, () => - `x`.repeat(generatedTitleMaxLength), - ), - ), - ) - oracleAssertion('existing comparison at 1528', () => - expect( - reconstructGeneratedPersistenceHistory(grammar, { - axes: [...grammar.axes], - titles: Array.from({ length: grammar.titleCount }, () => ``), - }).titles, - ).toEqual(Array.from({ length: grammar.titleCount }, () => ``)), - ) - oracleAssertion('existing comparison at 1534', () => - expect(() => - reconstructGeneratedPersistenceHistory(grammar, { - axes: [...grammar.axes], - titles: [ - `x`.repeat(generatedTitleMaxLength + 1), - ...Array.from( - { length: Math.max(0, grammar.titleCount - 1) }, - () => ``, - ), - ], - }), - ).toThrow(`invalid generated persistence history`), - ) - oracleAssertion('existing comparison at 1546', () => - expect(() => - reconstructGeneratedPersistenceHistory(grammar, { - ...grammar.witness, - axes: grammar.axes.map((axis, index) => - index === grammar.axes.length - 1 ? grammar.axes[0]! : axis, - ), - }), - ).toThrow(`invalid generated persistence history`), - ) - oracleAssertion('existing comparison at 1554', () => - expect(() => - reconstructGeneratedPersistenceHistory(grammar, { - ...grammar.witness, - axes: [...grammar.axes.slice(0, -1), `foreign-axis`], - }), - ).toThrow(`invalid generated persistence history`), - ) - } - }) - - it.each([ - { - name: `source-fifo-order older same-key value wins`, - reject: () => - expectSourceFifoOrderingObservation( - { - publicRows: [{ id: `shared`, title: `older` }], - durableRows: [{ id: `shared`, title: `older` }], - status: `ready`, - pendingMarkers: 0, - }, - [{ id: `shared`, title: `newer` }], - ), - }, - { - name: `abort-graph independent sibling inherits the abort`, - reject: () => - expectAbortGraphObservation( - { - predecessorStatus: `rejected`, - predecessorName: `AbortError`, - successorStatus: `rejected`, - sameFailure: true, - status: `crashed`, - publicSuccessor: undefined, - durableSuccessor: undefined, - pendingMarkers: 0, - }, - `independent`, - `survivor`, - `must survive`, - ), - }, - { - name: `owner-isolation bare write steals and publishes a queued marker`, - reject: () => - expectOwnerIsolationAdmission({ - exactError: false, - strayVisible: true, - }), - }, - { - name: `open-transaction boundary leaks one core marker`, - reject: () => - expectOpenTransactionBoundaryObservation( - { status: `crashed`, pendingMarkers: 1 }, - `terminal-failure`, - ), - }, - { - name: `request-local partial update drops the unseen baseline field`, - reject: () => - expectRequestLocalFailureObservation( - { - receiptStatus: `fulfilled`, - publicRows: [{ id: `shared`, title: `source` }], - durableRows: [ - { - id: `shared`, - title: `source`, - detail: `required baseline detail`, - }, - ], - status: `ready`, - publicError: undefined, - }, - [ - { - id: `shared`, - title: `source`, - detail: `required baseline detail`, - }, - ], - ), - }, - ])(`rejects named wrong answer: $name`, ({ reject }) => { - oracleAssertion('existing comparison at 1640', () => - expect(reject).toThrow(), - ) - }) - } - +// Each property stands aside during another property's replay, so the block +// needs no replay guard of its own. Its grammar and check calibrations run no +// production and live in persisted-model-calibration.test.ts. +describe(`generated persistence durability oracles`, () => { registerGeneratedPersistenceProperty({ property: `sqlite-persistence.source-fifo-order`, title: `serialized histories preserve source order and settle every receipt`, @@ -1766,192 +1029,222 @@ describeDurabilityOracles(`generated persistence durability oracles`, () => { }) }) -describeUnlessOracleReplay(`persistedCollectionOptions`, () => { +// The block needs no replay guard: each property in it stands aside during +// another property's replay. +describe(`persistedCollectionOptions`, () => { it.todo( `loads active subsets and collection metadata from one atomic full-reload generation`, ) - it.each([`during`, `straddles`, `after`] as const)( - `durably settles a source transaction that commits %s persisted hydration`, - async (position) => { - const hydrationEntered = createEventGate() - const hydration = createEventGate() - const adapter = createRecordingAdapter() - const restoreBaselineRows = overrideBaselineRows(adapter, async () => { - hydrationEntered.resolve() - await hydration.promise - return [] - }) + const hydrationBoundaryPositions = [`during`, `straddles`, `after`] as const - const persistedTransactionOrder: Array = [] - const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) - adapter.applyCommittedTx = async (...args) => { - const title = args[1].mutations.find( - (mutation) => mutation.type !== `delete`, - )?.value as Todo | undefined - if (title) persistedTransactionOrder.push(title.title) - await applyCommittedTx(...args) - } + async function runHydrationBoundaryCommit( + position: (typeof hydrationBoundaryPositions)[number], + ): Promise { + const hydrationEntered = createEventGate() + const hydration = createEventGate() + const adapter = createRecordingAdapter() + const restoreBaselineRows = overrideBaselineRows(adapter, async () => { + hydrationEntered.resolve() + await hydration.promise + return [] + }) - let remoteBegin: (() => void) | undefined - let remoteWrite: - ((message: { type: `insert`; value: Todo }) => void) | undefined - let remoteCommit: (() => true | Promise) | undefined - const collection = createCollection( - persistedCollectionOptions({ - id: `hydration-boundary-${position}`, - getKey: (item) => item.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - remoteBegin = begin - remoteWrite = write as (message: { - type: `insert` - value: Todo - }) => void - remoteCommit = commit - markReady() - }, - }, - persistence: { adapter }, - }), - ) - const ledger: Array = [] - const transactionId = position - const row = { id: `shared`, title: position } - let receipt: Promise | undefined - let hasPrimaryFailure = false - - const beginAndWrite = () => { - ledger.push({ type: `begin`, transactionId }) - remoteBegin?.() - ledger.push({ type: `write`, transactionId, row }) - remoteWrite?.({ type: `insert`, value: row }) - } - const commit = () => { - ledger.push({ type: `commit`, transactionId }) - const applied = remoteCommit?.() - oracleAssertion('existing comparison at 1746', () => - expect(applied).toBeInstanceOf(Promise), - ) - receipt = Promise.resolve(applied).then(() => undefined) - } + const persistedTransactionOrder: Array = [] + const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) + adapter.applyCommittedTx = async (...args) => { + const title = args[1].mutations.find( + (mutation) => mutation.type !== `delete`, + )?.value as Todo | undefined + if (title) persistedTransactionOrder.push(title.title) + await applyCommittedTx(...args) + } - try { - const ready = collection.stateWhenReady() - await atPersistedOracleCheckpoint( - hydrationEntered.promise, - `${position} hydration entered`, - ) - oracleAssertion('existing comparison at 1756', () => - expect(remoteBegin).toBeTypeOf(`function`), - ) - oracleAssertion('existing comparison at 1757', () => - expect(remoteCommit).toBeTypeOf(`function`), - ) + let remoteBegin: (() => void) | undefined + let remoteWrite: + ((message: { type: `insert`; value: Todo }) => void) | undefined + let remoteCommit: (() => true | Promise) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `hydration-boundary-${position}`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + remoteBegin = begin + remoteWrite = write as (message: { + type: `insert` + value: Todo + }) => void + remoteCommit = commit + markReady() + }, + }, + persistence: { adapter }, + }), + ) + const ledger: Array = [] + const transactionId = position + const row = { id: `shared`, title: position } + let receipt: Promise | undefined + let hasPrimaryFailure = false - if (position === `during`) { - beginAndWrite() - commit() - hydration.resolve() - await atPersistedOracleCheckpoint( - ready, - `${position} hydration completed`, - ) - } else { - if (position === `straddles`) beginAndWrite() - hydration.resolve() - await atPersistedOracleCheckpoint( - ready, - `${position} hydration completed`, - ) - if (position === `after`) beginAndWrite() - commit() - } + const beginAndWrite = () => { + ledger.push({ type: `begin`, transactionId }) + remoteBegin?.() + ledger.push({ type: `write`, transactionId, row }) + remoteWrite?.({ type: `insert`, value: row }) + } + const commit = () => { + ledger.push({ type: `commit`, transactionId }) + const applied = remoteCommit?.() + oracleAssertion('existing comparison at 1746', () => + expect(applied).toBeInstanceOf(Promise), + ) + receipt = Promise.resolve(applied).then(() => undefined) + } - const settlement = observeSettlement(receipt!) - const checkpointId = `checkpoint-${position}` - const checkpointRow = { id: checkpointId, title: checkpointId } - ledger.push({ type: `begin`, transactionId: checkpointId }) - remoteBegin?.() - ledger.push({ - type: `write`, - transactionId: checkpointId, - row: checkpointRow, - }) - remoteWrite?.({ type: `insert`, value: checkpointRow }) - ledger.push({ type: `commit`, transactionId: checkpointId }) - const checkpointReceipt = remoteCommit?.() - oracleAssertion('existing comparison at 1791', () => - expect(checkpointReceipt).toBeInstanceOf(Promise), + try { + const ready = collection.stateWhenReady() + await atPersistedOracleCheckpoint( + hydrationEntered.promise, + `${position} hydration entered`, + ) + oracleAssertion('existing comparison at 1756', () => + expect(remoteBegin).toBeTypeOf(`function`), + ) + oracleAssertion('existing comparison at 1757', () => + expect(remoteCommit).toBeTypeOf(`function`), + ) + + if (position === `during`) { + beginAndWrite() + commit() + hydration.resolve() + await atPersistedOracleCheckpoint( + ready, + `${position} hydration completed`, ) + } else { + if (position === `straddles`) beginAndWrite() + hydration.resolve() await atPersistedOracleCheckpoint( - Promise.resolve(checkpointReceipt), - `${position} later durable receipt`, + ready, + `${position} hydration completed`, ) + if (position === `after`) beginAndWrite() + commit() + } - // A settled later durable receipt is the exact cut: FIFO forbids it - // from overtaking an earlier commit opened in the old phase. - oracleAssertion('existing comparison at 1799', () => - expect(settlement.read()).toEqual({ status: `fulfilled` }), - ) - const expected = foldDurabilityLedger(ledger) - oracleAssertion('existing comparison at 1801', () => - expect(persistedTransactionOrder).toEqual(expected.commitOrder), - ) - oracleAssertion('existing comparison at 1802', () => - expect(stripVirtualProps(collection.get(`shared`))).toEqual(row), - ) - oracleAssertion('existing comparison at 1803', () => - expect(adapter.rows.get(`shared`)).toEqual(row), - ) - oracleAssertion('existing comparison at 1804', () => - expect(adapter.rows.get(checkpointId)).toEqual(checkpointRow), - ) + const settlement = observeSettlement(receipt!) + const checkpointId = `checkpoint-${position}` + const checkpointRow = { id: checkpointId, title: checkpointId } + ledger.push({ type: `begin`, transactionId: checkpointId }) + remoteBegin?.() + ledger.push({ + type: `write`, + transactionId: checkpointId, + row: checkpointRow, + }) + remoteWrite?.({ type: `insert`, value: checkpointRow }) + ledger.push({ type: `commit`, transactionId: checkpointId }) + const checkpointReceipt = remoteCommit?.() + oracleAssertion('existing comparison at 1791', () => + expect(checkpointReceipt).toBeInstanceOf(Promise), + ) + await atPersistedOracleCheckpoint( + Promise.resolve(checkpointReceipt), + `${position} later durable receipt`, + ) - await collection.cleanup() - restoreBaselineRows() - const reopened = createCollection( - persistedCollectionOptions({ - id: `hydration-boundary-${position}`, - getKey: (item) => item.id, - sync: { sync: ({ markReady }) => markReady() }, - persistence: { adapter }, - }), + // A settled later durable receipt is the exact cut: FIFO forbids it + // from overtaking an earlier commit opened in the old phase. + oracleAssertion('existing comparison at 1799', () => + expect(settlement.read()).toEqual({ status: `fulfilled` }), + ) + const expected = foldDurabilityLedger(ledger) + oracleAssertion('existing comparison at 1801', () => + expect(persistedTransactionOrder).toEqual(expected.commitOrder), + ) + oracleAssertion('existing comparison at 1802', () => + expect(stripVirtualProps(collection.get(`shared`))).toEqual(row), + ) + oracleAssertion('existing comparison at 1803', () => + expect(adapter.rows.get(`shared`)).toEqual(row), + ) + oracleAssertion('existing comparison at 1804', () => + expect(adapter.rows.get(checkpointId)).toEqual(checkpointRow), + ) + + await collection.cleanup() + restoreBaselineRows() + const reopened = createCollection( + persistedCollectionOptions({ + id: `hydration-boundary-${position}`, + getKey: (item) => item.id, + sync: { sync: ({ markReady }) => markReady() }, + persistence: { adapter }, + }), + ) + let hasReopenFailure = false + try { + await atPersistedOracleCheckpoint( + reopened.stateWhenReady(), + `${position} reopened hydration completed`, + ) + oracleAssertion('existing comparison at 1822', () => + expect(stripVirtualProps(reopened.get(`shared`))).toEqual( + expected.committedRows.get(`shared`), + ), ) - let hasReopenFailure = false - try { - await atPersistedOracleCheckpoint( - reopened.stateWhenReady(), - `${position} reopened hydration completed`, - ) - oracleAssertion('existing comparison at 1822', () => - expect(stripVirtualProps(reopened.get(`shared`))).toEqual( - expected.committedRows.get(`shared`), - ), - ) - } catch (error) { - hasReopenFailure = true - throw error - } finally { - await cleanupPersistedOracle( - [() => reopened.cleanup()], - hasReopenFailure, - ) - } } catch (error) { - hasPrimaryFailure = true + hasReopenFailure = true throw error } finally { - hydration.resolve() await cleanupPersistedOracle( - [() => collection.cleanup()], - hasPrimaryFailure, + [() => reopened.cleanup()], + hasReopenFailure, ) } + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + hydration.resolve() + await cleanupPersistedOracle( + [() => collection.cleanup()], + hasPrimaryFailure, + ) + } + } + + oracleProperty({ + id: `persistence.hydration-boundary-commit`, + law: { + statement: `A source transaction that commits during, straddling, or after a persisted hydration settles durably, with its row public and durable once.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The three commit positions relative to one held recording-adapter hydration.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: hydrationBoundaryPositions, + label: (position) => + `durably settles a source transaction that commits ${position} persisted hydration`, + resolve: (position) => ({ position }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runHydrationBoundaryCommit(plan.position), + ), + check: forwardAssertionJudgment, + }) - it(`does not let a later same-key transaction overtake a hydration-straddling commit`, async () => { + async function runHydrationStraddleSiblingFifo(): Promise { const hydrationEntered = createEventGate() const hydration = createEventGate() const adapter = createRecordingAdapter() @@ -2057,9 +1350,9 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { hasPrimaryFailure, ) } - }) + } - it(`settles an aborted hydration-straddling commit without applying it`, async () => { + async function runAbortedHydrationStraddle(): Promise { const hydrationEntered = createEventGate() const hydration = createEventGate() const adapter = createRecordingAdapter() @@ -2140,253 +1433,303 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { hasPrimaryFailure, ) } - }) + } - it(`append-only transaction judgment rejects omission and reordering`, () => { - const events: Array = [ - { type: `begin`, transactionId: `first` }, + oracleProperty({ + id: `persistence.hydration-straddle-fifo`, + law: { + statement: `A hydration-straddling source commit keeps its source order against a later same-key transaction, and an aborted one settles without applying.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `One fixed recording-adapter history with one held hydration and a later same-key transaction.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: [ { - type: `write`, - transactionId: `first`, - row: { id: `shared`, title: `first` }, + name: `does not let a later same-key transaction overtake a hydration-straddling commit`, }, - { type: `commit`, transactionId: `first` }, - { type: `begin`, transactionId: `second` }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runHydrationStraddleSiblingFifo(), + ), + check: forwardAssertionJudgment, + }) + + oracleProperty({ + id: `persistence.hydration-straddle-abort`, + law: { + statement: `A hydration-straddling source commit keeps its source order against a later same-key transaction, and an aborted one settles without applying.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `One fixed recording-adapter history with one held hydration and an aborted commit.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: [ { - type: `write`, - transactionId: `second`, - row: { id: `shared`, title: `second` }, + name: `settles an aborted hydration-straddling commit without applying it`, }, - { type: `commit`, transactionId: `second` }, - ] - const expected = foldDurabilityLedger(events) + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runAbortedHydrationStraddle(), + ), + check: forwardAssertionJudgment, + }) - oracleAssertion('existing comparison at 2042', () => - expect(expected.commitOrder).toEqual([`first`, `second`]), - ) - oracleAssertion('existing comparison at 2043', () => - expect(expected.commitOrder).not.toEqual([`second`]), - ) - oracleAssertion('existing comparison at 2044', () => - expect(expected.commitOrder).not.toEqual([`second`, `first`]), - ) - oracleAssertion('existing comparison at 2045', () => - expect(expected.committedRows.get(`shared`)).toEqual({ - id: `shared`, - title: `second`, + const bufferedAbortEntries = [`startup`, `resume`, `reset`] as const + + async function runBufferedAbortReadiness( + entry: (typeof bufferedAbortEntries)[number], + ): Promise { + const adapter = createRecordingAdapter() + const hydrationEntered = createEventGate() + const releaseHydration = createEventGate() + const coordinator = + entry === `reset` ? createCoordinatorHarness() : undefined + if (coordinator) { + coordinator.requestApplyCommittedTx = async (collectionId, tx) => { + await adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + tx, + ) + return { + type: `rpc:applyCommittedTx:res`, + rpcId: tx.txId, + ok: true, + term: tx.term, + seq: tx.seq, + latestRowVersion: tx.rowVersion, + } + } + } else { + overrideBaselineRows(adapter, async () => { + hydrationEntered.resolve() + await releaseHydration.promise + return [] + }) + } + let sourceParams!: TodoSyncParams + let hydrateResumeBaseline: (() => Promise) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: coordinator ? `sync-present` : `buffered-abort-${entry}`, + getKey: (row) => row.id, + syncMode: entry === `resume` ? `on-demand` : `eager`, + sync: { + sync: (params) => { + sourceParams = params + hydrateResumeBaseline = + params.metadata?.persistence?.hydrateBaseline + params.markReady() + return { loadSubset: () => true } + }, + }, + persistence: { adapter, coordinator }, }), ) - }) + const aborted = new AbortController() + let keyReads = 0 + let hydration: Promise | undefined + let abortedReceipt: Promise | undefined + let independentReceipt: Promise | undefined + let hasPrimaryFailure = false - it.each([`startup`, `resume`, `reset`] as const)( - `keeps the %s hydration ready after a buffered pre-application abort`, - async (entry) => { - const adapter = createRecordingAdapter() - const hydrationEntered = createEventGate() - const releaseHydration = createEventGate() - const coordinator = - entry === `reset` ? createCoordinatorHarness() : undefined - if (coordinator) { - coordinator.requestApplyCommittedTx = async (collectionId, tx) => { - await adapter.applyCommittedTx( - { kind: `eager`, collectionId: collectionId }, - tx, - ) - return { - type: `rpc:applyCommittedTx:res`, - rpcId: tx.txId, - ok: true, - term: tx.term, - seq: tx.seq, - latestRowVersion: tx.rowVersion, - } - } - } else { - overrideBaselineRows(adapter, async () => { + try { + if (entry === `resume`) { + collection.startSyncImmediate() + await atPersistedOracleCheckpoint( + collection.stateWhenReady(), + `resume collection ready before baseline`, + ) + oracleAssertion('existing comparison at 2115', () => + expect(hydrateResumeBaseline).toBeTypeOf(`function`), + ) + hydration = hydrateResumeBaseline!() + } else if (coordinator) { + await atPersistedOracleCheckpoint( + collection.stateWhenReady(), + `reset collection ready before reload`, + ) + const loadSubset = adapter.loadSubset.bind(adapter) + adapter.loadSubset = async (...args) => { hydrationEntered.resolve() await releaseHydration.promise - return [] + return loadSubset(...args) + } + coordinator.emit({ + type: `collection:reset`, + schemaVersion: 1, + resetEpoch: 1, }) + hydration = Promise.resolve() + } else { + hydration = collection.stateWhenReady() } - let sourceParams!: TodoSyncParams - let hydrateResumeBaseline: (() => Promise) | undefined - const collection = createCollection( - persistedCollectionOptions({ - id: coordinator ? `sync-present` : `buffered-abort-${entry}`, - getKey: (row) => row.id, - syncMode: entry === `resume` ? `on-demand` : `eager`, - sync: { - sync: (params) => { - sourceParams = params - hydrateResumeBaseline = - params.metadata?.persistence?.hydrateBaseline - params.markReady() - return { loadSubset: () => true } - }, + void hydration.catch(() => undefined) + await atPersistedOracleCheckpoint( + hydrationEntered.promise, + `${entry} hydration entered`, + ) + + sourceParams.begin() + sourceParams.write({ + type: `insert`, + value: { id: `prefix`, title: `staged before abort` }, + }) + sourceParams.write({ + type: `insert`, + value: { + get id() { + keyReads++ + if (keyReads === 2) aborted.abort() + return `aborting` }, - persistence: { adapter, coordinator }, - }), + title: `cancel before core application`, + }, + }) + abortedReceipt = Promise.resolve( + sourceParams.commit(aborted.signal), + ).then(() => undefined) + void abortedReceipt.catch(() => undefined) + + sourceParams.begin() + sourceParams.write({ + type: `insert`, + value: { id: `independent`, title: `survives abort` }, + }) + independentReceipt = Promise.resolve(sourceParams.commit()).then( + () => undefined, ) - const aborted = new AbortController() - let keyReads = 0 - let hydration: Promise | undefined - let abortedReceipt: Promise | undefined - let independentReceipt: Promise | undefined - let hasPrimaryFailure = false + void independentReceipt.catch(() => undefined) - try { - if (entry === `resume`) { - collection.startSyncImmediate() - await atPersistedOracleCheckpoint( - collection.stateWhenReady(), - `resume collection ready before baseline`, - ) - oracleAssertion('existing comparison at 2115', () => - expect(hydrateResumeBaseline).toBeTypeOf(`function`), - ) - hydration = hydrateResumeBaseline!() - } else if (coordinator) { - await atPersistedOracleCheckpoint( - collection.stateWhenReady(), - `reset collection ready before reload`, - ) - const loadSubset = adapter.loadSubset.bind(adapter) - adapter.loadSubset = async (...args) => { - hydrationEntered.resolve() - await releaseHydration.promise - return loadSubset(...args) - } - coordinator.emit({ - type: `collection:reset`, - schemaVersion: 1, - resetEpoch: 1, - }) - hydration = Promise.resolve() - } else { - hydration = collection.stateWhenReady() - } - void hydration.catch(() => undefined) - await atPersistedOracleCheckpoint( - hydrationEntered.promise, - `${entry} hydration entered`, - ) - - sourceParams.begin() - sourceParams.write({ - type: `insert`, - value: { id: `prefix`, title: `staged before abort` }, - }) - sourceParams.write({ - type: `insert`, - value: { - get id() { - keyReads++ - if (keyReads === 2) aborted.abort() - return `aborting` - }, - title: `cancel before core application`, - }, - }) - abortedReceipt = Promise.resolve( - sourceParams.commit(aborted.signal), - ).then(() => undefined) - void abortedReceipt.catch(() => undefined) - - sourceParams.begin() - sourceParams.write({ - type: `insert`, - value: { id: `independent`, title: `survives abort` }, - }) - independentReceipt = Promise.resolve(sourceParams.commit()).then( - () => undefined, - ) - void independentReceipt.catch(() => undefined) - - releaseHydration.resolve() - const outcomes = await atPersistedOracleCheckpoint( - Promise.allSettled([hydration, abortedReceipt, independentReceipt]), - `${entry} buffered abort settled`, - ) - const expected = foldDurabilityLedger([ - { type: `begin`, transactionId: `aborted` }, - { - type: `write`, - transactionId: `aborted`, - row: { id: `prefix`, title: `staged before abort` }, - }, - { type: `abort`, transactionId: `aborted` }, - { type: `begin`, transactionId: `independent` }, - { - type: `write`, - transactionId: `independent`, - row: { id: `independent`, title: `survives abort` }, - }, - { type: `commit`, transactionId: `independent` }, - ]) + releaseHydration.resolve() + const outcomes = await atPersistedOracleCheckpoint( + Promise.allSettled([hydration, abortedReceipt, independentReceipt]), + `${entry} buffered abort settled`, + ) + const expected = foldDurabilityLedger([ + { type: `begin`, transactionId: `aborted` }, + { + type: `write`, + transactionId: `aborted`, + row: { id: `prefix`, title: `staged before abort` }, + }, + { type: `abort`, transactionId: `aborted` }, + { type: `begin`, transactionId: `independent` }, + { + type: `write`, + transactionId: `independent`, + row: { id: `independent`, title: `survives abort` }, + }, + { type: `commit`, transactionId: `independent` }, + ]) - oracleAssertion('existing comparison at 2196', () => - expect(keyReads).toBeGreaterThanOrEqual(2), - ) - oracleAssertion('existing comparison at 2197', () => - expect(outcomes.map((outcome) => outcome.status)).toEqual([ - `fulfilled`, - `rejected`, - `fulfilled`, - ]), - ) - const abortedOutcome = outcomes[1] - if (abortedOutcome.status === `rejected`) { - oracleAssertion('existing comparison at 2203', () => - expect(abortedOutcome.reason).toBeInstanceOf( - SyncTransactionAbortedError, - ), - ) - } - oracleAssertion('existing comparison at 2205', () => - expect(collection.status).toBe(`ready`), - ) - oracleAssertion('existing comparison at 2206', () => - expect(collection._lifecycle.getSyncError()).toBeUndefined(), - ) - oracleAssertion('existing comparison at 2207', () => - expect(collection.get(`prefix`)).toBeUndefined(), - ) - oracleAssertion('existing comparison at 2208', () => - expect(collection.get(`aborting`)).toBeUndefined(), - ) - oracleAssertion('existing comparison at 2209', () => - expect(stripVirtualProps(collection.get(`independent`))).toEqual( - expected.committedRows.get(`independent`), + oracleAssertion('existing comparison at 2196', () => + expect(keyReads).toBeGreaterThanOrEqual(2), + ) + oracleAssertion('existing comparison at 2197', () => + expect(outcomes.map((outcome) => outcome.status)).toEqual([ + `fulfilled`, + `rejected`, + `fulfilled`, + ]), + ) + const abortedOutcome = outcomes[1] + if (abortedOutcome.status === `rejected`) { + oracleAssertion('existing comparison at 2203', () => + expect(abortedOutcome.reason).toBeInstanceOf( + SyncTransactionAbortedError, ), ) - oracleAssertion('existing comparison at 2212', () => - expect(adapter.rows).toEqual(expected.committedRows), - ) - oracleAssertion('existing comparison at 2213', () => - expect( - adapter.applyCommittedTxCalls.map(({ tx }) => - tx.mutations.map(({ key }) => key), - ), - ).toEqual([[`independent`]]), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - releaseHydration.resolve() - aborted.abort() - await cleanupPersistedOracle( - [ - () => hydration?.catch(() => undefined), - () => abortedReceipt?.catch(() => undefined), - () => independentReceipt?.catch(() => undefined), - () => collection.cleanup(), - ], - hasPrimaryFailure, - ) } + oracleAssertion('existing comparison at 2205', () => + expect(collection.status).toBe(`ready`), + ) + oracleAssertion('existing comparison at 2206', () => + expect(collection._lifecycle.getSyncError()).toBeUndefined(), + ) + oracleAssertion('existing comparison at 2207', () => + expect(collection.get(`prefix`)).toBeUndefined(), + ) + oracleAssertion('existing comparison at 2208', () => + expect(collection.get(`aborting`)).toBeUndefined(), + ) + oracleAssertion('existing comparison at 2209', () => + expect(stripVirtualProps(collection.get(`independent`))).toEqual( + expected.committedRows.get(`independent`), + ), + ) + oracleAssertion('existing comparison at 2212', () => + expect(adapter.rows).toEqual(expected.committedRows), + ) + oracleAssertion('existing comparison at 2213', () => + expect( + adapter.applyCommittedTxCalls.map(({ tx }) => + tx.mutations.map(({ key }) => key), + ), + ).toEqual([[`independent`]]), + ) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + releaseHydration.resolve() + aborted.abort() + await cleanupPersistedOracle( + [ + () => hydration?.catch(() => undefined), + () => abortedReceipt?.catch(() => undefined), + () => independentReceipt?.catch(() => undefined), + () => collection.cleanup(), + ], + hasPrimaryFailure, + ) + } + } + + oracleProperty({ + id: `persistence.buffered-abort-readiness`, + law: { + statement: `A startup, resume, or reset hydration stays ready after a buffered source transaction aborts before it applies, and the independent sibling survives.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The three hydration entries with one buffered pre-application abort.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: bufferedAbortEntries, + label: (entry) => + `keeps the ${entry} hydration ready after a buffered pre-application abort`, + resolve: (entry) => ({ entry }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runBufferedAbortReadiness(plan.entry), + ), + check: forwardAssertionJudgment, + }) // Commit acceptance creates a source obligation. Successful subset hydration // queued ahead of publication cannot revoke it. This finite grammar crosses @@ -2398,8 +1741,8 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // rejected below; reserved ones are covered by the persisted-read precedence // grammar's open commit timings. // This driver supplies adapter scheduling, not live Electric or OPFS delivery. - it.each( - ([`insert`, `update`] as const).flatMap((type) => + const queuedHydrationPublications = ([`insert`, `update`] as const).flatMap( + (type) => [1, 2, 3].flatMap((hydrationCount) => [false, true].flatMap((reserveCommitTurn) => [false, true].map((hasSuccessor) => ({ @@ -2410,202 +1753,230 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { })), ), ), - ), - )( - `publishes committed source $type after $hydrationCount queued hydrations (reserveCommitTurn=$reserveCommitTurn, successor=$hasSuccessor)`, - async ({ type, hydrationCount, reserveCommitTurn, hasSuccessor }) => { - // https://github.com/TanStack/db/issues/2036 - const adapter = createRecordingAdapter([ - { id: `shared`, title: `persisted`, detail: `retained baseline` }, - ]) - adapter.rowMetadata.set(`shared`, { owner: `persisted` }) - const persistenceEntered = createEventGate() - const releasePersistence = createEventGate() - const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) - let applyCalls = 0 - adapter.applyCommittedTx = async (...args) => { - if (++applyCalls === 1) { - persistenceEntered.resolve() - await releasePersistence.promise - } - await applyCommittedTx(...args) - for (const mutation of args[1].mutations) { - if (mutation.type !== `delete` && mutation.key === `shared`) { - durableSourceTitles.push((mutation.value as Todo).title) - } - } + ) + + async function runQueuedHydrationPublication({ + type, + hydrationCount, + reserveCommitTurn, + hasSuccessor, + }: (typeof queuedHydrationPublications)[number]): Promise { + // https://github.com/TanStack/db/issues/2036 + const adapter = createRecordingAdapter([ + { id: `shared`, title: `persisted`, detail: `retained baseline` }, + ]) + adapter.rowMetadata.set(`shared`, { owner: `persisted` }) + const persistenceEntered = createEventGate() + const releasePersistence = createEventGate() + const applyCommittedTx = adapter.applyCommittedTx.bind(adapter) + let applyCalls = 0 + adapter.applyCommittedTx = async (...args) => { + if (++applyCalls === 1) { + persistenceEntered.resolve() + await releasePersistence.promise } - const durableSourceTitles: Array = [] - const hydrationTitles: Array = [] - const loadSubset = adapter.loadSubset.bind(adapter) - adapter.loadSubset = async (...args) => { - hydrationTitles.push(collection.get(`shared`)?.title) - return loadSubset(...args) + await applyCommittedTx(...args) + for (const mutation of args[1].mutations) { + if (mutation.type !== `delete` && mutation.key === `shared`) { + durableSourceTitles.push((mutation.value as Todo).title) + } } - let sourceParams!: TodoSyncParams - const collection = createCollection( - persistedCollectionOptions({ - id: `committed-source-${type}-queued-behind-hydration`, - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: (params) => { - sourceParams = params - params.markReady() - return { loadSubset: () => true } - }, + } + const durableSourceTitles: Array = [] + const hydrationTitles: Array = [] + const loadSubset = adapter.loadSubset.bind(adapter) + adapter.loadSubset = async (...args) => { + hydrationTitles.push(collection.get(`shared`)?.title) + return loadSubset(...args) + } + let sourceParams!: TodoSyncParams + const collection = createCollection( + persistedCollectionOptions({ + id: `committed-source-${type}-queued-behind-hydration`, + getKey: (row) => row.id, + syncMode: `on-demand`, + sync: { + sync: (params) => { + sourceParams = params + params.markReady() + return { loadSubset: () => true } }, - persistence: { adapter }, - }), + }, + persistence: { adapter }, + }), + ) + const options = Array.from({ length: hydrationCount }, (_, index) => ({ + limit: index + 1, + })) + // Model complete source obligations independently of production queues. + // Hydration establishes the baseline first. A cached baseline row is a + // snapshot older than every source write, like a hydration seed + // (maintainer decision C1 of 2026-10-23, recorded in + // docs/contributing/oracle-reviews/2026-10-22-sync-metadata-session.md): + // a source insert names the whole row, so it replaces the baseline row + // and resets its metadata, while a partial update merges over it and + // keeps baseline ownership. A later partial update merges over the row + // the first source write left. + const sourceTitles = hasSuccessor + ? [`first source value`, `second source value`] + : [`first source value`] + const expected = foldDurabilityLedger( + sourceTitles.flatMap((title): Array => [ + { type: `begin`, transactionId: title }, + { + type: `write`, + transactionId: title, + row: + type === `insert` + ? { id: `shared`, title } + : { id: `shared`, title, detail: `retained baseline` }, + }, + { type: `commit`, transactionId: title }, + ]), + ) + const receipts: Array> = [] + let hasPrimaryFailure = false + try { + await collection.stateWhenReady() + sourceParams.begin() + sourceParams.write({ + type: `insert`, + value: { id: `gate`, title: `holds apply mutex` }, + }) + const gateReceipt = Promise.resolve(sourceParams.commit()) + void gateReceipt.catch(() => undefined) + receipts.push(gateReceipt) + await atPersistedOracleCheckpoint( + persistenceEntered.promise, + `predecessor persistence entered`, ) - const options = Array.from({ length: hydrationCount }, (_, index) => ({ - limit: index + 1, - })) - // Model complete source obligations independently of production queues. - // Hydration establishes the baseline first. A cached baseline row is a - // snapshot older than every source write, like a hydration seed - // (maintainer decision C1 of 2026-10-23, recorded in - // docs/contributing/oracle-reviews/2026-10-22-sync-metadata-session.md): - // a source insert names the whole row, so it replaces the baseline row - // and resets its metadata, while a partial update merges over it and - // keeps baseline ownership. A later partial update merges over the row - // the first source write left. - const sourceTitles = hasSuccessor - ? [`first source value`, `second source value`] - : [`first source value`] - const expected = foldDurabilityLedger( - sourceTitles.flatMap((title): Array => [ - { type: `begin`, transactionId: title }, - { - type: `write`, - transactionId: title, - row: - type === `insert` - ? { id: `shared`, title } - : { id: `shared`, title, detail: `retained baseline` }, - }, - { type: `commit`, transactionId: title }, - ]), + + // Reserve hydrations while the predecessor holds the mutex. + // None has started when the source transaction begins and commits. + for (const option of options) { + const hydration = Promise.resolve(collection._sync.loadSubset(option)) + void hydration.catch(() => undefined) + receipts.push(hydration) + } + await flushAsyncWork() + oracleAssertion('existing comparison at 2362', () => + expect(adapter.loadSubsetCalls).toHaveLength(0), ) - const receipts: Array> = [] - let hasPrimaryFailure = false - try { - await collection.stateWhenReady() - sourceParams.begin() - sourceParams.write({ - type: `insert`, - value: { id: `gate`, title: `holds apply mutex` }, - }) - const gateReceipt = Promise.resolve(sourceParams.commit()) - void gateReceipt.catch(() => undefined) - receipts.push(gateReceipt) - await atPersistedOracleCheckpoint( - persistenceEntered.promise, - `predecessor persistence entered`, - ) + sourceParams.begin() + if (reserveCommitTurn) { + sourceParams.metadata!.persistence!.reserveCommitTurn() + } + sourceParams.write({ + type, + value: { id: `shared`, title: `first source value` }, + }) + sourceParams.metadata!.collection.set(`cursor`, `first`) + const firstReceipt = Promise.resolve(sourceParams.commit()) + void firstReceipt.catch(() => undefined) + receipts.push(firstReceipt) - // Reserve hydrations while the predecessor holds the mutex. - // None has started when the source transaction begins and commits. - for (const option of options) { - const hydration = Promise.resolve(collection._sync.loadSubset(option)) - void hydration.catch(() => undefined) - receipts.push(hydration) - } - await flushAsyncWork() - oracleAssertion('existing comparison at 2362', () => - expect(adapter.loadSubsetCalls).toHaveLength(0), - ) + if (hasSuccessor) { sourceParams.begin() if (reserveCommitTurn) { sourceParams.metadata!.persistence!.reserveCommitTurn() } + oracleAssertion('existing comparison at 2381', () => + expect(sourceParams.metadata!.collection.get(`cursor`)).toBe(`first`), + ) sourceParams.write({ - type, - value: { id: `shared`, title: `first source value` }, + type: `update`, + value: { id: `shared`, title: `second source value` }, }) - sourceParams.metadata!.collection.set(`cursor`, `first`) - const firstReceipt = Promise.resolve(sourceParams.commit()) - void firstReceipt.catch(() => undefined) - receipts.push(firstReceipt) + sourceParams.metadata!.collection.set(`cursor`, `second`) + const secondReceipt = Promise.resolve(sourceParams.commit()) + void secondReceipt.catch(() => undefined) + receipts.push(secondReceipt) + } + oracleAssertion('existing comparison at 2391', () => + expect(collection.get(`shared`)).toBeUndefined(), + ) - if (hasSuccessor) { - sourceParams.begin() - if (reserveCommitTurn) { - sourceParams.metadata!.persistence!.reserveCommitTurn() - } - oracleAssertion('existing comparison at 2381', () => - expect(sourceParams.metadata!.collection.get(`cursor`)).toBe( - `first`, - ), - ) - sourceParams.write({ - type: `update`, - value: { id: `shared`, title: `second source value` }, - }) - sourceParams.metadata!.collection.set(`cursor`, `second`) - const secondReceipt = Promise.resolve(sourceParams.commit()) - void secondReceipt.catch(() => undefined) - receipts.push(secondReceipt) - } - oracleAssertion('existing comparison at 2391', () => - expect(collection.get(`shared`)).toBeUndefined(), - ) + releasePersistence.resolve() + // Observe every receipt before cleanup. allSettled preserves rejection + // identity alongside the wrong public/durable snapshot on main. + const outcomes = await atPersistedOracleCheckpoint( + Promise.allSettled(receipts), + `committed source receipts after queued hydrations`, + ) + oracleAssertion('existing comparison at 2400', () => + expect({ + outcomes, + hydrationTitles, + durableSourceTitles, + status: collection.status, + publicRow: stripVirtualProps(collection.get(`shared`)), + durableRow: adapter.rows.get(`shared`), + rowMetadata: adapter.rowMetadata.get(`shared`), + cursor: adapter.collectionMetadata.get(`cursor`), + durableKeys: adapter.applyCommittedTxCalls.map(({ tx }) => + tx.mutations.map(({ key }) => key), + ), + }).toEqual({ + outcomes: receipts.map(() => ({ + status: `fulfilled`, + value: undefined, + })), + hydrationTitles: options.map((_, index) => + index === 0 ? undefined : `persisted`, + ), + durableSourceTitles: expected.commitOrder, + status: `ready`, + publicRow: expected.committedRows.get(`shared`), + durableRow: expected.committedRows.get(`shared`), + rowMetadata: type === `insert` ? undefined : { owner: `persisted` }, + cursor: hasSuccessor ? `second` : `first`, + durableKeys: [[`gate`], ...sourceTitles.map(() => [`shared`])], + }), + ) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + releasePersistence.resolve() + await cleanupPersistedOracle( + [ + ...options.map( + (option) => () => collection._sync.unloadSubset(option), + ), + () => Promise.allSettled(receipts), + () => collection.cleanup(), + ], + hasPrimaryFailure, + ) + } + } - releasePersistence.resolve() - // Observe every receipt before cleanup. allSettled preserves rejection - // identity alongside the wrong public/durable snapshot on main. - const outcomes = await atPersistedOracleCheckpoint( - Promise.allSettled(receipts), - `committed source receipts after queued hydrations`, - ) - oracleAssertion('existing comparison at 2400', () => - expect({ - outcomes, - hydrationTitles, - durableSourceTitles, - status: collection.status, - publicRow: stripVirtualProps(collection.get(`shared`)), - durableRow: adapter.rows.get(`shared`), - rowMetadata: adapter.rowMetadata.get(`shared`), - cursor: adapter.collectionMetadata.get(`cursor`), - durableKeys: adapter.applyCommittedTxCalls.map(({ tx }) => - tx.mutations.map(({ key }) => key), - ), - }).toEqual({ - outcomes: receipts.map(() => ({ - status: `fulfilled`, - value: undefined, - })), - hydrationTitles: options.map((_, index) => - index === 0 ? undefined : `persisted`, - ), - durableSourceTitles: expected.commitOrder, - status: `ready`, - publicRow: expected.committedRows.get(`shared`), - durableRow: expected.committedRows.get(`shared`), - rowMetadata: type === `insert` ? undefined : { owner: `persisted` }, - cursor: hasSuccessor ? `second` : `first`, - durableKeys: [[`gate`], ...sourceTitles.map(() => [`shared`])], - }), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - releasePersistence.resolve() - await cleanupPersistedOracle( - [ - ...options.map( - (option) => () => collection._sync.unloadSubset(option), - ), - () => Promise.allSettled(receipts), - () => collection.cleanup(), - ], - hasPrimaryFailure, - ) - } + oracleProperty({ + id: `persistence.queued-hydration-publication`, + law: { + statement: `A committed source insert or update queued behind persisted hydrations publishes its durable result once the hydrations finish, with or without reserveCommitTurn and a successor.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The enumerated mutation type, one to three queued hydrations, reserveCommitTurn, and successor cells.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: queuedHydrationPublications, + label: (history) => + `publishes committed source ${history.type} after ${history.hydrationCount} queued hydrations (reserveCommitTurn=${history.reserveCommitTurn}, successor=${history.hasSuccessor})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runQueuedHydrationPublication(plan.history), + ), + check: forwardAssertionJudgment, + }) // Law: a complete persisted value replaces the old value in every field, // including field absence. This follows the durable snapshot contract and @@ -2627,245 +1998,268 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // Built-in SQLite drops undefined object fields during encoding, so those // two arms exercise the generic adapter boundary only; removal of a defined // field is the receiving SQLite-representable case. - it.each( + const durableRowShapes = ( + [`complete demand`, `retired peer notice`, `retired reset`] as const + ).flatMap((route) => ( - [`complete demand`, `retired peer notice`, `retired reset`] as const - ).flatMap((route) => - ( - [ - `remove`, - `remove with metadata`, - `add`, - `remove undefined`, - `add undefined`, - `scalar`, - ] as const - ).map((shape) => ({ - route, - shape, - })), - ), - )( - `publishes exact durable row shape within $route ($shape)`, - async ({ route, shape }) => { - const ordinary: Todo = - shape === `add undefined` - ? { id: `ordinary`, title: `Old` } - : shape === `remove undefined` - ? { id: `ordinary`, title: `Old`, detail: undefined } - : { id: `ordinary`, title: `Old`, detail: `obsolete` } - const removed = { id: `removed`, title: `Old` } - const seed = { id: `seed`, title: `Hydrated`, detail: `source-owned` } - const unseen = { id: `unseen`, title: `Only in storage` } - const fresh: Todo = - shape === `remove` || - shape === `remove with metadata` || - shape === `remove undefined` - ? { id: ordinary.id, title: ordinary.title } - : shape === `add` - ? { ...ordinary, payload: { rank: 1 } } - : shape === `add undefined` - ? { ...ordinary, detail: undefined } - : { ...ordinary, title: `Fresh` } - const expectedFresh = structuredClone(fresh) - const before = new Map([ - [ordinary.id, structuredClone(ordinary)], - [removed.id, structuredClone(removed)], - [seed.id, structuredClone(seed)], - ]) - const after = new Map([ - [fresh.id, fresh], - [unseen.id, unseen], - [seed.id, { id: seed.id, title: `Older durable seed` }], - ]) - const adapter = createRecordingAdapter([ordinary, removed]) - if (shape === `remove with metadata`) - adapter.rowMetadata.set(ordinary.id, { source: `durable` }) - const coordinator = createCoordinatorHarness() - const collectionId = `exact-shape-${route}-${shape}` - let source!: TodoSyncParams - const descriptor = collectionOptions( - persistedCollectionOptions({ - id: collectionId, - syncMode: `on-demand`, - getKey: (row) => row.id, - sync: { - sync: (params) => { - source = params - params.markReady() - return { loadSubset: () => true } - }, + [ + `remove`, + `remove with metadata`, + `add`, + `remove undefined`, + `add undefined`, + `scalar`, + ] as const + ).map((shape) => ({ + route, + shape, + })), + ) + + async function runDurableRowShape({ + route, + shape, + }: (typeof durableRowShapes)[number]): Promise { + const ordinary: Todo = + shape === `add undefined` + ? { id: `ordinary`, title: `Old` } + : shape === `remove undefined` + ? { id: `ordinary`, title: `Old`, detail: undefined } + : { id: `ordinary`, title: `Old`, detail: `obsolete` } + const removed = { id: `removed`, title: `Old` } + const seed = { id: `seed`, title: `Hydrated`, detail: `source-owned` } + const unseen = { id: `unseen`, title: `Only in storage` } + const fresh: Todo = + shape === `remove` || + shape === `remove with metadata` || + shape === `remove undefined` + ? { id: ordinary.id, title: ordinary.title } + : shape === `add` + ? { ...ordinary, payload: { rank: 1 } } + : shape === `add undefined` + ? { ...ordinary, detail: undefined } + : { ...ordinary, title: `Fresh` } + const expectedFresh = structuredClone(fresh) + const before = new Map([ + [ordinary.id, structuredClone(ordinary)], + [removed.id, structuredClone(removed)], + [seed.id, structuredClone(seed)], + ]) + const after = new Map([ + [fresh.id, fresh], + [unseen.id, unseen], + [seed.id, { id: seed.id, title: `Older durable seed` }], + ]) + const adapter = createRecordingAdapter([ordinary, removed]) + if (shape === `remove with metadata`) + adapter.rowMetadata.set(ordinary.id, { source: `durable` }) + const coordinator = createCoordinatorHarness() + const collectionId = `exact-shape-${route}-${shape}` + let source!: TodoSyncParams + const descriptor = collectionOptions( + persistedCollectionOptions({ + id: collectionId, + syncMode: `on-demand`, + getKey: (row) => row.id, + sync: { + sync: (params) => { + source = params + params.markReady() + return { loadSubset: () => true } }, - persistence: { adapter, coordinator }, - }), + }, + persistence: { adapter, coordinator }, + }), + ) + const client = new DbClient() + client.hydrate({ + collections: [{ collectionId, rows: [{ key: seed.id, value: seed }] }], + }) + const collection = client.collection(descriptor) + const firstDemand = { limit: 10 } + const publishedOrdinaryValues: Array = [] + const publishedChanges: Array = [] + const publishedSnapshots: Array> = [] + const publicRows = () => + new Map( + Array.from(collection.values(), (row) => [ + row.id, + stripVirtualProps(row), + ]), ) - const client = new DbClient() - client.hydrate({ - collections: [{ collectionId, rows: [{ key: seed.id, value: seed }] }], - }) - const collection = client.collection(descriptor) - const firstDemand = { limit: 10 } - const publishedOrdinaryValues: Array = [] - const publishedChanges: Array = [] - const publishedSnapshots: Array> = [] - const publicRows = () => - new Map( - Array.from(collection.values(), (row) => [ - row.id, - stripVirtualProps(row), - ]), - ) - let subscription: - ReturnType | undefined - let hasPrimaryFailure = false - try { - collection.startSyncImmediate() - await collection.stateWhenReady() - await collection._sync.loadSubset(firstDemand) - expectExactSnapshot(publicRows(), before) - if (route !== `complete demand`) - collection._sync.unloadSubset(firstDemand) - // An initial-state subscription would itself acquire subset demand. - // Observe subsequent changes without changing this route's demand. - subscription = collection.subscribeChanges( - (batch) => { - publishedSnapshots.push( - new Map( - Array.from(publicRows(), ([key, row]) => [ - key, - structuredClone(row), - ]), - ), - ) - publishedChanges.push( - ...batch.map((change) => `${change.type}:${change.key}`), - ) - for (const change of batch) { - if (change.key === ordinary.id && change.type !== `delete`) { - publishedOrdinaryValues.push( - structuredClone(stripVirtualProps(change.value)), - ) - } + let subscription: ReturnType | undefined + let hasPrimaryFailure = false + try { + collection.startSyncImmediate() + await collection.stateWhenReady() + await collection._sync.loadSubset(firstDemand) + expectExactSnapshot(publicRows(), before) + if (route !== `complete demand`) + collection._sync.unloadSubset(firstDemand) + // An initial-state subscription would itself acquire subset demand. + // Observe subsequent changes without changing this route's demand. + subscription = collection.subscribeChanges( + (batch) => { + publishedSnapshots.push( + new Map( + Array.from(publicRows(), ([key, row]) => [ + key, + structuredClone(row), + ]), + ), + ) + publishedChanges.push( + ...batch.map((change) => `${change.type}:${change.key}`), + ) + for (const change of batch) { + if (change.key === ordinary.id && change.type !== `delete`) { + publishedOrdinaryValues.push( + structuredClone(stripVirtualProps(change.value)), + ) } + } + }, + { includeInitialState: false }, + ) + const subsetReadsBeforeRevision = adapter.loadSubsetCalls.length + + adapter.rows.clear() + for (const [key, row] of after) adapter.rows.set(key, row) + // The peer/reset signal and its subsequent durable read describe one + // coherent generation. Direct fixture row edits do not advance the + // recording adapter's transaction ledger, so supply that position here. + const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) + adapter.loadResumeSnapshot = async (...args) => ({ + ...(await loadResumeSnapshot(...args)), + latestTerm: route === `retired peer notice` ? 1 : 0, + latestSeq: route === `retired peer notice` ? 1 : 0, + latestRowVersion: route === `retired peer notice` ? 1 : 0, + resetEpoch: route === `retired reset` ? 1 : 0, + }) + const expected = expectedScopedSnapshot( + before, + after, + new Map([[seed.id, before.get(seed.id)!]]), + route === `complete demand`, + ) + const published = createEventGate() + const runInHydrationScope = adapter.runInHydrationScope?.bind(adapter) + adapter.runInHydrationScope = async (task) => { + const result = await (runInHydrationScope?.(task) ?? task(adapter)) + published.resolve() + return result + } + if (route === `complete demand`) { + const completeDemand = {} + await collection._sync.loadSubset(completeDemand) + collection._sync.unloadSubset(completeDemand) + } else if (route === `retired peer notice`) { + coordinator.emit( + { + type: `tx:committed`, + term: 1, + seq: 1, + txId: `exact-shape`, + latestRowVersion: 1, + requiresFullReload: true, }, - { includeInitialState: false }, + undefined, + collectionId, ) - const subsetReadsBeforeRevision = adapter.loadSubsetCalls.length - - adapter.rows.clear() - for (const [key, row] of after) adapter.rows.set(key, row) - // The peer/reset signal and its subsequent durable read describe one - // coherent generation. Direct fixture row edits do not advance the - // recording adapter's transaction ledger, so supply that position here. - const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) - adapter.loadResumeSnapshot = async (...args) => ({ - ...(await loadResumeSnapshot(...args)), - latestTerm: route === `retired peer notice` ? 1 : 0, - latestSeq: route === `retired peer notice` ? 1 : 0, - latestRowVersion: route === `retired peer notice` ? 1 : 0, - resetEpoch: route === `retired reset` ? 1 : 0, - }) - const expected = expectedScopedSnapshot( - before, - after, - new Map([[seed.id, before.get(seed.id)!]]), - route === `complete demand`, + await atPersistedOracleCheckpoint( + published.promise, + `exact-shape peer notice published`, ) - const published = createEventGate() - const runInHydrationScope = adapter.runInHydrationScope?.bind(adapter) - adapter.runInHydrationScope = async (task) => { - const result = await (runInHydrationScope?.(task) ?? task(adapter)) - published.resolve() - return result - } - if (route === `complete demand`) { - const completeDemand = {} - await collection._sync.loadSubset(completeDemand) - collection._sync.unloadSubset(completeDemand) - } else if (route === `retired peer notice`) { - coordinator.emit( - { - type: `tx:committed`, - term: 1, - seq: 1, - txId: `exact-shape`, - latestRowVersion: 1, - requiresFullReload: true, - }, - undefined, - collectionId, - ) - await atPersistedOracleCheckpoint( - published.promise, - `exact-shape peer notice published`, - ) - } else { - coordinator.emit( - { type: `collection:reset`, schemaVersion: 1, resetEpoch: 1 }, - undefined, - collectionId, - ) - await atPersistedOracleCheckpoint( - published.promise, - `exact-shape reset published`, - ) - } - expectExactSnapshot(publicRows(), expected) - oracleAssertion('existing comparison at 2652', () => - expect(publishedSnapshots).toHaveLength(1), + } else { + coordinator.emit( + { type: `collection:reset`, schemaVersion: 1, resetEpoch: 1 }, + undefined, + collectionId, ) - for (const snapshot of publishedSnapshots) - expectExactSnapshot(snapshot, expected) - if (shape === `remove with metadata`) - oracleAssertion('existing comparison at 2656', () => - expect(source.metadata?.row.get(ordinary.id)).toStrictEqual({ - source: `durable`, - }), - ) - oracleAssertion('existing comparison at 2659', () => - expect(adapter.loadSubsetCalls).toHaveLength( - subsetReadsBeforeRevision + (route === `complete demand` ? 1 : 0), - ), + await atPersistedOracleCheckpoint( + published.promise, + `exact-shape reset published`, + ) + } + expectExactSnapshot(publicRows(), expected) + oracleAssertion('existing comparison at 2652', () => + expect(publishedSnapshots).toHaveLength(1), + ) + for (const snapshot of publishedSnapshots) + expectExactSnapshot(snapshot, expected) + if (shape === `remove with metadata`) + oracleAssertion('existing comparison at 2656', () => + expect(source.metadata?.row.get(ordinary.id)).toStrictEqual({ + source: `durable`, + }), ) - oracleAssertion('existing comparison at 2662', () => - expect(publishedOrdinaryValues.length).toBeGreaterThan(0), + oracleAssertion('existing comparison at 2659', () => + expect(adapter.loadSubsetCalls).toHaveLength( + subsetReadsBeforeRevision + (route === `complete demand` ? 1 : 0), + ), + ) + oracleAssertion('existing comparison at 2662', () => + expect(publishedOrdinaryValues.length).toBeGreaterThan(0), + ) + for (const row of publishedOrdinaryValues) + oracleAssertion('existing comparison at 2664', () => + expect(row).toStrictEqual(expectedFresh), ) - for (const row of publishedOrdinaryValues) - oracleAssertion('existing comparison at 2664', () => - expect(row).toStrictEqual(expectedFresh), - ) - oracleAssertion('existing comparison at 2665', () => - expect(publishedChanges).toContain(`delete:removed`), + oracleAssertion('existing comparison at 2665', () => + expect(publishedChanges).toContain(`delete:removed`), + ) + oracleAssertion('existing comparison at 2666', () => + expect( + publishedChanges.some((change) => change.endsWith(`:seed`)), + ).toBe(false), + ) + if (route === `complete demand`) + oracleAssertion('existing comparison at 2670', () => + expect(publishedChanges).toContain(`insert:unseen`), ) - oracleAssertion('existing comparison at 2666', () => + else + oracleAssertion('existing comparison at 2672', () => expect( - publishedChanges.some((change) => change.endsWith(`:seed`)), + publishedChanges.some((change) => change.endsWith(`:unseen`)), ).toBe(false), ) - if (route === `complete demand`) - oracleAssertion('existing comparison at 2670', () => - expect(publishedChanges).toContain(`insert:unseen`), - ) - else - oracleAssertion('existing comparison at 2672', () => - expect( - publishedChanges.some((change) => change.endsWith(`:unseen`)), - ).toBe(false), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - subscription?.unsubscribe() - if (route === `complete demand`) - collection._sync.unloadSubset(firstDemand) - await cleanupPersistedOracle( - [() => client.cleanup()], - hasPrimaryFailure, - ) - } + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + subscription?.unsubscribe() + if (route === `complete demand`) + collection._sync.unloadSubset(firstDemand) + await cleanupPersistedOracle([() => client.cleanup()], hasPrimaryFailure) + } + } + + oracleProperty({ + id: `persistence.durable-row-shape`, + law: { + statement: `A durable row published within complete demand, a retired peer notice, or a retired reset keeps its exact durable field set, as the scoped snapshot model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The enumerated route and row-shape cells over the recording adapter.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: durableRowShapes, + label: (history) => + `publishes exact durable row shape within ${history.route} (${history.shape})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runDurableRowShape(plan.history), + ), + check: forwardAssertionJudgment, + }) // Law: a small peer delta must lead to the same demand-scoped public rows as // the committed durable relation. The wire value is a partial source patch, @@ -2879,202 +2273,230 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // predicate field, `enter` omits an existing payload field, and `leave` // changes the predicate; each has a neighboring route with no active demand. // These controlled notices do not prove native SQLite sender or host delivery. - it.each( - ([`active filter`, `retired demand`] as const).flatMap((route) => - ([`stay`, `enter`, `leave`] as const).map((transition) => ({ - route, - transition, - })), - ), - )( - `publishes the durable result of a targeted partial $transition with $route`, - async ({ route, transition }) => { - const old: Todo = { - id: `ordinary`, - title: transition === `enter` ? `Other` : `Keep`, - detail: `retained field`, - } - const patch: Partial & Pick = - transition === `stay` - ? { id: old.id, detail: `new detail` } - : { id: old.id, title: transition === `enter` ? `Keep` : `Other` } - const durable: Todo = { - ...old, - ...patch, - } - const unseen: Todo = { id: `unseen`, title: `Keep` } - const adapter = createRecordingAdapter([old]) - const originalLoad = adapter.loadSubset.bind(adapter) - adapter.loadSubset = async (id, options, context) => - (await originalLoad(id, options, context)).filter( - (row) => !options.where || row.value.title === `Keep`, - ) - const coordinator = createCoordinatorHarness() - const collectionId = `targeted-partial-${route}-${transition}` - const collection = createCollection( - persistedCollectionOptions({ - id: collectionId, - syncMode: `on-demand`, - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - markReady() - return { loadSubset: () => true } - }, + const targetedPartialTransitions = ( + [`active filter`, `retired demand`] as const + ).flatMap((route) => + ([`stay`, `enter`, `leave`] as const).map((transition) => ({ + route, + transition, + })), + ) + + async function runTargetedPartialTransition({ + route, + transition, + }: (typeof targetedPartialTransitions)[number]): Promise { + const old: Todo = { + id: `ordinary`, + title: transition === `enter` ? `Other` : `Keep`, + detail: `retained field`, + } + const patch: Partial & Pick = + transition === `stay` + ? { id: old.id, detail: `new detail` } + : { id: old.id, title: transition === `enter` ? `Keep` : `Other` } + const durable: Todo = { + ...old, + ...patch, + } + const unseen: Todo = { id: `unseen`, title: `Keep` } + const adapter = createRecordingAdapter([old]) + const originalLoad = adapter.loadSubset.bind(adapter) + adapter.loadSubset = async (id, options, context) => + (await originalLoad(id, options, context)).filter( + (row) => !options.where || row.value.title === `Keep`, + ) + const coordinator = createCoordinatorHarness() + const collectionId = `targeted-partial-${route}-${transition}` + const collection = createCollection( + persistedCollectionOptions({ + id: collectionId, + syncMode: `on-demand`, + getKey: (row) => row.id, + sync: { + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => true } }, - persistence: { adapter, coordinator }, - }), + }, + persistence: { adapter, coordinator }, + }), + ) + const demand = + route === `active filter` + ? { + where: new IR.Func(`eq`, [ + new IR.PropRef([`title`]), + new IR.Value(`Keep`), + ]), + } + : {} + const published = createEventGate() + const publishedSnapshots: Array> = [] + const publishedOrdinaryValues: Array = [] + const publishedChanges: Array = [] + const publicRows = () => + new Map( + Array.from(collection.values(), (row) => [ + row.id, + stripVirtualProps(row), + ]), ) - const demand = - route === `active filter` - ? { - where: new IR.Func(`eq`, [ - new IR.PropRef([`title`]), - new IR.Value(`Keep`), - ]), - } - : {} - const published = createEventGate() - const publishedSnapshots: Array> = [] - const publishedOrdinaryValues: Array = [] - const publishedChanges: Array = [] - const publicRows = () => + let subscription: ReturnType | undefined + let hasPrimaryFailure = false + try { + await collection.stateWhenReady() + await collection._sync.loadSubset(demand) + const before = new Map( + Array.from(publicRows(), ([key, row]) => [key, structuredClone(row)]), + ) + expectExactSnapshot( + before, new Map( - Array.from(collection.values(), (row) => [ - row.id, - stripVirtualProps(row), - ]), + route === `active filter` && transition === `enter` + ? [] + : [[old.id, old]], + ), + ) + if (route === `retired demand`) collection._sync.unloadSubset(demand) + subscription = collection.subscribeChanges( + (batch) => { + publishedSnapshots.push( + new Map( + Array.from(publicRows(), ([key, row]) => [ + key, + structuredClone(row), + ]), + ), + ) + publishedChanges.push( + ...batch.map((change) => `${change.type}:${change.key}`), + ) + for (const change of batch) { + if (change.key === old.id && change.type !== `delete`) + publishedOrdinaryValues.push( + structuredClone(stripVirtualProps(change.value)), + ) + } + published.resolve() + }, + { includeInitialState: false }, + ) + const mutations: PersistedTx[`mutations`] = [ + { type: `update`, key: old.id, value: patch }, + ...(route === `retired demand` + ? [{ type: `insert` as const, key: unseen.id, value: unseen }] + : []), + ] + await adapter.applyCommittedTx( + { kind: `eager`, collectionId }, + { + txId: `targeted-partial`, + term: 1, + seq: 1, + rowVersion: 1, + mutations, + }, + ) + const authoredDurable = new Map([ + [old.id, durable], + ...(route === `retired demand` + ? ([[unseen.id, unseen]] as Array<[string, Todo]>) + : []), + ]) + expectExactSnapshot(adapter.rows, authoredDurable) + coordinator.emit( + { + type: `tx:committed`, + term: 1, + seq: 1, + txId: `targeted-partial`, + latestRowVersion: 1, + requiresFullReload: false, + changedRows: [ + { key: old.id, value: patch }, + ...(route === `retired demand` + ? [{ key: unseen.id, value: unseen }] + : []), + ], + deletedKeys: [], + }, + undefined, + collectionId, + ) + await atPersistedOracleCheckpoint( + published.promise, + `targeted partial notice published`, + ) + await flushAsyncWork() + const expected = expectedTargetedSnapshot( + before, + authoredDurable, + route === `active filter` ? `Keep` : undefined, + ) + expectExactSnapshot(publicRows(), expected) + oracleAssertion('existing comparison at 2863', () => + expect(publishedSnapshots).toHaveLength(1), + ) + for (const snapshot of publishedSnapshots) + expectExactSnapshot(snapshot, expected) + if (expected.has(old.id)) { + oracleAssertion('existing comparison at 2867', () => + expect(publishedOrdinaryValues).toHaveLength(1), ) - let subscription: - ReturnType | undefined - let hasPrimaryFailure = false - try { - await collection.stateWhenReady() - await collection._sync.loadSubset(demand) - const before = new Map( - Array.from(publicRows(), ([key, row]) => [key, structuredClone(row)]), + oracleAssertion('existing comparison at 2868', () => + expect(publishedOrdinaryValues[0]).toStrictEqual(durable), ) - expectExactSnapshot( - before, - new Map( - route === `active filter` && transition === `enter` - ? [] - : [[old.id, old]], - ), - ) - if (route === `retired demand`) collection._sync.unloadSubset(demand) - subscription = collection.subscribeChanges( - (batch) => { - publishedSnapshots.push( - new Map( - Array.from(publicRows(), ([key, row]) => [ - key, - structuredClone(row), - ]), - ), - ) - publishedChanges.push( - ...batch.map((change) => `${change.type}:${change.key}`), - ) - for (const change of batch) { - if (change.key === old.id && change.type !== `delete`) - publishedOrdinaryValues.push( - structuredClone(stripVirtualProps(change.value)), - ) - } - published.resolve() - }, - { includeInitialState: false }, - ) - const mutations: PersistedTx[`mutations`] = [ - { type: `update`, key: old.id, value: patch }, - ...(route === `retired demand` - ? [{ type: `insert` as const, key: unseen.id, value: unseen }] - : []), - ] - await adapter.applyCommittedTx( - { kind: `eager`, collectionId }, - { - txId: `targeted-partial`, - term: 1, - seq: 1, - rowVersion: 1, - mutations, - }, - ) - const authoredDurable = new Map([ - [old.id, durable], - ...(route === `retired demand` - ? ([[unseen.id, unseen]] as Array<[string, Todo]>) - : []), - ]) - expectExactSnapshot(adapter.rows, authoredDurable) - coordinator.emit( - { - type: `tx:committed`, - term: 1, - seq: 1, - txId: `targeted-partial`, - latestRowVersion: 1, - requiresFullReload: false, - changedRows: [ - { key: old.id, value: patch }, - ...(route === `retired demand` - ? [{ key: unseen.id, value: unseen }] - : []), - ], - deletedKeys: [], - }, - undefined, - collectionId, - ) - await atPersistedOracleCheckpoint( - published.promise, - `targeted partial notice published`, - ) - await flushAsyncWork() - const expected = expectedTargetedSnapshot( - before, - authoredDurable, - route === `active filter` ? `Keep` : undefined, - ) - expectExactSnapshot(publicRows(), expected) - oracleAssertion('existing comparison at 2863', () => - expect(publishedSnapshots).toHaveLength(1), - ) - for (const snapshot of publishedSnapshots) - expectExactSnapshot(snapshot, expected) - if (expected.has(old.id)) { - oracleAssertion('existing comparison at 2867', () => - expect(publishedOrdinaryValues).toHaveLength(1), - ) - oracleAssertion('existing comparison at 2868', () => - expect(publishedOrdinaryValues[0]).toStrictEqual(durable), - ) - } else { - oracleAssertion('existing comparison at 2870', () => - expect(publishedChanges).toContain(`delete:${old.id}`), - ) - } - if (route === `retired demand`) - oracleAssertion('existing comparison at 2873', () => - expect( - publishedChanges.some((change) => change.endsWith(`:unseen`)), - ).toBe(false), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - subscription?.unsubscribe() - if (route === `active filter`) collection._sync.unloadSubset(demand) - await cleanupPersistedOracle( - [() => collection.cleanup()], - hasPrimaryFailure, + } else { + oracleAssertion('existing comparison at 2870', () => + expect(publishedChanges).toContain(`delete:${old.id}`), ) } + if (route === `retired demand`) + oracleAssertion('existing comparison at 2873', () => + expect( + publishedChanges.some((change) => change.endsWith(`:unseen`)), + ).toBe(false), + ) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + subscription?.unsubscribe() + if (route === `active filter`) collection._sync.unloadSubset(demand) + await cleanupPersistedOracle( + [() => collection.cleanup()], + hasPrimaryFailure, + ) + } + } + + oracleProperty({ + id: `persistence.targeted-partial-transition`, + law: { + statement: `A targeted partial update that stays in, enters, or leaves an active filter or retired demand publishes its durable result, as the scoped snapshot model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The enumerated route and transition cells over the recording adapter.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: targetedPartialTransitions, + label: (history) => + `publishes the durable result of a targeted partial ${history.transition} with ${history.route}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runTargetedPartialTransition(plan.history), + ), + check: forwardAssertionJudgment, + }) // A peer's notice reports a source write in this run, and the last write // wins: it replaces a DbClient seed this tab holds, row and metadata @@ -3087,638 +2509,295 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // The model is the authored durable snapshot; public rows, the seed key's // metadata, and change keys are checked after the targeted commit // publishes. - it.each( - ([`active demand`, `active filter`, `retired demand`] as const).flatMap( - (route) => - ([`replacement`, `partial update`, `deletion`] as const).flatMap( - (mutation) => - ([`set`, `delete`] as const).map((metadataAction) => ({ - route, - mutation, - metadataAction, - })), - ), - ), - )( - `replaces a hydrated seed and metadata with a peer's targeted $mutation with $route ($metadataAction)`, - async ({ route, mutation, metadataAction }) => { - const ordinary = - route === `active filter` - ? { id: `ordinary`, title: `Keep`, detail: `old` } - : { id: `ordinary`, title: `Old durable` } - const ordinaryPatch = - route === `active filter` - ? { id: ordinary.id, detail: `fresh` } - : { id: ordinary.id, title: `Fresh durable` } - const durableOrdinary = { ...ordinary, ...ordinaryPatch } - const seed = { - id: `seed`, - title: route === `active filter` ? `Keep` : `Hydrated seed`, - detail: `source-owned`, - } - const storedSeed = { - id: `seed`, - title: route === `active filter` ? `Keep` : `Older durable seed`, - detail: `older`, - } - const seedMutation: PersistedTx[`mutations`][number] = - mutation === `deletion` - ? { type: `delete`, key: seed.id, value: storedSeed } - : mutation === `partial update` - ? { - type: `update`, - key: seed.id, - value: - route === `active filter` - ? { id: seed.id, title: `Other` } - : { id: seed.id, detail: `still older` }, - } - : { - type: `update`, - key: seed.id, - value: { - id: seed.id, - title: - route === `active filter` ? `Other` : `Still older durable`, - detail: `older`, - }, - } - const durableSeed: Todo | undefined = - mutation === `deletion` - ? undefined - : mutation === `partial update` - ? route === `active filter` - ? { ...storedSeed, title: `Other` } - : { ...storedSeed, detail: `still older` } - : { - ...storedSeed, - title: - route === `active filter` ? `Other` : `Still older durable`, - } - const adapter = createRecordingAdapter([ordinary, storedSeed]) - adapter.rowMetadata.set(seed.id, { owner: `older durable` }) - if (route === `active filter`) { - const originalLoad = adapter.loadSubset.bind(adapter) - adapter.loadSubset = async (id, options, context) => - (await originalLoad(id, options, context)).filter( - (row) => !options.where || row.value.title === `Keep`, - ) - } - const coordinator = createCoordinatorHarness() - const collectionId = `targeted-seed-${route}` - let sourceParams!: TodoSyncParams - const descriptor = collectionOptions( - persistedCollectionOptions({ - id: collectionId, - syncMode: `on-demand`, - getKey: (row) => row.id, - sync: { - sync: (params) => { - sourceParams = params - params.markReady() - return { loadSubset: () => true } - }, - }, - persistence: { adapter, coordinator }, - }), - ) - const client = new DbClient() - const seedMetadata = { owner: `hydrated source` } - client.hydrate({ - collections: [ - { - collectionId, - rows: [{ key: seed.id, value: seed, metadata: seedMetadata }], - }, - ], - }) - const collection = client.collection(descriptor) - const demand = - route === `active filter` - ? { - where: new IR.Func(`eq`, [ - new IR.PropRef([`title`]), - new IR.Value(`Keep`), - ]), - } - : {} - const published = createEventGate() - const publishedKeys: Array = [] - const publishedOrdinaryValues: Array = [] - const publishedSnapshots: Array> = [] - const publicRows = () => - new Map( - Array.from(collection.values(), (row) => [ - row.id, - stripVirtualProps(row), - ]), - ) - let subscription: - ReturnType | undefined - let hasPrimaryFailure = false - try { - collection.startSyncImmediate() - await collection.stateWhenReady() - await collection._sync.loadSubset(demand) - const before = new Map([ - [ordinary.id, ordinary], - [seed.id, seed], - ]) - expectExactSnapshot(publicRows(), before) - oracleAssertion('existing comparison at 3040', () => - expect(sourceParams.metadata?.row.get(seed.id)).toEqual(seedMetadata), - ) - if (route === `retired demand`) collection._sync.unloadSubset(demand) - subscription = collection.subscribeChanges( - (batch) => { - publishedSnapshots.push( - new Map( - Array.from(publicRows(), ([key, row]) => [ - key, - structuredClone(row), - ]), - ), - ) - publishedKeys.push(...batch.map((change) => String(change.key))) - for (const change of batch) { - if (change.key === ordinary.id && change.type !== `delete`) - publishedOrdinaryValues.push( - structuredClone(stripVirtualProps(change.value)), - ) - } - published.resolve() - }, - { includeInitialState: false }, - ) - await adapter.applyCommittedTx( - { kind: `eager`, collectionId }, - { - txId: `targeted-seed`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [ - { - type: `update`, - key: ordinary.id, - value: ordinaryPatch, - }, - seedMutation, - ], - rowMetadataMutations: [ - metadataAction === `set` - ? { - type: `set`, - key: seed.id, - value: { owner: `peer durable` }, - } - : { type: `delete`, key: seed.id }, - ], - }, - ) - const authoredDurable = new Map([ - [ordinary.id, durableOrdinary], - ...(durableSeed - ? ([[seed.id, durableSeed]] as Array<[string, Todo]>) - : []), - ]) - expectExactSnapshot(adapter.rows, authoredDurable) - coordinator.emit( - { - type: `tx:committed`, - term: 1, - seq: 1, - txId: `targeted-seed`, - latestRowVersion: 1, - requiresFullReload: false, - changedRows: [ - { key: ordinary.id, value: ordinaryPatch }, - ...(seedMutation.type === `delete` - ? [] - : [{ key: seed.id, value: seedMutation.value }]), - ], - deletedKeys: seedMutation.type === `delete` ? [seed.id] : [], - rowMetadataMutations: [ - metadataAction === `set` - ? { - type: `set`, - key: seed.id, - value: { owner: `peer durable` }, - } - : { type: `delete`, key: seed.id }, - ], - }, - undefined, - collectionId, - ) - await atPersistedOracleCheckpoint( - published.promise, - `targeted seed notice published`, - ) - await flushAsyncWork() - const expected = - route === `active filter` - ? expectedTargetedSnapshot(before, authoredDurable, `Keep`) - : expectedScopedSnapshot( - before, - authoredDurable, - new Map(), - route === `active demand`, - ) - expectExactSnapshot(publicRows(), expected) - // Row metadata lives with a public row, so a seed key that the peer - // deleted or moved out of demand keeps none. - oracleAssertion('existing comparison at 3141', () => - expect(sourceParams.metadata?.row.get(seed.id)).toEqual( - metadataAction === `set` && expected.has(seed.id) - ? { owner: `peer durable` } - : undefined, - ), - ) - oracleAssertion('existing comparison at 3146', () => - expect(publishedSnapshots).toHaveLength(1), - ) - for (const snapshot of publishedSnapshots) - expectExactSnapshot(snapshot, expected) - oracleAssertion('existing comparison at 3149', () => - expect(publishedOrdinaryValues).toEqual([durableOrdinary]), - ) - oracleAssertion('existing comparison at 3150', () => - expect(publishedKeys).toContain(ordinary.id), - ) - oracleAssertion('existing comparison at 3151', () => - expect(publishedKeys).toContain(seed.id), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - subscription?.unsubscribe() - if (route !== `retired demand`) collection._sync.unloadSubset(demand) - await cleanupPersistedOracle( - [() => client.cleanup()], - hasPrimaryFailure, - ) - } - }, - ) - - // A source transaction can write the same key more than once. The durable - // store folds operations in order, but the targeted notice separates all - // changed rows from deleted keys. Delete -> update and update -> delete have - // identical wire arrays and opposite durable outcomes, so a receiver cannot - // reconstruct either result from that envelope alone. The independent case - // table authors the final durable relation; the recording adapter checks - // that each operation sequence reaches it before the notice is delivered. - // A same-key insert also shares the targeted changedRows shape with an - // update, yet replaces rather than merges the durable row. Both active - // complete and retired demand compare the public row at each callback and - // after the next task turn. Two updates are a neighboring control that - // retains the old field omitted by each patch. - it.each( - ([`active demand`, `retired demand`] as const).flatMap((route) => - ( - [ - `delete then update`, - `update then delete`, - `two updates`, - `insert replacement`, - ] as const - ).map((sequence) => ({ route, sequence })), + const targetedSeedReplacements = ( + [`active demand`, `active filter`, `retired demand`] as const + ).flatMap((route) => + ([`replacement`, `partial update`, `deletion`] as const).flatMap( + (mutation) => + ([`set`, `delete`] as const).map((metadataAction) => ({ + route, + mutation, + metadataAction, + })), ), - )( - `follows durable same-key order for targeted $sequence with $route`, - async ({ route, sequence }) => { - const old = { id: `row`, title: `Old`, detail: `old detail` } - const fresh = { id: old.id, title: `Fresh` } - const detailPatch = { id: old.id, detail: `new detail` } - const titlePatch = { id: old.id, title: `Fresh` } - const mutations: PersistedTx[`mutations`] = - sequence === `delete then update` - ? [ - { type: `delete`, key: old.id, value: old }, - { type: `update`, key: old.id, value: fresh }, - ] - : sequence === `update then delete` - ? [ - { type: `update`, key: old.id, value: fresh }, - { type: `delete`, key: old.id, value: old }, - ] - : sequence === `insert replacement` - ? [{ type: `insert`, key: old.id, value: fresh }] - : [ - { type: `update`, key: old.id, value: detailPatch }, - { type: `update`, key: old.id, value: titlePatch }, - ] - const authoredDurable = new Map( - sequence === `update then delete` - ? [] - : sequence === `delete then update` || - sequence === `insert replacement` - ? [[old.id, fresh]] - : [[old.id, { ...old, ...detailPatch, ...titlePatch }]], - ) - const adapter = createRecordingAdapter([old]) - const coordinator = createCoordinatorHarness() - const collectionId = `targeted-order-${route}-${sequence}` - const collection = createCollection( - persistedCollectionOptions({ - id: collectionId, - syncMode: `on-demand`, - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - markReady() - return { loadSubset: () => true } - }, - }, - persistence: { adapter, coordinator }, - }), - ) - const demand = {} - const published = createEventGate() - const publishedSnapshots: Array> = [] - const publicRows = () => - new Map( - Array.from(collection.values(), (row) => [ - row.id, - stripVirtualProps(row), - ]), - ) - let subscription: - ReturnType | undefined - let hasPrimaryFailure = false - try { - await collection.stateWhenReady() - await collection._sync.loadSubset(demand) - expectExactSnapshot(publicRows(), new Map([[old.id, old]])) - if (route === `retired demand`) collection._sync.unloadSubset(demand) - subscription = collection.subscribeChanges( - () => { - publishedSnapshots.push( - new Map( - Array.from(publicRows(), ([key, row]) => [ - key, - structuredClone(row), - ]), - ), - ) - published.resolve() - }, - { includeInitialState: false }, - ) - await adapter.applyCommittedTx( - { kind: `eager`, collectionId }, - { - txId: `targeted-order`, - term: 1, - seq: 1, - rowVersion: 1, - mutations, - }, - ) - expectExactSnapshot(adapter.rows, authoredDurable) - const changedRows = - sequence === `two updates` - ? [ - { key: old.id, value: detailPatch }, - { key: old.id, value: titlePatch }, - ] - : [{ key: old.id, value: fresh }] - coordinator.emit( - { - type: `tx:committed`, - term: 1, - seq: 1, - txId: `targeted-order`, - latestRowVersion: 1, - requiresFullReload: false, - changedRows, - deletedKeys: - sequence === `two updates` || sequence === `insert replacement` - ? [] - : [old.id], - }, - undefined, - collectionId, - ) - await atPersistedOracleCheckpoint( - published.promise, - `targeted same-key notice published`, - ) - await flushAsyncWork() - const expected = expectedScopedSnapshot( - new Map([[old.id, old]]), - authoredDurable, - new Map(), - route === `active demand`, - ) - expectExactSnapshot(publicRows(), expected) - oracleAssertion('existing comparison at 3318', () => - expect(publishedSnapshots).toHaveLength(1), - ) - for (const snapshot of publishedSnapshots) - expectExactSnapshot(snapshot, expected) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - subscription?.unsubscribe() - if (route === `active demand`) collection._sync.unloadSubset(demand) - await cleanupPersistedOracle( - [() => collection.cleanup()], - hasPrimaryFailure, - ) - } - }, ) - type RetainedDemandOperation = - | { type: `acquire` | `release`; demand: `one` | `two` } - | { type: `truncate` } - // The generated prefix varies demand ownership and reset history. Its - // terminal suffix reaches a retained hydrated demand, fails a source write, - // then checks the exact error and absence of a new upstream acquisition. - // The pre-fix fast path returned true and incremented upstreamLoads here. - // This recording adapter does not establish browser/OPFS behavior. - const retainedDemandHistory = fc - .array( - fc.record({ - type: fc.constantFrom(`acquire` as const, `release` as const), - demand: fc.constantFrom(`one` as const, `two` as const), - }), - { minLength: 1, maxLength: 20 }, - ) - .chain((operations) => - fc - .array(fc.integer({ min: 0, max: operations.length }), { - maxLength: 4, - }) - .map((truncatePositions): Array => { - const positions = new Set(truncatePositions) - return operations.flatMap((operation, index) => - positions.has(index) - ? [{ type: `truncate` as const }, operation] - : [operation], - ) - }), - ) - const runRetainedDemandHistory = async ( - history: ReadonlyArray, - ): Promise => { - const adapter = createRecordingAdapter([ - { id: `1`, title: `one` }, - { id: `2`, title: `two` }, - ]) - const loadRows = adapter.loadSubset - adapter.loadSubset = async (...args) => { - const rows = await loadRows(...args) - return rows.slice(0, args[1].limit) + async function runTargetedSeedReplacement({ + route, + mutation, + metadataAction, + }: (typeof targetedSeedReplacements)[number]): Promise { + const ordinary = + route === `active filter` + ? { id: `ordinary`, title: `Keep`, detail: `old` } + : { id: `ordinary`, title: `Old durable` } + const ordinaryPatch = + route === `active filter` + ? { id: ordinary.id, detail: `fresh` } + : { id: ordinary.id, title: `Fresh durable` } + const durableOrdinary = { ...ordinary, ...ordinaryPatch } + const seed = { + id: `seed`, + title: route === `active filter` ? `Keep` : `Hydrated seed`, + detail: `source-owned`, } - let truncateSource: (() => void) | undefined - let failSource: (() => true | Promise) | undefined - let upstreamLoads = 0 - const collection = createCollection( - persistedCollectionOptions({ - id: `hydrated-demand-history`, - getKey: (row) => row.id, - syncMode: `on-demand`, - sync: { - sync: ({ begin, write, truncate, commit, markReady }) => { - truncateSource = () => { - begin() - truncate() - commit() - } - failSource = () => { - begin() - write({ - type: `insert`, - value: { id: `terminal-write`, title: `must not persist` }, - }) - return commit() + const storedSeed = { + id: `seed`, + title: route === `active filter` ? `Keep` : `Older durable seed`, + detail: `older`, + } + const seedMutation: PersistedTx[`mutations`][number] = + mutation === `deletion` + ? { type: `delete`, key: seed.id, value: storedSeed } + : mutation === `partial update` + ? { + type: `update`, + key: seed.id, + value: + route === `active filter` + ? { id: seed.id, title: `Other` } + : { id: seed.id, detail: `still older` }, } - markReady() - return { - loadSubset: () => { - upstreamLoads++ - return true + : { + type: `update`, + key: seed.id, + value: { + id: seed.id, + title: + route === `active filter` ? `Other` : `Still older durable`, + detail: `older`, }, } + const durableSeed: Todo | undefined = + mutation === `deletion` + ? undefined + : mutation === `partial update` + ? route === `active filter` + ? { ...storedSeed, title: `Other` } + : { ...storedSeed, detail: `still older` } + : { + ...storedSeed, + title: + route === `active filter` ? `Other` : `Still older durable`, + } + const adapter = createRecordingAdapter([ordinary, storedSeed]) + adapter.rowMetadata.set(seed.id, { owner: `older durable` }) + if (route === `active filter`) { + const originalLoad = adapter.loadSubset.bind(adapter) + adapter.loadSubset = async (id, options, context) => + (await originalLoad(id, options, context)).filter( + (row) => !options.where || row.value.title === `Keep`, + ) + } + const coordinator = createCoordinatorHarness() + const collectionId = `targeted-seed-${route}` + let sourceParams!: TodoSyncParams + const descriptor = collectionOptions( + persistedCollectionOptions({ + id: collectionId, + syncMode: `on-demand`, + getKey: (row) => row.id, + sync: { + sync: (params) => { + sourceParams = params + params.markReady() + return { loadSubset: () => true } }, }, - persistence: { adapter }, + persistence: { adapter, coordinator }, }), ) - collection.startSyncImmediate() - for (let attempt = 0; attempt < 20 && !truncateSource; attempt++) { - await flushAsyncWork() - } - oracleAssertion('existing comparison at 3416', () => - expect(truncateSource).toBeTypeOf(`function`), - ) - - const active: Record<`one` | `two`, Array> = { - one: [], - two: [], - } - const hydrated = new Set<`one` | `two`>() - const optionsFor = (demand: `one` | `two`): LoadSubsetOptions => ({ - limit: demand === `one` ? 1 : 2, + const client = new DbClient() + const seedMetadata = { owner: `hydrated source` } + client.hydrate({ + collections: [ + { + collectionId, + rows: [{ key: seed.id, value: seed, metadata: seedMetadata }], + }, + ], }) - + const collection = client.collection(descriptor) + const demand = + route === `active filter` + ? { + where: new IR.Func(`eq`, [ + new IR.PropRef([`title`]), + new IR.Value(`Keep`), + ]), + } + : {} + const published = createEventGate() + const publishedKeys: Array = [] + const publishedOrdinaryValues: Array = [] + const publishedSnapshots: Array> = [] + const publicRows = () => + new Map( + Array.from(collection.values(), (row) => [ + row.id, + stripVirtualProps(row), + ]), + ) + let subscription: ReturnType | undefined + let hasPrimaryFailure = false try { - for (const operation of history) { - if (operation.type === `truncate`) { - truncateSource?.() - hydrated.clear() - continue - } - - const demand = operation.demand - if (operation.type === `release`) { - const options = active[demand].pop() - if (!options) continue - collection._sync.unloadSubset(options) - if (active[demand].length === 0) hydrated.delete(demand) - continue - } - - const options = optionsFor(demand) - const result = collection._sync.loadSubset(options) - oracleAssertion('existing comparison at 3446', () => - expect(result === true).toBe(hydrated.has(demand)), - ) - if (result !== true) await result - active[demand].push(options) - hydrated.add(demand) - } - - // A terminal durability failure is absorbing even when a retained - // demand still has hydrated rows. No later acquisition owns a new - // upstream lease or receives synchronous readiness. - const retained = optionsFor(`one`) - const retainedResult = collection._sync.loadSubset(retained) - if (retainedResult !== true) await retainedResult - const adapterFailure = new Error(`retained demand durability failed`) - adapter.applyCommittedTx = () => Promise.reject(adapterFailure) - const failedReceipt = failSource?.() - if (!(failedReceipt instanceof Promise)) { - throw new Error(`expected a failing source receipt`) - } - const receiptOutcome = await failedReceipt.then( - () => ({ status: `fulfilled` as const }), - (reason: unknown) => ({ status: `rejected` as const, reason }), + collection.startSyncImmediate() + await collection.stateWhenReady() + await collection._sync.loadSubset(demand) + const before = new Map([ + [ordinary.id, ordinary], + [seed.id, seed], + ]) + expectExactSnapshot(publicRows(), before) + oracleAssertion('existing comparison at 3040', () => + expect(sourceParams.metadata?.row.get(seed.id)).toEqual(seedMetadata), ) - const terminalError = collection._lifecycle.getSyncError() - oracleAssertion('existing comparison at 3469', () => - expect(receiptOutcome).toEqual({ - status: `rejected`, - reason: terminalError, - }), + if (route === `retired demand`) collection._sync.unloadSubset(demand) + subscription = collection.subscribeChanges( + (batch) => { + publishedSnapshots.push( + new Map( + Array.from(publicRows(), ([key, row]) => [ + key, + structuredClone(row), + ]), + ), + ) + publishedKeys.push(...batch.map((change) => String(change.key))) + for (const change of batch) { + if (change.key === ordinary.id && change.type !== `delete`) + publishedOrdinaryValues.push( + structuredClone(stripVirtualProps(change.value)), + ) + } + published.resolve() + }, + { includeInitialState: false }, ) - oracleAssertion('existing comparison at 3473', () => - expect(terminalError).toMatchObject({ - name: `PersistedCollectionDurabilityError`, - cause: adapterFailure, - }), + await adapter.applyCommittedTx( + { kind: `eager`, collectionId }, + { + txId: `targeted-seed`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [ + { + type: `update`, + key: ordinary.id, + value: ordinaryPatch, + }, + seedMutation, + ], + rowMetadataMutations: [ + metadataAction === `set` + ? { + type: `set`, + key: seed.id, + value: { owner: `peer durable` }, + } + : { type: `delete`, key: seed.id }, + ], + }, ) - - const loadsBeforeRetry = upstreamLoads - let retryFailure: unknown - try { - await collection._sync.loadSubset(optionsFor(`one`)) - } catch (error) { - retryFailure = error - } - oracleAssertion('existing comparison at 3485', () => - expect({ retryFailure, upstreamLoads }).toEqual({ - retryFailure: terminalError, - upstreamLoads: loadsBeforeRetry, - }), + const authoredDurable = new Map([ + [ordinary.id, durableOrdinary], + ...(durableSeed + ? ([[seed.id, durableSeed]] as Array<[string, Todo]>) + : []), + ]) + expectExactSnapshot(adapter.rows, authoredDurable) + coordinator.emit( + { + type: `tx:committed`, + term: 1, + seq: 1, + txId: `targeted-seed`, + latestRowVersion: 1, + requiresFullReload: false, + changedRows: [ + { key: ordinary.id, value: ordinaryPatch }, + ...(seedMutation.type === `delete` + ? [] + : [{ key: seed.id, value: seedMutation.value }]), + ], + deletedKeys: seedMutation.type === `delete` ? [seed.id] : [], + rowMetadataMutations: [ + metadataAction === `set` + ? { + type: `set`, + key: seed.id, + value: { owner: `peer durable` }, + } + : { type: `delete`, key: seed.id }, + ], + }, + undefined, + collectionId, + ) + await atPersistedOracleCheckpoint( + published.promise, + `targeted seed notice published`, + ) + await flushAsyncWork() + const expected = + route === `active filter` + ? expectedTargetedSnapshot(before, authoredDurable, `Keep`) + : expectedScopedSnapshot( + before, + authoredDurable, + new Map(), + route === `active demand`, + ) + expectExactSnapshot(publicRows(), expected) + // Row metadata lives with a public row, so a seed key that the peer + // deleted or moved out of demand keeps none. + oracleAssertion('existing comparison at 3141', () => + expect(sourceParams.metadata?.row.get(seed.id)).toEqual( + metadataAction === `set` && expected.has(seed.id) + ? { owner: `peer durable` } + : undefined, + ), + ) + oracleAssertion('existing comparison at 3146', () => + expect(publishedSnapshots).toHaveLength(1), ) + for (const snapshot of publishedSnapshots) + expectExactSnapshot(snapshot, expected) + oracleAssertion('existing comparison at 3149', () => + expect(publishedOrdinaryValues).toEqual([durableOrdinary]), + ) + oracleAssertion('existing comparison at 3150', () => + expect(publishedKeys).toContain(ordinary.id), + ) + oracleAssertion('existing comparison at 3151', () => + expect(publishedKeys).toContain(seed.id), + ) + } catch (error) { + hasPrimaryFailure = true + throw error } finally { - await collection.cleanup() + subscription?.unsubscribe() + if (route !== `retired demand`) collection._sync.unloadSubset(demand) + await cleanupPersistedOracle([() => client.cleanup()], hasPrimaryFailure) } } oracleProperty({ - id: `persistence.retained-demand`, + id: `persistence.targeted-seed-replacement`, law: { - statement: `The retained independent persistence helper judges its existing legal histories at every original observation checkpoint.`, - authority: `The persistence contracts and individual law stated beside the independent helper.`, - limits: `The existing bounded recording-adapter histories; no additional provider, native host, schedule, or gap-closure claim.`, + statement: `A peer's targeted replacement, partial update, or deletion replaces a hydrated seed and its metadata within active demand, an active filter, or retired demand, as the targeted snapshot model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The enumerated route, mutation, and metadata-action cells over the recording adapter.`, }, owns: [ `src/persisted.ts`, `src/sqlite-core-adapter.ts`, `src/remote-subset-owner.ts`, ], - histories: { - arbitrary: retainedDemandHistory, - runs: 50, - fixedSeeds: [186001], - }, + histories: targetedSeedReplacements, + label: (history) => + `replaces a hydrated seed and metadata with a peer's targeted ${history.mutation} with ${history.route} (${history.metadataAction})`, resolve: (history) => ({ history }), model: recordedAssertionJudgment, driver: (plan, observe) => @@ -3726,361 +2805,174 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { `existing persistence judgments`, plan, observe, - () => runRetainedDemandHistory(plan.history), + () => runTargetedSeedReplacement(plan.history), ), check: forwardAssertionJudgment, }) - /** - * A follower has no local source. It routes demand to the elected owner, - * and the owner's commits reach it only as durable rows plus a commit - * notice. After a cache rotation the follower is in scoped recovery, where - * durable rows cannot replace source rows. Rotation gives the follower a - * fresh cache generation, and the follower asks the owner to reacquire - * every active demand into it. Once those reacquisitions settle, the new - * generation holds exactly the owner's rows for that demand, so durable - * rows are authoritative again. The law is therefore: while the - * reacquisitions are in flight, the follower holds the generation's commit - * notices; when they settle, scoped recovery ends for that generation and - * the held notices, and every later one, apply through the normal path. - * No sender is special: a peer's notice after recovery is an ordinary - * invalidation that rereads durable rows. - * - * The independent model is `expectedScopedSnapshot` with complete demand - * over the authored durable rows of the current generation. It knows - * nothing about notices or scoped recovery; the driver applies it only at - * checkpoints where the owner's evidence has arrived. The ensure log is a - * separate observation: one acquisition and one reacquisition after - * rotation, and no other ensure. A bounded leader that re-commits on every - * ensure makes the alternative design (answer every notice during scoped - * recovery with a refetch) loop, and the bound turns that loop into an - * assertion failure. - * - * The grammar crosses when the owner first commits the row (before or - * after the rotation) with a later broadcast for the same key: none, an - * owner update, or a peer notice that claims a different value. An owner - * update's durable read either succeeds or fails. Recovery has ended, so a - * failed read of it is a real failure: the follower becomes terminal and - * keeps the rows it showed, instead of silently ignoring the failure as if - * a new recovery had started during the read. - * The - * observations are the follower's rows when its first load settles, after - * the rotation's reacquisition, and after the later broadcast, plus the - * ensure log at quiescence. The coordinator is a controlled double of an - * elected owner; it does not prove the browser transport. - * - * 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` - later: `none` | `owner` | `peer` - // Applies only to a later owner update. - ownerRead: `ok` | `fails` - // Control: an owner notice's read is in flight when rotation starts, and - // then fails. Recovery owns that read, so its failure is ignored. - readFailsAsRotationStarts?: boolean - // How the owner's notices in the rotated generation reach the follower: - // in order, after a gap in the stream position (the follower's position - // restarts at zero when it rotates), or as an exact-ID reconciliation - // reload. Each must publish the same rows once recovery ends. - noticeShape?: `plain` | `gap` | `reconciliation` - } - const assertFollowerGenerationNoticeHistory = async ({ - ownerCommit, - later, - ownerRead, - readFailsAsRotationStarts = false, - noticeShape = `plain`, - }: FollowerGenerationNoticeHistory): Promise => { - const readFailure = new Error(`owner update read failed`) - let failNextRead = false - let holdNextRead: - { promise: Promise; reject: (e: unknown) => void } | undefined - const oldStorageId = `follower-notice-old` - const newStorageId = `follower-notice-new` - const ownerId = `elected-owner` - const v1: Todo = { id: `remote`, title: `Owner row` } - const v2: Todo = { id: `remote`, title: `Owner update` } - const durable = new Map>([ - [oldStorageId, new Map()], - [newStorageId, new Map()], - ]) - let storageId = oldStorageId - // The owner's upstream row: present from the start, or only once the - // owner fetched it after the rotation began. - let upstream: Todo | undefined = - ownerCommit === `before-rotation` ? v1 : undefined - const ensures: Array<{ id: string; refetch: boolean }> = [] - const subscribers = new Map< - string, - (message: ProtocolEnvelope) => void - >() - const versions = new Map() - // Enough commits for every legal history; a refetch loop exceeds it. - const commitBudget = 8 - let commits = 0 - const notify = (id: string, row: Todo, senderId: string) => { - // A gap skips one stream position in the rotated generation. - const skip = - noticeShape === `gap` && id === newStorageId && !versions.has(id) - ? 1 - : 0 - const version = (versions.get(id) ?? 0) + 1 + skip - versions.set(id, version) - const reconciliation = - noticeShape === `reconciliation` && id === newStorageId - subscribers.get(id)?.({ - v: 1, - dbName: `test-db`, - collectionId: id, - senderId, - ts: Date.now(), - payload: { - type: `tx:committed`, - term: 1, - seq: version, - txId: `${senderId}-${id}-${version}`, - latestRowVersion: version, - requiresFullReload: reconciliation, - ...(reconciliation ? { reconciliationReload: true } : {}), - changedRows: [{ key: row.id, value: { ...row } }], - deletedKeys: [], - } satisfies TxCommitted, - }) - } - const ownerCommits = (id: string, row: Todo) => { - if (commits++ >= commitBudget) return - durable.get(id)!.set(row.id, structuredClone(row)) - notify(id, row, ownerId) - } - const adapter: PersistenceAdapter = { - loadSubset: async (target) => { - const hold = holdNextRead - if (hold) { - holdNextRead = undefined - await hold.promise - } - if (failNextRead) { - failNextRead = false - throw readFailure - } - return Array.from( - durable.get(recordedStorageId(target))!.values(), - (value) => ({ - key: value.id, - value: structuredClone(value), - }), - ) - }, - claimCacheGeneration: async () => ({ - storageCollectionId: oldStorageId, - claimId: `follower-notice-claim`, - expiresAtMs: Date.now() + 60_000, - }), - renewCacheGenerationClaim: async () => Date.now() + 60_000, - rotateCacheGeneration: async () => { - storageId = newStorageId - if (ownerCommit === `after-rotation`) upstream = v1 - return { - storageCollectionId: newStorageId, - claimId: `follower-notice-claim`, - expiresAtMs: Date.now() + 60_000, - } - }, - releaseCacheGenerationClaim: async () => {}, - loadResumeSnapshot: async () => ({ - rows: [], - keySet: { status: `consistent` }, - collectionMetadata: [], - latestTerm: 0, - latestSeq: 0, - latestRowVersion: 0, - resetEpoch: 0, - }), - applyCommittedTx: async () => {}, - ensureIndex: async () => {}, - } - const coordinator: PersistedCollectionCoordinator = { - getNodeId: () => `follower-node`, - subscribe: (id, onMessage) => { - subscribers.set(id, onMessage) - return () => { - if (subscribers.get(id) === onMessage) subscribers.delete(id) - } - }, - publish: () => {}, - isLeader: () => false, - ensureLeadership: async () => {}, - requestEnsurePersistedIndex: async () => {}, - // The elected owner loads its upstream and commits it into the - // requested generation before it replies, on every ensure. - requestEnsureRemoteSubset: async (id, options) => { - ensures.push({ id, refetch: options.refetch === true }) - if (upstream) ownerCommits(id, upstream) - }, - requestReleaseRemoteSubset: async () => {}, - registerRemoteSubsetOwner: () => () => {}, - requestApplyCommittedTx: (_id, tx) => - Promise.resolve({ - type: `rpc:applyCommittedTx:res`, - rpcId: tx.txId, - ok: true, - term: tx.term, - seq: tx.seq, - latestRowVersion: tx.rowVersion, - }), - pullSince: () => - Promise.resolve({ - type: `rpc:pullSince:res`, - rpcId: `follower-notice-pull`, - ok: true, - latestTerm: 1, - latestSeq: 0, - latestRowVersion: 0, - requiresFullReload: false, - changedKeys: [], - deletedKeys: [], - }), - } - let source!: TodoSyncParams + // A source transaction can write the same key more than once. The durable + // store folds operations in order, but the targeted notice separates all + // changed rows from deleted keys. Delete -> update and update -> delete have + // identical wire arrays and opposite durable outcomes, so a receiver cannot + // reconstruct either result from that envelope alone. The independent case + // table authors the final durable relation; the recording adapter checks + // that each operation sequence reaches it before the notice is delivered. + // A same-key insert also shares the targeted changedRows shape with an + // update, yet replaces rather than merges the durable row. Both active + // complete and retired demand compare the public row at each callback and + // after the next task turn. Two updates are a neighboring control that + // retains the old field omitted by each patch. + const targetedSameKeySequences = ( + [`active demand`, `retired demand`] as const + ).flatMap((route) => + ( + [ + `delete then update`, + `update then delete`, + `two updates`, + `insert replacement`, + ] as const + ).map((sequence) => ({ route, sequence })), + ) + + async function runTargetedSameKeyOrder({ + route, + sequence, + }: (typeof targetedSameKeySequences)[number]): Promise { + const old = { id: `row`, title: `Old`, detail: `old detail` } + const fresh = { id: old.id, title: `Fresh` } + const detailPatch = { id: old.id, detail: `new detail` } + const titlePatch = { id: old.id, title: `Fresh` } + const mutations: PersistedTx[`mutations`] = + sequence === `delete then update` + ? [ + { type: `delete`, key: old.id, value: old }, + { type: `update`, key: old.id, value: fresh }, + ] + : sequence === `update then delete` + ? [ + { type: `update`, key: old.id, value: fresh }, + { type: `delete`, key: old.id, value: old }, + ] + : sequence === `insert replacement` + ? [{ type: `insert`, key: old.id, value: fresh }] + : [ + { type: `update`, key: old.id, value: detailPatch }, + { type: `update`, key: old.id, value: titlePatch }, + ] + const authoredDurable = new Map( + sequence === `update then delete` + ? [] + : sequence === `delete then update` || sequence === `insert replacement` + ? [[old.id, fresh]] + : [[old.id, { ...old, ...detailPatch, ...titlePatch }]], + ) + const adapter = createRecordingAdapter([old]) + const coordinator = createCoordinatorHarness() + const collectionId = `targeted-order-${route}-${sequence}` const collection = createCollection( persistedCollectionOptions({ - id: `follower-generation-notice`, + id: collectionId, syncMode: `on-demand`, getKey: (row) => row.id, sync: { - sync: (params) => { - source = params - params.markReady() - return { - loadSubset: async () => {}, - restartAfterScopedRecovery: async (cacheRotated) => { - await cacheRotated - }, - } + sync: ({ markReady }) => { + markReady() + return { loadSubset: () => true } }, }, - persistence: { adapter, coordinator }, - }), - ) - const demand: LoadSubsetOptions = { limit: 10 } - const publicRows = () => - new Map( - Array.from(collection.state, ([key, row]) => [ - String(key), - stripVirtualProps(row) as Todo, - ]), + persistence: { adapter, coordinator }, + }), + ) + const demand = {} + const published = createEventGate() + const publishedSnapshots: Array> = [] + const publicRows = () => + new Map( + Array.from(collection.values(), (row) => [ + row.id, + stripVirtualProps(row), + ]), + ) + let subscription: ReturnType | undefined + let hasPrimaryFailure = false + try { + await collection.stateWhenReady() + await collection._sync.loadSubset(demand) + expectExactSnapshot(publicRows(), new Map([[old.id, old]])) + if (route === `retired demand`) collection._sync.unloadSubset(demand) + subscription = collection.subscribeChanges( + () => { + publishedSnapshots.push( + new Map( + Array.from(publicRows(), ([key, row]) => [ + key, + structuredClone(row), + ]), + ), + ) + published.resolve() + }, + { includeInitialState: false }, + ) + await adapter.applyCommittedTx( + { kind: `eager`, collectionId }, + { + txId: `targeted-order`, + term: 1, + seq: 1, + rowVersion: 1, + mutations, + }, + ) + expectExactSnapshot(adapter.rows, authoredDurable) + const changedRows = + sequence === `two updates` + ? [ + { key: old.id, value: detailPatch }, + { key: old.id, value: titlePatch }, + ] + : [{ key: old.id, value: fresh }] + coordinator.emit( + { + type: `tx:committed`, + term: 1, + seq: 1, + txId: `targeted-order`, + latestRowVersion: 1, + requiresFullReload: false, + changedRows, + deletedKeys: + sequence === `two updates` || sequence === `insert replacement` + ? [] + : [old.id], + }, + undefined, + collectionId, ) - // A checkpoint after the follower has processed every delivered notice: - // the scan crosses the same apply mutex the notices use. - const settled = async (label: string) => { - await flushAsyncWork() await atPersistedOracleCheckpoint( - source.metadata!.persistence!.scanPersistedRows(), - label, + published.promise, + `targeted same-key notice published`, ) await flushAsyncWork() - } - const expectOwnerEvidence = (previous: ReadonlyMap) => { const expected = expectedScopedSnapshot( - previous, - durable.get(storageId)!, + new Map([[old.id, old]]), + authoredDurable, new Map(), - true, + route === `active demand`, ) expectExactSnapshot(publicRows(), expected) - return expected - } - let hasPrimaryFailure = false - try { - collection.startSyncImmediate() - await collection.stateWhenReady() - await atPersistedOracleCheckpoint( - Promise.resolve(collection._sync.loadSubset(demand)), - `follower demand settled`, - ) - await settled(`first-ready notices processed`) - let visible = expectOwnerEvidence(new Map()) - - const persistence = source.metadata?.persistence - if (!persistence?.startScopedRecovery) { - throw new Error(`Expected scoped recovery capability`) - } - let rotation: Promise - if (readFailsAsRotationStarts) { - let reject!: (error: unknown) => void - const promise = new Promise((_resolve, rejectRead) => { - reject = rejectRead - }) - void promise.catch(() => undefined) - holdNextRead = { promise, reject } - ownerCommits(oldStorageId, { - id: `remote`, - title: `Pre-rotation update`, - }) - await flushAsyncWork() - rotation = persistence.startScopedRecovery() - reject(readFailure) - } else { - rotation = persistence.startScopedRecovery() - } - await atPersistedOracleCheckpoint( - rotation, - `follower cache rotation settled`, - ) - await atPersistedOracleCheckpoint( - vi.waitFor(() => - oracleAssertion('existing comparison at 3796', () => - expect(ensures.map(({ id }) => id)).toContain(newStorageId), - ), - ), - `reacquisition reached the owner`, - ) - await settled(`reacquisition notices processed`) - // Answering the owner's notice with another refetch would make the - // owner commit again, and the follower would refetch forever. - oracleAssertion('existing comparison at 3803', () => - expect.soft(ensures).toHaveLength(2), - ) - visible = expectOwnerEvidence(visible) - - if (later === `owner`) { - upstream = v2 - failNextRead = ownerRead === `fails` - ownerCommits(newStorageId, v2) - if (ownerRead === `fails`) { - // Not settled(): a terminal runtime rejects the checkpoint scan. - await flushAsyncWork() - await vi.waitFor(() => - oracleAssertion('existing comparison at 3813', () => - expect(collection.status).toBe(`crashed`), - ), - ) - expectExactSnapshot(publicRows(), visible) - } else { - await settled(`owner update processed`) - expectOwnerEvidence(visible) - } - } else if (later === `peer`) { - // A peer's notice after recovery is an ordinary invalidation: the - // follower rereads durable rows, which the peer did not change, so - // its rows stay as they were and it sends no other ensure. - notify(newStorageId, { id: `remote`, title: `Peer claim` }, `peer-node`) - await settled(`peer notice processed`) - expectOwnerEvidence(visible) - } - - oracleAssertion('existing comparison at 3828', () => - expect(ensures).toEqual([ - { id: oldStorageId, refetch: false }, - { id: newStorageId, refetch: true }, - ]), + oracleAssertion('existing comparison at 3318', () => + expect(publishedSnapshots).toHaveLength(1), ) + for (const snapshot of publishedSnapshots) + expectExactSnapshot(snapshot, expected) } catch (error) { hasPrimaryFailure = true throw error } finally { + subscription?.unsubscribe() + if (route === `active demand`) collection._sync.unloadSubset(demand) await cleanupPersistedOracle( [() => collection.cleanup()], hasPrimaryFailure, @@ -4088,798 +2980,315 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { } } - // 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` }, - { ownerCommit: `after-rotation`, later: `none` }, - { ownerCommit: `after-rotation`, later: `owner` }, - { ownerCommit: `before-rotation`, later: `peer` }, - ] as const)( - `publishes an elected owner's rows to a follower after rotation: commit $ownerCommit, later $later`, - (history) => { - return assertFollowerGenerationNoticeHistory({ - ...history, - ownerRead: `ok`, - }) + oracleProperty({ + id: `persistence.targeted-same-key-order`, + law: { + statement: `Targeted same-key sequences publish in durable order within active or retired demand.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The enumerated route and same-key sequence cells over the recording adapter.`, }, - ) - - // 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) => - ([`before-rotation`, `after-rotation`] as const).flatMap((ownerCommit) => - ([`none`, `owner`] as const).map((later) => ({ - ownerCommit, - later, - noticeShape, - })), + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: targetedSameKeySequences, + label: (history) => + `follows durable same-key order for targeted ${history.sequence} with ${history.route}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runTargetedSameKeyOrder(plan.history), ), - ), - )( - `publishes an elected owner's rows after rotation when its notice is a $noticeShape: commit $ownerCommit, later $later`, - (history) => { - return assertFollowerGenerationNoticeHistory({ - ...history, - ownerRead: `ok`, - }) - }, - ) - - // 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) => { - return assertFollowerGenerationNoticeHistory({ - ...history, - ownerRead: `fails`, - }) - }, - ) - - // 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`, - () => { - return assertFollowerGenerationNoticeHistory({ - ownerCommit: `before-rotation`, - later: `none`, - ownerRead: `ok`, - readFailsAsRotationStarts: true, - }) - }, - ) - - const followerGenerationNoticeHistory = fc.record({ - ownerCommit: fc.constantFrom< - FollowerGenerationNoticeHistory[`ownerCommit`] - >(`before-rotation`, `after-rotation`), - later: fc.constantFrom( - `none`, - `owner`, - `peer`, - ), - ownerRead: fc.constantFrom( - `ok`, - `fails`, - ), + check: forwardAssertionJudgment, }) - // 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 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)`, - () => { - // Preserve the known-limit fixed-seed corpus and its expected failure. - // This is supplemental characterization, not a passing native property. - return (async () => { - for (const history of fc.sample( - followerGenerationNoticeHistory, - oracleFixedSeed(2128, { numRuns: oracleRuns(12) }), - )) - await assertFollowerGenerationNoticeHistory(history) - })() - }, - ) - - const expectedFollowerRemoteDemand = ( - history: FollowerRemoteDemandHistory, - ) => { - const applied = history.apply === `ok` - return { - // While the owner's commit has not applied here, nothing has settled. - whileApplyHeld: { settled: false, rows: undefined }, - settled: - history.consumer === `live-query` - ? applied - ? { outcome: `fulfilled`, rows: [`remote`], status: `ready` } - : { outcome: `rejected`, rows: undefined, status: `crashed` } - : applied - ? { - outcome: `fulfilled`, - rows: [`remote`], - status: undefined, - releases: 0, - } - : { - outcome: `rejected`, - rows: undefined, - status: `crashed`, - releases: 1, - }, - } - } - const assertFollowerRemoteDemandHistory = async ( - history: FollowerRemoteDemandHistory, - ) => { - const expected = expectedFollowerRemoteDemand(history) - const errors = vi.spyOn(console, `error`).mockImplementation(() => {}) - const warnings = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const tab = createFollowerTab(history) - const groupQuery = (group: string) => - createLiveQueryCollection((q) => - q.from({ row: tab.source }).where(({ row }) => eq(row.group, group)), - ) - const query = groupQuery(`a`) - const other = groupQuery(`b`) - try { - // The consumer's observation: its outcome, and the demanded rows it - // sees when it settles. - const seen: { - outcome: `pending` | `fulfilled` | `rejected` - rows?: Array - } = { outcome: `pending` } - const demandedRows = () => - Array.from(tab.source.values()) - .filter((row) => row.group === `a`) - .map((row) => row.id) - .sort() - let settled: Promise - if (history.consumer === `live-query`) { - query.onFirstReady(() => { - seen.rows = query.toArray.map((row) => (row as FollowerRow).id).sort() + type RetainedDemandOperation = + | { type: `acquire` | `release`; demand: `one` | `two` } + | { type: `truncate` } + // The generated prefix varies demand ownership and reset history. Its + // terminal suffix reaches a retained hydrated demand, fails a source write, + // then checks the exact error and absence of a new upstream acquisition. + // The pre-fix fast path returned true and incremented upstreamLoads here. + // This recording adapter does not establish browser/OPFS behavior. + const retainedDemandHistory = fc + .array( + fc.record({ + type: fc.constantFrom(`acquire` as const, `release` as const), + demand: fc.constantFrom(`one` as const, `two` as const), + }), + { minLength: 1, maxLength: 20 }, + ) + .chain((operations) => + fc + .array(fc.integer({ min: 0, max: operations.length }), { + maxLength: 4, }) - settled = query.preload().then( - () => (seen.outcome = `fulfilled`), - () => (seen.outcome = `rejected`), - ) - } else { - settled = Promise.resolve( - tab.source._sync.loadSubset({ - where: new IR.Func(`eq`, [ - new IR.PropRef([`group`]), - new IR.Value(`a`), - ]), - }), - ).then( - () => { - seen.outcome = `fulfilled` - seen.rows = demandedRows() - }, - () => (seen.outcome = `rejected`), - ) - } - if (history.placement === `queued`) void other.preload().catch(() => {}) - - if (history.placement === `queued`) { - // The owner's broadcast arrives while this hydration holds the apply - // mutex, so it waits in the queue until the hydration ends. - await within(tab.otherHydrationStarted) - await new Promise((resolve) => setTimeout(resolve, 0)) - tab.releaseOtherHydration() - } - const applyStarted = await within(tab.applyReadStarted) - await new Promise((resolve) => setTimeout(resolve, 0)) - // Checkpoint: the owner has replied, but its row has not applied here. - oracleAssertion('existing comparison at 4037', () => - expect({ - applyStarted, - settled: seen.outcome !== `pending`, - rows: seen.rows, - }).toEqual({ applyStarted: true, ...expected.whileApplyHeld }), - ) - - tab.releaseApply() - await within(settled) - await new Promise((resolve) => setTimeout(resolve, 0)) - // Checkpoint: the apply settled. - const releasesAtSettle = tab.releases() - oracleAssertion('existing comparison at 4048', () => - expect({ - outcome: seen.outcome, - rows: seen.rows, - ...(`releases` in expected.settled - ? { releases: releasesAtSettle } - : {}), - status: - expected.settled.status === undefined - ? undefined - : history.consumer === `live-query` && - expected.settled.status === `ready` - ? query.status - : expected.settled.status === `crashed` - ? history.consumer === `live-query` - ? `${query.status}/${tab.source.status}` - : tab.source.status - : tab.source.status, - }).toEqual({ - outcome: expected.settled.outcome, - rows: expected.settled.rows, - ...(`releases` in expected.settled - ? { releases: expected.settled.releases } - : {}), - status: - expected.settled.status === `crashed` && - history.consumer === `live-query` - ? `crashed/crashed` - : expected.settled.status, + .map((truncatePositions): Array => { + const positions = new Set(truncatePositions) + return operations.flatMap((operation, index) => + positions.has(index) + ? [{ type: `truncate` as const }, operation] + : [operation], + ) }), - ) - } finally { - await query.cleanup() - await other.cleanup() - await tab.source.cleanup() - errors.mockRestore() - warnings.mockRestore() - } - } - - const followerRemoteDemandHistories: Array = ( - [`direct`, `queued`, `rotated`, `expired`] as const - ).flatMap((placement) => - ([`ok`, `fails`] as const).flatMap((apply) => - ([`live-query`, `load`] as const).map((consumer) => ({ - placement, - apply, - consumer, - })), - ), - ) - it.each( - followerRemoteDemandHistories.filter( - ({ placement }) => placement !== `rotated`, - ), - )( - `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, - (...args: Parameters) => { - return assertFollowerRemoteDemandHistory(...args) - }, - ) - // 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( - ({ placement }) => placement === `rotated`, - ), - )( - `settles a follower's remote demand with the owner's applied rows: $placement broadcast, apply $apply, $consumer`, - (...args: Parameters) => { - return assertFollowerRemoteDemandHistory(...args) - }, - ) - - // Scoped recovery means durable rows cannot replace source rows. Owner - // evidence is an exception only in a rotated generation, which holds - // nothing but the owner's writes. Without a managed generation the old - // durable cache stays in place, so the model predicts that an owner notice - // during that recovery republishes none of the untrusted durable rows. - /** - * After a cache rotation, a follower reacquires every active demand into - * the new generation through the owner. The owner commits each demand's - * rows into that generation and broadcasts them before it replies, so the - * follower holds those notices. Scoped recovery may end only once every - * reacquisition has succeeded: before that, the new generation is still - * incomplete and its durable rows have no authority. Starting scoped - * recovery withdraws the old generation's rows. - * - * The independent model knows each demand's row version per generation: - * the owner wrote version 1 into the old generation and writes version 2 - * into the new one when it reacquires the demand. Until every - * reacquisition has settled, neither demand shows a row. If all succeed, - * both show the new generation's rows, including an owner update that - * arrived while the reacquisitions were settling. If any fails, the - * follower stays in scoped recovery, shows no row from the incomplete - * generation, and the source reports the failure as soon as that - * reacquisition settles. A reacquisition is a remote request, so the - * failure is the ready source's failure reason (`reportError()`: the source - * keeps syncing), never a crash; a crash is only for local storage or an - * internal invariant (glossary "crash"; maintainer decision for PR #2168). - * - * A demand that the caller releases or aborts while it reacquires no longer - * needs the new generation's rows: its aborted ensure is neither a failure - * nor a reported error, and the other demands decide the outcome. Releasing - * it twice changes nothing. A load that fails on its own still counts as a - * failure even though the failed load gives up its demand. - * - * A new demand loaded while the reacquisitions are in flight settles only - * once they all have: with the rows of the new generation if recovery - * ended, or rejected if it did not. Its own ensure answering early does not - * make its held rows visible. - * - * Only a recovery that rotated can end this way. A recovery that keeps the - * old generation, or one in which this node is the leader, leaves scoped - * recovery in place, and a newer rotation supersedes an older one's - * reacquisition. - * - * 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 fixed histories that fail. - * - * Limits. A failed reacquisition is not retried; the follower stays in - * scoped recovery until a later recovery rotates again. - * - * The grammar crosses the order in which two demands' reacquisitions - * settle, whether the owner updates demand a's row (version 3) between - * the first and last settlement, which reacquisitions fail or are released, - * and whether a new demand loads during reacquisition. Separate cells start - * rotation from two reads that find the claim lost, supersede reacquisition - * by a rotation as leader, and recover without rotating. The driver - * observes the rows, the new load's outcome, and the source status after - * every settlement, plus the ensure count, which a refetch loop would - * exceed. - */ - type RotatedReacquisitionHistory = { - order: [`a`, `b`] | [`b`, `a`] - noticeBetween: boolean - // Which reacquisitions fail. `released` means demand b is released - // while it reacquires, so its ensure aborts; that is not a failure. - fails: `none` | `a` | `b` | `both` | `released` - // A new demand, c, loads while the reacquisitions are in flight. - newDemand?: boolean - // While reacquisition is in flight, the node is elected leader and a - // second rotation supersedes the first; it then follows again, and a new - // demand c loads. With nothing reacquiring, the node stays in scoped - // recovery: held notices must still be handled, not queued forever. - supersededAsLeader?: boolean - // How rotation starts: the source asks for scoped recovery after both - // demands loaded, or both demands' first reads are in flight when the - // cache claim is found lost. - trigger?: `source` | `claim-lost-during-reads` - } - type VersionedRow = { id: string; group: string; v: number } - type ReacquisitionObservation = { - rows: Record<`a` | `b` | `c`, number | undefined> - // The new demand's load, when the history has one. - newLoad?: `pending` | `fulfilled` | `rejected` - // `retrying`: ready, with a failure reason. - status: `crashed` | `retrying` | `healthy` - } - const expectedReacquisition = ( - history: RotatedReacquisitionHistory, - settled: number, - ): ReacquisitionObservation => { - const fails = (group: `a` | `b`) => - history.fails === group || history.fails === `both` - const allSettled = settled === 2 - const ended = allSettled && !fails(`a`) && !fails(`b`) - // A failed reacquisition is reported when it settles, and the next - // remote request that succeeds clears it (maintainer decision: a coarse - // clear). A released demand's aborted ensure is neither. - let failed = false - for (const group of history.order.slice(0, settled)) - if (fails(group)) failed = true - else if (!(group === `b` && history.fails === `released`)) failed = false - return { - rows: ended - ? { - a: history.noticeBetween ? 3 : 2, - // A released demand's held notice still applies once recovery - // ends; the new generation's rows are trusted then. - b: 2, - c: history.newDemand ? 2 : undefined, - } - : { a: undefined, b: undefined, c: undefined }, - ...(history.newDemand - ? { - newLoad: !allSettled - ? (`pending` as const) - : ended - ? (`fulfilled` as const) - : (`rejected` as const), - } - : {}), - status: failed ? `retrying` : `healthy`, - } - } - - // Each model predicts the observation from the authored history alone: the - // follower's settled outcome and rows, and the rotated generation's rows, - // load status, and error state after the given number of settlements. - declareOracle({ - model: [expectedFollowerRemoteDemand, expectedReacquisition], - }) - - const assertRotatedReacquisition = async ( - history: RotatedReacquisitionHistory, - ) => { - const errors = vi.spyOn(console, `error`).mockImplementation(() => {}) - const warnings = vi.spyOn(console, `warn`).mockImplementation(() => {}) - const oldStorageId = `reacquire-old` - const newStorageId = `reacquire-new` - const durable = new Map>([ - [oldStorageId, new Map()], - [newStorageId, new Map()], + ) + const runRetainedDemandHistory = async ( + history: ReadonlyArray, + ): Promise => { + const adapter = createRecordingAdapter([ + { id: `1`, title: `one` }, + { id: `2`, title: `two` }, ]) - let subscriber: ((message: ProtocolEnvelope) => void) | undefined - const seqs = new Map() - const ownerCommits = (storageId: string, row: VersionedRow) => { - const seq = (seqs.get(storageId) ?? 0) + 1 - seqs.set(storageId, seq) - durable.get(storageId)!.set(row.id, row) - const payload: TxCommitted = { - type: `tx:committed`, - term: 1, - seq, - txId: `owner-${storageId}-${seq}`, - latestRowVersion: seq, - requiresFullReload: false, - changedRows: [{ key: row.id, value: row }], - deletedKeys: [], - } - subscriber?.({ - v: 1, - dbName: `test-db`, - collectionId: storageId, - senderId: `owner`, - ts: Date.now(), - payload, - }) - } - type Reply = { - resolve: () => void - reject: (error: unknown) => void - } - const replies = new Map() - let ensures = 0 - let cEnsures = 0 - const groupOf = (options: LoadSubsetOptions) => { - const where = options.where as IR.Func | undefined - const value = where?.args[1] as IR.Value | undefined - return value?.value as `a` | `b` | `c` | undefined + const loadRows = adapter.loadSubset + adapter.loadSubset = async (...args) => { + const rows = await loadRows(...args) + return rows.slice(0, args[1].limit) } - const duringReads = history.trigger === `claim-lost-during-reads` - const firstReads = createDeferred() - let reads = 0 - let claimLost = false - const adapter = { - loadSubset: async ( - target: PersistedStorageTarget, - ): Promise> => { - if (duringReads && ++reads <= 2) { - await firstReads.promise - claimLost = true - } - return Array.from( - durable.get(recordedStorageId(target))!.values(), - (value) => ({ key: value.id, value: { ...value } }), - ) - }, - claimCacheGeneration: async () => ({ - storageCollectionId: oldStorageId, - claimId: `reacquire-claim`, - expiresAtMs: Date.now() + 60_000, - }), - renewCacheGenerationClaim: async () => - claimLost ? undefined : Date.now() + 60_000, - rotateCacheGeneration: async () => { - claimLost = false - return { - storageCollectionId: newStorageId, - claimId: `reacquire-claim-2`, - expiresAtMs: Date.now() + 60_000, - } - }, - releaseCacheGenerationClaim: async () => {}, - loadResumeSnapshot: () => - Promise.resolve({ - rows: [], - keySet: { status: `consistent` }, - collectionMetadata: [], - latestTerm: 0, - latestSeq: 0, - latestRowVersion: 0, - resetEpoch: 0, - }), - applyCommittedTx: async () => {}, - ensureIndex: async () => {}, - } as unknown as PersistenceAdapter - let leader = false - const coordinator = { - getNodeId: () => `follower`, - subscribe: ( - _collectionId: string, - onMessage: (message: ProtocolEnvelope) => void, - ) => { - subscriber = onMessage - return () => { - subscriber = undefined - } - }, - publish: () => {}, - isLeader: () => leader, - ensureLeadership: async () => {}, - requestEnsurePersistedIndex: async () => {}, - // The owner commits the demand's row into the requesting generation - // and broadcasts it before it replies. In the new generation the - // driver releases the reply; a later refetch finds nothing new. - requestEnsureRemoteSubset: ( - storageId: string, - options: LoadSubsetOptions, - ) => { - ensures++ - const group = groupOf(options) - if (!group) return Promise.resolve() - if (storageId === oldStorageId) { - ownerCommits(oldStorageId, { id: `${group}-row`, group, v: 1 }) - return Promise.resolve() - } - if (group === `c`) cEnsures++ - if (replies.has(group) || (group === `c` && cEnsures > 1)) - return Promise.resolve() - ownerCommits(newStorageId, { id: `${group}-row`, group, v: 2 }) - // The new demand's ensure answers at once; the follower must still - // hold its rows until reacquisition ends. - if (group === `c`) return Promise.resolve() - return new Promise((resolve, reject) => { - replies.set(group, { resolve, reject }) - }) - }, - requestReleaseRemoteSubset: async () => {}, - registerRemoteSubsetOwner: () => () => {}, - requestApplyCommittedTx: (_collectionId: string, tx: TxCommitted) => - Promise.resolve({ - type: `rpc:applyCommittedTx:res`, - rpcId: tx.txId, - ok: true, - term: tx.term, - seq: tx.seq, - latestRowVersion: tx.seq, - }), - pullSince: (storageId: string) => - Promise.resolve({ - type: `rpc:pullSince:res`, - rpcId: `reacquire-pull`, - ok: true, - latestTerm: 1, - latestSeq: seqs.get(storageId) ?? 0, - latestRowVersion: seqs.get(storageId) ?? 0, - requiresFullReload: true, - changedKeys: [], - deletedKeys: [], - }), - } as unknown as PersistedCollectionCoordinator - let sourceParams: SyncConfig[`sync`] extends ( - params: infer P, - ) => unknown - ? P - : never - const source = createCollection( - persistedCollectionOptions({ - id: `follower-reacquire-${Math.random().toString(36).slice(2)}`, + let truncateSource: (() => void) | undefined + let failSource: (() => true | Promise) | undefined + let upstreamLoads = 0 + const collection = createCollection( + persistedCollectionOptions({ + id: `hydrated-demand-history`, getKey: (row) => row.id, - persistence: { adapter, coordinator }, syncMode: `on-demand`, sync: { - sync: (params) => { - sourceParams = params - // A ready source: only a ready Collection can carry the failed - // reacquisition as its failure reason. - params.markReady() + sync: ({ begin, write, truncate, commit, markReady }) => { + truncateSource = () => { + begin() + truncate() + commit() + } + failSource = () => { + begin() + write({ + type: `insert`, + value: { id: `terminal-write`, title: `must not persist` }, + }) + return commit() + } + markReady() return { - loadSubset: () => Promise.resolve(), - restartAfterScopedRecovery: () => {}, + loadSubset: () => { + upstreamLoads++ + return true + }, } }, }, + persistence: { adapter }, }), ) - source.startSyncImmediate() - let newLoad: `pending` | `fulfilled` | `rejected` = `pending` - const observe = (): ReacquisitionObservation => ({ - rows: { - a: source.get(`a-row`)?.v, - b: source.get(`b-row`)?.v, - c: source.get(`c-row`)?.v, - }, - ...(history.newDemand ? { newLoad } : {}), - status: - source.status === `crashed` - ? `crashed` - : source.health.failureReason - ? `retrying` - : `healthy`, - }) - const turn = () => new Promise((resolve) => setTimeout(resolve, 0)) - const settle = async () => { - for (let i = 0; i < 8; i++) await turn() + collection.startSyncImmediate() + for (let attempt = 0; attempt < 20 && !truncateSource; attempt++) { + await flushAsyncWork() } - const demand = (group: `a` | `b` | `c`): LoadSubsetOptions => ({ - where: new IR.Func(`eq`, [ - new IR.PropRef([`group`]), - new IR.Value(group), - ]), + oracleAssertion('existing comparison at 3416', () => + expect(truncateSource).toBeTypeOf(`function`), + ) + + const active: Record<`one` | `two`, Array> = { + one: [], + two: [], + } + const hydrated = new Set<`one` | `two`>() + const optionsFor = (demand: `one` | `two`): LoadSubsetOptions => ({ + limit: demand === `one` ? 1 : 2, }) - const demands = { a: demand(`a`), b: demand(`b`), c: demand(`c`) } - const load = (group: `a` | `b` | `c`) => - source._sync.loadSubset(demands[group]) + try { - if (duringReads) { - // Both first reads are in flight when the claim is found lost. - void Promise.resolve(load(`a`)).catch(() => undefined) - void Promise.resolve(load(`b`)).catch(() => undefined) - await settle() - firstReads.resolve() - } else { - for (const group of [`a`, `b`] as const) await load(group) - await settle() - oracleAssertion('existing comparison at 4476', () => - expect(observe().rows).toEqual({ a: 1, b: 1 }), - ) - void sourceParams!.metadata!.persistence!.startScopedRecovery!() - } - await vi.waitFor( - () => - oracleAssertion('existing comparison at 4479', () => - expect(replies.size).toBe(2), - ), - { timeout: 2_000 }, - ) - await settle() - if (history.newDemand) { - void Promise.resolve(load(`c`)).then( - () => (newLoad = `fulfilled`), - () => (newLoad = `rejected`), + for (const operation of history) { + if (operation.type === `truncate`) { + truncateSource?.() + hydrated.clear() + continue + } + + const demand = operation.demand + if (operation.type === `release`) { + const options = active[demand].pop() + if (!options) continue + collection._sync.unloadSubset(options) + if (active[demand].length === 0) hydrated.delete(demand) + continue + } + + const options = optionsFor(demand) + const result = collection._sync.loadSubset(options) + oracleAssertion('existing comparison at 3446', () => + expect(result === true).toBe(hydrated.has(demand)), ) + if (result !== true) await result + active[demand].push(options) + hydrated.add(demand) } - if (history.fails === `released`) { - // Releasing the same demand twice must be idempotent. - source._sync.unloadSubset(demands.b) - source._sync.unloadSubset(demands.b) - } - await settle() - // Checkpoint: both reacquisitions are in flight. - oracleAssertion('existing comparison at 4494', () => - expect(observe()).toEqual(expectedReacquisition(history, 0)), - ) - if (history.supersededAsLeader) { - leader = true - await sourceParams!.metadata!.persistence!.startScopedRecovery!() - await settle() - leader = false - let outcome: `pending` | `fulfilled` | `rejected` = `pending` - void Promise.resolve(load(`c`)).then( - () => (outcome = `fulfilled`), - () => (outcome = `rejected`), - ) - await settle() - // Checkpoint: the superseded reacquisition no longer holds notices. - // The node stays in scoped recovery with nothing reacquiring, so the - // owner's notice for c is handled by one refetch instead of waiting - // in the queue forever; the untrusted generation shows no row. - oracleAssertion('existing comparison at 4510', () => - expect({ outcome, cEnsures, c: source.get(`c-row`)?.v }).toEqual({ - outcome: `fulfilled`, - cEnsures: 2, - c: undefined, - }), - ) - for (const reply of replies.values()) reply.resolve() - return + + // A terminal durability failure is absorbing even when a retained + // demand still has hydrated rows. No later acquisition owns a new + // upstream lease or receives synchronous readiness. + const retained = optionsFor(`one`) + const retainedResult = collection._sync.loadSubset(retained) + if (retainedResult !== true) await retainedResult + const adapterFailure = new Error(`retained demand durability failed`) + adapter.applyCommittedTx = () => Promise.reject(adapterFailure) + const failedReceipt = failSource?.() + if (!(failedReceipt instanceof Promise)) { + throw new Error(`expected a failing source receipt`) } - for (const [index, group] of history.order.entries()) { - if (index === 1 && history.noticeBetween) { - ownerCommits(newStorageId, { id: `a-row`, group: `a`, v: 3 }) - await settle() - } - if (history.fails === `released` && group === `b`) { - const aborted = new Error(`released`) - aborted.name = `AbortError` - replies.get(group)!.reject(aborted) - } else if (history.fails === group || history.fails === `both`) - replies.get(group)!.reject(new Error(`reacquire ${group} failed`)) - else replies.get(group)!.resolve() - await settle() - // Checkpoint: after each settlement. - oracleAssertion('existing comparison at 4532', () => - expect(observe()).toEqual(expectedReacquisition(history, index + 1)), - ) + const receiptOutcome = await failedReceipt.then( + () => ({ status: `fulfilled` as const }), + (reason: unknown) => ({ status: `rejected` as const, reason }), + ) + const terminalError = collection._lifecycle.getSyncError() + oracleAssertion('existing comparison at 3469', () => + expect(receiptOutcome).toEqual({ + status: `rejected`, + reason: terminalError, + }), + ) + oracleAssertion('existing comparison at 3473', () => + expect(terminalError).toMatchObject({ + name: `PersistedCollectionDurabilityError`, + cause: adapterFailure, + }), + ) + + const loadsBeforeRetry = upstreamLoads + let retryFailure: unknown + try { + await collection._sync.loadSubset(optionsFor(`one`)) + } catch (error) { + retryFailure = error } - // Two acquisitions and two reacquisitions, a new demand's ensure, plus - // one refresh per held notice when recovery stays; a refetch loop - // exceeds the bound. - oracleAssertion('existing comparison at 4537', () => - expect(ensures).toBeLessThanOrEqual(16), + oracleAssertion('existing comparison at 3485', () => + expect({ retryFailure, upstreamLoads }).toEqual({ + retryFailure: terminalError, + upstreamLoads: loadsBeforeRetry, + }), ) } finally { - await source.cleanup() - errors.mockRestore() - warnings.mockRestore() + await collection.cleanup() } } - const rotatedReacquisitionHistories: Array = ( - [ - [`a`, `b`], - [`b`, `a`], - ] as const - ).flatMap((order) => - [false, true].flatMap((noticeBetween) => - ([`none`, `a`, `b`, `both`, `released`] as const).flatMap((fails) => - [false, true].map((newDemand) => ({ - order: [...order], - noticeBetween, - fails, - newDemand, - })), - ), - ), - ) - // 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 reacquisitionLimit = ({ - fails, - newDemand, - }: RotatedReacquisitionHistory) => - newDemand === true || fails === `none` || fails === `released` - it.each( - rotatedReacquisitionHistories.filter( - (history) => !reacquisitionLimit(history), - ), - )( - `ends a follower's scoped recovery only after every reacquisition succeeds: order $order, notice between $noticeBetween, fails $fails, new demand $newDemand`, - (...args: Parameters) => { - return assertRotatedReacquisition(...args) + oracleProperty({ + id: `persistence.retained-demand`, + law: { + statement: `The retained independent persistence helper judges its existing legal histories at every original observation checkpoint.`, + authority: `The persistence contracts and individual law stated beside the independent helper.`, + limits: `The existing bounded recording-adapter histories; no additional provider, native host, schedule, or gap-closure claim.`, }, - 10_000, - ) - 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`, - (...args: Parameters) => { - return assertRotatedReacquisition(...args) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: { + arbitrary: retainedDemandHistory, + runs: 50, + fixedSeeds: [186001], }, - 10_000, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runRetainedDemandHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) + + const rotatedReacquisitionCells = rotatedReacquisitionHistories.filter( + (history) => !reacquisitionLimit(history), ) - it(`releases held notices when a second rotation supersedes reacquisition as leader`, () => { - return assertRotatedReacquisition({ - order: [`a`, `b`], - noticeBetween: false, - fails: `none`, - supersededAsLeader: true, - }) - }, 10_000) + + oracleProperty({ + id: `persistence.rotated-reacquisition`, + law: { + statement: `A follower ends scoped recovery only after every reacquisition into the rotated generation succeeds, holding the generation's notices until then, as the reacquisition model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The rotated reacquisition cells that production satisfies today; the cells that the known limit #2139 covers are known-defect witnesses in persisted-known-defects.test.ts.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: rotatedReacquisitionCells, + label: (history) => + `ends a follower's scoped recovery only after every reacquisition succeeds: order [ ${history.order.map((demand) => `'${demand}'`).join(`, `)} ], notice between ${history.noticeBetween}, fails ${history.fails}, new demand ${history.newDemand}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => assertRotatedReacquisition(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 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'`, () => { - 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'`, - () => { - return assertRotatedReacquisition({ + const rotatedReacquisitionFixedHistories = [ + { + name: `releases held notices when a second rotation supersedes reacquisition as leader`, + history: { order: [`a`, `b`], noticeBetween: false, fails: `none`, + supersededAsLeader: true, + }, + }, + { + name: `holds a rotated follower's notices when two reads find the claim lost: fails 'a'`, + history: { + order: [`a`, `b`], + noticeBetween: false, + fails: `a`, trigger: `claim-lost-during-reads`, - }) + }, }, - 10_000, - ) + ] satisfies Array<{ + name: string + history: Parameters[0] + }> + + oracleProperty({ + id: `persistence.rotated-reacquisition-fixed`, + law: { + statement: `A follower releases held notices when a second rotation supersedes reacquisition as leader, and holds a rotated follower's notices when two reads find the claim lost.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `Two fixed reacquisition histories through the same driver and model.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: rotatedReacquisitionFixedHistories, + label: ({ name }) => name, + resolve: ({ history }) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => assertRotatedReacquisition(plan.history), + ), + check: forwardAssertionJudgment, + timeout: 10_000, + }) const followerRemoteDemandHistory = fc.record({ // Known limit: see #2139. The rotated placement is a fixed history above. @@ -4910,6 +3319,14 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { arbitrary: followerRemoteDemandHistory, runs: 12, fixedSeeds: [], + // Every placement that production satisfies today, as named fixed + // histories; the rotated placement is a known-defect witness (#2139). + fixed: followerRemoteDemandHistories + .filter(({ placement }) => placement !== `rotated`) + .map((history) => ({ + name: `settles a follower's remote demand with the owner's applied rows: ${history.placement} broadcast, apply ${history.apply}, ${history.consumer}`, + history, + })), }, resolve: (history) => ({ history }), model: recordedAssertionJudgment, @@ -4923,137 +3340,177 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { check: forwardAssertionJudgment, }) - it.each(persistedKeySetEvidenceStatuses.map((status) => ({ status })))( - `keeps on-demand rows independent from $status baseline evidence`, - async ({ status }) => { - const adapter = createRecordingAdapter([ - { id: `on-demand`, title: `On-demand row` }, - ]) - const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) - adapter.loadResumeSnapshot = async (...args) => ({ - ...(await loadResumeSnapshot(...args)), - rows: [ - { - key: `baseline`, - value: { id: `baseline`, title: `Baseline row` }, - }, - ], - keySet: { status }, - }) - let hydrateBaseline: (() => Promise) | undefined - const collection = createCollection( - persistedCollectionOptions({ - id: `${status}-baseline-and-on-demand`, - syncMode: `on-demand`, - getKey: (item) => item.id, - sync: { - sync: ({ markReady, metadata }) => { - const capability = metadata?.persistence - if (!capability) { - throw new Error(`Expected persisted sync capability`) - } - hydrateBaseline = capability.hydrateBaseline - markReady() - return { loadSubset: () => true } - }, + const onDemandBaselineStatuses = persistedKeySetEvidenceStatuses.map( + (status) => ({ status }), + ) + + async function runOnDemandBaselineEvidence({ + status, + }: (typeof onDemandBaselineStatuses)[number]): Promise { + const adapter = createRecordingAdapter([ + { id: `on-demand`, title: `On-demand row` }, + ]) + const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) + adapter.loadResumeSnapshot = async (...args) => ({ + ...(await loadResumeSnapshot(...args)), + rows: [ + { + key: `baseline`, + value: { id: `baseline`, title: `Baseline row` }, + }, + ], + keySet: { status }, + }) + let hydrateBaseline: (() => Promise) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `${status}-baseline-and-on-demand`, + syncMode: `on-demand`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady, metadata }) => { + const capability = metadata?.persistence + if (!capability) { + throw new Error(`Expected persisted sync capability`) + } + hydrateBaseline = capability.hydrateBaseline + markReady() + return { loadSubset: () => true } }, - persistence: { adapter }, - }), + }, + persistence: { adapter }, + }), + ) + + try { + collection.startSyncImmediate() + await vi.waitFor(() => + oracleAssertion('existing comparison at 4681', () => + expect(hydrateBaseline).toBeTypeOf(`function`), + ), ) + await hydrateBaseline!() + await flushAsyncWork() + await flushAsyncWork() - try { - collection.startSyncImmediate() - await vi.waitFor(() => - oracleAssertion('existing comparison at 4681', () => - expect(hydrateBaseline).toBeTypeOf(`function`), - ), - ) - await hydrateBaseline!() - await flushAsyncWork() - await flushAsyncWork() + const baselineVisible = collection.has(`baseline`) + oracleAssertion('existing comparison at 4687', () => + expect(collection.has(`on-demand`)).toBe(false), + ) + await collection._sync.loadSubset({}) - const baselineVisible = collection.has(`baseline`) - oracleAssertion('existing comparison at 4687', () => - expect(collection.has(`on-demand`)).toBe(false), - ) - await collection._sync.loadSubset({}) + expectOnDemandEvidenceLaw({ + status, + route: `loadSubset`, + baselineVisible, + onDemandVisible: collection.has(`on-demand`), + }) + } finally { + await collection.cleanup() + } + } - expectOnDemandEvidenceLaw({ - status, - route: `loadSubset`, - baselineVisible, - onDemandVisible: collection.has(`on-demand`), - }) - } finally { - await collection.cleanup() - } + oracleProperty({ + id: `persistence.on-demand-evidence-baseline`, + law: { + statement: `On-demand rows stay visible whatever the persisted key-set evidence status of the baseline, which shows only when the evidence is not incompatible.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The three persisted key-set evidence statuses through loadSubset.`, }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: onDemandBaselineStatuses, + label: (history) => + `keeps on-demand rows independent from ${history.status} baseline evidence`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runOnDemandBaselineEvidence(plan.history), + ), + check: forwardAssertionJudgment, + }) + + const onDemandForceReloadStatuses = persistedKeySetEvidenceStatuses.map( + (status) => ({ status }), ) - it.each(persistedKeySetEvidenceStatuses.map((status) => ({ status })))( - `keeps force reload rows independent from $status startup evidence`, - async ({ status }) => { - const adapter = createRecordingAdapter([ - { id: `on-demand`, title: `On-demand row` }, - ]) - const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) - adapter.loadResumeSnapshot = async (...args) => ({ - ...(await loadResumeSnapshot(...args)), - keySet: { status }, - }) - const collection = createCollection( - persistedCollectionOptions({ - id: `${status}-local-only-force-reload`, - syncMode: `on-demand`, - getKey: (item) => item.id, - persistence: { adapter }, - }), + async function runOnDemandForceReloadEvidence({ + status, + }: (typeof onDemandForceReloadStatuses)[number]): Promise { + const adapter = createRecordingAdapter([ + { id: `on-demand`, title: `On-demand row` }, + ]) + const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) + adapter.loadResumeSnapshot = async (...args) => ({ + ...(await loadResumeSnapshot(...args)), + keySet: { status }, + }) + const collection = createCollection( + persistedCollectionOptions({ + id: `${status}-local-only-force-reload`, + syncMode: `on-demand`, + getKey: (item) => item.id, + persistence: { adapter }, + }), + ) + + try { + collection.startSyncImmediate() + await vi.waitFor(() => + oracleAssertion('existing comparison at 4726', () => + expect(adapter.loadResumeSnapshotCalls.length).toBeGreaterThan(0), + ), ) + await collection.utils.forceReloadSubset!({}) - try { - collection.startSyncImmediate() - await vi.waitFor(() => - oracleAssertion('existing comparison at 4726', () => - expect(adapter.loadResumeSnapshotCalls.length).toBeGreaterThan(0), - ), - ) - await collection.utils.forceReloadSubset!({}) + oracleAssertion('existing comparison at 4730', () => + expect(adapter.loadResumeSnapshotCalls[0]?.includeRows).toBe(false), + ) + expectOnDemandEvidenceLaw({ + status, + route: `forceReloadSubset`, + // This local-only route reads startup evidence but does not expose + // baseline hydration. Do not claim a baseline observation here. + baselineVisible: `not-observed`, + onDemandVisible: collection.has(`on-demand`), + }) + } finally { + await collection.cleanup() + } + } - oracleAssertion('existing comparison at 4730', () => - expect(adapter.loadResumeSnapshotCalls[0]?.includeRows).toBe(false), - ) - expectOnDemandEvidenceLaw({ - status, - route: `forceReloadSubset`, - // This local-only route reads startup evidence but does not expose - // baseline hydration. Do not claim a baseline observation here. - baselineVisible: `not-observed`, - onDemandVisible: collection.has(`on-demand`), - }) - } finally { - await collection.cleanup() - } + oracleProperty({ + id: `persistence.on-demand-evidence-force-reload`, + law: { + statement: `Force-reloaded on-demand rows stay visible whatever the persisted key-set evidence status at startup.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The three persisted key-set evidence statuses through forceReloadSubset.`, }, - ) - - it(`rejects an evidence-coupled on-demand hydration mutant`, () => { - oracleAssertion('existing comparison at 4747', () => - expect(() => - expectOnDemandEvidenceLaw({ - status: `incompatible`, - route: `loadSubset`, - baselineVisible: false, - onDemandVisible: false, - }), - ).toThrow(`on-demand rows must not inherit baseline evidence rejection`), - ) - - expectOnDemandEvidenceLaw({ - status: `incompatible`, - route: `loadSubset`, - baselineVisible: false, - onDemandVisible: true, - }) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: onDemandForceReloadStatuses, + label: (history) => + `keeps force reload rows independent from ${history.status} startup evidence`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runOnDemandForceReloadEvidence(plan.history), + ), + check: forwardAssertionJudgment, }) let indexRun = 0 @@ -5170,183 +3627,209 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // source receipt must replay without awaiting the persisting operation whose // callback is itself awaiting that receipt. Persistence reach, both public // settlements, and the final source row expose the otherwise hidden cycle. - itUnlessAnyOracleReplay.each(hydrationScopeModes)( - `replays a buffered source receipt without blocking its persisting predecessor (%s scope)`, - async (scopeMode) => { - const adapter = createRecordingAdapter() - const hydrateLoadEntered = createDeferred() - const allowHydrateLoad = createDeferred() - let gateHydrationLoad = true - const replayState = { persisted: false } + async function runBufferedReceiptCausalReplay( + scopeMode: (typeof hydrationScopeModes)[number], + ): Promise { + const adapter = createRecordingAdapter() + const hydrateLoadEntered = createDeferred() + const allowHydrateLoad = createDeferred() + let gateHydrationLoad = true + const replayState = { persisted: false } - const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) - adapter.loadResumeSnapshot = async (...args) => { - if (args[1]?.includeRows === true && gateHydrationLoad) { - gateHydrationLoad = false - hydrateLoadEntered.resolve() - await allowHydrateLoad.promise - } - return loadResumeSnapshot(...args) - } - const applyCommittedTx = adapter.applyCommittedTx - adapter.applyCommittedTx = async (...args) => { - replayState.persisted = true - await applyCommittedTx(...args) + const loadResumeSnapshot = adapter.loadResumeSnapshot.bind(adapter) + adapter.loadResumeSnapshot = async (...args) => { + if (args[1]?.includeRows === true && gateHydrationLoad) { + gateHydrationLoad = false + hydrateLoadEntered.resolve() + await allowHydrateLoad.promise } - const routing = configureHydrationScope(adapter, scopeMode) - const accepted = createDeferred() - const releaseMutation = createDeferred() - - let remoteBegin: (() => void) | undefined - let remoteWrite: - ((message: { type: `update`; value: Todo }) => void) | undefined - let remoteCommit: (() => true | Promise) | undefined - const sourceReady = createDeferred() - const bufferedCommitReturned = createDeferred<{ - receipt: Promise - }>() + return loadResumeSnapshot(...args) + } + const applyCommittedTx = adapter.applyCommittedTx + adapter.applyCommittedTx = async (...args) => { + replayState.persisted = true + await applyCommittedTx(...args) + } + const routing = configureHydrationScope(adapter, scopeMode) + const accepted = createDeferred() + const releaseMutation = createDeferred() - const collection = createCollection( - persistedCollectionOptions({ - id: `sync-present-buffered-causal-replay`, - getKey: (item) => item.id, - sync: { - sync: ({ begin, write, commit, markReady }) => { - remoteBegin = begin - remoteWrite = write as (message: { - type: `update` - value: Todo - }) => void - remoteCommit = commit - sourceReady.resolve() - markReady() - return { loadSubset: () => true } - }, - }, - persistence: { adapter }, - onInsert: async () => { - if (!remoteBegin || !remoteWrite || !remoteCommit) { - throw new Error(`source sync is not ready`) - } - remoteBegin() - remoteWrite({ - type: `update`, - value: { id: `source-row`, title: `Buffered during hydrate` }, - }) - const applied = remoteCommit() - if (applied === true) { - throw new Error(`source commit was not buffered during hydration`) - } - bufferedCommitReturned.resolve({ receipt: applied }) - // The handler waits for acceptance; the row becomes visible when - // this mutation settles. - await whenSyncAccepted(applied) - accepted.resolve() - await releaseMutation.promise + let remoteBegin: (() => void) | undefined + let remoteWrite: + ((message: { type: `update`; value: Todo }) => void) | undefined + let remoteCommit: (() => true | Promise) | undefined + const sourceReady = createDeferred() + const bufferedCommitReturned = createDeferred<{ + receipt: Promise + }>() + + const collection = createCollection( + persistedCollectionOptions({ + id: `sync-present-buffered-causal-replay`, + getKey: (item) => item.id, + sync: { + sync: ({ begin, write, commit, markReady }) => { + remoteBegin = begin + remoteWrite = write as (message: { + type: `update` + value: Todo + }) => void + remoteCommit = commit + sourceReady.resolve() + markReady() + return { loadSubset: () => true } }, - }), - ) + }, + persistence: { adapter }, + onInsert: async () => { + if (!remoteBegin || !remoteWrite || !remoteCommit) { + throw new Error(`source sync is not ready`) + } + remoteBegin() + remoteWrite({ + type: `update`, + value: { id: `source-row`, title: `Buffered during hydrate` }, + }) + const applied = remoteCommit() + if (applied === true) { + throw new Error(`source commit was not buffered during hydration`) + } + bufferedCommitReturned.resolve({ receipt: applied }) + // The handler waits for acceptance; the row becomes visible when + // this mutation settles. + await whenSyncAccepted(applied) + accepted.resolve() + await releaseMutation.promise + }, + }), + ) - const preload = Promise.resolve(collection.preload()) - void preload.catch(() => undefined) - let mutationPersisted: Promise | undefined - let bufferedReceipt: Promise | undefined - let hasPrimaryFailure = false + const preload = Promise.resolve(collection.preload()) + void preload.catch(() => undefined) + let mutationPersisted: Promise | undefined + let bufferedReceipt: Promise | undefined + let hasPrimaryFailure = false - try { - await atPersistedOracleCheckpoint( - hydrateLoadEntered.promise, - `buffered causal replay hydration entered`, - ) - await atPersistedOracleCheckpoint( - sourceReady.promise, - `buffered causal replay source ready`, - ) + try { + await atPersistedOracleCheckpoint( + hydrateLoadEntered.promise, + `buffered causal replay hydration entered`, + ) + await atPersistedOracleCheckpoint( + sourceReady.promise, + `buffered causal replay source ready`, + ) - const mutation = collection.insert({ id: `local`, title: `Pending` }) - mutationPersisted = mutation.when(`settled`) - void mutationPersisted.catch(() => undefined) - const bufferedCommit = await atPersistedOracleCheckpoint( - bufferedCommitReturned.promise, - `buffered causal replay commit returned`, - ) - bufferedReceipt = bufferedCommit.receipt - void bufferedReceipt.catch(() => undefined) + const mutation = collection.insert({ id: `local`, title: `Pending` }) + mutationPersisted = mutation.when(`settled`) + void mutationPersisted.catch(() => undefined) + const bufferedCommit = await atPersistedOracleCheckpoint( + bufferedCommitReturned.promise, + `buffered causal replay commit returned`, + ) + bufferedReceipt = bufferedCommit.receipt + void bufferedReceipt.catch(() => undefined) - allowHydrateLoad.resolve() + allowHydrateLoad.resolve() - await atPersistedOracleCheckpoint( - accepted.promise, - `source accepted before optimistic publication`, - ) - oracleAssertion('existing comparison at 4980', () => - expect(routing?.violations() ?? []).toEqual([]), - ) - const appliedState = observeSettlement(bufferedReceipt) - await flushAsyncWork() - oracleAssertion('existing comparison at 4983', () => - expect(appliedState.read()).toEqual({ status: `pending` }), - ) - oracleAssertion('existing comparison at 4984', () => - expect(adapter.rows.get(`source-row`)).toEqual({ - id: `source-row`, - title: `Buffered during hydrate`, - }), - ) - oracleAssertion('existing comparison at 4988', () => - expect(collection.get(`source-row`)).toBeUndefined(), - ) - releaseMutation.resolve() - // The replay is accepted and stored while the mutation persists; its - // receipt resolves when the mutation settles and the row publishes. - const causalMutationPersisted = mutationPersisted - const causalBufferedReceipt = bufferedReceipt - await oracleAssertion('existing comparison at 4992', () => - expect( - atPersistedOracleCheckpoint( - causalMutationPersisted, - `buffered causal replay mutation persisted`, - ), - ).resolves.toBeDefined(), - ) - oracleAssertion('existing comparison at 4998', () => - expect(replayState.persisted).toBe(true), - ) - await oracleAssertion('existing comparison at 4999', () => - expect( - atPersistedOracleCheckpoint( - causalBufferedReceipt, - `buffered causal replay source receipt`, - ), - ).resolves.toBeUndefined(), - ) - await oracleAssertion('existing comparison at 5005', () => - expect( - atPersistedOracleCheckpoint( - preload, - `buffered causal replay preload settled`, - ), - ).resolves.toBeUndefined(), - ) - oracleAssertion('existing comparison at 5011', () => - expect(stripVirtualProps(collection.get(`source-row`))).toEqual({ - id: `source-row`, - title: `Buffered during hydrate`, - }), - ) - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - allowHydrateLoad.resolve() - releaseMutation.resolve() - await cleanupPersistedOracle( - [() => collection.cleanup()], - hasPrimaryFailure, - ) - } + await atPersistedOracleCheckpoint( + accepted.promise, + `source accepted before optimistic publication`, + ) + oracleAssertion('existing comparison at 4980', () => + expect(routing?.violations() ?? []).toEqual([]), + ) + const appliedState = observeSettlement(bufferedReceipt) + await flushAsyncWork() + oracleAssertion('existing comparison at 4983', () => + expect(appliedState.read()).toEqual({ status: `pending` }), + ) + oracleAssertion('existing comparison at 4984', () => + expect(adapter.rows.get(`source-row`)).toEqual({ + id: `source-row`, + title: `Buffered during hydrate`, + }), + ) + oracleAssertion('existing comparison at 4988', () => + expect(collection.get(`source-row`)).toBeUndefined(), + ) + releaseMutation.resolve() + // The replay is accepted and stored while the mutation persists; its + // receipt resolves when the mutation settles and the row publishes. + const causalMutationPersisted = mutationPersisted + const causalBufferedReceipt = bufferedReceipt + await oracleAssertion('existing comparison at 4992', () => + expect( + atPersistedOracleCheckpoint( + causalMutationPersisted, + `buffered causal replay mutation persisted`, + ), + ).resolves.toBeDefined(), + ) + oracleAssertion('existing comparison at 4998', () => + expect(replayState.persisted).toBe(true), + ) + await oracleAssertion('existing comparison at 4999', () => + expect( + atPersistedOracleCheckpoint( + causalBufferedReceipt, + `buffered causal replay source receipt`, + ), + ).resolves.toBeUndefined(), + ) + await oracleAssertion('existing comparison at 5005', () => + expect( + atPersistedOracleCheckpoint( + preload, + `buffered causal replay preload settled`, + ), + ).resolves.toBeUndefined(), + ) + oracleAssertion('existing comparison at 5011', () => + expect(stripVirtualProps(collection.get(`source-row`))).toEqual({ + id: `source-row`, + title: `Buffered during hydrate`, + }), + ) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + allowHydrateLoad.resolve() + releaseMutation.resolve() + await cleanupPersistedOracle( + [() => collection.cleanup()], + hasPrimaryFailure, + ) + } + } + + oracleProperty({ + id: `persistence.buffered-receipt-causal-replay`, + law: { + statement: `After hydration releases its buffer, a buffered source receipt replays without awaiting the persisting operation whose callback awaits that receipt, and both public settlements and the final source row follow.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The three hydration scope modes over the recording adapter.`, }, - ) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: hydrationScopeModes, + label: (scopeMode) => + `replays a buffered source receipt without blocking its persisting predecessor (${scopeMode} scope)`, + resolve: (scopeMode) => ({ scopeMode }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runBufferedReceiptCausalReplay(plan.scopeMode), + ), + check: forwardAssertionJudgment, + }) // # Do persisted reads preserve sync history, and do reserved commits survive them? // @@ -6843,51 +5326,71 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { // Row-algebra cross: the original routes and timings, every operation and // truncate replay, with a reservation that commits. - it.each( - ( - [ - { syncMode: `on-demand`, route: `subset` }, - { syncMode: `on-demand`, route: `complete subset` }, - { syncMode: `on-demand`, route: `collection-reset` }, - { syncMode: `on-demand`, route: `full-reload` }, - { syncMode: `on-demand`, route: `seq-gap` }, - { syncMode: `on-demand`, route: `seq-gap pull` }, - { syncMode: `on-demand`, route: `seq-gap deltas` }, - { syncMode: `eager`, route: `collection-reset` }, - { syncMode: `eager`, route: `full-reload` }, - { syncMode: `eager`, route: `seq-gap` }, - { syncMode: `eager`, route: `seq-gap pull` }, - { syncMode: `eager`, route: `seq-gap deltas` }, - ] as const - ).flatMap(({ syncMode, route }) => - ([`insert`, `update`, `delete`] as const).flatMap((operation) => - ([`none`, `before`, `after`] as const).flatMap((truncateReplay) => - ( - [ - `after read`, - `queued`, - `open across mutex turn`, - `open across scope entry`, - ] as const - ).map((commitTiming): PersistedReadHistory => ({ - syncMode, - route, - operation, - truncateReplay, - commitTiming, - ending: `commits`, - followUp: `none`, - })), - ), + const persistedReadRowAlgebraHistories = ( + [ + { syncMode: `on-demand`, route: `subset` }, + { syncMode: `on-demand`, route: `complete subset` }, + { syncMode: `on-demand`, route: `collection-reset` }, + { syncMode: `on-demand`, route: `full-reload` }, + { syncMode: `on-demand`, route: `seq-gap` }, + { syncMode: `on-demand`, route: `seq-gap pull` }, + { syncMode: `on-demand`, route: `seq-gap deltas` }, + { syncMode: `eager`, route: `collection-reset` }, + { syncMode: `eager`, route: `full-reload` }, + { syncMode: `eager`, route: `seq-gap` }, + { syncMode: `eager`, route: `seq-gap pull` }, + { syncMode: `eager`, route: `seq-gap deltas` }, + ] as const + ).flatMap(({ syncMode, route }) => + ([`insert`, `update`, `delete`] as const).flatMap((operation) => + ([`none`, `before`, `after`] as const).flatMap((truncateReplay) => + ( + [ + `after read`, + `queued`, + `open across mutex turn`, + `open across scope entry`, + ] as const + ).map((commitTiming): PersistedReadHistory => ({ + syncMode, + route, + operation, + truncateReplay, + commitTiming, + ending: `commits`, + followUp: `none`, + })), ), ), - )( - `preserves sync history over persisted reads ($syncMode, $route, $operation, truncate=$truncateReplay, timing=$commitTiming)`, - (...args: Parameters) => { - return runPersistedReadHistory(...args) - }, ) + oracleProperty({ + id: `persistence.persisted-read-row-algebra`, + law: { + statement: `Sync history survives persisted reads: every operation and truncate replay over the original routes and timings ends as the persisted-read model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The row-algebra cross of the persisted-read grammar, with a reservation that commits.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: persistedReadRowAlgebraHistories, + label: (history) => + `preserves sync history over persisted reads (${history.syncMode}, ${history.route}, ${history.operation}, truncate=${history.truncateReplay}, timing=${history.commitTiming})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runPersistedReadHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) + // Reservation cross: every route, reserving timing, ending, and follow-up, // with one insert and no truncate replay. `after read` is each route's // control. An ending or follow-up appears only where it can happen: cleanup, @@ -7024,26 +5527,57 @@ describeUnlessOracleReplay(`persistedCollectionOptions`, () => { reReserveDelays: [commitDelay, reserveDelay], })), ).flat() - it.each(reReserveHistories)( - `keeps a follower's load behind a second reservation (delays=$reReserveDelays)`, - (...args: Parameters) => { - return runPersistedReadHistory(...args) + oracleProperty({ + id: `persistence.persisted-read-re-reserve`, + law: { + statement: `A follower's load stays behind a second reservation, as the persisted-read model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The re-reserve commit and reserve delays of the persisted-read grammar.`, }, - ) - it.each(reservationHistories)( - `keeps reserved commits and their neighbours lawful ($syncMode, $route, timing=$commitTiming, ending=$ending, follow-up=$followUp)`, - (...args: Parameters) => { - return runPersistedReadHistory(...args) + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: reReserveHistories, + label: (history) => + `keeps a follower's load behind a second reservation (delays=[ ${history.reReserveDelays!.join(`, `)} ])`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runPersistedReadHistory(plan.history), + ), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `persistence.persisted-read-reservation`, + law: { + statement: `Reserved commits and their neighbours stay lawful over persisted reads, as the persisted-read model predicts.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The reservation routes, timings, endings, and follow-ups of the persisted-read grammar.`, }, - ) - - // The model predicts every observation from the history and its input - // rows; the driver runs the real Collection and compares; the history - // lists are the grammar the registrations below draw from. - declareOracle({ - check: [runPersistedReadHistory], - model: [expectedPersistedReadOutcome], - grammar: [reservationHistories, reReserveHistories], + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: reservationHistories, + label: (history) => + `keeps reserved commits and their neighbours lawful (${history.syncMode}, ${history.route}, timing=${history.commitTiming}, ending=${history.ending}, follow-up=${history.followUp})`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runPersistedReadHistory(plan.history), + ), + check: forwardAssertionJudgment, }) }) @@ -7152,7 +5686,9 @@ function expectedDurableHydrationTransactions( // 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 block holds both. -describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { +// The block needs no replay guard: its property stands aside during another +// property's replay. +describe(`hydration source durability routing`, () => { const first = { id: `shared`, title: `first source value` } const second = { id: `shared`, title: `last source value` } const other = { id: `other`, title: `independent source value` } @@ -7273,522 +5809,537 @@ describeUnlessAnyOracleReplay(`hydration source durability routing`, () => { ), ) - it.each(cases)( - `$phase scoped=$scoped baseline=$baselineCount $name`, - async ({ phase, scoped, baselineCount, transactions, failAt }) => { - // The shared key makes source precedence over persisted data observable. - const baseline = baselineCount - ? [{ id: `shared`, title: `old persisted value` }] - : [] - const modelTransactions = structuredClone(transactions) - const adapter = createRecordingAdapter(structuredClone(baseline)) - let readEntered = createDeferred() - let releaseRead = createDeferred() - let holdRead = phase === `startup` - const load = adapter.loadSubset.bind(adapter) - const resume = adapter.loadResumeSnapshot.bind(adapter) - const held = async (snapshot: T): Promise => { - if (holdRead) { - holdRead = false - readEntered.resolve() - await releaseRead.promise - } - return snapshot + async function runHydrationSourceCase({ + phase, + scoped, + baselineCount, + transactions, + failAt, + }: (typeof cases)[number]): Promise { + // The shared key makes source precedence over persisted data observable. + const baseline = baselineCount + ? [{ id: `shared`, title: `old persisted value` }] + : [] + const modelTransactions = structuredClone(transactions) + const adapter = createRecordingAdapter(structuredClone(baseline)) + let readEntered = createDeferred() + let releaseRead = createDeferred() + let holdRead = phase === `startup` + const load = adapter.loadSubset.bind(adapter) + const resume = adapter.loadResumeSnapshot.bind(adapter) + const held = async (snapshot: T): Promise => { + if (holdRead) { + holdRead = false + readEntered.resolve() + await releaseRead.promise } - adapter.loadResumeSnapshot = async (...args) => { - const result = await resume(...args) - return args[1]?.includeRows === false ? result : held(result) + return snapshot + } + adapter.loadResumeSnapshot = async (...args) => { + const result = await resume(...args) + return args[1]?.includeRows === false ? result : held(result) + } + adapter.loadSubset = async (...args) => held(await load(...args)) + const durableTransactions = + expectedDurableHydrationTransactions(modelTransactions) + const admissions = durableTransactions.map(() => createDeferred()) + const releases = durableTransactions.map(() => createDeferred()) + const store = adapter.applyCommittedTx.bind(adapter) + const storageError = Object.assign(new Error(`held durable write failed`), { + code: `SQLITE_IOERR_FSYNC`, + path: [`database`, `wal`], + }) + const admitted: Array = [] + let admissionCount = 0 + let holdDurability = true + adapter.applyCommittedTx = async (...args) => { + const index = admissionCount++ + admitted.push(structuredClone(args[1])) + if (holdDurability) { + admissions[index]?.resolve() + await releases[index]?.promise + if (index === failAt) throw storageError } - adapter.loadSubset = async (...args) => held(await load(...args)) - const durableTransactions = - expectedDurableHydrationTransactions(modelTransactions) - const admissions = durableTransactions.map(() => createDeferred()) - const releases = durableTransactions.map(() => createDeferred()) - const store = adapter.applyCommittedTx.bind(adapter) - const storageError = Object.assign( - new Error(`held durable write failed`), - { code: `SQLITE_IOERR_FSYNC`, path: [`database`, `wal`] }, - ) - const admitted: Array = [] - let admissionCount = 0 - let holdDurability = true - adapter.applyCommittedTx = async (...args) => { - const index = admissionCount++ - admitted.push(structuredClone(args[1])) - if (holdDurability) { - admissions[index]?.resolve() - await releases[index]?.promise - if (index === failAt) throw storageError + await store(...args) + } + const probe = scoped ? installHydrationRoutingProbe(adapter) : undefined + let source!: TodoSyncParams + const collection = createCollection( + persistedCollectionOptions({ + id: `hydration-source-routing`, + getKey: (row) => row.id, + syncMode: phase === `subscription` ? `on-demand` : `eager`, + sync: { + sync: (params) => { + source = params + params.markReady() + return { loadSubset: () => true } + }, + }, + persistence: { adapter }, + }), + ) + const preload = ( + phase === `subscription` + ? collection.stateWhenReady() + : collection.preload() + ).then(() => undefined) + const preloadState = observeSettlement(preload) + let subscription: ReturnType | undefined + const receipts: Array> = [] + const settlements: Array> = [] + const acceptances: Array> = [] + let reopened: Collection | undefined + let hasPrimaryFailure = false + const rows = () => + [...collection.values()] + .map(stripVirtualProps) + .sort((a, b) => a.id.localeCompare(b.id)) + const durable = () => ({ + rows: [...adapter.rows.values()].sort((a, b) => a.id.localeCompare(b.id)), + rowMetadata: [...adapter.rowMetadata].sort(), + collectionMetadata: [...adapter.collectionMetadata].sort(), + }) + const commit = (transaction: HydrationSourceTransaction) => { + source.begin() + for (const edit of structuredClone(transaction.edits)) { + if (edit.type === `put`) + source.write({ type: `update`, value: edit.row }) + else if (edit.type === `remove`) + source.write({ type: `delete`, key: edit.key }) + else if (edit.type === `truncate`) source.truncate() + else { + const metadata = + edit.type === `row-metadata` + ? source.metadata!.row + : source.metadata!.collection + if (edit.value === undefined) metadata.delete(edit.key) + else metadata.set(edit.key, edit.value) } - await store(...args) } - const probe = scoped ? installHydrationRoutingProbe(adapter) : undefined - let source!: TodoSyncParams - const collection = createCollection( - persistedCollectionOptions({ - id: `hydration-source-routing`, - getKey: (row) => row.id, - syncMode: phase === `subscription` ? `on-demand` : `eager`, - sync: { - sync: (params) => { - source = params - params.markReady() - return { loadSubset: () => true } - }, - }, - persistence: { adapter }, - }), - ) - const preload = ( - phase === `subscription` - ? collection.stateWhenReady() - : collection.preload() - ).then(() => undefined) - const preloadState = observeSettlement(preload) - let subscription: - ReturnType | undefined - const receipts: Array> = [] - const settlements: Array> = [] - const acceptances: Array> = [] - let reopened: Collection | undefined - let hasPrimaryFailure = false - const rows = () => - [...collection.values()] - .map(stripVirtualProps) - .sort((a, b) => a.id.localeCompare(b.id)) - const durable = () => ({ - rows: [...adapter.rows.values()].sort((a, b) => - a.id.localeCompare(b.id), + const controller = new AbortController() + if (transaction.abort) controller.abort() + const receipt = source.commit(controller.signal) + const promise = Promise.resolve(receipt).then(() => undefined) + receipts.push(promise) + settlements.push(observeSettlement(promise)) + acceptances.push( + observeSettlement( + Promise.resolve(whenSyncAccepted(receipt)).then(() => undefined), ), - rowMetadata: [...adapter.rowMetadata].sort(), - collectionMetadata: [...adapter.collectionMetadata].sort(), - }) - const commit = (transaction: HydrationSourceTransaction) => { - source.begin() - for (const edit of structuredClone(transaction.edits)) { - if (edit.type === `put`) - source.write({ type: `update`, value: edit.row }) - else if (edit.type === `remove`) - source.write({ type: `delete`, key: edit.key }) - else if (edit.type === `truncate`) source.truncate() - else { - const metadata = - edit.type === `row-metadata` - ? source.metadata!.row - : source.metadata!.collection - if (edit.value === undefined) metadata.delete(edit.key) - else metadata.set(edit.key, edit.value) - } - } - const controller = new AbortController() - if (transaction.abort) controller.abort() - const receipt = source.commit(controller.signal) - const promise = Promise.resolve(receipt).then(() => undefined) - receipts.push(promise) - settlements.push(observeSettlement(promise)) - acceptances.push( - observeSettlement( - Promise.resolve(whenSyncAccepted(receipt)).then(() => undefined), - ), + ) + } + try { + if (phase !== `startup`) + await atPersistedOracleCheckpoint(preload, `initial readiness`) + if (phase === `subscription`) { + holdRead = true + subscription = collection.subscribeChanges(() => {}, { + includeInitialState: true, + }) + } + if (phase !== `after-ready`) + await atPersistedOracleCheckpoint( + readEntered.promise, + `held persistence read`, + ) + for (const transaction of modelTransactions) commit(transaction) + await flushAsyncWork() + if (phase !== `after-ready`) { + expectHydrationReceiptsPending( + settlements.filter((_, index) => !modelTransactions[index]!.abort), + ) + expectHydrationReceiptsPending( + acceptances.filter((_, index) => !modelTransactions[index]!.abort), + ) + oracleAssertion('existing comparison at 7091', () => + expect(rows()).toEqual([]), + ) + oracleAssertion('existing comparison at 7092', () => + expect(admissionCount).toBe(0), ) } - try { - if (phase !== `startup`) - await atPersistedOracleCheckpoint(preload, `initial readiness`) - if (phase === `subscription`) { - holdRead = true - subscription = collection.subscribeChanges(() => {}, { - includeInitialState: true, - }) - } - if (phase !== `after-ready`) - await atPersistedOracleCheckpoint( - readEntered.promise, - `held persistence read`, - ) - for (const transaction of modelTransactions) commit(transaction) - await flushAsyncWork() - if (phase !== `after-ready`) { - expectHydrationReceiptsPending( - settlements.filter((_, index) => !modelTransactions[index]!.abort), - ) - expectHydrationReceiptsPending( - acceptances.filter((_, index) => !modelTransactions[index]!.abort), - ) - oracleAssertion('existing comparison at 7091', () => - expect(rows()).toEqual([]), - ) - oracleAssertion('existing comparison at 7092', () => - expect(admissionCount).toBe(0), - ) - } - releaseRead.resolve() - for (let index = 0; index < durableTransactions.length; index++) { - await atPersistedOracleCheckpoint( - admissions[index]!.promise, - `durability admission ${index}`, - ) - oracleAssertion('existing comparison at 7100', () => - expect( - probe?.violations() ?? [], - `nested source durability must use its current scoped adapter`, - ).toEqual([]), - ) - // Capture the full transaction at admission, before awaiting any - // receipt. A FIFO reversal must fail here, not time out waiting for - // the transaction that should have occupied this durable turn. - const edits = durableTransactions[index]!.edits - oracleAssertion('existing comparison at 7108', () => - expect( - admitted[index]!.mutations.map((mutation) => - mutation.type === `delete` - ? { type: `remove`, key: mutation.key } - : { type: `put`, row: mutation.value }, - ), - ).toEqual( - edits.filter( - (edit) => edit.type === `put` || edit.type === `remove`, - ), + releaseRead.resolve() + for (let index = 0; index < durableTransactions.length; index++) { + await atPersistedOracleCheckpoint( + admissions[index]!.promise, + `durability admission ${index}`, + ) + oracleAssertion('existing comparison at 7100', () => + expect( + probe?.violations() ?? [], + `nested source durability must use its current scoped adapter`, + ).toEqual([]), + ) + // Capture the full transaction at admission, before awaiting any + // receipt. A FIFO reversal must fail here, not time out waiting for + // the transaction that should have occupied this durable turn. + const edits = durableTransactions[index]!.edits + oracleAssertion('existing comparison at 7108', () => + expect( + admitted[index]!.mutations.map((mutation) => + mutation.type === `delete` + ? { type: `remove`, key: mutation.key } + : { type: `put`, row: mutation.value }, ), - ) - oracleAssertion('existing comparison at 7119', () => - expect([admitted[index]!.seq, admitted[index]!.rowVersion]).toEqual( - [index + 1, index + 1], + ).toEqual( + edits.filter( + (edit) => edit.type === `put` || edit.type === `remove`, ), - ) - oracleAssertion('existing comparison at 7123', () => - expect(admitted[index]!.truncate === true).toBe( - edits.some((edit) => edit.type === `truncate`), + ), + ) + oracleAssertion('existing comparison at 7119', () => + expect([admitted[index]!.seq, admitted[index]!.rowVersion]).toEqual([ + index + 1, + index + 1, + ]), + ) + oracleAssertion('existing comparison at 7123', () => + expect(admitted[index]!.truncate === true).toBe( + edits.some((edit) => edit.type === `truncate`), + ), + ) + await flushAsyncWork() + const modelIndex = modelTransactions.indexOf( + durableTransactions[index]!, + ) + expectHydrationReceiptsPending( + settlements + .slice(modelIndex) + .filter( + (_, offset) => !modelTransactions[modelIndex + offset]!.abort, ), - ) - await flushAsyncWork() - const modelIndex = modelTransactions.indexOf( - durableTransactions[index]!, - ) - expectHydrationReceiptsPending( - settlements - .slice(modelIndex) - .filter( - (_, offset) => !modelTransactions[modelIndex + offset]!.abort, - ), - ) - expectHydrationReceiptsPending( - acceptances - .slice(modelIndex) - .filter( - (_, offset) => !modelTransactions[modelIndex + offset]!.abort, - ), - ) - oracleAssertion('existing comparison at 7144', () => - expect(durable()).toEqual( - hydrationSourceSnapshot( - baseline, - modelTransactions.slice(0, modelIndex), - ), + ) + expectHydrationReceiptsPending( + acceptances + .slice(modelIndex) + .filter( + (_, offset) => !modelTransactions[modelIndex + offset]!.abort, ), - ) - oracleAssertion('existing comparison at 7150', () => - expect( - admissionCount, - `the suffix cannot overtake held durability`, - ).toBe(index + 1), - ) - // Core truncate may establish Collection readiness at publication. - // Source acceptance/receipt must still wait for durability above. - if ( - phase === `startup` && - !modelTransactions - .slice(0, modelIndex + 1) - .some((tx) => tx.edits.some((edit) => edit.type === `truncate`)) - ) - oracleAssertion('existing comparison at 7162', () => - expect(preloadState.read()).toEqual({ status: `pending` }), - ) - releases[index]!.resolve() - if (index === failAt) break - await atPersistedOracleCheckpoint( - receipts[modelIndex]!, - `individual source receipt ${modelIndex}`, - ) - oracleAssertion('existing comparison at 7169', () => - expect(settlements[modelIndex]!.read()).toEqual({ - status: `fulfilled`, - }), - ) - } - const allSettled = Promise.allSettled(receipts) - if (failAt !== undefined && admissions[failAt + 1]) { - await atPersistedOracleCheckpoint( - Promise.race([allSettled, admissions[failAt + 1]!.promise]), - `terminal failure fences the suffix`, - ) - oracleAssertion('existing comparison at 7179', () => - expect( - admissionCount, - `failed durability must not admit a suffix`, - ).toBe(failAt + 1), - ) - } - await atPersistedOracleCheckpoint( - allSettled, - `all source receipts settled`, ) - await flushAsyncWork() - if (failAt !== undefined) { - const failingIndex = modelTransactions.indexOf( - durableTransactions[failAt]!, - ) - const rejected = settlements[failingIndex]!.read() - oracleAssertion('existing comparison at 7194', () => - expect(rejected.status).toBe(`rejected`), - ) - if (rejected.status !== `rejected`) - throw new Error(`missing durable failure`) - oracleAssertion('existing comparison at 7197', () => - expect(rejected.reason).toBeInstanceOf( - PersistedCollectionDurabilityError, + oracleAssertion('existing comparison at 7144', () => + expect(durable()).toEqual( + hydrationSourceSnapshot( + baseline, + modelTransactions.slice(0, modelIndex), ), + ), + ) + oracleAssertion('existing comparison at 7150', () => + expect( + admissionCount, + `the suffix cannot overtake held durability`, + ).toBe(index + 1), + ) + // Core truncate may establish Collection readiness at publication. + // Source acceptance/receipt must still wait for durability above. + if ( + phase === `startup` && + !modelTransactions + .slice(0, modelIndex + 1) + .some((tx) => tx.edits.some((edit) => edit.type === `truncate`)) + ) + oracleAssertion('existing comparison at 7162', () => + expect(preloadState.read()).toEqual({ status: `pending` }), ) - oracleAssertion('existing comparison at 7200', () => - expect(rejected.reason).toMatchObject({ - code: `SQLITE_IOERR_FSYNC`, - path: [`database`, `wal`], + releases[index]!.resolve() + if (index === failAt) break + await atPersistedOracleCheckpoint( + receipts[modelIndex]!, + `individual source receipt ${modelIndex}`, + ) + oracleAssertion('existing comparison at 7169', () => + expect(settlements[modelIndex]!.read()).toEqual({ + status: `fulfilled`, + }), + ) + } + const allSettled = Promise.allSettled(receipts) + if (failAt !== undefined && admissions[failAt + 1]) { + await atPersistedOracleCheckpoint( + Promise.race([allSettled, admissions[failAt + 1]!.promise]), + `terminal failure fences the suffix`, + ) + oracleAssertion('existing comparison at 7179', () => + expect( + admissionCount, + `failed durability must not admit a suffix`, + ).toBe(failAt + 1), + ) + } + await atPersistedOracleCheckpoint( + allSettled, + `all source receipts settled`, + ) + await flushAsyncWork() + if (failAt !== undefined) { + const failingIndex = modelTransactions.indexOf( + durableTransactions[failAt]!, + ) + const rejected = settlements[failingIndex]!.read() + oracleAssertion('existing comparison at 7194', () => + expect(rejected.status).toBe(`rejected`), + ) + if (rejected.status !== `rejected`) + throw new Error(`missing durable failure`) + oracleAssertion('existing comparison at 7197', () => + expect(rejected.reason).toBeInstanceOf( + PersistedCollectionDurabilityError, + ), + ) + oracleAssertion('existing comparison at 7200', () => + expect(rejected.reason).toMatchObject({ + code: `SQLITE_IOERR_FSYNC`, + path: [`database`, `wal`], + }), + ) + for (const state of settlements.slice(failingIndex)) + oracleAssertion('existing comparison at 7205', () => + expect(state.read()).toEqual({ + status: `rejected`, + reason: rejected.reason, }), ) - for (const state of settlements.slice(failingIndex)) - oracleAssertion('existing comparison at 7205', () => - expect(state.read()).toEqual({ - status: `rejected`, - reason: rejected.reason, - }), - ) - oracleAssertion('existing comparison at 7209', () => - expect(collection.status).toBe(`crashed`), - ) - oracleAssertion('existing comparison at 7210', () => - expect(collection._lifecycle.getSyncError()).toBe(rejected.reason), - ) - oracleAssertion('existing comparison at 7211', () => - expect((rejected.reason as Error).cause).toBe(storageError), - ) - await oracleAssertion('existing comparison at 7212', () => - expect( - Promise.resolve().then(async () => { - source.begin() - source.write({ - type: `insert`, - value: { id: `late`, title: `rejected same-run admission` }, - }) - await source.commit() - }), - ).rejects.toBe(rejected.reason), - ) - oracleAssertion('existing comparison at 7222', () => - expect(admissionCount).toBe(failAt + 1), - ) - oracleAssertion('existing comparison at 7223', () => - expect(durable()).toEqual( - hydrationSourceSnapshot( - baseline, - modelTransactions.slice(0, failingIndex), - ), + oracleAssertion('existing comparison at 7209', () => + expect(collection.status).toBe(`crashed`), + ) + oracleAssertion('existing comparison at 7210', () => + expect(collection._lifecycle.getSyncError()).toBe(rejected.reason), + ) + oracleAssertion('existing comparison at 7211', () => + expect((rejected.reason as Error).cause).toBe(storageError), + ) + await oracleAssertion('existing comparison at 7212', () => + expect( + Promise.resolve().then(async () => { + source.begin() + source.write({ + type: `insert`, + value: { id: `late`, title: `rejected same-run admission` }, + }) + await source.commit() + }), + ).rejects.toBe(rejected.reason), + ) + oracleAssertion('existing comparison at 7222', () => + expect(admissionCount).toBe(failAt + 1), + ) + oracleAssertion('existing comparison at 7223', () => + expect(durable()).toEqual( + hydrationSourceSnapshot( + baseline, + modelTransactions.slice(0, failingIndex), + ), + ), + ) + } else { + await atPersistedOracleCheckpoint(preload, `Collection readiness`) + const expected = hydrationSourceSnapshot(baseline, modelTransactions) + oracleAssertion('existing comparison at 7232', () => + expect(rows()).toEqual(expected.rows), + ) + oracleAssertion('existing comparison at 7233', () => + expect(durable()).toEqual(expected), + ) + oracleAssertion('existing comparison at 7234', () => + expect( + source + .metadata!.collection.list() + .map(({ key, value }) => [key, value]) + .sort(), + ).toEqual(expected.collectionMetadata), + ) + for (const row of expected.rows) + oracleAssertion('existing comparison at 7241', () => + expect(source.metadata!.row.get(row.id)).toEqual( + new Map(expected.rowMetadata).get(row.id), ), ) - } else { - await atPersistedOracleCheckpoint(preload, `Collection readiness`) - const expected = hydrationSourceSnapshot(baseline, modelTransactions) - oracleAssertion('existing comparison at 7232', () => - expect(rows()).toEqual(expected.rows), - ) - oracleAssertion('existing comparison at 7233', () => - expect(durable()).toEqual(expected), - ) - oracleAssertion('existing comparison at 7234', () => - expect( - source - .metadata!.collection.list() - .map(({ key, value }) => [key, value]) - .sort(), - ).toEqual(expected.collectionMetadata), + modelTransactions.forEach((tx, index) => + oracleAssertion('existing comparison at 7245', () => + expect(settlements[index]!.read()).toEqual( + tx.abort + ? { + status: `rejected`, + reason: expect.objectContaining({ name: `AbortError` }), + } + : { status: `fulfilled` }, + ), + ), + ) + oracleAssertion('existing comparison at 7254', () => + expect(admissionCount).toBe(durableTransactions.length), + ) + oracleAssertion('existing comparison at 7255', () => + expect(collection.status).toBe(`ready`), + ) + // Same-run next use distinguishes a valid scope loan from a cached + // unscheduled adapter. This ordinary commit must take the public route. + holdDurability = false + await flushAsyncWork() + const tail: HydrationSourceTransaction = { + edits: [ + { type: `put`, row: { id: `tail`, title: `after scope exit` } }, + ], + } + commit(tail) + await atPersistedOracleCheckpoint( + receipts.at(-1)!, + `ordinary source after scope exit`, + ) + oracleAssertion('existing comparison at 7270', () => + expect(probe?.violations() ?? []).toEqual([]), + ) + if (probe) + oracleAssertion('existing comparison at 7272', () => + expect(probe.calls.at(-1)).toMatchObject({ + route: `public`, + scope: undefined, + live: true, + }), ) - for (const row of expected.rows) - oracleAssertion('existing comparison at 7241', () => - expect(source.metadata!.row.get(row.id)).toEqual( - new Map(expected.rowMetadata).get(row.id), - ), - ) - modelTransactions.forEach((tx, index) => - oracleAssertion('existing comparison at 7245', () => - expect(settlements[index]!.read()).toEqual( - tx.abort - ? { - status: `rejected`, - reason: expect.objectContaining({ name: `AbortError` }), - } - : { status: `fulfilled` }, - ), + const withTail = hydrationSourceSnapshot(baseline, [ + ...modelTransactions, + tail, + ]) + oracleAssertion('existing comparison at 7281', () => + expect(rows()).toEqual(withTail.rows), + ) + oracleAssertion('existing comparison at 7282', () => + expect(durable()).toEqual(withTail), + ) + oracleAssertion('existing comparison at 7283', () => + expect(adapter.applyCommittedTxCalls.map(({ tx }) => tx.seq)).toEqual( + Array.from( + { length: durableTransactions.length + 1 }, + (_, index) => index + 1, ), + ), + ) + subscription?.unsubscribe() + subscription = undefined + let afterSecondScope = withTail + if (phase === `subscription`) { + readEntered = createDeferred() + releaseRead = createDeferred() + holdRead = true + const nextLoad = Promise.resolve( + collection._sync.loadSubset({ limit: 7 }), ) - oracleAssertion('existing comparison at 7254', () => - expect(admissionCount).toBe(durableTransactions.length), + void nextLoad.catch(() => undefined) + await atPersistedOracleCheckpoint( + readEntered.promise, + `second hydration entered`, ) - oracleAssertion('existing comparison at 7255', () => - expect(collection.status).toBe(`ready`), + const priorScope = Math.max( + 0, + ...(probe?.calls.map((call) => call.scope ?? 0) ?? []), ) - // Same-run next use distinguishes a valid scope loan from a cached - // unscheduled adapter. This ordinary commit must take the public route. - holdDurability = false - await flushAsyncWork() - const tail: HydrationSourceTransaction = { + const next: HydrationSourceTransaction = { edits: [ - { type: `put`, row: { id: `tail`, title: `after scope exit` } }, + { + type: `put`, + row: { id: `next-scope`, title: `fresh adapter loan` }, + }, ], } - commit(tail) + commit(next) + releaseRead.resolve() await atPersistedOracleCheckpoint( - receipts.at(-1)!, - `ordinary source after scope exit`, + Promise.all([nextLoad, receipts.at(-1)!]), + `second hydration source settled`, ) - oracleAssertion('existing comparison at 7270', () => + oracleAssertion('existing comparison at 7322', () => expect(probe?.violations() ?? []).toEqual([]), ) - if (probe) - oracleAssertion('existing comparison at 7272', () => + if (probe) { + oracleAssertion('existing comparison at 7324', () => expect(probe.calls.at(-1)).toMatchObject({ - route: `public`, - scope: undefined, + route: `scoped`, live: true, }), ) - const withTail = hydrationSourceSnapshot(baseline, [ + oracleAssertion('existing comparison at 7328', () => + expect(probe.calls.at(-1)!.scope).toBeGreaterThan(priorScope), + ) + } + afterSecondScope = hydrationSourceSnapshot(baseline, [ ...modelTransactions, tail, + next, ]) - oracleAssertion('existing comparison at 7281', () => - expect(rows()).toEqual(withTail.rows), - ) - oracleAssertion('existing comparison at 7282', () => - expect(durable()).toEqual(withTail), - ) - oracleAssertion('existing comparison at 7283', () => - expect( - adapter.applyCommittedTxCalls.map(({ tx }) => tx.seq), - ).toEqual( - Array.from( - { length: durableTransactions.length + 1 }, - (_, index) => index + 1, - ), - ), - ) - subscription?.unsubscribe() - subscription = undefined - let afterSecondScope = withTail - if (phase === `subscription`) { - readEntered = createDeferred() - releaseRead = createDeferred() - holdRead = true - const nextLoad = Promise.resolve( - collection._sync.loadSubset({ limit: 7 }), - ) - void nextLoad.catch(() => undefined) - await atPersistedOracleCheckpoint( - readEntered.promise, - `second hydration entered`, - ) - const priorScope = Math.max( - 0, - ...(probe?.calls.map((call) => call.scope ?? 0) ?? []), - ) - const next: HydrationSourceTransaction = { - edits: [ - { - type: `put`, - row: { id: `next-scope`, title: `fresh adapter loan` }, - }, - ], - } - commit(next) - releaseRead.resolve() - await atPersistedOracleCheckpoint( - Promise.all([nextLoad, receipts.at(-1)!]), - `second hydration source settled`, - ) - oracleAssertion('existing comparison at 7322', () => - expect(probe?.violations() ?? []).toEqual([]), - ) - if (probe) { - oracleAssertion('existing comparison at 7324', () => - expect(probe.calls.at(-1)).toMatchObject({ - route: `scoped`, - live: true, - }), - ) - oracleAssertion('existing comparison at 7328', () => - expect(probe.calls.at(-1)!.scope).toBeGreaterThan(priorScope), - ) - } - afterSecondScope = hydrationSourceSnapshot(baseline, [ - ...modelTransactions, - tail, - next, - ]) - oracleAssertion('existing comparison at 7335', () => - expect(rows()).toEqual(afterSecondScope.rows), - ) - oracleAssertion('existing comparison at 7336', () => - expect(durable()).toEqual(afterSecondScope), - ) - } - await atPersistedOracleCheckpoint( - collection.cleanup(), - `source cleanup before reopen`, - ) - reopened = createCollection( - persistedCollectionOptions({ - id: `hydration-source-routing`, - getKey: (row) => row.id, - persistence: { adapter }, - }), + oracleAssertion('existing comparison at 7335', () => + expect(rows()).toEqual(afterSecondScope.rows), ) - await atPersistedOracleCheckpoint( - reopened.preload(), - `durable reopen`, - ) - const reopenedRows = [...reopened.values()] - oracleAssertion('existing comparison at 7353', () => - expect( - reopenedRows - .map(stripVirtualProps) - .sort((a, b) => a.id.localeCompare(b.id)), - ).toEqual(afterSecondScope.rows), + oracleAssertion('existing comparison at 7336', () => + expect(durable()).toEqual(afterSecondScope), ) } - } catch (error) { - hasPrimaryFailure = true - throw error - } finally { - releaseRead.resolve() - releases.forEach((release) => release.resolve()) - await cleanupPersistedOracle( - [ - () => Promise.allSettled([preload, ...receipts]), - () => subscription?.unsubscribe(), - () => collection.cleanup(), - () => reopened?.cleanup(), - ], - hasPrimaryFailure, + await atPersistedOracleCheckpoint( + collection.cleanup(), + `source cleanup before reopen`, + ) + reopened = createCollection( + persistedCollectionOptions({ + id: `hydration-source-routing`, + getKey: (row) => row.id, + persistence: { adapter }, + }), + ) + await atPersistedOracleCheckpoint(reopened.preload(), `durable reopen`) + const reopenedRows = [...reopened.values()] + oracleAssertion('existing comparison at 7353', () => + expect( + reopenedRows + .map(stripVirtualProps) + .sort((a, b) => a.id.localeCompare(b.id)), + ).toEqual(afterSecondScope.rows), ) } - }, - ) -}) + } catch (error) { + hasPrimaryFailure = true + throw error + } finally { + releaseRead.resolve() + releases.forEach((release) => release.resolve()) + await cleanupPersistedOracle( + [ + () => Promise.allSettled([preload, ...receipts]), + () => subscription?.unsubscribe(), + () => collection.cleanup(), + () => reopened?.cleanup(), + ], + hasPrimaryFailure, + ) + } + } -// A follower's cache-generation notices: the check and its history grammar -// live in persisted-support.ts, beside the law they state. The fixed and -// random campaigns run the same property and budget. -declareOracle({ - check: [assertGenerationNoticeHistory], - grammar: [generationNoticeHistory], + oracleProperty({ + id: `persistence.hydration-source-routing`, + law: { + statement: `Source transactions around a persisted hydration route to durable admission as the hydration source snapshot model predicts, and a source key keeps precedence over persisted data.`, + authority: `RFC #1659, the public persistence and sync contracts, and the law stated beside this test.`, + limits: `The startup, subscription, and after-ready phases, scoped or not, with zero or one baseline row, crossed with the authored transaction histories.`, + }, + owns: [ + `src/persisted.ts`, + `src/sqlite-core-adapter.ts`, + `src/remote-subset-owner.ts`, + ], + histories: cases, + label: (history) => + `${history.phase} scoped=${history.scoped} baseline=${history.baselineCount} ${history.name}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `existing persistence judgments`, + plan, + observe, + () => runHydrationSourceCase(plan.history), + ), + check: forwardAssertionJudgment, + }) }) describe(`persistedCollectionOptions`, () => { diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 2f14e43076..665a221bb2 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -42,6 +42,24 @@ export const properties: ReadonlyArray = [ `browser-coordinator.fresh-init`, `browser-coordinator.write-ownership`, `browser-coordinator.real-tab-follower`, + `persistence.hydration-boundary-commit`, + `persistence.hydration-straddle-fifo`, + `persistence.hydration-straddle-abort`, + `persistence.buffered-abort-readiness`, + `persistence.queued-hydration-publication`, + `persistence.durable-row-shape`, + `persistence.targeted-partial-transition`, + `persistence.targeted-seed-replacement`, + `persistence.targeted-same-key-order`, + `persistence.rotated-reacquisition`, + `persistence.rotated-reacquisition-fixed`, + `persistence.on-demand-evidence-baseline`, + `persistence.on-demand-evidence-force-reload`, + `persistence.persisted-read-row-algebra`, + `persistence.persisted-read-re-reserve`, + `persistence.persisted-read-reservation`, + `persistence.hydration-source-routing`, + `persistence.buffered-receipt-causal-replay`, ] export const owners: ReadonlyArray = [ @@ -91,4 +109,9 @@ export const owners: ReadonlyArray = [ `browser-coordinator`, `subset-wire subset-ack collection-scoped-lease remote-lease-takeover adapter-isolation fresh-init write-ownership real-tab-follower`, ], + [ + `db-sqlite-persistence-core/tests/persisted-oracle.test.ts`, + `persistence`, + `hydration-boundary-commit hydration-straddle-fifo hydration-straddle-abort buffered-abort-readiness queued-hydration-publication durable-row-shape targeted-partial-transition targeted-seed-replacement targeted-same-key-order rotated-reacquisition rotated-reacquisition-fixed on-demand-evidence-baseline on-demand-evidence-force-reload persisted-read-row-algebra persisted-read-re-reserve persisted-read-reservation hydration-source-routing buffered-receipt-causal-replay`, + ], ] From 0e1a173f128d944aa2590b80d07a961676b23288 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:41:45 -0700 Subject: [PATCH 096/116] style(db): format the includes space oracle Co-authored-by: Isaac --- packages/db/tests/query/includes-space-oracle.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/db/tests/query/includes-space-oracle.test.ts b/packages/db/tests/query/includes-space-oracle.test.ts index c5613eed8c..bcdb4e8131 100644 --- a/packages/db/tests/query/includes-space-oracle.test.ts +++ b/packages/db/tests/query/includes-space-oracle.test.ts @@ -56,7 +56,10 @@ async function judgeReachableFacades(rootCount: number): Promise { expectedFacadeCount(rootCount), ) expect( - [...adapters].reduce((n, adapter) => n + countEntries(adapter.entries), 0), + [...adapters].reduce( + (n, adapter) => n + countEntries(adapter.entries), + 0, + ), ).toBe(expectedFacadeCount(rootCount)) expect( [...adapters].reduce( From e8743d868ac079744da8b44794f4569784eef85d Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:42:28 -0700 Subject: [PATCH 097/116] test(query, convex, conformance): register the plain oracle tests through oracleProperty Convert the D2 sites in query-db-collection, convex-db-collection and db-adapter-conformance. Fixed histories and cases become fixed or enumerated oracleProperty histories that run the file's existing driver and comparison; each calibration that ran production becomes a calibration whose mutant counts only the rejections that its old matcher accepted (calibration-rejection.ts). Model and checker calibrations, grammar-size checks and unit tests that compare production with written values move to ordinary test files, with the definitions they need in test-free oracle-named companion modules. The refetch oracle's two it.fails loader-defect witnesses move to refetch-ordered-prefix-known-defects.test.ts. The scope-omission framework guard is left as it was. Co-authored-by: Isaac --- .../e2e/auth-app-oracle.test.ts | 196 +- .../e2e/backend-oracle.test.ts | 232 +- .../e2e/lazy-paging-oracle.test.ts | 442 ++-- .../e2e/public-pagination-oracle.test.ts | 362 +-- packages/convex-db-collection/package.json | 2 +- .../tests/calibration-rejection.ts | 35 + .../tests/lazy-paging-config.test.ts | 58 + .../lazy-paging-failure-history-model.test.ts | 28 + ...ng-failure-history-oracle.property.test.ts | 25 +- .../lazy-paging-interleaving-model.test.ts | 26 + ...aging-interleaving-oracle.property.test.ts | 95 +- .../tests/lazy-paging-model.test.ts | 52 + ...ing-native-receipt-oracle.property.test.ts | 66 +- .../tests/lazy-paging-oracle.property.test.ts | 111 +- ...azy-paging-receipt-oracle.property.test.ts | 76 +- .../lazy-paging-revalidation-model.test.ts | 71 + ...aging-revalidation-oracle.property.test.ts | 122 +- .../tests/lazy-paging-version-model.test.ts | 18 + ...azy-paging-version-oracle.property.test.ts | 78 +- ...est.ts => lazy-paging-write-model.test.ts} | 3 - .../tests/pagination-oracle.property.test.ts | 78 +- .../tests/pagination-trace-model-oracle.ts | 45 + .../tests/pagination-trace.test.ts | 40 + .../tests/sdk-cache-oracle.test.ts | 217 +- .../reference-adapter-oracle.property.test.ts | 237 +- .../db/tests/converted-properties/adapters.ts | 97 +- .../tests/calibration-rejection.ts | 35 + ...rsor-pagination-oracle.integration.test.ts | 315 ++- ...ination-oracle.no-peek.integration.test.ts | 232 +- .../cursor-pagination-reference-support.ts | 11 + .../tests/cursor-pagination-reference.test.ts | 56 + .../cursor-pagination.cache-oracle.test.ts | 606 +++-- .../tests/cursor-pagination.oracle.test.ts | 184 +- .../includes-work-counter-oracle.test.ts | 305 ++- ...mistic-ordered-refetch-calibration.test.ts | 46 + ...optimistic-ordered-refetch-model-oracle.ts | 46 + .../optimistic-ordered-refetch-oracle.test.ts | 82 +- ...rsisted-direct-write-window.oracle.test.ts | 226 +- .../refetch-ordered-prefix-driver-oracle.ts | 838 +++++++ .../refetch-ordered-prefix-grammar-oracle.ts | 34 + ...fetch-ordered-prefix-known-defects.test.ts | 69 + .../refetch-ordered-prefix-oracle.test.ts | 2224 ++++++----------- .../tests/refetch-ordered-prefix.test.ts | 9 + .../tests/ssr-dehydration-oracle.test.ts | 310 ++- packages/query-db-collection/vitest.config.ts | 1 + 45 files changed, 4759 insertions(+), 3682 deletions(-) create mode 100644 packages/convex-db-collection/tests/calibration-rejection.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-config.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-failure-history-model.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-interleaving-model.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-model.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-revalidation-model.test.ts create mode 100644 packages/convex-db-collection/tests/lazy-paging-version-model.test.ts rename packages/convex-db-collection/tests/{lazy-paging-write-model-oracle.test.ts => lazy-paging-write-model.test.ts} (95%) create mode 100644 packages/convex-db-collection/tests/pagination-trace-model-oracle.ts create mode 100644 packages/convex-db-collection/tests/pagination-trace.test.ts create mode 100644 packages/query-db-collection/tests/calibration-rejection.ts create mode 100644 packages/query-db-collection/tests/cursor-pagination-reference-support.ts create mode 100644 packages/query-db-collection/tests/cursor-pagination-reference.test.ts create mode 100644 packages/query-db-collection/tests/optimistic-ordered-refetch-calibration.test.ts create mode 100644 packages/query-db-collection/tests/optimistic-ordered-refetch-model-oracle.ts create mode 100644 packages/query-db-collection/tests/refetch-ordered-prefix-driver-oracle.ts create mode 100644 packages/query-db-collection/tests/refetch-ordered-prefix-grammar-oracle.ts create mode 100644 packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts diff --git a/packages/convex-db-collection/e2e/auth-app-oracle.test.ts b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts index 604f5b7095..4a6e607ea1 100644 --- a/packages/convex-db-collection/e2e/auth-app-oracle.test.ts +++ b/packages/convex-db-collection/e2e/auth-app-oracle.test.ts @@ -17,7 +17,13 @@ import { ConvexClient, ConvexHttpClient } from 'convex/browser' import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' import { createCollection } from '@tanstack/db' -import { expect, inject, it } from 'vitest' +import { expect, inject } from 'vitest' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { oracleProperty } from '../../db/tests/oracle-property' import { convexCollectionOptions } from '../src/index' import { withTestCleanup } from '../tests/helpers' @@ -87,87 +93,109 @@ const history: ReadonlyArray = [ { role: `admin`, operation: `delete` }, ] -it.each([`javascript`, `react`] as const)( - `receives genuine authenticated role transitions with the %s client`, - async (kind) => { - const http = new ConvexHttpClient(url) - await expect(http.query(list, {})).rejects.toThrow(`Not signed in`) - const result = await http.action(signIn, { provider: `anonymous` }) - expect(result.tokens?.token.split(`.`)).toHaveLength(3) - const token = result.tokens!.token - const invalid = new ConvexHttpClient(url) - invalid.setAuth(`${token.slice(0, -3)}bad`) - await expect(invalid.query(list, {})).rejects.toThrow() - const makeClient = () => - kind === `javascript` - ? new ConvexClient(url, { unsavedChangesWarning: false, logger: false }) - : new ConvexReactClient(url, { - unsavedChangesWarning: false, - logger: false, - }) - const unauthenticated = makeClient() - const denied = createCollection( - convexCollectionOptions({ - client: unauthenticated, - query: list, - queryArgs: {}, - getKey: (row) => row._id, - gcTime: 0, - }), - ) - await withTestCleanup( - async () => { - await expect(denied.preload()).rejects.toThrow(`Not signed in`) - expect([...denied.values()]).toEqual([]) - }, - () => denied.cleanup(), - () => unauthenticated.close(), - ) - const client = makeClient() - client.setAuth(() => Promise.resolve(token)) - const collection = createCollection( - convexCollectionOptions({ - client, - query: list, - queryArgs: {}, - getKey: (row) => row._id, - gcTime: 0, - }), - ) - const marker = `auth-${kind}-${crypto.randomUUID()}` - await withTestCleanup( - async () => { - await collection.preload() - let model: Array = [] - for (const step of history) { - // This is the upstream demo's public role selector, not admin auth. - await client.mutation(role, { role: step.role }) - const predicted = stepModel(model, step, marker) - const inserted = [...collection.values()].find( - (row) => row.body === marker, - ) - const attempt = - step.operation === `send` - ? client.mutation(send, { body: marker }) - : client.mutation(remove, { messageId: inserted!._id }) - if (predicted.allowed) { - await attempt - model = predicted.next - } else - await expect(attempt).rejects.toThrow(`Insufficient permissions`) - await collection.utils.awaitSync() - // Compare at every accepted source checkpoint, including denials and - // demotion. Two successful equal-body sends remain two model rows. - expect( - [...collection.values()] - .filter((row) => row.body === marker) - .map((row) => row.body) - .sort(), - ).toEqual([...model].sort()) - } - }, - () => collection.cleanup(), - () => client.close(), - ) +const clients = [`javascript`, `react`] as const + +// One SDK client signs in through the upstream application and runs the +// authored role history. At every accepted source checkpoint the public rows +// must equal the stepModel multiset. +async function receiveRoleTransitions( + kind: (typeof clients)[number], +): Promise { + const http = new ConvexHttpClient(url) + await expect(http.query(list, {})).rejects.toThrow(`Not signed in`) + const result = await http.action(signIn, { provider: `anonymous` }) + expect(result.tokens?.token.split(`.`)).toHaveLength(3) + const token = result.tokens!.token + const invalid = new ConvexHttpClient(url) + invalid.setAuth(`${token.slice(0, -3)}bad`) + await expect(invalid.query(list, {})).rejects.toThrow() + const makeClient = () => + kind === `javascript` + ? new ConvexClient(url, { unsavedChangesWarning: false, logger: false }) + : new ConvexReactClient(url, { + unsavedChangesWarning: false, + logger: false, + }) + const unauthenticated = makeClient() + const denied = createCollection( + convexCollectionOptions({ + client: unauthenticated, + query: list, + queryArgs: {}, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + await withTestCleanup( + async () => { + await expect(denied.preload()).rejects.toThrow(`Not signed in`) + expect([...denied.values()]).toEqual([]) + }, + () => denied.cleanup(), + () => unauthenticated.close(), + ) + const client = makeClient() + client.setAuth(() => Promise.resolve(token)) + const collection = createCollection( + convexCollectionOptions({ + client, + query: list, + queryArgs: {}, + getKey: (row) => row._id, + gcTime: 0, + }), + ) + const marker = `auth-${kind}-${crypto.randomUUID()}` + await withTestCleanup( + async () => { + await collection.preload() + let model: Array = [] + for (const step of history) { + // This is the upstream demo's public role selector, not admin auth. + await client.mutation(role, { role: step.role }) + const predicted = stepModel(model, step, marker) + const inserted = [...collection.values()].find( + (row) => row.body === marker, + ) + const attempt = + step.operation === `send` + ? client.mutation(send, { body: marker }) + : client.mutation(remove, { messageId: inserted!._id }) + if (predicted.allowed) { + await attempt + model = predicted.next + } else await expect(attempt).rejects.toThrow(`Insufficient permissions`) + await collection.utils.awaitSync() + // Compare at every accepted source checkpoint, including denials and + // demotion. Two successful equal-body sends remain two model rows. + expect( + [...collection.values()] + .filter((row) => row.body === marker) + .map((row) => row.body) + .sort(), + ).toEqual([...model].sort()) + } + }, + () => collection.cleanup(), + () => client.close(), + ) +} + +oracleProperty({ + id: `convex.auth-app-roles`, + law: { + statement: `A rejected operation leaves the public Collection unchanged, and a permitted send or deletion reaches its rows after awaitSync accepts the source, as the independent role table predicts.`, + authority: `The role-based Convex Auth application pinned in auth-app/README.md and its server functions' read/write/admin access, as stated at the top of this file.`, + limits: `One bounded authored role history through the upstream application, with both SDK WebSocket clients; no OAuth, token refresh, role administration security or browser claim.`, }, -) + histories: clients, + label: (kind) => + `receives genuine authenticated role transitions with the ${kind} client`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`convex auth roles`, plan, observe, () => + receiveRoleTransitions(plan.kind), + ), + check: forwardAssertionJudgment, +}) diff --git a/packages/convex-db-collection/e2e/backend-oracle.test.ts b/packages/convex-db-collection/e2e/backend-oracle.test.ts index 0bce46af4e..f8217a9a87 100644 --- a/packages/convex-db-collection/e2e/backend-oracle.test.ts +++ b/packages/convex-db-collection/e2e/backend-oracle.test.ts @@ -33,7 +33,7 @@ import { createCollection } from '@tanstack/db' import { ConvexClient } from 'convex/browser' import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' -import { describe, expect, inject, it } from 'vitest' +import { expect, inject } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -96,118 +96,138 @@ async function expectMirrorsAllTasks( ) } -describe.each([`javascript`, `react`] as const)( - `real backend with %s client`, - (kind) => { - it.each([`list`, `split`] as const)( - `loads %s pages through inserts, moves, deletions, and server splits`, - async (functionName) => { - const client = - kind === `javascript` - ? new ConvexClient(url, { unsavedChangesWarning: false }) - : new ConvexReactClient(url, { unsavedChangesWarning: false }) - let splitResponses = 0 - const inspect = (value: unknown): void => { - if ( - value && - typeof value === `object` && - `pageStatus` in value && - (value.pageStatus === `SplitRequired` || - value.pageStatus === `SplitRecommended`) - ) - splitResponses++ - } - const source: ConvexCollectionClient = - `onUpdate` in client - ? { - onUpdate: (query, args, callback, onError) => - client.onUpdate( - query, - args, - (value) => { - inspect(value) - return callback(value) - }, - onError, - ), - } - : { - watchQuery: (query, args, options) => { - const watch = client.watchQuery(query, args, options) - return { - ...watch, - localQueryResult: () => { - const value = watch.localQueryResult() - inspect(value) - return value - }, - } - }, - } +const pageLoads = ([`javascript`, `react`] as const).flatMap((kind) => + ([`list`, `split`] as const).map((functionName) => ({ kind, functionName })), +) - const collection = createCollection( - convexPaginatedCollectionOptions({ - id: `tasks`, - client: source, - query: makeFunctionReference< - `query`, - { paginationOpts: PaginationOptions }, - PaginationResult - >(`tasks:${functionName}`), - queryArgs: {}, - pageSize: 4, - getKey: (task) => task.clientId, - gcTime: 0, - }), - ) - const check = () => expectMirrorsAllTasks(client, collection) - await withTestCleanup( - async () => { - await client.mutation(seed, { count: 23 }) - await collection.preload() - oracleAssertion(`backend-oracle.test.ts:160:toBe`, () => - expect(collection.size).toBe(23), - ) - await check() - for (const operation of [ - { - clientId: `new-first`, - rank: -10, - text: `insert before all pages`, - }, - { - clientId: `new-last`, - rank: 100, - text: `append after exhaustion`, +// One client loads one paginated function's pages, then receives each +// authored backend write. After every write the Collection must mirror the +// backend's own complete `all` query. +async function loadPagesThroughWrites({ + kind, + functionName, +}: (typeof pageLoads)[number]): Promise { + const client = + kind === `javascript` + ? new ConvexClient(url, { unsavedChangesWarning: false }) + : new ConvexReactClient(url, { unsavedChangesWarning: false }) + let splitResponses = 0 + const inspect = (value: unknown): void => { + if ( + value && + typeof value === `object` && + `pageStatus` in value && + (value.pageStatus === `SplitRequired` || + value.pageStatus === `SplitRecommended`) + ) + splitResponses++ + } + const source: ConvexCollectionClient = + `onUpdate` in client + ? { + onUpdate: (query, args, callback, onError) => + client.onUpdate( + query, + args, + (value) => { + inspect(value) + return callback(value) }, - { - clientId: `3`, - rank: 3, - text: `delete a boundary`, - remove: true, + onError, + ), + } + : { + watchQuery: (query, args, options) => { + const watch = client.watchQuery(query, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + inspect(value) + return value }, - { clientId: `12`, rank: -5, text: `move across cursor ranges` }, - { clientId: `8`, rank: 8, text: `update a middle page` }, - ]) { - await client.mutation(write, { - ...operation, - idempotencyKey: crypto.randomUUID(), - }) - await check() } - if (functionName === `split`) - oracleAssertion( - `backend-oracle.test.ts:189:toBeGreaterThan`, - () => expect(splitResponses).toBeGreaterThan(0), - ) }, - () => collection.cleanup(), - () => client.close(), + } + + const collection = createCollection( + convexPaginatedCollectionOptions({ + id: `tasks`, + client: source, + query: makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:${functionName}`), + queryArgs: {}, + pageSize: 4, + getKey: (task) => task.clientId, + gcTime: 0, + }), + ) + const check = () => expectMirrorsAllTasks(client, collection) + await withTestCleanup( + async () => { + await client.mutation(seed, { count: 23 }) + await collection.preload() + oracleAssertion(`backend-oracle.test.ts:160:toBe`, () => + expect(collection.size).toBe(23), + ) + await check() + for (const operation of [ + { + clientId: `new-first`, + rank: -10, + text: `insert before all pages`, + }, + { + clientId: `new-last`, + rank: 100, + text: `append after exhaustion`, + }, + { + clientId: `3`, + rank: 3, + text: `delete a boundary`, + remove: true, + }, + { clientId: `12`, rank: -5, text: `move across cursor ranges` }, + { clientId: `8`, rank: 8, text: `update a middle page` }, + ]) { + await client.mutation(write, { + ...operation, + idempotencyKey: crypto.randomUUID(), + }) + await check() + } + if (functionName === `split`) + oracleAssertion(`backend-oracle.test.ts:189:toBeGreaterThan`, () => + expect(splitResponses).toBeGreaterThan(0), ) - }, - ) + }, + () => collection.cleanup(), + () => client.close(), + ) +} + +oracleProperty({ + id: `convex.backend-pages`, + law: { + statement: `A paginated Convex Collection mirrors the backend's complete task list after preload and after each insert, move, deletion and server split.`, + authority: `The real-backend contract stated at the top of this file; docs/collections/convex-collection.md; docs/contributing/oracle-coverage.md.`, + limits: `The isolated local backend, 23 seeded tasks, five authored writes, the list and split functions, through both installed SDKs.`, }, -) + histories: pageLoads, + label: ({ kind, functionName }) => + `real backend with ${kind} client: loads ${functionName} pages through inserts, moves, deletions, and server splits`, + resolve: (pageLoad) => ({ pageLoad }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`convex backend pages`, plan, observe, () => + loadPagesThroughWrites(plan.pageLoad), + ), + check: forwardAssertionJudgment, +}) async function durableHistory( draft: { clientId: string; text: string; rank: number; payload?: Value }, diff --git a/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts b/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts index 4e37a54b47..b4189c3af4 100644 --- a/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts +++ b/packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts @@ -11,7 +11,13 @@ import { import { ConvexClient } from 'convex/browser' import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' -import { expect, inject, it } from 'vitest' +import { expect, inject } from 'vitest' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { oracleProperty } from '../../db/tests/oracle-property' import { convexPaginatedCollectionOptions } from '../src/index' import { assertLazyCoverage, @@ -43,226 +49,244 @@ const all = makeFunctionReference<`query`, {}, Array>(`tasks:all`) const modelRows = (rows: ReadonlyArray): Array => rows.map(({ clientId, rank, text }) => ({ id: clientId, rank, text })) -it.each([`javascript`, `react`] as const)( - `receives lazy scopes, repair and reconnect with the %s SDK`, - async (kind) => { - const gate = await createNetworkGate(inject(`convexUrl`)) - const writer = new ConvexClient(inject(`convexUrl`), { - unsavedChangesWarning: false, - }) - const client = - kind === `javascript` - ? new ConvexClient(gate.url, { unsavedChangesWarning: false }) - : new ConvexReactClient(gate.url, { unsavedChangesWarning: false }) - const delivered = new Map() - let exhausted = false - let checkWork = true - let scope: LazyScope = { limit: 3 } - let required = 0 - let recommended = 0 - const started = new Set() - let successorStarts = 0 - const inspect = ( - value: PaginationResult, - opts: PaginationOptions, - ) => { - if (value.pageStatus === `SplitRequired`) required++ - else { - for (const row of modelRows(value.page)) delivered.set(row.id, row) - } - if (value.pageStatus === `SplitRecommended`) recommended++ - if (opts.endCursor == null && value.isDone) exhausted = true +const sdks = [`javascript`, `react`] as const + +// One SDK receives lazy scopes over real server cursors, then named mutation +// and reconnect scenes. Each checkpoint compares the live rows with +// expectedLazyRows and the source with the lazy coverage model. +async function receiveLazyScopes(kind: (typeof sdks)[number]): Promise { + const gate = await createNetworkGate(inject(`convexUrl`)) + const writer = new ConvexClient(inject(`convexUrl`), { + unsavedChangesWarning: false, + }) + const client = + kind === `javascript` + ? new ConvexClient(gate.url, { unsavedChangesWarning: false }) + : new ConvexReactClient(gate.url, { unsavedChangesWarning: false }) + const delivered = new Map() + let exhausted = false + let checkWork = true + let scope: LazyScope = { limit: 3 } + let required = 0 + let recommended = 0 + const started = new Set() + let successorStarts = 0 + const inspect = (value: PaginationResult, opts: PaginationOptions) => { + if (value.pageStatus === `SplitRequired`) required++ + else { + for (const row of modelRows(value.page)) delivered.set(row.id, row) } - const start = (args: { paginationOpts: PaginationOptions }) => { - const opts = args.paginationOpts - const key = JSON.stringify(opts) - if ( - checkWork && - opts.cursor !== null && - opts.endCursor == null && - !started.has(key) - ) { - expect( - deliveredScopeComplete([...delivered.values()], exhausted, scope), - `native lazy work: unrelated successor`, - ).toBe(false) - successorStarts++ - } - started.add(key) + if (value.pageStatus === `SplitRecommended`) recommended++ + if (opts.endCursor == null && value.isDone) exhausted = true + } + const start = (args: { paginationOpts: PaginationOptions }) => { + const opts = args.paginationOpts + const key = JSON.stringify(opts) + if ( + checkWork && + opts.cursor !== null && + opts.endCursor == null && + !started.has(key) + ) { + expect( + deliveredScopeComplete([...delivered.values()], exhausted, scope), + `native lazy work: unrelated successor`, + ).toBe(false) + successorStarts++ } - // These wrappers record request tuples and responses without producing rows, - // changing cancellation, or decoding the server's opaque cursor strings. - const transport: ConvexCollectionClient = - `onUpdate` in client - ? { - onUpdate: (query, args, callback, onError) => { - start(args as { paginationOpts: PaginationOptions }) - const subscription = client.onUpdate( - query, - args, - (value) => { + started.add(key) + } + // These wrappers record request tuples and responses without producing rows, + // changing cancellation, or decoding the server's opaque cursor strings. + const transport: ConvexCollectionClient = + `onUpdate` in client + ? { + onUpdate: (query, args, callback, onError) => { + start(args as { paginationOpts: PaginationOptions }) + const subscription = client.onUpdate( + query, + args, + (value) => { + inspect( + value as PaginationResult, + (args as { paginationOpts: PaginationOptions }) + .paginationOpts, + ) + callback(value) + }, + onError, + ) + return Object.assign(() => subscription(), { + unsubscribe: () => subscription(), + getCurrentValue: () => { + const value = subscription.getCurrentValue() + if (value !== undefined) inspect( value as PaginationResult, (args as { paginationOpts: PaginationOptions }) .paginationOpts, ) - callback(value) - }, - onError, - ) - return Object.assign(() => subscription(), { - unsubscribe: () => subscription(), - getCurrentValue: () => { - const value = subscription.getCurrentValue() - if (value !== undefined) - inspect( - value as PaginationResult, - (args as { paginationOpts: PaginationOptions }) - .paginationOpts, - ) - return value - }, - }) - }, - } - : { - watchQuery: (query, args, options) => { - start(args as { paginationOpts: PaginationOptions }) - const watch = client.watchQuery(query, args, options) - return { - ...watch, - localQueryResult: () => { - const value = watch.localQueryResult() - if (value !== undefined) - inspect( - value as PaginationResult, - (args as { paginationOpts: PaginationOptions }) - .paginationOpts, - ) - return value - }, - } - }, - } - const source = createCollection( - convexPaginatedCollectionOptions({ - client: transport, - query: makeFunctionReference< - `query`, - { paginationOpts: PaginationOptions }, - PaginationResult - >(`tasks:split`), - queryArgs: {}, - syncMode: `on-demand`, - queryOrder: { field: `rank`, direction: `asc` }, - pageSize: 4, - getKey: (row) => row.clientId, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - gcTime: 0, - }), - ) - const live = createLiveQueryCollection((q) => - q - .from({ row: source }) - .orderBy(({ row }) => row.rank) - .orderBy(({ row }) => row.clientId) - .select(({ row }) => row) - .limit(3), - ) - await withTestCleanup( - async () => { - await writer.mutation(seed, { count: 23 }) - source.startSyncImmediate() - expect(started.size).toBe(0) - await live.preload() - let truth = modelRows(await writer.query(all, {})) - expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) - assertLazyCoverage(modelRows([...source.values()]), truth, scope) - expect(source.size).toBeLessThan(truth.length) - scope = { offset: 2, limit: 7 } - await live.utils.setWindow(scope) - expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) - assertLazyCoverage(modelRows([...source.values()]), truth, scope) - expect(source.size).toBeLessThan(truth.length) - expect(successorStarts).toBeGreaterThan(0) - // Work comparison is declared only for stable acquisitions. These named - // mutation/reconnect scenes compare source coverage and actual live values. - checkWork = false - const check = async () => { - truth = modelRows(await writer.query(all, {})) - await source.utils.awaitSync() - await expect - .poll(() => modelRows(live.toArray), { timeout: 10000 }) - .toEqual(expectedLazyRows(truth, scope)) - assertLazyCoverage(modelRows([...source.values()]), truth, scope) + return value + }, + }) + }, } + : { + watchQuery: (query, args, options) => { + start(args as { paginationOpts: PaginationOptions }) + const watch = client.watchQuery(query, args, options) + return { + ...watch, + localQueryResult: () => { + const value = watch.localQueryResult() + if (value !== undefined) + inspect( + value as PaginationResult, + (args as { paginationOpts: PaginationOptions }) + .paginationOpts, + ) + return value + }, + } + }, + } + const source = createCollection( + convexPaginatedCollectionOptions({ + client: transport, + query: makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:split`), + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + pageSize: 4, + getKey: (row) => row.clientId, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + gcTime: 0, + }), + ) + const live = createLiveQueryCollection((q) => + q + .from({ row: source }) + .orderBy(({ row }) => row.rank) + .orderBy(({ row }) => row.clientId) + .select(({ row }) => row) + .limit(3), + ) + await withTestCleanup( + async () => { + await writer.mutation(seed, { count: 23 }) + source.startSyncImmediate() + expect(started.size).toBe(0) + await live.preload() + let truth = modelRows(await writer.query(all, {})) + expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + expect(source.size).toBeLessThan(truth.length) + scope = { offset: 2, limit: 7 } + await live.utils.setWindow(scope) + expect(modelRows(live.toArray)).toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + expect(source.size).toBeLessThan(truth.length) + expect(successorStarts).toBeGreaterThan(0) + // Work comparison is declared only for stable acquisitions. These named + // mutation/reconnect scenes compare source coverage and actual live values. + checkWork = false + const check = async () => { + truth = modelRows(await writer.query(all, {})) + await source.utils.awaitSync() + await expect + .poll(() => modelRows(live.toArray), { timeout: 10000 }) + .toEqual(expectedLazyRows(truth, scope)) + assertLazyCoverage(modelRows([...source.values()]), truth, scope) + } + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `2`, + rank: 2, + text: `deleted`, + remove: true, + }) + await check() + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `20`, + rank: -10, + text: `moved into unread prefix`, + }) + await check() + // A bounded interval that formerly contained a few rows now contains many. + // The real read budget forces SplitRequired, distinct from a hint. + for (let i = 0; i < 14; i++) await writer.mutation(write, { idempotencyKey: crypto.randomUUID(), - clientId: `2`, - rank: 2, - text: `deleted`, - remove: true, - }) - await check() - await writer.mutation(write, { - idempotencyKey: crypto.randomUUID(), - clientId: `20`, - rank: -10, - text: `moved into unread prefix`, + clientId: `dense${i}`, + rank: 0.01 + i / 100, + text: `dense`, }) - await check() - // A bounded interval that formerly contained a few rows now contains many. - // The real read budget forces SplitRequired, distinct from a hint. - for (let i = 0; i < 14; i++) - await writer.mutation(write, { - idempotencyKey: crypto.randomUUID(), - clientId: `dense${i}`, - rank: 0.01 + i / 100, - text: `dense`, - }) - await check() - expect( - required, - `native SplitRequired receiving premise`, - ).toBeGreaterThan(0) - // The leading boundary now crosses a native four-row rank tie. The - // trailing clientId order belongs to DB, independent of Convex's tie order. - for (const clientId of [`10`, `11`, `12`]) { - await writer.mutation(write, { - idempotencyKey: crypto.randomUUID(), - clientId, - rank: 0.05, - text: `native tie`, - }) - } - await check() - const nativeTieRows = truth.filter((row) => row.rank === 0.05).length - expect(nativeTieRows).toBe(4) - expect( - [...source.values()].filter((row) => row.rank === 0.05), - ).toHaveLength(nativeTieRows) - const beforeRun = source.status - gate.disconnect() + await check() + expect( + required, + `native SplitRequired receiving premise`, + ).toBeGreaterThan(0) + // The leading boundary now crosses a native four-row rank tie. The + // trailing clientId order belongs to DB, independent of Convex's tie order. + for (const clientId of [`10`, `11`, `12`]) { await writer.mutation(write, { idempotencyKey: crypto.randomUUID(), - clientId: `22`, - rank: -20, - text: `offline arrival`, + clientId, + rank: 0.05, + text: `native tie`, }) - gate.reconnect() - await check() - expect(source.status).toBe(beforeRun) - console.info( - `CONVEX_LAZY_BACKEND ${JSON.stringify({ kind, successorStarts, required, recommended, nativeTieRows, requestTuples: started.size, scope })}`, - ) - }, - () => live.cleanup(), - () => source.cleanup(), - () => client.close(), - () => writer.close(), - () => gate.close(), - ) + } + await check() + const nativeTieRows = truth.filter((row) => row.rank === 0.05).length + expect(nativeTieRows).toBe(4) + expect( + [...source.values()].filter((row) => row.rank === 0.05), + ).toHaveLength(nativeTieRows) + const beforeRun = source.status + gate.disconnect() + await writer.mutation(write, { + idempotencyKey: crypto.randomUUID(), + clientId: `22`, + rank: -20, + text: `offline arrival`, + }) + gate.reconnect() + await check() + expect(source.status).toBe(beforeRun) + console.info( + `CONVEX_LAZY_BACKEND ${JSON.stringify({ kind, successorStarts, required, recommended, nativeTieRows, requestTuples: started.size, scope })}`, + ) + }, + () => live.cleanup(), + () => source.cleanup(), + () => client.close(), + () => writer.close(), + () => gate.close(), + ) +} + +oracleProperty({ + id: `convex.lazy-backend`, + law: { + statement: `An on-demand paginated Collection over real server cursors shows the lazy model's window, and its source covers the model's scope, through scope changes, deletions, moves, dense splits, native ties and a reconnect.`, + authority: `The lazy paging model in tests/lazy-paging-model-oracle.ts and the receiving contract stated at the top of this file; docs/collections/convex-collection.md.`, + limits: `One seeded local backend, the split function, named mutation and reconnect scenes, through both installed SDKs; request work is compared only for stable acquisitions.`, }, - 60000, -) + histories: sdks, + label: (kind) => + `receives lazy scopes, repair and reconnect with the ${kind} SDK`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`convex lazy backend`, plan, observe, () => + receiveLazyScopes(plan.kind), + ), + check: forwardAssertionJudgment, + timeout: 60000, +}) diff --git a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts index 07ca2345e1..4cd4236cc7 100644 --- a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts +++ b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts @@ -42,7 +42,13 @@ import { createCollection } from '@tanstack/db' import { ConvexClient } from 'convex/browser' import { ConvexReactClient } from 'convex/react' import { makeFunctionReference } from 'convex/server' -import { describe, expect, inject, it } from 'vitest' +import { expect, inject } from 'vitest' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { oracleProperty } from '../../db/tests/oracle-property' import { convexPaginatedCollectionOptions } from '../src/index' import { withTestCleanup } from '../tests/helpers' import { createNetworkGate } from './network-gate' @@ -75,178 +81,192 @@ function expectedRows( // The backend starts with a new runtime directory and empty database. File and // test execution are sequential, so this ledger includes all successful writes // across the six histories, including rows outside the requested author scope. -describe.each([`javascript`, `react`] as const)( - `public pagination app with %s client`, - (kind) => { - it.each(queries)( - `mirrors %s through live writes and a real reconnect`, - async (name) => { - const gate = await createNetworkGate(backend) - const writer = new ConvexClient(backend, { +const mirrors = ([`javascript`, `react`] as const).flatMap((kind) => + queries.map((name) => ({ kind, name })), +) + +// One client mirrors one query through live writes, a real disconnect, and +// reconnect. Every select and publication observation must be a complete +// ledger revision, and each checkpoint must equal the model's rows. +async function mirrorThroughReconnect({ + kind, + name, +}: (typeof mirrors)[number]): Promise { + const gate = await createNetworkGate(backend) + const writer = new ConvexClient(backend, { + unsavedChangesWarning: false, + logger: false, + }) + const client = + kind === `javascript` + ? new ConvexClient(gate.url, { + unsavedChangesWarning: false, + logger: false, + }) + : new ConvexReactClient(gate.url, { unsavedChangesWarning: false, logger: false, }) - const client = - kind === `javascript` - ? new ConvexClient(gate.url, { - unsavedChangesWarning: false, - logger: false, - }) - : new ConvexReactClient(gate.url, { - unsavedChangesWarning: false, - logger: false, - }) - const author = `author:${kind}:${name}` - const query = makeFunctionReference< - `query`, - { paginationOpts: PaginationOptions; author?: string }, - PaginationResult - >(`messages:${name}`) - let selected: ReadonlyArray = [] - let recording = false - let disconnected = false - let allowedSnapshots: Array> = [] - const disconnectedSnapshots: Array> = [] - const observations: Array<{ - boundary: `select` | `publication` - rows: Array - allowed: Array> - }> = [] - const byBody = (a: Message, b: Message) => a.body.localeCompare(b.body) - // Snapshot the allowed revisions now. Extending the ledger later must - // not retroactively excuse an impossible earlier observation. Public - // Collection iteration order is not provider order; select preserves it. - const record = ( - boundary: `select` | `publication`, - rows: ReadonlyArray, - ) => { - if (!recording) return - const project = (snapshot: ReadonlyArray) => { - const projected = snapshot.map(({ author: from, body }) => ({ - author: from, - body, - })) - return boundary === `publication` - ? projected.sort(byBody) - : projected - } - observations.push({ - boundary, - rows: project(rows), - allowed: allowedSnapshots.map(project), - }) - } - const checkObservations = () => { - for (const observation of observations) - expect( - observation.allowed, - `${observation.boundary} must expose a complete ledger revision`, - ).toContainEqual(observation.rows) - } - const collection = createCollection( - convexPaginatedCollectionOptions({ - client, - query, - queryArgs: name === `listWithExtraArg` ? { author } : {}, - pageSize: 3, - select: (rows) => { - selected = rows.map(({ author: from, body }) => ({ - author: from, - body, - })) - record(`select`, selected) - return rows - }, - getKey: (row) => row.body, - gcTime: 0, - }), - ) - const publish = async (from: string) => { - const message = { - author: from, - body: `message ${ledger.length} from ${from}`, - } - if (recording) { - const next = expectedRows(name, author, [...ledger, message]) - if (disconnected) disconnectedSnapshots.push(next) - else allowedSnapshots.push(next) - } - await writer.mutation(send, message) - ledger.push(message) - } - const check = async (waitForUpdate = true) => { - const expected = expectedRows(name, author) - if (waitForUpdate) - await expect - .poll(() => selected, { timeout: 15000 }) - .toEqual(expected) - else expect(selected).toEqual(expected) - await collection.utils.awaitSync() - expect( - [...collection.values()] - .map(({ author: from, body }) => ({ author: from, body })) - .sort(byBody), - ).toEqual([...expected].sort(byBody)) - checkObservations() - // Both cuts have reached this complete source revision. A later - // source phase may repeat it or advance through new complete revisions. - allowedSnapshots = [expected] - } - let subscription: - ReturnType | undefined - await withTestCleanup( - async () => { - for (let index = 0; index < 17; index++) - await publish(index % 2 === 0 ? author : `other:${author}`) - allowedSnapshots = [expectedRows(name, author)] - recording = true - subscription = collection.subscribeChanges(() => { - record(`publication`, [...collection.values()]) - }) - await collection.preload() - await check(false) - await publish(author) - await check() - await publish(`other:${author}`) - await check() - const before = [...selected] - disconnected = true - gate.disconnect() - await expect - .poll(() => client.connectionState().isWebSocketConnected) - .toBe(false) - await publish(author) - await publish(`other:${author}`) - expect(selected).toEqual(before) - expect( - [...collection.values()] - .map(({ author: from, body }) => ({ author: from, body })) - .sort(byBody), - ).toEqual([...before].sort(byBody)) - checkObservations() - allowedSnapshots.push(...disconnectedSnapshots) - disconnected = false - gate.reconnect() - await check() - expect( - observations.some( - (observation) => observation.boundary === `select`, - ), - ).toBe(true) - expect( - observations.some( - (observation) => observation.boundary === `publication`, - ), - ).toBe(true) - }, - () => subscription?.unsubscribe(), - () => collection.cleanup(), - () => client.close(), - () => writer.close(), - () => gate.close(), - ) + const author = `author:${kind}:${name}` + const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions; author?: string }, + PaginationResult + >(`messages:${name}`) + let selected: ReadonlyArray = [] + let recording = false + let disconnected = false + let allowedSnapshots: Array> = [] + const disconnectedSnapshots: Array> = [] + const observations: Array<{ + boundary: `select` | `publication` + rows: Array + allowed: Array> + }> = [] + const byBody = (a: Message, b: Message) => a.body.localeCompare(b.body) + // Snapshot the allowed revisions now. Extending the ledger later must + // not retroactively excuse an impossible earlier observation. Public + // Collection iteration order is not provider order; select preserves it. + const record = ( + boundary: `select` | `publication`, + rows: ReadonlyArray, + ) => { + if (!recording) return + const project = (snapshot: ReadonlyArray) => { + const projected = snapshot.map(({ author: from, body }) => ({ + author: from, + body, + })) + return boundary === `publication` ? projected.sort(byBody) : projected + } + observations.push({ + boundary, + rows: project(rows), + allowed: allowedSnapshots.map(project), + }) + } + const checkObservations = () => { + for (const observation of observations) + expect( + observation.allowed, + `${observation.boundary} must expose a complete ledger revision`, + ).toContainEqual(observation.rows) + } + const collection = createCollection( + convexPaginatedCollectionOptions({ + client, + query, + queryArgs: name === `listWithExtraArg` ? { author } : {}, + pageSize: 3, + select: (rows) => { + selected = rows.map(({ author: from, body }) => ({ + author: from, + body, + })) + record(`select`, selected) + return rows }, - 45000, - ) + getKey: (row) => row.body, + gcTime: 0, + }), + ) + const publish = async (from: string) => { + const message = { + author: from, + body: `message ${ledger.length} from ${from}`, + } + if (recording) { + const next = expectedRows(name, author, [...ledger, message]) + if (disconnected) disconnectedSnapshots.push(next) + else allowedSnapshots.push(next) + } + await writer.mutation(send, message) + ledger.push(message) + } + const check = async (waitForUpdate = true) => { + const expected = expectedRows(name, author) + if (waitForUpdate) + await expect.poll(() => selected, { timeout: 15000 }).toEqual(expected) + else expect(selected).toEqual(expected) + await collection.utils.awaitSync() + expect( + [...collection.values()] + .map(({ author: from, body }) => ({ author: from, body })) + .sort(byBody), + ).toEqual([...expected].sort(byBody)) + checkObservations() + // Both cuts have reached this complete source revision. A later + // source phase may repeat it or advance through new complete revisions. + allowedSnapshots = [expected] + } + let subscription: ReturnType | undefined + await withTestCleanup( + async () => { + for (let index = 0; index < 17; index++) + await publish(index % 2 === 0 ? author : `other:${author}`) + allowedSnapshots = [expectedRows(name, author)] + recording = true + subscription = collection.subscribeChanges(() => { + record(`publication`, [...collection.values()]) + }) + await collection.preload() + await check(false) + await publish(author) + await check() + await publish(`other:${author}`) + await check() + const before = [...selected] + disconnected = true + gate.disconnect() + await expect + .poll(() => client.connectionState().isWebSocketConnected) + .toBe(false) + await publish(author) + await publish(`other:${author}`) + expect(selected).toEqual(before) + expect( + [...collection.values()] + .map(({ author: from, body }) => ({ author: from, body })) + .sort(byBody), + ).toEqual([...before].sort(byBody)) + checkObservations() + allowedSnapshots.push(...disconnectedSnapshots) + disconnected = false + gate.reconnect() + await check() + expect( + observations.some((observation) => observation.boundary === `select`), + ).toBe(true) + expect( + observations.some( + (observation) => observation.boundary === `publication`, + ), + ).toBe(true) + }, + () => subscription?.unsubscribe(), + () => collection.cleanup(), + () => client.close(), + () => writer.close(), + () => gate.close(), + ) +} + +oracleProperty({ + id: `convex.public-pagination`, + law: { + statement: `A paginated Collection over the public pagination app mirrors the append-only ledger, in reverse insertion order with the query's author predicate and text transformation, through live writes and a real reconnect; every observation is a complete ledger revision.`, + authority: `The public pagination application and contract stated at the top of this file; docs/collections/convex-collection.md.`, + limits: `Three queries through both SDK clients against one local backend, with sequential histories sharing one ledger; no concurrent writers or server cursor claim.`, }, -) + histories: mirrors, + label: ({ kind, name }) => + `public pagination app with ${kind} client: mirrors ${name} through live writes and a real reconnect`, + resolve: (mirror) => ({ mirror }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`convex public pagination`, plan, observe, () => + mirrorThroughReconnect(plan.mirror), + ), + check: forwardAssertionJudgment, + timeout: 45000, +}) diff --git a/packages/convex-db-collection/package.json b/packages/convex-db-collection/package.json index 17f0e4ceb7..f7ea1c59b6 100644 --- a/packages/convex-db-collection/package.json +++ b/packages/convex-db-collection/package.json @@ -21,7 +21,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest run", - "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/ownership-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts tests/lazy-paging-oracle.property.test.ts tests/lazy-paging-history-oracle.property.test.ts tests/lazy-paging-receipt-oracle.property.test.ts tests/sdk-cache-oracle.test.ts tests/lazy-paging-interleaving-oracle.property.test.ts tests/lazy-paging-native-receipt-oracle.property.test.ts tests/lazy-paging-version-oracle.property.test.ts tests/lazy-paging-failure-history-oracle.property.test.ts tests/lazy-paging-revalidation-oracle.property.test.ts tests/lazy-paging-load-outcome-oracle.property.test.ts tests/lazy-paging-abort-replay-oracle.property.test.ts tests/lazy-paging-write-model-oracle.test.ts tests/persisted-startup-conformance-oracle.property.test.ts", + "test:oracles": "vitest run tests/snapshot-oracle.property.test.ts tests/ownership-oracle.property.test.ts tests/pagination-oracle.property.test.ts tests/offline-oracle.property.test.ts tests/values-oracle.property.test.ts tests/lazy-paging-oracle.property.test.ts tests/lazy-paging-history-oracle.property.test.ts tests/lazy-paging-receipt-oracle.property.test.ts tests/sdk-cache-oracle.test.ts tests/lazy-paging-interleaving-oracle.property.test.ts tests/lazy-paging-native-receipt-oracle.property.test.ts tests/lazy-paging-version-oracle.property.test.ts tests/lazy-paging-failure-history-oracle.property.test.ts tests/lazy-paging-revalidation-oracle.property.test.ts tests/lazy-paging-load-outcome-oracle.property.test.ts tests/lazy-paging-abort-replay-oracle.property.test.ts tests/persisted-startup-conformance-oracle.property.test.ts", "typecheck": "tsc --noEmit", "test:e2e": "vitest run --config vitest.e2e.config.ts", "test:e2e:lazy": "vitest run --config vitest.lazy.e2e.config.ts" diff --git a/packages/convex-db-collection/tests/calibration-rejection.ts b/packages/convex-db-collection/tests/calibration-rejection.ts new file mode 100644 index 0000000000..01bab4a094 --- /dev/null +++ b/packages/convex-db-collection/tests/calibration-rejection.ts @@ -0,0 +1,35 @@ +// Transport for a calibration whose mutant driver must be rejected by the +// existing comparison. The old calibration test accepted only the rejections +// that its Vitest matcher accepted; `rejectsAs` keeps exactly that matcher. +// It marks an accepted rejection as the comparison's verdict at the old +// checkpoint, so recordAssertionJudgment reports it under the property's law. +// Any other rejection becomes a new, unmarked driver error, so no checkpoint +// inside it counts. A mutant that resolves records no verdict here. +import { rejectAt } from '../../db/tests/oracle-assertion-adapter' + +export async function rejectsAs( + checkpoint: string, + run: () => Promise, + accepts: (failure: unknown) => void, +): Promise { + let rejected = false + let failure: unknown + try { + await run() + } catch (error) { + rejected = true + failure = error + } + if (!rejected) return + try { + accepts(failure) + } catch { + throw new Error( + `${checkpoint}: the mutant failed in a way that its calibration does not accept: ${String(failure)}`, + ) + } + rejectAt( + checkpoint, + failure instanceof Error ? failure : new Error(String(failure)), + ) +} diff --git a/packages/convex-db-collection/tests/lazy-paging-config.test.ts b/packages/convex-db-collection/tests/lazy-paging-config.test.ts new file mode 100644 index 0000000000..f3ddfa4826 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-config.test.ts @@ -0,0 +1,58 @@ +// Invalid lazy row caps reject before readiness. Each case compares the real +// paginated adapter with written expectations, so it is an ordinary test, not +// a history of lazy-paging-oracle.property.test.ts. +import { createCollection } from '@tanstack/db' +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { convexPaginatedCollectionOptions } from '../src/index' +import { withTestCleanup } from './helpers' +import { lazyFixture, lazyQuery } from './lazy-paging-driver' + +it.each([0, -1, 1.5, Infinity, NaN])( + `rejects invalid lazy row caps before readiness: %s`, + async (maxRows) => { + const f = lazyFixture([], 1) + await f.source.cleanup() + const source = createCollection( + convexPaginatedCollectionOptions({ + client: f.remote.client, + query: lazyQuery, + queryArgs: {}, + syncMode: `on-demand`, + queryOrder: { field: `rank`, direction: `asc` }, + maxRows, + getKey: (row) => row.id, + }), + ) + const statuses: Array = [] + const off = source.on(`status:change`, ({ status }) => { + statuses.push(status) + }) + await withTestCleanup( + async () => { + await oracleAssertion( + `lazy-paging-oracle.property.test.ts:986:toBeInstanceOf`, + () => expect(source.preload()).rejects.toBeInstanceOf(Error), + ) + oracleAssertion( + `lazy-paging-oracle.property.test.ts:987:toBeInstanceOf`, + () => expect(source.utils.getSyncError()).toBeInstanceOf(RangeError), + ) + oracleAssertion( + `lazy-paging-oracle.property.test.ts:988:toContain`, + () => + expect( + statuses, + `lazy startup: invalid provider config reported ready`, + ).not.toContain(`ready`), + ) + oracleAssertion( + `lazy-paging-oracle.property.test.ts:992:toHaveLength`, + () => expect(f.remote.slots).toHaveLength(0), + ) + }, + off, + () => source.cleanup(), + ) + }, +) diff --git a/packages/convex-db-collection/tests/lazy-paging-failure-history-model.test.ts b/packages/convex-db-collection/tests/lazy-paging-failure-history-model.test.ts new file mode 100644 index 0000000000..0ec0dc9539 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-failure-history-model.test.ts @@ -0,0 +1,28 @@ +// A model calibration of LazyTerminalGraphModel: old graphs may not recover or +// change after cleanup. It runs no production; +// lazy-paging-failure-history-oracle.property.test.ts judges production with +// the same model. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { LazyTerminalGraphModel } from './lazy-paging-model-oracle' + +it(`rejects old graphs recovering or changing after cleanup`, () => { + const row = { id: `one`, rank: 1, text: `authored` } + const model = new LazyTerminalGraphModel() + model.cleanup([row]) + model.observe(`crashed`, [row]) + oracleAssertion( + `lazy-paging-failure-history-oracle.property.test.ts:459:toThrow`, + () => + expect(() => model.observe(`ready`, [row])).toThrow( + `old graph recovered`, + ), + ) + oracleAssertion( + `lazy-paging-failure-history-oracle.property.test.ts:460:toThrow`, + () => + expect(() => + model.observe(`crashed`, [{ ...row, text: `new run` }]), + ).toThrow(`dead graph published`), + ) +}) 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 7e2d687c88..c323908dae 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 @@ -24,7 +24,7 @@ */ import { createLiveQueryCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -32,7 +32,6 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { @@ -558,25 +557,3 @@ oracleProperty({ ), timeout: 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]) - model.observe(`crashed`, [row]) - oracleAssertion( - `lazy-paging-failure-history-oracle.property.test.ts:459:toThrow`, - () => - expect(() => model.observe(`ready`, [row])).toThrow( - `old graph recovered`, - ), - ) - oracleAssertion( - `lazy-paging-failure-history-oracle.property.test.ts:460:toThrow`, - () => - expect(() => - model.observe(`crashed`, [{ ...row, text: `new run` }]), - ).toThrow(`dead graph published`), - ) -}) diff --git a/packages/convex-db-collection/tests/lazy-paging-interleaving-model.test.ts b/packages/convex-db-collection/tests/lazy-paging-interleaving-model.test.ts new file mode 100644 index 0000000000..d9ce451d0b --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-interleaving-model.test.ts @@ -0,0 +1,26 @@ +// A model rule of the lazy receipt settlement model, checked by hand without +// production. lazy-paging-interleaving-oracle.property.test.ts judges +// production with the same LazyReceiptModel. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { LazyReceiptModel } from './lazy-paging-receipt-model' + +it(`permits core fulfillment after abort once an uncancelable baseline applies`, () => { + const model = new LazyReceiptModel() + model.acquire(`a`, [`v1`]) + model.initiate(`a`, `baseline`) + model.abort(`a`) + model.accept(`receipt`, [`v1`], [`a`]) + oracleAssertion( + `lazy-paging-interleaving-oracle.property.test.ts:437:toThrow`, + () => expect(() => model.observe(`a`, `success`)).toThrow(`in-flight work`), + ) + model.finish(`a`, `baseline`) + oracleAssertion( + `lazy-paging-interleaving-oracle.property.test.ts:439:toThrow`, + () => expect(() => model.observe(`a`, `success`)).toThrow(`receipt`), + ) + model.apply(`receipt`) + model.observe(`a`, `success`) + model.complete() +}) 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 634583992a..f1f54b5bb5 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 @@ -17,7 +17,7 @@ */ import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -25,7 +25,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' +import { rejectsAs } from './calibration-rejection' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { SyncAppliedReceipt, SyncConfig } from '@tanstack/db' @@ -429,6 +429,30 @@ oracleProperty({ `uncancelable-accept-after-abort`, `abort-with-only-inflight-work`, ] as const, + // The fault calibrations below corrupt these authored interleavings: the + // premature in-flight cancellation needs the cancel at the first response. + fixed: [ + { + name: `three responses and batches, cancel at the second`, + history: { + responses: 3, + batches: 3, + choices: [0], + cancelAt: 2, + uncancelable: true, + }, + }, + { + name: `three responses and batches, cancel at the first`, + history: { + responses: 3, + batches: 3, + choices: [0], + cancelAt: 1, + uncancelable: true, + }, + }, + ], constructed: [ { name: `existing constructive history`, @@ -454,50 +478,27 @@ oracleProperty({ reach: (state) => observe(`reach`, state), }), ), + calibrations: ( + [`borrow-peer`, `first-only`, `premature-inflight-cancel`] as const + ).map((fault) => ({ + name: `rejects ${fault} under interleaving`, + law: `convex.lazy-interleaving`, + driver: async ( + plan: { + history: AssertionHistory + }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`convex.lazy-interleaving`, plan, observe, () => + rejectsAs( + `lazy-paging-interleaving-oracle.property.test.ts:414:toThrow`, + () => runInterleaving({ ...plan.history, constructive: true }, fault), + (failure) => + expect(() => { + throw failure + }).toThrow(/receipt (law|model)/), + ), + ), + })), timeout: 60000, }) - -it.each([`borrow-peer`, `first-only`, `premature-inflight-cancel`] as const)( - `rejects %s under interleaving`, - async (fault) => { - oracleRoute(`calibration`) - await oracleAssertion( - `lazy-paging-interleaving-oracle.property.test.ts:414:toThrow`, - () => - expect( - runInterleaving( - { - responses: 3, - batches: 3, - choices: [0], - cancelAt: fault === `premature-inflight-cancel` ? 1 : 2, - constructive: true, - uncancelable: true, - }, - fault, - ), - ).rejects.toThrow(/receipt (law|model)/), - ) - }, -) - -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`) - model.abort(`a`) - model.accept(`receipt`, [`v1`], [`a`]) - oracleAssertion( - `lazy-paging-interleaving-oracle.property.test.ts:437:toThrow`, - () => expect(() => model.observe(`a`, `success`)).toThrow(`in-flight work`), - ) - model.finish(`a`, `baseline`) - oracleAssertion( - `lazy-paging-interleaving-oracle.property.test.ts:439:toThrow`, - () => expect(() => model.observe(`a`, `success`)).toThrow(`receipt`), - ) - model.apply(`receipt`) - model.observe(`a`, `success`) - model.complete() -}) diff --git a/packages/convex-db-collection/tests/lazy-paging-model.test.ts b/packages/convex-db-collection/tests/lazy-paging-model.test.ts new file mode 100644 index 0000000000..d5461036f3 --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-model.test.ts @@ -0,0 +1,52 @@ +// Checker and model calibrations for lazy-paging-oracle.property.test.ts: +// hand-built wrong windows that the shared coverage and publication checks +// must reject. They run no production. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { + assertLazyCoverage, + assertLazyPublications, + deliveredScopeComplete, + expectedLazyRows, +} from './lazy-paging-model-oracle' + +// Wrong-answer controls distinguish missing same-rank coverage, skipped +// overshoot, transient publication and hidden-suffix knowledge independently +// 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`, () => { + const rows = [ + { id: `a`, rank: 1, text: `` }, + { id: `b`, rank: 1, text: `` }, + { id: `c`, rank: 2, text: `` }, + ] + oracleAssertion(`lazy-paging-oracle.property.test.ts:942:toThrow`, () => + expect(() => assertLazyCoverage([rows[0]!], rows, { limit: 1 })).toThrow( + `lazy coverage`, + ), + ) + oracleAssertion(`lazy-paging-oracle.property.test.ts:945:toThrow`, () => + expect(() => assertLazyPublications([[rows[0]!]], [], rows)).toThrow( + `lazy publication`, + ), + ) + for (const suffix of [ + [{ id: `c`, rank: 3, text: `` }], + [{ id: `d`, rank: 2, text: `` }], + ]) { + const delivered = [ + { id: `a`, rank: 1, text: `` }, + { id: `b`, rank: 2, text: `` }, + ] + oracleAssertion(`lazy-paging-oracle.property.test.ts:956:toBe`, () => + expect(deliveredScopeComplete(delivered, false, { limit: 2 })).toBe( + false, + ), + ) + oracleAssertion(`lazy-paging-oracle.property.test.ts:957:toEqual`, () => + expect(expectedLazyRows([...delivered, ...suffix], { limit: 2 })).toEqual( + delivered, + ), + ) + } +}) 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 bc0dece342..479624f93f 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 @@ -14,7 +14,7 @@ */ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -22,7 +22,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' +import { rejectsAs } from './calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { LazyReceiptModel } from './lazy-paging-receipt-model' @@ -355,6 +355,20 @@ oracleProperty({ `javascript`, `react`, ] as const, + // The startup-receipt calibration below corrupts this history. + fixed: [ + { + name: `eight rows in pages of two through the JavaScript receiver`, + history: { + size: 8, + pageSize: 2, + descending: false, + react: false, + reverse: false, + text: ``, + }, + }, + ], constructed: [ ...([false, true] as const).map((react) => ({ name: react @@ -388,27 +402,31 @@ oracleProperty({ reach: (state) => observe(`reach`, state), }), ), - timeout: 60000, -}) - -it(`rejects a native receiver borrowing the applied startup receipt`, async () => { - oracleRoute(`calibration`) - await oracleAssertion( - `lazy-paging-native-receipt-oracle.property.test.ts:317:toThrow`, - () => - expect( - nativeHistory( - { - size: 8, - pageSize: 2, - descending: false, - react: false, - cancel: false, - reverse: false, - text: ``, - }, - true, + calibrations: [ + { + name: `rejects a native receiver borrowing the applied startup receipt`, + law: `convex.lazy-native-receipt`, + driver: async ( + plan: { + history: AssertionHistory + }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `convex.lazy-native-receipt`, + plan, + observe, + () => + rejectsAs( + `lazy-paging-native-receipt-oracle.property.test.ts:317:toThrow`, + () => nativeHistory({ ...plan.history, cancel: false }, true), + (failure) => + expect(() => { + throw failure + }).toThrow(/receipt law/), + ), ), - ).rejects.toThrow(/receipt law/), - ) + }, + ], + timeout: 60000, }) 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 627304f0e1..4021872ec2 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 @@ -20,13 +20,8 @@ * diagnostic and does not claim arbitrary nonquiescent termination coverage. */ import fc from 'fast-check' -import { - IR, - createCollection, - createLiveQueryCollection, - createTransaction, -} from '@tanstack/db' -import { expect, it } from 'vitest' +import { IR, createLiveQueryCollection, createTransaction } from '@tanstack/db' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -34,20 +29,12 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' -import { convexPaginatedCollectionOptions } from '../src/index' import { withTestCleanup } from './helpers' -import { - lazyFixture, - lazyOrder, - lazyQuery, - lazyRequest, -} from './lazy-paging-driver' +import { lazyFixture, lazyOrder, lazyRequest } from './lazy-paging-driver' import { assertLazyCoverage, assertLazyPublications, copyLazyRows, - deliveredScopeComplete, expectedLazyRows, ordered, } from './lazy-paging-model-oracle' @@ -1133,95 +1120,3 @@ oracleProperty({ ), timeout: 60000, }) - -// Wrong-answer controls distinguish missing same-rank coverage, skipped -// overshoot, transient publication and hidden-suffix knowledge independently -// 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: `` }, - { id: `c`, rank: 2, text: `` }, - ] - oracleAssertion(`lazy-paging-oracle.property.test.ts:942:toThrow`, () => - expect(() => assertLazyCoverage([rows[0]!], rows, { limit: 1 })).toThrow( - `lazy coverage`, - ), - ) - oracleAssertion(`lazy-paging-oracle.property.test.ts:945:toThrow`, () => - expect(() => assertLazyPublications([[rows[0]!]], [], rows)).toThrow( - `lazy publication`, - ), - ) - for (const suffix of [ - [{ id: `c`, rank: 3, text: `` }], - [{ id: `d`, rank: 2, text: `` }], - ]) { - const delivered = [ - { id: `a`, rank: 1, text: `` }, - { id: `b`, rank: 2, text: `` }, - ] - oracleAssertion(`lazy-paging-oracle.property.test.ts:956:toBe`, () => - expect(deliveredScopeComplete(delivered, false, { limit: 2 })).toBe( - false, - ), - ) - oracleAssertion(`lazy-paging-oracle.property.test.ts:957:toEqual`, () => - expect(expectedLazyRows([...delivered, ...suffix], { limit: 2 })).toEqual( - delivered, - ), - ) - } -}) - -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( - convexPaginatedCollectionOptions({ - client: f.remote.client, - query: lazyQuery, - queryArgs: {}, - syncMode: `on-demand`, - queryOrder: { field: `rank`, direction: `asc` }, - maxRows, - getKey: (row) => row.id, - }), - ) - const statuses: Array = [] - const off = source.on(`status:change`, ({ status }) => { - statuses.push(status) - }) - await withTestCleanup( - async () => { - await oracleAssertion( - `lazy-paging-oracle.property.test.ts:986:toBeInstanceOf`, - () => expect(source.preload()).rejects.toBeInstanceOf(Error), - ) - oracleAssertion( - `lazy-paging-oracle.property.test.ts:987:toBeInstanceOf`, - () => expect(source.utils.getSyncError()).toBeInstanceOf(RangeError), - ) - oracleAssertion( - `lazy-paging-oracle.property.test.ts:988:toContain`, - () => - expect( - statuses, - `lazy startup: invalid provider config reported ready`, - ).not.toContain(`ready`), - ) - oracleAssertion( - `lazy-paging-oracle.property.test.ts:992:toHaveLength`, - () => expect(f.remote.slots).toHaveLength(0), - ) - }, - off, - () => source.cleanup(), - ) - }, -) 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 017579655d..b6b1f85975 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 @@ -21,7 +21,7 @@ */ import { createCollection, createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -29,7 +29,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' +import { rejectsAs } from './calibration-rejection' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { @@ -483,6 +483,13 @@ oracleProperty({ `true-and-throw`, `quiescent-settlement`, ] as const, + // The faulty-adapter calibrations below corrupt this history. + fixed: [ + { + name: `three responses in two batches`, + history: { responses: 3, batches: 2, reverse: false, text: `` }, + }, + ], constructed: [ { name: `existing constructive history`, @@ -510,35 +517,40 @@ oracleProperty({ reach: (state) => observe(`reach`, state), }), ), + // The prompt-cancel fault is observable only on a canceled history, as in + // the old calibration; every other fault runs the uncanceled history. + calibrations: ( + [ + `borrow-peer`, + `first-only`, + `skip-response`, + `content-only`, + `prompt-cancel`, + `stranded`, + ] as const + ).map((fault) => ({ + name: `rejects the ${fault} faulty adapter at the real Collection boundary`, + law: `convex.lazy-exact-receipt`, + driver: async ( + plan: { + history: AssertionHistory + }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`convex.lazy-exact-receipt`, plan, observe, () => + rejectsAs( + `lazy-paging-receipt-oracle.property.test.ts:434:toThrow`, + () => + receiptHistory( + { ...plan.history, cancel: fault === `prompt-cancel` }, + fault, + ), + (failure) => + expect(() => { + throw failure + }).toThrow(/receipt (law|progress)/), + ), + ), + })), timeout: 60000, }) - -it.each([ - `borrow-peer`, - `first-only`, - `skip-response`, - `content-only`, - `prompt-cancel`, - `stranded`, -] as const)( - `rejects the %s faulty adapter at the real Collection boundary`, - async (fault) => { - oracleRoute(`calibration`) - await oracleAssertion( - `lazy-paging-receipt-oracle.property.test.ts:434:toThrow`, - () => - expect( - receiptHistory( - { - responses: 3, - batches: 2, - cancel: fault === `prompt-cancel`, - reverse: false, - text: ``, - }, - fault, - ), - ).rejects.toThrow(/receipt (law|progress)/), - ) - }, -) diff --git a/packages/convex-db-collection/tests/lazy-paging-revalidation-model.test.ts b/packages/convex-db-collection/tests/lazy-paging-revalidation-model.test.ts new file mode 100644 index 0000000000..1127fc6eff --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-revalidation-model.test.ts @@ -0,0 +1,71 @@ +// Model calibrations for lazy-paging-revalidation-oracle.property.test.ts: +// each steps LazyReceiptModel or LazyVersionModel by hand and requires the +// rule to reject a wrong settlement or publication. They run no production. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { LazyReceiptModel } from './lazy-paging-receipt-model' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' + +it(`calibrates cleanup authority without treating cleanup as receipt application`, () => { + const model = new LazyReceiptModel() + model.acquire(`a`, [`equal`]) + model.accept(`actual`, [`equal`], [`a`]) + model.abort(`a`) + oracleAssertion( + `lazy-paging-revalidation-oracle.property.test.ts:374:toThrow`, + () => + expect(() => model.abandon(`actual`)).toThrow( + /abandonment without pending cleanup work/, + ), + ) + model.cleanup() + oracleAssertion( + `lazy-paging-revalidation-oracle.property.test.ts:378:toThrow`, + () => + expect(() => model.observe(`a`, `canceled`)).toThrow( + /settled before actual applied/, + ), + ) + model.abandon(`actual`) + oracleAssertion( + `lazy-paging-revalidation-oracle.property.test.ts:382:toThrow`, + () => + expect(() => model.observe(`a`, `success`)).toThrow( + /settled before actual applied/, + ), + ) + model.observe(`a`, `canceled`) + model.complete() +}) + +it(`calibrates exact window order while accepting coherent skipped revisions`, () => { + const a = { id: `a`, rank: 0, text: `old` } + const b = { id: `b`, rank: 1, text: `old` } + const latest = [ + { ...a, text: `latest` }, + { ...b, text: `latest` }, + ] + const model = new LazyVersionModel(`sequence`) + model.offer([a, b]) + model.offer([ + { ...a, text: `middle` }, + { ...b, text: `middle` }, + ]) + model.offer(latest) + model.observe([a, b]) + oracleAssertion( + `lazy-paging-revalidation-oracle.property.test.ts:405:toThrow`, + () => + expect(() => model.observe([b, a])).toThrow( + /mixed revision or public rollback/, + ), + ) + model.observe(latest) + oracleAssertion( + `lazy-paging-revalidation-oracle.property.test.ts:409:toThrow`, + () => + expect(() => model.observe([a, b])).toThrow( + /mixed revision or public rollback/, + ), + ) +}) 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 d7c03b2531..d505806062 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 @@ -24,7 +24,7 @@ */ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -32,7 +32,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' +import { rejectsAs } from './calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { lazyOracleBaseRuns, receiveBothSdks } from './lazy-paging-campaign' @@ -394,6 +394,20 @@ oracleProperty({ `javascript`, `react`, ] as const, + // The applied-content calibration below corrupts this history, so it is + // the first named history that the calibration tries. + fixed: [ + { + name: `an equal-valued held receipt whose first caller aborts`, + history: { + size: 5, + pageSize: 2, + descending: false, + cancelFirst: true, + text: ``, + }, + }, + ], constructed: [ { name: `existing constructive history`, @@ -421,92 +435,22 @@ oracleProperty({ reach: (state) => observe(`reach`, state), }), ), - timeout: 60000, -}) - -it(`rejects applied-content substitution for an equal-valued held receipt`, async () => { - oracleRoute(`calibration`) - await oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:359:toThrow`, - () => - expect( - receive( - { - size: 5, - pageSize: 2, - descending: false, - cancelFirst: true, - text: ``, - }, - `javascript`, - true, + calibrations: [ + { + name: `rejects applied-content substitution for an equal-valued held receipt`, + law: `convex.revalidation`, + driver: async (plan: { history: History }, observe) => + recordAssertionJudgment(`convex.revalidation`, plan, observe, () => + rejectsAs( + `lazy-paging-revalidation-oracle.property.test.ts:359:toThrow`, + () => receive(plan.history, `javascript`, true), + (failure) => + expect(() => { + throw failure + }).toThrow(/receipt law/), + ), ), - ).rejects.toThrow(/receipt law/), - ) -}) - -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`]) - model.abort(`a`) - oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:374:toThrow`, - () => - expect(() => model.abandon(`actual`)).toThrow( - /abandonment without pending cleanup work/, - ), - ) - model.cleanup() - oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:378:toThrow`, - () => - expect(() => model.observe(`a`, `canceled`)).toThrow( - /settled before actual applied/, - ), - ) - model.abandon(`actual`) - oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:382:toThrow`, - () => - expect(() => model.observe(`a`, `success`)).toThrow( - /settled before actual applied/, - ), - ) - model.observe(`a`, `canceled`) - model.complete() -}) - -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 = [ - { ...a, text: `latest` }, - { ...b, text: `latest` }, - ] - const model = new LazyVersionModel(`sequence`) - model.offer([a, b]) - model.offer([ - { ...a, text: `middle` }, - { ...b, text: `middle` }, - ]) - model.offer(latest) - model.observe([a, b]) - oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:405:toThrow`, - () => - expect(() => model.observe([b, a])).toThrow( - /mixed revision or public rollback/, - ), - ) - model.observe(latest) - oracleAssertion( - `lazy-paging-revalidation-oracle.property.test.ts:409:toThrow`, - () => - expect(() => model.observe([a, b])).toThrow( - /mixed revision or public rollback/, - ), - ) + }, + ], + timeout: 60000, }) diff --git a/packages/convex-db-collection/tests/lazy-paging-version-model.test.ts b/packages/convex-db-collection/tests/lazy-paging-version-model.test.ts new file mode 100644 index 0000000000..9218f2612a --- /dev/null +++ b/packages/convex-db-collection/tests/lazy-paging-version-model.test.ts @@ -0,0 +1,18 @@ +// A model rule of LazyVersionModel, checked by hand without production. +// lazy-paging-version-oracle.property.test.ts judges production with it. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { LazyVersionModel } from './lazy-paging-version-model-oracle' + +it(`allows stuttering, skipped and observationally identical revisions`, () => { + const a = [{ id: `a`, rank: 0, text: `first` }] + const b = [{ id: `a`, rank: 0, text: `middle` }] + const c = [{ id: `a`, rank: 0, text: `last` }] + const model = new LazyVersionModel() + for (const rows of [a, a, b, c]) model.offer(rows) + for (const rows of [a, a, c, c]) model.observe(rows) + oracleAssertion( + `lazy-paging-version-oracle.property.test.ts:258:toThrow`, + () => expect(() => model.observe(a)).toThrow(`lazy version`), + ) +}) 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 b8f07b9768..d3d003717f 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 @@ -24,7 +24,7 @@ */ import { createTransaction } from '@tanstack/db' import fc from 'fast-check' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -32,7 +32,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { oracleRoute } from '../../db/tests/oracle-config' +import { rejectsAs } from './calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { assertLazySourceFacts } from './lazy-paging-model-oracle' @@ -260,6 +260,22 @@ oracleProperty({ `three-or-more-revisions`, `superseded-before-delivery`, ] as const, + // The transient-publication calibrations below corrupt this history. + fixed: [ + { + name: `four held revisions of eight rows with supersession`, + history: { + size: 8, + pageSize: 2, + descending: false, + react: false, + revisions: 4, + text: `authored`, + choices: [0, 1], + skip: true, + }, + }, + ], constructed: [ ...([false, true] as const).map((react) => ({ name: react @@ -299,45 +315,23 @@ oracleProperty({ reach: (state) => observe(`reach`, state), }), ), + calibrations: ([`rollback`, `mixed`] as const).map((fault) => ({ + name: `rejects a transient ${fault} publication even with the correct final revision`, + law: `convex.lazy-version`, + driver: async ( + plan: { history: History }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`convex.lazy-version`, plan, observe, () => + rejectsAs( + `lazy-paging-version-oracle.property.test.ts:232:toThrow`, + () => runVersions(plan.history, fault), + (failure) => + expect(() => { + throw failure + }).toThrow(`lazy version`), + ), + ), + })), timeout: 60000, }) - -it.each([`rollback`, `mixed`] as const)( - `rejects a transient %s publication even with the correct final revision`, - async (fault) => { - oracleRoute(`calibration`) - await oracleAssertion( - `lazy-paging-version-oracle.property.test.ts:232:toThrow`, - () => - expect( - runVersions( - { - size: 8, - pageSize: 2, - descending: false, - react: false, - revisions: 4, - text: `authored`, - choices: [0, 1], - skip: true, - }, - fault, - ), - ).rejects.toThrow(`lazy version`), - ) - }, -) - -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` }] - const model = new LazyVersionModel() - for (const rows of [a, a, b, c]) model.offer(rows) - for (const rows of [a, a, c, c]) model.observe(rows) - oracleAssertion( - `lazy-paging-version-oracle.property.test.ts:258:toThrow`, - () => expect(() => model.observe(a)).toThrow(`lazy version`), - ) -}) 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.test.ts similarity index 95% rename from packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts rename to packages/convex-db-collection/tests/lazy-paging-write-model.test.ts index cc0d098c96..4df2dd7984 100644 --- a/packages/convex-db-collection/tests/lazy-paging-write-model-oracle.test.ts +++ b/packages/convex-db-collection/tests/lazy-paging-write-model.test.ts @@ -1,12 +1,10 @@ import { expect, it } from 'vitest' -import { oracleRoute } from '../../db/tests/oracle-config' import { LazyWriteModel } from './lazy-paging-write-model-oracle' /** 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]) { @@ -39,7 +37,6 @@ 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/pagination-oracle.property.test.ts b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts index 9ba2019786..0bde282227 100644 --- a/packages/convex-db-collection/tests/pagination-oracle.property.test.ts +++ b/packages/convex-db-collection/tests/pagination-oracle.property.test.ts @@ -23,7 +23,7 @@ */ import fc from 'fast-check' import { createCollection } from '@tanstack/db' -import { expect, it } from 'vitest' +import { expect } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -32,9 +32,9 @@ import { } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' import { convexPaginatedCollectionOptions } from '../src/index' -import { oracleRoute } from '../../db/tests/oracle-config' import { withTestCleanup } from './helpers' import { copyRows, keyedRows, provider, query } from './pagination-support' +import { expectRevisionTrace } from './pagination-trace-model-oracle' import type { Row } from './pagination-support' type AssertionHistory = T extends fc.Arbitrary ? H : never @@ -111,80 +111,6 @@ const rowStates = [ `public-snapshot-publication`, ] as const -function sameRows( - left: ReadonlyArray, - right: ReadonlyArray, -): boolean { - return ( - left.length === right.length && - left.every( - (row, index) => - row.id === right[index]!.id && - row.rank === right[index]!.rank && - row.text === right[index]!.text, - ) - ) -} - -// During one coherent source revision, observations may retain the prior -// complete snapshot and then expose the new complete snapshot. Repeated -// observations are permitted. Once the new snapshot appears, stale prior rows -// cannot reappear without another source revision. Keep every observation: -// overwriting one variable would hide a prefix immediately repaired in-turn. -function expectRevisionTrace( - boundary: string, - observations: ReadonlyArray>, - prior: ReadonlyArray, - current: ReadonlyArray, -): void { - let currentSeen = false - for (const observation of observations) { - if (sameRows(observation, current)) currentSeen = true - else - oracleAssertion(`pagination-oracle.property.test.ts:104:toEqual`, () => - expect( - observation, - `${boundary}: complete monotone source snapshots`, - ).toEqual(currentSeen ? current : prior), - ) - } -} - -// Calibrate both recorders' shared trace relation without imposing one callback -// per revision. Repeated old/new snapshots are legal; prefixes and rollback -// after the new snapshot are not, even when a final callback repairs the result. -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, - text: String(rank), - })) - oracleAssertion(`pagination-oracle.property.test.ts:131:toThrow`, () => - expect(() => - expectRevisionTrace(boundary, [[], [], current, current], [], current), - ).not.toThrow(), - ) - oracleAssertion(`pagination-oracle.property.test.ts:134:toThrow`, () => - expect(() => - expectRevisionTrace( - boundary, - [current.slice(0, 1), current], - [], - current, - ), - ).toThrow(), - ) - oracleAssertion(`pagination-oracle.property.test.ts:142:toThrow`, () => - expect(() => - expectRevisionTrace(boundary, [current, [], current], [], current), - ).toThrow(), - ) - }, -) - // Calibration: publishing a one-row prefix before the full result, or // republishing the empty prior snapshot after the full result, fails the select // trace comparison at seed 20261022/path 0:0:0:0:0:0:0:0:0:0. The unchanged diff --git a/packages/convex-db-collection/tests/pagination-trace-model-oracle.ts b/packages/convex-db-collection/tests/pagination-trace-model-oracle.ts new file mode 100644 index 0000000000..f594fe7856 --- /dev/null +++ b/packages/convex-db-collection/tests/pagination-trace-model-oracle.ts @@ -0,0 +1,45 @@ +// The source-revision trace relation that pagination-oracle.property.test.ts +// compares the select and Collection publication recorders with, and that +// pagination-trace.test.ts calibrates. This module registers no tests. +import { expect } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import type { Row } from './pagination-support' + +export function sameRows( + left: ReadonlyArray, + right: ReadonlyArray, +): boolean { + return ( + left.length === right.length && + left.every( + (row, index) => + row.id === right[index]!.id && + row.rank === right[index]!.rank && + row.text === right[index]!.text, + ) + ) +} + +// During one coherent source revision, observations may retain the prior +// complete snapshot and then expose the new complete snapshot. Repeated +// observations are permitted. Once the new snapshot appears, stale prior rows +// cannot reappear without another source revision. Keep every observation: +// overwriting one variable would hide a prefix immediately repaired in-turn. +export function expectRevisionTrace( + boundary: string, + observations: ReadonlyArray>, + prior: ReadonlyArray, + current: ReadonlyArray, +): void { + let currentSeen = false + for (const observation of observations) { + if (sameRows(observation, current)) currentSeen = true + else + oracleAssertion(`pagination-oracle.property.test.ts:104:toEqual`, () => + expect( + observation, + `${boundary}: complete monotone source snapshots`, + ).toEqual(currentSeen ? current : prior), + ) + } +} diff --git a/packages/convex-db-collection/tests/pagination-trace.test.ts b/packages/convex-db-collection/tests/pagination-trace.test.ts new file mode 100644 index 0000000000..baf9df9677 --- /dev/null +++ b/packages/convex-db-collection/tests/pagination-trace.test.ts @@ -0,0 +1,40 @@ +// Checker calibrations for the trace relation in +// pagination-trace-model-oracle.ts: hand-built select and publication traces +// that it must accept or reject. They run no production. +import { expect, it } from 'vitest' +import { oracleAssertion } from '../../db/tests/oracle-assertion-adapter' +import { expectRevisionTrace } from './pagination-trace-model-oracle' + +// Calibrate both recorders' shared trace relation without imposing one callback +// per revision. Repeated old/new snapshots are legal; prefixes and rollback +// after the new snapshot are not, even when a final callback repairs the result. +it.each([`select`, `Collection publication`])( + `calibrates complete monotone snapshots at %s`, + (boundary) => { + const current = Array.from({ length: 3 }, (_, rank) => ({ + id: String(rank), + rank, + text: String(rank), + })) + oracleAssertion(`pagination-oracle.property.test.ts:131:toThrow`, () => + expect(() => + expectRevisionTrace(boundary, [[], [], current, current], [], current), + ).not.toThrow(), + ) + oracleAssertion(`pagination-oracle.property.test.ts:134:toThrow`, () => + expect(() => + expectRevisionTrace( + boundary, + [current.slice(0, 1), current], + [], + current, + ), + ).toThrow(), + ) + oracleAssertion(`pagination-oracle.property.test.ts:142:toThrow`, () => + expect(() => + expectRevisionTrace(boundary, [current, [], current], [], current), + ).toThrow(), + ) + }, +) 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 6386431b20..d2957a4438 100644 --- a/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts +++ b/packages/convex-db-collection/tests/sdk-cache-oracle.test.ts @@ -8,8 +8,13 @@ import { Buffer } from 'node:buffer' 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 { expect } from 'vitest' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { oracleProperty } from '../../db/tests/oracle-property' import { assertLazyPublications, expectedLazyRows, @@ -51,99 +56,121 @@ const timestamp = (value: number) => { return bytes.toString(`base64`) } -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, - logger: false as const, - } - const client = - kind === `javascript` - ? new ConvexClient(`https://test.convex.cloud`, options) - : new ConvexReactClient(`https://test.convex.cloud`, options) - const query = makeFunctionReference< - `query`, - { paginationOpts: PaginationOptions }, - PaginationResult - >(`tasks:list`) - const handles: Array<{ - read: () => PaginationResult | undefined - release: () => void - }> = [] - const trace: Array> = [] - const record = () => - trace.push(handles.flatMap((handle) => handle.read()?.page ?? [])) - await withTestCleanup( - () => { - for (const cursor of [null, `boundary`]) { - const args = { paginationOpts: { cursor, numItems: 1 } } - if (`onUpdate` in client) { - const sub = client.onUpdate(query, args, record) - handles.push({ read: () => sub.getCurrentValue(), release: sub }) - } else { - const watch = client.watchQuery(query, args) - handles.push({ - read: () => watch.localQueryResult(), - release: watch.onUpdate(record), - }) - } - } - const socket = Socket.instances.at(-1)! - socket.open() - const messages = socket.sent.filter( - (message) => message.type === `ModifyQuerySet`, - ) - const version = messages.at(-1)!.newVersion - const adds = messages - .flatMap((message) => message.modifications) - .filter((item) => item.type === `Add`) - expect(adds).toHaveLength(2) - let prior: Array = [] - for (let revision = 1; revision <= 2; revision++) { - const authored = [0, 1].map((rank) => ({ - id: String(rank), - rank, - text: `revision ${revision}`, - })) - const start = trace.length - socket.receive({ - type: `Transition`, - startVersion: { - querySet: revision === 1 ? 0 : version, - ts: timestamp(revision - 1), - identity: 0, - }, - endVersion: { - querySet: version, - ts: timestamp(revision), - identity: 0, - }, - modifications: adds.map((item, index) => ({ - type: `QueryUpdated`, - queryId: item.queryId, - value: { - page: [authored[index]], - isDone: index === 1, - continueCursor: `end-${index}`, - }, - logLines: [], - journal: null, - })), +const sdks = [`javascript`, `react`] as const + +// One SDK receives two legal multi-query transitions. At every callback the +// shared row and publication model judges the pages that the cached range +// queries hold. +async function readCoherentRangeCaches( + kind: (typeof sdks)[number], +): Promise { + const options = { + webSocketConstructor: Socket as unknown as typeof WebSocket, + unsavedChangesWarning: false, + logger: false as const, + } + const client = + kind === `javascript` + ? new ConvexClient(`https://test.convex.cloud`, options) + : new ConvexReactClient(`https://test.convex.cloud`, options) + const query = makeFunctionReference< + `query`, + { paginationOpts: PaginationOptions }, + PaginationResult + >(`tasks:list`) + const handles: Array<{ + read: () => PaginationResult | undefined + release: () => void + }> = [] + const trace: Array> = [] + const record = () => + trace.push(handles.flatMap((handle) => handle.read()?.page ?? [])) + await withTestCleanup( + () => { + for (const cursor of [null, `boundary`]) { + const args = { paginationOpts: { cursor, numItems: 1 } } + if (`onUpdate` in client) { + const sub = client.onUpdate(query, args, record) + handles.push({ read: () => sub.getCurrentValue(), release: sub }) + } else { + const watch = client.watchQuery(query, args) + handles.push({ + read: () => watch.localQueryResult(), + release: watch.onUpdate(record), }) - expect(trace.length).toBeGreaterThan(start) - const expected = expectedLazyRows(authored, {}) - assertLazyPublications(trace.slice(start), prior, expected) - expect(trace.at(-1)).toEqual(expected) - prior = expected } - }, - () => { - for (const handle of handles) handle.release() - }, - () => client.close(), - ) + } + const socket = Socket.instances.at(-1)! + socket.open() + const messages = socket.sent.filter( + (message) => message.type === `ModifyQuerySet`, + ) + const version = messages.at(-1)!.newVersion + const adds = messages + .flatMap((message) => message.modifications) + .filter((item) => item.type === `Add`) + expect(adds).toHaveLength(2) + let prior: Array = [] + for (let revision = 1; revision <= 2; revision++) { + const authored = [0, 1].map((rank) => ({ + id: String(rank), + rank, + text: `revision ${revision}`, + })) + const start = trace.length + socket.receive({ + type: `Transition`, + startVersion: { + querySet: revision === 1 ? 0 : version, + ts: timestamp(revision - 1), + identity: 0, + }, + endVersion: { + querySet: version, + ts: timestamp(revision), + identity: 0, + }, + modifications: adds.map((item, index) => ({ + type: `QueryUpdated`, + queryId: item.queryId, + value: { + page: [authored[index]], + isDone: index === 1, + continueCursor: `end-${index}`, + }, + logLines: [], + journal: null, + })), + }) + expect(trace.length).toBeGreaterThan(start) + const expected = expectedLazyRows(authored, {}) + assertLazyPublications(trace.slice(start), prior, expected) + expect(trace.at(-1)).toEqual(expected) + prior = expected + } + }, + () => { + for (const handle of handles) handle.release() + }, + () => client.close(), + ) +} + +oracleProperty({ + id: `convex.sdk-cache`, + law: { + statement: `A Convex query transition installs every changed query cache before it dispatches any listener, so each SDK callback reads a coherent prefix of one authored revision.`, + authority: `The receiving premise of lazy-paging-model-oracle.ts stated at the top of this file; docs/collections/convex-collection.md.`, + limits: `Two legal multi-query wire transitions through a controlled socket, in the JavaScript and React SDKs. It checks SDK scheduling, not server cursor correctness.`, }, -) + histories: sdks, + label: (kind) => + `reads coherent range caches before every ${kind} SDK callback`, + resolve: (kind) => ({ kind }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`convex.sdk-cache`, plan, observe, () => + readCoherentRangeCaches(plan.kind), + ), + check: forwardAssertionJudgment, +}) 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 f570c952e4..43a56721ac 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,10 +1,15 @@ -import { describe, expect, it } from 'vitest' -import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' +import { describe, expect } from 'vitest' import { oracleProperty, pass, violation } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { checkEagerLiveSourceHistory, reachStatesFor } from '../src' import { checkEagerLiveSourceObservations, eagerLiveSourceHistories, + eagerLiveSourceLaw, eagerLiveSourceOracle, eagerLiveSourceOracleDriver, eagerLiveSourceRequiredStates, @@ -13,6 +18,7 @@ import { referenceDriver } from './reference-adapter' import type { Observation } from '../../db/tests/oracle-property' import type { ConformanceRow, + EagerLiveSourceDriver, EagerLiveSourceHistory, EagerLiveSourceReachState, EagerLiveSourceStep, @@ -139,19 +145,32 @@ describe(`eager live-source oracle campaigns`, () => { // cut, so in this history only the synchronous check at the restart rejects // it; in a campaign a non-prompt restart rejects it too. 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` }, - { kind: `restart`, prompt: true }, - { kind: `snapshot`, rows: [] }, - ]), - ).rejects.toMatchObject({ law: `LIFE-5 (prompt restart)` }) + const promptRestart: EagerLiveSourceHistory = [ + { kind: `fail` }, + { kind: `restart`, prompt: true }, + { kind: `snapshot`, rows: [] }, + ] + oracleProperty({ + id: `${property}.prompt-restart`, + ...eagerLiveSourceOracle(driver), + law: { + ...eagerLiveSourceLaw, + limits: `The one prompt-restart history above, through the shared driver and check, on the reference adapter.`, }, - ) + check: checkEagerLiveSourceObservations, + histories: [promptRestart], + label: () => `a failure, a prompt restart and an empty delivery`, + calibrations: [ + { + name: `rejects a run that is ready at its prompt restart`, + law: `LIFE-5 (prompt restart)`, + driver: eagerLiveSourceOracleDriver( + referenceDriver(`ready-at-restart`), + ), + }, + ], + timeout: campaignTimeoutMs, + }) }) // Per-change commits use the same law and generated histories; reversing a @@ -442,88 +461,124 @@ describe(`eager live-source oracle campaigns`, () => { ], ] - 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, - history, - ) - expect(skipped).toBe(0) - }, - ) + // Exclusion: each history ends with one step that is illegal where it + // stands. The check skips it and leaves the model where it was. A batch + // before the first ready is legal only with `listen-before-fetch`, so its + // case runs on a driver that does not declare it. + const lateListener = { + ...driver, + capabilities: new Set( + [...driver.capabilities].filter((c) => c !== `listen-before-fetch`), + ), + } + const invalid: Array<[string, EagerLiveSourceHistory]> = [ + [ + `a second renewal in one sync run`, + [{ kind: `renew` }, { kind: `renew` }], + ], + [ + `a reconnect without a disconnect`, + [{ kind: `snapshot`, rows: [] }, { kind: `reconnect` }], + ], + [ + `a delivery after the provider failed`, + [{ kind: `fail` }, { kind: `snapshot`, rows: [] }], + ], + [ + `a batch while disconnected`, + [ + { kind: `snapshot`, rows: [] }, + { kind: `disconnect` }, + { kind: `changes`, changes: [] }, + ], + ], + ] - oracleReplayTest(it).each(valueWitnesses)( - `reconstructs %s`, - async (_name, history) => { - oracleRoute(`fixed history`) - expect(withinGrammar(history, withoutFields)).toBe(true) - const { skipped } = await checkEagerLiveSourceHistory( - withoutFields, - history, - ) - expect(skipped).toBe(0) + /** A grammar control: the domain its history runs in, the history, and the + * number of steps the check must skip (0 for reconstruction). */ + type GrammarControl = { + domain: EagerLiveSourceDriver + history: EagerLiveSourceHistory + reconstructs: boolean + skipped: number + } + // Witness rows may hold Dates and bigints, so each history is the control's + // test name, and `resolve` maps it to its control. + const grammarControls = new Map([ + ...witnesses.map(([name, history]): [string, GrammarControl] => [ + `reconstructs ${name}`, + { domain: withoutValues, history, reconstructs: true, skipped: 0 }, + ]), + ...valueWitnesses.map(([name, history]): [string, GrammarControl] => [ + `reconstructs ${name}`, + { domain: withoutFields, history, reconstructs: true, skipped: 0 }, + ]), + [ + `excludes a batch before the first ready without listen-before-fetch`, + { + domain: lateListener, + history: [{ kind: `changes` as const, changes: [] }], + reconstructs: false, + skipped: 1, }, - ) + ], + ...invalid.map(([name, history]): [string, GrammarControl] => [ + `excludes ${name}`, + { domain: withoutValues, history, reconstructs: false, skipped: 1 }, + ]), + ]) - // Exclusion: each history ends with one step that is illegal where it - // stands. The check skips it and leaves the model where it was. A batch - // before the first ready is legal only with `listen-before-fetch`, so its - // case runs on a driver that does not declare it. - const lateListener = { - ...driver, - capabilities: new Set( - [...driver.capabilities].filter((c) => c !== `listen-before-fetch`), + // Runs one control through the shared check on the reference adapter. A + // reconstruction must lie in the generator's domain and skip no step; an + // exclusion skips exactly its final illegal step. + async function runGrammarControl({ + domain, + history, + reconstructs, + skipped: expectedSkipped, + }: GrammarControl): Promise { + if (reconstructs) expect(withinGrammar(history, domain)).toBe(true) + const { skipped } = await checkEagerLiveSourceHistory(domain, history) + expect(skipped).toBe(expectedSkipped) + } + + // The controls whose test names start with `prefix`, through the shared + // check on the reference adapter. + function grammarControlCases(prefix: `reconstructs ` | `excludes `) { + return { + law: { + statement: `Reconstruction: each known adapter witness lies in the history grammar's domain, and the shared check accepts it on the reference adapter without skipping a step. Exclusion: a history that ends with a step illegal where it stands has exactly that step skipped, and the model stays where it was.`, + authority: `docs/contributing/oracle-rubric.md ORC-004 grammar controls; the shared oracle in src/eager-live-source-oracle.ts.`, + limits: `The named Neon, TrailBase and RxDB witnesses and the five invalid histories above, on the reference adapter; this judges the grammar and the check, not a published adapter.`, + }, + histories: [...grammarControls.keys()].filter((name) => + name.startsWith(prefix), ), + label: (name: string) => name, + resolve: (name: string) => grammarControls.get(name)!, + model: recordedAssertionJudgment, + driver: ( + plan: GrammarControl, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`grammar control`, plan, observe, () => + runGrammarControl(plan), + ), + timeout: campaignTimeoutMs, } - 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, - history, - ) - expect(skipped).toBe(1) - }, - ) - const invalid: Array<[string, EagerLiveSourceHistory]> = [ - [ - `a second renewal in one sync run`, - [{ kind: `renew` }, { kind: `renew` }], - ], - [ - `a reconnect without a disconnect`, - [{ kind: `snapshot`, rows: [] }, { kind: `reconnect` }], - ], - [ - `a delivery after the provider failed`, - [{ kind: `fail` }, { kind: `snapshot`, rows: [] }], - ], - [ - `a batch while disconnected`, - [ - { kind: `snapshot`, rows: [] }, - { kind: `disconnect` }, - { kind: `changes`, changes: [] }, - ], - ], - ] - oracleReplayTest(it).each(invalid)( - `excludes %s`, - async (_name, history) => { - oracleRoute(`fixed history`) - const { skipped } = await checkEagerLiveSourceHistory( - withoutValues, - history, - ) - expect(skipped).toBe(1) - }, - ) + } + + describe(`eager live-source grammar controls`, () => { + oracleProperty({ + id: `${property}.reconstruction`, + ...grammarControlCases(`reconstructs `), + check: forwardAssertionJudgment, + }) + oracleProperty({ + id: `${property}.exclusion`, + ...grammarControlCases(`excludes `), + check: forwardAssertionJudgment, + }) }) // Ablation judges the grammar rather than a new product law. The same diff --git a/packages/db/tests/converted-properties/adapters.ts b/packages/db/tests/converted-properties/adapters.ts index 03358f69b9..b4f0618d11 100644 --- a/packages/db/tests/converted-properties/adapters.ts +++ b/packages/db/tests/converted-properties/adapters.ts @@ -3,25 +3,116 @@ // edits this file, so parallel groups do not conflict in oracle-config.ts // or oracle-replay-manifest.ts. Integration folds both lists into those // files and deletes this directory. -import type { ConvertedOwnerGroup } from './index.js' +import type { ConvertedOwnerGroup } from "./index.js"; export const properties: ReadonlyArray = [ // query-db-collection (refetch) + `query-db.refetch-ordered-prefix-fixed`, + `query-db.refetch-ordered-prefix-cases`, + `query-db.refetch-held-page-end-structural`, // query-db-collection (rest) + `query-db.ssr-dehydration`, + `cursor-pagination.held-response-peer`, + `cursor-pagination.cache-cases`, + `cursor-pagination.refresh-before-growth`, + `cursor-pagination.exact-pages`, + `cursor-pagination.no-peek-bridge`, + `query-db.includes-work-counter`, + `query-db.persisted-direct-write-window`, + `query-db.persisted-direct-write-refetch-order`, + `query-db.persisted-direct-duplicate-insert`, // electric-db-collection // convex-db-collection and db-adapter-conformance + `adapter-conformance.eager-live-source.reference.prompt-restart`, + `adapter-conformance.eager-live-source.reference.reconstruction`, + `adapter-conformance.eager-live-source.reference.exclusion`, + `convex.sdk-cache`, + `convex.backend-pages`, + `convex.auth-app-roles`, + `convex.public-pagination`, + `convex.lazy-backend`, // offline-transactions `offline.serializer.mutation-wire-cleanup`, `offline.settlement.restored`, `offline.settlement.started-peer`, `offline.connectivity-replay`, -] +]; export const owners: ReadonlyArray = [ // query-db-collection (refetch) + [ + `query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts`, + `query-db`, + `refetch-ordered-prefix-fixed refetch-ordered-prefix-cases refetch-held-page-end-structural`, + ], // query-db-collection (rest) + [ + `query-db-collection/tests/ssr-dehydration-oracle.test.ts`, + `query-db`, + `ssr-dehydration`, + ], + [ + `query-db-collection/tests/cursor-pagination.oracle.test.ts`, + `cursor-pagination`, + `held-response-peer`, + ], + [ + `query-db-collection/tests/cursor-pagination.cache-oracle.test.ts`, + `cursor-pagination`, + `cache-cases`, + ], + [ + `query-db-collection/tests/cursor-pagination-oracle.integration.test.ts`, + `cursor-pagination`, + `refresh-before-growth exact-pages`, + ], + [ + `query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts`, + `cursor-pagination`, + `no-peek-bridge`, + ], + [ + `query-db-collection/tests/includes-work-counter-oracle.test.ts`, + `query-db`, + `includes-work-counter`, + ], + [ + `query-db-collection/tests/persisted-direct-write-window.oracle.test.ts`, + `query-db`, + `persisted-direct-write-window persisted-direct-write-refetch-order persisted-direct-duplicate-insert`, + ], // electric-db-collection // convex-db-collection and db-adapter-conformance + [ + `db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts`, + `adapter-conformance.eager-live-source.reference`, + `prompt-restart reconstruction exclusion`, + ], + [ + `convex-db-collection/tests/sdk-cache-oracle.test.ts`, + `convex`, + `sdk-cache`, + ], + [ + `convex-db-collection/e2e/backend-oracle.test.ts`, + `convex`, + `backend-pages`, + ], + [ + `convex-db-collection/e2e/auth-app-oracle.test.ts`, + `convex`, + `auth-app-roles`, + ], + [ + `convex-db-collection/e2e/public-pagination-oracle.test.ts`, + `convex`, + `public-pagination`, + ], + [ + `convex-db-collection/e2e/lazy-paging-oracle.test.ts`, + `convex`, + `lazy-backend`, + ], // offline-transactions [ `offline-transactions/tests/transaction-serializer-oracle.property.test.ts`, @@ -38,4 +129,4 @@ export const owners: ReadonlyArray = [ `offline`, `connectivity-replay`, ], -] +]; diff --git a/packages/query-db-collection/tests/calibration-rejection.ts b/packages/query-db-collection/tests/calibration-rejection.ts new file mode 100644 index 0000000000..6cf0d9215a --- /dev/null +++ b/packages/query-db-collection/tests/calibration-rejection.ts @@ -0,0 +1,35 @@ +// Transport for a calibration whose mutant must be rejected by the existing +// comparison. The old calibration test accepted only the rejections that its +// Vitest matcher accepted; `rejectsAs` keeps exactly that matcher. It marks an +// accepted rejection as the comparison's verdict at `checkpoint`, so +// recordAssertionJudgment reports it under the property's law. Any other +// rejection becomes a new, unmarked driver error, so no checkpoint inside it +// counts. A mutant that resolves records no verdict here. +import { rejectAt } from '../../db/tests/oracle-assertion-adapter' + +export async function rejectsAs( + checkpoint: string, + run: () => Promise, + accepts: (failure: unknown) => void, +): Promise { + let rejected = false + let failure: unknown + try { + await run() + } catch (error) { + rejected = true + failure = error + } + if (!rejected) return + try { + accepts(failure) + } catch { + throw new Error( + `${checkpoint}: the mutant failed in a way that its calibration does not accept: ${String(failure)}`, + ) + } + rejectAt( + checkpoint, + failure instanceof Error ? failure : new Error(String(failure)), + ) +} 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 a616b8a3b9..af321b895b 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 @@ -1,8 +1,12 @@ import { QueryClient } from '@tanstack/query-core' import { createCollection } from '@tanstack/db' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import fc from 'fast-check' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { differs, oracleProperty, @@ -118,6 +122,140 @@ function checkManualWriteTrace( ) } +const refreshCases = [false, true].flatMap((descending) => + [2, 5].map((backendSize) => ({ descending, backendSize })), +) + +// The backend changes behind a loaded window. A row-query refetch alone must +// leave the window unchanged; the explicit refresh must reach the model's +// window before fetchNextPage grows it. +async function refreshBeforeGrowth({ + descending, + backendSize, +}: (typeof refreshCases)[number]): Promise { + const rows = Array.from({ length: 3 }, (_, id) => ({ + id, + rank: 0, + group: 0, + })) + const scope = { group: undefined, descending } + const fixture = createFixture(rows, scope, backendSize) + const controller = createLiveQueryWindowController(fixture.live, { + pageSize: 3, + }) + const stop = controller.subscribe(() => {}) + try { + await controller.preload() + expect(observeRows(controller.getSnapshot().data)).toEqual( + expectedWindow(rows, scope, 3).rows, + ) + expect(controller.getSnapshot().hasNextPage).toBe(false) + for (const count of [9, 2, 0, 7]) { + const previous = observeRows(controller.getSnapshot().data) + const calls = fixture.backend.calls.length + rows.splice( + 0, + rows.length, + ...Array.from({ length: count }, (_, id) => ({ + id, + rank: Math.floor(id / 6), + group: count, + })), + ) + // Refreshing only the outer row query still allows its fresh page + // cache. Prefix invalidation is the explicit two-cache refresh path. + await fixture.source.utils.refetch({ throwOnError: true }) + expect(observeRows(controller.getSnapshot().data)).toEqual(previous) + expect(fixture.backend.calls).toHaveLength(calls) + await fixture.refresh() + const expected = expectedWindow(rows, scope, 3) + await vi.waitFor(() => { + expect(observeRows(controller.getSnapshot().data)).toEqual( + expected.rows, + ) + expect(controller.getSnapshot().hasNextPage).toBe(expected.hasNextPage) + }) + } + await controller.fetchNextPage() + expect(observeRows(controller.getSnapshot().data)).toEqual( + expectedWindow(rows, scope, 6).rows, + ) + expect(controller.getSnapshot().hasNextPage).toBe(true) + } finally { + stop() + controller.dispose() + await fixture.cleanup() + } +} + +// One matrix cell: grow the window page by page and compare each snapshot +// and every ready publication with the model's window. +async function publishExactPages({ + count, + backendSize, + pageSize, + descending, +}: (typeof matrix)[number]): Promise { + const rows = Array.from({ length: count }, (_, id) => ({ + id, + rank: Math.floor(id / 6), + group: id % 2, + })) + const scope = { group: undefined, descending } + const fixture = createFixture(rows, scope, backendSize) + const controller = createLiveQueryWindowController(fixture.live, { + pageSize, + }) + const publications: Array<{ + rows: Array + pages: number + more: boolean + ready: boolean + }> = [] + const unsubscribe = controller.subscribe(() => { + const snapshot = controller.getSnapshot() + publications.push({ + rows: observeRows(snapshot.data), + pages: snapshot.pageParams.length, + more: snapshot.hasNextPage, + ready: snapshot.isReady, + }) + }) + try { + await controller.preload() + for (let page = 1; page <= Math.ceil(count / pageSize) + 1; page++) { + const expected = expectedWindow(rows, scope, page * pageSize) + const snapshot = controller.getSnapshot() + expect(observeRows(snapshot.data)).toEqual(expected.rows) + expect(snapshot.hasNextPage).toBe(expected.hasNextPage) + expect(observeRows(snapshot.pages.flat())).toEqual(expected.rows) + if (!expected.hasNextPage) break + await controller.fetchNextPage() + } + expect(publications.length).toBeGreaterThan(0) + expect(publications.some((published) => published.ready)).toBe(true) + for (const published of publications) { + if (published.ready) { + const expected = expectedWindow(rows, scope, published.pages * pageSize) + expect(published.rows).toEqual(expected.rows) + expect(published.more).toBe(expected.hasNextPage) + } + } + expect(fixture.requests.length).toBeGreaterThan(0) + expect(fixture.requests[0]?.limit).toBe(pageSize + 1) + if (count > pageSize) { + expect(fixture.cursorRequestCount()).toBeGreaterThan(0) + } + expect(fixture.backend.calls).toHaveLength( + Math.max(1, Math.ceil(count / backendSize)), + ) + } finally { + unsubscribe() + controller.dispose() + await fixture.cleanup() + } +} + 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 @@ -308,138 +446,49 @@ describe(`cursor adapter through production pagination`, () => { }) }, ) - it.each( - [false, true].flatMap((descending) => - [2, 5].map((backendSize) => ({ descending, backendSize })), - ), - )( - `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, - group: 0, - })) - const scope = { group: undefined, descending } - const fixture = createFixture(rows, scope, backendSize) - const controller = createLiveQueryWindowController(fixture.live, { - pageSize: 3, - }) - const stop = controller.subscribe(() => {}) - try { - await controller.preload() - expect(observeRows(controller.getSnapshot().data)).toEqual( - expectedWindow(rows, scope, 3).rows, - ) - expect(controller.getSnapshot().hasNextPage).toBe(false) - for (const count of [9, 2, 0, 7]) { - const previous = observeRows(controller.getSnapshot().data) - const calls = fixture.backend.calls.length - rows.splice( - 0, - rows.length, - ...Array.from({ length: count }, (_, id) => ({ - id, - rank: Math.floor(id / 6), - group: count, - })), - ) - // Refreshing only the outer row query still allows its fresh page - // cache. Prefix invalidation is the explicit two-cache refresh path. - await fixture.source.utils.refetch({ throwOnError: true }) - expect(observeRows(controller.getSnapshot().data)).toEqual(previous) - expect(fixture.backend.calls).toHaveLength(calls) - await fixture.refresh() - const expected = expectedWindow(rows, scope, 3) - await vi.waitFor(() => { - expect(observeRows(controller.getSnapshot().data)).toEqual( - expected.rows, - ) - expect(controller.getSnapshot().hasNextPage).toBe( - expected.hasNextPage, - ) - }) - } - await controller.fetchNextPage() - expect(observeRows(controller.getSnapshot().data)).toEqual( - expectedWindow(rows, scope, 6).rows, - ) - expect(controller.getSnapshot().hasNextPage).toBe(true) - } finally { - stop() - controller.dispose() - await fixture.cleanup() - } + oracleProperty({ + id: `cursor-pagination.refresh-before-growth`, + law: { + statement: `After the backend changes, the visible window and hasNextPage keep their prior values until the page caches refresh, and then match the independent full-relation window before the window grows.`, + authority: `cursor-pagination/README.md; the end-to-end contract at the top of this file.`, + limits: `Three to nine rows of one rank, both directions and backend page sizes 2 and 5, through a real QueryCollection and live-query window controller.`, }, - ) + owns: [ + `src/cursor-pagination.ts#createCursorPager`, + `src/query.ts#queryCollectionOptions`, + ], + histories: refreshCases, + label: ({ descending, backendSize }) => + `refreshes changed backend rows before growing the window ${descending}/${backendSize}`, + resolve: (refreshCase) => ({ refreshCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`refresh before growth`, plan, observe, () => + refreshBeforeGrowth(plan.refreshCase), + ), + check: forwardAssertionJudgment, + }) - 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), - group: id % 2, - })) - const scope = { group: undefined, descending } - const fixture = createFixture(rows, scope, backendSize) - const controller = createLiveQueryWindowController(fixture.live, { - pageSize, - }) - const publications: Array<{ - rows: Array - pages: number - more: boolean - ready: boolean - }> = [] - const unsubscribe = controller.subscribe(() => { - const snapshot = controller.getSnapshot() - publications.push({ - rows: observeRows(snapshot.data), - pages: snapshot.pageParams.length, - more: snapshot.hasNextPage, - ready: snapshot.isReady, - }) - }) - try { - await controller.preload() - for (let page = 1; page <= Math.ceil(count / pageSize) + 1; page++) { - const expected = expectedWindow(rows, scope, page * pageSize) - const snapshot = controller.getSnapshot() - expect(observeRows(snapshot.data)).toEqual(expected.rows) - expect(snapshot.hasNextPage).toBe(expected.hasNextPage) - expect(observeRows(snapshot.pages.flat())).toEqual(expected.rows) - if (!expected.hasNextPage) break - await controller.fetchNextPage() - } - expect(publications.length).toBeGreaterThan(0) - expect(publications.some((published) => published.ready)).toBe(true) - for (const published of publications) { - if (published.ready) { - const expected = expectedWindow( - rows, - scope, - published.pages * pageSize, - ) - expect(published.rows).toEqual(expected.rows) - expect(published.more).toBe(expected.hasNextPage) - } - } - expect(fixture.requests.length).toBeGreaterThan(0) - expect(fixture.requests[0]?.limit).toBe(pageSize + 1) - if (count > pageSize) { - expect(fixture.cursorRequestCount()).toBeGreaterThan(0) - } - expect(fixture.backend.calls).toHaveLength( - Math.max(1, Math.ceil(count / backendSize)), - ) - } finally { - unsubscribe() - controller.dispose() - await fixture.cleanup() - } + oracleProperty({ + id: `cursor-pagination.exact-pages`, + law: { + statement: `Every ready publication and every page of the window equals the independent full-relation window for its page count, with exact exhaustion and one backend read per backend page.`, + authority: `cursor-pagination/README.md; the end-to-end contract at the top of this file.`, + limits: `The finite matrix of row counts 0, 1, 9 and 20, backend page sizes 2 and 5, window page sizes 3 and 7 and both directions.`, }, - ) + owns: [ + `src/cursor-pagination.ts#createCursorPager`, + `src/query.ts#queryCollectionOptions`, + ], + histories: matrix, + label: ({ count, backendSize, pageSize, descending }) => + `publishes exact pages and exhaustion ${count}/${backendSize}/${pageSize}/${descending}`, + resolve: (pageCase) => ({ pageCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`exact pages`, plan, observe, () => + publishExactPages(plan.pageCase), + ), + check: forwardAssertionJudgment, + }) }) 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 13897dc0fb..8177947c08 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 @@ -19,10 +19,15 @@ import { createCollection, createLiveQueryCollection, } from '@tanstack/db' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { createLiveQueryWindowController } from '../../db/src/live-query-window-controller.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-property.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' 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' @@ -139,95 +144,146 @@ function createGraphFixture( } } -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) - const controller = createLiveQueryWindowController(baseline.live, { - pageSize: 3, - }) - const unsubscribe = controller.subscribe(() => {}) - try { - model.retainDemand(`a`, 3) - await model.refresh() - await controller.preload() - expect(model.get(`a`)).toEqual(expectedWindow(candidate.rows, scope, 3)) - expect(controller.getSnapshot().hasNextPage).toBe( - model.get(`a`)?.hasNextPage, - ) - expect(controller.getSnapshot().data.map(({ id }) => id)).toEqual( - model.get(`a`)?.rows.map(({ id }) => id), - ) - expect(candidate.transport.requests[0]).toBe(3) - expect(baseline.transport.requests[0]).toBe(4) - expect(candidate.transport.backend.calls).toHaveLength(1) - expect(baseline.transport.backend.calls).toHaveLength(count > 3 ? 2 : 1) - } finally { - unsubscribe() - controller.dispose() - await candidate.cleanup() - await baseline.cleanup() - } - }, - ) - - it(`holds continuation behind actual source and graph publication`, async () => { - oracleRoute(`case`) - const fixture = createGraphFixture(9, 3, true) - const model = createNoPeekDemandModel(fixture.acquire) +// Compares the candidate's rows and continuation with the full-relation +// reference and with the current peek-ahead controller. +async function matchPeekAheadOutput(count: number): Promise { + const candidate = createGraphFixture(count, 3) + const baseline = createGraphFixture(count, 3) + const model = createNoPeekDemandModel(candidate.acquire) + const controller = createLiveQueryWindowController(baseline.live, { + pageSize: 3, + }) + const unsubscribe = controller.subscribe(() => {}) + try { model.retainDemand(`a`, 3) - let notifications = 0 - model.subscribe(() => notifications++) - const pending = model.refresh() - const observed = pending.then( - () => undefined, - (error: unknown) => error, + await model.refresh() + await controller.preload() + expect(model.get(`a`)).toEqual(expectedWindow(candidate.rows, scope, 3)) + expect(controller.getSnapshot().hasNextPage).toBe( + model.get(`a`)?.hasNextPage, ) - try { - await fixture.received.promise - expect(fixture.fact()?.fact?.hasMore).toBe(true) - expect(fixture.live.toArray).toEqual([]) - expect(model.get(`a`)).toBeUndefined() - expect(notifications).toBe(0) - fixture.release.resolve() - expect(await observed).toBeUndefined() - expect(model.get(`a`)).toEqual(expectedWindow(fixture.rows, scope, 3)) - expect(notifications).toBe(1) - } finally { - fixture.release.resolve() - await observed - await fixture.cleanup() - } - }) + expect(controller.getSnapshot().data.map(({ id }) => id)).toEqual( + model.get(`a`)?.rows.map(({ id }) => id), + ) + expect(candidate.transport.requests[0]).toBe(3) + expect(baseline.transport.requests[0]).toBe(4) + expect(candidate.transport.backend.calls).toHaveLength(1) + expect(baseline.transport.backend.calls).toHaveLength(count > 3 ? 2 : 1) + } finally { + unsubscribe() + controller.dispose() + await candidate.cleanup() + await baseline.cleanup() + } +} - 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) - try { - const rawPrefix = await createFactTransport(fixture.rows, 3, scope).read( +// The candidate must not publish or notify before the held source batch +// reaches the graph; then it publishes the reference window once. +async function holdContinuationBehindPublication(): Promise { + const fixture = createGraphFixture(9, 3, true) + const model = createNoPeekDemandModel(fixture.acquire) + model.retainDemand(`a`, 3) + let notifications = 0 + model.subscribe(() => notifications++) + const pending = model.refresh() + const observed = pending.then( + () => undefined, + (error: unknown) => error, + ) + try { + await fixture.received.promise + expect(fixture.fact()?.fact?.hasMore).toBe(true) + expect(fixture.live.toArray).toEqual([]) + expect(model.get(`a`)).toBeUndefined() + expect(notifications).toBe(0) + fixture.release.resolve() + expect(await observed).toBeUndefined() + expect(model.get(`a`)).toEqual(expectedWindow(fixture.rows, scope, 3)) + expect(notifications).toBe(1) + } finally { + fixture.release.resolve() + await observed + await fixture.cleanup() + } +} + +// An opaque local filter makes the candidate request the extra row through +// the full-source path; its window is the reference over the filtered rows. +async function fallBackForLocalFilter(): Promise { + const fixture = createGraphFixture(9, 3, false, true) + const model = createNoPeekDemandModel(fixture.acquire, false) + model.retainDemand(`a`, 3) + try { + const rawPrefix = await createFactTransport(fixture.rows, 3, scope).read(3) + expect(rawPrefix.fact?.hasMore).toBe(true) + await model.refresh() + expect(model.get(`a`)).toEqual( + expectedWindow( + fixture.rows.filter((row) => row.id < 3), + scope, 3, - ) - expect(rawPrefix.fact?.hasMore).toBe(true) - await model.refresh() - expect(model.get(`a`)).toEqual( - expectedWindow( - fixture.rows.filter((row) => row.id < 3), - scope, - 3, - ), - ) - expect(fixture.windows).toEqual([4]) - expect(model.get(`a`)?.hasNextPage).toBe(false) - // An opaque local filter requires the existing full-source loading path. - expect(fixture.transport.requests[0]).toBe(10) - } finally { - await fixture.cleanup() - } + ), + ) + expect(fixture.windows).toEqual([4]) + expect(model.get(`a`)?.hasNextPage).toBe(false) + // An opaque local filter requires the existing full-source loading path. + expect(fixture.transport.requests[0]).toBe(10) + } finally { + await fixture.cleanup() + } +} + +type NoPeekCase = + | { name: string; kind: `peek-ahead`; count: number } + | { name: string; kind: `held` } + | { name: string; kind: `filtered` } + +const noPeekCases: Array = [ + ...[0, 3, 4, 9].map((count): NoPeekCase => ({ + name: `matches peek-ahead output with ${count} remote rows`, + kind: `peek-ahead`, + count, + })), + { + name: `holds continuation behind actual source and graph publication`, + kind: `held`, + }, + { + name: `falls back for a local filter even though the order is unchanged`, + kind: `filtered`, + }, +] + +function runNoPeekCase(noPeekCase: NoPeekCase): Promise { + switch (noPeekCase.kind) { + case `peek-ahead`: + return matchPeekAheadOutput(noPeekCase.count) + case `held`: + return holdContinuationBehindPublication() + case `filtered`: + return fallBackForLocalFilter() + } +} + +describe(`no-peek bridge at real graph publication`, () => { + // 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. + oracleProperty({ + id: `cursor-pagination.no-peek-bridge`, + law: { + statement: `The no-peek candidate replaces the extra output row only with a fact that names the same complete publication and output prefix; its rows and hasNextPage equal the full-relation reference at each named settlement and fallback cut.`, + authority: `cursor-pagination/NO-PEEK-RESULTS.md, the candidate rule; the full-relation model in cursor-pagination/model-oracle.ts.`, + limits: `Fixed histories over an immutable unique-id ordered source; no second graph publication, generic compiler eligibility or public API claim.`, + }, + histories: noPeekCases, + label: ({ name }) => name, + resolve: (noPeekCase) => ({ noPeekCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`no-peek bridge`, plan, observe, () => + runNoPeekCase(plan.noPeekCase), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/query-db-collection/tests/cursor-pagination-reference-support.ts b/packages/query-db-collection/tests/cursor-pagination-reference-support.ts new file mode 100644 index 0000000000..0322fd99ce --- /dev/null +++ b/packages/query-db-collection/tests/cursor-pagination-reference-support.ts @@ -0,0 +1,11 @@ +// Fixed rows and scope shared by the opaque cursor pager oracle +// (cursor-pagination.oracle.test.ts) and its ordinary reference tests +// (cursor-pagination-reference.test.ts). +import type { Row, Scope } from './cursor-pagination/model-oracle.js' + +export const fixtureRows: Array = Array.from({ length: 9 }, (_, id) => ({ + id, + rank: Math.floor(id / 6), + group: id % 2, +})) +export const allAscending: Scope = { group: undefined, descending: false } diff --git a/packages/query-db-collection/tests/cursor-pagination-reference.test.ts b/packages/query-db-collection/tests/cursor-pagination-reference.test.ts new file mode 100644 index 0000000000..0b06a1aa9e --- /dev/null +++ b/packages/query-db-collection/tests/cursor-pagination-reference.test.ts @@ -0,0 +1,56 @@ +// Rules of the cursor pagination reference model and its slice comparison, +// checked by hand without production. cursor-pagination.oracle.test.ts judges +// the opaque cursor pager with the same expectedRows model. +import { describe, expect, it } from 'vitest' +import { expectedRows } from './cursor-pagination/model-oracle.js' +import { + allAscending, + fixtureRows, +} from './cursor-pagination-reference-support' +import type { Row, Scope, Window } from './cursor-pagination/model-oracle.js' + +async function checkRead( + source: Array, + scope: Scope, + window: Window, + read: (window: Window) => Promise>, +) { + const expected = expectedRows(source, scope, window) + expect(await read(window), `ordered slice ${JSON.stringify(window)}`).toEqual( + expected, + ) +} + +describe(`cursor pagination reference`, () => { + it(`preserves ties, filtering and reverse total order`, () => { + expect( + expectedRows( + fixtureRows, + { group: 1, descending: true }, + { offset: 1, limit: 3 }, + ).map((row) => row.id), + ).toEqual([5, 3, 1]) + expect( + expectedRows(fixtureRows, allAscending, { offset: 0, limit: 0 }), + ).toEqual([]) + }) + + it(`rejects capped-page, wrong-order and duplicate-row answers`, async () => { + const window = { offset: 0, limit: 8 } + const expected = expectedRows(fixtureRows, allAscending, window) + for (const wrong of [ + expected.slice(0, 2), + [...expected].reverse(), + [...expected.slice(0, 7), expected[0]!], + ]) { + await expect( + checkRead(fixtureRows, allAscending, window, () => + Promise.resolve(wrong), + ), + ).rejects.toThrow(`ordered slice`) + } + await checkRead(fixtureRows, allAscending, window, () => + Promise.resolve(expected), + ) + }) +}) 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 cded4c79b8..c5316b8a76 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 @@ -1,14 +1,19 @@ import { QueryClient } from '@tanstack/query-core' import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' import { createCursorPager } from '../src/index.js' +import { rejectsAs } from './calibration-rejection' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' @@ -87,6 +92,317 @@ function checkCacheTrace( ) } +// ## Fixed cache cases +// +// Each case runs the production pager over a real QueryClient and compares +// every window with the reference slice (checkWindow and expectedRows). They +// name Query cache events that the generated grammar does not schedule. + +// After invalidation the pager must read the refreshed rows. The calibration +// below ignores the invalidation and requires checkWindow to reject the reuse. +async function refreshAfterInvalidation( + ignoreInvalidation: boolean, +): Promise { + const client = new QueryClient({ + defaultOptions: { + queries: { staleTime: Infinity, gcTime: Infinity, retry: false }, + }, + }) + let rows = makeRows(2) + const key = [`sensitivity`] + const pager = createCursorPager({ + queryClient: client, + queryKey: key, + fetchPage: () => Promise.resolve({ rows, nextCursor: null }), + }) + try { + await checkWindow(pager, rows, 2) + rows = makeRows(2, 1) + await client.invalidateQueries({ queryKey: key }) + if (ignoreInvalidation) { + // Deliberately bypass Query's stale check, but still execute the + // production pager and the same value checker as the history law. + client + .getQueryCache() + .find({ queryKey: key, exact: true })! + .setState({ isInvalidated: false }) + } + // With the mutant, the calibration requires this check to reject the reuse + // with its `window matches` comparison. + await checkWindow(pager, rows, 2) + } finally { + vi.restoreAllMocks() + client.clear() + } +} + +// A 99 ms cutoff refetches too soon; a 101 ms cutoff reuses stale rows. +async function reuseUntilExpiry(): Promise { + // A 99 ms cutoff refetches too soon; a 101 ms cutoff reuses stale rows. + vi.useFakeTimers({ toFake: [`Date`] }) + vi.setSystemTime(1000) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) + const key = [`expiry-edge`] + const initial = makeRows(2) + let source = initial + let calls = 0 + const pager = createCursorPager({ + queryClient: client, + queryKey: key, + staleTime: 100, + fetchPage: () => { + calls++ + return Promise.resolve({ rows: source, nextCursor: null }) + }, + }) + try { + await checkWindow(pager, initial, 2) + source = makeRows(2, 1) + vi.setSystemTime(1099) + await checkWindow(pager, initial, 2) + expect(calls).toBe(1) + vi.setSystemTime(1100) + await checkWindow(pager, source, 2) + expect(calls).toBe(2) + } finally { + client.clear() + vi.useRealTimers() + } +} + +async function cancelHeldWork(): Promise { + const client = new QueryClient({ + defaultOptions: { + queries: { staleTime: Infinity, gcTime: Infinity, retry: false }, + }, + }) + const key = [`cancel`] + const hold = createDeferred() + const delivered = createDeferred() + let deliveredSignal: AbortSignal | undefined + let holdResponse = true + const rows = makeRows(2) + const pager = createCursorPager({ + queryClient: client, + queryKey: key, + fetchPage: async (_cursor, signal) => { + deliveredSignal = signal + if (holdResponse) { + await hold.promise + delivered.resolve() + } + return { rows, nextCursor: null } + }, + }) + const pending = pager.read({}).then( + () => `success`, + () => `rejected`, + ) + try { + await vi.waitFor(() => expect(deliveredSignal).toBeDefined()) + await client.cancelQueries({ queryKey: key, exact: true }) + expect(await pending).toBe(`rejected`) + expect(deliveredSignal?.aborted).toBe(true) + hold.resolve() + await delivered.promise + await new Promise((resolve) => setTimeout(resolve, 0)) + expect(client.getQueryData(key)).toBeUndefined() + holdResponse = false + await checkWindow(pager, rows, 2) + } finally { + hold.resolve() + await pending + client.clear() + } +} + +async function collectInactivePages(gcTime: number): Promise { + vi.useFakeTimers() + const client = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + const backend = createBackend(makeRows(6), scope, 2) + const key = [`collect`] + const pager = createCursorPager({ + queryClient: client, + queryKey: key, + staleTime: Infinity, + gcTime, + fetchPage: backend.fetchPage, + }) + try { + await pager.read({ limit: 3 }) + expect(backend.calls).toHaveLength(2) + expect(client.getQueryData(key)).toBeDefined() + // The 25 ms case rejects a cutoff moved one millisecond either way. + if (gcTime > 0) { + await vi.advanceTimersByTimeAsync(gcTime - 1) + expect(client.getQueryData(key)).toBeDefined() + } + await vi.advanceTimersByTimeAsync(1) + expect(client.getQueryData(key)).toBeUndefined() + expect(await pager.read({ limit: 3 })).toEqual( + expectedRows(makeRows(6), scope, { offset: 0, limit: 3 }), + ) + expect(backend.calls).toHaveLength(4) + } finally { + client.clear() + vi.useRealTimers() + } +} + +async function keepCachedDataDuringRefresh( + cause: `expiry` | `invalidate`, +): Promise { + vi.useFakeTimers({ toFake: [`Date`] }) + vi.setSystemTime(1000) + const client = new QueryClient({ + defaultOptions: { queries: { retry: false, gcTime: Infinity } }, + }) + const key = [`refresh`] + let source = makeRows(6) + let backend = createBackend(source, scope, 2) + let hold: ReturnType> | undefined + const pager = createCursorPager({ + queryClient: client, + queryKey: key, + staleTime: 100, + fetchPage: async (cursor, signal) => { + if (cursor === undefined) backend = createBackend(source, scope, 2) + const page = await backend.fetchPage(cursor, signal) + if (hold) await hold.promise + return page + }, + }) + try { + await pager.read({ limit: 4 }) + const old = client.getQueryData(key) + source = makeRows(6, 1) + if (cause === `expiry`) vi.setSystemTime(1100) + else await client.invalidateQueries({ queryKey: key }) + hold = createDeferred() + const error = new Error(`refresh failed`) + const pending = pager.read({ limit: 4 }).catch((reason: unknown) => reason) + await vi.waitFor(() => expect(client.isFetching()).toBe(1)) + expect(client.getQueryData(key)).toBe(old) + hold.reject(error) + expect(await pending).toBe(error) + expect(client.getQueryData(key)).toBe(old) + hold = undefined + expect(await pager.read({ limit: 4 })).toEqual( + expectedRows(source, scope, { offset: 0, limit: 4 }), + ) + } finally { + hold?.resolve() + client.clear() + vi.useRealTimers() + } +} + +async function shareFetchesAcrossReaders(): Promise { + const client = new QueryClient({ + defaultOptions: { + queries: { retry: false, gcTime: Infinity, staleTime: Infinity }, + }, + }) + const backend = createBackend(makeRows(6), scope, 2) + const hold = createDeferred() + const options = { + queryClient: client, + queryKey: [`shared`], + fetchPage: async (cursor: string | undefined, signal: AbortSignal) => { + const page = await backend.fetchPage(cursor, signal) + await hold.promise + return page + }, + } + const a = createCursorPager(options) + const b = createCursorPager(options) + const abort = new AbortController() + const error = new Error(`reader left`) + const pending = Promise.allSettled([ + a.read({ limit: 2 }, abort.signal), + b.read({ limit: 4 }), + ]) + try { + await vi.waitFor(() => expect(backend.calls).toHaveLength(1)) + abort.abort(error) + hold.resolve() + expect(await pending).toEqual([ + { status: `rejected`, reason: error }, + { + status: `fulfilled`, + value: expectedRows(makeRows(6), scope, { offset: 0, limit: 4 }), + }, + ]) + expect(backend.calls).toHaveLength(2) + } finally { + hold.resolve() + await pending + client.clear() + } +} + +type CacheCase = + | { name: string; kind: `invalidation` } + | { name: string; kind: `expiry` } + | { name: string; kind: `cancellation` } + | { name: string; kind: `collection`; gcTime: number } + | { name: string; kind: `refresh`; cause: `expiry` | `invalidate` } + | { name: string; kind: `shared` } + +// The invalidation case comes first: the calibration runs on the first case +// whose clean run passes and that its mutant changes. +const cacheCases: Array = [ + { name: `accepts a real refresh after invalidation`, kind: `invalidation` }, + { + name: `reuses a cache one millisecond before expiry and refreshes at expiry`, + kind: `expiry`, + }, + { + name: `QueryClient cancellation rejects held work without installing its late page`, + kind: `cancellation`, + }, + ...[0, 25].map((gcTime): CacheCase => ({ + name: `collects inactive pages independently of freshness with gcTime ${gcTime}`, + kind: `collection`, + gcTime, + })), + ...([`expiry`, `invalidate`] as const).map((cause): CacheCase => ({ + name: `keeps cached data while a ${cause} refresh is held or fails`, + kind: `refresh`, + cause, + })), + { + name: `shares Query fetches across readers without letting one abort cancel its peer`, + kind: `shared`, + }, +] + +// `ignoreInvalidation` is the calibration's mutant configuration; only the +// invalidation case reads it. +function runCacheCase( + cacheCase: CacheCase, + ignoreInvalidation = false, +): Promise { + switch (cacheCase.kind) { + case `invalidation`: + return refreshAfterInvalidation(ignoreInvalidation) + case `expiry`: + return reuseUntilExpiry() + case `cancellation`: + return cancelHeldWork() + case `collection`: + return collectInactivePages(cacheCase.gcTime) + case `refresh`: + return keepCachedDataDuringRefresh(cacheCase.cause) + case `shared`: + return shareFetchesAcrossReaders() + } +} + describe(`cursor cache lifecycle`, () => { describe.each([ [`global`, `maxPages`], @@ -359,126 +675,6 @@ 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: { - queries: { staleTime: Infinity, gcTime: Infinity, retry: false }, - }, - }) - let rows = makeRows(2) - const key = [`sensitivity`] - const pager = createCursorPager({ - queryClient: client, - queryKey: key, - fetchPage: () => Promise.resolve({ rows, nextCursor: null }), - }) - try { - await checkWindow(pager, rows, 2) - rows = makeRows(2, 1) - await client.invalidateQueries({ queryKey: key }) - if (ignoreInvalidation) { - // Deliberately bypass Query's stale check, but still execute the - // production pager and the same value checker as the history law. - client - .getQueryCache() - .find({ queryKey: key, exact: true })! - .setState({ isInvalidated: false }) - await expect(checkWindow(pager, rows, 2)).rejects.toThrow( - `window matches`, - ) - } else await checkWindow(pager, rows, 2) - } finally { - vi.restoreAllMocks() - client.clear() - } - } - }) - - 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) - const client = new QueryClient({ - defaultOptions: { queries: { retry: false, gcTime: Infinity } }, - }) - const key = [`expiry-edge`] - const initial = makeRows(2) - let source = initial - let calls = 0 - const pager = createCursorPager({ - queryClient: client, - queryKey: key, - staleTime: 100, - fetchPage: () => { - calls++ - return Promise.resolve({ rows: source, nextCursor: null }) - }, - }) - try { - await checkWindow(pager, initial, 2) - source = makeRows(2, 1) - vi.setSystemTime(1099) - await checkWindow(pager, initial, 2) - expect(calls).toBe(1) - vi.setSystemTime(1100) - await checkWindow(pager, source, 2) - expect(calls).toBe(2) - } finally { - client.clear() - vi.useRealTimers() - } - }) - - 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 }, - }, - }) - const key = [`cancel`] - const hold = createDeferred() - const delivered = createDeferred() - let deliveredSignal: AbortSignal | undefined - let holdResponse = true - const rows = makeRows(2) - const pager = createCursorPager({ - queryClient: client, - queryKey: key, - fetchPage: async (_cursor, signal) => { - deliveredSignal = signal - if (holdResponse) { - await hold.promise - delivered.resolve() - } - return { rows, nextCursor: null } - }, - }) - const pending = pager.read({}).then( - () => `success`, - () => `rejected`, - ) - try { - await vi.waitFor(() => expect(deliveredSignal).toBeDefined()) - await client.cancelQueries({ queryKey: key, exact: true }) - expect(await pending).toBe(`rejected`) - expect(deliveredSignal?.aborted).toBe(true) - hold.resolve() - await delivered.promise - await new Promise((resolve) => setTimeout(resolve, 0)) - expect(client.getQueryData(key)).toBeUndefined() - holdResponse = false - await checkWindow(pager, rows, 2) - } finally { - hold.resolve() - await pending - client.clear() - } - }) - oracleProperty({ id: `cursor-pagination.cache`, law: { @@ -626,139 +822,41 @@ describe(`cursor cache lifecycle`, () => { check: (_history, trace, expected) => checkCacheTrace(trace, expected), }) - 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 } }, - }) - const backend = createBackend(makeRows(6), scope, 2) - const key = [`collect`] - const pager = createCursorPager({ - queryClient: client, - queryKey: key, - staleTime: Infinity, - gcTime, - fetchPage: backend.fetchPage, - }) - try { - await pager.read({ limit: 3 }) - expect(backend.calls).toHaveLength(2) - expect(client.getQueryData(key)).toBeDefined() - // The 25 ms case rejects a cutoff moved one millisecond either way. - if (gcTime > 0) { - await vi.advanceTimersByTimeAsync(gcTime - 1) - expect(client.getQueryData(key)).toBeDefined() - } - await vi.advanceTimersByTimeAsync(1) - expect(client.getQueryData(key)).toBeUndefined() - expect(await pager.read({ limit: 3 })).toEqual( - expectedRows(makeRows(6), scope, { offset: 0, limit: 3 }), - ) - expect(backend.calls).toHaveLength(4) - } finally { - client.clear() - vi.useRealTimers() - } - }, - ) - - 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({ - defaultOptions: { queries: { retry: false, gcTime: Infinity } }, - }) - const key = [`refresh`] - let source = makeRows(6) - let backend = createBackend(source, scope, 2) - let hold: ReturnType> | undefined - const pager = createCursorPager({ - queryClient: client, - queryKey: key, - staleTime: 100, - fetchPage: async (cursor, signal) => { - if (cursor === undefined) backend = createBackend(source, scope, 2) - const page = await backend.fetchPage(cursor, signal) - if (hold) await hold.promise - return page - }, - }) - try { - await pager.read({ limit: 4 }) - const old = client.getQueryData(key) - source = makeRows(6, 1) - if (cause === `expiry`) vi.setSystemTime(1100) - else await client.invalidateQueries({ queryKey: key }) - hold = createDeferred() - const error = new Error(`refresh failed`) - const pending = pager - .read({ limit: 4 }) - .catch((reason: unknown) => reason) - await vi.waitFor(() => expect(client.isFetching()).toBe(1)) - expect(client.getQueryData(key)).toBe(old) - hold.reject(error) - expect(await pending).toBe(error) - expect(client.getQueryData(key)).toBe(old) - hold = undefined - expect(await pager.read({ limit: 4 })).toEqual( - expectedRows(source, scope, { offset: 0, limit: 4 }), - ) - } finally { - hold?.resolve() - client.clear() - vi.useRealTimers() - } + oracleProperty({ + id: `cursor-pagination.cache-cases`, + law: { + ...cacheLaw, + statement: `Across invalidation, expiry, cancellation, garbage collection, a held or failed refresh and shared readers, every pager window equals the reference slice of the source snapshot that the Query cache permits.`, }, - ) - - 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 }, - }, - }) - const backend = createBackend(makeRows(6), scope, 2) - const hold = createDeferred() - const options = { - queryClient: client, - queryKey: [`shared`], - fetchPage: async (cursor: string | undefined, signal: AbortSignal) => { - const page = await backend.fetchPage(cursor, signal) - await hold.promise - return page + owns: [`src/cursor-pagination.ts#createCursorPager`], + histories: cacheCases, + label: ({ name }) => name, + resolve: (cacheCase) => ({ cacheCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`cursor cache case`, plan, observe, () => + runCacheCase(plan.cacheCase), + ), + check: forwardAssertionJudgment, + calibrations: [ + { + name: `the cache oracle rejects reuse after invalidation and accepts a real refresh`, + law: `cursor cache case`, + driver: ( + plan: { cacheCase: CacheCase }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(`cursor cache case`, plan, observe, () => + rejectsAs( + `ignored invalidation`, + () => runCacheCase(plan.cacheCase, true), + (failure) => + expect(() => { + throw failure + }).toThrow(`window matches`), + ), + ), }, - } - const a = createCursorPager(options) - const b = createCursorPager(options) - const abort = new AbortController() - const error = new Error(`reader left`) - const pending = Promise.allSettled([ - a.read({ limit: 2 }, abort.signal), - b.read({ limit: 4 }), - ]) - try { - await vi.waitFor(() => expect(backend.calls).toHaveLength(1)) - abort.abort(error) - hold.resolve() - expect(await pending).toEqual([ - { status: `rejected`, reason: error }, - { - status: `fulfilled`, - value: expectedRows(makeRows(6), scope, { offset: 0, limit: 4 }), - }, - ]) - expect(backend.calls).toHaveLength(2) - } finally { - hold.resolve() - await pending - client.clear() - } + ], }) }) 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 45f6ee3ea2..616bca9ed7 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -1,12 +1,20 @@ import fc from 'fast-check' -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { differs, oracleProperty, pass, } from '../../db/tests/oracle-property.js' import { createDeferred } from '../../db/src/deferred.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { + allAscending, + fixtureRows, +} from './cursor-pagination-reference-support' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' import { createCursorPager } from './cursor-pagination/pager.js' @@ -53,12 +61,6 @@ const windowArbitrary = fc.record({ offset: fc.integer({ min: 0, max: 35 }), limit: fc.option(fc.integer({ min: 0, max: 35 }), { nil: undefined }), }) -const fixtureRows: Array = Array.from({ length: 9 }, (_, id) => ({ - id, - rank: Math.floor(id / 6), - group: id % 2, -})) -const allAscending: Scope = { group: undefined, descending: false } const fixedSeed = 1_779_001 const pagerLaw = { @@ -151,53 +153,60 @@ oracleProperty({ check: (_history, trace, expected) => checkPagerTrace(trace, expected), }) -async function checkRead( - source: Array, - scope: Scope, - window: Window, - read: (window: Window) => Promise>, -) { - const expected = expectedRows(source, scope, window) - expect(await read(window), `ordered slice ${JSON.stringify(window)}`).toEqual( - expected, - ) -} - -describe(`cursor pagination reference`, () => { - it(`preserves ties, filtering and reverse total order`, () => { - oracleRoute(`case`) - expect( - expectedRows( - fixtureRows, - { group: 1, descending: true }, - { offset: 1, limit: 3 }, - ).map((row) => row.id), - ).toEqual([5, 3, 1]) - expect( - expectedRows(fixtureRows, allAscending, { offset: 0, limit: 0 }), - ).toEqual([]) +const heldResponseActions = [`abort`, `reject`, `reset`] as const +// A held first read ends by abort, rejection or reset while a peer reads. +// The peer's rows must equal the model's slice, and only the first read fails. +async function keepPeerAfterHeldResponse( + action: (typeof heldResponseActions)[number], +): Promise { + const backend = createBackend(fixtureRows, allAscending, 2) + const gate = createDeferred() + const abort = new AbortController() + const failure = new Error(`backend failed`) + let calls = 0 + const pager = createCursorPager(async (cursor, signal) => { + const page = await backend.fetchPage(cursor, signal) + if (++calls === 1) await gate.promise + return page }) - - 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 [ - expected.slice(0, 2), - [...expected].reverse(), - [...expected.slice(0, 7), expected[0]!], - ]) { - await expect( - checkRead(fixtureRows, allAscending, window, () => - Promise.resolve(wrong), - ), - ).rejects.toThrow(`ordered slice`) - } - await checkRead(fixtureRows, allAscending, window, () => - Promise.resolve(expected), - ) + const outcomes: Array = [] + const first = pager.read({ offset: 0, limit: 2 }, abort.signal).then( + () => { + outcomes.push(`first-success`) + return undefined + }, + (error: unknown) => { + outcomes.push(`first-error`) + return error + }, + ) + await vi.waitFor(() => expect(calls).toBe(1)) + expect(outcomes).toEqual([]) + if (action === `abort`) abort.abort() + if (action === `reset`) pager.reset() + const peer = pager.read({ offset: 0, limit: 6 }) + const peerObserved = peer.then((rows) => { + outcomes.push(`peer-success`) + return rows }) -}) + if (action === `reject`) gate.reject(failure) + else gate.resolve() + const error = await first + if (action === `reject`) expect(error).toBe(failure) + else expect(error).toMatchObject({ name: `AbortError` }) + await expect(peerObserved).resolves.toEqual( + expectedRows(fixtureRows, allAscending, { offset: 0, limit: 6 }), + ) + expect(outcomes).toEqual([`first-error`, `peer-success`]) + // Cancelling a reader leaves the shared Query fetch active. A successful + // page remains reusable; failed or reset Query fetches start again. + if (action === `abort`) { + expect(backend.calls[1]).toEqual(expect.any(String)) + expect(backend.calls).toHaveLength(3) + } else { + expect(backend.calls.slice(0, 2)).toEqual([undefined, undefined]) + } +} describe(`opaque cursor adapter`, () => { oracleProperty({ @@ -231,59 +240,24 @@ describe(`opaque cursor adapter`, () => { check: (_history, trace, expected) => checkPagerTrace(trace, expected), }) - 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() - const failure = new Error(`backend failed`) - let calls = 0 - const pager = createCursorPager(async (cursor, signal) => { - const page = await backend.fetchPage(cursor, signal) - if (++calls === 1) await gate.promise - return page - }) - const outcomes: Array = [] - const first = pager.read({ offset: 0, limit: 2 }, abort.signal).then( - () => { - outcomes.push(`first-success`) - return undefined - }, - (error: unknown) => { - outcomes.push(`first-error`) - return error - }, - ) - await vi.waitFor(() => expect(calls).toBe(1)) - expect(outcomes).toEqual([]) - if (action === `abort`) abort.abort() - if (action === `reset`) pager.reset() - const peer = pager.read({ offset: 0, limit: 6 }) - const peerObserved = peer.then((rows) => { - outcomes.push(`peer-success`) - return rows - }) - if (action === `reject`) gate.reject(failure) - else gate.resolve() - const error = await first - if (action === `reject`) expect(error).toBe(failure) - else expect(error).toMatchObject({ name: `AbortError` }) - await expect(peerObserved).resolves.toEqual( - expectedRows(fixtureRows, allAscending, { offset: 0, limit: 6 }), - ) - expect(outcomes).toEqual([`first-error`, `peer-success`]) - // Cancelling a reader leaves the shared Query fetch active. A successful - // page remains reusable; failed or reset Query fetches start again. - if (action === `abort`) { - expect(backend.calls[1]).toEqual(expect.any(String)) - expect(backend.calls).toHaveLength(3) - } else { - expect(backend.calls.slice(0, 2)).toEqual([undefined, undefined]) - } + oracleProperty({ + id: `cursor-pagination.held-response-peer`, + law: { + ...pagerLaw, + statement: `A held response that ends by abort, rejection or reset fails only its own read; a healthy peer read returns the full-relation slice.`, }, - ) + owns: [`src/cursor-pagination.ts#createCursorPager`], + histories: heldResponseActions, + label: (action) => + `keeps a healthy peer usable after held response ${action}`, + resolve: (action) => ({ action }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`held response peer`, plan, observe, () => + keepPeerAfterHeldResponse(plan.action), + ), + check: forwardAssertionJudgment, + }) oracleProperty({ id: `cursor-pagination.reset`, 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 5295d65216..89c7717194 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 @@ -6,9 +6,14 @@ import { include, } from '@tanstack/db' import { QueryClient } from '@tanstack/query-core' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { evaluateReferenceExpression } from '../../db/tests/reference-expression-oracle' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import { queryCollectionOptions } from '../src/query' import type { Collection } from '@tanstack/db' @@ -503,129 +508,201 @@ function 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), - ) - }, +// ## Fixed tree cases +// +// Each case runs the real nested-includes query over Query collections and +// compares the reachable shape and source work with the independent tree +// (checkNestedTreeShape and the expect helpers above). The comparison +// controls corrupt a copy of the captured observation, or the copied public +// rows before capture, and require the same comparison to reject it. + +// The complete reachable tree for `rootCount` roots. +async function checkReachableShape(rootCount: number): Promise { + await checkNestedTreeShape(rootCount, (observation) => + expectNestedTreeShape(observation, rootCount), ) +} - it(`exposes no nested collections for an empty root query`, async () => { - oracleRoute(`case`) - await checkNestedTreeShape(0, (observation) => - expectNestedTreeShape(observation, 0), +// Traversing the result must not deliver more source rows. +async function checkTraversalDelivery(): Promise { + await checkNestedTreeShape(20, (observation) => { + expect(observation.sourceRowsDeliveredAfterTraversal).toEqual( + observation.sourceRowsDeliveredAtPreload, ) }) +} - 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 fix source delivery at preload and reachable shape. - await checkNestedTreeShape(20, (observation) => - expectNestedTreeShape(observation, 20), - ) - }) +// Disconnected branches add no acquisition or delivery at preload. +async function checkDisconnectedBranches( + irrelevantBranchCount: number, +): Promise { + const observations: Array = [] + await checkNestedTreeShape( + 2, + (observation) => { + expectNestedTreeShape(observation, 2) + observations.push(observation) + }, + (rows) => rows, + 0, + true, + ) + await checkNestedTreeShape( + 2, + (observation) => { + expectNestedTreeShape(observation, 2) + observations.push(observation) + }, + (rows) => rows, + irrelevantBranchCount, + true, + ) + const [baseline, scaled] = observations as [NestedTreeShape, NestedTreeShape] + expectIrrelevantBranchWork(baseline, scaled) + + // An extra acquisition for an unreachable branch could leave the public + // tree correct. It must still fail this comparison at preload. + const wrongWork: NestedTreeShape = { + ...scaled, + acquisitionAttemptsAtPreload: { + ...scaled.acquisitionAttemptsAtPreload, + branches: scaled.acquisitionAttemptsAtPreload.branches + 1, + }, + } + expect(() => expectIrrelevantBranchWork(baseline, wrongWork)).toThrow() +} - it(`does not deliver more source rows while traversing the result`, async () => { - oracleRoute(`case`) - await checkNestedTreeShape(20, (observation) => { - expect(observation.sourceRowsDeliveredAfterTraversal).toEqual( - observation.sourceRowsDeliveredAtPreload, - ) - }) +// The work comparison rejects a repeated source row delivery after traversal. +async function checkRepeatedDeliveryRejected(): Promise { + await checkNestedTreeShape(2, (observation) => { + expectNestedTreeShape(observation, 2) + const repeatedDelivery: NestedTreeShape = { + ...observation, + sourceRowsDeliveredAfterTraversal: { + ...observation.sourceRowsDeliveredAfterTraversal, + branches: observation.sourceRowsDeliveredAfterTraversal.branches + 1, + }, + } + expect(() => expectNestedTreeWork(repeatedDelivery, 2)).toThrow() }) +} - it.each([1, 3])( - `does not acquire or deliver %i disconnected branches at preload`, - async (irrelevantBranchCount) => { - oracleRoute(`case`) - const observations: Array = [] - await checkNestedTreeShape( - 2, - (observation) => { - expectNestedTreeShape(observation, 2) - observations.push(observation) - }, - (rows) => rows, - 0, - true, - ) - await checkNestedTreeShape( - 2, - (observation) => { - expectNestedTreeShape(observation, 2) - observations.push(observation) - }, - (rows) => rows, - irrelevantBranchCount, - true, - ) - const [baseline, scaled] = observations as [ - NestedTreeShape, - NestedTreeShape, - ] - expectIrrelevantBranchWork(baseline, scaled) - - // An extra acquisition for an unreachable branch could leave the public - // tree correct. It must still fail this comparison at preload. - const wrongWork: NestedTreeShape = { - ...scaled, - acquisitionAttemptsAtPreload: { - ...scaled.acquisitionAttemptsAtPreload, - branches: scaled.acquisitionAttemptsAtPreload.branches + 1, - }, - } - expect(() => expectIrrelevantBranchWork(baseline, wrongWork)).toThrow() +// The entry comparison rejects a subtree swap or a corrupted value even when +// reachable cardinality and source work agree. +async function checkCorruptedTreeRejected( + fault: `subtree swap` | `value corruption`, +): Promise { + await checkNestedTreeShape(2, (observation) => + expectNestedTreeShape(observation, 2), + ) + await checkNestedTreeShape( + 2, + (observation) => { + expectNestedTreeWork(observation, 2) + expect(() => expectTreeEntries(observation.entries, 2)).toThrow() + }, + (roots) => { + expect(roots).toHaveLength(2) + const [first, second] = roots as [NodeRow, NodeRow] + // Corrupt copied public rows before capture, not expected records or + // production state. Both branches still use real child Collections. + return fault === `subtree swap` + ? [ + { ...first, children: second.children }, + { ...second, children: first.children }, + ] + : [{ ...first, value: `wrong root value` }, second] }, ) +} - it(`rejects a repeated source row delivery after traversal`, async () => { - oracleRoute(`calibration`) - await checkNestedTreeShape(2, (observation) => { - expectNestedTreeShape(observation, 2) - const repeatedDelivery: NestedTreeShape = { - ...observation, - sourceRowsDeliveredAfterTraversal: { - ...observation.sourceRowsDeliveredAfterTraversal, - branches: observation.sourceRowsDeliveredAfterTraversal.branches + 1, - }, - } - expect(() => expectNestedTreeWork(repeatedDelivery, 2)).toThrow() - }) - }) +type TreeCase = + | { name: string; kind: `shape`; rootCount: number } + | { name: string; kind: `traversal` } + | { name: string; kind: `disconnected`; irrelevantBranchCount: number } + | { name: string; kind: `repeated delivery` } + | { + name: string + kind: `corrupted tree` + fault: `subtree swap` | `value corruption` + } + +// 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. +const treeCases: Array = [ + ...[1, 2, 3, 4, 5, 10, 13, 17].map((rootCount): TreeCase => ({ + name: `preserves the complete reachable nested tree shape for ${rootCount} roots`, + kind: `shape`, + rootCount, + })), + { + name: `exposes no nested collections for an empty root query`, + kind: `shape`, + rootCount: 0, + }, + // These semantic counters do not claim to measure elapsed time or internal + // allocations. They fix source delivery at preload and reachable shape. + { + name: `checks the reported 20-by-2-by-5-by-10 tree`, + kind: `shape`, + rootCount: 20, + }, + { + name: `does not deliver more source rows while traversing the result`, + kind: `traversal`, + }, + ...[1, 3].map((irrelevantBranchCount): TreeCase => ({ + name: `does not acquire or deliver ${irrelevantBranchCount} disconnected branches at preload`, + kind: `disconnected`, + irrelevantBranchCount, + })), + { + name: `rejects a repeated source row delivery after traversal`, + kind: `repeated delivery`, + }, + ...([`subtree swap`, `value corruption`] as const).map((fault): TreeCase => ({ + name: `rejects ${fault} even when reachable cardinality and source work agree`, + kind: `corrupted tree`, + fault, + })), +] + +function runTreeCase(treeCase: TreeCase): Promise { + switch (treeCase.kind) { + case `shape`: + return checkReachableShape(treeCase.rootCount) + case `traversal`: + return checkTraversalDelivery() + case `disconnected`: + return checkDisconnectedBranches(treeCase.irrelevantBranchCount) + case `repeated delivery`: + return checkRepeatedDeliveryRejected() + case `corrupted tree`: + return checkCorruptedTreeRejected(treeCase.fault) + } +} - 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), - ) - await checkNestedTreeShape( - 2, - (observation) => { - expectNestedTreeWork(observation, 2) - expect(() => expectTreeEntries(observation.entries, 2)).toThrow() - }, - (roots) => { - expect(roots).toHaveLength(2) - const [first, second] = roots as [NodeRow, NodeRow] - // Corrupt copied public rows before capture, not expected records or - // production state. Both branches still use real child Collections. - return fault === `subtree swap` - ? [ - { ...first, children: second.children }, - { ...second, children: first.children }, - ] - : [{ ...first, value: `wrong root value` }, second] - }, - ) +describe(`nested includes reachable-shape oracle`, () => { + oracleProperty({ + id: `query-db.includes-work-counter`, + law: { + statement: `A nested includes query over Query collections exposes exactly the reachable tree derived from the source foreign keys, and acquires and delivers only the reachable source rows at preload, without more delivery during traversal.`, + authority: `The reachable-shape contract stated at the top of this file; query/live/ARCHITECTURE.md includes materialization.`, + limits: `The fixed root counts, disconnected-branch counts and comparison controls above; semantic counters, not elapsed time or internal allocations.`, }, - ) + // 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. + owns: [`src/query.ts#queryCollectionOptions`], + histories: treeCases, + label: ({ name }) => name, + resolve: (treeCase) => ({ treeCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`reachable tree`, plan, observe, () => + runTreeCase(plan.treeCase), + ), + check: forwardAssertionJudgment, + }) }) diff --git a/packages/query-db-collection/tests/optimistic-ordered-refetch-calibration.test.ts b/packages/query-db-collection/tests/optimistic-ordered-refetch-calibration.test.ts new file mode 100644 index 0000000000..db68a754ac --- /dev/null +++ b/packages/query-db-collection/tests/optimistic-ordered-refetch-calibration.test.ts @@ -0,0 +1,46 @@ +// Checker calibrations for optimistic-ordered-refetch-oracle.test.ts. Each +// control builds a correct public observation, checks that the comparison +// accepts it, corrupts it, and requires the comparison to reject it. They run +// no production, so they calibrate the comparison, not Query DB. +import { expect, it } from 'vitest' +import { + checkOrderedMutationObservation, + expectedPublicWindow, +} from './optimistic-ordered-refetch-model-oracle.js' +import type { Row } from './optimistic-ordered-refetch-probe.js' + +// These controls challenge the same public-window/outcome comparison. They +// establish sensitivity without changing any production code. +it.each([ + `rollback-confirmed-row`, + `discarded-value-failure`, + `reversed-order`, +] as const)(`rejects ordered wrong observation: %s`, (fault) => { + const remote: Array = [ + { id: 1, folder: `inbox`, at: 1 }, + { id: 2, folder: `inbox`, at: 2 }, + ] + const ending = + fault === `rollback-confirmed-row` + ? `reject-after-write` + : `fulfilled-legacy` + const failure = new Error(`handler failure`) + const actual = { + ids: expectedPublicWindow(remote), + error: + ending === `reject-after-write` + ? (failure as unknown) + : (undefined as unknown), + state: ending === `reject-after-write` ? `failed` : `completed`, + } + checkOrderedMutationObservation(remote, ending, actual, failure) + if (fault === `rollback-confirmed-row`) actual.ids = [3, 2] + if (fault === `discarded-value-failure`) { + actual.error = new Error(`legacy value`) + actual.state = `failed` + } + if (fault === `reversed-order`) actual.ids.reverse() + expect(() => + checkOrderedMutationObservation(remote, ending, actual, failure), + ).toThrow() +}) diff --git a/packages/query-db-collection/tests/optimistic-ordered-refetch-model-oracle.ts b/packages/query-db-collection/tests/optimistic-ordered-refetch-model-oracle.ts new file mode 100644 index 0000000000..e39a43d2b0 --- /dev/null +++ b/packages/query-db-collection/tests/optimistic-ordered-refetch-model-oracle.ts @@ -0,0 +1,46 @@ +// The public-window model and the per-checkpoint comparison of +// optimistic-ordered-refetch-oracle.test.ts. That oracle and its checker +// calibrations in optimistic-ordered-refetch-calibration.test.ts both import +// them. This module registers no tests. +import { expect } from 'vitest' +import type { Row } from './optimistic-ordered-refetch-probe.js' + +export type OrderedMutationHistory = { + operation: `insert` | `update` | `delete` + ending: + | `fulfilled-void` + | `fulfilled-legacy` + | `reject-before-write` + | `reject-after-write` + reportDuringHold: boolean +} + +/** + * The public window of the live query `inbox` order `at desc` limit 2, computed + * from the plain remote row ledger. This is the model that the check compares + * the live rows against at each checkpoint. + */ +export function expectedPublicWindow( + remote: ReadonlyArray, +): Array { + return remote + .filter(({ folder }) => folder === `inbox`) + .sort((left, right) => right.at - left.at) + .slice(0, 2) + .map(({ id }) => id) +} + +// Ordering derives from the backend ledger, while outcome derives from the +// handler's Promise. Rejecting after confirmation changes only the latter. +export function checkOrderedMutationObservation( + remote: ReadonlyArray, + ending: OrderedMutationHistory[`ending`], + observed: { ids: Array; error: unknown; state: string }, + failure: Error, +): void { + expect(observed.ids).toEqual(expectedPublicWindow(remote)) + expect(observed.error).toBe(ending.startsWith(`reject`) ? failure : undefined) + expect(observed.state).toBe( + ending.startsWith(`reject`) ? `failed` : `completed`, + ) +} 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 10a397d577..8c761bf9a7 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 @@ -5,19 +5,23 @@ import { createLiveQueryCollection, eq, } from '@tanstack/db' -import { expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { queryCollectionOptions } from '../src/query.js' import { applyPredicates } from '../e2e/query-filter.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' import { differs, oracleProperty, pass } from '../../db/tests/oracle-property' import { withOracleCleanup } from '../../db/tests/utils' import { atMutationCheckpoint } from '../../db/tests/mutation-confirmation-support' import { isNarrowRankTieProbe } from './optimistic-ordered-refetch-probe.js' +import { + checkOrderedMutationObservation, + expectedPublicWindow, +} from './optimistic-ordered-refetch-model-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property' import type { QueryCollectionConfig } from '../src/query' import type { LoadSubsetOptions } from '@tanstack/db' import type { Row } from './optimistic-ordered-refetch-probe.js' +import type { OrderedMutationHistory } from './optimistic-ordered-refetch-model-oracle.js' type ProviderPhase = `initial` | `optimistic` | `held-invalidation` | `handler-refetch` @@ -50,15 +54,6 @@ type ProviderPhase = * immediate, held-refetch, and settled checkpoints. The backend evaluator is * only driver wiring: it never computes the expected window. */ -type OrderedMutationHistory = { - operation: `insert` | `update` | `delete` - ending: - | `fulfilled-void` - | `fulfilled-legacy` - | `reject-before-write` - | `reject-after-write` - reportDuringHold: boolean -} const orderedMutationHistories: Array = ( [`insert`, `update`, `delete`] as const ).flatMap((operation) => @@ -326,34 +321,6 @@ async function driveOptimisticOrderedRefetch( ]) } -/** - * The public window of the live query `inbox` order `at desc` limit 2, computed - * from the plain remote row ledger. This is the model that the check compares - * the live rows against at each checkpoint. - */ -function expectedPublicWindow(remote: ReadonlyArray): Array { - return remote - .filter(({ folder }) => folder === `inbox`) - .sort((left, right) => right.at - left.at) - .slice(0, 2) - .map(({ id }) => id) -} - -// Ordering derives from the backend ledger, while outcome derives from the -// handler's Promise. Rejecting after confirmation changes only the latter. -function checkOrderedMutationObservation( - remote: ReadonlyArray, - ending: OrderedMutationHistory[`ending`], - observed: { ids: Array; error: unknown; state: string }, - failure: Error, -): void { - expect(observed.ids).toEqual(expectedPublicWindow(remote)) - expect(observed.error).toBe(ending.startsWith(`reject`) ? failure : undefined) - expect(observed.state).toBe( - ending.startsWith(`reject`) ? `failed` : `completed`, - ) -} - // Recompute the original immediate and settled windows from the authored // operation and handler ending. Request shapes stay observations of the real // adapter; they do not decide the independent row ledger. @@ -486,40 +453,3 @@ oracleProperty({ driver: driveOptimisticOrderedRefetch, check: checkOrderedMutationTrace, }) - -// These controls challenge the same public-window/outcome comparison. They -// establish sensitivity without changing any production code. -it.each([ - `rollback-confirmed-row`, - `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 }, - ] - const ending = - fault === `rollback-confirmed-row` - ? `reject-after-write` - : `fulfilled-legacy` - const failure = new Error(`handler failure`) - const actual = { - ids: expectedPublicWindow(remote), - error: - ending === `reject-after-write` - ? (failure as unknown) - : (undefined as unknown), - state: ending === `reject-after-write` ? `failed` : `completed`, - } - checkOrderedMutationObservation(remote, ending, actual, failure) - if (fault === `rollback-confirmed-row`) actual.ids = [3, 2] - if (fault === `discarded-value-failure`) { - actual.error = new Error(`legacy value`) - actual.state = `failed` - } - if (fault === `reversed-order`) actual.ids.reverse() - expect(() => - checkOrderedMutationObservation(remote, ending, actual, failure), - ).toThrow() -}) 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 57fd6f5bb0..1f2a2bea8d 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 @@ -1,8 +1,14 @@ -import { describe, expect, it, vi } from 'vitest' +import { describe, expect, vi } from 'vitest' import { DuplicateKeySyncError, createCollection } from '@tanstack/db' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { queryCollectionOptions } from '../src/query' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' +import { rejectsAs } from './calibration-rejection' import { checked, createAdapter, @@ -448,30 +454,118 @@ async function compareWriteWithReference( ) } -// A mutant that reports readiness but discards the first source transaction -// commits its empty metadata transaction and must fail the same comparison. -// Selecting its causal schedule -// cannot excuse missing source rows at the final observation cut. -for (const kind of [`fake`, `sqlite`] as const) - for (const op of ops) - it(`rejects a discarded first result for ${op} through ${kind}`, async () => { - oracleRoute(`calibration`) - await expect( - compareWriteWithReference(op, `startup`, `refetch-only`, kind, true), - ).rejects.toThrow(/reference/) - }) +type WriteCase = { + name: string + op: Op + holder: Holder + keyCase: KeyCase + kind: StorageKind +} +// The fake-adapter matrix, then the real SQLite subset: each write type with +// the key that only the waiting refetch holds, at all three held boundaries. +const writeCases: Array = [ + ...holders.flatMap((holder) => + keyCases.flatMap((keyCase) => + ops.map((op): WriteCase => ({ + name: `${op} with ${holder} holding the lock, key ${keyCase}`, + op, + holder, + keyCase, + kind: `fake`, + })), + ), + ), + ...holders.flatMap((holder) => + ops.map((op): WriteCase => ({ + name: `real SQLite: ${op} with ${holder} holding the lock, key refetch-only`, + op, + holder, + keyCase: `refetch-only`, + kind: `sqlite`, + })), + ), +] describe(`persisted direct writes behind a waiting refetch`, () => { - for (const holder of holders) { - 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) - }) - } - } - } + oracleProperty({ + id: `query-db.persisted-direct-write-window`, + law: { + statement: `A persisted direct write behind a waiting refetch returns the outcome and rows of the no-persistence reference with the same source-commit order, and storage then holds the visible rows.`, + authority: `The direct-write window contract stated at the top of this file; docs/collections/query-collection.md, Direct Writes.`, + limits: `Four write types, three held boundaries and two key cases over the fake adapter, and the refetch-only subset over node SQLite; no concurrent writers or failed I/O.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: writeCases, + label: ({ name }) => name, + resolve: (writeCase) => ({ writeCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment( + `persisted direct write window`, + plan, + observe, + () => + compareWriteWithReference( + plan.writeCase.op, + plan.writeCase.holder, + plan.writeCase.keyCase, + plan.writeCase.kind, + ), + ), + check: forwardAssertionJudgment, + // A mutant that reports readiness but discards the first source transaction + // commits its empty metadata transaction and must fail the same comparison. + // Selecting its causal schedule cannot excuse missing source rows at the + // final observation cut. Each calibration configures that mutant only for + // its own write type and storage at the startup boundary with the + // refetch-only key, the history the old calibration ran; every other + // history runs unchanged and passes, so the calibration reaches that one. + calibrations: ([`fake`, `sqlite`] as const).flatMap((kind) => + ops.map((op) => ({ + name: `rejects a discarded first result for ${op} through ${kind}`, + law: `persisted direct write window`, + driver: ( + plan: { writeCase: WriteCase }, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment( + `persisted direct write window`, + plan, + observe, + () => { + const { writeCase } = plan + const mutated = + writeCase.op === op && + writeCase.kind === kind && + writeCase.holder === `startup` && + writeCase.keyCase === `refetch-only` + if (!mutated) + return compareWriteWithReference( + writeCase.op, + writeCase.holder, + writeCase.keyCase, + writeCase.kind, + ) + return rejectsAs( + `discarded first result`, + () => + compareWriteWithReference( + op, + `startup`, + `refetch-only`, + kind, + true, + ), + (failure) => + expect(() => { + throw failure + }).toThrow(/reference/), + ) + }, + ), + })), + ), + }) }) /** @@ -672,17 +766,6 @@ async function compareLaterRefetchOrder( ) } -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) - }) - } - } -}) - /** * ## Real SQLite receives the same law * @@ -692,23 +775,42 @@ describe(`a later refetch while a persisted direct write waits`, () => { * waiting refetch holds, at all three held boundaries, plus the four ordering * cases. The same commit recorder determines the reference's causal order. */ -describe(`real SQLite: persisted direct writes behind a waiting refetch`, () => { - for (const holder of holders) { - for (const op of ops) { - it(`${op} with ${holder} holding the lock, key refetch-only`, async () => { - oracleRoute(`case`) - await compareWriteWithReference(op, holder, `refetch-only`, `sqlite`) - }) - } - } - 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`) - }) - } - } +type OrderCase = { name: string; start: Start; touch: Touch; kind: StorageKind } +const orderCases: Array = ([`fake`, `sqlite`] as const).flatMap( + (kind) => + ([`before`, `after`] as const).flatMap((start) => + ([`same`, `different`] as const).map((touch): OrderCase => ({ + name: `${kind === `sqlite` ? `real SQLite: ` : ``}R3 started ${start} the write, ${touch} key`, + start, + touch, + kind, + })), + ), +) + +describe(`a later refetch while a persisted direct write waits`, () => { + oracleProperty({ + id: `query-db.persisted-direct-write-refetch-order`, + law: { + statement: `A refetch that started before a waiting persisted direct write loses to it on its keys and wins on the others; one that started after it wins. The visible rows equal the no-persistence reference, and storage holds them.`, + authority: `The merge rule for direct writes and in-flight fetches stated above; docs/collections/query-collection.md, Direct Writes.`, + limits: `Both start orders and both key choices, over the fake adapter and node SQLite.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: orderCases, + label: ({ name }) => name, + resolve: (orderCase) => ({ orderCase }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`later refetch order`, plan, observe, () => + compareLaterRefetchOrder( + plan.orderCase.start, + plan.orderCase.touch, + plan.orderCase.kind, + ), + ), + check: forwardAssertionJudgment, + }) }) /** @@ -774,8 +876,22 @@ 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() + oracleProperty({ + id: `query-db.persisted-direct-duplicate-insert`, + law: { + statement: `Without persistence, core rejects a sync insert of an existing key; the persisted Collection must reject it too and keep the stored row.`, + authority: `Core sync insert semantics (DuplicateKeySyncError); the duplicate-insert law stated above.`, + limits: `One existing key, applied before the write runs, over the fake adapter.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: [`rejects like the reference and keeps the row`], + label: (name) => name, + resolve: (name) => ({ name }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`duplicate insert`, plan, observe, () => + compareDuplicateInsert(), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-driver-oracle.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-driver-oracle.ts new file mode 100644 index 0000000000..afb6be0244 --- /dev/null +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-driver-oracle.ts @@ -0,0 +1,838 @@ +// The model, production driver and refinement check of the ordered-prefix +// refetch oracle in refetch-ordered-prefix-oracle.test.ts, which states the +// contract, and of its known-defect witnesses in +// refetch-ordered-prefix-known-defects.test.ts. This module registers no +// tests. +import { + BTreeIndex, + createCollection, + createLiveQueryCollection, + eq, + getLoadSubsetDemandKey, + isUndefined, + not, +} from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { rejectAt } from '../../db/tests/query/ordered-comparison-oracle.js' +import { evaluateReferenceExpression } from '../../db/tests/reference-expression-oracle' +import { queryCollectionOptions } from '../src/query' +import { sameOrderedIds, settle } from './refetch-ordered-prefix-support' +import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' + +export type Item = { id: number; rank: number; label: string } +export type Marker = { id: number; itemId: number } + +// ## Model +// +// The window is a pure function of the backend rows, the local markers, and +// the query's order and window. A LEFT-joined marker filters rows after the +// provider's order and limit, so the loader must page past a filtered row. +// The model sorts the current backend rows with an independent comparator. +// It shares no production cursor, comparator, or ordering code. +export type Scenario = { + // `ignore` honors neither cursor nor offset. It breaks the request + // contract and is used only to check that refetch fails fast. + encoding: `cursor` | `offset` | `ignore` + // A function Query key derives each key from the request data. + functionKey: boolean + // One order term uses unique ranks. Two terms allow rank ties, which the + // loader resolves with separate tie requests. + terms: 1 | 2 + // The leading order term: a number, or a mixed-case string under the + // default locale collation, whose order differs from code-unit order. + orderKey: `rank` | `label` + // The most rows the provider returns for one request, if it caps pages. + pageCap: number | undefined + limit: number + offset: number +} + +export function compareRows(a: Item, b: Item, scenario: Scenario): number { + const leading = + scenario.orderKey === `rank` + ? b.rank - a.rank + : b.label.localeCompare(a.label) + return leading || (scenario.terms === 2 ? a.id - b.id : 0) +} + +export function expectedWindow( + backend: ReadonlyMap, + markers: ReadonlySet, + scenario: Scenario, +): Array { + return [...backend.values()] + .filter((row) => markers.has(row.id)) + .sort((a, b) => compareRows(a, b, scenario)) + .slice(scenario.offset, scenario.offset + scenario.limit) + .map((row) => row.id) +} + +// The loader treats every row through its boundary as loaded, and its +// boundary is at or after the window's last row. So every backend row from +// the start of the order through that row must be in the source Collection, +// with its current values. A window shorter than its limit read the whole +// source, so then every backend row must be. The window law alone sees a +// missing row only once a marker makes it a window row; this law sees it +// while it is still latent. +export function expectedCoverage( + backend: ReadonlyMap, + markers: ReadonlySet, + scenario: Scenario, +): Array { + const rows = [...backend.values()].sort((a, b) => compareRows(a, b, scenario)) + const window = expectedWindow(backend, markers, scenario) + if (window.length < scenario.limit) return rows + const end = backend.get(window.at(-1)!)! + return rows.filter((row) => compareRows(row, end, scenario) <= 0) +} + +// The commands of a history and a case; the oracle's history grammar +// generates them. +export type Command = + | { type: `upsert`; id: number; rank: number } + | { type: `label`; id: number; label: string } + | { type: `delete`; id: number } + | { type: `flip`; id: number } + | { type: `observe`; id: number } + | { type: `invalidate` } + | { type: `refetch` } + | { type: `hold`; kind: `update` | `delete`; id: number } + | { type: `release` } + +export type Case = { + scenario: Scenario + initial: Array<{ id: number; rank: number; marked: boolean }> + history: Array +} + +// ## Production driver +// +// A real QueryClient backs an on-demand Query Collection with an eager B-tree +// index. Its query function serves each request from the current backend: +// `where` (including tie requests), then the encoding's continuation, then +// order and limit. A `cursor` provider applies `cursor.whereFrom` and ignores +// `offset` when a cursor is present. An `offset` provider ignores the cursor +// and skips `offset` rows. The marker Collection is a local eager source. +export type Run = { + windowIds: () => Array + backend: Map + markers: Set + apply: (command: Command) => Promise + requests: Array + // Calls at the public adapter boundary, before Query metadata normalization. + acquisitions: Array + // Rows returned by the provider so far. + servedRows: () => number + // Requests whose Query key does not match their request data. + keyMismatches: () => number + setEncoding: (encoding: Scenario[`encoding`]) => void + // Fail every provider request after the next `count` requests, until + // cleared. A failed request rejects its Query function. + failAfter: (count: number | undefined) => void + // The Query cache entries this Collection owns: one per tracked request. + cachedQueries: () => number + // Queries an observer still holds. A refetch re-runs only these; a released + // Query stays cached until its gcTime. + activeQueries: () => number + // Reports that served pages may have shifted (`loadedSubsets:invalidated` + // events on the source Collection) so far. + reports: () => number + // Ordered reanswers with changed key membership or an earlier end, so far. + // A report needs one of these; unrelated value edits do not qualify. + changedReanswers: () => number + // Reanswers with the same keys whose last row moves earlier in the order. + sameKeyEarlierEnds: () => number + // Same-key earlier ends on the deleted held boundary with an omitted gap. + deleteHeldGaps: () => number + // Request offsets at which the public report reached the core receiver. + reportRequestOffsets: () => ReadonlyArray + // Make the next change application fail once: a change subscriber throws + // while the refetched rows commit. + failNextApplication: () => void + // Start an optimistic update of a loaded row whose handler waits until the + // returned release is called. While it persists, the Collection holds + // every sync transaction back. + holdUpdate: (id: number) => () => Promise + // Whether a hold is running. No window is owed until its release. + holding: () => boolean + // The source Collection's rows, which the coverage law checks. + loadedRows: () => ReadonlyMap + refetch: (opts?: { throwOnError?: boolean }) => Promise + cleanup: () => Promise> +} + +let collectionSequence = 0 + +export async function startRun(testCase: Case): Promise { + const { scenario } = testCase + const id = `refetch-ordered-prefix-${collectionSequence++}` + // Letters alternate case, so locale and code-unit orders differ. + const letters = [`a`, `B`, `c`, `D`, `e`, `F`] + const toItem = (row: { id: number; rank: number }): Item => ({ + id: row.id, + rank: scenario.terms === 1 ? row.rank * 100 + row.id : row.rank, + label: + (letters[row.rank] ?? `z`) + (scenario.terms === 1 ? String(row.id) : ``), + }) + const backend = new Map(testCase.initial.map((row) => [row.id, toItem(row)])) + const markers = new Set( + testCase.initial.filter((row) => row.marked).map((row) => row.id), + ) + const requests: Array = [] + let encoding = scenario.encoding + let servedRows = 0 + let keyMismatches = 0 + const keyFor = (options: LoadSubsetOptions) => { + const demandKey = getLoadSubsetDemandKey(options) + return demandKey === undefined ? [id] : [id, demandKey] + } + let failFrom: number | undefined + const answers = new Map>() + let changedReanswers = 0 + let sameKeyEarlierEnds = 0 + let deleteHeldGaps = 0 + let heldRow: { kind: `update` | `delete`; id: number } | undefined + const serve = (options: LoadSubsetOptions): Array => { + const page = answer(options) + if (options.orderBy) { + const request = JSON.stringify([ + getLoadSubsetDemandKey(options), + options.offset, + ]) + const previous = answers.get(request) + if (previous !== undefined) { + const keys = (rows: Array) => + rows + .map((row) => row.id) + .sort() + .join(`,`) + const changedKeys = keys(previous) !== keys(page) + const oldEnd = previous.at(-1) + const newEnd = page.at(-1) + const earlierEnd = + oldEnd !== undefined && + (newEnd === undefined || compareRows(newEnd, oldEnd, scenario) < 0) + // Report relevance is independent of payload equality: an order-equal + // label edit cannot lose prefix coverage. A moved end can, even when + // key membership is unchanged. Complete values remain in the separate + // source-coverage comparison. + if (changedKeys || earlierEnd) changedReanswers++ + if (!changedKeys && earlierEnd && newEnd !== undefined) { + sameKeyEarlierEnds++ + if ( + heldRow?.kind === `delete` && + heldRow.id === oldEnd.id && + [...backend.values()].some( + (row) => + compareRows(row, newEnd, scenario) > 0 && + compareRows(row, oldEnd, scenario) < 0 && + !previous.some((before) => before.id === row.id) && + !page.some((after) => after.id === row.id), + ) + ) { + deleteHeldGaps++ + } + } + } + answers.set(request, page) + } + return page + } + const answer = (options: LoadSubsetOptions): Array => { + requests.push(options) + if (failFrom !== undefined && requests.length > failFrom) { + throw new Error(`provider unavailable`) + } + const usesCursor = encoding === `cursor` && options.cursor + const predicates = [ + options.where, + usesCursor ? options.cursor!.whereFrom : undefined, + ].filter((where) => where !== undefined) + let page = [...backend.values()].filter((row) => + predicates.every( + (where) => evaluateReferenceExpression(where, row) === true, + ), + ) + if (options.orderBy) { + page.sort((a, b) => compareRows(a, b, scenario)) + if (!usesCursor && encoding !== `ignore`) { + page = page.slice(options.offset ?? 0) + } + if (options.limit !== undefined) page = page.slice(0, options.limit) + if (scenario.pageCap !== undefined) page = page.slice(0, scenario.pageCap) + } + servedRows += page.length + return page.map((row) => ({ ...row })) + } + + const queryClient = new QueryClient({ + defaultOptions: { + queries: { gcTime: Infinity, retry: false, staleTime: Infinity }, + }, + }) + const config = queryCollectionOptions({ + id, + queryClient, + queryKey: scenario.functionKey ? keyFor : [id], + syncMode: `on-demand`, + queryFn: (context) => { + const options = context.meta!.loadSubsetOptions! + if ( + scenario.functionKey && + JSON.stringify(context.queryKey) !== JSON.stringify(keyFor(options)) + ) { + keyMismatches++ + } + return Promise.resolve(serve(options)) + }, + getKey: (row) => row.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + // A held optimistic write stays persisting until released; the + // backend does not change. + onUpdate: async () => { + await heldWrite + }, + onDelete: async () => { + await heldWrite + }, + }) + const acquisitions: Array = [] + const sync = config.sync.sync + config.sync = { + ...config.sync, + sync: (operations) => { + const handlers = sync(operations) + if ( + handlers === undefined || + typeof handlers === `function` || + handlers.loadSubset === undefined + ) + return handlers + const load = handlers.loadSubset + return { + ...handlers, + loadSubset: (options) => { + acquisitions.push(options) + return load(options) + }, + } + }, + } + const items = createCollection(config) + let heldWrite: Promise = Promise.resolve() + // The running hold's release, if any. + let releaseHold: (() => Promise) | undefined + const heldPreloads: Array> = [] + const hold = (kind: `update` | `delete`, rowId: number) => { + let release!: () => void + heldWrite = new Promise((resolve) => { + release = resolve + }) + const transaction = + kind === `update` + ? items.update(rowId, (draft) => { + draft.label += `!` + }) + : items.delete(rowId) + return async () => { + release() + await transaction.when(`settled`) + await settle() + } + } + + let reports = 0 + const reportRequestOffsets: Array = [] + items.on(`loadedSubsets:invalidated`, () => { + reports++ + reportRequestOffsets.push(acquisitions.length) + }) + let failApplication = false + items.subscribeChanges(() => { + if (!failApplication) return + failApplication = false + throw new Error(`change subscriber failed`) + }) + + let markerSync!: Parameters[`sync`]>[0] + const markerCollection = createCollection({ + id: `${id}-markers`, + getKey: (marker) => marker.id, + autoIndex: `eager`, + defaultIndexType: BTreeIndex, + sync: { + sync: (operations) => { + markerSync = operations + operations.begin() + for (const itemId of markers) { + operations.write({ type: `insert`, value: { id: itemId, itemId } }) + } + operations.commit() + operations.markReady() + }, + }, + }) + + const window = createLiveQueryCollection((q) => { + const ordered = q + .from({ item: items }) + .leftJoin({ marker: markerCollection }, ({ item, marker }) => + eq(item.id, marker.itemId), + ) + .where(({ marker }) => not(isUndefined(marker.itemId))) + .orderBy( + ({ item }) => (scenario.orderKey === `rank` ? item.rank : item.label), + `desc`, + ) + return ( + scenario.terms === 2 + ? ordered.orderBy(({ item }) => item.id, `asc`) + : ordered + ) + .offset(scenario.offset) + .limit(scenario.limit) + .select(({ item }) => ({ id: item.id })) + }) + + const observers: Array<{ cleanup: () => Promise }> = [] + const apply = async (command: Command): Promise => { + switch (command.type) { + case `upsert`: + backend.set(command.id, toItem(command)) + return + case `label`: { + const row = backend.get(command.id) + if (row !== undefined) + backend.set(command.id, { ...row, label: command.label }) + return + } + case `delete`: + backend.delete(command.id) + return + case `flip`: { + const marked = markers.has(command.id) + if (marked) markers.delete(command.id) + else markers.add(command.id) + markerSync.begin() + markerSync.write({ + type: marked ? `delete` : `insert`, + value: { id: command.id, itemId: command.id }, + }) + const receipt = markerSync.commit() + if (receipt !== true) await receipt + await settle() + return + } + case `observe`: { + const observer = createLiveQueryCollection((q) => + q + .from({ item: items }) + .where(({ item }) => eq(item.id, command.id)) + .select(({ item }) => ({ id: item.id })), + ) + observers.push(observer) + // A preload waits until its rows are visible, so under a hold it + // settles only after the release. + const preload = observer.preload() + if (releaseHold === undefined) await preload + else heldPreloads.push(preload) + await settle() + return + } + case `invalidate`: + await queryClient.invalidateQueries({ queryKey: [id] }) + await settle() + return + case `refetch`: + await items.utils.refetch() + await settle() + return + // A row that is not loaded cannot take an optimistic write, so the + // hold is a no-op then. + case `hold`: + if (releaseHold === undefined && items.has(command.id)) { + heldRow = { kind: command.kind, id: command.id } + releaseHold = hold(command.kind, command.id) + await settle() + } + return + case `release`: { + const release = releaseHold + releaseHold = undefined + heldRow = undefined + await release?.() + await Promise.all(heldPreloads.splice(0)) + await settle() + return + } + } + } + + await window.preload() + await settle() + return { + windowIds: () => window.toArray.map((row) => row.id), + backend, + markers, + apply, + requests, + acquisitions, + servedRows: () => servedRows, + keyMismatches: () => keyMismatches, + setEncoding: (next) => { + encoding = next + }, + failAfter: (count) => { + failFrom = count === undefined ? undefined : requests.length + count + }, + cachedQueries: () => + queryClient.getQueryCache().findAll({ queryKey: [id] }).length, + activeQueries: () => + queryClient.getQueryCache().findAll({ queryKey: [id], type: `active` }) + .length, + reports: () => reports, + changedReanswers: () => changedReanswers, + sameKeyEarlierEnds: () => sameKeyEarlierEnds, + deleteHeldGaps: () => deleteHeldGaps, + reportRequestOffsets: () => reportRequestOffsets, + failNextApplication: () => { + failApplication = true + }, + holdUpdate: (rowId) => hold(`update`, rowId), + holding: () => releaseHold !== undefined, + loadedRows: () => + new Map( + items.toArray.map(({ id: rowId, rank, label }) => [ + rowId, + { id: rowId, rank, label }, + ]), + ), + refetch: (opts) => items.utils.refetch(opts), + cleanup: async () => { + const failures: Array = [] + // A failed history can stop inside a hold. Release it, so its + // transaction does not outlive this run. + try { + await apply({ type: `release` }) + } catch (error) { + failures.push(error) + } + for (const collection of [ + ...observers, + window, + items, + markerCollection, + ]) { + try { + await collection.cleanup() + } catch (error) { + failures.push(error) + } + } + queryClient.clear() + return failures + }, + } +} + +// ## Refinement check +// +// The checkpoints are preload, every invalidation, and every refetch. There the +// public window's +// ordered ids must equal the model's ids. The comparison keeps order, duplicates, and missing +// rows. A failure names its checkpoint, so a loader defect at preload is not +// mistaken for a refetch defect. +// +// The run also records the premise of the gap: a refetch or invalidation of a +// lagging provider after a continuation page was served. The campaigns must +// reach it, and must reach such a refetch while a hold is running. +export type Observation = { + reachedSameKeyEarlierEndDuringHold: boolean + reachedDeleteHeldGapReportAndRepair: boolean + reachedLaggingRefetchAfterContinuation: boolean + // A refetch of a lagging provider while an optimistic write held sync rows + // back. + reachedLaggingRefetchDuringHold: boolean + // Such a refetch that also reported, so a loader may have judged the skip + // while the refetched rows were held back. + reachedReportDuringHold: boolean + // A refetch that reported while the window was full: the loader then + // judges whether its own first page still proves the window, and repairs + // when a moved end leaves it unproven. + reachedReportOverFullWindow: boolean + // Reports over the whole run. + reports: number +} + +// A step's reports, checked against the report laws in the contract. +export function checkReports( + command: Command, + reports: number, + changedReanswers: number, + checkpoint: string, +): void { + if (reports > 1) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:988`, + new Error( + `a ${command.type} at ${checkpoint} reported ${reports} times; expected at most 1`, + ), + ) + } + if (reports > 0 && changedReanswers === 0) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:993`, + new Error( + `a ${command.type} at ${checkpoint} reported, but no ordered request changed keys or moved its end earlier`, + ), + ) + } +} + +export async function checkCase(testCase: Case): Promise { + const run = await startRun(testCase) + let reachedSameKeyEarlierEndDuringHold = false + let reachedDeleteHeldGapReportAndRepair = false + let reachedLaggingRefetchAfterContinuation = false + let reachedLaggingRefetchDuringHold = false + let reachedReportDuringHold = false + let reachedReportOverFullWindow = false + let primary: unknown + const check = (checkpoint: string) => { + const expected = expectedWindow(run.backend, run.markers, testCase.scenario) + const actual = run.windowIds() + if (!sameOrderedIds(actual, expected)) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1012`, + new Error( + `window ${checkpoint} was ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}`, + ), + ) + } + const loaded = run.loadedRows() + const uncovered = expectedCoverage( + run.backend, + run.markers, + testCase.scenario, + ).filter( + (row) => JSON.stringify(loaded.get(row.id)) !== JSON.stringify(row), + ) + if (uncovered.length > 0) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1025`, + new Error( + `coverage ${checkpoint}: rows ${JSON.stringify(uncovered)} sort at or before the window's end but are ${JSON.stringify(uncovered.map((row) => loaded.get(row.id) ?? null))} in the source Collection`, + ), + ) + } + } + // A full-source request names no predicate, order, limit, or cursor. A + // refetch may re-run one that already existed, but must not start the first. + const isFullSource = (options: LoadSubsetOptions) => + options.where === undefined && + options.orderBy === undefined && + options.limit === undefined && + options.cursor === undefined + const checkNoNewFullSource = (from: number, checkpoint: string) => { + if (run.requests.slice(0, from).some(isFullSource)) return + if (run.requests.slice(from).some(isFullSource)) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1040`, + new Error(`a ${checkpoint} started a full-source request`), + ) + } + } + try { + check(`after preload`) + let providerCurrent = true + const history: Array = [...testCase.history, { type: `refetch` }] + for (const [step, command] of history.entries()) { + if ( + (command.type === `refetch` || command.type === `invalidate`) && + !providerCurrent && + run.requests.some((options) => options.cursor !== undefined) + ) { + reachedLaggingRefetchAfterContinuation = true + } + if (command.type === `refetch` && !providerCurrent && run.holding()) { + reachedLaggingRefetchDuringHold = true + } + const requestsBefore = run.requests.length + const reportsBefore = run.reports() + const reanswersBefore = run.changedReanswers() + const sameKeyEndsBefore = run.sameKeyEarlierEnds() + const gapsBefore = run.deleteHeldGaps() + await run.apply(command) + if ( + run.deleteHeldGaps() > gapsBefore && + run.reports() > reportsBefore && + run + .reportRequestOffsets() + .slice(reportsBefore) + .some((from) => + run.acquisitions + .slice(from) + .some((request) => request.refetch === true), + ) + ) { + reachedDeleteHeldGapReportAndRepair = true + } + if (run.holding() && run.sameKeyEarlierEnds() > sameKeyEndsBefore) { + reachedSameKeyEarlierEndDuringHold = true + } + if ( + command.type === `refetch` && + run.holding() && + run.reports() > reportsBefore + ) { + reachedReportDuringHold = true + } + if ( + command.type === `refetch` && + run.reports() > reportsBefore && + run.windowIds().length === testCase.scenario.limit + ) { + reachedReportOverFullWindow = true + } + checkReports( + command, + run.reports() - reportsBefore, + run.changedReanswers() - reanswersBefore, + `step ${step}`, + ) + if (command.type === `refetch` || command.type === `invalidate`) { + checkNoNewFullSource(requestsBefore, `${command.type} at step ${step}`) + } + if ( + command.type === `upsert` || + command.type === `delete` || + command.type === `label` + ) { + providerCurrent = false + } else if (command.type === `release`) { + if (providerCurrent) check(`after release at step ${step}`) + } else if (command.type === `refetch` || command.type === `invalidate`) { + providerCurrent = true + if (!run.holding()) check(`after ${command.type} at step ${step}`) + } + } + // Repeat the refetch twice with the backend unchanged. The second read + // must serve no more rows than the first. + // A first-page request is ordered and limited, with no cursor. + const firstPageRequests = (from: number) => + run.requests + .slice(from) + .filter( + (options) => + options.orderBy !== undefined && + options.limit !== undefined && + options.cursor === undefined, + ).length + const servedBy = async (command: Command) => { + const before = run.servedRows() + const requestsBefore = run.requests.length + const reportsBefore = run.reports() + await run.apply(command) + const reports = run.reports() - reportsBefore + if (reports > 0) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1136`, + new Error( + `a ${command.type} of an unchanged backend reported ${reports} times`, + ), + ) + } + // The backend is unchanged, so the loader starts no repair and every + // first-page request comes from the refetch itself. + const firstPages = firstPageRequests(requestsBefore) + if (firstPages > 1) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1144`, + new Error( + `a ${command.type} of an unchanged backend started ${firstPages} first-page requests; expected at most 1`, + ), + ) + } + return run.servedRows() - before + } + // The first repeat may still settle loader work that the history + // started, so only later repeats check the first-page law. + const before = run.servedRows() + await run.apply({ type: `refetch` }) + const first = run.servedRows() - before + const second = await servedBy({ type: `refetch` }) + if (second > first) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1157`, + new Error( + `a repeated refetch served ${second} rows after ${first} with an unchanged backend`, + ), + ) + } + check(`after repeated refetches`) + // An invalidation refetches every page at once. It reads the same served + // prefixes as an explicit refetch, so it serves no more rows. + const invalidated = await servedBy({ type: `invalidate` }) + if (invalidated > second) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1166`, + new Error( + `an invalidation served ${invalidated} rows; an explicit refetch of the same state served ${second}`, + ), + ) + } + check(`after an invalidation of an unchanged backend`) + if (run.keyMismatches() > 0) { + rejectAt( + `refetch-ordered-prefix-oracle.test.ts:1172`, + new Error( + `${run.keyMismatches()} requests carried a Query key that did not match their request data`, + ), + ) + } + } catch (error) { + primary = error + } + const cleanupFailures = await run.cleanup() + if (primary !== undefined && cleanupFailures.length > 0) { + throw new AggregateError( + [primary, ...cleanupFailures], + `refetch prefix law and cleanup failed`, + { cause: primary }, + ) + } + if (primary !== undefined) throw primary + if (cleanupFailures.length > 0) { + throw new AggregateError(cleanupFailures, `refetch prefix cleanup failed`) + } + return { + reachedSameKeyEarlierEndDuringHold, + reachedDeleteHeldGapReportAndRepair, + reachedLaggingRefetchAfterContinuation, + reachedLaggingRefetchDuringHold, + reachedReportDuringHold, + reachedReportOverFullWindow, + reports: run.reports(), + } +} + +// A history that never settles is a progress failure, not a slow pass. The +// deadline turns it into a counterexample that fast-check can shrink. It is +// generous because a long history runs many settle loops on a loaded machine. +export async function withDeadline(work: Promise): Promise { + let timer: ReturnType | undefined + try { + return await Promise.race([ + work, + new Promise((_, reject) => { + timer = setTimeout( + () => reject(new Error(`history did not settle within 30 s`)), + 30_000, + ) + }), + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-grammar-oracle.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-grammar-oracle.ts new file mode 100644 index 0000000000..9c4ca5f771 --- /dev/null +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-grammar-oracle.ts @@ -0,0 +1,34 @@ +// The structural page-end histories of refetch-ordered-prefix-oracle.test.ts. +// Each history names the page width and offset, the hold regime (a delete or +// an update of the boundary row, a delete of another row, or no hold), and +// whether the boundary moves earlier or later. The oracle builds each with +// `pageEndCase` and runs it through its shared driver and comparison. This +// module registers no tests; the grammar-size check is in +// refetch-ordered-prefix.test.ts. +export type PageEndStructuralHistory = { + limit: 1 | 2 | 3 + offset: 0 | 1 + hold: `delete` | `update` | `other-delete` | `none` + direction: `earlier` | `later` +} + +export const pageEndStructuralHistories: Array = ( + [1, 2, 3] as const +).flatMap((limit) => + ([0, 1] as const).flatMap((offset) => + ([`delete`, `update`, `other-delete`, `none`] as const).flatMap((hold) => + ([`earlier`, `later`] as const).map((direction) => ({ + limit, + offset, + hold, + direction, + })), + ), + ), +) + +// A delete hold whose boundary moves earlier must report and repair its gap +// at every width and offset. +export const pageEndStructuralStates = [1, 2, 3].flatMap((limit) => + [0, 1].map((offset) => `delete-held gap width ${limit} offset ${offset}`), +) diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts new file mode 100644 index 0000000000..7a29aca777 --- /dev/null +++ b/packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts @@ -0,0 +1,69 @@ +// Known ordered-loader defects outside #2136, witnessed through the +// ordered-prefix refetch oracle's production driver and check +// (refetch-ordered-prefix-driver-oracle.ts). Each `it.fails` witness passes +// while the defect reproduces and fails once the loader is fixed, so the limits +// in refetch-ordered-prefix-oracle.test.ts can then be removed. +import { expect, it } from 'vitest' +import { checkCase, startRun } from './refetch-ordered-prefix-driver-oracle' + +// Known loader defects outside #2136. These witnesses fail today and +// reproduce on main; `it.fails` turns green work into a red signal once the +// ordered loader is fixed, so the limits above can be removed. +it.fails( + `an offset-only provider fills a joined window at preload (known loader defect)`, + async () => { + const run = await startRun({ + scenario: { + encoding: `offset`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 1, + offset: 0, + }, + initial: [ + { id: 0, rank: 0, marked: true }, + { id: 1, rank: 0, marked: false }, + { id: 2, rank: 0, marked: false }, + ], + history: [], + }) + try { + expect(run.windowIds()).toEqual([0]) + } finally { + await run.cleanup() + } + }, +) + +it.fails( + `a capped provider keeps a moved row in the window after a refetch (known loader defect)`, + async () => { + await checkCase({ + scenario: { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: 2, + terms: 1, + limit: 2, + offset: 1, + }, + initial: [ + { id: 0, rank: 1, marked: true }, + { id: 1, rank: 0, marked: false }, + ], + history: [ + { type: `flip`, id: 7 }, + { type: `upsert`, id: 2, rank: 1 }, + { type: `upsert`, id: 7, rank: 0 }, + { type: `refetch` }, + { type: `delete`, id: 3 }, + { type: `invalidate` }, + { type: `upsert`, id: 3, rank: 1 }, + { type: `upsert`, id: 0, rank: 0 }, + ], + }) + }, +) 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 30a53f0fde..15530be241 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 @@ -1,29 +1,30 @@ -import { - BTreeIndex, - createCollection, - createLiveQueryCollection, - eq, - getLoadSubsetDemandKey, - isUndefined, - not, -} from '@tanstack/db' -import { QueryClient } from '@tanstack/query-core' import fc from 'fast-check' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { oracleProperty, oracleReplayRequested, - oracleRoute, } from '../../db/tests/oracle-config.js' import { observeComparison, recordedComparison, - rejectAt, } from '../../db/tests/query/ordered-comparison-oracle.js' -import { evaluateReferenceExpression } from '../../db/tests/reference-expression-oracle' -import { queryCollectionOptions } from '../src/query' -import { sameOrderedIds, settle } from './refetch-ordered-prefix-support' -import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' +import { settle } from './refetch-ordered-prefix-support' +import { + pageEndStructuralHistories, + pageEndStructuralStates, +} from './refetch-ordered-prefix-grammar-oracle' +import { + checkCase, + expectedWindow, + startRun, + withDeadline, +} from './refetch-ordered-prefix-driver-oracle' +import type { + Case, + Command, + Observation, + Scenario, +} from './refetch-ordered-prefix-driver-oracle' /** * # Does an on-demand refetch leave a gap between served ordered pages? @@ -155,73 +156,6 @@ import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' * reply does not prove source exhaustion (glossary). */ -type Item = { id: number; rank: number; label: string } -type Marker = { id: number; itemId: number } - -// ## Model -// -// The window is a pure function of the backend rows, the local markers, and -// the query's order and window. A LEFT-joined marker filters rows after the -// provider's order and limit, so the loader must page past a filtered row. -// The model sorts the current backend rows with an independent comparator. -// It shares no production cursor, comparator, or ordering code. -type Scenario = { - // `ignore` honors neither cursor nor offset. It breaks the request - // contract and is used only to check that refetch fails fast. - encoding: `cursor` | `offset` | `ignore` - // A function Query key derives each key from the request data. - functionKey: boolean - // One order term uses unique ranks. Two terms allow rank ties, which the - // loader resolves with separate tie requests. - terms: 1 | 2 - // The leading order term: a number, or a mixed-case string under the - // default locale collation, whose order differs from code-unit order. - orderKey: `rank` | `label` - // The most rows the provider returns for one request, if it caps pages. - pageCap: number | undefined - limit: number - offset: number -} - -function compareRows(a: Item, b: Item, scenario: Scenario): number { - const leading = - scenario.orderKey === `rank` - ? b.rank - a.rank - : b.label.localeCompare(a.label) - return leading || (scenario.terms === 2 ? a.id - b.id : 0) -} - -function expectedWindow( - backend: ReadonlyMap, - markers: ReadonlySet, - scenario: Scenario, -): Array { - return [...backend.values()] - .filter((row) => markers.has(row.id)) - .sort((a, b) => compareRows(a, b, scenario)) - .slice(scenario.offset, scenario.offset + scenario.limit) - .map((row) => row.id) -} - -// The loader treats every row through its boundary as loaded, and its -// boundary is at or after the window's last row. So every backend row from -// the start of the order through that row must be in the source Collection, -// with its current values. A window shorter than its limit read the whole -// source, so then every backend row must be. The window law alone sees a -// missing row only once a marker makes it a window row; this law sees it -// while it is still latent. -function expectedCoverage( - backend: ReadonlyMap, - markers: ReadonlySet, - scenario: Scenario, -): Array { - const rows = [...backend.values()].sort((a, b) => compareRows(a, b, scenario)) - const window = expectedWindow(backend, markers, scenario) - if (window.length < scenario.limit) return rows - const end = backend.get(window.at(-1)!)! - return rows.filter((row) => compareRows(row, end, scenario) <= 0) -} - // ## History grammar // // - `label` changes only a backend label. Under numeric order it is a @@ -257,23 +191,6 @@ function expectedCoverage( // 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 = - | { type: `upsert`; id: number; rank: number } - | { type: `label`; id: number; label: string } - | { type: `delete`; id: number } - | { type: `flip`; id: number } - | { type: `observe`; id: number } - | { type: `invalidate` } - | { type: `refetch` } - | { type: `hold`; kind: `update` | `delete`; id: number } - | { type: `release` } - -type Case = { - scenario: Scenario - initial: Array<{ id: number; rank: number; marked: boolean }> - history: Array -} - const ids = fc.integer({ min: 0, max: 7 }) const ranks = fc.integer({ min: 0, max: 5 }) @@ -520,895 +437,165 @@ const sameKeyEndCaseArbitrary = sameKeyEndInputArbitrary.map((input) => pageEndCase(input, `delete`), ) -// ## Production driver -// -// A real QueryClient backs an on-demand Query Collection with an eager B-tree -// index. Its query function serves each request from the current backend: -// `where` (including tie requests), then the encoding's continuation, then -// order and limit. A `cursor` provider applies `cursor.whereFrom` and ignores -// `offset` when a cursor is present. An `offset` provider ignores the cursor -// and skips `offset` rows. The marker Collection is a local eager source. -type Run = { - windowIds: () => Array - backend: Map - markers: Set - apply: (command: Command) => Promise - requests: Array - // Calls at the public adapter boundary, before Query metadata normalization. - acquisitions: Array - // Rows returned by the provider so far. - servedRows: () => number - // Requests whose Query key does not match their request data. - keyMismatches: () => number - setEncoding: (encoding: Scenario[`encoding`]) => void - // Fail every provider request after the next `count` requests, until - // cleared. A failed request rejects its Query function. - failAfter: (count: number | undefined) => void - // The Query cache entries this Collection owns: one per tracked request. - cachedQueries: () => number - // Queries an observer still holds. A refetch re-runs only these; a released - // Query stays cached until its gcTime. - activeQueries: () => number - // Reports that served pages may have shifted (`loadedSubsets:invalidated` - // events on the source Collection) so far. - reports: () => number - // Ordered reanswers with changed key membership or an earlier end, so far. - // A report needs one of these; unrelated value edits do not qualify. - changedReanswers: () => number - // Reanswers with the same keys whose last row moves earlier in the order. - sameKeyEarlierEnds: () => number - // Same-key earlier ends on the deleted held boundary with an omitted gap. - deleteHeldGaps: () => number - // Request offsets at which the public report reached the core receiver. - reportRequestOffsets: () => ReadonlyArray - // Make the next change application fail once: a change subscriber throws - // while the refetched rows commit. - failNextApplication: () => void - // Start an optimistic update of a loaded row whose handler waits until the - // returned release is called. While it persists, the Collection holds - // every sync transaction back. - holdUpdate: (id: number) => () => Promise - // Whether a hold is running. No window is owed until its release. - holding: () => boolean - // The source Collection's rows, which the coverage law checks. - loadedRows: () => ReadonlyMap - refetch: (opts?: { throwOnError?: boolean }) => Promise - cleanup: () => Promise> -} - -let collectionSequence = 0 - -async function startRun(testCase: Case): Promise { - const { scenario } = testCase - const id = `refetch-ordered-prefix-${collectionSequence++}` - // Letters alternate case, so locale and code-unit orders differ. - const letters = [`a`, `B`, `c`, `D`, `e`, `F`] - const toItem = (row: { id: number; rank: number }): Item => ({ - id: row.id, - rank: scenario.terms === 1 ? row.rank * 100 + row.id : row.rank, - label: - (letters[row.rank] ?? `z`) + (scenario.terms === 1 ? String(row.id) : ``), - }) - const backend = new Map(testCase.initial.map((row) => [row.id, toItem(row)])) - const markers = new Set( - testCase.initial.filter((row) => row.marked).map((row) => row.id), - ) - const requests: Array = [] - let encoding = scenario.encoding - let servedRows = 0 - let keyMismatches = 0 - const keyFor = (options: LoadSubsetOptions) => { - const demandKey = getLoadSubsetDemandKey(options) - return demandKey === undefined ? [id] : [id, demandKey] - } - let failFrom: number | undefined - const answers = new Map>() - let changedReanswers = 0 - let sameKeyEarlierEnds = 0 - let deleteHeldGaps = 0 - let heldRow: { kind: `update` | `delete`; id: number } | undefined - const serve = (options: LoadSubsetOptions): Array => { - const page = answer(options) - if (options.orderBy) { - const request = JSON.stringify([ - getLoadSubsetDemandKey(options), - options.offset, - ]) - const previous = answers.get(request) - if (previous !== undefined) { - const keys = (rows: Array) => - rows - .map((row) => row.id) - .sort() - .join(`,`) - const changedKeys = keys(previous) !== keys(page) - const oldEnd = previous.at(-1) - const newEnd = page.at(-1) - const earlierEnd = - oldEnd !== undefined && - (newEnd === undefined || compareRows(newEnd, oldEnd, scenario) < 0) - // Report relevance is independent of payload equality: an order-equal - // label edit cannot lose prefix coverage. A moved end can, even when - // key membership is unchanged. Complete values remain in the separate - // source-coverage comparison. - if (changedKeys || earlierEnd) changedReanswers++ - if (!changedKeys && earlierEnd && newEnd !== undefined) { - sameKeyEarlierEnds++ - if ( - heldRow?.kind === `delete` && - heldRow.id === oldEnd.id && - [...backend.values()].some( - (row) => - compareRows(row, newEnd, scenario) > 0 && - compareRows(row, oldEnd, scenario) < 0 && - !previous.some((before) => before.id === row.id) && - !page.some((after) => after.id === row.id), - ) - ) { - deleteHeldGaps++ - } - } - } - answers.set(request, page) - } - return page - } - const answer = (options: LoadSubsetOptions): Array => { - requests.push(options) - if (failFrom !== undefined && requests.length > failFrom) { - throw new Error(`provider unavailable`) - } - const usesCursor = encoding === `cursor` && options.cursor - const predicates = [ - options.where, - usesCursor ? options.cursor!.whereFrom : undefined, - ].filter((where) => where !== undefined) - let page = [...backend.values()].filter((row) => - predicates.every( - (where) => evaluateReferenceExpression(where, row) === true, - ), - ) - if (options.orderBy) { - page.sort((a, b) => compareRows(a, b, scenario)) - if (!usesCursor && encoding !== `ignore`) { - page = page.slice(options.offset ?? 0) - } - if (options.limit !== undefined) page = page.slice(0, options.limit) - if (scenario.pageCap !== undefined) page = page.slice(0, scenario.pageCap) - } - servedRows += page.length - return page.map((row) => ({ ...row })) - } - - const queryClient = new QueryClient({ - defaultOptions: { - queries: { gcTime: Infinity, retry: false, staleTime: Infinity }, - }, - }) - const config = queryCollectionOptions({ - id, - queryClient, - queryKey: scenario.functionKey ? keyFor : [id], - syncMode: `on-demand`, - queryFn: (context) => { - const options = context.meta!.loadSubsetOptions! - if ( - scenario.functionKey && - JSON.stringify(context.queryKey) !== JSON.stringify(keyFor(options)) - ) { - keyMismatches++ - } - return Promise.resolve(serve(options)) - }, - getKey: (row) => row.id, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - // A held optimistic write stays persisting until released; the - // backend does not change. - onUpdate: async () => { - await heldWrite - }, - onDelete: async () => { - await heldWrite - }, - }) - const acquisitions: Array = [] - const sync = config.sync.sync - config.sync = { - ...config.sync, - sync: (operations) => { - const handlers = sync(operations) - if ( - handlers === undefined || - typeof handlers === `function` || - handlers.loadSubset === undefined - ) - return handlers - const load = handlers.loadSubset - return { - ...handlers, - loadSubset: (options) => { - acquisitions.push(options) - return load(options) - }, - } - }, - } - const items = createCollection(config) - let heldWrite: Promise = Promise.resolve() - // The running hold's release, if any. - let releaseHold: (() => Promise) | undefined - const heldPreloads: Array> = [] - const hold = (kind: `update` | `delete`, rowId: number) => { - let release!: () => void - heldWrite = new Promise((resolve) => { - release = resolve - }) - const transaction = - kind === `update` - ? items.update(rowId, (draft) => { - draft.label += `!` - }) - : items.delete(rowId) - return async () => { - release() - await transaction.when(`settled`) - await settle() - } - } - - let reports = 0 - const reportRequestOffsets: Array = [] - items.on(`loadedSubsets:invalidated`, () => { - reports++ - reportRequestOffsets.push(acquisitions.length) - }) - let failApplication = false - items.subscribeChanges(() => { - if (!failApplication) return - failApplication = false - throw new Error(`change subscriber failed`) - }) - - let markerSync!: Parameters[`sync`]>[0] - const markerCollection = createCollection({ - id: `${id}-markers`, - getKey: (marker) => marker.id, - autoIndex: `eager`, - defaultIndexType: BTreeIndex, - sync: { - sync: (operations) => { - markerSync = operations - operations.begin() - for (const itemId of markers) { - operations.write({ type: `insert`, value: { id: itemId, itemId } }) - } - operations.commit() - operations.markReady() - }, - }, - }) - - const window = createLiveQueryCollection((q) => { - const ordered = q - .from({ item: items }) - .leftJoin({ marker: markerCollection }, ({ item, marker }) => - eq(item.id, marker.itemId), - ) - .where(({ marker }) => not(isUndefined(marker.itemId))) - .orderBy( - ({ item }) => (scenario.orderKey === `rank` ? item.rank : item.label), - `desc`, - ) - return ( - scenario.terms === 2 - ? ordered.orderBy(({ item }) => item.id, `asc`) - : ordered - ) - .offset(scenario.offset) - .limit(scenario.limit) - .select(({ item }) => ({ id: item.id })) - }) - - const observers: Array<{ cleanup: () => Promise }> = [] - const apply = async (command: Command): Promise => { - switch (command.type) { - case `upsert`: - backend.set(command.id, toItem(command)) - return - case `label`: { - const row = backend.get(command.id) - if (row !== undefined) - backend.set(command.id, { ...row, label: command.label }) - return - } - case `delete`: - backend.delete(command.id) - return - case `flip`: { - const marked = markers.has(command.id) - if (marked) markers.delete(command.id) - else markers.add(command.id) - markerSync.begin() - markerSync.write({ - type: marked ? `delete` : `insert`, - value: { id: command.id, itemId: command.id }, - }) - const receipt = markerSync.commit() - if (receipt !== true) await receipt - await settle() - return - } - case `observe`: { - const observer = createLiveQueryCollection((q) => - q - .from({ item: items }) - .where(({ item }) => eq(item.id, command.id)) - .select(({ item }) => ({ id: item.id })), - ) - observers.push(observer) - // A preload waits until its rows are visible, so under a hold it - // settles only after the release. - const preload = observer.preload() - if (releaseHold === undefined) await preload - else heldPreloads.push(preload) - await settle() - return - } - case `invalidate`: - await queryClient.invalidateQueries({ queryKey: [id] }) - await settle() - return - case `refetch`: - await items.utils.refetch() - await settle() - return - // A row that is not loaded cannot take an optimistic write, so the - // hold is a no-op then. - case `hold`: - if (releaseHold === undefined && items.has(command.id)) { - heldRow = { kind: command.kind, id: command.id } - releaseHold = hold(command.kind, command.id) - await settle() - } - return - case `release`: { - const release = releaseHold - releaseHold = undefined - heldRow = undefined - await release?.() - await Promise.all(heldPreloads.splice(0)) - await settle() - return - } - } - } - - await window.preload() - await settle() - return { - windowIds: () => window.toArray.map((row) => row.id), - backend, - markers, - apply, - requests, - acquisitions, - servedRows: () => servedRows, - keyMismatches: () => keyMismatches, - setEncoding: (next) => { - encoding = next - }, - failAfter: (count) => { - failFrom = count === undefined ? undefined : requests.length + count - }, - cachedQueries: () => - queryClient.getQueryCache().findAll({ queryKey: [id] }).length, - activeQueries: () => - queryClient.getQueryCache().findAll({ queryKey: [id], type: `active` }) - .length, - reports: () => reports, - changedReanswers: () => changedReanswers, - sameKeyEarlierEnds: () => sameKeyEarlierEnds, - deleteHeldGaps: () => deleteHeldGaps, - reportRequestOffsets: () => reportRequestOffsets, - failNextApplication: () => { - failApplication = true - }, - holdUpdate: (rowId) => hold(`update`, rowId), - holding: () => releaseHold !== undefined, - loadedRows: () => - new Map( - items.toArray.map(({ id: rowId, rank, label }) => [ - rowId, - { id: rowId, rank, label }, - ]), - ), - refetch: (opts) => items.utils.refetch(opts), - cleanup: async () => { - const failures: Array = [] - // A failed history can stop inside a hold. Release it, so its - // transaction does not outlive this run. - try { - await apply({ type: `release` }) - } catch (error) { - failures.push(error) - } - for (const collection of [ - ...observers, - window, - items, - markerCollection, - ]) { - try { - await collection.cleanup() - } catch (error) { - failures.push(error) - } - } - queryClient.clear() - return failures - }, - } -} - -// ## Refinement check +// ## Named fixed histories // -// The checkpoints are preload, every invalidation, and every refetch. There the -// public window's -// ordered ids must equal the model's ids. The comparison keeps order, duplicates, and missing -// rows. A failure names its checkpoint, so a loader defect at preload is not -// mistaken for a refetch defect. -// -// The run also records the premise of the gap: a refetch or invalidation of a -// lagging provider after a continuation page was served. The campaigns must -// reach it, and must reach such a refetch while a hold is running. -type Observation = { - reachedSameKeyEarlierEndDuringHold: boolean - reachedDeleteHeldGapReportAndRepair: boolean - reachedLaggingRefetchAfterContinuation: boolean - // A refetch of a lagging provider while an optimistic write held sync rows - // back. - reachedLaggingRefetchDuringHold: boolean - // Such a refetch that also reported, so a loader may have judged the skip - // while the refetched rows were held back. - reachedReportDuringHold: boolean - // A refetch that reported while the window was full: the loader then - // judges whether its own first page still proves the window, and repairs - // when a moved end leaves it unproven. - reachedReportOverFullWindow: boolean - // Reports over the whole run. - reports: number -} - -// A step's reports, checked against the report laws in the contract. -function checkReports( - command: Command, - reports: number, - changedReanswers: number, - checkpoint: string, -): void { - if (reports > 1) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:988`, - new Error( - `a ${command.type} at ${checkpoint} reported ${reports} times; expected at most 1`, - ), - ) - } - if (reports > 0 && changedReanswers === 0) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:993`, - new Error( - `a ${command.type} at ${checkpoint} reported, but no ordered request changed keys or moved its end earlier`, - ), - ) - } -} - -async function checkCase(testCase: Case): Promise { - const run = await startRun(testCase) - let reachedSameKeyEarlierEndDuringHold = false - let reachedDeleteHeldGapReportAndRepair = false - let reachedLaggingRefetchAfterContinuation = false - let reachedLaggingRefetchDuringHold = false - let reachedReportDuringHold = false - let reachedReportOverFullWindow = false - let primary: unknown - const check = (checkpoint: string) => { - const expected = expectedWindow(run.backend, run.markers, testCase.scenario) - const actual = run.windowIds() - if (!sameOrderedIds(actual, expected)) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1012`, - new Error( - `window ${checkpoint} was ${JSON.stringify(actual)}; expected ${JSON.stringify(expected)}`, - ), - ) - } - const loaded = run.loadedRows() - const uncovered = expectedCoverage( - run.backend, - run.markers, - testCase.scenario, - ).filter( - (row) => JSON.stringify(loaded.get(row.id)) !== JSON.stringify(row), - ) - if (uncovered.length > 0) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1025`, - new Error( - `coverage ${checkpoint}: rows ${JSON.stringify(uncovered)} sort at or before the window's end but are ${JSON.stringify(uncovered.map((row) => loaded.get(row.id) ?? null))} in the source Collection`, - ), - ) - } - } - // A full-source request names no predicate, order, limit, or cursor. A - // refetch may re-run one that already existed, but must not start the first. - const isFullSource = (options: LoadSubsetOptions) => - options.where === undefined && - options.orderBy === undefined && - options.limit === undefined && - options.cursor === undefined - const checkNoNewFullSource = (from: number, checkpoint: string) => { - if (run.requests.slice(0, from).some(isFullSource)) return - if (run.requests.slice(from).some(isFullSource)) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1040`, - new Error(`a ${checkpoint} started a full-source request`), - ) - } - } - try { - check(`after preload`) - let providerCurrent = true - const history: Array = [...testCase.history, { type: `refetch` }] - for (const [step, command] of history.entries()) { - if ( - (command.type === `refetch` || command.type === `invalidate`) && - !providerCurrent && - run.requests.some((options) => options.cursor !== undefined) - ) { - reachedLaggingRefetchAfterContinuation = true - } - if (command.type === `refetch` && !providerCurrent && run.holding()) { - reachedLaggingRefetchDuringHold = true - } - const requestsBefore = run.requests.length - const reportsBefore = run.reports() - const reanswersBefore = run.changedReanswers() - const sameKeyEndsBefore = run.sameKeyEarlierEnds() - const gapsBefore = run.deleteHeldGaps() - await run.apply(command) - if ( - run.deleteHeldGaps() > gapsBefore && - run.reports() > reportsBefore && - run - .reportRequestOffsets() - .slice(reportsBefore) - .some((from) => - run.acquisitions - .slice(from) - .some((request) => request.refetch === true), - ) - ) { - reachedDeleteHeldGapReportAndRepair = true - } - if (run.holding() && run.sameKeyEarlierEnds() > sameKeyEndsBefore) { - reachedSameKeyEarlierEndDuringHold = true - } - if ( - command.type === `refetch` && - run.holding() && - run.reports() > reportsBefore - ) { - reachedReportDuringHold = true - } - if ( - command.type === `refetch` && - run.reports() > reportsBefore && - run.windowIds().length === testCase.scenario.limit - ) { - reachedReportOverFullWindow = true - } - checkReports( - command, - run.reports() - reportsBefore, - run.changedReanswers() - reanswersBefore, - `step ${step}`, - ) - if (command.type === `refetch` || command.type === `invalidate`) { - checkNoNewFullSource(requestsBefore, `${command.type} at step ${step}`) - } - if ( - command.type === `upsert` || - command.type === `delete` || - command.type === `label` - ) { - providerCurrent = false - } else if (command.type === `release`) { - if (providerCurrent) check(`after release at step ${step}`) - } else if (command.type === `refetch` || command.type === `invalidate`) { - providerCurrent = true - if (!run.holding()) check(`after ${command.type} at step ${step}`) - } - } - // Repeat the refetch twice with the backend unchanged. The second read - // must serve no more rows than the first. - // A first-page request is ordered and limited, with no cursor. - const firstPageRequests = (from: number) => - run.requests - .slice(from) - .filter( - (options) => - options.orderBy !== undefined && - options.limit !== undefined && - options.cursor === undefined, - ).length - const servedBy = async (command: Command) => { - const before = run.servedRows() - const requestsBefore = run.requests.length - const reportsBefore = run.reports() - await run.apply(command) - const reports = run.reports() - reportsBefore - if (reports > 0) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1136`, - new Error( - `a ${command.type} of an unchanged backend reported ${reports} times`, - ), - ) - } - // The backend is unchanged, so the loader starts no repair and every - // first-page request comes from the refetch itself. - const firstPages = firstPageRequests(requestsBefore) - if (firstPages > 1) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1144`, - new Error( - `a ${command.type} of an unchanged backend started ${firstPages} first-page requests; expected at most 1`, - ), - ) - } - return run.servedRows() - before - } - // The first repeat may still settle loader work that the history - // started, so only later repeats check the first-page law. - const before = run.servedRows() - await run.apply({ type: `refetch` }) - const first = run.servedRows() - before - const second = await servedBy({ type: `refetch` }) - if (second > first) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1157`, - new Error( - `a repeated refetch served ${second} rows after ${first} with an unchanged backend`, - ), - ) - } - check(`after repeated refetches`) - // An invalidation refetches every page at once. It reads the same served - // prefixes as an explicit refetch, so it serves no more rows. - const invalidated = await servedBy({ type: `invalidate` }) - if (invalidated > second) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1166`, - new Error( - `an invalidation served ${invalidated} rows; an explicit refetch of the same state served ${second}`, - ), - ) - } - check(`after an invalidation of an unchanged backend`) - if (run.keyMismatches() > 0) { - rejectAt( - `refetch-ordered-prefix-oracle.test.ts:1172`, - new Error( - `${run.keyMismatches()} requests carried a Query key that did not match their request data`, - ), - ) - } - } catch (error) { - primary = error - } - const cleanupFailures = await run.cleanup() - if (primary !== undefined && cleanupFailures.length > 0) { - throw new AggregateError( - [primary, ...cleanupFailures], - `refetch prefix law and cleanup failed`, - { cause: primary }, - ) - } - if (primary !== undefined) throw primary - if (cleanupFailures.length > 0) { - throw new AggregateError(cleanupFailures, `refetch prefix cleanup failed`) - } - return { - reachedSameKeyEarlierEndDuringHold, - reachedDeleteHeldGapReportAndRepair, - reachedLaggingRefetchAfterContinuation, - reachedLaggingRefetchDuringHold, - reachedReportDuringHold, - reachedReportOverFullWindow, - reports: run.reports(), - } -} - -// A history that never settles is a progress failure, not a slow pass. The -// deadline turns it into a counterexample that fast-check can shrink. It is -// generous because a long history runs many settle loops on a loaded machine. -async function withDeadline(work: Promise): Promise { - let timer: ReturnType | undefined - try { - return await Promise.race([ - work, - new Promise((_, reject) => { - timer = setTimeout( - () => reject(new Error(`history did not settle within 30 s`)), - 30_000, - ) - }), - ]) - } finally { - clearTimeout(timer) - } +// Each history below is a regression or a contract example. It runs through +// the generated campaigns' driver and comparison (`checkCase`), and the model +// computes every expected window and source prefix. The generated grammar +// cannot produce these histories: its ids stay below 8, it caps no pages, +// it uses only the cursor encoding, and it composes rounds through a map that +// fast-check cannot invert. A history may also name a premise that it must +// reach (`laggingRefetchAfterContinuation`), or the report count that its +// contract comment fixes (`reports`). +type FixedHistory = { + name: string + testCase: Case + laggingRefetchAfterContinuation?: true + reports?: number } -const property = `query-db.refetch-ordered-prefix` +const encodings = [`cursor`, `offset`] as const -describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { +const fixedHistories: Array = [ // The issue's trace (rank desc, limit 2). Removing rank 10's marker makes // the loader serve a continuation after rank 4. The backend then gains // rank 5 (marked) and rank 6 (unmarked). The refetched first page ends at // 6, the continuation still starts after 4, and rank 5 is in neither. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - // With one order term the fixture rank is `rank * 100 + id`, so - // rank 0 orders these rows by id: 10, 4, 1, 0. - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `upsert`, id: 6, rank: 0 }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `shows a row that moved between two served pages after a refetch (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + // With one order term the fixture rank is `rank * 100 + id`, so + // rank 0 orders these rows by id: 10, 4, 1, 0. + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `upsert`, id: 6, rank: 0 }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // The issue's trace with rank 5 left unmarked. The window over the backend // is still [4, 1], so the window law passes even when rank 5 is in no // served page. The loader still treats rank 5 as loaded, because it sorts // before the window's end; marking it later would never load it. Only the // coverage law sees this gap while it is latent. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `upsert`, id: 6, rank: 0 }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `loads a row that moved between two served pages before it joins the window (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `upsert`, id: 6, rank: 0 }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // The issue's trace with a provider that returns one row per request. A // short reply does not end the order, so the repair must keep reading. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: 1, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `upsert`, id: 6, rank: 0 }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `shows a moved row after a refetch when the provider caps pages (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: 1, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `upsert`, id: 6, rank: 0 }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // Fracture scan witness. A second live query loads row 6 before the // refetch, so the refetched first page changes while no Collection row // does. A design that waits for a changed Collection row misses the gap. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 6, rank: 0 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `observe`, id: 6 }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `shows a moved row when another live query loaded the page's new row first (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 6, rank: 0 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `observe`, id: 6 }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // Without a hold, a page that keeps its keys also reaches the loader as // an order-change update. The held histories below distinguish that path: // accepted rows remain private, so the adapter must report the earlier end // even while a deleted row hides its own order change. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 2, - limit: 2, - offset: 0, - }, - initial: [ - { id: 0, rank: 6, marked: true }, - { id: 1, rank: 4, marked: true }, - { id: 2, rank: 1, marked: true }, - { id: 3, rank: 0, marked: true }, - ], - history: [ - { type: `flip`, id: 0 }, - { type: `upsert`, id: 1, rank: 2 }, - { type: `refetch` }, - { type: `upsert`, id: 7, rank: 3 }, - { type: `flip`, id: 7 }, - ], - }) + ...encodings.map((encoding): FixedHistory => ({ + name: `repairs when a page keeps its keys but its last row moves (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 2, + limit: 2, + offset: 0, + }, + initial: [ + { id: 0, rank: 6, marked: true }, + { id: 1, rank: 4, marked: true }, + { id: 2, rank: 1, marked: true }, + { id: 3, rank: 0, marked: true }, + ], + history: [ + { type: `flip`, id: 0 }, + { type: `upsert`, id: 1, rank: 2 }, + { type: `refetch` }, + { type: `upsert`, id: 7, rank: 3 }, + { type: `flip`, id: 7 }, + ], }, - ) + })), // A page with no later page whose end moves earlier in the order. Rows that // arrive ahead of the page push its old last row out, but a tie request @@ -1416,43 +603,40 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // through the old boundary as loaded. A new row that ties at the page's new // end sorts before that boundary and is in neither result. (Found by the // ordered-work oracle's refetch-then-signal provider.) - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 2, - limit: 3, - offset: 0, - }, - initial: [ - { id: 0, rank: 5, marked: true }, - { id: 1, rank: 5, marked: true }, - { id: 2, rank: 4, marked: true }, - { id: 5, rank: 0, marked: false }, - ], - history: [ - { type: `upsert`, id: 10, rank: 5 }, - { type: `upsert`, id: 11, rank: 5 }, - { type: `flip`, id: 11 }, - ], - }) + ...encodings.map((encoding): FixedHistory => ({ + name: `shows a row that ties at a page's new, earlier end (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 2, + limit: 3, + offset: 0, + }, + initial: [ + { id: 0, rank: 5, marked: true }, + { id: 1, rank: 5, marked: true }, + { id: 2, rank: 4, marked: true }, + { id: 5, rank: 0, marked: false }, + ], + history: [ + { type: `upsert`, id: 10, rank: 5 }, + { type: `upsert`, id: 11, rank: 5 }, + { type: `flip`, id: 11 }, + ], }, - ) + })), // A filtered one-page window whose page end moves earlier, with unique // order values. Row 4 arrives ahead of the page without a marker, so the // window needs a row past the page; row 3 now sorts between the page's // 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({ + { + name: `shows a row that lands between a filtered page's new and old end`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -1470,117 +654,15 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `upsert`, id: 3, rank: 1 }, { type: `upsert`, id: 4, rank: 1 }, ], - }) - }) - - // The same shape while an optimistic update persists. The Collection - // accepts the refetched rows at once but holds them back until the update - // settles, so the report arrives while the old rows are still visible. - // Other live queries hold rows 0, 1 and 2, so row 0 stays after it leaves - // the page and no delete starts the loader's own repair. Once the rows - // 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, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 1, - offset: 0, - } - const run = await startRun({ - scenario, - initial: [ - { id: 0, rank: 1, marked: true }, - { id: 1, rank: 0, marked: true }, - { id: 2, rank: 0, marked: true }, - { id: 3, rank: 0, marked: true }, - ], - history: [], - }) - let primary: unknown - try { - for (const id of [0, 1, 2]) await run.apply({ type: `observe`, id }) - await run.apply({ type: `upsert`, id: 3, rank: 1 }) - await run.apply({ type: `upsert`, id: 4, rank: 1 }) - const release = run.holdUpdate(1) - await run.apply({ type: `refetch` }) - await release() - expect(run.windowIds()).toEqual( - expectedWindow(run.backend, run.markers, scenario), - ) - } catch (error) { - primary = error - } - const failures = await run.cleanup() - if (primary !== undefined) throw primary - expect(failures).toEqual([]) - }) - - // The ordinary insert into a top-N list. The page's end moves earlier, so - // Query DB reports, but every window row is in the window's own first page. - // The tie the loader read belongs to the old end, so its rows prove - // nothing about rows that tie with the new end. The loader proves the - // window again with bounded reads (a repair, or the new end's tie; the - // shape is the loader's choice), reads nothing unbounded, and retires the - // requests it replaces, so the Query count stays the same. - 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, - orderKey: `rank`, - pageCap: undefined, - terms, - limit: 2, - offset: 0, - } - const run = await startRun({ - scenario, - initial: [3, 2, 1, 0].map((rank, id) => ({ id, rank, marked: true })), - history: [], - }) - let primary: unknown - try { - await run.apply({ type: `upsert`, id: 5, rank: 5 }) - await run.apply({ type: `flip`, id: 5 }) - const queries = run.cachedQueries() - const activeBefore = run.activeQueries() - const requestsBefore = run.requests.length - await run.apply({ type: `refetch` }) - expect(run.windowIds()).toEqual( - expectedWindow(run.backend, run.markers, scenario), - ) - const extra = run.requests.slice(requestsBefore + queries) - expect(extra.length).toBeGreaterThan(0) - // Every read is bounded: an ordered prefix with a limit, or a tie - // that reads equal leading values. None reads the full source. - for (const request of extra) { - expect( - request.limit !== undefined || request.where !== undefined, - ).toBe(true) - } - expect(run.activeQueries()).toBe(activeBefore) - } catch (error) { - primary = error - } - const failures = await run.cleanup() - if (primary !== undefined) throw primary - expect(failures).toEqual([]) - }, - ) + }, + }, // A changed first page whose only later page is empty. The empty // 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({ + { + name: `shows a row that moved before an empty later page`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -1603,73 +685,67 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `upsert`, id: 0, rank: 1 }, { type: `upsert`, id: 5, rank: 0 }, ], - }) - }) + }, + }, // The issue's trace again, but the pages refetch through a Query // invalidation instead of `utils.refetch()`. Each page refetches alone, so // each must read its own served prefix. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `upsert`, id: 6, rank: 0 }, - { type: `invalidate` }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `shows a row that moved between two served pages after a Query invalidation (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `upsert`, id: 6, rank: 0 }, + { type: `invalidate` }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // A second refetch must still cover the first served boundary. The first // refetch inserts rows ahead of it; the backend then gains a row between // the new and old ends, which the second refetch must deliver. - 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, - functionKey: true, - orderKey: `rank`, - pageCap: undefined, - terms: 2, - limit: 2, - offset: 0, - }, - initial: [ - { id: 0, rank: 6, marked: true }, - { id: 1, rank: 4, marked: true }, - { id: 2, rank: 1, marked: true }, - { id: 3, rank: 0, marked: true }, - ], - history: [ - { type: `flip`, id: 0 }, - { type: `upsert`, id: 7, rank: 5 }, - { type: `refetch` }, - { type: `upsert`, id: 8, rank: 3 }, - { type: `flip`, id: 8 }, - ], - }) - expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + ...encodings.map((encoding): FixedHistory => ({ + name: `a second refetch still covers the first served boundary (${encoding} encoding)`, + testCase: { + scenario: { + encoding, + functionKey: true, + orderKey: `rank`, + pageCap: undefined, + terms: 2, + limit: 2, + offset: 0, + }, + initial: [ + { id: 0, rank: 6, marked: true }, + { id: 1, rank: 4, marked: true }, + { id: 2, rank: 1, marked: true }, + { id: 3, rank: 0, marked: true }, + ], + history: [ + { type: `flip`, id: 0 }, + { type: `upsert`, id: 7, rank: 5 }, + { type: `refetch` }, + { type: `upsert`, id: 8, rank: 3 }, + { type: `flip`, id: 8 }, + ], }, - ) + laggingRefetchAfterContinuation: true, + })), // A provider that honors neither encoding returns the same page for every // continuation. That breaks the request contract, but a refetch must still @@ -1677,7 +753,7 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // Pages that keep their start and end at or after their old end, with no // later page: no row can fall outside them, so no refetch reports. The // delete is visible, so the loader repairs through its own path. - it.each([ + ...[ { name: `a one-page window loses a row`, initial: [3, 2, 1, 1].map((rank, id) => ({ id, rank, marked: true })), @@ -1695,9 +771,9 @@ 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({ + ].map(({ name, initial, history }): FixedHistory => ({ + name: `a refetch does not report when ${name}`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -1709,269 +785,9 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => }, initial, history, - }) - expect(observation.reports).toBe(0) - }) - - // A change subscriber throws while the refetched first page commits, so - // 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, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - } - const run = await startRun({ - scenario, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [], - }) - let primary: unknown - try { - for (const command of [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `upsert`, id: 6, rank: 0 }, - ] as const) { - await run.apply(command) - } - const last = run.windowIds() - run.failNextApplication() - await run.refetch({ throwOnError: false }) - await settle() - const expected = expectedWindow(run.backend, run.markers, scenario) - expect([last, expected]).toContainEqual(run.windowIds()) - await run.apply({ type: `refetch` }) - expect(run.windowIds()).toEqual(expected) - } catch (error) { - primary = error - } - const failures = await run.cleanup() - if (primary !== undefined) throw primary - expect(failures).toEqual([]) - }) - - it(`a refetch settles when the provider honors neither cursor nor offset`, async () => { - oracleRoute(`case`) - const run = await startRun({ - scenario: { - encoding: `cursor`, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 1, - offset: 0, - }, - // With one order term the fixture rank is `rank * 100 + id`: 3, 2, 1, 0. - initial: [3, 2, 1, 0].map((id) => ({ id, rank: 0, marked: id === 0 })), - history: [], - }) - try { - expect(run.requests.some((options) => options.cursor)).toBe(true) - run.setEncoding(`ignore`) - await run.apply({ type: `upsert`, id: 4, rank: 0 }) - await withDeadline(run.apply({ type: `refetch` })) - const settled = run.requests.length - await settle() - expect(run.requests).toHaveLength(settled) - } finally { - await run.cleanup() - } - }) - - // Fault injection. The refetch's own page requests succeed, but every later - // request, which is the read that repairs the window, fails. The window - // shows no other result than its last one or the right one, and the refetch - // starts no full-source request. Once the provider recovers, the loader's - // automatic retry restores it. - 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, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), - history: [ - { type: `flip`, id: 10 }, - { type: `upsert`, id: 5, rank: 0 }, - { type: `flip`, id: 5 }, - { type: `upsert`, id: 6, rank: 0 }, - ], - } - const run = await startRun(testCase) - const loggedError = vi - .spyOn(console, `error`) - .mockImplementation(() => {}) - try { - for (const command of testCase.history) await run.apply(command) - const before = run.windowIds() - const requestsBefore = run.requests.length - // Let each tracked Query refetch once, then fail everything after. - run.failAfter(run.cachedQueries()) - await run.apply({ type: `refetch` }) - // A failed repair publishes no other window: the result is the last - // window, or the right one when the refetched pages alone fix it, as - // they do for a provider that honors only the offset. - const expected = expectedWindow( - run.backend, - run.markers, - testCase.scenario, - ) - expect([before, expected]).toContainEqual(run.windowIds()) - expect( - run.requests - .slice(requestsBefore) - .some( - (options) => - options.where === undefined && - options.orderBy === undefined && - options.limit === undefined && - options.cursor === undefined, - ), - ).toBe(false) - // The provider recovers; the loader's automatic retry (250 ms, then - // 500 ms) restores the window. - run.failAfter(undefined) - await new Promise((resolve) => setTimeout(resolve, 900)) - await settle() - expect(run.windowIds()).toEqual( - expectedWindow(run.backend, run.markers, testCase.scenario), - ) - } finally { - loggedError.mockRestore() - await run.cleanup() - } }, - ) - - // Known loader defects outside #2136. These witnesses fail today and - // reproduce on main; `it.fails` turns green work into a red signal once the - // ordered loader is fixed, so the limits above can be removed. - 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`, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 1, - offset: 0, - }, - initial: [ - { id: 0, rank: 0, marked: true }, - { id: 1, rank: 0, marked: false }, - { id: 2, rank: 0, marked: false }, - ], - history: [], - }) - try { - expect(run.windowIds()).toEqual([0]) - } finally { - await run.cleanup() - } - }, - ) - - // This trace was a known defect: after a joined-side removal the loader - // stopped refilling. A repair's retirement removed a row the loader had - // 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`, - functionKey: false, - orderKey: `rank`, - pageCap: undefined, - terms: 1, - limit: 2, - offset: 0, - }, - initial: [ - { id: 5, rank: 2, marked: true }, - { id: 1, rank: 2, marked: false }, - { id: 3, rank: 2, marked: false }, - ], - history: [ - { type: `flip`, id: 2 }, - { type: `upsert`, id: 7, rank: 5 }, - { type: `delete`, id: 1 }, - { type: `refetch` }, - { type: `flip`, id: 1 }, - { type: `flip`, id: 7 }, - { type: `upsert`, id: 1, rank: 1 }, - { type: `upsert`, id: 2, rank: 0 }, - { type: `upsert`, id: 6, rank: 1 }, - { type: `upsert`, id: 7, rank: 3 }, - { type: `flip`, id: 2 }, - { type: `refetch` }, - // The provider is current here; removing 7's marker must refill. - { type: `flip`, id: 7 }, - ], - } - const run = await startRun(testCase) - try { - for (const command of testCase.history) await run.apply(command) - expect(run.windowIds()).toEqual( - expectedWindow(run.backend, run.markers, testCase.scenario), - ) - } finally { - await run.cleanup() - } - }) - - 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`, - functionKey: false, - orderKey: `rank`, - pageCap: 2, - terms: 1, - limit: 2, - offset: 1, - }, - initial: [ - { id: 0, rank: 1, marked: true }, - { id: 1, rank: 0, marked: false }, - ], - history: [ - { type: `flip`, id: 7 }, - { type: `upsert`, id: 2, rank: 1 }, - { type: `upsert`, id: 7, rank: 0 }, - { type: `refetch` }, - { type: `delete`, id: 3 }, - { type: `invalidate` }, - { type: `upsert`, id: 3, rank: 1 }, - { type: `upsert`, id: 0, rank: 0 }, - ], - }) - }, - ) + reports: 0, + })), // Found by the random campaign. A peer query loads row 0's new rank, which // starts the loader's own repair. Finishing that repair releases the old @@ -1980,9 +796,9 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // must not discard the boundary. When it did, the next marker flip read a // 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({ + { + name: `a repair that releases another request's row keeps one first page`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -2008,8 +824,8 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `flip`, id: 0 }, { type: `delete`, id: 4 }, ], - }) - }) + }, + }, // Found by the random campaign at four times the run count. A report's // repair at step 10 releases the old tie and continuation Queries, so rows @@ -2018,9 +834,9 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // answers synchronously from its cache. The loader must keep paging after // 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({ + { + name: `a short window keeps paging through Queries answered from the cache`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -2050,8 +866,8 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `upsert`, id: 3, rank: 3 }, { type: `refetch` }, ], - }) - }) + }, + }, // A refetch while an optimistic delete persists. The delete is visible, so // the loader repairs; that repair's rows and the refetch's rows are held @@ -2062,9 +878,9 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // [3, 2]. That reproduced with this oracle's report and overlap changes // 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({ + { + name: `a refetch during a held optimistic delete keeps the window right after release`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -2091,17 +907,17 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `hold`, kind: `update`, id: 0 }, { type: `release` }, ], - }) - }) + }, + }, // Found by the random campaign. A Query invalidation lets another consumer // observe row 1 before the backend deletes it, and the final refetch applies // while an optimistic update holds source publication back. The public law // 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({ + { + name: `a held refetch removes a deleted row owned by an observed query`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -2129,14 +945,14 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `refetch` }, { type: `release` }, ], - }) - }) + }, + }, // 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({ + { + name: `keeps coverage when a held page keeps its key and moves earlier`, + testCase: { scenario: { encoding: `cursor`, functionKey: false, @@ -2161,7 +977,407 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { type: `upsert`, id: 0, rank: 0 }, { type: `refetch` }, ], - }) + }, + }, +] + +// Each fixed history runs the shared driver and comparison. A premise or +// report count that the history names is checked after the comparison. +async function runFixedHistory(history: FixedHistory): Promise { + const observation = await checkCase(history.testCase) + if (history.laggingRefetchAfterContinuation) + expect(observation.reachedLaggingRefetchAfterContinuation).toBe(true) + if (history.reports !== undefined) + expect(observation.reports).toBe(history.reports) +} + +// ## Named fixed procedures +// +// These histories need steps that the command language does not name: an +// optimistic update held across specific applies, the requests that one +// refetch starts, a failed change application, a provider that breaks the +// request contract, or provider failures after a given request. Each runs the +// production driver (`startRun`) and compares the public window with the +// model (`expectedWindow`), or checks the law that its comment states. +type FixedProcedure = + | { name: string; procedure: `held update between page ends` } + | { name: string; procedure: `bounded reproof`; terms: 1 | 2 } + | { name: string; procedure: `failed page application` } + | { name: string; procedure: `provider ignores both encodings` } + | { + name: string + procedure: `failed repair and retry` + encoding: `cursor` | `offset` + } + | { name: string; procedure: `current provider refill` } + +const fixedProcedures: Array = [ + { + name: `shows a row that lands between a page's new and old end while an optimistic update persists`, + procedure: `held update between page ends`, + }, + ...([1, 2] as const).map((terms): FixedProcedure => ({ + name: `a refetch that moves a first page's end proves the window again with bounded reads (${terms} order terms)`, + procedure: `bounded reproof`, + terms, + })), + { + name: `a refetch whose page application fails keeps the last window until the next refetch`, + procedure: `failed page application`, + }, + { + name: `a refetch settles when the provider honors neither cursor nor offset`, + procedure: `provider ignores both encodings`, + }, + ...encodings.map((encoding): FixedProcedure => ({ + name: `keeps the last window when the repair fails, and the retry restores it (${encoding} encoding)`, + procedure: `failed repair and retry`, + encoding, + })), + { + name: `a current provider refills a joined window after a marker removal`, + procedure: `current provider refill`, + }, +] + +// The same shape as the filtered page-end history while an optimistic update +// persists. The Collection +// accepts the refetched rows at once but holds them back until the update +// settles, so the report arrives while the old rows are still visible. +// Other live queries hold rows 0, 1 and 2, so row 0 stays after it leaves +// the page and no delete starts the loader's own repair. Once the rows +// 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. +async function runHeldUpdateBetweenPageEnds(): Promise { + const scenario: Scenario = { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 1, + offset: 0, + } + const run = await startRun({ + scenario, + initial: [ + { id: 0, rank: 1, marked: true }, + { id: 1, rank: 0, marked: true }, + { id: 2, rank: 0, marked: true }, + { id: 3, rank: 0, marked: true }, + ], + history: [], + }) + let primary: unknown + try { + for (const id of [0, 1, 2]) await run.apply({ type: `observe`, id }) + await run.apply({ type: `upsert`, id: 3, rank: 1 }) + await run.apply({ type: `upsert`, id: 4, rank: 1 }) + const release = run.holdUpdate(1) + await run.apply({ type: `refetch` }) + await release() + expect(run.windowIds()).toEqual( + expectedWindow(run.backend, run.markers, scenario), + ) + } catch (error) { + primary = error + } + const failures = await run.cleanup() + if (primary !== undefined) throw primary + expect(failures).toEqual([]) +} + +// The ordinary insert into a top-N list. The page's end moves earlier, so +// Query DB reports, but every window row is in the window's own first page. +// The tie the loader read belongs to the old end, so its rows prove +// nothing about rows that tie with the new end. The loader proves the +// window again with bounded reads (a repair, or the new end's tie; the +// shape is the loader's choice), reads nothing unbounded, and retires the +// requests it replaces, so the Query count stays the same. +async function runBoundedReproof(terms: 1 | 2): Promise { + const scenario: Scenario = { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms, + limit: 2, + offset: 0, + } + const run = await startRun({ + scenario, + initial: [3, 2, 1, 0].map((rank, id) => ({ id, rank, marked: true })), + history: [], + }) + let primary: unknown + try { + await run.apply({ type: `upsert`, id: 5, rank: 5 }) + await run.apply({ type: `flip`, id: 5 }) + const queries = run.cachedQueries() + const activeBefore = run.activeQueries() + const requestsBefore = run.requests.length + await run.apply({ type: `refetch` }) + expect(run.windowIds()).toEqual( + expectedWindow(run.backend, run.markers, scenario), + ) + const extra = run.requests.slice(requestsBefore + queries) + expect(extra.length).toBeGreaterThan(0) + // Every read is bounded: an ordered prefix with a limit, or a tie + // that reads equal leading values. None reads the full source. + for (const request of extra) { + expect(request.limit !== undefined || request.where !== undefined).toBe( + true, + ) + } + expect(run.activeQueries()).toBe(activeBefore) + } catch (error) { + primary = error + } + const failures = await run.cleanup() + if (primary !== undefined) throw primary + expect(failures).toEqual([]) +} + +// A change subscriber throws while the refetched first page commits, so +// 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. +async function runFailedPageApplication(): Promise { + const scenario: Scenario = { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + } + const run = await startRun({ + scenario, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [], + }) + let primary: unknown + try { + for (const command of [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `upsert`, id: 6, rank: 0 }, + ] as const) { + await run.apply(command) + } + const last = run.windowIds() + run.failNextApplication() + await run.refetch({ throwOnError: false }) + await settle() + const expected = expectedWindow(run.backend, run.markers, scenario) + expect([last, expected]).toContainEqual(run.windowIds()) + await run.apply({ type: `refetch` }) + expect(run.windowIds()).toEqual(expected) + } catch (error) { + primary = error + } + const failures = await run.cleanup() + if (primary !== undefined) throw primary + expect(failures).toEqual([]) +} + +// A provider that honors neither encoding returns the same page for every +// continuation. That breaks the request contract, but a refetch must still +// settle, and the loader must not keep requesting. +async function runProviderIgnoringBothEncodings(): Promise { + const run = await startRun({ + scenario: { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 1, + offset: 0, + }, + // With one order term the fixture rank is `rank * 100 + id`: 3, 2, 1, 0. + initial: [3, 2, 1, 0].map((id) => ({ id, rank: 0, marked: id === 0 })), + history: [], + }) + try { + expect(run.requests.some((options) => options.cursor)).toBe(true) + run.setEncoding(`ignore`) + await run.apply({ type: `upsert`, id: 4, rank: 0 }) + await withDeadline(run.apply({ type: `refetch` })) + const settled = run.requests.length + await settle() + expect(run.requests).toHaveLength(settled) + } finally { + await run.cleanup() + } +} + +// Fault injection. The refetch's own page requests succeed, but every later +// request, which is the read that repairs the window, fails. The window +// shows no other result than its last one or the right one, and the refetch +// starts no full-source request. Once the provider recovers, the loader's +// automatic retry restores it. +async function runFailedRepairAndRetry( + encoding: `cursor` | `offset`, +): Promise { + const testCase: Case = { + scenario: { + encoding, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [10, 4, 1, 0].map((id) => ({ id, rank: 0, marked: true })), + history: [ + { type: `flip`, id: 10 }, + { type: `upsert`, id: 5, rank: 0 }, + { type: `flip`, id: 5 }, + { type: `upsert`, id: 6, rank: 0 }, + ], + } + const run = await startRun(testCase) + const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) + try { + for (const command of testCase.history) await run.apply(command) + const before = run.windowIds() + const requestsBefore = run.requests.length + // Let each tracked Query refetch once, then fail everything after. + run.failAfter(run.cachedQueries()) + await run.apply({ type: `refetch` }) + // A failed repair publishes no other window: the result is the last + // window, or the right one when the refetched pages alone fix it, as + // they do for a provider that honors only the offset. + const expected = expectedWindow(run.backend, run.markers, testCase.scenario) + expect([before, expected]).toContainEqual(run.windowIds()) + expect( + run.requests + .slice(requestsBefore) + .some( + (options) => + options.where === undefined && + options.orderBy === undefined && + options.limit === undefined && + options.cursor === undefined, + ), + ).toBe(false) + // The provider recovers; the loader's automatic retry (250 ms, then + // 500 ms) restores the window. + run.failAfter(undefined) + await new Promise((resolve) => setTimeout(resolve, 900)) + await settle() + expect(run.windowIds()).toEqual( + expectedWindow(run.backend, run.markers, testCase.scenario), + ) + } finally { + loggedError.mockRestore() + await run.cleanup() + } +} + +// This trace was a known defect: after a joined-side removal the loader +// stopped refilling. A repair's retirement removed a row the loader had +// sent, which discarded the repair's boundary, and paging restarted from +// the start of the order. It now passes. +async function runCurrentProviderRefill(): Promise { + const testCase: Case = { + scenario: { + encoding: `cursor`, + functionKey: false, + orderKey: `rank`, + pageCap: undefined, + terms: 1, + limit: 2, + offset: 0, + }, + initial: [ + { id: 5, rank: 2, marked: true }, + { id: 1, rank: 2, marked: false }, + { id: 3, rank: 2, marked: false }, + ], + history: [ + { type: `flip`, id: 2 }, + { type: `upsert`, id: 7, rank: 5 }, + { type: `delete`, id: 1 }, + { type: `refetch` }, + { type: `flip`, id: 1 }, + { type: `flip`, id: 7 }, + { type: `upsert`, id: 1, rank: 1 }, + { type: `upsert`, id: 2, rank: 0 }, + { type: `upsert`, id: 6, rank: 1 }, + { type: `upsert`, id: 7, rank: 3 }, + { type: `flip`, id: 2 }, + { type: `refetch` }, + // The provider is current here; removing 7's marker must refill. + { type: `flip`, id: 7 }, + ], + } + const run = await startRun(testCase) + try { + for (const command of testCase.history) await run.apply(command) + expect(run.windowIds()).toEqual( + expectedWindow(run.backend, run.markers, testCase.scenario), + ) + } finally { + await run.cleanup() + } +} + +async function runFixedProcedure(history: FixedProcedure): Promise { + switch (history.procedure) { + case `held update between page ends`: + return runHeldUpdateBetweenPageEnds() + case `bounded reproof`: + return runBoundedReproof(history.terms) + case `failed page application`: + return runFailedPageApplication() + case `provider ignores both encodings`: + return runProviderIgnoringBothEncodings() + case `failed repair and retry`: + return runFailedRepairAndRetry(history.encoding) + case `current provider refill`: + return runCurrentProviderRefill() + } +} + +const property = `query-db.refetch-ordered-prefix` + +describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { + // The named fixed histories and procedures replay the generated + // campaign's law through the same driver and comparison. + oracleProperty({ + id: `query-db.refetch-ordered-prefix-fixed`, + law: { + statement: `After refetch and Query invalidation settle, the public window and source prefix match the current backend rows.`, + authority: `LoadSubset contract and query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery.`, + limits: `Named regressions that the generated command grammar cannot produce; each is replayed through the same driver and comparison.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: fixedHistories, + label: ({ name }) => name, + model: (history, observations) => recordedComparison(history, observations), + driver: async (history, observe) => + observeComparison(history, observe, () => runFixedHistory(history)), + check: (_history, _observations, verdict) => verdict, + }) + + oracleProperty({ + id: `query-db.refetch-ordered-prefix-cases`, + law: { + statement: `After refetch and Query invalidation settle, the public window and source prefix match the current backend rows.`, + authority: `LoadSubset contract and query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery.`, + limits: `Named procedures with a held update, request counts, a failed change application, a contract-breaking provider, or provider failures, which the command grammar cannot express; each runs the same production driver and model.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: fixedProcedures, + label: ({ name }) => name, + model: (history, observations) => recordedComparison(history, observations), + driver: async (history, observe) => + observeComparison(history, observe, () => runFixedProcedure(history)), + check: (_history, _observations, verdict) => verdict, }) const endProperty = `query-db.refetch-held-page-end` @@ -2226,85 +1442,53 @@ describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => // These widths and adjacent hold regimes cannot depend on a random seed. // Every case still uses the shared model, provider and public comparison. - 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}`, - ), - ) - const reached = new Map(states.map((state) => [state, 0])) - let fixedHistories = 0 - const counts: Array<{ - limit: number - offset: number - hold: string - direction: string - reports: number - }> = [] - for (const limit of [1, 2, 3]) { - for (const offset of [0, 1]) { - for (const hold of [ - `delete`, - `update`, - `other-delete`, - undefined, - ] as const) { - for (const direction of [`earlier`, `later`] as const) { - fixedHistories++ - const observation = await checkCase( - pageEndCase( - { - limit, - offset, - terms: 2, - functionKey: true, - refresh: `refetch`, - gapRank: 2, - firstId: 3, - }, - hold, - direction, - ), - ) - if ( - hold === `delete` && - direction === `earlier` && - observation.reachedDeleteHeldGapReportAndRepair - ) { - const state = `delete-held gap width ${limit} offset ${offset}` - if (!reached.has(state)) - throw new Error( - `Undeclared structural page-end state: ${state}`, - ) - reached.set(state, reached.get(state)! + 1) - } - counts.push({ - limit, - offset, - hold: hold ?? `none`, - direction, - reports: observation.reports, - }) - } - } - } - } - expect(fixedHistories, `fixed page-end histories`).toBeGreaterThanOrEqual( - 48, + // At every width and offset, a delete hold whose boundary moves earlier + // must reach the reported and repaired gap. + oracleProperty({ + id: `query-db.refetch-held-page-end-structural`, + law: { + statement: `A held page whose keys stay the same and end moves earlier preserves the refetched window and source prefix.`, + authority: `LoadSubset contract and query/live/ARCHITECTURE.md: Ordered requests, continuation, and recovery.`, + limits: `The 48 structural page-end histories: widths 1 to 3, offsets 0 and 1, a delete, update, other-row delete or no hold, and an earlier or later boundary, with two order terms, a function Query key and explicit refetches.`, + }, + owns: [`src/query.ts#queryCollectionOptions`], + histories: { + enumerated: pageEndStructuralHistories, + states: pageEndStructuralStates, + }, + label: ({ limit, offset, hold, direction }) => + `same-key boundary shifts preserve coverage across widths and hold regimes: limit ${limit} offset ${offset} hold ${hold} ${direction}`, + model: (history, observations) => recordedComparison(history, observations), + driver: async (history, observe) => { + const { limit, offset, hold, direction } = history + let observation: Observation | undefined + await observeComparison(history, observe, async () => { + observation = await checkCase( + pageEndCase( + { + limit, + offset, + terms: 2, + functionKey: true, + refresh: `refetch`, + gapRank: 2, + firstId: 3, + }, + hold === `none` ? undefined : hold, + direction, + ), + ) + }) + if ( + hold === `delete` && + direction === `earlier` && + observation!.reachedDeleteHeldGapReportAndRepair ) - for (const state of states) - expect( - reached.get(state), - `${state} fixed-history minimum`, - ).toBeGreaterThanOrEqual(1) - console.info(`page-end structural reach`, Object.fromEntries(reached)) - console.info(`page-end structural report counts`, JSON.stringify(counts)) + observe(`reach`, `delete-held gap width ${limit} offset ${offset}`) }, - 120_000, - ) + check: (_history, _observations, verdict) => verdict, + timeout: 120_000, + }) // The premise-directed campaign for the skip under a hold. Its reach // witness requires refetches that report while a hold is running. diff --git a/packages/query-db-collection/tests/refetch-ordered-prefix.test.ts b/packages/query-db-collection/tests/refetch-ordered-prefix.test.ts index 18876a1003..698c868240 100644 --- a/packages/query-db-collection/tests/refetch-ordered-prefix.test.ts +++ b/packages/query-db-collection/tests/refetch-ordered-prefix.test.ts @@ -6,11 +6,20 @@ import { IR, createCollection } from '@tanstack/db' import { describe, expect, it } from 'vitest' import { queryCollectionOptions } from '../src/query' import { sameOrderedIds, settle } from './refetch-ordered-prefix-support' +import { pageEndStructuralHistories } from './refetch-ordered-prefix-grammar-oracle' import type { LoadSubsetOptions } from '@tanstack/db' let collectionSequence = 0 describe(`query-db on-demand refetch keeps served ordered prefixes whole`, () => { + // Moved from the oracle's structural page-end census: the grammar must + // still enumerate every width, offset, hold regime and direction. + it(`enumerates every structural page-end history`, () => { + expect( + pageEndStructuralHistories.length, + `fixed page-end histories`, + ).toBeGreaterThanOrEqual(48) + }) it(`the comparison rejects a window with the right size but a stale row`, () => { expect(sameOrderedIds([5, 4], [5, 4])).toBe(true) expect(sameOrderedIds([4, 1], [5, 4])).toBe(false) 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 1440882c62..151aa80267 100644 --- a/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts +++ b/packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts @@ -7,9 +7,14 @@ import { eq, } from '@tanstack/db' import { crossSerializeStream, getCrossReferenceHeader } from 'seroval' -import { describe, expect, it } from 'vitest' +import { describe, expect } from 'vitest' import { queryCollectionOptions } from '../src/query.js' -import { oracleRoute } from '../../db/tests/oracle-config.js' +import { oracleProperty } from '../../db/tests/oracle-property' +import { + forwardAssertionJudgment, + recordAssertionJudgment, + recordedAssertionJudgment, +} from '../../db/tests/oracle-assertion-adapter' import type { DehydratedState } from '@tanstack/query-core' import type { LoadSubsetOptions } from '@tanstack/db' @@ -153,143 +158,184 @@ 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]) - const initial = dehydrate(queryClient) - expectDehydratedRow(initial) - const streamed = await serializeRouterPayload(initial) - expectHydratedRow(initial, streamed) - }, [() => queryClient.clear()]) - }) +// Serializes an ordinary successful Query cache entry. +async function serializeOrdinaryEntry(): Promise { + const queryClient = createQueryClient() + await checkWithCleanup(async () => { + queryClient.setQueryData([`plain-ssr-control`], [row]) + const initial = dehydrate(queryClient) + expectDehydratedRow(initial) + const streamed = await serializeRouterPayload(initial) + expectHydratedRow(initial, streamed) + }, [() => queryClient.clear()]) +} - it(`serializes a successful on-demand entry with a compound predicate`, async () => { - oracleRoute(`case`) - const queryClient = createQueryClient() - let receivedWhere: LoadSubsetOptions[`where`] - const collection = createCollection( - queryCollectionOptions({ - id: `on-demand-ssr-oracle`, - queryClient, - queryKey: [`on-demand-ssr-oracle`], - meta: { origin: `user` }, - queryFn: (context) => { - receivedWhere = context.meta?.loadSubsetOptions?.where - return Promise.resolve([row]) - }, - getKey: (item) => item.id, - syncMode: `on-demand`, - startSync: true, - }), - ) - const live = createLiveQueryCollection((query) => - query - .from({ item: collection }) - .where(({ item }) => eq(item.category, `A`)) - .where(({ item }) => eq(item.id, `1`)) - .select(({ item }) => ({ id: item.id, category: item.category })), - ) +// Serializes a successful on-demand entry with a compound predicate. +async function serializeCompoundPredicateEntry(): Promise { + const queryClient = createQueryClient() + let receivedWhere: LoadSubsetOptions[`where`] + const collection = createCollection( + queryCollectionOptions({ + id: `on-demand-ssr-oracle`, + queryClient, + queryKey: [`on-demand-ssr-oracle`], + meta: { origin: `user` }, + queryFn: (context) => { + receivedWhere = context.meta?.loadSubsetOptions?.where + return Promise.resolve([row]) + }, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + }), + ) + const live = createLiveQueryCollection((query) => + query + .from({ item: collection }) + .where(({ item }) => eq(item.category, `A`)) + .where(({ item }) => eq(item.id, `1`)) + .select(({ item }) => ({ id: item.id, category: item.category })), + ) - await checkWithCleanup(async () => { - await live.preload() - expect(receivedWhere).toMatchObject({ type: `func`, name: `and` }) - expect(receivedWhere).toBeInstanceOf(IR.Func) - expect(live.toArray.map((item) => item.id)).toEqual([row.id]) + await checkWithCleanup(async () => { + await live.preload() + expect(receivedWhere).toMatchObject({ type: `func`, name: `and` }) + expect(receivedWhere).toBeInstanceOf(IR.Func) + expect(live.toArray.map((item) => item.id)).toEqual([row.id]) - const initial = dehydrate(queryClient) - expectDehydratedRow(initial) - const streamed = await serializeRouterPayload(initial) - expectHydratedRow(initial, streamed, { origin: `user` }) - }, [ - () => live.cleanup(), - () => collection.cleanup(), - () => queryClient.clear(), - ]) - }) + const initial = dehydrate(queryClient) + expectDehydratedRow(initial) + const streamed = await serializeRouterPayload(initial) + expectHydratedRow(initial, streamed, { origin: `user` }) + }, [ + () => live.cleanup(), + () => collection.cleanup(), + () => queryClient.clear(), + ]) +} - 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 - const collection = createCollection( - queryCollectionOptions({ - id: `on-demand-ssr-signal-oracle`, - queryClient, - queryKey: [`on-demand-ssr-signal-oracle`], - queryFn: (context) => { - receivedSignal = context.meta?.loadSubsetOptions?.signal - return Promise.resolve([row]) - }, - getKey: (item) => item.id, - syncMode: `on-demand`, - startSync: true, - }), - ) +// Serializes a successful on-demand entry with a cancellation signal. +async function serializeCancellationSignalEntry(): Promise { + const queryClient = createQueryClient() + const requestSignal = new AbortController().signal + let receivedSignal: AbortSignal | undefined + const collection = createCollection( + queryCollectionOptions({ + id: `on-demand-ssr-signal-oracle`, + queryClient, + queryKey: [`on-demand-ssr-signal-oracle`], + queryFn: (context) => { + receivedSignal = context.meta?.loadSubsetOptions?.signal + return Promise.resolve([row]) + }, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + }), + ) - await checkWithCleanup(async () => { - await collection._sync.loadSubset({ - signal: requestSignal, - }) - expect(receivedSignal).toBe(requestSignal) - const initial = dehydrate(queryClient) - expectDehydratedRow(initial) - const streamed = await serializeRouterPayload(initial) - expectHydratedRow(initial, streamed) - }, [() => collection.cleanup(), () => queryClient.clear()]) - }) + await checkWithCleanup(async () => { + await collection._sync.loadSubset({ + signal: requestSignal, + }) + expect(receivedSignal).toBe(requestSignal) + const initial = dehydrate(queryClient) + expectDehydratedRow(initial) + const streamed = await serializeRouterPayload(initial) + expectHydratedRow(initial, streamed) + }, [() => collection.cleanup(), () => queryClient.clear()]) +} - 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 = [ - { - expression: new IR.PropRef([`id`]), - compareOptions: { - direction: `asc`, - nulls: `last`, - stringSort: `custom`, - compare, - }, +// Serializes an ordered cursor request with a custom comparator. +async function serializeOrderedCursorEntry(): Promise { + const queryClient = createQueryClient() + const compare = (a: string, b: string): number => a.localeCompare(b) + const orderBy: NonNullable = [ + { + expression: new IR.PropRef([`id`]), + compareOptions: { + direction: `asc`, + nulls: `last`, + stringSort: `custom`, + compare, }, - ] - const cursor: NonNullable = { - whereFrom: new IR.Func(`gt`, [new IR.PropRef([`id`]), new IR.Value(`0`)]), - whereCurrent: new IR.Func(`eq`, [ - new IR.PropRef([`id`]), - new IR.Value(`1`), - ]), - } - let receivedOptions: LoadSubsetOptions | undefined - const collection = createCollection( - queryCollectionOptions({ - id: `on-demand-ssr-cursor-oracle`, - queryClient, - queryKey: [`on-demand-ssr-cursor-oracle`], - queryFn: (context) => { - receivedOptions = context.meta?.loadSubsetOptions - return Promise.resolve([row]) - }, - getKey: (item) => item.id, - syncMode: `on-demand`, - startSync: true, - }), - ) + }, + ] + const cursor: NonNullable = { + whereFrom: new IR.Func(`gt`, [new IR.PropRef([`id`]), new IR.Value(`0`)]), + whereCurrent: new IR.Func(`eq`, [ + new IR.PropRef([`id`]), + new IR.Value(`1`), + ]), + } + let receivedOptions: LoadSubsetOptions | undefined + const collection = createCollection( + queryCollectionOptions({ + id: `on-demand-ssr-cursor-oracle`, + queryClient, + queryKey: [`on-demand-ssr-cursor-oracle`], + queryFn: (context) => { + receivedOptions = context.meta?.loadSubsetOptions + return Promise.resolve([row]) + }, + getKey: (item) => item.id, + syncMode: `on-demand`, + startSync: true, + }), + ) - await checkWithCleanup(async () => { - await collection._sync.loadSubset({ orderBy, cursor }) - expect(receivedOptions?.orderBy?.[0]?.compareOptions).toMatchObject({ - compare, - }) - expect(receivedOptions?.cursor?.whereFrom).toBe(cursor.whereFrom) - const initial = dehydrate(queryClient) - expectDehydratedRow(initial) - const streamed = await serializeRouterPayload(initial) - expectHydratedRow(initial, streamed) - }, [() => collection.cleanup(), () => queryClient.clear()]) + await checkWithCleanup(async () => { + await collection._sync.loadSubset({ orderBy, cursor }) + expect(receivedOptions?.orderBy?.[0]?.compareOptions).toMatchObject({ + compare, + }) + expect(receivedOptions?.cursor?.whereFrom).toBe(cursor.whereFrom) + const initial = dehydrate(queryClient) + expectDehydratedRow(initial) + const streamed = await serializeRouterPayload(initial) + expectHydratedRow(initial, streamed) + }, [() => collection.cleanup(), () => queryClient.clear()]) +} + +// Each fixed request form, by its test name, and the case that runs it. +const ssrForms = new Map Promise>([ + [ + `serializes an ordinary successful Query cache entry`, + serializeOrdinaryEntry, + ], + [ + `serializes a successful on-demand entry with a compound predicate`, + serializeCompoundPredicateEntry, + ], + [ + `serializes a successful on-demand entry with a cancellation signal`, + serializeCancellationSignalEntry, + ], + [ + `serializes an ordered cursor request with a custom comparator`, + serializeOrderedCursorEntry, + ], +]) + +describe(`Query collection SSR dehydration oracle`, () => { + oracleProperty({ + id: `query-db.ssr-dehydration`, + law: { + statement: `A successful QueryClient cache entry, ordinary or on-demand with any request metadata, survives dehydrate and the router stream serialization and hydrates with its row and without its loadSubsetOptions.`, + authority: `The SSR boundary reported in #1950, as stated at the top of this file; Query Core dehydrate/hydrate and Seroval crossSerializeStream.`, + limits: `The four fixed request forms above through dehydrate and crossSerializeStream; it does not run TanStack Start or prove that a browser Collection resumes the query.`, + }, + // What this oracle owns: the Query cache entries that queryCollectionOptions + // creates and loads, which the SSR dehydration and stream checks observe. + owns: [`src/query.ts#queryCollectionOptions`], + histories: [...ssrForms.keys()], + label: (form) => form, + resolve: (form) => ({ form }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`ssr dehydration`, plan, observe, () => + ssrForms.get(plan.form)!(), + ), + check: forwardAssertionJudgment, }) }) diff --git a/packages/query-db-collection/vitest.config.ts b/packages/query-db-collection/vitest.config.ts index b07a8d8e33..64351ff053 100644 --- a/packages/query-db-collection/vitest.config.ts +++ b/packages/query-db-collection/vitest.config.ts @@ -25,6 +25,7 @@ export default defineConfig({ // and the typecheck program still check them. `tests/reload-housekeeping-oracle.test.ts`, `tests/failing-queries-oracle.test.ts`, + `tests/optimistic-ordered-refetch-oracle.test.ts`, ], }, }, From 08f44a0b442ffd666b9f0faeb64880c3f53dc3f0 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:42:59 -0700 Subject: [PATCH 098/116] test(electric): register the plain oracle tests through oracleProperty Convert the D2 sites in electric-db-collection. Fixed histories and cases become fixed or enumerated oracleProperty histories through the file's existing drivers and comparisons, and production calibrations become calibrations that count only the rejections their old matchers accepted. Grammar reconstructions, model rules and checker calibrations that run no production move to ordinary test files, with their grammars and models in test-free oracle-named companion modules. serializerProp forwards owns and fixed histories to oracleProperty. Co-authored-by: Isaac --- .../db/tests/converted-properties/adapters.ts | 53 +- ...sql-predicate-semantics-oracle.e2e.test.ts | 171 +- .../tests/electric-adapter-grammar-oracle.ts | 215 ++ .../tests/electric-adapter-grammar.test.ts | 211 ++ ...ectric-descriptor-isolation-oracle.test.ts | 107 +- .../tests/electric-oracle.property.test.ts | 1758 +++++++---------- ...ctric-recovery-durable-row-model-oracle.ts | 16 + .../electric-recovery-durable-row.test.ts | 41 + .../tests/electric-recovery-oracle.test.ts | 433 ++-- ...ric-recovery-publication-grammar-oracle.ts | 48 + ...ctric-recovery-publication-grammar.test.ts | 22 + ...ctric-sdk-delivery-oracle.property.test.ts | 334 +++- .../tests/pg-serializer-array-controls.ts | 39 + .../tests/pg-serializer-calibration.test.ts | 63 + .../pg-serializer-oracle.property.test.ts | 403 ++-- .../tests/pg-serializer-property-oracle.ts | 12 + .../electric-db-collection/vite.config.ts | 6 +- 17 files changed, 2248 insertions(+), 1684 deletions(-) create mode 100644 packages/electric-db-collection/tests/electric-adapter-grammar-oracle.ts create mode 100644 packages/electric-db-collection/tests/electric-adapter-grammar.test.ts create mode 100644 packages/electric-db-collection/tests/electric-recovery-durable-row-model-oracle.ts create mode 100644 packages/electric-db-collection/tests/electric-recovery-durable-row.test.ts create mode 100644 packages/electric-db-collection/tests/electric-recovery-publication-grammar-oracle.ts create mode 100644 packages/electric-db-collection/tests/electric-recovery-publication-grammar.test.ts create mode 100644 packages/electric-db-collection/tests/pg-serializer-array-controls.ts diff --git a/packages/db/tests/converted-properties/adapters.ts b/packages/db/tests/converted-properties/adapters.ts index b4f0618d11..6ab0548032 100644 --- a/packages/db/tests/converted-properties/adapters.ts +++ b/packages/db/tests/converted-properties/adapters.ts @@ -3,7 +3,7 @@ // edits this file, so parallel groups do not conflict in oracle-config.ts // or oracle-replay-manifest.ts. Integration folds both lists into those // files and deletes this directory. -import type { ConvertedOwnerGroup } from "./index.js"; +import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ // query-db-collection (refetch) @@ -22,6 +22,23 @@ export const properties: ReadonlyArray = [ `query-db.persisted-direct-write-refetch-order`, `query-db.persisted-direct-duplicate-insert`, // electric-db-collection + `pg-serializer.array-controls`, + `electric.persisted-tag-history-restart`, + `electric.persisted-tag-history-recovery`, + `electric-recovery.persisted-restart`, + `electric-recovery.publication-stream-fixed`, + `electric-recovery.invalid-resume`, + `electric.mutation-confirmation`, + `electric.initial-tagged-move-outs-cases`, + `electric.process-grammar-fixed`, + `electric.publication-before-persistence-fixed`, + `electric.adapter-failure-policy-fixed`, + `electric.design-witnesses`, + `electric.retry-decision-fixed`, + `electric.durable-prefix-fixed`, + `electric.sdk-snapshot-delivery-fixed`, + `electric.sdk-client-retry`, + `electric.sql-predicate-semantics-faults`, // convex-db-collection and db-adapter-conformance `adapter-conformance.eager-live-source.reference.prompt-restart`, `adapter-conformance.eager-live-source.reference.reconstruction`, @@ -36,7 +53,7 @@ export const properties: ReadonlyArray = [ `offline.settlement.restored`, `offline.settlement.started-peer`, `offline.connectivity-replay`, -]; +] export const owners: ReadonlyArray = [ // query-db-collection (refetch) @@ -82,6 +99,36 @@ export const owners: ReadonlyArray = [ `persisted-direct-write-window persisted-direct-write-refetch-order persisted-direct-duplicate-insert`, ], // electric-db-collection + [ + `electric-db-collection/tests/pg-serializer-oracle.property.test.ts`, + `pg-serializer`, + `array-controls`, + ], + [ + `electric-db-collection/tests/electric-descriptor-isolation-oracle.test.ts`, + `electric`, + `persisted-tag-history-restart persisted-tag-history-recovery`, + ], + [ + `electric-db-collection/tests/electric-recovery-oracle.test.ts`, + `electric-recovery`, + `persisted-restart publication-stream-fixed invalid-resume`, + ], + [ + `electric-db-collection/tests/electric-oracle.property.test.ts`, + `electric`, + `mutation-confirmation initial-tagged-move-outs-cases process-grammar-fixed publication-before-persistence-fixed adapter-failure-policy-fixed design-witnesses retry-decision-fixed durable-prefix-fixed`, + ], + [ + `electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts`, + `electric`, + `sdk-snapshot-delivery-fixed sdk-client-retry`, + ], + [ + `electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts`, + `electric`, + `sql-predicate-semantics-faults`, + ], // convex-db-collection and db-adapter-conformance [ `db-adapter-conformance/tests/reference-adapter-oracle.property.test.ts`, @@ -129,4 +176,4 @@ export const owners: ReadonlyArray = [ `offline`, `connectivity-replay`, ], -]; +] 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 b12f66d01d..7bf51b913d 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 @@ -9,16 +9,15 @@ import { describe, expect, inject, - it, } from 'vitest' import { makePgClient } from '../../db-collection-e2e/support/global-setup' import { compileSQL } from '../src/sql-compiler' -import { - oracleReplayRequested, - oracleRoute, - oracleRuns, -} from '../../db/tests/oracle-config' +import { oracleReplayRequested, oracleRuns } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' +import { + recordAssertionJudgment, + rejectAt, +} from '../../db/tests/oracle-assertion-adapter' import { checkExistingElectricComparison, existingElectricComparison, @@ -425,71 +424,99 @@ describe(`Electric predicate compiler semantics`, () => { timeout: 30000, }) - 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 }, - { id: 3, count: null, label: null, enabled: true }, - { id: 4, count: 0, label: null, enabled: true }, - ] - await client.query(`TRUNCATE "${table}"`) - for (const row of world) { - await client.query( - `INSERT INTO "${table}" (id, count, label, enabled) VALUES ($1, $2, $3, $4)`, - [row.id, row.count, row.label, row.enabled], - ) - } - const faultControlPredicate: Predicate = { - type: `and`, - left: { - type: `or`, - left: { type: `compare`, op: `gt`, column: `count`, value: 1 }, - right: { type: `isNull`, column: `count` }, - }, - right: { type: `compare`, op: `eq`, column: `enabled`, value: true }, - } - const expected = world - .filter((row) => evaluate(faultControlPredicate, row) === true) - .map(({ id }) => id) - const compiled = compileSQL({ where: toExpression(faultControlPredicate) }) - const compiledResult = await client.query<{ id: number }>( - `SELECT id FROM "${table}" WHERE ${compiled.where} ORDER BY id`, - Object.values(compiled.params ?? {}), - ) - expect(compiledResult.rows.map(({ id }) => id)).toEqual(expected) - - const faults = [ - { - name: `wrong bound`, - sql: `("count" > $1 OR "count" IS NULL) AND "enabled" = $2`, - params: [`2`, `true`], - }, - { - name: `lost predicate`, - sql: `"count" > $1 OR "count" IS NULL`, - params: [`1`], - }, - { - name: `incorrect null handling`, - sql: `"count" > $1 AND "enabled" = $2`, - params: [`1`, `true`], - }, - { - name: `swapped comparison direction`, - sql: `("count" < $1 OR "count" IS NULL) AND "enabled" = $2`, - params: [`1`, `true`], - }, - ] - for (const fault of faults) { - const result = await client.query<{ id: number }>( - `SELECT id FROM "${table}" WHERE ${fault.sql} ORDER BY id`, - fault.params, - ) - expect( - result.rows.map(({ id }) => id), - fault.name, - ).not.toEqual(expected) - } + // The fault calibrations replace the compiled SQL of exactly this world and + // predicate with hand-written wrong SQL, so the pair runs as its own + // enumerated case through checkPostgresPredicate; each fault must select + // rows that the independent evaluator does not permit. + const faultControlWorld: Array = [ + { id: 1, count: 2, label: null, enabled: true }, + { id: 2, count: 2, label: null, enabled: false }, + { id: 3, count: null, label: null, enabled: true }, + { id: 4, count: 0, label: null, enabled: true }, + ] + const faultControlPredicate: Predicate = { + type: `and`, + left: { + type: `or`, + left: { type: `compare`, op: `gt`, column: `count`, value: 1 }, + right: { type: `isNull`, column: `count` }, + }, + right: { type: `compare`, op: `eq`, column: `enabled`, value: true }, + } + const faults = [ + { + name: `wrong bound`, + sql: `("count" > $1 OR "count" IS NULL) AND "enabled" = $2`, + params: [`2`, `true`], + }, + { + name: `lost predicate`, + sql: `"count" > $1 OR "count" IS NULL`, + params: [`1`], + }, + { + name: `incorrect null handling`, + sql: `"count" > $1 AND "enabled" = $2`, + params: [`1`, `true`], + }, + { + name: `swapped comparison direction`, + sql: `("count" < $1 OR "count" IS NULL) AND "enabled" = $2`, + params: [`1`, `true`], + }, + ] + const FAULT_LAW = `electric.sql-predicate-semantics-faults` + type FaultPlan = { history: [Array, Predicate] } + oracleProperty({ + id: `electric.sql-predicate-semantics-faults`, + law: { + statement: `Emitted SQL and serialized parameters select the rows permitted by independent three-valued predicate semantics.`, + authority: `The compiler contract inventory above, PostgreSQL three-valued semantics, and this owner's separately specified finite evaluator.`, + limits: `The one fault-control world and predicate, received through real PostgreSQL, that the representative compiler-fault calibrations replace.`, + }, + owns: [`src/sql-compiler.ts#compileSQL`], + histories: [[faultControlWorld, faultControlPredicate]], + label: () => `a nullable bound conjunction over four rows`, + ...existingElectricComparison(FAULT_LAW, checkPostgresPredicate), + check: checkExistingElectricComparison, + calibrations: faults.map((fault) => ({ + name: `rejects representative compiler faults against PostgreSQL: ${fault.name}`, + law: FAULT_LAW, + driver: ( + plan: FaultPlan, + observe: (checkpoint: string, value: unknown) => void, + ) => + recordAssertionJudgment(FAULT_LAW, plan, observe, async () => { + const [world, faultPredicate] = plan.history + await client.query(`TRUNCATE "${table}"`) + for (const row of world) { + await client.query( + `INSERT INTO "${table}" (id, count, label, enabled) VALUES ($1, $2, $3, $4)`, + [row.id, row.count, row.label, row.enabled], + ) + } + const expected = world + .filter((row) => evaluate(faultPredicate, row) === true) + .map(({ id }) => id) + const result = await client.query<{ id: number }>( + `SELECT id FROM "${table}" WHERE ${fault.sql} ORDER BY id`, + fault.params, + ) + const selected = result.rows.map(({ id }) => id) + // Exactly the old matcher: the fault's rows differ from the model's. + try { + expect(selected, fault.name).not.toEqual(expected) + } catch { + return + } + rejectAt( + fault.name, + new Error( + `${fault.name}: selected ${JSON.stringify(selected)}, but the evaluator permits ${JSON.stringify(expected)}`, + ), + ) + }), + })), + timeout: 30000, }) }) diff --git a/packages/electric-db-collection/tests/electric-adapter-grammar-oracle.ts b/packages/electric-db-collection/tests/electric-adapter-grammar-oracle.ts new file mode 100644 index 0000000000..ebe956a936 --- /dev/null +++ b/packages/electric-db-collection/tests/electric-adapter-grammar-oracle.ts @@ -0,0 +1,215 @@ +// The Electric adapter oracle's pure grammar and comparison parts: the +// queued-presence and persistence-interleaving campaign grammars, their +// reconstruction checks, the durability-hold and interleaving comparisons, and +// the SDK reset-framing domain predicate. electric-oracle.property.test.ts runs +// them against production; electric-adapter-grammar.test.ts calibrates them +// without production. This module registers no tests. +import { fc } from '@fast-check/vitest' +import { expect } from 'vitest' +import { rowsFromMap } from './electric-adapter-support' +import type { OracleRow } from './electric-adapter-support' +import type { Message } from '@electric-sql/client' + +export function isSdkResetFramedPartition( + batches: Array>>, +): boolean { + // Model the installed SDK's HTTP 409 reset path: it publishes a synthetic + // singleton reset, not the response body. See electric-sdk-framing.test.ts. + // Arbitrary data/reset coalescing is outside this verified protocol domain; + // this is not a claim that the SDK validates every other server response. + return batches.every( + (batch) => + batch.length <= 1 || + batch.every( + (message) => + (message.headers as Record).control !== + `must-refetch`, + ), + ) +} + +export type QueuedPresenceHistory = + `insert-update` | `update-update` | `delete-update` | `truncate-update` + +export type QueuedPresenceCampaign = { + histories: Array + names: [string, string] +} + +export const queuedPresenceFixedSeed = 20_260_924 +export const queuedPresenceHistories: ReadonlyArray = [ + `insert-update`, + `update-update`, + `delete-update`, + `truncate-update`, +] +export const queuedPresenceArbitraries: [ + fc.Arbitrary>, + fc.Arbitrary<[string, string]>, +] = [ + fc.uniqueArray(fc.constantFrom(...queuedPresenceHistories), { + minLength: queuedPresenceHistories.length, + maxLength: queuedPresenceHistories.length, + }), + fc.tuple(fc.string({ maxLength: 12 }), fc.string({ maxLength: 12 })), +] + +export function reconstructQueuedPresenceCampaign( + value: unknown, +): QueuedPresenceCampaign { + if (typeof value !== `object` || value === null) { + throw new Error(`queued-presence campaign must be an object`) + } + const campaign = value as { histories?: unknown; names?: unknown } + if ( + !Array.isArray(campaign.histories) || + campaign.histories.length !== queuedPresenceHistories.length || + new Set(campaign.histories).size !== queuedPresenceHistories.length || + !campaign.histories.every((history) => + queuedPresenceHistories.includes(history), + ) + ) { + throw new Error( + `queued-presence histories must contain every supported transition exactly once`, + ) + } + if ( + !Array.isArray(campaign.names) || + campaign.names.length !== 2 || + !campaign.names.every( + (name) => typeof name === `string` && name.length <= 12, + ) + ) { + throw new Error(`queued-presence names are outside the grammar`) + } + return campaign as unknown as QueuedPresenceCampaign +} + +export function expectQueuedPresenceAtDurabilityHold( + actual: Array<[string | number, string, string]>, + history: QueuedPresenceHistory, + gate: OracleRow, +): void { + const expectedRows = new Map([[1, gate]]) + if (history !== `insert-update`) { + expectedRows.set(2, { + id: 2, + name: `baseline`, + stable: `stable-2`, + }) + } + expect(actual).toEqual(rowsFromMap(expectedRows)) +} + +export type PersistenceInterleavingKind = + `open-transaction-hydration` | `subset-supersedes-absence` + +export type PersistenceInterleavingCampaign = { + histories: Array + names: [string, string] +} + +export type PersistenceInterleavingObservation = + | { + kind: `open-transaction-hydration` + hydrationStartedBeforeCommit: boolean + hydrationOutcomeBeforeRelease: string + cachedRowBeforeRelease: boolean + hydrationOutcome: string + publicRowsAtHydrationSettlement: Array<[string | number, string, string]> + deliveryErrors: Array + publicRows: Array<[string | number, string, string]> + durableRows: Array<[string | number, string, string]> + status: string + publicError: string | undefined + } + | { + kind: `subset-supersedes-absence` + publicRows: Array<[string | number, string, string]> + durableRows: Array<[string | number, string, string]> + status: string + publicError: string | undefined + } + +export const persistenceInterleavingKinds: ReadonlyArray = + [`open-transaction-hydration`, `subset-supersedes-absence`] + +export const persistenceInterleavingCampaignArbitrary: fc.Arbitrary = + fc.record({ + histories: fc.uniqueArray( + fc.constantFrom(...persistenceInterleavingKinds), + { + minLength: persistenceInterleavingKinds.length, + maxLength: persistenceInterleavingKinds.length, + }, + ), + names: fc.tuple( + fc.string({ minLength: 1, maxLength: 12 }), + fc.string({ minLength: 1, maxLength: 12 }), + ), + }) + +export function reconstructPersistenceInterleavingCampaign( + value: unknown, +): PersistenceInterleavingCampaign { + if (typeof value !== `object` || value === null) { + throw new Error(`persistence-interleaving campaign must be an object`) + } + const campaign = value as { histories?: unknown; names?: unknown } + if ( + !Array.isArray(campaign.histories) || + campaign.histories.length !== persistenceInterleavingKinds.length || + new Set(campaign.histories).size !== persistenceInterleavingKinds.length || + !campaign.histories.every((history) => + persistenceInterleavingKinds.includes(history), + ) + ) { + throw new Error( + `persistence-interleaving histories must contain every supported schedule exactly once`, + ) + } + if ( + !Array.isArray(campaign.names) || + campaign.names.length !== 2 || + !campaign.names.every( + (name) => + typeof name === `string` && name.length >= 1 && name.length <= 12, + ) + ) { + throw new Error(`persistence-interleaving names are outside the grammar`) + } + return campaign as PersistenceInterleavingCampaign +} + +export function expectPersistenceInterleavingObservation( + observation: PersistenceInterleavingObservation, + names: [string, string], +): void { + const rows: Array<[string | number, string, string]> = [ + [1, names[1], `stable-1`], + ] + if (observation.kind === `open-transaction-hydration`) { + const rowsWithCache = [...rows, [2, `cached`, `stable-2`] as const] + expect(observation, JSON.stringify(observation)).toEqual({ + kind: `open-transaction-hydration`, + hydrationStartedBeforeCommit: false, + hydrationOutcomeBeforeRelease: `pending`, + cachedRowBeforeRelease: false, + hydrationOutcome: `AbortError`, + publicRowsAtHydrationSettlement: rowsWithCache, + deliveryErrors: [], + publicRows: rowsWithCache, + durableRows: rowsWithCache, + status: `ready`, + publicError: undefined, + }) + return + } + expect(observation, JSON.stringify(observation)).toEqual({ + kind: `subset-supersedes-absence`, + publicRows: rows, + durableRows: rows, + status: `ready`, + publicError: undefined, + }) +} diff --git a/packages/electric-db-collection/tests/electric-adapter-grammar.test.ts b/packages/electric-db-collection/tests/electric-adapter-grammar.test.ts new file mode 100644 index 0000000000..2907e1dd86 --- /dev/null +++ b/packages/electric-db-collection/tests/electric-adapter-grammar.test.ts @@ -0,0 +1,211 @@ +// Calibrates the Electric adapter oracle's grammars and comparisons without +// production: each grammar reconstructs its fixed-seed sample and rejects +// ablated, out-of-range and foreign campaigns, and each comparison rejects a +// named wrong answer. The oracle in electric-oracle.property.test.ts runs the +// same parts against production. +import { fc } from '@fast-check/vitest' +import { expect, it } from 'vitest' +import { oracleReplayTest } from '../../db/tests/oracle-config' +import { + change, + mustRefetch, + subsetEnd, + upToDate, +} from './electric-adapter-support' +import { + expectPersistenceInterleavingObservation, + expectQueuedPresenceAtDurabilityHold, + isSdkResetFramedPartition, + persistenceInterleavingCampaignArbitrary, + queuedPresenceArbitraries, + queuedPresenceFixedSeed, + queuedPresenceHistories, + reconstructPersistenceInterleavingCampaign, + reconstructQueuedPresenceCampaign, +} from './electric-adapter-grammar-oracle' +import type { OracleRow } from './electric-adapter-support' +import type { Message } from '@electric-sql/client' + +oracleReplayTest(it)( + `reconstructs the queued-presence grammar and rejects ablated, out-of-range, and foreign campaigns`, + () => { + const campaigns = fc.sample(fc.tuple(...queuedPresenceArbitraries), { + seed: queuedPresenceFixedSeed, + numRuns: 20, + }) + for (const [histories, names] of campaigns) { + expect(reconstructQueuedPresenceCampaign({ histories, names })).toEqual({ + histories, + names, + }) + } + + const witness = { + histories: [...queuedPresenceHistories], + names: [``, `x`.repeat(12)] as [string, string], + } + expect(reconstructQueuedPresenceCampaign(witness)).toEqual(witness) + expect(() => + reconstructQueuedPresenceCampaign({ names: witness.names }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ histories: witness.histories }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: witness.histories.slice(1), + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: [ + `insert-update`, + `insert-update`, + `delete-update`, + `truncate-update`, + ], + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + histories: [ + `insert-update`, + `update-update`, + `delete-update`, + `replace-update`, + ], + }), + ).toThrow() + expect(() => + reconstructQueuedPresenceCampaign({ + ...witness, + names: [`x`.repeat(13), ``], + }), + ).toThrow() + }, +) + +oracleReplayTest(it)( + `rejects named wrong answer: later callback publishes before durability`, + () => { + expect(() => + expectQueuedPresenceAtDurabilityHold( + [ + [1, `holds persistence`, `stable-1`], + [2, `later`, `stable-2`], + ], + `insert-update`, + { id: 1, name: `holds persistence`, stable: `stable-1` }, + ), + ).toThrow() + }, +) + +oracleReplayTest(it)( + `keeps SDK reset callbacks separate from every neighboring message kind`, + () => { + const neighbors: Array> = [ + change(`insert`, 1, `row`), + change(`update`, 1, `row`), + change(`delete`, 1, `row`), + { + headers: { event: `move-out`, patterns: [{ pos: 0, value: `left` }] }, + }, + upToDate, + subsetEnd, + mustRefetch, + ] + expect(isSdkResetFramedPartition([[mustRefetch]])).toBe(true) + for (const neighbor of neighbors) { + for (const messages of [ + [neighbor, mustRefetch], + [mustRefetch, neighbor], + ]) { + expect( + isSdkResetFramedPartition([messages]), + JSON.stringify(messages), + ).toBe(false) + expect( + isSdkResetFramedPartition(messages.map((message) => [message])), + ).toBe(true) + } + } + }, +) + +oracleReplayTest(it)( + `reconstructs persistence-interleaving campaigns and rejects ablated, out-of-range, and foreign histories`, + () => { + const campaigns = fc.sample(persistenceInterleavingCampaignArbitrary, { + seed: 18_530_601, + numRuns: 20, + }) + for (const campaign of campaigns) { + expect(reconstructPersistenceInterleavingCampaign(campaign)).toEqual( + campaign, + ) + expect(() => + reconstructPersistenceInterleavingCampaign({ + names: campaign.names, + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + histories: campaign.histories, + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + ...campaign, + histories: [`open-transaction-hydration`, `foreign`], + }), + ).toThrow() + expect(() => + reconstructPersistenceInterleavingCampaign({ + ...campaign, + names: [``, campaign.names[1]], + }), + ).toThrow() + } + }, +) + +oracleReplayTest(it)( + `rejects named wrong answers for persistence interleavings`, + () => { + const names: [string, string] = [`before`, `after`] + expect(() => + expectPersistenceInterleavingObservation( + { + kind: `open-transaction-hydration`, + hydrationStartedBeforeCommit: true, + hydrationOutcomeBeforeRelease: `AbortError`, + cachedRowBeforeRelease: true, + hydrationOutcome: `fulfilled`, + publicRowsAtHydrationSettlement: [], + deliveryErrors: [`InvalidPersistedCollectionConfigError`], + publicRows: [], + durableRows: [], + status: `ready`, + publicError: undefined, + }, + names, + ), + ).toThrow() + expect(() => + expectPersistenceInterleavingObservation( + { + kind: `subset-supersedes-absence`, + publicRows: [], + durableRows: [], + status: `ready`, + publicError: undefined, + }, + names, + ), + ).toThrow() + }, +) 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 035ad39721..59c46e94e8 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 @@ -1,9 +1,8 @@ import { fc } from '@fast-check/vitest' -import { beforeEach, expect, it, vi } from 'vitest' +import { beforeEach, expect, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' -import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { checkExistingElectricComparison, @@ -56,8 +55,6 @@ vi.mock(`@electric-sql/client`, async () => (await import(`./electric-descriptor-harness`)).createMockedElectricClient(), ) -const fixedCase = oracleReplayTest(it) - // The durable fixture is a driver, not the independent tag model. It must // preserve the SQLite adapter's mutation algebra: an insert replaces the old // value, while metadataChanged clears metadata even when the new value is @@ -406,27 +403,48 @@ async function runTagHistory( // Bounded neighbors isolate membership loss from reset debt. The ordinary // untagged resume is the distinguishing control for changes_only capability. -fixedCase.each([ +// The generated property runs every restart combination of a history; each +// neighbor here selects one combination, so they are enumerated cases of the +// same driver and comparison. +const restartDemandCases = [ { tagged: true, legacyResume: false, cold: true, fresh: false }, { tagged: false, legacyResume: false, cold: true, fresh: false }, { tagged: false, legacyResume: true, cold: true, fresh: false }, { tagged: false, legacyResume: false, cold: false, fresh: true }, -])( - `settles restart demand with tagged=$tagged legacy=$legacyResume cold=$cold reset=$fresh`, - async ({ cold, fresh, ...history }) => { - oracleRoute(`case`) - await runTagHistory( - { - ...history, - syncMode: `on-demand`, - interruptRecovery: false, - edits: [], - removals: [`left`, `right`, `other`], - }, - { cold, fresh }, - ) +] +async function runRestartDemandCase({ + cold, + fresh, + ...history +}: (typeof restartDemandCases)[number]) { + await runTagHistory( + { + ...history, + syncMode: `on-demand`, + interruptRecovery: false, + edits: [], + removals: [`left`, `right`, `other`], + }, + { cold, fresh }, + ) +} +oracleProperty({ + id: `electric.persisted-tag-history-restart`, + law: { + statement: `Compatible warm resumes retain tag membership; fresh source snapshots rebuild it across warm and cold restart.`, + authority: `The independent tag-set law and persisted recovery contract stated above runTagHistory.`, + limits: `Four on-demand restart neighbors, each with one selected restart combination, through the same driver and comparison as electric.persisted-tag-history.`, }, -) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: restartDemandCases, + label: ({ tagged, legacyResume, cold, fresh }) => + `settles restart demand with tagged=${tagged} legacy=${legacyResume} cold=${cold} reset=${fresh}`, + ...existingElectricComparison( + `electric.persisted-tag-history-restart`, + runRestartDemandCase, + ), + check: checkExistingElectricComparison, +}) // The two campaigns use the same grammar, checker, and budget. A path replay // of the property runs only the random campaign. Reproduce a saved failure with: @@ -513,23 +531,40 @@ beforeEach(() => { // acquisition readiness. Retiring the session must abandon its commit turn; // otherwise a demand in the replacement session waits on impossible work. -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, - legacyResume: false, - interruptRecovery: true, - edits: [], - removals: [`left`, `right`, `other`], - }) - // Three interrupted rebuilds plus one compatible warm resume. Each - // interrupted branch checks its abandoned callback was actually invoked. - expect(streams).toHaveLength(11) +async function runAbandonedRecoveryCase( + syncMode: `eager` | `on-demand` | `progressive`, +) { + // Each case counts its streams from a reset harness, as a fresh test does. + resetElectricDescriptorHarness() + await runTagHistory({ + syncMode, + tagged: true, + legacyResume: false, + interruptRecovery: true, + edits: [], + removals: [`left`, `right`, `other`], + }) + // Three interrupted rebuilds plus one compatible warm resume. Each + // interrupted branch checks its abandoned callback was actually invoked. + expect(streams).toHaveLength(11) +} +oracleProperty({ + id: `electric.persisted-tag-history-recovery`, + law: { + statement: `Abandoned tag-recovery callbacks delivered after a replacement provider session starts change no membership.`, + authority: `The independent tag-set law and persisted recovery contract stated above runTagHistory.`, + limits: `One tagged interrupted-recovery history in each sync mode, through the same driver and comparison as electric.persisted-tag-history, with its stream count.`, }, -) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [`eager`, `on-demand`, `progressive`] as const, + label: (syncMode) => + `delivers abandoned tag-recovery callbacks after replacement starts in ${syncMode} mode`, + ...existingElectricComparison( + `electric.persisted-tag-history-recovery`, + runAbandonedRecoveryCase, + ), + check: checkExistingElectricComparison, +}) const ownerHistory = fc.record({ startSync: fc.boolean(), 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 24a8285039..a796bcc432 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -1,6 +1,6 @@ import { isDeepStrictEqual } from 'node:util' import { fc } from '@fast-check/vitest' -import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, vi } from 'vitest' import { IR, createCollection, @@ -15,17 +15,22 @@ import { } from '../../db-sqlite-persistence-core/src' import { queryCollectionOptions } from '../../query-db-collection/src/query' import { electricCollectionOptions } from '../src/electric' -import { - oracleReplayRequested, - oracleReplayTest, - oracleRoute, -} from '../../db/tests/oracle-config' +import { oracleReplayRequested } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { checkMutationConfirmation, mutationConfirmationHistories, mutationConfirmationInputs, } from '../../db/tests/mutation-confirmation-oracle' +import { + expectPersistenceInterleavingObservation, + expectQueuedPresenceAtDurabilityHold, + isSdkResetFramedPartition, + persistenceInterleavingCampaignArbitrary, + queuedPresenceArbitraries, + queuedPresenceFixedSeed, + reconstructPersistenceInterleavingCampaign, +} from './electric-adapter-grammar-oracle' import { checkExistingElectricComparison, existingElectricComparison, @@ -67,6 +72,11 @@ import { subsetEnd, upToDate, } from './electric-adapter-support' +import type { + PersistenceInterleavingCampaign, + PersistenceInterleavingObservation, + QueuedPresenceHistory, +} from './electric-adapter-grammar-oracle' import type { MutationConfirmationObservation } from '../../db/tests/mutation-confirmation-oracle' import type { ObservedPreload, @@ -164,237 +174,224 @@ describe(`mutation outcome and confirmation`, () => { Object.fromEntries(mutationCases), ) }) - it.each(mutationHistories)( - `settles a handler independently of discarded values: %j`, - async (history) => { - oracleRoute(`case`) - const { operation, confirmation, outcome, returned } = history - const expectedCuts = [ - `entered`, - `settled`, - `settlement-waiter`, - ...(outcome === `reject-before-write` ? [] : [`server-written`]), - ...(confirmation === `none` - ? [] - : [`confirmation-requested`, `confirmation-accepted`]), - ...(confirmation === `none` && outcome !== `reject-before-write` - ? [`late-confirmed`] - : []), - ] - const observedCuts = new Set() - resetElectricOracleMocks() - let subscriber!: (messages: Array>) => void - mockSubscribe.mockImplementationOnce((callback) => { - subscriber = callback - return () => {} - }) - const enterRelease = createDeferred() - const confirmationRelease = createDeferred() - const settleRelease = createDeferred() - const entered = createDeferred() - const serverWritten = createDeferred() - const confirmationAccepted = createDeferred() - const confirmationRequested = createDeferred() - const failure = new Error(`handler ${outcome}`) - let handlerWrites = 0 - let confirmationCalls = 0 - let confirmationStatus: MutationConfirmationObservation[`confirmationStatus`] = `not-requested` - let settlement: MutationConfirmationObservation[`settlement`] = `pending` - let error: unknown - let verdict: Promise | undefined - let releaseCollection: (() => Promise) | undefined - await withElectricCleanup(async () => { - const message = change(operation, 1, `confirmed`) - if (operation !== `delete`) - message.value = { ...mutationConfirmationInputs.confirmed } - message.headers.txids = [55] - const handler = async () => { - entered.resolve() - await enterRelease.promise - if (outcome === `reject-before-write`) throw failure - handlerWrites++ - serverWritten.resolve() - await confirmationRelease.promise - if (confirmation === `txid`) { - confirmationCalls++ - confirmationStatus = `pending` - const ack = collection.utils.awaitTxId(55).then(() => { - confirmationStatus = `accepted` - }) - confirmationRequested.resolve() - await ack - } - confirmationAccepted.resolve() - await settleRelease.promise - if (outcome === `reject-after-write`) throw failure - return returned === `legacy-txid` ? { txid: 55 } : undefined + // One enumerated history through the real Electric adapter, compared with + // the shared outcome/confirmation model at each cut. + async function runMutationConfirmationHistory( + history: (typeof mutationHistories)[number], + ): Promise { + const { operation, confirmation, outcome, returned } = history + const expectedCuts = [ + `entered`, + `settled`, + `settlement-waiter`, + ...(outcome === `reject-before-write` ? [] : [`server-written`]), + ...(confirmation === `none` + ? [] + : [`confirmation-requested`, `confirmation-accepted`]), + ...(confirmation === `none` && outcome !== `reject-before-write` + ? [`late-confirmed`] + : []), + ] + const observedCuts = new Set() + resetElectricOracleMocks() + let subscriber!: (messages: Array>) => void + mockSubscribe.mockImplementationOnce((callback) => { + subscriber = callback + return () => {} + }) + const enterRelease = createDeferred() + const confirmationRelease = createDeferred() + const settleRelease = createDeferred() + const entered = createDeferred() + const serverWritten = createDeferred() + const confirmationAccepted = createDeferred() + const confirmationRequested = createDeferred() + const failure = new Error(`handler ${outcome}`) + let handlerWrites = 0 + let confirmationCalls = 0 + let confirmationStatus: MutationConfirmationObservation[`confirmationStatus`] = `not-requested` + let settlement: MutationConfirmationObservation[`settlement`] = `pending` + let error: unknown + let verdict: Promise | undefined + let releaseCollection: (() => Promise) | undefined + await withElectricCleanup(async () => { + const message = change(operation, 1, `confirmed`) + if (operation !== `delete`) + message.value = { ...mutationConfirmationInputs.confirmed } + message.headers.txids = [55] + const handler = async () => { + entered.resolve() + await enterRelease.promise + if (outcome === `reject-before-write`) throw failure + handlerWrites++ + serverWritten.resolve() + await confirmationRelease.promise + if (confirmation === `txid`) { + confirmationCalls++ + confirmationStatus = `pending` + const ack = collection.utils.awaitTxId(55).then(() => { + confirmationStatus = `accepted` + }) + confirmationRequested.resolve() + await ack } - const runtimeHandler = handler as unknown as () => Promise - const collection = createCollection( - electricCollectionOptions({ - id: `discarded-value-${JSON.stringify(history)}`, - shapeOptions: { - url: `http://test-url`, - params: { table: `test_table` }, - }, - startSync: true, - getKey: (row) => row.id, - onInsert: runtimeHandler, - onUpdate: runtimeHandler, - onDelete: runtimeHandler, - }), - ) - releaseCollection = () => collection.cleanup() - subscriber([ - ...(history.peer ? [change(`insert`, 2, `peer`)] : []), - ...(operation === `insert` ? [] : [change(`insert`, 1, `before`)]), - upToDate, - ]) - await atCheckpoint(collection.preload(), `initial Electric preload`) - const transaction = - operation === `insert` - ? collection.insert({ ...mutationConfirmationInputs.optimistic }) - : operation === `delete` - ? collection.delete(1) - : collection.update(1, (draft) => { - draft.name = `optimistic` - }) - const observe = (): MutationConfirmationObservation => ({ - rows: collection.toArray - .map(({ id, name, stable }) => ({ id, name, stable })) - .sort((a, b) => a.id - b.id), - handlerWrites, - confirmationCalls, - fetches: 0, - confirmationStatus, - providerRequests: - mockStream.requestSnapshot.mock.calls.length + - mockStream.fetchSnapshot.mock.calls.length + - mockStream.forceDisconnectAndRefresh.mock.calls.length, - state: transaction.state, - settlement, - error, - }) - verdict = transaction.when(`settled`).then( - () => { - settlement = `fulfilled` - return observe() - }, - (reason: unknown) => { - settlement = `rejected` - error = reason - return observe() + confirmationAccepted.resolve() + await settleRelease.promise + if (outcome === `reject-after-write`) throw failure + return returned === `legacy-txid` ? { txid: 55 } : undefined + } + const runtimeHandler = handler as unknown as () => Promise + const collection = createCollection( + electricCollectionOptions({ + id: `discarded-value-${JSON.stringify(history)}`, + shapeOptions: { + url: `http://test-url`, + params: { table: `test_table` }, }, - ) - await atCheckpoint(entered.promise, `Electric handler entry`) - checkMutationConfirmation(history, `entered`, observe(), failure) - observedCuts.add(`entered`) - enterRelease.resolve() - if (outcome !== `reject-before-write`) { - await atCheckpoint(serverWritten.promise, `Electric server write`) + startSync: true, + getKey: (row) => row.id, + onInsert: runtimeHandler, + onUpdate: runtimeHandler, + onDelete: runtimeHandler, + }), + ) + releaseCollection = () => collection.cleanup() + subscriber([ + ...(history.peer ? [change(`insert`, 2, `peer`)] : []), + ...(operation === `insert` ? [] : [change(`insert`, 1, `before`)]), + upToDate, + ]) + await atCheckpoint(collection.preload(), `initial Electric preload`) + const transaction = + operation === `insert` + ? collection.insert({ ...mutationConfirmationInputs.optimistic }) + : operation === `delete` + ? collection.delete(1) + : collection.update(1, (draft) => { + draft.name = `optimistic` + }) + const observe = (): MutationConfirmationObservation => ({ + rows: collection.toArray + .map(({ id, name, stable }) => ({ id, name, stable })) + .sort((a, b) => a.id - b.id), + handlerWrites, + confirmationCalls, + fetches: 0, + confirmationStatus, + providerRequests: + mockStream.requestSnapshot.mock.calls.length + + mockStream.fetchSnapshot.mock.calls.length + + mockStream.forceDisconnectAndRefresh.mock.calls.length, + state: transaction.state, + settlement, + error, + }) + verdict = transaction.when(`settled`).then( + () => { + settlement = `fulfilled` + return observe() + }, + (reason: unknown) => { + settlement = `rejected` + error = reason + return observe() + }, + ) + await atCheckpoint(entered.promise, `Electric handler entry`) + checkMutationConfirmation(history, `entered`, observe(), failure) + observedCuts.add(`entered`) + enterRelease.resolve() + if (outcome !== `reject-before-write`) { + await atCheckpoint(serverWritten.promise, `Electric server write`) + checkMutationConfirmation(history, `server-written`, observe(), failure) + observedCuts.add(`server-written`) + confirmationRelease.resolve() + if (confirmation === `txid`) { + await atCheckpoint( + confirmationRequested.promise, + `Electric acknowledgement registration`, + ) + await Promise.resolve() checkMutationConfirmation( history, - `server-written`, + `confirmation-requested`, observe(), failure, ) - observedCuts.add(`server-written`) - confirmationRelease.resolve() - if (confirmation === `txid`) { - await atCheckpoint( - confirmationRequested.promise, - `Electric acknowledgement registration`, - ) - await Promise.resolve() - checkMutationConfirmation( - history, - `confirmation-requested`, - observe(), - failure, - ) - observedCuts.add(`confirmation-requested`) - subscriber([message, upToDate]) - } - await atCheckpoint( - confirmationAccepted.promise, - `Electric confirmation acceptance`, - ) - if (confirmation !== `none`) { - checkMutationConfirmation( - history, - `confirmation-accepted`, - observe(), - failure, - ) - observedCuts.add(`confirmation-accepted`) - } - settleRelease.resolve() + observedCuts.add(`confirmation-requested`) + subscriber([message, upToDate]) } - const atWaiter = await atCheckpoint( - verdict, - `Electric handler settlement`, + await atCheckpoint( + confirmationAccepted.promise, + `Electric confirmation acceptance`, ) - checkMutationConfirmation(history, `settled`, atWaiter, failure) - observedCuts.add(`settlement-waiter`) - checkMutationConfirmation(history, `settled`, observe(), failure) - observedCuts.add(`settled`) - if (confirmation === `none` && outcome !== `reject-before-write`) { - const ack = collection.utils.awaitTxId(55) - subscriber([message, upToDate]) - await atCheckpoint(ack, `later Electric stream confirmation`) + if (confirmation !== `none`) { checkMutationConfirmation( history, - `late-confirmed`, + `confirmation-accepted`, observe(), failure, ) - observedCuts.add(`late-confirmed`) - checkMutationConfirmation(history, `settled`, atWaiter, failure) + observedCuts.add(`confirmation-accepted`) } - if (!oracleReplayRequested()) - expect(expectedCuts.filter((cut) => !observedCuts.has(cut))).toEqual( - [], - ) - const mutationCase = `${operation}/${outcome}/${confirmation}/${returned}/peer:${history.peer}` - mutationCases.set(mutationCase, mutationCases.get(mutationCase)! + 1) - }, [ - () => { - enterRelease.resolve() - confirmationRelease.resolve() - settleRelease.resolve() - if (confirmationStatus === `pending`) { - const message = change(operation, 1, `confirmed`) - if (operation !== `delete`) - message.value = { ...mutationConfirmationInputs.confirmed } - message.headers.txids = [55] - subscriber([message, upToDate]) - } - }, - () => verdict && atCheckpoint(verdict, `Electric cleanup settlement`), - () => releaseCollection?.(), - ]) + settleRelease.resolve() + } + const atWaiter = await atCheckpoint( + verdict, + `Electric handler settlement`, + ) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + observedCuts.add(`settlement-waiter`) + checkMutationConfirmation(history, `settled`, observe(), failure) + observedCuts.add(`settled`) + if (confirmation === `none` && outcome !== `reject-before-write`) { + const ack = collection.utils.awaitTxId(55) + subscriber([message, upToDate]) + await atCheckpoint(ack, `later Electric stream confirmation`) + checkMutationConfirmation(history, `late-confirmed`, observe(), failure) + observedCuts.add(`late-confirmed`) + checkMutationConfirmation(history, `settled`, atWaiter, failure) + } + if (!oracleReplayRequested()) + expect(expectedCuts.filter((cut) => !observedCuts.has(cut))).toEqual([]) + const mutationCase = `${operation}/${outcome}/${confirmation}/${returned}/peer:${history.peer}` + mutationCases.set(mutationCase, mutationCases.get(mutationCase)! + 1) + }, [ + () => { + enterRelease.resolve() + confirmationRelease.resolve() + settleRelease.resolve() + if (confirmationStatus === `pending`) { + const message = change(operation, 1, `confirmed`) + if (operation !== `delete`) + message.value = { ...mutationConfirmationInputs.confirmed } + message.headers.txids = [55] + subscriber([message, upToDate]) + } + }, + () => verdict && atCheckpoint(verdict, `Electric cleanup settlement`), + () => releaseCollection?.(), + ]) + } + oracleProperty({ + id: `electric.mutation-confirmation`, + law: { + statement: `A mutation handler settles from its own outcome and confirmation, independently of the values it returns or discards.`, + authority: `The shared outcome/confirmation model in packages/db/tests/mutation-confirmation-oracle.ts, received through the Electric adapter.`, + limits: `The finite Electric mutation-confirmation histories over controlled ShapeStream delivery; no live Electric-service claim.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: mutationHistories, + label: (history) => + `settles a handler independently of discarded values: ${JSON.stringify(history)}`, + ...existingElectricComparison( + `electric.mutation-confirmation`, + runMutationConfirmationHistory, + ), + check: checkExistingElectricComparison, + }) }) -function isSdkResetFramedPartition( - batches: Array>>, -): boolean { - // Model the installed SDK's HTTP 409 reset path: it publishes a synthetic - // singleton reset, not the response body. See electric-sdk-framing.test.ts. - // Arbitrary data/reset coalescing is outside this verified protocol domain; - // this is not a claim that the SDK validates every other server response. - return batches.every( - (batch) => - batch.length <= 1 || - batch.every( - (message) => - (message.headers as Record).control !== - `must-refetch`, - ), - ) -} - type SourceLedgerEvent = { type: `source-commit` offset: string @@ -598,66 +595,10 @@ type ReferenceState = { * convergence. ShapeStream is mocked at the installed SDK boundary, so this * owner does not establish live-service framing or native-host scheduling. */ -type QueuedPresenceHistory = - `insert-update` | `update-update` | `delete-update` | `truncate-update` - -type QueuedPresenceCampaign = { - histories: Array - names: [string, string] -} - const queuedPresenceProperty = `electric.queued-presence-order` -const queuedPresenceFixedSeed = 20_260_924 const queuedPresenceRuns = 8 -const queuedPresenceHistories: ReadonlyArray = [ - `insert-update`, - `update-update`, - `delete-update`, - `truncate-update`, -] -const queuedPresenceArbitraries: [ - fc.Arbitrary>, - fc.Arbitrary<[string, string]>, -] = [ - fc.uniqueArray(fc.constantFrom(...queuedPresenceHistories), { - minLength: queuedPresenceHistories.length, - maxLength: queuedPresenceHistories.length, - }), - fc.tuple(fc.string({ maxLength: 12 }), fc.string({ maxLength: 12 })), -] const persistenceInterleavingProperty = `electric.persistence-interleaving` -function reconstructQueuedPresenceCampaign( - value: unknown, -): QueuedPresenceCampaign { - if (typeof value !== `object` || value === null) { - throw new Error(`queued-presence campaign must be an object`) - } - const campaign = value as { histories?: unknown; names?: unknown } - if ( - !Array.isArray(campaign.histories) || - campaign.histories.length !== queuedPresenceHistories.length || - new Set(campaign.histories).size !== queuedPresenceHistories.length || - !campaign.histories.every((history) => - queuedPresenceHistories.includes(history), - ) - ) { - throw new Error( - `queued-presence histories must contain every supported transition exactly once`, - ) - } - if ( - !Array.isArray(campaign.names) || - campaign.names.length !== 2 || - !campaign.names.every( - (name) => typeof name === `string` && name.length <= 12, - ) - ) { - throw new Error(`queued-presence names are outside the grammar`) - } - return campaign as unknown as QueuedPresenceCampaign -} - function expectedQueuedPresenceRows( history: QueuedPresenceHistory, gate: OracleRow, @@ -675,22 +616,6 @@ function expectedQueuedPresenceRows( return expectedRows } -function expectQueuedPresenceAtDurabilityHold( - actual: Array<[string | number, string, string]>, - history: QueuedPresenceHistory, - gate: OracleRow, -): void { - const expectedRows = new Map([[1, gate]]) - if (history !== `insert-update`) { - expectedRows.set(2, { - id: 2, - name: `baseline`, - stable: `stable-2`, - }) - } - expect(actual).toEqual(rowsFromMap(expectedRows)) -} - async function runQueuedPresenceCampaign( histories: Array, names: [string, string], @@ -837,86 +762,6 @@ async function runQueuedPresenceHistory( * These histories exercise the persisted Collection wrapper and mocked * Electric callback boundary, not native-host scheduling. */ -type PersistenceInterleavingKind = - `open-transaction-hydration` | `subset-supersedes-absence` - -type PersistenceInterleavingCampaign = { - histories: Array - names: [string, string] -} - -type PersistenceInterleavingObservation = - | { - kind: `open-transaction-hydration` - hydrationStartedBeforeCommit: boolean - hydrationOutcomeBeforeRelease: string - cachedRowBeforeRelease: boolean - hydrationOutcome: string - publicRowsAtHydrationSettlement: Array<[string | number, string, string]> - deliveryErrors: Array - publicRows: Array<[string | number, string, string]> - durableRows: Array<[string | number, string, string]> - status: string - publicError: string | undefined - } - | { - kind: `subset-supersedes-absence` - publicRows: Array<[string | number, string, string]> - durableRows: Array<[string | number, string, string]> - status: string - publicError: string | undefined - } - -const persistenceInterleavingKinds: ReadonlyArray = - [`open-transaction-hydration`, `subset-supersedes-absence`] - -const persistenceInterleavingCampaignArbitrary: fc.Arbitrary = - fc.record({ - histories: fc.uniqueArray( - fc.constantFrom(...persistenceInterleavingKinds), - { - minLength: persistenceInterleavingKinds.length, - maxLength: persistenceInterleavingKinds.length, - }, - ), - names: fc.tuple( - fc.string({ minLength: 1, maxLength: 12 }), - fc.string({ minLength: 1, maxLength: 12 }), - ), - }) - -function reconstructPersistenceInterleavingCampaign( - value: unknown, -): PersistenceInterleavingCampaign { - if (typeof value !== `object` || value === null) { - throw new Error(`persistence-interleaving campaign must be an object`) - } - const campaign = value as { histories?: unknown; names?: unknown } - if ( - !Array.isArray(campaign.histories) || - campaign.histories.length !== persistenceInterleavingKinds.length || - new Set(campaign.histories).size !== persistenceInterleavingKinds.length || - !campaign.histories.every((history) => - persistenceInterleavingKinds.includes(history), - ) - ) { - throw new Error( - `persistence-interleaving histories must contain every supported schedule exactly once`, - ) - } - if ( - !Array.isArray(campaign.names) || - campaign.names.length !== 2 || - !campaign.names.every( - (name) => - typeof name === `string` && name.length >= 1 && name.length <= 12, - ) - ) { - throw new Error(`persistence-interleaving names are outside the grammar`) - } - return campaign as PersistenceInterleavingCampaign -} - function errorName(error: unknown): string { return typeof error === `object` && error !== null && @@ -1148,39 +993,6 @@ async function observeSubsetSupersedesAbsence( } } -function expectPersistenceInterleavingObservation( - observation: PersistenceInterleavingObservation, - names: [string, string], -): void { - const rows: Array<[string | number, string, string]> = [ - [1, names[1], `stable-1`], - ] - if (observation.kind === `open-transaction-hydration`) { - const rowsWithCache = [...rows, [2, `cached`, `stable-2`] as const] - expect(observation, JSON.stringify(observation)).toEqual({ - kind: `open-transaction-hydration`, - hydrationStartedBeforeCommit: false, - hydrationOutcomeBeforeRelease: `pending`, - cachedRowBeforeRelease: false, - hydrationOutcome: `AbortError`, - publicRowsAtHydrationSettlement: rowsWithCache, - deliveryErrors: [], - publicRows: rowsWithCache, - durableRows: rowsWithCache, - status: `ready`, - publicError: undefined, - }) - return - } - expect(observation, JSON.stringify(observation)).toEqual({ - kind: `subset-supersedes-absence`, - publicRows: rows, - durableRows: rows, - status: `ready`, - publicError: undefined, - }) -} - async function runPersistenceInterleavingCampaign( campaign: PersistenceInterleavingCampaign, ): Promise { @@ -2695,17 +2507,33 @@ describe(`Electric adapter laws`, () => { resetElectricOracleMocks() }) - oracleReplayTest(it).each( - ([`eager`, `on-demand`, `progressive`] as const).flatMap((syncMode) => - [0, 1, 2].map((removals) => ({ syncMode, removals })), - ), - )( - `preserves live updates after $removals initial tagged move-outs in $syncMode mode`, - ({ syncMode, removals }) => { - oracleRoute(`case`) - return checkInitialMoveOut(syncMode, removals, 1, `updated`) + // Each case runs one sync mode, where the generated property runs all + // three per history, so they are enumerated cases of the same driver. + oracleProperty({ + id: `electric.initial-tagged-move-outs-cases`, + law: { + statement: `generated initial tagged move-outs preserve later live updates`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `Row 1 updated after zero to two initial tagged move-outs, in each sync mode separately; the same driver as electric.initial-tagged-move-outs.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: ([`eager`, `on-demand`, `progressive`] as const).flatMap( + (syncMode) => [0, 1, 2].map((removals) => ({ syncMode, removals })), + ), + label: ({ syncMode, removals }) => + `preserves live updates after ${removals} initial tagged move-outs in ${syncMode} mode`, + ...existingElectricComparison( + `electric.initial-tagged-move-outs-cases`, + ({ + syncMode, + removals, + }: { + syncMode: ElectricSyncMode + removals: number + }) => checkInitialMoveOut(syncMode, removals, 1, `updated`), + ), + check: checkExistingElectricComparison, + }) const electricHistoryRegistration1 = electricHistoryOptions( `electric.initial-tagged-move-outs`, @@ -2758,186 +2586,145 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) - oracleReplayTest(it)( - `delivers retired callbacks without advancing a failed lifecycle`, - async () => { - oracleRoute(`fixed history`) - const result = await runProcessGrammar(`process-grammar-terminal-error`, [ - { - kind: `batch`, - slot: `a`, - operation: `update`, - id: 1, - name: `unseen`, - txid: 55, - }, - { kind: `snapshot`, slot: `a`, id: 1, name: `stale callback` }, - { - kind: `retired-callback`, - slot: `a`, - id: 1, - name: `actually delivered`, - }, - ]) - expect( - result.reached - .filter(({ command }) => command.kind === `retired-callback`) - .map(({ result: reached }) => reached), - ).toEqual([ - `delivered: retired callback`, - `delivered: retired callback`, - `delivered: retired callback`, - ]) - expect( - result.reached.find( - ({ command }) => - command.kind === `snapshot` && command.name === `stale callback`, - )?.result, - ).toBe(`skipped: terminal error`) - }, - ) - - oracleReplayTest(it).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 }, - { kind: `preload`, slot: `a` }, - ...(failure === `cleanup` - ? [{ kind: `cleanup` as const, slot: `a` as const }] - : [ - { - kind: `batch` as const, - slot: `a` as const, - operation: `update` as const, - id: 3, - name: `unseen`, - txid: 56, - }, - ]), - { kind: `retired-callback`, slot: `a`, id: 3, name: `after rejection` }, - ]) - expect(result.preloads.map(({ status }) => status)).toEqual([ - `fulfilled`, - `fulfilled`, - `rejected`, - ]) - const rejected = result.preloads[2]! - if (rejected.status === `rejected`) { - expect(rejected.reason).toMatchObject( - failure === `cleanup` - ? { name: `AbortError` } - : { - message: `Electric resume state referenced an unseen row; a full snapshot is required`, - }, - ) - } - expect( - result.reached.find( - ({ command }) => - command.kind === `retired-callback` && - command.name === `after rejection`, - )?.result, - ).toBe(`delivered: retired callback`) - }, - ) - - 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` }, - 741, + // Named process-grammar witnesses. Each runs authored commands through + // runProcessGrammar and its model, then checks what that history reached. + async function runTerminalErrorWitness(): Promise { + const result = await runProcessGrammar(`process-grammar-terminal-error`, [ + { + kind: `batch`, + slot: `a`, + operation: `update`, + id: 1, + name: `unseen`, + txid: 55, + }, + { kind: `snapshot`, slot: `a`, id: 1, name: `stale callback` }, + { + kind: `retired-callback`, + slot: `a`, + id: 1, + name: `actually delivered`, + }, + ]) + expect( + result.reached + .filter(({ command }) => command.kind === `retired-callback`) + .map(({ result: reached }) => reached), + ).toEqual([ + `delivered: retired callback`, + `delivered: retired callback`, + `delivered: retired callback`, + ]) + expect( + result.reached.find( + ({ command }) => + command.kind === `snapshot` && command.name === `stale callback`, + )?.result, + ).toBe(`skipped: terminal error`) + } + async function runPreloadRejectionWitness( + failure: `invalid-resume` | `cleanup`, + ): Promise { + const result = await runProcessGrammar(`preload-${failure}`, [ + { kind: `create`, slot: `a` }, + { kind: `import`, slot: `a`, txid: 4, resume: true }, + { kind: `preload`, slot: `a` }, + ...(failure === `cleanup` + ? [{ kind: `cleanup` as const, slot: `a` as const }] + : [ + { + kind: `batch` as const, + slot: `a` as const, + operation: `update` as const, + id: 3, + name: `unseen`, + txid: 56, + }, + ]), + { kind: `retired-callback`, slot: `a`, id: 3, name: `after rejection` }, + ]) + expect(result.preloads.map(({ status }) => status)).toEqual([ + `fulfilled`, + `fulfilled`, + `rejected`, + ]) + const rejected = result.preloads[2]! + if (rejected.status === `rejected`) { + expect(rejected.reason).toMatchObject( + failure === `cleanup` + ? { name: `AbortError` } + : { + message: `Electric resume state referenced an unseen row; a full snapshot is required`, + }, ) + } + expect( + result.reached.find( + ({ command }) => + command.kind === `retired-callback` && + command.name === `after rejection`, + )?.result, + ).toBe(`delivered: retired callback`) + } + type ProcessGrammarWitness = + | { witness: `terminal-error` } + | { witness: `preload-rejection`; failure: `invalid-resume` | `cleanup` } + oracleProperty({ + id: `electric.process-grammar-fixed`, + law: { + statement: `generated process grammar preserves lifecycle and concurrent-collection isolation`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `Three authored command histories (a terminal error with retired callbacks, and an initial preload's invalid-resume or cleanup rejection), through the same runProcessGrammar driver as electric.process-grammar.`, }, - ) - - 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, - }) - for (const [histories, names] of campaigns) { - expect(reconstructQueuedPresenceCampaign({ histories, names })).toEqual( - { - histories, - names, - }, - ) - } + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [ + { witness: `terminal-error` }, + { witness: `preload-rejection`, failure: `invalid-resume` }, + { witness: `preload-rejection`, failure: `cleanup` }, + ] satisfies Array, + label: (history: ProcessGrammarWitness) => + history.witness === `terminal-error` + ? `delivers retired callbacks without advancing a failed lifecycle` + : `records an initial preload's ${history.failure} rejection before continuing the grammar`, + ...existingElectricComparison( + `electric.process-grammar-fixed`, + (history: ProcessGrammarWitness) => + history.witness === `terminal-error` + ? runTerminalErrorWitness() + : runPreloadRejectionWitness(history.failure), + ), + check: checkExistingElectricComparison, + }) - oracleRoute(`calibration`) - const witness = { - histories: [...queuedPresenceHistories], - names: [``, `x`.repeat(12)] as [string, string], - } - expect(reconstructQueuedPresenceCampaign(witness)).toEqual(witness) - expect(() => - reconstructQueuedPresenceCampaign({ names: witness.names }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ histories: witness.histories }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: witness.histories.slice(1), - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: [ - `insert-update`, - `insert-update`, - `delete-update`, - `truncate-update`, - ], - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - histories: [ - `insert-update`, - `update-update`, - `delete-update`, - `replace-update`, - ], - }), - ).toThrow() - expect(() => - reconstructQueuedPresenceCampaign({ - ...witness, - names: [`x`.repeat(13), ``], - }), - ).toThrow() + // The authored witness uses its own source txid, which the generated + // property derives from the row id, so it is an enumerated case. + oracleProperty({ + id: `electric.publication-before-persistence-fixed`, + law: { + statement: `generated Electric commits publish before adapter persistence settles`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `The authored row 41 with source txid 741, through the same driver as the generated publication-before-persistence property.`, }, - ) - - oracleReplayTest(it)( - `rejects named wrong answer: later callback publishes before durability`, - () => { - oracleRoute(`calibration`) - expect(() => - expectQueuedPresenceAtDurabilityHold( - [ - [1, `holds persistence`, `stable-1`], - [2, `later`, `stable-2`], - ], - `insert-update`, - { id: 1, name: `holds persistence`, stable: `stable-1` }, + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [ + { + row: { id: 41, name: `visible first`, stable: `stable-41` }, + txid: 741, + }, + ], + label: () => + `publishes an Electric source commit before its adapter persistence settles`, + ...existingElectricComparison( + `electric.publication-before-persistence-fixed`, + ({ row, txid }: { row: OracleRow; txid: number }) => + runPublicationBeforePersistenceWitness( + `publication-before-persistence-fixed`, + row, + txid, ), - ).toThrow() - }, - ) + ), + check: checkExistingElectricComparison, + }) const electricHistoryRegistration3 = electricHistoryOptions( queuedPresenceProperty, @@ -2991,17 +2778,45 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) - 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` }, - { id: 52, name: `must not be admitted`, stable: `stable-52` }, - ) + // The authored witness's later row is id 52, which the generated property's + // id + 100 rule cannot produce, so it is an enumerated case. + oracleProperty({ + id: `electric.adapter-failure-policy-fixed`, + law: { + statement: `generated adapter failures reject publicly and admit no later Electric work`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `The authored rows 51 and 52, through the same driver as the generated adapter-failure property.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [ + { + published: { + id: 51, + name: `published before failure`, + stable: `stable-51`, + }, + rejected: { id: 52, name: `must not be admitted`, stable: `stable-52` }, + }, + ], + label: () => + `turns adapter rejection into a named terminal Electric persistence error`, + ...existingElectricComparison( + `electric.adapter-failure-policy-fixed`, + ({ + published, + rejected, + }: { + published: OracleRow + rejected: OracleRow + }) => + runPersistenceFailureCrashOnlyWitness( + `terminal-persistence-error-fixed`, + published, + rejected, + ), + ), + check: checkExistingElectricComparison, + }) const failurePolicyArbitraries: [ fc.Arbitrary, @@ -3086,167 +2901,281 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) - 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` }, - headers: { operation: `update` }, - } - const updateHistory = [[completeUnseenUpdate, upToDate]] - expect(recomputeCommittedRows(updateHistory, `ignore`)).toEqual([]) - expect(recomputeCommittedRows(updateHistory, `promote-complete`)).toEqual( - [[1, `complete`, `stable-1`]], - ) - const updateActual = await runTrace( - `design-unseen-update`, - `eager`, - [], - updateHistory, - ) - expect(updateActual.rows).toEqual( - recomputeCommittedRows(updateHistory, `ignore`), - ) + // Named design witnesses. Each runs authored histories through runTrace, + // the persisted and Query traces or the scheduler driver, and compares them + // with the reference reducers (or, for the HTTP transcript, with complete + // snapshots authored independently of both reducers). + async function runUnseenUpdateAndResetVisibilityWitness(): Promise { + const completeUnseenUpdate: ChangeMessage = { + key: `1`, + value: { id: 1, name: `complete`, stable: `stable-1` }, + headers: { operation: `update` }, + } + const updateHistory = [[completeUnseenUpdate, upToDate]] + expect(recomputeCommittedRows(updateHistory, `ignore`)).toEqual([]) + expect(recomputeCommittedRows(updateHistory, `promote-complete`)).toEqual([ + [1, `complete`, `stable-1`], + ]) + const updateActual = await runTrace( + `design-unseen-update`, + `eager`, + [], + updateHistory, + ) + expect(updateActual.rows).toEqual( + recomputeCommittedRows(updateHistory, `ignore`), + ) - const resetHistory = [ - [change(`insert`, 1, `committed`), upToDate], - [mustRefetch], - ] - const atomicReset = recomputedSnapshots([], resetHistory) - const immediateReset: typeof atomicReset = [atomicReset[0]!, []] - expect(atomicReset).not.toEqual(immediateReset) - const resetActual = await runTrace( - `design-reset-visibility`, - `eager`, + const resetHistory = [ + [change(`insert`, 1, `committed`), upToDate], + [mustRefetch], + ] + const atomicReset = recomputedSnapshots([], resetHistory) + const immediateReset: typeof atomicReset = [atomicReset[0]!, []] + expect(atomicReset).not.toEqual(immediateReset) + const resetActual = await runTrace( + `design-reset-visibility`, + `eager`, + [], + resetHistory, + ) + expect(resetActual.snapshots).toEqual(atomicReset) + } + async function runCallbackAtomicAndSubsetPublicationWitness(): Promise { + const callbackHistory = [ + [ + change(`insert`, 1, `before-control`), + upToDate, + change(`update`, 1, `after-control`), + ], + ] + const callbackAtomic = recomputeCommittedRows(callbackHistory) + const freezeAtControl: typeof callbackAtomic = [ + [1, `before-control`, `stable-1`], + ] + expect(callbackAtomic).toEqual([[1, `after-control`, `stable-1`]]) + expect(callbackAtomic).not.toEqual(freezeAtControl) + expect(isSdkResetFramedPartition([[upToDate, mustRefetch]])).toBe(false) + expect( + isSdkResetFramedPartition([[mustRefetch, subsetEnd, mustRefetch]]), + ).toBe(false) + + const readyPrefix = [[upToDate]] + const subsetHistory = [ + [change(`insert`, 1, `subset-publication`)], + [subsetEnd], + ] + const subsetPublishes = recomputedSnapshots(readyPrefix, subsetHistory) + const upToDateOnly: typeof subsetPublishes = [[], []] + expect(subsetPublishes).not.toEqual(upToDateOnly) + + for (const syncMode of [`eager`, `on-demand`, `progressive`] as const) { + const callbackActual = await runTrace( + `design-callback-atomic-${syncMode}`, + syncMode, [], - resetHistory, + callbackHistory, ) - expect(resetActual.snapshots).toEqual(atomicReset) - }, - ) + expect(callbackActual.rows).toEqual(callbackAtomic) - 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`), - change(`delete`, 1, `row`), - { - headers: { event: `move-out`, patterns: [{ pos: 0, value: `left` }] }, - }, - upToDate, - subsetEnd, - mustRefetch, - ] - expect(isSdkResetFramedPartition([[mustRefetch]])).toBe(true) - for (const neighbor of neighbors) { - for (const messages of [ - [neighbor, mustRefetch], - [mustRefetch, neighbor], - ]) { - expect( - isSdkResetFramedPartition([messages]), - JSON.stringify(messages), - ).toBe(false) - expect( - isSdkResetFramedPartition(messages.map((message) => [message])), - ).toBe(true) - } + const subsetActual = await runTrace( + `design-subset-publication-${syncMode}`, + syncMode, + readyPrefix, + subsetHistory, + ) + expect(subsetActual.snapshots).toEqual(subsetPublishes) + } + } + async function runSchedulerPermutationWitness(): Promise { + const events: Array = [ + `startup-promise`, + `hydration`, + `snapshot-available`, + `release-persistence-apply`, + `cleanup`, + ] + let permutationIndex = 0 + const outcomes = new Set<`resolved` | `aborted`>() + let sawAcknowledgementBeforeDurability = false + for (const order of permutations(events)) { + const currentIndex = permutationIndex++ + try { + const result = await runSchedulerPermutation( + `scheduler-permutation-${currentIndex}`, + order, + ) + outcomes.add(result.outcome) + sawAcknowledgementBeforeDurability ||= result.acknowledgedBeforeDurable + } catch (error) { + throw new Error( + `scheduler permutation ${currentIndex} failed: ${order.join(` → `)}`, + { cause: error }, + ) } - }, - ) - - oracleReplayTest(it)( - `distinguishes callback-atomic and subset publication semantics`, - async () => { - oracleRoute(`case`) - const callbackHistory = [ - [ - change(`insert`, 1, `before-control`), - upToDate, - change(`update`, 1, `after-control`), - ], - ] - const callbackAtomic = recomputeCommittedRows(callbackHistory) - const freezeAtControl: typeof callbackAtomic = [ - [1, `before-control`, `stable-1`], - ] - expect(callbackAtomic).toEqual([[1, `after-control`, `stable-1`]]) - expect(callbackAtomic).not.toEqual(freezeAtControl) - expect(isSdkResetFramedPartition([[upToDate, mustRefetch]])).toBe(false) - expect( - isSdkResetFramedPartition([[mustRefetch, subsetEnd, mustRefetch]]), - ).toBe(false) - - const readyPrefix = [[upToDate]] - const subsetHistory = [ - [change(`insert`, 1, `subset-publication`)], - [subsetEnd], - ] - const subsetPublishes = recomputedSnapshots(readyPrefix, subsetHistory) - const upToDateOnly: typeof subsetPublishes = [[], []] - expect(subsetPublishes).not.toEqual(upToDateOnly) - - for (const syncMode of [`eager`, `on-demand`, `progressive`] as const) { - const callbackActual = await runTrace( - `design-callback-atomic-${syncMode}`, - syncMode, + } + expect(outcomes).toEqual(new Set([`resolved`, `aborted`])) + expect(sawAcknowledgementBeforeDurability).toBe(true) + } + async function runAuthoredHttpTranscriptWitness(): Promise { + const { ShapeStream: RealShapeStream } = await vi.importActual<{ + ShapeStream: typeof ShapeStream + }>(`@electric-sql/client`) + const ready: Message = { + headers: { control: `up-to-date`, global_last_seen_lsn: `2` }, + } + const authored: Array>> = [ + [ + change(`insert`, 1, `old`), + { headers: { control: `up-to-date`, global_last_seen_lsn: `1` } }, + ], + [mustRefetch], + [ + change(`insert`, 2, `replacement`), + subsetEnd, + change(`insert`, 3, `after-control`), + ready, + ], + ] + // These complete snapshots are authored directly, not computed by either + // reference reducer or from any adapter result. A post-control row belongs + // to the same delivered callback's atomic publication. + const complete: TraceResult[`snapshots`] = [ + [[1, `old`, `stable-1`]], + [[1, `old`, `stable-1`]], + [ + [2, `replacement`, `stable-2`], + [3, `after-control`, `stable-3`], + ], + ] + const controller = new AbortController() + const headers = { + 'electric-handle': `authored-old`, + 'electric-offset': `1_0`, + // Without this completion header the SDK can prefetch the next chunk; + // a positional mock-response queue would not describe the live request. + 'electric-up-to-date': `true`, + 'electric-schema': JSON.stringify({ + id: { type: `int4` }, + name: { type: `text` }, + stable: { type: `text` }, + }), + } + const wire = (messages: Array>) => + JSON.stringify( + messages.map((message) => + `value` in message + ? { + ...message, + value: { ...message.value, id: String(message.value.id) }, + } + : message, + ), + ) + const responses = [ + new Response(wire(authored[0]!), { headers }), + new Response(wire(authored[1]!), { + status: 409, + headers: { 'electric-handle': `authored-new` }, + }), + new Response(wire(authored[2]!), { + headers: { + ...headers, + 'electric-handle': `authored-new`, + 'electric-offset': `2_0`, + 'electric-cursor': `1`, + }, + }), + ] + const received: Array>> = [] + const stream = new RealShapeStream({ + url: `http://test-url/v1/authored-publication-transcript`, + params: { table: `rows` }, + signal: controller.signal, + fetchClient: () => { + const response = responses.shift() + if (response) return Promise.resolve(response) + return new Promise((_resolve, reject) => { + if (controller.signal.aborted) reject(controller.signal.reason) + else + controller.signal.addEventListener( + `abort`, + () => reject(controller.signal.reason), + { once: true }, + ) + }) + }, + }) + const unsubscribe = stream.subscribe((messages) => { + received.push(structuredClone(messages)) + }) + try { + await vi.waitFor(() => + expect( + received, + JSON.stringify({ received, remainingResponses: responses.length }), + ).toHaveLength(3), + ) + expect(received).toEqual(authored) + expect(isSdkResetFramedPartition(received)).toBe(true) + // This validates this installed SDK's controlled-HTTP antecedent only. + // It is not proof that all servers or SSE streams emit this transcript. + for (const mode of [`eager`, `on-demand`, `progressive`] as const) { + const direct = await runTrace( + `authored-direct-${mode}`, + mode, [], - callbackHistory, + structuredClone(received), ) - expect(callbackActual.rows).toEqual(callbackAtomic) - - const subsetActual = await runTrace( - `design-subset-publication-${syncMode}`, - syncMode, - readyPrefix, - subsetHistory, + const persisted = await runPersistedTrace( + `authored-persisted-${mode}`, + mode, + structuredClone(received), + complete, ) - expect(subsetActual.snapshots).toEqual(subsetPublishes) + expect(direct.snapshots).toEqual(complete) + expect(persisted.snapshots).toEqual(complete) + expect(persisted.durableRows).toEqual(complete[2]) } + const query = await runQueryTrace( + `authored-query`, + structuredClone(received), + complete, + ) + expect(query.snapshots).toEqual(complete) + } finally { + unsubscribe() + controller.abort() + } + } + const designWitnesses = { + [`distinguishes plausible unseen-update and reset-visibility semantics`]: + runUnseenUpdateAndResetVisibilityWitness, + [`distinguishes callback-atomic and subset publication semantics`]: + runCallbackAtomicAndSubsetPublicationWitness, + [`settles every startup, hydration, snapshot availability, persistence-apply release, and cleanup permutation`]: + runSchedulerPermutationWitness, + [`checks an authored HTTP transcript and separate complete Query snapshots`]: + runAuthoredHttpTranscriptWitness, + } + type DesignWitness = keyof typeof designWitnesses + oracleProperty({ + id: `electric.design-witnesses`, + law: { + statement: `operational and denotational reference designs agree before checking production`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `Four authored witnesses: unseen-update and reset visibility, callback-atomic and subset publication, every scheduler permutation of five events, and one installed-SDK HTTP transcript with authored complete snapshots.`, }, - ) - - oracleReplayTest(it)( - `settles every startup, hydration, snapshot availability, persistence-apply release, and cleanup permutation`, - async () => { - oracleRoute(`case`) - const events: Array = [ - `startup-promise`, - `hydration`, - `snapshot-available`, - `release-persistence-apply`, - `cleanup`, - ] - let permutationIndex = 0 - const outcomes = new Set<`resolved` | `aborted`>() - let sawAcknowledgementBeforeDurability = false - for (const order of permutations(events)) { - const currentIndex = permutationIndex++ - try { - const result = await runSchedulerPermutation( - `scheduler-permutation-${currentIndex}`, - order, - ) - outcomes.add(result.outcome) - sawAcknowledgementBeforeDurability ||= - result.acknowledgedBeforeDurable - } catch (error) { - throw new Error( - `scheduler permutation ${currentIndex} failed: ${order.join(` → `)}`, - { cause: error }, - ) - } - } - expect(outcomes).toEqual(new Set([`resolved`, `aborted`])) - expect(sawAcknowledgementBeforeDurability).toBe(true) - }, - 30_000, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: Object.keys(designWitnesses) as Array, + label: (name) => name, + ...existingElectricComparison( + `electric.design-witnesses`, + (name: DesignWitness) => designWitnesses[name](), + ), + check: checkExistingElectricComparison, + timeout: 30_000, + }) const electricHistoryRegistration7 = electricHistoryOptions( `electric.synthetic-batch-partition`, @@ -3504,15 +3433,6 @@ describe(`Electric adapter laws`, () => { ], ] - oracleReplayTest(it).each(publicationEpochExamples)( - `reconstructs the authored publication-epoch history`, - (...args) => { - oracleRoute(`case`) - return assertPublicationEpochHistory(...args) - }, - 30_000, - ) - const electricHistoryRegistration9 = electricHistoryOptions( `electric.publication-epoch-convergence`, `Electric drivers converge with the denotational reference and model-fed Query projection across publication epochs`, @@ -3533,146 +3453,14 @@ describe(`Electric adapter laws`, () => { ...electricHistoryRegistration9.histories, runs: 20, fixedSeeds: [42714], + fixed: publicationEpochExamples.map((history, index) => ({ + name: `reconstructs the authored publication-epoch history ${index}`, + history, + })), }, check: checkExistingElectricComparison, }) - 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`) - const ready: Message = { - headers: { control: `up-to-date`, global_last_seen_lsn: `2` }, - } - const authored: Array>> = [ - [ - change(`insert`, 1, `old`), - { headers: { control: `up-to-date`, global_last_seen_lsn: `1` } }, - ], - [mustRefetch], - [ - change(`insert`, 2, `replacement`), - subsetEnd, - change(`insert`, 3, `after-control`), - ready, - ], - ] - // These complete snapshots are authored directly, not computed by either - // reference reducer or from any adapter result. A post-control row belongs - // to the same delivered callback's atomic publication. - const complete: TraceResult[`snapshots`] = [ - [[1, `old`, `stable-1`]], - [[1, `old`, `stable-1`]], - [ - [2, `replacement`, `stable-2`], - [3, `after-control`, `stable-3`], - ], - ] - const controller = new AbortController() - const headers = { - 'electric-handle': `authored-old`, - 'electric-offset': `1_0`, - // Without this completion header the SDK can prefetch the next chunk; - // a positional mock-response queue would not describe the live request. - 'electric-up-to-date': `true`, - 'electric-schema': JSON.stringify({ - id: { type: `int4` }, - name: { type: `text` }, - stable: { type: `text` }, - }), - } - const wire = (messages: Array>) => - JSON.stringify( - messages.map((message) => - `value` in message - ? { - ...message, - value: { ...message.value, id: String(message.value.id) }, - } - : message, - ), - ) - const responses = [ - new Response(wire(authored[0]!), { headers }), - new Response(wire(authored[1]!), { - status: 409, - headers: { 'electric-handle': `authored-new` }, - }), - new Response(wire(authored[2]!), { - headers: { - ...headers, - 'electric-handle': `authored-new`, - 'electric-offset': `2_0`, - 'electric-cursor': `1`, - }, - }), - ] - const received: Array>> = [] - const stream = new RealShapeStream({ - url: `http://test-url/v1/authored-publication-transcript`, - params: { table: `rows` }, - signal: controller.signal, - fetchClient: () => { - const response = responses.shift() - if (response) return Promise.resolve(response) - return new Promise((_resolve, reject) => { - if (controller.signal.aborted) reject(controller.signal.reason) - else - controller.signal.addEventListener( - `abort`, - () => reject(controller.signal.reason), - { once: true }, - ) - }) - }, - }) - const unsubscribe = stream.subscribe((messages) => { - received.push(structuredClone(messages)) - }) - try { - await vi.waitFor(() => - expect( - received, - JSON.stringify({ received, remainingResponses: responses.length }), - ).toHaveLength(3), - ) - expect(received).toEqual(authored) - expect(isSdkResetFramedPartition(received)).toBe(true) - // This validates this installed SDK's controlled-HTTP antecedent only. - // It is not proof that all servers or SSE streams emit this transcript. - for (const mode of [`eager`, `on-demand`, `progressive`] as const) { - const direct = await runTrace( - `authored-direct-${mode}`, - mode, - [], - structuredClone(received), - ) - const persisted = await runPersistedTrace( - `authored-persisted-${mode}`, - mode, - structuredClone(received), - complete, - ) - expect(direct.snapshots).toEqual(complete) - expect(persisted.snapshots).toEqual(complete) - expect(persisted.durableRows).toEqual(complete[2]) - } - const query = await runQueryTrace( - `authored-query`, - structuredClone(received), - complete, - ) - expect(query.snapshots).toEqual(complete) - } finally { - unsubscribe() - controller.abort() - } - }, - ) - const electricHistoryRegistration10 = electricHistoryOptions( `electric.invalid-resume-partition`, `generated invalid resume transitions fail under every batch partition`, @@ -4543,16 +4331,36 @@ describe(`Electric adapter laws`, () => { // The former fast-check examples include authored retry schedules outside // the mapped arbitrary's shrink domain. Keep the same shared comparison and // both former campaign executions without changing the random generator. - oracleReplayTest(it).each(retryExamples)( - `electric.retry-decision: retained fixed history %#`, - checkRetryHistory, - 60_000, - ) - oracleReplayTest(it).each(retryExamples)( - `electric.retry-decision: retained random history %#`, - checkRetryHistory, - 60_000, - ) + oracleProperty({ + id: `electric.retry-decision-fixed`, + law: { + statement: `a source that still tries stays loading, and only a give-up fails its waits`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `The authored retry schedules above, each under both former campaign names, through the same checkRetryHistory comparison as electric.retry-decision.`, + }, + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [`fixed`, `random`].flatMap((campaign) => + retryExamples.map(([history], index) => ({ + campaign, + index, + history, + })), + ), + label: ({ campaign, index }) => + `electric.retry-decision: retained ${campaign} history ${index}`, + ...existingElectricComparison( + `electric.retry-decision-fixed`, + ({ + history, + }: { + campaign: string + index: number + history: RetryHistory + }) => checkRetryHistory(history), + ), + check: checkExistingElectricComparison, + timeout: 60_000, + }) const electricHistoryRegistration12 = electricHistoryOptions( `electric.retry-decision`, `a source that still tries stays loading, and only a give-up fails its waits`, @@ -4574,28 +4382,49 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) - oracleReplayTest(it)( - `does not advance durable Electric resume metadata past a rejected row commit`, - async () => { - oracleRoute(`case`) - await runRejectedDurablePrefixWitness(`durable-prefix-fixed`, [ - `first`, - `second`, - ]) - }, - ) - - 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`], - true, - ) + // The authored witnesses keep their own collection ids, and the second + // accepts a prefix first, which the generated property does not draw. + oracleProperty({ + id: `electric.durable-prefix-fixed`, + law: { + statement: `a rejected initial Electric row commit creates no durable source prefix`, + authority: `The named independent Electric model/comparison passed to electricHistoryOptions and its adjacent contract prose.`, + limits: `Two authored row-commit histories, one rejecting the first commit and one rejecting after an accepted prefix, through the same driver as the generated durable-prefix property.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [ + { + name: `does not advance durable Electric resume metadata past a rejected row commit`, + id: `durable-prefix-fixed`, + names: [`first`, `second`], + acceptedPrefix: false, + }, + { + name: `retains an accepted Electric source prefix when the next row commit rejects`, + id: `durable-prefix-after-success`, + names: [`accepted`, `rejected`], + acceptedPrefix: true, + }, + ], + label: ({ name }) => name, + ...existingElectricComparison( + `electric.durable-prefix-fixed`, + ({ + id, + names, + acceptedPrefix, + }: { + name: string + id: string + names: Array + acceptedPrefix: boolean + }) => + acceptedPrefix + ? runRejectedDurablePrefixWitness(id, names as [string, string], true) + : runRejectedDurablePrefixWitness(id, names as [string, string]), + ), + check: checkExistingElectricComparison, + }) const durablePrefixArbitrary = fc.tuple( fc.string({ maxLength: 12 }), @@ -5034,83 +4863,6 @@ describe(`Electric adapter laws`, () => { check: checkExistingElectricComparison, }) - oracleReplayTest(it)( - `reconstructs persistence-interleaving campaigns and rejects ablated, out-of-range, and foreign histories`, - () => { - const campaigns = fc.sample(persistenceInterleavingCampaignArbitrary, { - seed: 18_530_601, - numRuns: 20, - }) - for (const campaign of campaigns) { - oracleRoute(`fixed seed`) - expect(reconstructPersistenceInterleavingCampaign(campaign)).toEqual( - campaign, - ) - oracleRoute(`calibration`) - expect(() => - reconstructPersistenceInterleavingCampaign({ - names: campaign.names, - }), - ).toThrow() - expect(() => - reconstructPersistenceInterleavingCampaign({ - histories: campaign.histories, - }), - ).toThrow() - expect(() => - reconstructPersistenceInterleavingCampaign({ - ...campaign, - histories: [`open-transaction-hydration`, `foreign`], - }), - ).toThrow() - expect(() => - reconstructPersistenceInterleavingCampaign({ - ...campaign, - names: [``, campaign.names[1]], - }), - ).toThrow() - } - }, - ) - - oracleReplayTest(it)( - `rejects named wrong answers for persistence interleavings`, - () => { - oracleRoute(`calibration`) - const names: [string, string] = [`before`, `after`] - expect(() => - expectPersistenceInterleavingObservation( - { - kind: `open-transaction-hydration`, - hydrationStartedBeforeCommit: true, - hydrationOutcomeBeforeRelease: `AbortError`, - cachedRowBeforeRelease: true, - hydrationOutcome: `fulfilled`, - publicRowsAtHydrationSettlement: [], - deliveryErrors: [`InvalidPersistedCollectionConfigError`], - publicRows: [], - durableRows: [], - status: `ready`, - publicError: undefined, - }, - names, - ), - ).toThrow() - expect(() => - expectPersistenceInterleavingObservation( - { - kind: `subset-supersedes-absence`, - publicRows: [], - durableRows: [], - status: `ready`, - publicError: undefined, - }, - names, - ), - ).toThrow() - }, - ) - const electricHistoryRegistration18 = electricHistoryOptions( persistenceInterleavingProperty, `preserves persisted Electric interleavings`, diff --git a/packages/electric-db-collection/tests/electric-recovery-durable-row-model-oracle.ts b/packages/electric-db-collection/tests/electric-recovery-durable-row-model-oracle.ts new file mode 100644 index 0000000000..fffbbe2fdb --- /dev/null +++ b/packages/electric-db-collection/tests/electric-recovery-durable-row-model-oracle.ts @@ -0,0 +1,16 @@ +// The durable row model of the recovery fixture's Map adapter: the row each +// SQLite operation leaves. electric-recovery-durable-row.test.ts checks the +// fixture against it. This module registers no tests. +import type { Item } from './electric-recovery-support' + +/** + * The Map adapter stands in for SQLite's row operations. An insert replaces + * the row; an update keeps the fields that the command omits. + */ +export function expectedDurableRow( + operation: `insert` | `update`, + previous: Item, + next: Item, +): Item { + return operation === `insert` ? next : { ...previous, ...next } +} diff --git a/packages/electric-db-collection/tests/electric-recovery-durable-row.test.ts b/packages/electric-db-collection/tests/electric-recovery-durable-row.test.ts new file mode 100644 index 0000000000..0a5a875d16 --- /dev/null +++ b/packages/electric-db-collection/tests/electric-recovery-durable-row.test.ts @@ -0,0 +1,41 @@ +// Checks the recovery fixture's Map adapter, the durable boundary of the +// Electric recovery oracle's driver, against the durable row model. It runs +// no production, so it lives outside the oracle. +import { expect, it } from 'vitest' +import { expectedDurableRow } from './electric-recovery-durable-row-model-oracle' +import { + fixture, + freshRow, + withFixtureCleanup, +} from './electric-recovery-support' + +// The Map adapter is the driver's durable boundary. It must enact SQLite's +// distinct row operations before its rows can serve as evidence for a peer +// notice. Insert replaces a same-key row; update retains omitted fields. +it.each([`insert`, `update`] as const)( + `models a same-key %s against the durable row operation`, + async (operation) => { + const f = fixture(`on-demand`) + await withFixtureCleanup(f, async () => { + const old = { ...freshRow, obsolete: `old field` } + await f.commitExternalTx({ + txId: `old`, + term: 1, + seq: 1, + rowVersion: 1, + mutations: [{ type: `insert`, key: old.id, value: old }], + }) + const next = { ...freshRow, name: `next` } + await f.commitExternalTx({ + txId: `next`, + term: 2, + seq: 1, + rowVersion: 2, + mutations: [{ type: operation, key: next.id, value: next }], + }) + expect(f.rows.get(next.id)).toEqual( + expectedDurableRow(operation, old, next), + ) + }) + }, +) 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 c418e78b89..a2ea160d5a 100644 --- a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts @@ -1,10 +1,8 @@ import { DatabaseSync } from 'node:sqlite' import { isDeepStrictEqual } from 'node:util' -import { fc } from '@fast-check/vitest' -import { beforeEach, describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, vi } from 'vitest' import { createCollection } from '@tanstack/db' import { ShapeStream } from '@electric-sql/client' -import { oracleFixedSeed, oracleRoute } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { SQLiteCorePersistenceAdapter, @@ -30,6 +28,8 @@ import { upToDate, withFixtureCleanup, } from './electric-recovery-support' +import { publicationHistoryArbitrary } from './electric-recovery-publication-grammar-oracle' +import type { PublicationHistoryCommand } from './electric-recovery-publication-grammar-oracle' import type { Message } from '@electric-sql/client' import type { PersistedTx } from '../../db-sqlite-persistence-core/src' import type { ElectricCollectionUtils } from '../src/electric' @@ -95,6 +95,13 @@ vi.mock(`@electric-sql/client`, async () => ({ }), })) +// Each test, and each enumerated case, starts with no stream subscribers and +// cleared SDK mock calls, so its subscriber and request indexes are its own. +function resetRecoveryBoundary() { + subscribers.length = 0 + vi.clearAllMocks() +} + function deferred() { let resolve!: () => void const promise = new Promise((complete) => { @@ -554,61 +561,6 @@ function expectedPostRestartObservation( } } -/** - * The Map adapter stands in for SQLite's row operations. An insert replaces - * the row; an update keeps the fields that the command omits. - */ -function expectedDurableRow( - operation: `insert` | `update`, - previous: Item, - next: Item, -): Item { - return operation === `insert` ? next : { ...previous, ...next } -} - -type PublicationHistoryCommand = { - id: number - name: string - deleted: boolean - fullReload: boolean - operation?: `insert` | `update` - obsolete?: boolean -} - -/** - * One model command pairs two production boundaries: a committed peer - * publication, then an Electric stream update. The row Map models complete - * public values; the publication marker is only a reach witness, not model - * state. No later command branches on that marker. - * - * The bounded grammar permits 1–8 pairs, keys 2–4 beside the untouched - * baseline key 1, and names of length 0–8. Key variation distinguishes - * independent rows from repeated edits; name and optional field variation - * distinguish partial update from same-key insert replacement. Deletion - * changes membership; - * fullReload requires a persisted read. A targeted notice can also require - * a read when its row is new to this Collection: the notice carries a - * patch without a completeness certificate. Public rows, rather than the - * number of reads, decide whether that route satisfies the law. Multiple - * pairs expose repeated-key histories. The authored history below - * reconstructs insert then delete with a reload. The other fixed histories - * reach both sequence-length and name/key margins. The grammar excludes a - * malformed peer transaction that lists one key as both changed and deleted; - * the publisher constructs exactly one of those lists from `deleted`. - * Arbitrary keys and longer histories remain outside this property. - */ -const publicationHistoryArbitrary = fc.array( - fc.record({ - id: fc.integer({ min: 2, max: 4 }), - name: fc.string({ maxLength: 8 }), - deleted: fc.boolean(), - fullReload: fc.boolean(), - operation: fc.constantFrom(`insert` as const, `update` as const), - obsolete: fc.boolean(), - }), - { minLength: 1, maxLength: 8 }, -) - /** * The model applies a committed peer publication to the expected rows. A * delete removes the key; an insert replaces the row; an update keeps the @@ -756,42 +708,131 @@ function expectedReplacementRows(empty: boolean): Array { return empty ? [] : [freshRow] } -describe(`persisted Electric recovery laws`, () => { - // The Map adapter is the driver's durable boundary. It must enact SQLite's - // distinct row operations before its rows can serve as evidence for a peer - // notice. Insert replaces a same-key row; update retains omitted fields. - 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` } - await f.commitExternalTx({ - txId: `old`, - term: 1, - seq: 1, - rowVersion: 1, - mutations: [{ type: `insert`, key: old.id, value: old }], - }) - const next = { ...freshRow, name: `next` } - await f.commitExternalTx({ - txId: `next`, - term: 2, - seq: 1, - rowVersion: 2, - mutations: [{ type: operation, key: next.id, value: next }], - }) - expect(f.rows.get(next.id)).toEqual( - expectedDurableRow(operation, old, next), - ) +// One invalid-resume scenario: the invalid resume's cut, then the fresh +// replacement's cuts, compared with expectedReplacementRows. +async function runInvalidResumeScenario({ + syncMode, + empty, + hydration, +}: (typeof scenarios)[number]) { + resetRecoveryBoundary() + const f = fixture(syncMode) + const gate = deferred() + await withFixtureCleanup( + f, + async () => { + f.start() + await vi.waitFor(() => expect(f.publicRows()).toEqual([oldRow])) + await vi.waitFor(() => expect(subscribers).toHaveLength(1)) + expect(vi.mocked(ShapeStream).mock.calls[0]?.[0]).toMatchObject({ + offset: `10_0`, + handle: `shape-old`, + }) + const invalidCut = f.exposures.length + f.record(`before invalid resume`) + subscribers[0]!([ + change(`delete`, { id: 1 }), + change(`update`, { id: 2, name: `partial` }), + upToDate, + ]) + f.record(`after invalid resume`) + await vi.waitFor(() => expect(f.collection.status).toBe(`error`)) + await vi.waitFor(() => + expect(f.metadata.get(`electric:resume`)).toMatchObject({ + kind: `reset`, + }), + ) + expect(f.publicRows()).toEqual([oldRow]) + expect(f.durableRows()).toEqual([oldRow]) + expectWholeRecoveryTrace(f.exposures.slice(invalidCut), [[oldRow]]) + + f.stopObserving() + await f.collection.cleanup() + f.pauseHydration(gate.promise) + const recoveryCut = f.exposures.length + f.start() + await vi.waitFor(() => expect(subscribers).toHaveLength(2)) + expect(vi.mocked(ShapeStream).mock.calls[1]?.[0]).toMatchObject({ + offset: undefined, + handle: undefined, }) + // Fresh progressive mode hydrates persisted rows only on demand. + const hydrationDone = + syncMode === `progressive` + ? Promise.resolve(f.collection._sync.loadSubset({ limit: 10 })) + : undefined + const hydrationOutcome = hydrationDone?.then( + () => undefined, + (error: unknown) => ({ error }), + ) + const awaitHydration = async () => { + const outcome = await hydrationOutcome + if (outcome) throw outcome.error + } + if (hydration === `before`) { + gate.resolve() + await awaitHydration() + await vi.waitFor(() => expect(f.publicRows()).toEqual([oldRow])) + } + f.record(`before replacement data`) + const partialCut = f.exposures.length + const expected = expectedReplacementRows(empty) + subscribers[1]!( + expected.map((row) => change(`insert`, structuredClone(row))), + ) + f.record(`after replacement data`) + subscribers[1]!([{ headers: { control: `subset-end` } }]) + f.record(`after subset completion`) + expectWholeRecoveryTrace(f.exposures.slice(partialCut), [ + hydration === `before` ? [oldRow] : [], + ]) + subscribers[1]!([upToDate]) + f.record(`after replacement commit`) + // Preserve the original hydration-after-commit cells: the final + // control is delivered before releasing the paused hydration gate. + gate.resolve() + await awaitHydration() + await vi.waitFor(() => expect(f.collection.status).toBe(`ready`)) + await vi.waitFor(() => + expect(f.metadata.get(`electric:resume`)).toMatchObject({ + kind: `resume`, + requiresTagState: false, + offset: `20_0`, + }), + ) + // The source's complete snapshot defines both results. A reset marker + // plus a fresh offset is not proof that the old materialization left. + const rowFailures: Array = [] + try { + expect(f.publicRows()).toEqual(expected) + } catch (error) { + rowFailures.push( + new Error(`Public replacement rows differ`, { cause: error }), + ) + } + try { + expect(f.durableRows()).toEqual(expected) + } catch (error) { + rowFailures.push( + new Error(`Durable replacement rows differ`, { cause: error }), + ) + } + if (rowFailures.length > 0) { + throw new AggregateError(rowFailures, `Replacement-ready row mismatch`) + } + f.record(`replacement ready`) + expectWholeRecoveryTrace(f.exposures.slice(recoveryCut), [ + [], + [oldRow], + expected, + ]) }, + gate.resolve, ) - beforeEach(() => { - subscribers.length = 0 - vi.clearAllMocks() - }) +} + +describe(`persisted Electric recovery laws`, () => { + beforeEach(resetRecoveryBoundary) /** * This matrix is the reached consumer half of the SQLite reset/resume law. @@ -810,14 +851,26 @@ describe(`persisted Electric recovery laws`, () => { * nonempty + resume + schema-reset + up-to-date-only cell preserves the * original report at https://github.com/TanStack/db/issues/1589. */ - 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`) + oracleProperty({ + id: `electric-recovery.persisted-restart`, + law: { + statement: `A persisted Electric restart couples durable resume state to its transition: the request offset and handle, complete public and durable rows, durable resume kind and readiness at the post-restart up-to-date checkpoint follow the canonical-row model.`, + authority: `The persisted-replica recovery law in this file's opening contract and expectedPostRestartObservation's independent model.`, + limits: `The finite reset/resume matrix above over the real SQLite adapter and controlled ShapeStream; no callback multiplicity, real HTTP or arbitrary external edit sequences.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: persistedRestartScenarios, + label: ({ resumeKind, transition, rowState, sourceHistory }) => + `couples ${resumeKind} state to ${transition} with ${rowState} rows and ${sourceHistory} source history`, + ...existingElectricComparison( + `electric-recovery.persisted-restart`, + async (scenario: PersistedRestartScenario) => { + const { cleanupEvidence } = await observePersistedRestart(scenario) + expect(cleanupEvidence).toContain(`passed: committed retired-stream`) + }, + ), + check: checkExistingElectricComparison, + }) const publicationHistoryExamples: Array<[Array]> = [ @@ -862,28 +915,33 @@ describe(`persisted Electric recovery laws`, () => { ], ] - it.each(publicationHistoryExamples)( - `reconstructs the authored persistence publication history`, - (commands) => { - oracleRoute(`fixed history`) - return assertPublicationHistory(commands) + // The authored histories omit optional fields that the generated record + // always draws, so the arbitrary cannot take them back. They run as their + // own enumerated cases through the same driver and comparison. + oracleProperty({ + id: `electric-recovery.publication-stream-fixed`, + law: { + statement: `Independent persistence publications and stream deltas agree with complete-row state.`, + authority: `The persisted-replica recovery law in this file's opening contract and assertPublicationHistory's independent snapshot judgment.`, + limits: `The four authored publication histories above, through the same driver and comparison as electric-recovery.publication-stream-convergence.`, }, - ) - - it(`fixed recovery corpus reaches every deletion and reload combination`, () => { - oracleRoute(`fixed seed`) - const combinations = new Set( - fc - .sample( - publicationHistoryArbitrary, - oracleFixedSeed(1659, { numRuns: 20 }), - ) - .flat() - .map(({ deleted, fullReload }) => `${deleted}:${fullReload}`), - ) - expect(combinations).toEqual( - new Set([`false:false`, `false:true`, `true:false`, `true:true`]), - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: publicationHistoryExamples.map(([commands], index) => ({ + index, + commands, + })), + label: ({ index }) => + `reconstructs the authored persistence publication history ${index}`, + ...existingElectricComparison( + `electric-recovery.publication-stream-fixed`, + ({ + commands, + }: { + index: number + commands: Array + }) => assertPublicationHistory(commands), + ), + check: checkExistingElectricComparison, }) oracleProperty({ @@ -906,126 +964,21 @@ describe(`persisted Electric recovery laws`, () => { check: checkExistingElectricComparison, }) - 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( - f, - async () => { - f.start() - await vi.waitFor(() => expect(f.publicRows()).toEqual([oldRow])) - await vi.waitFor(() => expect(subscribers).toHaveLength(1)) - expect(vi.mocked(ShapeStream).mock.calls[0]?.[0]).toMatchObject({ - offset: `10_0`, - handle: `shape-old`, - }) - const invalidCut = f.exposures.length - f.record(`before invalid resume`) - subscribers[0]!([ - change(`delete`, { id: 1 }), - change(`update`, { id: 2, name: `partial` }), - upToDate, - ]) - f.record(`after invalid resume`) - await vi.waitFor(() => expect(f.collection.status).toBe(`error`)) - await vi.waitFor(() => - expect(f.metadata.get(`electric:resume`)).toMatchObject({ - kind: `reset`, - }), - ) - expect(f.publicRows()).toEqual([oldRow]) - expect(f.durableRows()).toEqual([oldRow]) - expectWholeRecoveryTrace(f.exposures.slice(invalidCut), [[oldRow]]) - - f.stopObserving() - await f.collection.cleanup() - f.pauseHydration(gate.promise) - const recoveryCut = f.exposures.length - f.start() - await vi.waitFor(() => expect(subscribers).toHaveLength(2)) - expect(vi.mocked(ShapeStream).mock.calls[1]?.[0]).toMatchObject({ - offset: undefined, - handle: undefined, - }) - // Fresh progressive mode hydrates persisted rows only on demand. - const hydrationDone = - syncMode === `progressive` - ? Promise.resolve(f.collection._sync.loadSubset({ limit: 10 })) - : undefined - const hydrationOutcome = hydrationDone?.then( - () => undefined, - (error: unknown) => ({ error }), - ) - const awaitHydration = async () => { - const outcome = await hydrationOutcome - if (outcome) throw outcome.error - } - if (hydration === `before`) { - gate.resolve() - await awaitHydration() - await vi.waitFor(() => expect(f.publicRows()).toEqual([oldRow])) - } - f.record(`before replacement data`) - const partialCut = f.exposures.length - const expected = expectedReplacementRows(empty) - subscribers[1]!( - expected.map((row) => change(`insert`, structuredClone(row))), - ) - f.record(`after replacement data`) - subscribers[1]!([{ headers: { control: `subset-end` } }]) - f.record(`after subset completion`) - expectWholeRecoveryTrace(f.exposures.slice(partialCut), [ - hydration === `before` ? [oldRow] : [], - ]) - subscribers[1]!([upToDate]) - f.record(`after replacement commit`) - // Preserve the original hydration-after-commit cells: the final - // control is delivered before releasing the paused hydration gate. - gate.resolve() - await awaitHydration() - await vi.waitFor(() => expect(f.collection.status).toBe(`ready`)) - await vi.waitFor(() => - expect(f.metadata.get(`electric:resume`)).toMatchObject({ - kind: `resume`, - requiresTagState: false, - offset: `20_0`, - }), - ) - // The source's complete snapshot defines both results. A reset marker - // plus a fresh offset is not proof that the old materialization left. - const rowFailures: Array = [] - try { - expect(f.publicRows()).toEqual(expected) - } catch (error) { - rowFailures.push( - new Error(`Public replacement rows differ`, { cause: error }), - ) - } - try { - expect(f.durableRows()).toEqual(expected) - } catch (error) { - rowFailures.push( - new Error(`Durable replacement rows differ`, { cause: error }), - ) - } - if (rowFailures.length > 0) { - throw new AggregateError( - rowFailures, - `Replacement-ready row mismatch`, - ) - } - f.record(`replacement ready`) - expectWholeRecoveryTrace(f.exposures.slice(recoveryCut), [ - [], - [oldRow], - expected, - ]) - }, - gate.resolve, - ) + oracleProperty({ + id: `electric-recovery.invalid-resume`, + law: { + statement: `An invalid resume keeps the last complete public and durable rows and records repair debt; the fresh replacement then replaces omitted cached rows atomically, whether hydration finishes before or after the replacement commits.`, + authority: `The persisted-replica recovery law in this file's opening contract and expectedReplacementRows' complete-source model.`, + limits: `Eager and progressive sync, empty or nonempty replacement, and hydration before or after the replacement callback, over the controlled ShapeStream and Map adapter fixture.`, }, - ) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: scenarios, + label: ({ syncMode, empty, hydration }) => + `${syncMode} invalid resume replaces omitted cached rows: empty=${empty}, hydration=${hydration} callback`, + ...existingElectricComparison( + `electric-recovery.invalid-resume`, + runInvalidResumeScenario, + ), + check: checkExistingElectricComparison, + }) }) diff --git a/packages/electric-db-collection/tests/electric-recovery-publication-grammar-oracle.ts b/packages/electric-db-collection/tests/electric-recovery-publication-grammar-oracle.ts new file mode 100644 index 0000000000..ebe44da4ad --- /dev/null +++ b/packages/electric-db-collection/tests/electric-recovery-publication-grammar-oracle.ts @@ -0,0 +1,48 @@ +// The persistence publication history grammar of the Electric recovery +// oracle. electric-recovery-oracle.test.ts runs its histories through +// production; electric-recovery-publication-grammar.test.ts checks its fixed +// sample's reach. This module registers no tests. +import { fc } from '@fast-check/vitest' + +export type PublicationHistoryCommand = { + id: number + name: string + deleted: boolean + fullReload: boolean + operation?: `insert` | `update` + obsolete?: boolean +} + +/** + * One model command pairs two production boundaries: a committed peer + * publication, then an Electric stream update. The row Map models complete + * public values; the publication marker is only a reach witness, not model + * state. No later command branches on that marker. + * + * The bounded grammar permits 1–8 pairs, keys 2–4 beside the untouched + * baseline key 1, and names of length 0–8. Key variation distinguishes + * independent rows from repeated edits; name and optional field variation + * distinguish partial update from same-key insert replacement. Deletion + * changes membership; + * fullReload requires a persisted read. A targeted notice can also require + * a read when its row is new to this Collection: the notice carries a + * patch without a completeness certificate. Public rows, rather than the + * number of reads, decide whether that route satisfies the law. Multiple + * pairs expose repeated-key histories. The authored history below + * reconstructs insert then delete with a reload. The other fixed histories + * reach both sequence-length and name/key margins. The grammar excludes a + * malformed peer transaction that lists one key as both changed and deleted; + * the publisher constructs exactly one of those lists from `deleted`. + * Arbitrary keys and longer histories remain outside this property. + */ +export const publicationHistoryArbitrary = fc.array( + fc.record({ + id: fc.integer({ min: 2, max: 4 }), + name: fc.string({ maxLength: 8 }), + deleted: fc.boolean(), + fullReload: fc.boolean(), + operation: fc.constantFrom(`insert` as const, `update` as const), + obsolete: fc.boolean(), + }), + { minLength: 1, maxLength: 8 }, +) diff --git a/packages/electric-db-collection/tests/electric-recovery-publication-grammar.test.ts b/packages/electric-db-collection/tests/electric-recovery-publication-grammar.test.ts new file mode 100644 index 0000000000..62c1e20c27 --- /dev/null +++ b/packages/electric-db-collection/tests/electric-recovery-publication-grammar.test.ts @@ -0,0 +1,22 @@ +// Checks that the recovery oracle's fixed-seed publication grammar sample +// reaches every deletion and reload combination. It samples the grammar and +// runs no production, so it lives outside the oracle. +import { fc } from '@fast-check/vitest' +import { expect, it } from 'vitest' +import { oracleFixedSeed } from '../../db/tests/oracle-config' +import { publicationHistoryArbitrary } from './electric-recovery-publication-grammar-oracle' + +it(`fixed recovery corpus reaches every deletion and reload combination`, () => { + const combinations = new Set( + fc + .sample( + publicationHistoryArbitrary, + oracleFixedSeed(1659, { numRuns: 20 }), + ) + .flat() + .map(({ deleted, fullReload }) => `${deleted}:${fullReload}`), + ) + expect(combinations).toEqual( + new Set([`false:false`, `false:true`, `true:false`, `true:true`]), + ) +}) 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 8cb0eaed57..f4403678a9 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 @@ -1,5 +1,5 @@ import fc from 'fast-check' -import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, expect, vi } from 'vitest' import { IR, createCollection, @@ -9,17 +9,16 @@ import { import { ShapeStream } from '@electric-sql/client' import { persistedCollectionOptions } from '../../db-sqlite-persistence-core/src' import { electricCollectionOptions } from '../src/electric' -import { - oracleReplayRequested, - oracleReplayTest, - oracleRoute, - oracleRuns, -} from '../../db/tests/oracle-config' +import { oracleReplayRequested, oracleRuns } from '../../db/tests/oracle-config' import { oracleProperty, violation as oracleViolation, pass, } from '../../db/tests/oracle-property' +import { + recordAssertionJudgment, + rejectAt, +} from '../../db/tests/oracle-assertion-adapter' import { checkExistingElectricComparison, existingElectricComparison, @@ -128,8 +127,6 @@ vi.mock(`@electric-sql/client`, async (importOriginal) => { // TANSTACK_DB_ORACLE_SEED=20260917 TANSTACK_DB_ORACLE_PATH=0 \ // TANSTACK_DB_ORACLE_PROPERTY=electric.sdk-snapshot-delivery \ // pnpm --dir packages/electric-db-collection exec vitest run tests/electric-sdk-delivery-oracle.property.test.ts -// Ordinary tests stand aside during any path replay, which runs one property. -const fixedCase = oracleReplayTest(it) // TANSTACK_DB_ELECTRIC_SDK_DELIVERY_FAULT is a fault-injection switch, not a // replay variable. It stays readable during replays. const replayFault = process.env.TANSTACK_DB_ELECTRIC_SDK_DELIVERY_FAULT @@ -825,78 +822,162 @@ oracleProperty({ timeout: campaignTimeoutMs, }) -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`), - }) -}) +/** Whether a calibration's old Vitest matchers accept a rejection. */ +function matches(assertions: () => void): boolean { + try { + assertions() + return true + } catch { + return false + } +} +const asError = (failure: unknown): Error => + failure instanceof Error ? failure : new Error(String(failure)) +/** A rejection that the old matchers did not accept stays a driver error: a + * new, unmarked error, so no comparison checkpoint inside it counts. */ +const unaccepted = (mutant: string, failure: unknown): Error => + new Error( + `${mutant} failed in a way that its calibration does not accept: ${String(failure)}`, + ) +const rejectionOf = (run: Promise): Promise => + run.then( + () => undefined, + (error: unknown) => error ?? new Error(String(error)), + ) -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 - snapshotViolation.reset() - try { - let primary: unknown - try { - await snapshotViolation.check({ width: 2, name: `payload` }, () => - checkSnapshots(2, `payload`, true, cleanupFailure), - ) - } catch (error) { - primary = snapshotViolation.report(error) - } - expect(primary).toMatchObject({ - name: `AssertionError`, - message: expect.stringContaining(`snapshot 0 applied rows`), - }) - expect((primary as Error).message).toContain( - `Original violation: AssertionError:snapshot 0 applied rows`, - ) - expect(warning.mock.calls).toEqual([ - [ - `Electric oracle cleanup failed after a primary error: injected snapshot cleanup`, - cleanupFailure, - ], - ]) - expect(ShapeStream.prototype.subscribe).toBe(subscribe) - } finally { - warning.mockRestore() - } +// The snapshot calibrations below corrupt exactly the two-row `payload` +// history with no retries, which the generated grammar cannot take back (it +// always draws live attempts), so it runs as its own enumerated case through +// the same checkSnapshots driver and comparison. +type SnapshotCalibrationPlan = { history: { width: number; name: string } } +const SNAPSHOT_FIXED_LAW = `electric.sdk-snapshot-delivery-fixed` +oracleProperty({ + id: `electric.sdk-snapshot-delivery-fixed`, + law: { + statement: `Installed-SDK snapshot deliveries agree with the independent overlapping source.`, + authority: `The snapshot-delivery contract and independent source/health judgments stated beside checkSnapshots.`, + limits: `The two-row payload history without retries, which the snapshot calibrations corrupt; it runs through the same driver as electric.sdk-snapshot-delivery.`, }, -) - -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 - const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) - try { - await expect( - checkSnapshots(2, `payload`, false, undefined, { - primary, - cleanup: secondary, + owns: [`src/electric.ts#electricCollectionOptions`], + histories: [{ width: 2, name: `payload` }], + label: ({ width, name }) => `${width} rows named ${name}, no retries`, + ...existingElectricComparison( + SNAPSHOT_FIXED_LAW, + ({ width, name }: { width: number; name: string }) => + checkSnapshots(width, name), + ), + check: checkExistingElectricComparison, + calibrations: [ + { + name: `detects dropped snapshot rows`, + law: SNAPSHOT_FIXED_LAW, + driver: (plan: SnapshotCalibrationPlan, observe) => + recordAssertionJudgment(SNAPSHOT_FIXED_LAW, plan, observe, async () => { + const { width, name } = plan.history + const failure = await rejectionOf(checkSnapshots(width, name, true)) + if (failure === undefined) return + // Exactly the old matcher. + if ( + matches(() => + expect(failure).toMatchObject({ + name: `AssertionError`, + message: expect.stringContaining(`snapshot 0 applied rows`), + }), + ) + ) + rejectAt(`dropped snapshot rows`, asError(failure)) + throw unaccepted(`the dropped-rows mutant`, failure) }), - ).rejects.toBe(primary) - expect(warning.mock.calls).toEqual([ - [ - `Electric oracle cleanup failed after a primary error: snapshot HTTP provider during setup`, - secondary, - ], - ]) - expect(ShapeStream.prototype.subscribe).toBe(subscribe) - } finally { - warning.mockRestore() - } - }, -) + }, + { + name: `retains the snapshot law and checkpoint when cleanup also fails`, + law: SNAPSHOT_FIXED_LAW, + driver: (plan: SnapshotCalibrationPlan, observe) => + recordAssertionJudgment(SNAPSHOT_FIXED_LAW, plan, observe, async () => { + const { width, name } = plan.history + const cleanupFailure = new Error(`injected cleanup failure`) + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + const subscribe = ShapeStream.prototype.subscribe + snapshotViolation.reset() + try { + let primary: unknown + try { + await snapshotViolation.check({ width, name }, () => + checkSnapshots(width, name, true, cleanupFailure), + ) + } catch (error) { + primary = snapshotViolation.report(error) + } + if (primary === undefined) return + // Exactly the old matchers: the primary law and checkpoint survive, + // the cleanup failure is reported once, and the SDK is restored. + if ( + matches(() => { + expect(primary).toMatchObject({ + name: `AssertionError`, + message: expect.stringContaining(`snapshot 0 applied rows`), + }) + expect((primary as Error).message).toContain( + `Original violation: AssertionError:snapshot 0 applied rows`, + ) + expect(warning.mock.calls).toEqual([ + [ + `Electric oracle cleanup failed after a primary error: injected snapshot cleanup`, + cleanupFailure, + ], + ]) + expect(ShapeStream.prototype.subscribe).toBe(subscribe) + }) + ) + rejectAt(`snapshot cleanup failure`, asError(primary)) + throw unaccepted(`the cleanup-failure mutant`, primary) + } finally { + warning.mockRestore() + } + }), + }, + { + name: `restores the SDK setup resources after construction fails`, + law: SNAPSHOT_FIXED_LAW, + driver: (plan: SnapshotCalibrationPlan, observe) => + recordAssertionJudgment(SNAPSHOT_FIXED_LAW, plan, observe, async () => { + const { width, name } = plan.history + const primary = new Error(`collection construction failed`) + const secondary = new Error(`provider release failed`) + const subscribe = ShapeStream.prototype.subscribe + const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) + try { + const failure = await rejectionOf( + checkSnapshots(width, name, false, undefined, { + primary, + cleanup: secondary, + }), + ) + if (failure === undefined) return + // Exactly the old matchers: the construction failure stays primary, + // the release failure is reported, and the SDK is restored. + if ( + matches(() => { + expect(failure).toBe(primary) + expect(warning.mock.calls).toEqual([ + [ + `Electric oracle cleanup failed after a primary error: snapshot HTTP provider during setup`, + secondary, + ], + ]) + expect(ShapeStream.prototype.subscribe).toBe(subscribe) + }) + ) + rejectAt(`setup failure cleanup`, asError(failure)) + throw unaccepted(`the setup-failure mutant`, failure) + } finally { + warning.mockRestore() + } + }), + }, + ], + timeout: campaignTimeoutMs, +}) async function checkMembership( leftWidth: number, @@ -1084,6 +1165,8 @@ oracleProperty({ arbitrary: membershipHistoryArbitrary, runs: 12, fixedSeeds: [20260918], + // The dropped move-in calibration corrupts this smallest history. + fixed: [{ name: `one left, one right, one cycle`, history: [1, 1, 1] }], }, ...existingElectricComparison( `electric.sdk-dnf-membership`, @@ -1093,17 +1176,39 @@ oracleProperty({ ), ), check: checkExistingElectricComparison, + calibrations: [ + { + name: `detects dropped move-in at its visibility checkpoint`, + law: `electric.sdk-dnf-membership`, + driver: (plan: { history: [number, number, number] }, observe) => + recordAssertionJudgment( + `electric.sdk-dnf-membership`, + plan, + observe, + async () => { + const [left, right, cycles] = plan.history + const failure = await rejectionOf( + checkMembership(left, right, cycles, true), + ) + if (failure === undefined) return + // Exactly the old matcher. + if ( + matches(() => + expect(failure).toMatchObject({ + name: `AssertionError`, + message: expect.stringContaining(`after silent move-in`), + }), + ) + ) + rejectAt(`dropped move-in`, asError(failure)) + throw unaccepted(`the dropped move-in mutant`, failure) + }, + ), + }, + ], timeout: campaignTimeoutMs, }) -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`), - }) -}) - /** * # The retry-decision law through the installed Electric client * @@ -1346,14 +1451,25 @@ async function runClientRetryCell( ]) } -fixedCase.each(clientRetryCells)( - `installed client: %s in %s mode with an onError %s follows the retry-decision model`, - (outcome, mode, decision) => { - oracleRoute(`case`) - return runClientRetryCell(outcome, mode, decision) +oracleProperty({ + id: `electric.sdk-client-retry`, + law: { + statement: `The installed Electric client's retry, give-up and recovery outcomes follow the retry-decision model at every checkpoint.`, + authority: `The retry-decision law and model in electric-retry-decision-model.ts, received through the installed client as stated above.`, + limits: `The eighteen outcome, sync-mode and onError-decision cells above; rejection causes are compared only as rejections here.`, }, - 60_000, -) + owns: [`src/electric.ts#electricCollectionOptions`], + histories: clientRetryCells, + label: ([outcome, mode, decision]) => + `installed client: ${outcome} in ${mode} mode with an onError ${decision} follows the retry-decision model`, + ...existingElectricComparison( + `electric.sdk-client-retry`, + ([outcome, mode, decision]: (typeof clientRetryCells)[number]) => + runClientRetryCell(outcome, mode, decision), + ), + check: checkExistingElectricComparison, + timeout: 60_000, +}) /** * A managed cache claim can expire while an installed SDK snapshot request is @@ -1708,10 +1824,6 @@ async function checkManagedRotation(cuts: ReadonlyArray) { ]) } -fixedCase(`discards an old SDK snapshot after managed cache rotation`, () => { - oracleRoute(`case`) - return checkManagedRotation([`after-snapshots`]) -}) const rotationViolation = sameViolation() oracleProperty({ id: `electric.sdk-rotation-release`, @@ -1732,6 +1844,12 @@ oracleProperty({ ), runs: 40, fixedSeeds: [20261026], + fixed: [ + { + name: `discards an old SDK snapshot after managed cache rotation`, + history: [`after-snapshots`], + }, + ], }, ...existingElectricComparison( `electric.sdk-rotation-release`, @@ -1999,20 +2117,23 @@ async function checkSourceRelease(history: ReleaseHistory): Promise { // Replay the smallest incomplete deliveries and their terminal neighbors in // every mode through the same driver and cleanup comparison as generation. -fixedCase.each([`eager`, `on-demand`, `progressive`] as const)( - `releases listeners after incomplete delivery in %s mode`, - async (mode) => { - oracleRoute(`case`) - for (const answer of [`row`, `reset`] as const) - for (const end of [ +const incompleteDeliveries = ( + [`eager`, `on-demand`, `progressive`] as const +).flatMap((mode) => + ([`row`, `reset`] as const).flatMap((answer) => + ( + [ `open`, `give-up`, `abort`, `deciding-retry`, `deciding-give-up`, - ] as const) - await checkSourceRelease({ mode, runs: [{ answers: [answer], end }] }) - }, + ] as const + ).map((end) => ({ + name: `releases listeners after incomplete delivery in ${mode} mode: ${answer} answer, ${end} end`, + history: { mode, runs: [{ answers: [answer], end }] }, + })), + ), ) const releaseViolation = sameViolation() @@ -2056,6 +2177,7 @@ oracleProperty({ arbitrary: releaseHistoryArbitrary, runs: 40, fixedSeeds: [20261024], + fixed: incompleteDeliveries, }, ...existingElectricComparison( `electric.sdk-source-release`, diff --git a/packages/electric-db-collection/tests/pg-serializer-array-controls.ts b/packages/electric-db-collection/tests/pg-serializer-array-controls.ts new file mode 100644 index 0000000000..fbb3198344 --- /dev/null +++ b/packages/electric-db-collection/tests/pg-serializer-array-controls.ts @@ -0,0 +1,39 @@ +// Named finite-array controls: each input and the PostgreSQL array text that +// the independent decoder must read back as it. The serializer oracle runs +// each input through the real serializer; pg-serializer-calibration.test.ts +// decodes the hand-written outputs. +import type { FiniteArrayValue } from './pg-serializer-finite-decoder' + +export const validArrays: Array<{ + name: string + input: Array + output: string +}> = [ + { name: `empty`, input: [], output: `{}` }, + { name: `numbers`, input: [1, 2, -3, 1e21], output: `{1,2,-3,1e+21}` }, + { + name: `booleans in order`, + input: [false, true, false], + output: `{false,true,false}`, + }, + { + name: `nullish multiplicity`, + input: [null, undefined], + output: `{NULL,NULL}`, + }, + { + name: `empty and NULL text`, + input: [``, `NULL`, `null`], + output: `{"","NULL","null"}`, + }, + { + name: `delimiters and escapes`, + input: [`a,b`, `{z}`, `a"b`, `c\\d`], + output: `{"a,b","{z}","a\\"b","c\\\\d"}`, + }, + { + name: `whitespace and typed-looking text`, + input: [` leading `, `line\nbreak`, `0`, `true`], + output: `{" leading ","line\nbreak","0","true"}`, + }, +] diff --git a/packages/electric-db-collection/tests/pg-serializer-calibration.test.ts b/packages/electric-db-collection/tests/pg-serializer-calibration.test.ts index 58a34d1ded..f66a217f45 100644 --- a/packages/electric-db-collection/tests/pg-serializer-calibration.test.ts +++ b/packages/electric-db-collection/tests/pg-serializer-calibration.test.ts @@ -6,10 +6,12 @@ import { oracleRoute, oracleRuns, } from '../../db/tests/oracle-config' +import { validArrays } from './pg-serializer-array-controls' import { expectFiniteArrayContents, omissionCalibrationOptions, } from './pg-serializer-property-oracle' +import type { FiniteArrayValue } from './pg-serializer-finite-decoder' oracleReplayTest(it)( `shrinks and exactly replays omitted nonempty arrays`, @@ -36,3 +38,64 @@ oracleReplayTest(it)( expect(replay.errorInstance).toMatchObject({ name: `AssertionError` }) }, ) + +// The independent decoder reads each named control's hand-written output back +// as its input; the serializer oracle runs the same inputs through production. +oracleReplayTest(it).each(validArrays)( + `decodes the independent $name control`, + ({ input, output }) => { + expectFiniteArrayContents(input, output) + }, +) + +const corruptArrays: Array<{ + name: string + input: Array + output: string +}> = [ + { name: `all booleans omitted`, input: [true], output: `{}` }, + { name: `one boolean omitted`, input: [true, false], output: `{true}` }, + { + name: `boolean duplicated`, + input: [true, false], + output: `{true,false,false}`, + }, + { + name: `booleans reordered`, + input: [true, false], + output: `{false,true}`, + }, + { name: `boolean replaced`, input: [true, false], output: `{true,true}` }, + { + name: `nullish elements omitted`, + input: [null, undefined], + output: `{}`, + }, + { name: `NULL replaced with text`, input: [null], output: `{"NULL"}` }, + { name: `text replaced with NULL`, input: [`NULL`], output: `{NULL}` }, + { name: `empty text omitted`, input: [``], output: `{}` }, + { + name: `strings reordered`, + input: [`left`, `right`], + output: `{"right","left"}`, + }, + { + name: `one required escape omitted`, + input: [`a"b`, `c\\d`], + output: `{"a\\"b","cd"}`, + }, + { + name: `unrelated escaped content`, + input: [`a"b`], + output: `{"unrelated\\"text"}`, + }, + { name: `numeric suffix`, input: [1.25], output: `{1.25junk}` }, +] +oracleReplayTest(it).each(corruptArrays)( + `rejects $name`, + ({ input, output }) => { + // 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() + }, +) 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 adac89542c..03688e0645 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 { describe, expect } from 'vitest' import { fc } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' -import { oracleReplayTest, oracleRoute } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' +import { validArrays } from './pg-serializer-array-controls' +import { + checkExistingElectricComparison, + existingElectricComparison, +} from './electric-property-oracle' import { parseFiniteNumber } from './pg-serializer-finite-decoder' import { expectFiniteArrayContents, @@ -9,14 +14,14 @@ import { } from './pg-serializer-property-oracle' import type { FiniteArrayValue } from './pg-serializer-finite-decoder' +type ArrayControl = { name: string; input: Array } + // Replay one named law with TANSTACK_DB_ORACLE_SEED=, // TANSTACK_DB_ORACLE_PATH=, and // TANSTACK_DB_ORACLE_PROPERTY=. Example: // TANSTACK_DB_ORACLE_SEED=1 TANSTACK_DB_ORACLE_PATH=0 \ // TANSTACK_DB_ORACLE_PROPERTY=pg-serializer.mixed-array-contents \ // pnpm exec vitest run tests/pg-serializer-oracle.property.test.ts -// Examples stand aside during any path replay, which runs one property. -const example = oracleReplayTest(it) /** * # Does PostgreSQL text serialization preserve the supported value domain? @@ -54,86 +59,82 @@ const example = oracleReplayTest(it) describe(`pg-serializer property-based tests`, () => { describe(`string serialization`, () => { - serializerProp(`pg-serializer.string-passthrough`, [fc.string()])( - `strings pass through unchanged`, - (str) => { - expect(serialize(str)).toBe(str) - }, - ) + serializerProp(`pg-serializer.string-passthrough`, [fc.string()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`strings pass through unchanged`, (str) => { + expect(serialize(str)).toBe(str) + }) - serializerProp(`pg-serializer.string-idempotent`, [fc.string()])( - `strings are idempotent`, - (str) => { - // serialize(serialize(str)) should equal serialize(str) for strings - const once = serialize(str) - const twice = serialize(once) - expect(twice).toBe(once) - }, - ) + serializerProp(`pg-serializer.string-idempotent`, [fc.string()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`strings are idempotent`, (str) => { + // serialize(serialize(str)) should equal serialize(str) for strings + const once = serialize(str) + const twice = serialize(once) + expect(twice).toBe(once) + }) }) describe(`number serialization`, () => { - serializerProp(`pg-serializer.integer-round-trip`, [fc.integer()])( - `integers round-trip through strict numeric parsing`, - (n) => { - const serialized = serialize(n) - expect(parseFiniteNumber(serialized)).toBe(n) - }, - ) + serializerProp(`pg-serializer.integer-round-trip`, [fc.integer()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`integers round-trip through strict numeric parsing`, (n) => { + const serialized = serialize(n) + expect(parseFiniteNumber(serialized)).toBe(n) + }) - serializerProp(`pg-serializer.double-round-trip`, [ - fc - .double({ noNaN: true, noDefaultInfinity: true }) - .filter((n) => !Object.is(n, -0)), - ])(`finite doubles round-trip through strict numeric parsing`, (n) => { + serializerProp( + `pg-serializer.double-round-trip`, + [ + fc + .double({ noNaN: true, noDefaultInfinity: true }) + .filter((n) => !Object.is(n, -0)), + ], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`finite doubles round-trip through strict numeric parsing`, (n) => { // Number-to-string conversion loses -0's sign; parsing "-0" does not. const serialized = serialize(n) expect(parseFiniteNumber(serialized)).toBe(n) }) - serializerProp(`pg-serializer.integer-text`, [fc.integer()])( - `integers produce numeric strings`, - (n) => { - const serialized = serialize(n) - expect(serialized).toMatch(/^-?\d+$/) - }, - ) + serializerProp(`pg-serializer.integer-text`, [fc.integer()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`integers produce numeric strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) }) describe(`bigint serialization`, () => { - serializerProp(`pg-serializer.bigint-round-trip`, [fc.bigInt()])( - `bigints round-trip through BigInt parsing`, - (n) => { - const serialized = serialize(n) - expect(BigInt(serialized)).toBe(n) - }, - ) + serializerProp(`pg-serializer.bigint-round-trip`, [fc.bigInt()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`bigints round-trip through BigInt parsing`, (n) => { + const serialized = serialize(n) + expect(BigInt(serialized)).toBe(n) + }) - serializerProp(`pg-serializer.bigint-text`, [fc.bigInt()])( - `bigints produce integer strings`, - (n) => { - const serialized = serialize(n) - expect(serialized).toMatch(/^-?\d+$/) - }, - ) + serializerProp(`pg-serializer.bigint-text`, [fc.bigInt()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`bigints produce integer strings`, (n) => { + const serialized = serialize(n) + expect(serialized).toMatch(/^-?\d+$/) + }) }) describe(`boolean serialization`, () => { - serializerProp(`pg-serializer.boolean-tokens`, [fc.boolean()])( - `booleans serialize to 'true' or 'false'`, - (b) => { - const serialized = serialize(b) - expect(serialized).toBe(b ? `true` : `false`) - }, - ) + serializerProp(`pg-serializer.boolean-tokens`, [fc.boolean()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`booleans serialize to 'true' or 'false'`, (b) => { + const serialized = serialize(b) + expect(serialized).toBe(b ? `true` : `false`) + }) - serializerProp(`pg-serializer.boolean-round-trip`, [fc.boolean()])( - `booleans round-trip through string comparison`, - (b) => { - const serialized = serialize(b) - expect(serialized === `true`).toBe(b) - }, - ) + serializerProp(`pg-serializer.boolean-round-trip`, [fc.boolean()], { + owns: [`src/pg-serializer.ts#serialize`], + })(`booleans round-trip through string comparison`, (b) => { + const serialized = serialize(b) + expect(serialized === `true`).toBe(b) + }) }) describe(`date serialization`, () => { @@ -144,76 +145,91 @@ describe(`pg-serializer property-based tests`, () => { max: new Date(`9999-12-31T23:59:59.999Z`), }) - serializerProp(`pg-serializer.date-iso`, [arbitraryBoundedDate])( - `dates produce valid ISO strings`, - (date) => { - const serialized = serialize(date) - expect(serialized).toMatch( - /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, - ) - }, - ) + serializerProp(`pg-serializer.date-iso`, [arbitraryBoundedDate], { + owns: [`src/pg-serializer.ts#serialize`], + })(`dates produce valid ISO strings`, (date) => { + const serialized = serialize(date) + expect(serialized).toMatch( + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, + ) + }) - serializerProp(`pg-serializer.date-round-trip`, [arbitraryBoundedDate])( - `dates round-trip through Date parsing`, - (date) => { - const serialized = serialize(date) - const parsed = new Date(serialized) - expect(parsed.getTime()).toBe(date.getTime()) - }, - ) + serializerProp(`pg-serializer.date-round-trip`, [arbitraryBoundedDate], { + owns: [`src/pg-serializer.ts#serialize`], + })(`dates round-trip through Date parsing`, (date) => { + const serialized = serialize(date) + const parsed = new Date(serialized) + expect(parsed.getTime()).toBe(date.getTime()) + }) }) describe(`array serialization`, () => { - example(`retains every element at the ten-element array bound`, () => { - const input = Array.from({ length: 10 }, (_, index) => index) - expectFiniteArrayContents(input, serialize(input)) - }) - - serializerProp(`pg-serializer.integer-array-format`, [ - fc.array(fc.integer(), { maxLength: 10 }), - ])(`integer arrays produce Postgres array format`, (arr) => { + serializerProp( + `pg-serializer.integer-array-format`, + [fc.array(fc.integer(), { maxLength: 10 })], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`integer arrays produce Postgres array format`, (arr) => { const serialized = serialize(arr) expect(serialized).toMatch(/^\{.*\}$/) }) - serializerProp(`pg-serializer.integer-array-contents`, [ - fc.array(fc.integer(), { maxLength: 10 }), - ])(`integer arrays can be parsed back`, (arr) => { + serializerProp( + `pg-serializer.integer-array-contents`, + [fc.array(fc.integer(), { maxLength: 10 })], + { + owns: [`src/pg-serializer.ts#serialize`], + fixed: [ + { + name: `retains every element at the ten-element array bound`, + history: Array.from({ length: 10 }, (_, index) => index), + }, + ], + }, + )(`integer arrays can be parsed back`, (arr) => { const serialized = serialize(arr) expectFiniteArrayContents(arr, serialized) }) - serializerProp(`pg-serializer.boolean-array-contents`, [ - fc.array(fc.boolean(), { maxLength: 10 }), - ])(`boolean arrays serialize correctly`, (arr) => { + serializerProp( + `pg-serializer.boolean-array-contents`, + [fc.array(fc.boolean(), { maxLength: 10 })], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`boolean arrays serialize correctly`, (arr) => { const serialized = serialize(arr) expect(serialized).toMatch(/^\{.*\}$/) expectFiniteArrayContents(arr, serialized) }) - serializerProp(`pg-serializer.nullish-array-contents`, [ - fc.array(fc.constantFrom(null, undefined), { maxLength: 5 }), - ])(`arrays with null/undefined serialize to NULL`, (arr) => { + serializerProp( + `pg-serializer.nullish-array-contents`, + [fc.array(fc.constantFrom(null, undefined), { maxLength: 5 })], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`arrays with null/undefined serialize to NULL`, (arr) => { const serialized = serialize(arr) expectFiniteArrayContents(arr, serialized) }) }) describe(`string array escaping`, () => { - serializerProp(`pg-serializer.string-array-contents`, [ - fc.array(fc.string(), { maxLength: 5 }), - ])(`string arrays are properly quoted`, (arr) => { + serializerProp( + `pg-serializer.string-array-contents`, + [fc.array(fc.string(), { maxLength: 5 })], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`string arrays are properly quoted`, (arr) => { const serialized = serialize(arr) expect(serialized).toMatch(/^\{.*\}$/) expectFiniteArrayContents(arr, serialized) }) - serializerProp(`pg-serializer.string-escaping`, [ - fc - .tuple(fc.string(), fc.constantFrom(`"`, `\\`), fc.string()) - .map((parts) => parts.join(``)), - ])(`strings with special chars are escaped`, (str) => { + serializerProp( + `pg-serializer.string-escaping`, + [ + fc + .tuple(fc.string(), fc.constantFrom(`"`, `\\`), fc.string()) + .map((parts) => parts.join(``)), + ], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`strings with special chars are escaped`, (str) => { const serialized = serialize([str]) // Should contain escaped quotes or backslashes if (str.includes(`"`)) { @@ -227,23 +243,29 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`consistency properties`, () => { - serializerProp(`pg-serializer.scalar-determinism`, [ - fc.oneof( - fc.string(), - fc.integer(), - fc.boolean(), - fc.constant(null), - fc.date({ noInvalidDate: true }), - ), - ])(`serialize is deterministic`, (val) => { + serializerProp( + `pg-serializer.scalar-determinism`, + [ + fc.oneof( + fc.string(), + fc.integer(), + fc.boolean(), + fc.constant(null), + fc.date({ noInvalidDate: true }), + ), + ], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`serialize is deterministic`, (val) => { const first = serialize(val) const second = serialize(val) expect(first).toBe(second) }) - serializerProp(`pg-serializer.array-determinism`, [ - fc.array(fc.integer(), { maxLength: 10 }), - ])(`array serialization is deterministic`, (arr) => { + serializerProp( + `pg-serializer.array-determinism`, + [fc.array(fc.integer(), { maxLength: 10 })], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`array serialization is deterministic`, (arr) => { const first = serialize(arr) const second = serialize(arr) expect(first).toBe(second) @@ -251,112 +273,47 @@ describe(`pg-serializer property-based tests`, () => { }) describe(`finite scalar and array checker calibration`, () => { - const validArrays: Array<{ - name: string - input: Array - output: string - }> = [ - { name: `empty`, input: [], output: `{}` }, - { name: `numbers`, input: [1, 2, -3, 1e21], output: `{1,2,-3,1e+21}` }, - { - name: `booleans in order`, - input: [false, true, false], - output: `{false,true,false}`, - }, - { - name: `nullish multiplicity`, - input: [null, undefined], - output: `{NULL,NULL}`, - }, - { - name: `empty and NULL text`, - input: [``, `NULL`, `null`], - output: `{"","NULL","null"}`, - }, - { - name: `delimiters and escapes`, - input: [`a,b`, `{z}`, `a"b`, `c\\d`], - output: `{"a,b","{z}","a\\"b","c\\\\d"}`, - }, - { - name: `whitespace and typed-looking text`, - input: [` leading `, `line\nbreak`, `0`, `true`], - output: `{" leading ","line\nbreak","0","true"}`, - }, - ] - example.each(validArrays)( - `decodes the independent $name control and the real serializer`, - ({ input, output }) => { - oracleRoute(`case`) - expectFiniteArrayContents(input, output) - expectFiniteArrayContents(input, serialize(input)) - }, - ) - - const corruptArrays: Array<{ - name: string - input: Array - output: string - }> = [ - { name: `all booleans omitted`, input: [true], output: `{}` }, - { name: `one boolean omitted`, input: [true, false], output: `{true}` }, - { - name: `boolean duplicated`, - input: [true, false], - output: `{true,false,false}`, - }, - { - name: `booleans reordered`, - input: [true, false], - output: `{false,true}`, - }, - { name: `boolean replaced`, input: [true, false], output: `{true,true}` }, - { - name: `nullish elements omitted`, - input: [null, undefined], - output: `{}`, - }, - { name: `NULL replaced with text`, input: [null], output: `{"NULL"}` }, - { name: `text replaced with NULL`, input: [`NULL`], output: `{NULL}` }, - { name: `empty text omitted`, input: [``], output: `{}` }, - { - name: `strings reordered`, - input: [`left`, `right`], - output: `{"right","left"}`, - }, - { - name: `one required escape omitted`, - input: [`a"b`, `c\\d`], - output: `{"a\\"b","cd"}`, - }, - { - name: `unrelated escaped content`, - input: [`a"b`], - output: `{"unrelated\\"text"}`, - }, - { 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() + serializerProp( + `pg-serializer.mixed-array-contents`, + [ + fc.array( + fc.oneof( + fc.string(), + fc + .double({ noNaN: true, noDefaultInfinity: true }) + .filter((value) => !Object.is(value, -0)), + fc.boolean(), + fc.constantFrom(null, undefined), + ), + { maxLength: 10 }, + ), + ], + { owns: [`src/pg-serializer.ts#serialize`] }, + )(`mixed finite arrays retain ordered typed contents`, (input) => { + expectFiniteArrayContents(input, serialize(input)) }) - serializerProp(`pg-serializer.mixed-array-contents`, [ - fc.array( - fc.oneof( - fc.string(), - fc - .double({ noNaN: true, noDefaultInfinity: true }) - .filter((value) => !Object.is(value, -0)), - fc.boolean(), - fc.constantFrom(null, undefined), - ), - { maxLength: 10 }, + // The named controls run the real serializer through the same comparison. + // The generated string grammar cannot take back every control (it draws + // no line break), so they are their own enumerated cases; + // pg-serializer-calibration.test.ts decodes their hand-written outputs. + oracleProperty({ + id: `pg-serializer.array-controls`, + owns: [`src/pg-serializer.ts#serialize`], + law: { + statement: `Flat finite arrays retain ordered typed contents through the real serializer.`, + authority: `The established scalar output laws and independent finite-array decoder in pg-serializer-oracle.property.test.ts.`, + limits: `The seven named finite-array controls; no PostgreSQL acceptance or Electric provider casting claim.`, + }, + histories: validArrays.map(({ name, input }) => ({ name, input })), + label: ({ name }) => + `decodes the independent ${name} control and the real serializer`, + ...existingElectricComparison( + `pg-serializer.array-controls`, + ({ input }: ArrayControl) => + expectFiniteArrayContents(input, serialize(input)), ), - ])(`mixed finite arrays retain ordered typed contents`, (input) => { - expectFiniteArrayContents(input, serialize(input)) + check: checkExistingElectricComparison, }) }) }) diff --git a/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts b/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts index da6b8eadb6..c2c6d27d77 100644 --- a/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts +++ b/packages/electric-db-collection/tests/pg-serializer-property-oracle.ts @@ -8,6 +8,7 @@ import { } from './electric-property-oracle' import type { FiniteArrayValue } from './pg-serializer-finite-decoder' import type { fc } from '@fast-check/vitest' +import type { FixedHistory } from '../../db/tests/oracle-property' // The unchanged caller supplies the independent scalar or array comparison. // This adapter changes registration only: both campaigns keep seed 20260930 @@ -16,6 +17,15 @@ const registeredProperties = new Set() export function serializerProp( property: string, [arbitrary]: [fc.Arbitrary], + { + owns, + fixed, + }: { + /** The production code the calling oracle file judges. */ + owns?: ReadonlyArray + /** Named inputs that run through the same comparison. */ + fixed?: ReadonlyArray> + } = {}, ) { return (name: string, check: (value: T) => void): void => { if (registeredProperties.has(name)) { @@ -24,6 +34,7 @@ export function serializerProp( registeredProperties.add(name) oracleProperty({ id: property, + owns, law: { statement: name, authority: `The established scalar output laws and independent finite-array decoder in pg-serializer-oracle.property.test.ts.`, @@ -33,6 +44,7 @@ export function serializerProp( arbitrary, runs: 100, fixedSeeds: [20260930], + fixed, }, ...existingElectricComparison(property, check), check: checkExistingElectricComparison, diff --git a/packages/electric-db-collection/vite.config.ts b/packages/electric-db-collection/vite.config.ts index 906fb60a8b..8e60148aa3 100644 --- a/packages/electric-db-collection/vite.config.ts +++ b/packages/electric-db-collection/vite.config.ts @@ -16,7 +16,11 @@ const config = defineConfig({ include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], // oracleProperty registers runtime tests that the type-test collector // cannot discover. These files remain in the TypeScript program. - exclude: [`tests/documented-mutations-oracle.test.ts`], + exclude: [ + `tests/documented-mutations-oracle.test.ts`, + `tests/electric-descriptor-isolation-oracle.test.ts`, + `tests/electric-sdk-delivery-oracle.property.test.ts`, + ], }, }, }) From f716daffccb766c76556bb22db631f39b05461dd Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:43:22 -0700 Subject: [PATCH 099/116] docs: name the renamed witness and calibration files Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 6 +++--- packages/db/tests/indexed-db/ORACLE.md | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 1cd55d8442..4f58103d11 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2099,7 +2099,7 @@ generic visitor behavior outside the named collectors. | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Ordered relations and BTree | [top-K relation checker](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.ts) ([calibration](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-calibration.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. Its owned production is `lifecycle.ts` and the settlement restarts in `Transaction.commit`, `Transaction.rollback`, and `Transaction.touchCollection` (`transactions.ts`). Clock steps are 1, 20, 60, and 120 ms. Before `gcTime` no retirement is owed. From `gcTime` to `gcTime` + 20 ms either outcome is allowed, since exact cleanup latency is not promised. From `gcTime` + 20 ms retirement is owed. A retirement two milliseconds early is rejected; one millisecond early is not observed at the 99 ms checkpoint and survives as indistinguishable from `gcTime`. 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. Seven constructed histories, one or more for each of its seven declared reach states, enter those states in every campaign, so its reach gate does not depend on the seed; half of each campaign's histories are constructed, and the reach counts include the constructed histories, so a floor of 1 does not show what the generated histories reach alone. GC that ignores subscribers fails the fixed-seed and random campaigns. 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). | +| 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-calibration.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. Its owned production is `lifecycle.ts` and the settlement restarts in `Transaction.commit`, `Transaction.rollback`, and `Transaction.touchCollection` (`transactions.ts`). Clock steps are 1, 20, 60, and 120 ms. Before `gcTime` no retirement is owed. From `gcTime` to `gcTime` + 20 ms either outcome is allowed, since exact cleanup latency is not promised. From `gcTime` + 20 ms retirement is owed. A retirement two milliseconds early is rejected; one millisecond early is not observed at the 99 ms checkpoint and survives as indistinguishable from `gcTime`. 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. Seven constructed histories, one or more for each of its seven declared reach states, enter those states in every campaign, so its reach gate does not depend on the seed; half of each campaign's histories are constructed, and the reach counts include the constructed histories, so a floor of 1 does not show what the generated histories reach alone. GC that ignores subscribers fails the fixed-seed and random campaigns. 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), [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. | @@ -6033,7 +6033,7 @@ records calibration, validation and the remaining limits. #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` + with no orphan blocker. `packages/db/e2e/indexed-db/value-witness.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. @@ -6043,7 +6043,7 @@ records calibration, validation and the remaining limits. 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`. + `packages/db/e2e/indexed-db/value-witness.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 diff --git a/packages/db/tests/indexed-db/ORACLE.md b/packages/db/tests/indexed-db/ORACLE.md index 66b6334372..f759e55271 100644 --- a/packages/db/tests/indexed-db/ORACLE.md +++ b/packages/db/tests/indexed-db/ORACLE.md @@ -286,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; fixed receiving matrix and 10 fixed + 10 random-campaign histories per engine | +| `e2e/indexed-db/cross-tab-witness.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 @@ -488,7 +488,7 @@ and a valid suffix. Each rejection is a `SerializationError`, not the raw The approved capture extension below adds post-submission ownership. Cycles and arbitrary custom prototypes remain outside this adapter value corpus. -`e2e/indexed-db/value-oracle.spec.ts` receives those value and first-open premises through +`e2e/indexed-db/value-witness.spec.ts` receives those value and first-open premises through native IndexedDB and BroadcastChannel. Observations inspect rich values inside the page before serialization. Raw native Blob preparation fails in the local Playwright WebKit provider; those two cells prove matching adapter rejection, From da58029a4ef17877210a7d901377085dab3e1210 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:53:26 -0700 Subject: [PATCH 100/116] test(db-sqlite-persistence-core): the storage-fault and release sweeps are enumerated sources with declared reach Each sweep ran one plain test whose body looped over every case and then asserted the exact reach set. Each case is now its own enumerated history over a fresh database, and each reach fact is a declared state, so the enumerated reach test requires every state and fails on any other, which is the old exact-set equality. - storage-fault-sweep: the baseline run counted the calls at run time, so the source enumerates up to FAULT_CALL_BOUND (16) calls per session and timing, and the baseline history fails when a session makes more. An index past a session's last call fails nothing and checks nothing, as the loop skipped it. - release-sweep: steps 0 to 24. Each step now covers its own database online before the released session-2 load; before, the steps shared one covered database, and a released load writes no coverage. Co-authored-by: Isaac --- ...ed-demand-coverage-campaign-oracle.test.ts | 178 ++++++++++++------ .../db-sqlite-persistence-core/vite.config.ts | 1 + .../db/tests/converted-properties/sqlite.ts | 2 + 3 files changed, 122 insertions(+), 59 deletions(-) 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 021a774655..d63a888539 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 @@ -1,8 +1,8 @@ import { DatabaseSync } from 'node:sqlite' import { fc } from '@fast-check/vitest' -import { describe, expect, it, vi } from 'vitest' +import { expect, vi } from 'vitest' import { createCollection, createLiveQueryCollection, eq } from '@tanstack/db' -import { oracleReplayRequested, oracleRuns } from '../../db/tests/oracle-config' +import { oracleRuns } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { forwardAssertionJudgment, @@ -1238,65 +1238,125 @@ function checkRelease( }) } -describe(`persisted demand coverage over generated histories`, () => { - it.skipIf(oracleReplayRequested())( - `keeps the law when any one storage call fails`, - async () => { - quietConsole() - const baseline = await runWithFault(undefined) - // Positive witness: without a failure, session 2 settles D offline. - expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) - const reached = new Set() - const expectedReach: Array = [ - `coverage read failed`, - `row write failed`, - `coverage check failed`, - `coverage write failed`, - ] - for (const session of [1, 2] as const) { - const count = baseline.calls.filter((c) => c.session === session).length - for (let index = 0; index < count; index++) { - for (const timing of [`before effect`, `after effect`] as const) { - const run = await runWithFault({ session, index, timing }) - const failed = run.calls.filter((c) => c.session === session)[index] - if (!failed) continue - try { - checkFault(failed, timing, run, (state) => reached.add(state)) - } catch (error) { - throw new Error( - `failure at session ${session} call ${index} ` + - `(${failed.method}, ${timing}, late: ${failed.late})`, - { cause: error }, - ) - } - } - } - } - expect(reached).toEqual(new Set(expectedReach)) - }, - 600_000, - ) +// Part 2's sweep: every storage call of either session fails once, before or +// after its effect. Each failure is its own enumerated history over a fresh +// database. The calls are counted by the baseline run, which happens at run +// time, so the sweep enumerates up to FAULT_CALL_BOUND calls per session and +// the baseline history fails when a session makes more. An index past a +// session's last call fails nothing, and its history checks nothing, as the +// sweep skipped it before. +const FAULT_CALL_BOUND = 16 +type FaultSweepHistory = + | { kind: `baseline` } + | { kind: `fault`; session: 1 | 2; index: number; timing: FailureTiming } +const faultSweepHistories: Array = [ + { kind: `baseline` }, + ...([1, 2] as const).flatMap((session) => + Array.from({ length: FAULT_CALL_BOUND }, (_, index) => + ([`before effect`, `after effect`] as const).map( + (timing): FaultSweepHistory => ({ + kind: `fault`, + session, + index, + timing, + }), + ), + ).flat(), + ), +] +const faultReachStates: Array = [ + `coverage read failed`, + `row write failed`, + `coverage check failed`, + `coverage write failed`, +] + +async function runFaultSweepHistory( + history: FaultSweepHistory, + observe: (checkpoint: string, value: unknown) => void, +): Promise { + quietConsole() + if (history.kind === `baseline`) { + const baseline = await runWithFault(undefined) + // Positive witness: without a failure, session 2 settles D offline. + expect(baseline.session2).toEqual({ status: `ready`, rows: [`r1`, `r2`] }) + for (const session of [1, 2] as const) + expect( + baseline.calls.filter((c) => c.session === session).length, + ).toBeLessThanOrEqual(FAULT_CALL_BOUND) + return + } + const { session, index, timing } = history + const run = await runWithFault({ session, index, timing }) + const failed = run.calls.filter((c) => c.session === session)[index] + if (!failed) return + try { + checkFault(failed, timing, run, (state) => observe(`reach`, state)) + } catch (error) { + throw new Error( + `failure at session ${session} call ${index} ` + + `(${failed.method}, ${timing}, late: ${failed.late})`, + { cause: error }, + ) + } +} + +oracleProperty({ + id: `persisted-demand-coverage.storage-fault-sweep`, + law: { + statement: `When any one storage call of either session fails, before or after its effect, the coverage law holds: a failed coverage read, row write, coverage check, or coverage write leaves the demand loading or crashed as the law requires, and never settles it from SQLite with rows it did not cover.`, + authority: `The persisted demand coverage contract stated in this file (Part 2) and law 6 of packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `The two-session D history only; up to ${FAULT_CALL_BOUND} storage calls per session, which the baseline history checks.`, + }, + owns: [`src/persisted.ts`], + histories: { enumerated: faultSweepHistories, states: faultReachStates }, + label: (history) => + history.kind === `baseline` + ? `baseline: no storage call fails` + : `session ${history.session} call ${history.index} fails ${history.timing}`, + resolve: (history) => ({ history }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`storage fault sweep`, plan, observe, () => + runFaultSweepHistory(plan.history, observe), + ), + check: forwardAssertionJudgment, + timeout: 600_000, +}) - it.skipIf(oracleReplayRequested())( - `owns no wait after a release at any step of a load`, - async () => { +// Part 3's sweep: a release after each step of the load. Each step is its own +// enumerated history over a fresh database that session 1 covered online. +const releaseSteps = Array.from({ length: 25 }, (_, steps) => steps) +const releaseReachStates: Array = [ + `released before the source load`, + `released while the source load waited`, + `released during a coverage read`, +] + +oracleProperty({ + id: `persisted-demand-coverage.release-sweep`, + law: { + statement: `A released load owns no wait: after a release at any step of a load, the source Collection is not loading a subset, and it stays so when the clock passes the wait.`, + authority: `The persisted demand coverage contract stated in this file (Part 3) and law 6 of packages/db/src/query/live/ARCHITECTURE.md.`, + limits: `Releases after 0 to 24 steps of the session-2 load of D, each over a database that session 1 covered online.`, + }, + owns: [`src/persisted.ts`], + histories: { enumerated: releaseSteps, states: releaseReachStates }, + label: (steps) => `owns no wait after a release at step ${steps}`, + resolve: (steps) => ({ steps }), + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(`release sweep`, plan, observe, async () => { quietConsole() const driver = createDriver(new DatabaseSync(`:memory:`)) await coverGroupA(driver) - const reached = new Set() - const expectedReach: Array = [ - `released before the source load`, - `released while the source load waited`, - `released during a coverage read`, - ] - for (let steps = 0; steps <= 24; steps++) { - checkRelease( - steps, - await runReleaseAt(driver, steps, (state) => reached.add(state)), - ) - } - expect(reached).toEqual(new Set(expectedReach)) - }, - 300_000, - ) + checkRelease( + plan.steps, + await runReleaseAt(driver, plan.steps, (state) => + observe(`reach`, state), + ), + ) + }), + check: forwardAssertionJudgment, + timeout: 300_000, }) diff --git a/packages/db-sqlite-persistence-core/vite.config.ts b/packages/db-sqlite-persistence-core/vite.config.ts index 4ed5172624..cd62761298 100644 --- a/packages/db-sqlite-persistence-core/vite.config.ts +++ b/packages/db-sqlite-persistence-core/vite.config.ts @@ -22,6 +22,7 @@ const config = defineConfig({ `tests/sqlite-boolean-arity-oracle.test.ts`, `tests/collection-setup-reopen-oracle.test.ts`, + `tests/persisted-demand-coverage-campaign-oracle.test.ts`, `tests/shared-logical-scheduling-oracle.test.ts`, `tests/persisted-reload-replacement-oracle.test.ts`, `tests/persisted-readiness-oracle.test.ts`, diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 665a221bb2..2ab2390e80 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -19,6 +19,8 @@ export const properties: ReadonlyArray = [ `persisted-demand-coverage.bookkeeping`, `persisted-demand-coverage.window`, `persisted-demand-coverage.next-page`, + `persisted-demand-coverage.storage-fault-sweep`, + `persisted-demand-coverage.release-sweep`, `browser.shared-driver-fairness.neutral`, `browser.shared-driver-fairness.persist-storm`, `browser.shared-driver-fairness.persist-first`, From 1f2a036e92f8acc696c64ceacf3010c7b35dd4e9 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:55:24 -0700 Subject: [PATCH 101/116] test(browser-db-sqlite-persistence): adapter-bound RPC routing is a fixed-history oracleProperty that resets its fixture The routing witness ran as a plain test whose fixture the file's hooks set up and tore down. As a one-history property, the framework's determinism test runs it twice in one test, so its driver now resets the controlled fixture after each run, with the same functions that the hooks now call. The witness body and its comparisons are unchanged. Co-authored-by: Isaac --- .../per-collection-coordinator-oracle.test.ts | 48 +++++++++++++++---- .../db/tests/converted-properties/sqlite.ts | 8 +++- 2 files changed, 47 insertions(+), 9 deletions(-) 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 2f2f3482e1..3123756711 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 @@ -9,7 +9,7 @@ import { createLiveQueryCollection, eq, } from '@tanstack/db' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, vi } from 'vitest' import { forwardAssertionJudgment, oracleAssertion, @@ -17,7 +17,6 @@ import { recordedAssertionJudgment, rejectAt, } from '../../db/tests/oracle-assertion-adapter' -import { oracleRoute } from '../../db/tests/oracle-config' import { oracleProperty } from '../../db/tests/oracle-property' import { createBrowserWASQLitePersistence, @@ -218,12 +217,16 @@ Reach, challenge, replay, cleanup, and limits: failure, plus cancellation on release and disposal. */ -beforeEach(() => { +// Each test starts with a fresh controlled fixture and ends by proving that +// nothing it started is still live. A driver whose history runs more than once +// in one test, as the framework's determinism test runs it, resets the fixture +// itself after each run. +function prepareCoordinatorFixture(): void { durableTerms.clear() installControlledBrowserSeams() -}) +} -afterEach(async () => { +async function resetCoordinatorFixture(): Promise { const cleanupDiagnostics: Array = [] for (const coordinator of liveCoordinators) { await captureCleanup(cleanupDiagnostics, `afterEach live coordinator`, () => @@ -261,7 +264,10 @@ afterEach(async () => { heldLocks.clear() queuedLocks.clear() expect.soft(cleanupDiagnostics).toEqual([]) -}) +} + +beforeEach(prepareCoordinatorFixture) +afterEach(resetCoordinatorFixture) async function observeOwnerEntryOrResponse( ownerEntered: () => boolean, @@ -1616,8 +1622,7 @@ async function requestBothCollections( } describe(`generated collection-route histories`, () => { - it(`routes every adapter-bound RPC by collection before and after leadership transfer`, async () => { - oracleRoute(`fixed history`) + async function runAdapterBoundRpcRouting(): Promise { const makePullResult = ( marker: string, latestRowVersion: number, @@ -1864,6 +1869,33 @@ describe(`generated collection-route histories`, () => { }, ], ]) + } + oracleProperty({ + id: `browser-coordinator.adapter-bound-rpc-routing`, + law: { + ...coordinatorLaw, + limits: `Two collections, a leader, a follower, and a requester over the controlled BroadcastChannel, before and after one leadership transfer.`, + }, + owns: [`src/browser-coordinator.ts`], + histories: [ + { + name: `routes every adapter-bound RPC by collection before and after leadership transfer`, + }, + ], + label: ({ name }) => name, + model: recordedAssertionJudgment, + driver: (plan, observe) => + recordAssertionJudgment(coordinatorJudgment, plan, observe, async () => { + try { + await runAdapterBoundRpcRouting() + } finally { + // The determinism test runs this history twice in one test, so the + // fixture that the hooks reset between tests is reset here too. + await resetCoordinatorFixture() + prepareCoordinatorFixture() + } + }), + check: forwardAssertionJudgment, }) function createRoutingJudgment() { diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/converted-properties/sqlite.ts index 2ab2390e80..f7b8054722 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/converted-properties/sqlite.ts @@ -44,6 +44,7 @@ export const properties: ReadonlyArray = [ `browser-coordinator.fresh-init`, `browser-coordinator.write-ownership`, `browser-coordinator.real-tab-follower`, + `browser-coordinator.adapter-bound-rpc-routing`, `persistence.hydration-boundary-commit`, `persistence.hydration-straddle-fifo`, `persistence.hydration-straddle-abort`, @@ -90,6 +91,11 @@ export const owners: ReadonlyArray = [ `persisted-demand-coverage`, `cell lifecycle joined session-reset bookkeeping window next-page`, ], + [ + `db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-oracle.test.ts`, + `persisted-demand-coverage`, + `storage-fault-sweep release-sweep`, + ], [ `browser-db-sqlite-persistence/tests/shared-driver-fairness-oracle.test.ts`, `browser.shared-driver-fairness`, @@ -109,7 +115,7 @@ export const owners: ReadonlyArray = [ [ `browser-db-sqlite-persistence/tests/per-collection-coordinator-oracle.test.ts`, `browser-coordinator`, - `subset-wire subset-ack collection-scoped-lease remote-lease-takeover adapter-isolation fresh-init write-ownership real-tab-follower`, + `subset-wire subset-ack collection-scoped-lease remote-lease-takeover adapter-isolation fresh-init write-ownership real-tab-follower adapter-bound-rpc-routing`, ], [ `db-sqlite-persistence-core/tests/persisted-oracle.test.ts`, From aa6adf6d159f4f57f9549663ce4c57522c3a9dc7 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:57:23 -0700 Subject: [PATCH 102/116] test(db-sqlite-persistence-core): each fixed ordinary-transaction case is its own enumerated history sqlite-ordinary.fixed-cases ran one history whose driver looped over every size, parameter-cap, and semantic case and then checked the work bounds. No bound relates two cases: each case's work bound, and the parameter-cap call counts and largest parameter lists, belong to one case. Each case is now its own enumerated history through its own adapter, which checks its semantics first and then its own work, so a RED still names the semantic failure first. The comparisons and their values are unchanged; a wrong parameter-cap value fails exactly its case. Co-authored-by: Isaac --- .../tests/ordinary-transaction-work-oracle.ts | 196 ++++++++---------- 1 file changed, 91 insertions(+), 105 deletions(-) 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 95b44f5d2c..fbe08b8d95 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 @@ -1254,113 +1254,101 @@ export function runOrdinaryTransactionWorkOracle(): void { // The fixed process and design cases, each through the real adapter and // the model, then the work bound over all of them, so the RED identifies // work after every semantic comparison. - async function refineFixedCases(): Promise { - const workObservations: Array<{ - case: string - calls: number - bound: number - maxParams: number - }> = [] - for (const item of [ - ...workCases, - ...additionalWorkCases, - ...semanticCases, - ]) { - const host = fixture(item.cap) - const collectionId = `oracle-${item.name}` - let primary: unknown - try { - await host.adapter.loadResumeSnapshot({ - kind: `eager`, - collectionId: collectionId, - }) // schema setup outside work count - const history: Array = [] - if (item.seed) { - history.push(clone(item.seed)) - await host.adapter.applyCommittedTx( - { kind: `eager`, collectionId: collectionId }, - item.seed, - ) - } - host.counts.query = 0 - host.counts.run = 0 - host.counts.transactions = 0 - host.counts.maxParams = 0 - history.push(clone(item.candidate)) + // The fixed size, parameter-cap, and semantic cases, each its own + // enumerated history through its own adapter. A case's work is checked + // after its semantic comparisons, so a RED names the semantic failure + // first; a size at the parameter cap also checks its call count and + // largest parameter list. + const fixedCases = [...workCases, ...additionalWorkCases, ...semanticCases] + const capChecks: Record = { + 'unique-124-500-parameter-cap': { calls: 10, maxParams: 496 }, + 'unique-125-500-parameter-cap': { calls: 10, maxParams: 500 }, + 'unique-126-500-parameter-cap': { calls: 15, maxParams: 500 }, + } + for (const name of Object.keys(capChecks)) + if (!fixedCases.some((item) => item.name === name)) + throw new Error(`parameter-cap check names no fixed case: ${name}`) + async function refineFixedCase(item: Case): Promise { + let work: + | { case: string; calls: number; bound: number; maxParams: number } + | undefined + const host = fixture(item.cap) + const collectionId = `oracle-${item.name}` + let primary: unknown + try { + await host.adapter.loadResumeSnapshot({ + kind: `eager`, + collectionId: collectionId, + }) // schema setup outside work count + const history: Array = [] + if (item.seed) { + history.push(clone(item.seed)) await host.adapter.applyCommittedTx( { kind: `eager`, collectionId: collectionId }, - item.candidate, - ) - const calls = host.counts.query + host.counts.run - const candidateTransactions = host.counts.transactions - const maxParams = host.counts.maxParams - const actual = await observe(host.adapter, host.driver, collectionId) - const expected = modelAfter(history) - oracleAssertion('existing comparison at 1245', () => - expect( - actual, - `${item.name}: complete durable observation`, - ).toEqual(expected), - ) - oracleAssertion('existing comparison at 1248', () => - expect( - candidateTransactions, - `${item.name}: one SQLite transaction`, - ).toBe(1), + item.seed, ) - if (item.workSize !== undefined) { - const chunk = Math.min(125, Math.floor(item.cap / 4)) - const bound = - 12 + - 6 * (item.workFamilies ?? 1) * Math.ceil(item.workSize / chunk) - workObservations.push({ case: item.name, calls, bound, maxParams }) - } - } catch (error) { - primary = error - } - try { - host.close() - } catch (cleanup) { - if (primary !== undefined) - throw new AggregateError( - [primary, cleanup], - `${item.name}: primary and cleanup failed`, - { cause: primary }, - ) - throw cleanup } - if (primary !== undefined) throw primary - } - // Check work after every semantic comparison, so the RED identifies work. - console.log('ORDINARY_SQLITE_WORK', JSON.stringify(workObservations)) - for (const row of workObservations) { - oracleAssertion('existing comparison at 1278', () => - expect(row.calls, `${row.case}: work checkpoint`).toBeLessThanOrEqual( - row.bound, + host.counts.query = 0 + host.counts.run = 0 + host.counts.transactions = 0 + host.counts.maxParams = 0 + history.push(clone(item.candidate)) + await host.adapter.applyCommittedTx( + { kind: `eager`, collectionId: collectionId }, + item.candidate, + ) + const calls = host.counts.query + host.counts.run + const candidateTransactions = host.counts.transactions + const maxParams = host.counts.maxParams + const actual = await observe(host.adapter, host.driver, collectionId) + const expected = modelAfter(history) + oracleAssertion('existing comparison at 1245', () => + expect(actual, `${item.name}: complete durable observation`).toEqual( + expected, ), ) + oracleAssertion('existing comparison at 1248', () => + expect( + candidateTransactions, + `${item.name}: one SQLite transaction`, + ).toBe(1), + ) + if (item.workSize !== undefined) { + const chunk = Math.min(125, Math.floor(item.cap / 4)) + const bound = + 12 + 6 * (item.workFamilies ?? 1) * Math.ceil(item.workSize / chunk) + work = { case: item.name, calls, bound, maxParams } + } + } catch (error) { + primary = error + } + try { + host.close() + } catch (cleanup) { + if (primary !== undefined) + throw new AggregateError( + [primary, cleanup], + `${item.name}: primary and cleanup failed`, + { cause: primary }, + ) + throw cleanup } - const atSize = (size: number) => - workObservations.find( - (row) => row.case === `unique-${size}-500-parameter-cap`, - )! + if (primary !== undefined) throw primary + if (work === undefined) return + const row = work + console.log('ORDINARY_SQLITE_WORK', JSON.stringify(row)) + oracleAssertion('existing comparison at 1278', () => + expect(row.calls, `${row.case}: work checkpoint`).toBeLessThanOrEqual( + row.bound, + ), + ) + const cap = capChecks[row.case] + if (cap === undefined) return oracleAssertion('existing comparison at 1286', () => - expect(atSize(124).calls).toBeLessThanOrEqual(10), + expect(row.calls).toBeLessThanOrEqual(cap.calls), ) oracleAssertion('existing comparison at 1287', () => - expect(atSize(124).maxParams).toBe(496), - ) - oracleAssertion('existing comparison at 1288', () => - expect(atSize(125).calls).toBeLessThanOrEqual(10), - ) - oracleAssertion('existing comparison at 1289', () => - expect(atSize(125).maxParams).toBe(500), - ) - oracleAssertion('existing comparison at 1290', () => - expect(atSize(126).calls).toBeLessThanOrEqual(15), - ) - oracleAssertion('existing comparison at 1291', () => - expect(atSize(126).maxParams).toBe(500), + expect(row.maxParams).toBe(cap.maxParams), ) } // A hostile writer that discards existing metadata while batching: the @@ -1664,19 +1652,17 @@ export function runOrdinaryTransactionWorkOracle(): void { id: `sqlite-ordinary.fixed-cases`, law: { ...ordinaryLaw, - limits: `The fixed size, parameter-cap, and semantic cases, each through its own adapter in order, then the work bound over all of them.`, + limits: `The fixed size, parameter-cap, and semantic cases, each through its own adapter, with its work bound.`, }, owns: [`src/sqlite-core-adapter.ts`], - histories: [ - { - name: `refines durable semantics for the bounded process and design grammar; ordinary large writes are chunk-bounded`, - }, - ], - label: ({ name }) => name, + histories: fixedCases.map((item) => item.name), + label: (name) => + `refines durable semantics and bounds the work of ${name}`, + resolve: (name) => ({ item: fixedCases.find((c) => c.name === name)! }), model: recordedAssertionJudgment, driver: (plan, observe) => recordAssertionJudgment(ordinaryJudgment, plan, observe, () => - refineFixedCases(), + refineFixedCase(plan.item), ), check: forwardAssertionJudgment, }) From 51d0f547ecb5557cbe5c0664a625d806f567a205 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 09:59:02 -0700 Subject: [PATCH 103/116] docs: name the renamed browser witness specs; delete the unused describeUnlessAnyOracleReplay Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 14 +++++++------- docs/contributing/oracle-rubric.md | 2 +- packages/db-adapter-conformance/README.md | 4 ++-- .../tests/persisted-support.ts | 2 -- 4 files changed, 10 insertions(+), 12 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 4f58103d11..e70b32c2b9 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -1136,7 +1136,7 @@ completion, public rows rebuilt from the scenario seeds, persist FIFO order, the non-preemptible running persist, and the K=1 cut at each hydrate. The K=1 reference bounds completed persists only from above, so a synthetic preempted running persist passes it and fails only the non-preemptible law. The -[Chromium OPFS receiving spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts) +[Chromium OPFS receiving spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-witness.opfs.spec.ts) states no expected results of its own and adds a cold SQL read. It runs a neutral history, a held-BEGIN storm with every persist before the hydrates, and an interleaved storm with hydrates queued ahead of later persists. The @@ -3267,7 +3267,7 @@ settlement, started and terminated workers, the steps that production reached, and a later open after an interruption. The [jsdom driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts) reaches worker start, initialization, and the worker's WASM failure response. -The [Chromium driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts) +The [Chromium driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-witness.e2e.test.ts) reaches the lookup, the real worker's WASM fetch, and worker start against a Vite dev server that pre-bundles the built package, for Vite 7 and Vite 8 consumers. Under Vite 8 the copy's asset URLs point at the original install, so @@ -3294,7 +3294,7 @@ interrupt an open in the same turn as a failing step, because no law orders those causes yet. The -[install-layout oracle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts) +[install-layout oracle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/install-layout-witness.e2e.test.ts) models which installs open in Vite development and production: direct, hoisted, pnpm, transitive, npm-alias, and two-copy installs, three cache directories, and exclusion by import name or package name. Vite 7 and Vite 8 each run all @@ -3370,7 +3370,7 @@ comparison. Two drivers run it. The runs the real worker module and wa-sqlite WASM build behind a bridge Worker, over an in-memory VFS with OPFSCoopSyncVFS's lock protocol, with fixed, random, and SEED/PATH campaigns. The -[Chromium driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts) +[Chromium driver](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-witness.opfs.spec.ts) runs every fixed example against real OPFS and takes the access handle through the VFS's own BroadcastChannel and Web Lock handoff. Histories vary one to three inserts and selects, which execute meets a taken handle, whether the @@ -4788,7 +4788,7 @@ a mutant wrong-answer control, and an explicit statement of remaining limits. duplicate endpoints fails the SQLite law. Bypassing startup provenance validation fails the Browser owner. The real installed SDK/HTTP/PostgreSQL/Chromium OPFS receiving driver - `e2e/electric-source-prefix-oracle.opfs.spec.ts` shares the source-head + `e2e/electric-source-prefix-witness.opfs.spec.ts` shares the source-head judgment. The original ten cells cover both membership directions, empty/disjoint advancement and drain/no-drain, plus missing legacy provenance with disjoint advancement. Each declares held earlier response, stale commit, @@ -7868,7 +7868,7 @@ durable rows afterward, and rows after a local-only reopen. Scoped Query snapshots also check exact ownership at all four snapshot cuts. Startup checks the reload owner's one-full-row-read law before its own durable observation. Query's receiving driver preserves exact fetch-count and pending-fetch premises. -The [Chromium OPFS receiver](../../packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-oracle.opfs.spec.ts) +The [Chromium OPFS receiver](../../packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-witness.opfs.spec.ts) runs 116 Query histories, 104 PowerSync histories, 32 RxDB histories and 26 complete-result histories for each of Convex, Electric and TrailBase through the same composition and check on real worker/OPFS. It @@ -7964,7 +7964,7 @@ four direct-write operations, before-result and before/after captured baseline reads, early insert/upsert before persisted restore, writes before the first physical fetch, cached results without a physical fetch, and a later fetch. Each Node host runs 104 fixed histories and fixed/fresh campaigns of 80 histories; -the [Chromium receiving page](../../packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-oracle.opfs.spec.ts) +the [Chromium receiving page](../../packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-witness.opfs.spec.ts) runs the same 104 histories and judgment. Comparisons cover the accepted write when its baseline is already public, first-result public/durable/cache state, and public/durable state at later fetch settlement. The original derived-baseline diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index 2c0d0d2777..aa92de59f1 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -1029,7 +1029,7 @@ supply the entry point, access-handle control, database names, and result reporting. The Node driver also generates random histories from the same 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) +[Chromium spec](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/e2e/opfs-request-order-witness.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. diff --git a/packages/db-adapter-conformance/README.md b/packages/db-adapter-conformance/README.md index 2d37956a8d..e73177bd9a 100644 --- a/packages/db-adapter-conformance/README.md +++ b/packages/db-adapter-conformance/README.md @@ -144,7 +144,7 @@ are necessary: persistence's own snapshot choice can mask a broken retained-row predicate. After mutation settlement and applied receipts, source rows must equal the complete result. Readiness alone proves acceptance, so fulfillment during a persisting mutation can still expose the coherent retained baseline and overlay. -Chromium's `eager-gap-conformance-oracle.opfs.spec.ts` receives the same 24 +Chromium's `eager-gap-conformance-witness.opfs.spec.ts` receives the same 24 histories per adapter, plus 104 eager Query precedence, 48 on-demand Query scope, two cached-peer claims, and 24 TrailBase lifecycle histories from their primary owners. These 226 histories enforce separate reach and share their owners' comparisons. @@ -152,7 +152,7 @@ and share their owners' comparisons. ```sh pnpm --filter @tanstack/query-db-collection exec vitest run --coverage.enabled=false --typecheck.enabled=false tests/persisted-startup-conformance-oracle.test.ts pnpm --filter @tanstack/convex-db-collection exec vitest run --coverage.enabled=false --typecheck.enabled=false tests/persisted-startup-conformance-oracle.property.test.ts -pnpm --filter @tanstack/browser-db-sqlite-persistence exec playwright test --config playwright.opfs.config.ts persisted-startup-conformance-oracle.opfs.spec.ts +pnpm --filter @tanstack/browser-db-sqlite-persistence exec playwright test --config playwright.opfs.config.ts persisted-startup-conformance-witness.opfs.spec.ts ``` Restoring the original Query implementation fails startup at these receiving diff --git a/packages/db-sqlite-persistence-core/tests/persisted-support.ts b/packages/db-sqlite-persistence-core/tests/persisted-support.ts index 3526536fd6..3315758825 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-support.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-support.ts @@ -1696,8 +1696,6 @@ export const describeUnlessOracleReplay = oracleReplayTest( `persistence.follower-remote-demand`, ) -export const describeUnlessAnyOracleReplay = oracleReplayTest(describe) - export const generationNoticeHistory = fc.record({ retiredNotice: fc.constantFrom( `none`, From d44ec8d7b17ad45a2a999373fa14d8f483d74bd4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 10:04:00 -0700 Subject: [PATCH 104/116] test(db): drop the dead includes calibration hooks; enumerated replays select their cases first - includes-calibration.ts (runIncludesCalibration) has no caller, and nothing in the repository or CI sets TANSTACK_DB_ORACLE_CALIBRATION to missing-parent or extra-child, so the replay-gated observation mutations in the cross-formulation equivalence and ordered-window checks are removed. The native calibrations of those properties carry the same hostile observations. - directReplayTestName checks enumeratedProperties before the prefix rules, which named a random-seed campaign that an enumerated source does not have (includes-*, browser-coordinator.*). - local-storage.value-fidelity-fixed is written literally, so the manifest test finds its owner by name. Co-authored-by: Isaac --- ...age-value-fidelity-oracle.property.test.ts | 2 +- packages/db/tests/oracle-replay-manifest.ts | 7 ++-- .../db/tests/query/includes-calibration.ts | 11 ------- ...-cross-formulation-oracle.property.test.ts | 33 ++----------------- 4 files changed, 9 insertions(+), 44 deletions(-) delete mode 100644 packages/db/tests/query/includes-calibration.ts 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 06e3dc1c56..6ca0dfdf7a 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 @@ -470,7 +470,7 @@ describe(`LocalStorage value fidelity oracle`, () => { } oracleProperty({ - id: `${property}-fixed`, + id: `local-storage.value-fidelity-fixed`, law: { statement: `The default JSON storage boundary rejects lossy payloads without durable writes or handler calls, and restores accepted payloads in their independently described JSON form.`, authority: `LocalStorage guide acceptance and restore contract stated above`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index fb6c263399..032170f863 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -2030,6 +2030,11 @@ const nativeSyncMetadataProperties = new Set( ) function directReplayTestName(property: string): string | undefined { + // An enumerated source registers one test per history and no random + // campaign, so its direct replay selects every test of the property, before + // any prefix rule below can name a campaign it does not have. + if (enumeratedProperties.has(property)) + return `${property.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)} > ` if (nativeSyncMetadataProperties.has(property)) return `${property.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)} > random seed` if (property.startsWith(`adapter-conformance.persisted-baseline-lifecycle.`)) @@ -2063,8 +2068,6 @@ function directReplayTestName(property: string): string | undefined { ].includes(property) ) return `${property.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)} > random seed` - if (enumeratedProperties.has(property)) - return `${property.replace(/[.*+?^${}()|[\]\\]/g, `\\$&`)} > ` if ( [ `query.persisted-direct-ownership`, diff --git a/packages/db/tests/query/includes-calibration.ts b/packages/db/tests/query/includes-calibration.ts deleted file mode 100644 index 7b3aaf9905..0000000000 --- a/packages/db/tests/query/includes-calibration.ts +++ /dev/null @@ -1,11 +0,0 @@ -import fc from 'fast-check' - -// These runner controls inject a wrong observation into the calling owner's -// existing independent check, shrink it, and verify the exact replay protocol. -// They do not add a product campaign or compute an expected product result. -export function runIncludesCalibration( - property: fc.IAsyncProperty, - parameters: fc.Parameters, -): Promise> { - return fc.check(property, parameters) -} 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 8e40606ed2..7d07b5f52d 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 @@ -21,7 +21,7 @@ import { queryOnce, toArray, } from '../../src/query/index.js' -import { oracleProperty, readOracleRunConfig } from '../oracle-config.js' +import { oracleProperty } from '../oracle-config.js' import { flushPromises } from '../utils.js' import { registerIncludesProperty } from './includes-registration-oracle.js' import { @@ -831,8 +831,6 @@ function judgeOrderedWindowCase({ const orderedWindowLaw = `A nested include's ordered child window matches the recomputed windowed model through root and child moves.` describe(`includes cross-formulation oracle`, () => { - const replay = readOracleRunConfig() - // The next three properties have fixed route-transition histories. Their // generated integer is only a user token label: values span FastCheck's // default integer domain, while zero is reconstructed by the cold fixed @@ -1445,14 +1443,7 @@ describe(`includes cross-formulation oracle`, () => { const orderedWindowStatement = `matches recomputation for ordered offset and limit child windows for a fixed seed` const checkEquivalenceScenario = (scenario: CrossFormulationScenario) => - expectFormulationsEquivalent( - scenario, - replay.replayPath !== undefined && - replay.replayProperty === `includes-cross-formulation.equivalence` && - process.env.TANSTACK_DB_ORACLE_CALIBRATION === `missing-parent` - ? (rows) => rows.slice(1) - : undefined, - ) + expectFormulationsEquivalent(scenario) registerIncludesProperty( [scenarioArbitrary], @@ -1515,25 +1506,7 @@ describe(`includes cross-formulation oracle`, () => { scenario: CrossFormulationScenario offset: number limit: number - }) => - expectWindowedIncludeMatches( - scenario, - offset, - limit, - replay.replayPath !== undefined && - replay.replayProperty === `includes-cross-formulation.ordered-window` && - process.env.TANSTACK_DB_ORACLE_CALIBRATION === `extra-child` - ? (rows) => - rows.map((parent, index) => - index === 0 - ? { - ...parent, - children: [...parent.children, scenario.children[0]!], - } - : parent, - ) - : undefined, - ) + }) => expectWindowedIncludeMatches(scenario, offset, limit) registerIncludesProperty( [windowedScenarioArbitrary], From 2b4cf820b22ced05774b5662d4b5dc6949121115 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 10:28:54 -0700 Subject: [PATCH 105/116] test(db): the replay manifest knows the converted enumerated sources and e2e configs Every property that the declareOracle removal added is an enumerated source, so the manifest marks each one enumerated, and its direct replay selects its cases. For example, includes-collection.retired-held-facade replays as `-t 'includes-collection\.retired-held-facade > '` and runs its 2 tests. The electric move-suite owner and the Electron reload-class owner replay under their packages' vitest.e2e.config.ts. The manifest tests accept the literal pagination.limit-admission-fixed id, and the 13 new enumerated index-update properties beside the 20 generated matrix properties. Co-authored-by: Isaac --- packages/db/tests/oracle-replay-manifest.ts | 16 +++++++++++++++- packages/db/tests/oracle-replay.test.ts | 17 ++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 032170f863..e0d6fa861d 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -1,5 +1,8 @@ import { registeredOracleProperties } from './oracle-config.js' -import { convertedOwners } from './converted-properties/index.js' +import { + convertedOwners, + convertedProperties, +} from './converted-properties/index.js' /** * This manifest maps a replay name to the source file that can prove it ran. @@ -1886,6 +1889,9 @@ const reseededProperties = new Set() // Keep their literal registry names here; the owner groups still supply their // receiving files and any shared source module. const enumeratedProperties = new Set([ + // Every property that the declareOracle removal added is an enumerated + // source: one test per history, and no random campaign. + ...convertedProperties, ...[`rxdb`, `powersync`].flatMap((adapter) => [`controlled`, `node-sqlite`].map( (host) => `adapter.${adapter}.capability.${host}`, @@ -2141,6 +2147,14 @@ const outsideReplayConfigs = new Map([ * runs only under its own config. */ const ownerReplayConfigs = new Map([ + [ + `packages/electric-db-collection/e2e/electric.e2e.test.ts`, + `vitest.e2e.config.ts`, + ], + [ + `packages/electron-db-sqlite-persistence/tests/reload-class-oracle.electron.e2e.test.ts`, + `vitest.e2e.config.ts`, + ], [ `packages/browser-db-sqlite-persistence/e2e/query-scope-omission-oracle.browser.test.ts`, `vitest.adapter-oracles.config.ts`, diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index 445984eb48..1d386b6ba1 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -721,7 +721,11 @@ describe(`named oracle replay manifest`, () => { // These oracles number their properties in file order. const [family] = entry.property.split(`.`) expect(source).toContain(`\`${family}.\${id}\``) - } else if (entry.property.startsWith(`pagination.limit-admission-`)) { + } else if ( + entry.property.startsWith(`pagination.limit-admission-`) && + // The fixed admission histories name their property literally. + entry.property !== `pagination.limit-admission-fixed` + ) { const suffix = entry.property.slice( `pagination.limit-admission-`.length, ) @@ -846,9 +850,16 @@ describe(`named oracle replay manifest`, () => { const direct = oracleReplayManifest.filter(({ property }) => /^(index-update|pagination\.matrix)\./.test(property), ) - expect(direct).toHaveLength(20) + // The 20 generated matrix properties replay a seed and path; the fixed + // index-update histories are enumerated sources that replay their cases. + expect(direct.filter(({ status }) => status === `assertion`)).toHaveLength( + 20, + ) for (const entry of direct) { - expect(entry.status).toBe(`assertion`) + if (entry.status !== `assertion`) { + expect(entry.property).toMatch(/^index-update\./) + expect(entry.status).toBe(`enumerated-campaign`) + } expect(entry.testNamePattern).toBeDefined() } }) From 2c2ed80de3536252fdfd6d7f8a2198625ea3f098 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 10:30:33 -0700 Subject: [PATCH 106/116] docs: name the #2139 known-defect witness file in the coverage map Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index e70b32c2b9..cbde6853ba 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2123,7 +2123,7 @@ generic visitor behavior outside the named collectors. | Experimental no-peek continuation | [test-only live-window probe](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination-oracle.no-peek.integration.test.ts) | The candidate uses an authoritative fact only for the same complete output prefix and otherwise requests the extra row. It remains test-only; the shipped opaque-pagination owner keeps peek-ahead. See [experiment limits](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination/NO-PEEK-RESULTS.md). | | Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [recovery histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts), [held resume snapshots](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-resume-snapshot-races.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments, restart/reset lineage, held certification and durability races, source-order publication before durability, and late errors. The queued-presence property runs identical fixed/random generators plus isolated seed-and-path replay across insert, update, delete, and truncate callbacks. The recovery fixtures use a mocked ShapeStream; they do not establish live Electric-service framing or native persistence-host behavior. The TrailBase lifecycle oracle's early-demand 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 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 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), as `it.fails` witnesses in the ordinary file [`persisted-known-defects.test.ts`](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts) through the oracle's own drivers and models; 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. | From 38faaca57bc4618845eb626b3448464ad45e6268 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 11:28:11 -0700 Subject: [PATCH 107/116] test: drop the generated-construction guard that the native reach calibrations cover query.scope-omission-reacquisition (node hosts and Chromium) kept a plain it.skipIf(oracleReplayRequested()) test that rebuilt every constructed history from seed 1313, ran it, and required the check to reject it with its reach fact removed. The property's fixed seed is 1313, every build() records its obligations, and its native calibrations "omits state " remove exactly that reach fact from a constructed history under law "constructed reach"; each constructed history also runs clean as an example. So the guard and calibrateGeneratedConstructions are removed. Mutant: a build() that records no obligation fails all six native calibrations. Co-authored-by: Isaac --- ...uery-scope-omission-oracle.browser.test.ts | 17 ++--------- ...cope-omission-reacquisition-oracle.test.ts | 17 ++--------- .../scope-omission-reacquisition-oracle.ts | 30 ------------------- 3 files changed, 4 insertions(+), 60 deletions(-) diff --git a/packages/browser-db-sqlite-persistence/e2e/query-scope-omission-oracle.browser.test.ts b/packages/browser-db-sqlite-persistence/e2e/query-scope-omission-oracle.browser.test.ts index 0a65225dc8..999b5c1e33 100644 --- a/packages/browser-db-sqlite-persistence/e2e/query-scope-omission-oracle.browser.test.ts +++ b/packages/browser-db-sqlite-persistence/e2e/query-scope-omission-oracle.browser.test.ts @@ -1,14 +1,10 @@ -import { afterAll, beforeAll, it } from 'vitest' -import { oracleReplayRequested } from '../../db/tests/oracle-config' +import { afterAll, beforeAll } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' import { captureBrowserHistory, startAdapterChromium, } from '../tests/adapter-chromium-host' -import { - calibrateGeneratedConstructions, - options, -} from '../../query-db-collection/tests/scope-omission-reacquisition-oracle' +import { options } from '../../query-db-collection/tests/scope-omission-reacquisition-oracle' import { deliverQueryHistory } from './query-ownership-browser-support' import type { History } from '../../query-db-collection/tests/scope-omission-reacquisition-oracle' import type { QueryReceivingResult } from './query-ownership-browser-support' @@ -49,12 +45,3 @@ oracleProperty({ ...property, check: (h, trace, expected) => property.check(h, trace, expected), }) -let controlSequence = 0 -it.skipIf(oracleReplayRequested())( - 'rejects missing states from generated constructions', - () => - calibrateGeneratedConstructions((h, observe) => - receive(h, 'generated-control-browser-' + controlSequence++, observe), - ), - 120000, -) diff --git a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.test.ts b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.test.ts index e5acf0ef22..f07762e74a 100644 --- a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.test.ts +++ b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.test.ts @@ -1,18 +1,12 @@ // @vitest-environment jsdom -import { describe, it } from 'vitest' -import { oracleReplayRequested } from '../../db/tests/oracle-config' +import { describe } from 'vitest' import { oracleProperty } from '../../db/tests/oracle-property' import { createStartupHost } from '../../db-adapter-conformance/tests/persisted-startup-host' import { withOracleCleanup } from '../../db/tests/oracle-cleanup' -import { - calibrateGeneratedConstructions, - observeScope, - options, -} from './scope-omission-reacquisition-oracle' +import { observeScope, options } from './scope-omission-reacquisition-oracle' for (const host of ['controlled', 'node-sqlite'] as const) describe(host, () => { - let sequence = 0 const receive: Parameters[0] = async (h, id, observe) => { const storage = createStartupHost(host) await withOracleCleanup( @@ -26,11 +20,4 @@ for (const host of ['controlled', 'node-sqlite'] as const) ...property, check: (h, trace, expected) => property.check(h, trace, expected), }) - it.skipIf(oracleReplayRequested())( - 'rejects missing states from generated constructions', - () => - calibrateGeneratedConstructions((h, observe) => - receive(h, 'generated-control-' + host + '-' + sequence++, observe), - ), - ) }) diff --git a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts index daae66d545..8e49511728 100644 --- a/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts +++ b/packages/query-db-collection/tests/scope-omission-reacquisition-oracle.ts @@ -464,33 +464,3 @@ export function options( ], } } -/** The framework's generated construction objects differ from its seeded - * construction objects. Exercise the same builder, receiving path and check - * with generated inputs, then remove only its required actual reach fact. */ -export async function calibrateGeneratedConstructions( - receive: ( - h: History, - observe: (cut: string, value: unknown) => void, - ) => Promise, -) { - for (const input of fc.sample(arbitrary, { numRuns: 1, seed: 1313 })) { - for (const builder of constructed) { - const h = builder.build(input) - const trace: Array = [] - await receive(h, (checkpoint, value) => trace.push({ checkpoint, value })) - const clean = check(h, trace) - if (clean.kind !== 'pass') - throw new Error('Clean construction failed: ' + JSON.stringify(clean)) - const corrupt = trace.filter( - (o) => - o.checkpoint !== 'reach' || - !builder.enters.includes(o.value as string), - ) - const result = check(h, corrupt) - if (result.kind !== 'violation' || result.law !== 'constructed reach') - throw new Error( - 'Generated missing-state control passed for ' + builder.name, - ) - } - } -} From 9b9f591f01786f464037cc321d9f25b4108d04be Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 11:30:03 -0700 Subject: [PATCH 108/116] test: one calibration-rejection module for every package convex and query each had a copy of rejectsAs. It moves into packages/db/tests/calibration-rejection.ts beside rejection, asyncRejection, and clean, and the importers use the path they use for oracle-assertion-adapter. The replay manifest names the convex e2e owners' configs. Co-authored-by: Isaac --- .../tests/calibration-rejection.ts | 35 ------------------ ...aging-interleaving-oracle.property.test.ts | 2 +- ...ing-native-receipt-oracle.property.test.ts | 2 +- ...azy-paging-receipt-oracle.property.test.ts | 2 +- ...aging-revalidation-oracle.property.test.ts | 2 +- ...azy-paging-version-oracle.property.test.ts | 2 +- packages/db/tests/calibration-rejection.ts | 37 +++++++++++++++++++ packages/db/tests/oracle-replay-manifest.ts | 12 ++++++ .../tests/calibration-rejection.ts | 35 ------------------ .../cursor-pagination.cache-oracle.test.ts | 2 +- ...rsisted-direct-write-window.oracle.test.ts | 2 +- 11 files changed, 56 insertions(+), 77 deletions(-) delete mode 100644 packages/convex-db-collection/tests/calibration-rejection.ts delete mode 100644 packages/query-db-collection/tests/calibration-rejection.ts diff --git a/packages/convex-db-collection/tests/calibration-rejection.ts b/packages/convex-db-collection/tests/calibration-rejection.ts deleted file mode 100644 index 01bab4a094..0000000000 --- a/packages/convex-db-collection/tests/calibration-rejection.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Transport for a calibration whose mutant driver must be rejected by the -// existing comparison. The old calibration test accepted only the rejections -// that its Vitest matcher accepted; `rejectsAs` keeps exactly that matcher. -// It marks an accepted rejection as the comparison's verdict at the old -// checkpoint, so recordAssertionJudgment reports it under the property's law. -// Any other rejection becomes a new, unmarked driver error, so no checkpoint -// inside it counts. A mutant that resolves records no verdict here. -import { rejectAt } from '../../db/tests/oracle-assertion-adapter' - -export async function rejectsAs( - checkpoint: string, - run: () => Promise, - accepts: (failure: unknown) => void, -): Promise { - let rejected = false - let failure: unknown - try { - await run() - } catch (error) { - rejected = true - failure = error - } - if (!rejected) return - try { - accepts(failure) - } catch { - throw new Error( - `${checkpoint}: the mutant failed in a way that its calibration does not accept: ${String(failure)}`, - ) - } - rejectAt( - checkpoint, - failure instanceof Error ? failure : new Error(String(failure)), - ) -} 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 f1f54b5bb5..264116bebe 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 @@ -25,7 +25,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { SyncAppliedReceipt, SyncConfig } from '@tanstack/db' 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 479624f93f..6878c47ce9 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 @@ -22,7 +22,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { LazyReceiptModel } from './lazy-paging-receipt-model' 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 b6b1f85975..8f783e002e 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 @@ -29,7 +29,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { withTestCleanup } from './helpers' import { LazyReceiptModel } from './lazy-paging-receipt-model' import type { 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 d505806062..a60981e0bd 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 @@ -32,7 +32,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { lazyOracleBaseRuns, receiveBothSdks } from './lazy-paging-campaign' 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 d3d003717f..c84557ab03 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 @@ -32,7 +32,7 @@ import { recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' import { oracleProperty } from '../../db/tests/oracle-property' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { withTestCleanup } from './helpers' import { lazyFixture } from './lazy-paging-driver' import { assertLazySourceFacts } from './lazy-paging-model-oracle' diff --git a/packages/db/tests/calibration-rejection.ts b/packages/db/tests/calibration-rejection.ts index acac383a15..de14fc1672 100644 --- a/packages/db/tests/calibration-rejection.ts +++ b/packages/db/tests/calibration-rejection.ts @@ -3,6 +3,7 @@ // that the old matcher accepted; the driver then marks one held rejection with // rejectAt, so the property reports the check's law. Neither helper decides a // product result: the shared check still does. +import { rejectAt } from './oracle-assertion-adapter' /** * Runs one wrong answer through the shared check and returns the rejection @@ -54,3 +55,39 @@ export function clean(check: () => void): void { ) } } + +/** + * Runs a mutant driver whose rejection the old calibration accepted only + * through its Vitest matcher; `accepts` is that matcher, and it throws for any + * other rejection. An accepted rejection is marked as the comparison's + * verdict at `checkpoint`, so recordAssertionJudgment reports it under the + * property's law. Any other rejection becomes a new, unmarked driver error, + * so no checkpoint inside it counts. A mutant that resolves records no + * verdict here. + */ +export async function rejectsAs( + checkpoint: string, + run: () => Promise, + accepts: (failure: unknown) => void, +): Promise { + let rejected = false + let failure: unknown + try { + await run() + } catch (error) { + rejected = true + failure = error + } + if (!rejected) return + try { + accepts(failure) + } catch { + throw new Error( + `${checkpoint}: the mutant failed in a way that its calibration does not accept: ${String(failure)}`, + ) + } + rejectAt( + checkpoint, + failure instanceof Error ? failure : new Error(String(failure)), + ) +} diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index e0d6fa861d..5d2f1a1386 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -2147,6 +2147,18 @@ const outsideReplayConfigs = new Map([ * runs only under its own config. */ const ownerReplayConfigs = new Map([ + [ + `packages/convex-db-collection/e2e/auth-app-oracle.test.ts`, + `vitest.e2e.config.ts`, + ], + [ + `packages/convex-db-collection/e2e/public-pagination-oracle.test.ts`, + `vitest.e2e.config.ts`, + ], + [ + `packages/convex-db-collection/e2e/lazy-paging-oracle.test.ts`, + `vitest.lazy.e2e.config.ts`, + ], [ `packages/electric-db-collection/e2e/electric.e2e.test.ts`, `vitest.e2e.config.ts`, diff --git a/packages/query-db-collection/tests/calibration-rejection.ts b/packages/query-db-collection/tests/calibration-rejection.ts deleted file mode 100644 index 6cf0d9215a..0000000000 --- a/packages/query-db-collection/tests/calibration-rejection.ts +++ /dev/null @@ -1,35 +0,0 @@ -// Transport for a calibration whose mutant must be rejected by the existing -// comparison. The old calibration test accepted only the rejections that its -// Vitest matcher accepted; `rejectsAs` keeps exactly that matcher. It marks an -// accepted rejection as the comparison's verdict at `checkpoint`, so -// recordAssertionJudgment reports it under the property's law. Any other -// rejection becomes a new, unmarked driver error, so no checkpoint inside it -// counts. A mutant that resolves records no verdict here. -import { rejectAt } from '../../db/tests/oracle-assertion-adapter' - -export async function rejectsAs( - checkpoint: string, - run: () => Promise, - accepts: (failure: unknown) => void, -): Promise { - let rejected = false - let failure: unknown - try { - await run() - } catch (error) { - rejected = true - failure = error - } - if (!rejected) return - try { - accepts(failure) - } catch { - throw new Error( - `${checkpoint}: the mutant failed in a way that its calibration does not accept: ${String(failure)}`, - ) - } - rejectAt( - checkpoint, - failure instanceof Error ? failure : new Error(String(failure)), - ) -} 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 c5316b8a76..38ab118fa2 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 @@ -13,7 +13,7 @@ import { pass, } from '../../db/tests/oracle-property.js' import { createCursorPager } from '../src/index.js' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model-oracle.js' import type { Observation, Verdict } from '../../db/tests/oracle-property.js' 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 1f2a2bea8d..56e7d2e231 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 @@ -8,7 +8,7 @@ import { recordAssertionJudgment, recordedAssertionJudgment, } from '../../db/tests/oracle-assertion-adapter' -import { rejectsAs } from './calibration-rejection' +import { rejectsAs } from '../../db/tests/calibration-rejection' import { checked, createAdapter, From 5cfa0c7c71cff0471efeda2e081f7be3a452cf41 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 11:32:12 -0700 Subject: [PATCH 109/116] docs: AGENTS.md and the oracle-authoring skill state D2, wrapper owns, and witness and known-defect file naming Co-authored-by: Isaac --- .agents/skills/oracle-authoring/SKILL.md | 25 ++++++++++++++++++------ AGENTS.md | 9 +++++++++ docs/contributing/oracle-coverage.md | 11 +++++++---- 3 files changed, 35 insertions(+), 10 deletions(-) diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index 293727831d..b47c7b1bea 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -54,15 +54,28 @@ Follow the subsystem reading requirements in AGENTS.md. Name the production code that the oracle judges in `owns` on each `oracleProperty` call, as an inline literal array; the law has no owner field. +A file that registers through a wrapper passes the list in the wrapper call's +options literal, and the wrapper forwards it to `oracleProperty`. The [oracle guide](../../../docs/contributing/oracle-rubric.md#name-oracle-files-consistently) states the form that the gap report reads. -Keep unit tests and standalone concrete type assertions out of oracle 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. +An oracle file registers its tests only through `oracleProperty`; the +`oracle/no-unit-test` rule reports any other registration. Keep unit tests and +standalone concrete type assertions out of oracle files (ORC-016). A fixed +regression is a `histories.fixed` entry, or a history of an enumerated +property with the same driver, model, and check, without its own expected +answer. A finite table of cases is an enumerated source; a check that rejects a +wrong answer is a `calibrations` entry; a reach check over fixed scenarios is +an enumerated source with declared `states`. Never register a list of closures +under one property. 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, model-rule +expectations, and grammar-size checks belong in ordinary test files. An +`it.fails` witness of a known defect goes in an ordinary +`-known-defects.test.ts` that reuses the oracle's driver and check, +with the defect open in the coverage map. A spec for a runtime where +`oracleProperty` cannot load, such as Playwright or a bundled WebView suite, is +a receiving witness named `*-witness.*`. Use the [instrument library](../../../docs/contributing/instruments/index.md) when there is a material uncertainty. Select cards within the authorized task; diff --git a/AGENTS.md b/AGENTS.md index cc698689ac..52516e2f1f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -779,6 +779,15 @@ production with a hand-written value and a test that steps only the model. The `oracle/no-unit-test` lint rule reports every other registration in a file whose name contains `oracle` (maintainer decision, 2026-11-11). +Two kinds of test files support an oracle without being oracle files. An +`it.fails` witness of a known defect goes in an ordinary +`-known-defects.test.ts` that reuses the oracle's driver and check; +the coverage map records the defect as open and names the file. A spec for a +runtime where `oracleProperty` cannot load, such as a Playwright spec or a +suite that a WebView app bundles, is a receiving witness named `*-witness.*`: +it compares production with its oracle owner's shared check, and every oracle +lint rule except `oracle/no-unit-test` applies to it. + Name the production code that an oracle file judges in the `owns` list of each `oracleProperty` call, written inline as a literal array. A file that registers through a wrapper passes the list in the wrapper call's options literal, and diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cbde6853ba..04e5ca2034 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4509,8 +4509,10 @@ a mutant wrong-answer control, and an explicit statement of remaining limits. LEFT-joined filter: the ordered loader repeats a continuation `offset` capped at the window's `offset + limit` (`countAcquiredRows()`), so the provider serves the same page again. Witness: ranks 2, 1, 0 with only rank - 0 marked and limit 1 leaves the window empty (an `it.fails` case in the - Query DB ordered refetch oracle). Owner: the ordered-work oracle + 0 marked and limit 1 leaves the window empty (an `it.fails` case in + `packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts`, + through the Query DB ordered refetch oracle's driver and check in + `refetch-ordered-prefix-driver-oracle.ts`). Owner: the ordered-work oracle (`packages/db/tests/query/ordered-work-oracle.property.test.ts`) needs an offset-honoring provider; the Query DB ordered refetch owner then adds the offset encoding to its generated campaigns. @@ -4528,8 +4530,9 @@ a mutant wrong-answer control, and an explicit statement of remaining limits. return fewer rows than a request's `limit`. Under a joined, offset window, a row whose order changes can then drop out of the window after a refetch, through the loader's own repair; it reproduces on main. - Witness: the capped-provider `it.fails` case in the Query DB ordered - refetch oracle. Owner: the ordered-work oracle needs a page-capping + Witness: the capped-provider `it.fails` case in + `packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts`, + through the Query DB ordered refetch oracle's driver and check. Owner: the ordered-work oracle needs a page-capping provider; the Query DB ordered refetch owner then adds page caps to its generated campaigns. - [ ] **Refill after a joined-side removal when a continuation adds no rows.** From ce909f4d41fd0e52db837e088368af0c7d83181b Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 11:35:03 -0700 Subject: [PATCH 110/116] test(db): delete the oracle-declaration shim; the conversion registry becomes property-registry/ Nothing imports packages/db/tests/oracle-declaration.ts any more. The per-group registry modules that let the conversion branches work in parallel stay as data modules by area, renamed to property-registry/: two of them build their ids from driver tables, so folding them into oracle-config.ts would duplicate those tables. Every id in them is an enumerated source. Co-authored-by: Isaac --- packages/db/tests/oracle-config.ts | 2 +- packages/db/tests/oracle-declaration.ts | 4 ---- packages/db/tests/oracle-replay-manifest.ts | 2 +- .../adapters.ts | 8 +++----- .../db-collection-e2e.ts | 8 +++----- .../db-collection.ts | 8 +++----- .../db-core.ts | 8 +++----- .../db-query-includes.ts | 8 +++----- .../db-query-ordered.ts | 8 +++----- .../frameworks-ivm.ts | 8 +++----- .../{converted-properties => property-registry}/index.ts | 6 ++++-- .../{converted-properties => property-registry}/sqlite.ts | 8 +++----- 12 files changed, 30 insertions(+), 48 deletions(-) delete mode 100644 packages/db/tests/oracle-declaration.ts rename packages/db/tests/{converted-properties => property-registry}/adapters.ts (94%) rename packages/db/tests/{converted-properties => property-registry}/db-collection-e2e.ts (63%) rename packages/db/tests/{converted-properties => property-registry}/db-collection.ts (95%) rename packages/db/tests/{converted-properties => property-registry}/db-core.ts (96%) rename packages/db/tests/{converted-properties => property-registry}/db-query-includes.ts (96%) rename packages/db/tests/{converted-properties => property-registry}/db-query-ordered.ts (97%) rename packages/db/tests/{converted-properties => property-registry}/frameworks-ivm.ts (91%) rename packages/db/tests/{converted-properties => property-registry}/index.ts (76%) rename packages/db/tests/{converted-properties => property-registry}/sqlite.ts (93%) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 4059d701bb..335d7a4e92 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -8,7 +8,7 @@ import { configureOracles, validateOraclePropertyRegistry, } from '@tanstack/oracles' -import { convertedProperties } from './converted-properties/index.js' +import { convertedProperties } from './property-registry/index.js' export * from '@tanstack/oracles' diff --git a/packages/db/tests/oracle-declaration.ts b/packages/db/tests/oracle-declaration.ts deleted file mode 100644 index 316760a525..0000000000 --- a/packages/db/tests/oracle-declaration.ts +++ /dev/null @@ -1,4 +0,0 @@ -// 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-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 5d2f1a1386..b7f6ac080c 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -2,7 +2,7 @@ import { registeredOracleProperties } from './oracle-config.js' import { convertedOwners, convertedProperties, -} from './converted-properties/index.js' +} from './property-registry/index.js' /** * This manifest maps a replay name to the source file that can prove it ran. diff --git a/packages/db/tests/converted-properties/adapters.ts b/packages/db/tests/property-registry/adapters.ts similarity index 94% rename from packages/db/tests/converted-properties/adapters.ts rename to packages/db/tests/property-registry/adapters.ts index 6ab0548032..1fc8e6c33f 100644 --- a/packages/db/tests/converted-properties/adapters.ts +++ b/packages/db/tests/property-registry/adapters.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "adapters" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "adapters" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/converted-properties/db-collection-e2e.ts b/packages/db/tests/property-registry/db-collection-e2e.ts similarity index 63% rename from packages/db/tests/converted-properties/db-collection-e2e.ts rename to packages/db/tests/property-registry/db-collection-e2e.ts index 1a7e4e9ac5..83b93da90a 100644 --- a/packages/db/tests/converted-properties/db-collection-e2e.ts +++ b/packages/db/tests/property-registry/db-collection-e2e.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "db-collection-e2e" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "db-collection-e2e" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' // moves-oracle.suite.ts registers one enumerated property per sync mode. diff --git a/packages/db/tests/converted-properties/db-collection.ts b/packages/db/tests/property-registry/db-collection.ts similarity index 95% rename from packages/db/tests/converted-properties/db-collection.ts rename to packages/db/tests/property-registry/db-collection.ts index f6dc919552..674282cc3a 100644 --- a/packages/db/tests/converted-properties/db-collection.ts +++ b/packages/db/tests/property-registry/db-collection.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "db-collection" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "db-collection" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/converted-properties/db-core.ts b/packages/db/tests/property-registry/db-core.ts similarity index 96% rename from packages/db/tests/converted-properties/db-core.ts rename to packages/db/tests/property-registry/db-core.ts index f5887c49c5..39fc82f6fd 100644 --- a/packages/db/tests/converted-properties/db-core.ts +++ b/packages/db/tests/property-registry/db-core.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "db-core" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "db-core" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' // The framework drivers that register the shared window-history oracle, with diff --git a/packages/db/tests/converted-properties/db-query-includes.ts b/packages/db/tests/property-registry/db-query-includes.ts similarity index 96% rename from packages/db/tests/converted-properties/db-query-includes.ts rename to packages/db/tests/property-registry/db-query-includes.ts index 7c352d8967..7cb385417d 100644 --- a/packages/db/tests/converted-properties/db-query-includes.ts +++ b/packages/db/tests/property-registry/db-query-includes.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "db-query-includes" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "db-query-includes" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/converted-properties/db-query-ordered.ts b/packages/db/tests/property-registry/db-query-ordered.ts similarity index 97% rename from packages/db/tests/converted-properties/db-query-ordered.ts rename to packages/db/tests/property-registry/db-query-ordered.ts index 9f9b0d3ad7..a9eb28a9f3 100644 --- a/packages/db/tests/converted-properties/db-query-ordered.ts +++ b/packages/db/tests/property-registry/db-query-ordered.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "db-query-ordered" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "db-query-ordered" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' // pagination-oracle.property.test.ts diff --git a/packages/db/tests/converted-properties/frameworks-ivm.ts b/packages/db/tests/property-registry/frameworks-ivm.ts similarity index 91% rename from packages/db/tests/converted-properties/frameworks-ivm.ts rename to packages/db/tests/property-registry/frameworks-ivm.ts index 79cb33f4c6..1a1546262d 100644 --- a/packages/db/tests/converted-properties/frameworks-ivm.ts +++ b/packages/db/tests/property-registry/frameworks-ivm.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "frameworks-ivm" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "frameworks-ivm" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ diff --git a/packages/db/tests/converted-properties/index.ts b/packages/db/tests/property-registry/index.ts similarity index 76% rename from packages/db/tests/converted-properties/index.ts rename to packages/db/tests/property-registry/index.ts index 6c5292e98b..0188cd8996 100644 --- a/packages/db/tests/converted-properties/index.ts +++ b/packages/db/tests/property-registry/index.ts @@ -1,5 +1,7 @@ -// Collects each conversion group's property ids and replay owners. See the -// header of any group file in this directory. +// The property ids and replay owners of the oracles that moved every test +// registration into oracleProperty (the declareOracle removal, 2026-11-11), +// by area. oracle-config.ts registers the ids; oracle-replay-manifest.ts maps +// each to its replay owner. Each is an enumerated source. import * as adapters from './adapters.js' import * as dbCollection from './db-collection.js' import * as dbCollectionE2e from './db-collection-e2e.js' diff --git a/packages/db/tests/converted-properties/sqlite.ts b/packages/db/tests/property-registry/sqlite.ts similarity index 93% rename from packages/db/tests/converted-properties/sqlite.ts rename to packages/db/tests/property-registry/sqlite.ts index f7b8054722..6aca3b60a8 100644 --- a/packages/db/tests/converted-properties/sqlite.ts +++ b/packages/db/tests/property-registry/sqlite.ts @@ -1,8 +1,6 @@ -// The oracleProperty ids that the declareOracle removal adds for the -// "sqlite" conversion group, and their replay owners. Only that group's branch -// edits this file, so parallel groups do not conflict in oracle-config.ts -// or oracle-replay-manifest.ts. Integration folds both lists into those -// files and deletes this directory. +// The enumerated oracleProperty ids, and their replay owners, of the +// "sqlite" area: the properties that the declareOracle removal +// (2026-11-11) added. See index.ts. import type { ConvertedOwnerGroup } from './index.js' export const properties: ReadonlyArray = [ From 472c787e8c3e958f80fa02f56c2b0e28151615ab Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 11:53:40 -0700 Subject: [PATCH 111/116] docs: record the declareOracle conversion: moved tests, witnesses, known defects, and open exceptions Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 8 + .../2026-11-11-declare-oracle-removal.md | 323 ++++++++++++++++++ 2 files changed, 331 insertions(+) create mode 100644 docs/contributing/oracle-reviews/2026-11-11-declare-oracle-removal.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 04e5ca2034..4eb1bbcf18 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4330,6 +4330,14 @@ code from every literal `owns` list that it passes to a call: its `oracleProperty`, such as a shared conformance suite's registration function. +Every oracle file registers its tests only through `oracleProperty` +(maintainer decision, 2026-11-11). The +[conversion record](oracle-reviews/2026-11-11-declare-oracle-removal.md) +lists every test that moved to an ordinary file, the receiving witnesses +renamed `*-witness.*`, the known-defect witness files, and the open +exceptions: two multi-tab `it.fails` witnesses (#2267, #2209) and the +production-running reach censuses that wait for enforced reach minimums. + `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. A generated construction in each campaign fixes diff --git a/docs/contributing/oracle-reviews/2026-11-11-declare-oracle-removal.md b/docs/contributing/oracle-reviews/2026-11-11-declare-oracle-removal.md new file mode 100644 index 0000000000..acc1723ca7 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-11-declare-oracle-removal.md @@ -0,0 +1,323 @@ +# Removal of declareOracle: conversion record (2026-11-11) + +Maintainer decision, 2026-11-11: `declareOracle` is deleted from +`@tanstack/oracles`. Two laws replace it: + +- **D2.** An oracle file registers its tests only through `oracleProperty`. + `oracle/no-unit-test` reports every other test registration in a file + whose name contains `oracle`. +- **O1.** The gap report reads `owns` from an object-literal argument of any + call in a test file. A wrapper forwards its `owns` to `oracleProperty`. + +This record lists what the conversion moved, renamed, and left open. It +changes no product law. Each converted registration keeps its production +path, its inputs, its comparisons, and their checkpoints, except where this +record says otherwise. + +## Open items: D2 exceptions for the maintainer + +These sites still register plain tests in oracle files. `oracle/no-unit-test` +reports each one. None is a quiet exclusion: each is recorded here with its +reason and owner. + +| Site | Kind | Why it is not converted | Proposed resolution | +| --- | --- | --- | --- | +| `packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts`, "keeps a resumed lagging tab out of error when a peer deleted the row it updates" | `it.fails` known-defect witness, [#2267](https://github.com/TanStack/db/issues/2267) | Its driver, `runGeneratedHistory` (about 520 lines), closes over the describe's mutable host, stream, and reach state, so it cannot move to an ordinary known-defects file unchanged. | Extract the driver into a companion module, then move the witness to `multi-tab-electric-known-defects.test.ts`. Trace: `{ tabCount: 2, steps: [recover tab 1 pick 0; server change key 2 pick 1; server change key 1 pick 0; server change key 2 pick 0; server change key 1 pick 0; advance tab 0 pick 0] }`. | +| same file, "converges an open tab to the head after a lagging tab commits an older row and closes" | `it.fails` known-defect witness, [#2209](https://github.com/TanStack/db/issues/2209) | Same driver. | Same. Trace: `{ tabCount: 2, steps: [server change key 1 pick 1; catch up tab 0 pick 0; server change key 1 pick 1; catch up tab 0 pick 0; catch up tab 1 pick 1; close tab 1] }`. | +| `packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts`, "reaches a zero-sign source write in the fixed campaign" (at least 5 over seed 1657005) | Production-running reach census with a count minimum | The framework's declared reach has no minimum above 1. | F33: an enforced per-state minimum in `measured` of a generated source with fixed seeds, under law `reach minimum` (maintainer decision, 2026-11-11). | +| `packages/db/tests/collection-subscription-replay-oracle.property.test.ts` census (more than 0 over seed 1756) | Same | Same | F33 | +| `packages/db/tests/collection-state-retention-oracle.property.test.ts`, three censuses over seeds 86103 and 86104 (inserts > 0; deletes > absent deletes > 0; distinguishing partial updates > 0; zero-sign > 0) | Same | Same. "deletes > absent deletes" is "a present-row delete" at least once. | F33, with each relation as a state | +| `packages/db/tests/d2-source-reconciliation-oracle.property.test.ts`, "executes changed down-state sources and checks their restart replay" (500 samples, seed 1780, changed restarts > 0) | Same | Same | F33, as a second property with its own seed and run count | + +## Census hooks that stay pending F33 + +These production-running census hooks are `afterEach` or `afterAll` hooks, +not registrations, so the lint rule does not report them. They keep their +minimums unchanged until F33 adds enforced minimums: + +- `packages/query-db-collection/tests/refetch-ordered-prefix-oracle.test.ts`: + census hooks on its three generated properties (minimums 24, 3, and more + than 0). +- `packages/electric-db-collection/tests/electric-sdk-delivery-oracle.property.test.ts`: + three `afterEach` hooks that require every declared state at least once on + `sdk-snapshot-delivery`, `sdk-rotation-release`, and `sdk-source-release`. +- `packages/electric-db-collection/tests/electric-oracle.property.test.ts`: + the mutation-confirmation `afterAll` (every case at least once). +- `packages/electric-db-collection/e2e/sql-predicate-semantics-oracle.e2e.test.ts`: + `executedCells === oracleRuns(50)` and the required reach on seed 1814. +- `packages/offline-transactions/tests/transaction-settlement-oracle.property.test.ts`: + the restored-history `afterAll` (exact set of reached checkpoints). + +## Resolved during integration + +- The generated-construction guard in the Query and Chromium + scope-omission oracles (`it.skipIf(oracleReplayRequested())` around + `calibrateGeneratedConstructions`) is removed. The property's fixed seed is + the guard's seed, 1313; every `build()` records its obligations; and the + native calibrations `omits state ` remove exactly that reach fact from a + constructed history under law `constructed reach`. A mutant `build()` that + records no obligation fails all six native calibrations. +- Two closure-runner properties in db-collection, three sweeps in + db-sqlite-persistence-core, and `sqlite-ordinary.fixed-cases` became + enumerated sources: one history per scenario, with each required reach + fact a declared state. +- `electric.design-witnesses` selects four named procedures through a data + history. Three compare production with the model's recomputation and stay. + The fourth, the authored HTTP transcript, compares production with + authored transcripts and snapshots, so it belongs in an ordinary file. It + is listed as a follow-up: moving it needs its trace helpers extracted from + the oracle file. + +## Known-defect witnesses in ordinary files + +| File | Defect | Oracle owner | +| --- | --- | --- | +| `packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts` (8 `it.fails`) | [#2139](https://github.com/TanStack/db/issues/2139), rotated follower recovery | `persisted-oracle.test.ts`, through `persisted-follower-recovery-oracle.ts` | +| `packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts` (2 `it.fails`) | The open coverage-map items "Offset-only providers under a joined ordered window" and "A page-capping provider during an ordered repair" | `refetch-ordered-prefix-oracle.test.ts`, through `refetch-ordered-prefix-driver-oracle.ts` | + +## Receiving witnesses (renamed `*-witness.*`) + +`oracleProperty` registers through Vitest, and its main entry is Node-only. +Specs that run in Playwright, and suites that the Capacitor and Tauri WebView +apps bundle, keep their plain tests. Each compares production with the shared +check of its Vitest oracle owner. ESLint applies every oracle rule except +`no-unit-test` to them (`db/receiving-witnesses` in `eslint.config.js`). A +witness is not an oracle file, so the gap report does not credit its tests. +The two Vitest e2e witnesses, `install-layout` and `opfs-open-outcome`, lose +their `owns` lists for that reason. + +- `packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/eager-gap-conformance-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/electric-source-prefix-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/install-layout-oracle.e2e.test.ts` → `packages/browser-db-sqlite-persistence/e2e/install-layout-witness.e2e.test.ts` +- `packages/browser-db-sqlite-persistence/e2e/leader-close-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/leader-close-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-oracle.e2e.test.ts` → `packages/browser-db-sqlite-persistence/e2e/opfs-open-outcome-witness.e2e.test.ts` +- `packages/browser-db-sqlite-persistence/e2e/opfs-request-order-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/opfs-request-order-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/persisted-startup-conformance-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/reload-class-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/reload-class-witness.opfs.spec.ts` +- `packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-oracle.opfs.spec.ts` → `packages/browser-db-sqlite-persistence/e2e/shared-driver-fairness-witness.opfs.spec.ts` +- `packages/db-collection-e2e/src/suites/joins-oracle.suite.ts` → `packages/db-collection-e2e/src/suites/joins-witness.suite.ts` +- `packages/db-collection-e2e/src/suites/live-updates-oracle.suite.ts` → `packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts` +- `packages/db-collection-e2e/src/suites/pagination-oracle.suite.ts` → `packages/db-collection-e2e/src/suites/pagination-witness.suite.ts` +- `packages/db-collection-e2e/src/suites/predicates-oracle.suite.ts` → `packages/db-collection-e2e/src/suites/predicates-witness.suite.ts` +- `packages/db/e2e/indexed-db/cross-tab-oracle.spec.ts` → `packages/db/e2e/indexed-db/cross-tab-witness.spec.ts` +- `packages/db/e2e/indexed-db/value-oracle.spec.ts` → `packages/db/e2e/indexed-db/value-witness.spec.ts` + +## Tests moved to ordinary files + +Each group's own move list follows. A moved test keeps its body and its +assertions; only `oracleRoute` is removed. These tests run no production, or +compare production with written values, so they never close a gap. + +### db-core + +In each case the test bodies are unchanged and `oracleRoute` is removed. + +| From | To | +|---|---| +| `proxy-detachment-contract-oracle.test.ts`: own-data-property calibration ×4, accepts a native cyclic snapshot ×4, rejects a fault in a cyclic snapshot ×12 | `proxy-detachment-calibration.test.ts` | +| `proxy-revert-oracle.property.test.ts`: 3 fixed-seed censuses | `proxy-revert-census.test.ts` | +| `conformance/window-history-oracle.ts`: "rejects a pending keyed map…", "retained subscriptions distinguish…" | `conformance-window-history-calibration.test.ts` | +| `duration-query-equality-oracle.test.ts`: "calibrates all ten fields…" | `duration-query-equality-calibration.test.ts` | +| `conformance/indexing-reuse-oracle.test.ts`: both tests | `conformance/indexing-reuse-calibration.test.ts` | + +The rest of db-core's moves are the renamed calibration files in the inventory below. + +**New test-free companions:** `proxy-revert-model-oracle.ts`, `duration-query-equality-model-oracle.ts`, `production-error-messages-docs-oracle.ts`, `live-query-cache-lifecycle-model-oracle.ts`. **New support module:** `calibration-rejection.ts`. + +### db-query-ordered + +All files below are in `packages/db/tests/query/`. Each moved test keeps its old describe name, minus `oracleRoute`. + +| From | To | Tests | +|---|---|---| +| where-predicate-publication-oracle.property.test.ts | where-predicate-publication-model.test.ts | "fixed snapshot histories distinguish Kleene logic from two-valued logic" | +| ordered-source-loader-oracle.test.ts | ordered-source-loader-model.test.ts | "enumerates every staged-boundary explicit-window cell exactly once" | +| custom-index-order-selection-oracle.test.ts | custom-index-order-selection-model.test.ts | "rejects wrong output-indexing observations: %j" (15 cases) | +| load-subset-oracle.property.test.ts | load-subset-model.test.ts | "uses SQL unknown for nullish comparisons in the independent model"; "generates repeated, cursor, empty, and unbounded exact demands" | +| ordered-source-loader-state-oracle.test.ts | ordered-source-loader-state-model.test.ts | "rejects a retirement start observed %s" (2 cases) | +| pagination-oracle.property.test.ts | pagination-recomputation.test.ts | 18 literal-expectation sites, 25 tests. Two named drivers moved with them: `expectInflightRequestFillsNewWindow`, `expectOnDemandWindowsAreCompletionOrderIndependent`. | +| pagination-oracle.property.test.ts | pagination-model.test.ts | "rejects the right NaN window members in the wrong order"; "rejects a malformed bounded-repair request trace"; "distinguishes a filtered full-source fallback from a repair tie predicate"; the model halves of the eager NaN window cells and the filter-update cells | +| ordered-work-oracle.property.test.ts | ordered-work-examples.test.ts | 29 tests: the grammar-size check, 17 literal-expectation tests, 4 no-production checker calibrations, and the no-production full-join census | +| ordered-lifecycle-oracle.property.test.ts | ordered-lifecycle-model.test.ts | 8 tests: 5 grammar-size checks, the Promise-wrapped observation calibration, the palette anchor, and "generates the nullable value and lifecycle dimensions" (seed 93472, 100 runs) | + +Each new ordinary file has its own id counter. The counters only make collection ids unique. + +### db-query-includes + +1. **includes-functional-projection-oracle → includes-functional-projection.test.ts:** + - covers the declared output and renamed-field products + - covers every declared boundary product without duplicate cells +2. **includes-cross-formulation-oracle → includes-cross-formulation.test.ts:** + - bounded grammar axes distinguish routes, order, partitions, and windows + - TLP checker rejects a wrong branch even when the union is complete +3. **includes-temporal-oracle → includes-temporal.test.ts:** + - rejects an obsolete child row at the first scheduled checkpoint + - rejects a failed release that suppresses reacquisition +4. **includes-context-transport-oracle → includes-context-transport.test.ts:** + - expands every declared product without duplicate cells + - expands every declared input boundary without duplicates + - rejects lost children and cross-parent reuse at the public checkpoint + - rejects missing and duplicated joined contributions +5. **virtual-row-fields-effect-oracle → new virtual-row-fields-effect.test.ts:** + - rejects a hostile Effect metadata ledger: %s (12 cases) + - permits reordered delivery and fresh opaque keys after an exit + - permits empty callback partitions while preserving exact delta events + - records scalar callback values before later object mutation + - preserves later interval judgments after an invalid unknown update + - The fixtures `calibrationRows` and `calibrationLedger` moved with these tests. +6. **virtual-row-fields-input-oracle → new virtual-row-fields-input.test.ts:** + - rejects a hostile callback-input observation (8 cases) + - rejects a hostile derived ordinary output (5 cases) + - copies callback input before later source objects change + - accepts readable inherited source metadata at callback input + - copies nested callback values before later child objects change + - rejects an inherited replacement for an authored inline field (2 cases) + - rejects an extra own field on an inline array (2 cases) + - The `sourceFact` fixture moved with these tests. +7. **virtual-row-fields-oracle → virtual-row-fields.test.ts:** + - rejects source metadata on a grouped output (4 cases) + - rejects a hostile complete metadata result (14 cases) + - rejects a hostile metadata publication (8 cases) + - rejects facade enrichment of an inline value (4 cases) + - rejects a hostile nullable whole-row observation (3 cases) +8. **includes-collection-oracle → includes-collection.test.ts:** + - rejects a hostile facade interval observation (6 cases) +9. **includes-work-counter-oracle → includes-work-counter.test.ts:** + - reaches every window dimension in the executed fixed cases. This is a grammar-only census that runs no production: at least 3 per premise, seed 2137, 60 runs. + - rejects a source payload mutated in place (4 cases) +10. **includes-oracle.property → new includes-history-grammar.test.ts:** + - the sibling topology targets rows under one parent + - the descendant remains attached before its ancestor moves + - reconstructs every supported two-transition placement (grammar census, seed 1725) + - keeps each route-history topology and destination in the grammar + - reconstructs and distinguishes the split/atomic batch matrix + - keeps retired-route and moved-child matrix cuts distinct (grammar census, seeds 1726, 1733, 1727) + +**New test-free companion modules.** Each holds definitions moved unchanged from its oracle file. None registers tests, and no ordinary file imports an oracle file. +- `includes-cross-formulation-model-oracle.ts` +- `includes-temporal-check-oracle.ts` +- `includes-context-transport-model-oracle.ts` +- `virtual-row-fields-effect-model-oracle.ts` +- `virtual-row-fields-model-oracle.ts` +- `includes-work-counter-model-oracle.ts` +- `includes-history-grammar-oracle.ts` + +### frameworks-ivm + +All paths below are under `packages/`. Each test was moved without edits except that `oracleRoute` was removed. + +**db-ivm:** +- `reaches every kind, mutation, and verdict in the fixed campaign`: `db-ivm/tests/hash-identity-oracle.property.test.ts` → `db-ivm/tests/hash-identity-grammar.test.ts`. +- `generates ordered histories that promote retained rows`, `rejects invalid negative and duplicate-key input states`, and `rejects omitted, sign-flipped, wrong-member, wrong-window, wrong-limit, missing-promotion, and truthiness-filtered output`: `incrementalization-law-oracle.property.test.ts` → `db-ivm/tests/incrementalization-law-calibration.test.ts`. +- `reaches cancelled NaN-prefixed values beside other values`: `index-refinement-oracle.property.test.ts` → `db-ivm/tests/index-refinement-grammar.test.ts`. +- `rejects a representative absent from source inputs` (from distinct-representative) and `rejects omitted, duplicate and wrong-member snapshots` (from distinct-incrementalization) → `db-ivm/tests/distinct-calibration.test.ts`. +- `rejects end-relative slicing and offset-first clamping` and `rejects excess signed weight %s across a moved window`: `operators/topk-support-window-oracle.test.ts` → `db-ivm/tests/operators/topk-support-window-calibration.test.ts`. + +**react-db:** +- `rejects wrong descriptor release observation: %s` (×4): source-id-reuse → `react-db/tests/source-id-reuse-release-calibration.test.tsx`. +- `distinguishes a client-layout diagnostic from unrelated messages`: ssr-effects → `react-db/tests/ssr-effects-calibration.test.ts`. +- `rejects wrong multi-source release observation: %s` (×4): descriptor-start-release → `react-db/tests/descriptor-start-release-calibration.test.tsx`. +- `schema judgment rejects wrong binding and mutation observations %j` (×72), with its `observationFromModel` helper: bound-schema-options → `react-db/tests/bound-schema-options-calibration.test.tsx`. + +### sqlite + +- `opfs-open-outcome-oracle.test.ts`: "reaches every outcome that this driver controls" → `opfs-open-outcome-jsdom-grammar.test.ts` +- `expression-index-oracle.test.ts`: "reconstructs the retained generated grammar…" and "rejects the generated grammar when the %s axis is ablated" → `expression-index-grammar.test.ts` +- `sqlite-temporal-value-oracle.test.ts`: "distinguishes type and value for $0" and "rejects missing, duplicate, and reversed query results" → `sqlite-temporal-value-calibration.test.ts` +- `persisted-demand-coverage-campaign-oracle.test.ts`: "rejects observations that ignore lost rows…" → `persisted-demand-coverage-campaign-calibration.test.ts` +- `persisted-demand-coverage-oracle.test.ts`: "rejects wrong observations at a checkpoint", "distinguishes the law from the wrong design: %s", "enumerates every declared cell once" → `persisted-demand-coverage-calibration.test.ts` +- `shared-driver-fairness-oracle.test.ts`: "reconstructs the known persist-first witness…", "proves tail-order ablation…", "calibrates the checker against a synthetic persist-first observation" → `shared-driver-fairness-grammar.test.ts` +- `ordinary-transaction-work-oracle.ts`: "reconstructs the bounded generated action-history axes", "reconstructs independent-key work shapes and host caps", "calibrates the complete-observation comparison" → `ordinary-transaction-work-calibration.test.ts` +- `per-collection-coordinator-oracle.test.ts`: the 7 "calibrates …" tests (clone checker, acknowledgement, collection-scoped acquisition, lease checker, SQLite-state/fresh-registry, collection-key comparison, owner checker) → `per-collection-coordinator-calibration.test.ts` +- `persisted-oracle.test.ts`: "reconstructs the full grammar and rejects ablated…", "rejects named wrong answer: $name", "append-only transaction judgment rejects omission and reordering", "rejects an evidence-coupled on-demand hydration mutant" → `persisted-model-calibration.test.ts` + +### adapters + +- **offline:** + - `transaction-settlement-oracle` "rejects a wrong restored observation: %s" → `transaction-settlement-restored-model.test.ts` + - `leadership-replay-oracle` "append-only replay judgment rejects stale admission…" → `leadership-replay-ledger-model.test.ts` +- **convex:** + - `lazy-paging-revalidation-oracle` (2 model calibrations) → `lazy-paging-revalidation-model.test.ts` + - `lazy-paging-interleaving-oracle` "permits core fulfillment after abort…" → `lazy-paging-interleaving-model.test.ts` + - `lazy-paging-oracle` "rejects wrong coverage and transient windows…" → `lazy-paging-model.test.ts` + - `lazy-paging-oracle` "rejects invalid lazy row caps before readiness: %s" → `lazy-paging-config.test.ts` + - `lazy-paging-version-oracle` "allows stuttering…" → `lazy-paging-version-model.test.ts` + - `lazy-paging-failure-history-oracle` "rejects old graphs…" → `lazy-paging-failure-history-model.test.ts` + - `pagination-oracle` "calibrates complete monotone snapshots at %s" → `pagination-trace.test.ts` + - `lazy-paging-write-model-oracle.test.ts` (2 tests) → renamed to `lazy-paging-write-model.test.ts`, and removed from `test:oracles` in `package.json` +- **query:** + - `cursor-pagination.oracle` "preserves ties, filtering and reverse total order" and "rejects capped-page, wrong-order and duplicate-row answers" (with `checkRead`) → `cursor-pagination-reference.test.ts` + - `optimistic-ordered-refetch-oracle` "rejects ordered wrong observation: %s" → `optimistic-ordered-refetch-calibration.test.ts` + - The structural census's `>= 48` size check → a new test in `refetch-ordered-prefix.test.ts` +- **electric:** + - pg-serializer decoder half (7) and "rejects $name" (13) → `pg-serializer-calibration.test.ts` + - "models a same-key %s…" → `electric-recovery-durable-row.test.ts` + - "fixed recovery corpus reaches every deletion and reload combination" (grammar-only census) → `electric-recovery-publication-grammar.test.ts` + - 5 grammar and wrong-answer tests from `electric-oracle` → `electric-adapter-grammar.test.ts` + +### Inventory: new ordinary test files + +- `packages/browser-db-sqlite-persistence/tests/opfs-open-outcome-jsdom-grammar.test.ts` +- `packages/browser-db-sqlite-persistence/tests/per-collection-coordinator-calibration.test.ts` +- `packages/browser-db-sqlite-persistence/tests/shared-driver-fairness-grammar.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-config.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-failure-history-model.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-interleaving-model.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-model.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-revalidation-model.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-version-model.test.ts` +- `packages/convex-db-collection/tests/lazy-paging-write-model.test.ts` +- `packages/convex-db-collection/tests/pagination-trace.test.ts` +- `packages/db-ivm/tests/distinct-calibration.test.ts` +- `packages/db-ivm/tests/hash-identity-grammar.test.ts` +- `packages/db-ivm/tests/incrementalization-law-calibration.test.ts` +- `packages/db-ivm/tests/index-refinement-grammar.test.ts` +- `packages/db-ivm/tests/operators/topk-support-window-calibration.test.ts` +- `packages/db-sqlite-persistence-core/tests/ordinary-transaction-work-calibration.test.ts` +- `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-calibration.test.ts` +- `packages/db-sqlite-persistence-core/tests/persisted-demand-coverage-campaign-calibration.test.ts` +- `packages/db-sqlite-persistence-core/tests/persisted-known-defects.test.ts` +- `packages/db-sqlite-persistence-core/tests/persisted-model-calibration.test.ts` +- `packages/db-sqlite-persistence-core/tests/sqlite-temporal-value-calibration.test.ts` +- `packages/db/tests/collection-gc-retention-calibration.test.ts` +- `packages/db/tests/collection-row-metadata-composition.test.ts` +- `packages/db/tests/collection-subscription-lifecycle-grammar.test.ts` +- `packages/db/tests/conformance-window-history-calibration.test.ts` +- `packages/db/tests/conformance/indexing-reuse-calibration.test.ts` +- `packages/db/tests/db-client-hydration-authority.test.ts` +- `packages/db/tests/documented-mutations-calibration.test.ts` +- `packages/db/tests/duration-query-equality-calibration.test.ts` +- `packages/db/tests/indexed-db/cross-tab-calibration.test.ts` +- `packages/db/tests/indexed-db/persistence-values-calibration.test.ts` +- `packages/db/tests/live-query-cache-lifecycle-calibration.test.ts` +- `packages/db/tests/live-query-cache-options-calibration.test.ts` +- `packages/db/tests/mutation-confirmation-calibration.test.ts` +- `packages/db/tests/notification-laws-calibration.test.ts` +- `packages/db/tests/production-error-messages-calibration.test.ts` +- `packages/db/tests/proxy-detachment-calibration.test.ts` +- `packages/db/tests/proxy-revert-census.test.ts` +- `packages/db/tests/query/custom-index-order-selection-model.test.ts` +- `packages/db/tests/query/includes-history-grammar.test.ts` +- `packages/db/tests/query/load-subset-model.test.ts` +- `packages/db/tests/query/ordered-lifecycle-model.test.ts` +- `packages/db/tests/query/ordered-source-loader-model.test.ts` +- `packages/db/tests/query/ordered-source-loader-state-model.test.ts` +- `packages/db/tests/query/ordered-work-examples.test.ts` +- `packages/db/tests/query/pagination-model.test.ts` +- `packages/db/tests/query/pagination-recomputation.test.ts` +- `packages/db/tests/query/virtual-row-fields-effect.test.ts` +- `packages/db/tests/query/virtual-row-fields-input.test.ts` +- `packages/db/tests/query/where-predicate-publication-model.test.ts` +- `packages/electric-db-collection/tests/electric-adapter-grammar.test.ts` +- `packages/electric-db-collection/tests/electric-recovery-durable-row.test.ts` +- `packages/electric-db-collection/tests/electric-recovery-publication-grammar.test.ts` +- `packages/node-db-sqlite-persistence/tests/expression-index-grammar.test.ts` +- `packages/offline-transactions/tests/leadership-replay-ledger-model.test.ts` +- `packages/offline-transactions/tests/transaction-settlement-restored-model.test.ts` +- `packages/query-db-collection/tests/cursor-pagination-reference.test.ts` +- `packages/query-db-collection/tests/optimistic-ordered-refetch-calibration.test.ts` +- `packages/query-db-collection/tests/refetch-ordered-prefix-known-defects.test.ts` +- `packages/react-db/tests/bound-schema-options-calibration.test.tsx` +- `packages/react-db/tests/descriptor-start-release-calibration.test.tsx` +- `packages/react-db/tests/source-id-reuse-release-calibration.test.tsx` +- `packages/react-db/tests/ssr-effects-calibration.test.ts` From e532187c826cd53ce272b219170d935b0b2360f4 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 14:36:58 -0700 Subject: [PATCH 112/116] chore(deps): pin @tanstack/oracles and its lint plugin to f8a58a3 f8a58a3 is TanStack/oracles main after #33, which deletes declareOracle and renames the ./declaration entry to ./route. The four witness suites import ./route. The tarballs' dependencies and peers are unchanged from 84cd34a, so only the URLs and integrity hashes change in the lockfile. Co-authored-by: Isaac --- package.json | 4 ++-- .../src/suites/joins-witness.suite.ts | 2 +- .../src/suites/live-updates-witness.suite.ts | 2 +- .../src/suites/pagination-witness.suite.ts | 2 +- .../src/suites/predicates-witness.suite.ts | 2 +- packages/db/package.json | 2 +- pnpm-lock.yaml | 24 +++++++++---------- 7 files changed, 19 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 162130a0af..5569857e8a 100644 --- a/package.json +++ b/package.json @@ -52,8 +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@84cd34a", - "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a", + "@tanstack/eslint-plugin-oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3", "@tanstack/typedoc-config": "0.3.5", "@tanstack/vite-config": "0.5.2", "@testing-library/jest-dom": "^6.9.1", diff --git a/packages/db-collection-e2e/src/suites/joins-witness.suite.ts b/packages/db-collection-e2e/src/suites/joins-witness.suite.ts index 5b832f0fb7..9c95b07e35 100644 --- a/packages/db-collection-e2e/src/suites/joins-witness.suite.ts +++ b/packages/db-collection-e2e/src/suites/joins-witness.suite.ts @@ -10,7 +10,7 @@ import { describe, expect, it, vi } from 'vitest' // The browser-safe route entry: these suites also run inside the Capacitor // and Tauri WebView apps, which cannot load the framework's Node-only main // entry, so this witness keeps its routes without oracleProperty. -import { oracleRoute } from '@tanstack/oracles/declaration' +import { oracleRoute } from '@tanstack/oracles/route' import { waitFor, waitForQueryData } from '../utils/helpers' import type { Comment, diff --git a/packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts b/packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts index 177b47a4e0..92ae2878dc 100644 --- a/packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts +++ b/packages/db-collection-e2e/src/suites/live-updates-witness.suite.ts @@ -4,7 +4,7 @@ import { createLiveQueryCollection } from '@tanstack/db' // The browser-safe route entry: these suites also run inside the Capacitor // and Tauri WebView apps, which cannot load the framework's Node-only main // entry, so this witness keeps its routes without oracleProperty. -import { oracleRoute } from '@tanstack/oracles/declaration' +import { oracleRoute } from '@tanstack/oracles/route' import { waitForQueryData } from '../utils/helpers' import { expectNotificationHistory, diff --git a/packages/db-collection-e2e/src/suites/pagination-witness.suite.ts b/packages/db-collection-e2e/src/suites/pagination-witness.suite.ts index 5956b7df7f..fd8da9b360 100644 --- a/packages/db-collection-e2e/src/suites/pagination-witness.suite.ts +++ b/packages/db-collection-e2e/src/suites/pagination-witness.suite.ts @@ -9,7 +9,7 @@ import { createLiveQueryCollection, eq } from '@tanstack/db' // The browser-safe route entry: these suites also run inside the Capacitor // and Tauri WebView apps, which cannot load the framework's Node-only main // entry, so this witness keeps its routes without oracleProperty. -import { oracleRoute } from '@tanstack/oracles/declaration' +import { oracleRoute } from '@tanstack/oracles/route' import { assertAllItemsMatch, assertCollectionSize, diff --git a/packages/db-collection-e2e/src/suites/predicates-witness.suite.ts b/packages/db-collection-e2e/src/suites/predicates-witness.suite.ts index a04b5f0fe6..af9c389b93 100644 --- a/packages/db-collection-e2e/src/suites/predicates-witness.suite.ts +++ b/packages/db-collection-e2e/src/suites/predicates-witness.suite.ts @@ -25,7 +25,7 @@ import { // The browser-safe route entry: these suites also run inside the Capacitor // and Tauri WebView apps, which cannot load the framework's Node-only main // entry, so this witness keeps its routes without oracleProperty. -import { oracleRoute } from '@tanstack/oracles/declaration' +import { oracleRoute } from '@tanstack/oracles/route' import { assertAllItemsMatch, assertCollectionSize } from '../utils/assertions' import { waitForQueryData } from '../utils/helpers' import type { E2ETestConfig, Post, User } from '../types' diff --git a/packages/db/package.json b/packages/db/package.json index 505e8950a9..05f33eff28 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -64,7 +64,7 @@ "devDependencies": { "@playwright/test": "^1.65.0", "@tanstack/config": "^0.22.2", - "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a", + "@tanstack/oracles": "https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3", "@vitest/coverage-istanbul": "^5.1.0", "arktype": "^2.2.9", "fake-indexeddb": "^6.2.8", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 308765ccc3..987069d7fa 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -61,11 +61,11 @@ importers: 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@84cd34a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a(eslint@9.39.5(jiti@2.7.0)) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3(eslint@9.39.5(jiti@2.7.0)) '@tanstack/oracles': - specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3(fast-check@4.10.2)(vitest@5.1.0) '@tanstack/typedoc-config': specifier: 0.3.5 version: 0.3.5(typescript@5.9.3) @@ -1313,8 +1313,8 @@ importers: 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@84cd34a - version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0) + specifier: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3 + version: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3(fast-check@4.10.2)(vitest@5.1.0) '@vitest/coverage-istanbul': specifier: ^5.1.0 version: 5.1.0(vitest@5.1.0) @@ -6323,8 +6323,8 @@ packages: peerDependencies: eslint: ^8.0.0 || ^9.0.0 - '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a': - resolution: {integrity: sha512-e+rlsviqbUckek4jKgcuujqyQzTR1LYVvU7B6LafwhTiKTgQhYIZUaQbhMUG74G/ldnCUgmI8G6XCxfe9ZPOrQ==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a} + '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3': + resolution: {integrity: sha512-wXtg5bE7auy8mdEZMbOiAvS6hWDZ5+R7Wwu7K2gCJYILVZPLQtfZwWH56zT9J+67CU2M+s1qFaosbKu0wRGUvg==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3} version: 0.0.0 engines: {node: '>=20'} peerDependencies: @@ -6334,8 +6334,8 @@ packages: resolution: {integrity: sha512-w1+YvwmTkwt5n8yUqEH3IWZ9DsmorCZW/EQ3KYHnqEgIkOZpH54NvW6/Epq3Dzb8H0ifIeYX8PExvrNLIH5FIA==} engines: {node: '>=20.19'} - '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a': - resolution: {integrity: sha512-TxEfjXnOW8VrmRe+CZd0HVfeYtU88ciGqzTS+WZ+FyTtfYQ9tvhT9h7bp3Jj3f3T1IY89RUimr+KtV6EVTk8eg==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a} + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3': + resolution: {integrity: sha512-XJwD7KC82hSTGeuXEgv08WD/NMvvxOgxa1+bAHArJcyaL1C95S6Y0+wLhK5xcWf7Y6HJDEWezkCaW1wnx8k+tg==, tarball: https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3} version: 0.0.0 engines: {node: '>=20'} hasBin: true @@ -18606,13 +18606,13 @@ snapshots: - supports-color - typescript - '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@84cd34a(eslint@9.39.5(jiti@2.7.0))': + '@tanstack/eslint-plugin-oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/eslint-plugin-oracles@f8a58a3(eslint@9.39.5(jiti@2.7.0))': dependencies: eslint: 9.39.5(jiti@2.7.0) '@tanstack/history@1.162.5': {} - '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@84cd34a(fast-check@4.10.2)(vitest@5.1.0)': + '@tanstack/oracles@https://pkg.pr.new/TanStack/oracles/@tanstack/oracles@f8a58a3(fast-check@4.10.2)(vitest@5.1.0)': dependencies: fast-check: 4.10.2 typescript-for-tanstack-oracles: typescript@5.9.3 From f5aaf3e40a81eaec612199a50d7b48f795dac8bb Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 16:15:53 -0700 Subject: [PATCH 113/116] chore(lint): a reviewed, exact, shrink-only exception list for the eight remaining plain registrations The maintainer decided to "merge sooner and then remove once F33 is in" (2026-11-11). registrationExceptions in scripts/oracle-lint-settings.mjs lists the five oracle files that still register plain tests: the two multi-tab it.fails witnesses (#2267, #2209; retired by extracting the multi-tab driver) and the six production-running censuses (retired by F33), each with its exact report count. eslint.config.js and agent-lint-feedback.mjs turn oracle/no-unit-test off for those files only. scripts/check-registration-exceptions.mjs, run by pnpm lint:oracles, lints each listed file with the rule on and fails when its count differs from the record in either direction, or when the list gains a file or a count rises against the merge-base. The coverage map records the list and its removal condition. CI reported 6 of the 8 sites because pnpm's recursive lint stopped at packages/db: the two multi-tab sites in browser-db-sqlite-persistence were not linted, not conforming. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 11 +- eslint.config.js | 7 +- package.json | 2 +- scripts/agent-lint-feedback.mjs | 8 +- scripts/check-registration-exceptions.mjs | 120 ++++++++++++++++++++++ scripts/oracle-lint-settings.mjs | 53 ++++++++++ 6 files changed, 197 insertions(+), 4 deletions(-) create mode 100644 scripts/check-registration-exceptions.mjs diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 8273263061..d4af3c6639 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4347,7 +4347,16 @@ Every oracle file registers its tests only through `oracleProperty` lists every test that moved to an ordinary file, the receiving witnesses renamed `*-witness.*`, the known-defect witness files, and the open exceptions: two multi-tab `it.fails` witnesses (#2267, #2209) and the -production-running reach censuses that wait for enforced reach minimums. +production-running reach censuses that wait for enforced reach minimums. Those +eight registrations in five files are the reviewed exception list +`registrationExceptions` in `scripts/oracle-lint-settings.mjs`, under the +maintainer decision "merge sooner and then remove once F33 is in" +(2026-11-11). ESLint turns `oracle/no-unit-test` off for those files only, +and `scripts/check-registration-exceptions.mjs`, run by `pnpm lint:oracles`, +fails when a file's report count differs from its recorded count, or when +the list grows against the merge-base. An entry is removed when its retiring +work lands: F33 for the censuses, and extracting the multi-tab driver for +the two witnesses. `scripts/lint-scope-oracle.property.test.js` owns lint selection after an edit (`scripts.lint-scope`): the changed scope, the named (pre-commit) scope, diff --git a/eslint.config.js b/eslint.config.js index bdd8d4dbfc..1f586c797e 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,6 +1,9 @@ import { tanstackConfig } from '@tanstack/eslint-config' import oraclePlugin from '@tanstack/eslint-plugin-oracles' -import { oracleLintSettings } from './scripts/oracle-lint-settings.mjs' +import { + oracleLintSettings, + registrationExceptionConfig, +} from './scripts/oracle-lint-settings.mjs' export default [ ...tanstackConfig, @@ -58,6 +61,8 @@ export default [ { settings: oracleLintSettings }, oraclePlugin.configs.recommended, oraclePlugin.configs.production, + // The reviewed exception list: see scripts/oracle-lint-settings.mjs. + registrationExceptionConfig, // Receiving witnesses for runtimes that are not Vitest on Node (maintainer // decision, 2026-11-11). oracleProperty registers through Vitest and its // main entry is Node-only, so Playwright specs and the shared suites that diff --git a/package.json b/package.json index 5569857e8a..a8ebaa4b69 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "lint": "pnpm --filter \"./packages/**\" lint", "lint:check": "pnpm --filter \"./packages/**\" lint --fix=false", "lint:changed": "node scripts/agent-lint-feedback.mjs", - "lint:oracles": "node scripts/agent-lint-feedback.mjs --all", + "lint:oracles": "node scripts/agent-lint-feedback.mjs --all && node scripts/check-registration-exceptions.mjs", "lint:run-budgets": "node scripts/check-oracle-run-counts.mjs", "gap-report": "oracle-gaps --env-prefix TANSTACK_DB_ORACLE", "test:lint-rules": "vitest run --config scripts/oracle-scripts.vitest.config.mjs", diff --git a/scripts/agent-lint-feedback.mjs b/scripts/agent-lint-feedback.mjs index 813ff05c5b..59074c5472 100644 --- a/scripts/agent-lint-feedback.mjs +++ b/scripts/agent-lint-feedback.mjs @@ -71,7 +71,10 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { parseArgs } from 'node:util' import { failureLine, loadFailureLine } from './lint-load-failure.mjs' -import { oracleLintSettings } from './oracle-lint-settings.mjs' +import { + oracleLintSettings, + registrationExceptionConfig, +} from './oracle-lint-settings.mjs' const formats = new Set([`claude`, `codex`, `gemini`, `copilot`, `text`]) @@ -419,6 +422,9 @@ function createLinter() { ...oraclePlugin.configs.recommended, files: [`**/*.ts`, `**/*.tsx`, `**/*.js`, `**/*.mjs`, `**/*.jsx`], }, + // The reviewed exception list; check-registration-exceptions.mjs keeps + // each file's count exact. + registrationExceptionConfig, ], }) } diff --git a/scripts/check-registration-exceptions.mjs b/scripts/check-registration-exceptions.mjs new file mode 100644 index 0000000000..c9f69a45e1 --- /dev/null +++ b/scripts/check-registration-exceptions.mjs @@ -0,0 +1,120 @@ +#!/usr/bin/env node +/** + * Keeps the reviewed exception list of `oracle/no-unit-test` exact. + * + * `registrationExceptions` in scripts/oracle-lint-settings.mjs names the + * oracle files that may still register plain tests, with the number of + * reports that each has today and the work that retires it. ESLint turns the + * rule off for those files only, so this check lints each of them with the + * rule on and fails when: + * + * - a file's report count differs from its recorded count, in either + * direction: a new plain test is not hidden, and a converted one must be + * removed from the list; + * - the list gains a file, or a file's count rises, against the merge-base: + * the list may only shrink. + * + * `pnpm lint:oracles` runs it after the oracle lint. + */ +import { execFileSync } from 'node:child_process' +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { fileURLToPath, pathToFileURL } from 'node:url' +import tsParser from '@typescript-eslint/parser' +import oraclePlugin from '@tanstack/eslint-plugin-oracles' +import { ESLint } from 'eslint' +import { mergeBase } from './check-oracle-run-counts.mjs' +import { + oracleLintSettings, + registrationExceptions, +} from './oracle-lint-settings.mjs' + +const root = resolve(fileURLToPath(new URL(`..`, import.meta.url))) +const RULE = `oracle/no-unit-test` + +/** The report count of `oracle/no-unit-test` in each listed file, with the rule on. */ +async function reportCounts(files) { + const eslint = new ESLint({ + cwd: root, + overrideConfigFile: true, + overrideConfig: [ + { + files: [`**/*.ts`, `**/*.tsx`, `**/*.js`, `**/*.mjs`, `**/*.jsx`], + languageOptions: { parser: tsParser }, + }, + { settings: oracleLintSettings }, + { + ...oraclePlugin.configs.recommended, + files: [`**/*.ts`, `**/*.tsx`, `**/*.js`, `**/*.mjs`, `**/*.jsx`], + }, + ], + }) + const results = await eslint.lintFiles(files.map((file) => join(root, file))) + return new Map( + results.map((result) => [ + result.filePath.slice(root.length + 1), + result.messages.filter((message) => message.ruleId === RULE).length, + ]), + ) +} + +/** The exception list at the merge-base, or undefined when it has none. */ +async function baseExceptions() { + const base = mergeBase(root) + let text + try { + text = execFileSync( + `git`, + [`show`, `${base}:scripts/oracle-lint-settings.mjs`], + { cwd: root, encoding: `utf8` }, + ) + } catch { + return undefined + } + // The settings module imports nothing, so a copy of it loads on its own. + const directory = mkdtempSync(join(tmpdir(), `registration-exceptions-`)) + try { + const copy = join(directory, `oracle-lint-settings.mjs`) + writeFileSync(copy, text) + const module = await import(pathToFileURL(copy).href) + return module.registrationExceptions + } finally { + rmSync(directory, { recursive: true, force: true }) + } +} + +const problems = [] +const counts = await reportCounts( + registrationExceptions.map(({ file }) => file), +) +for (const { file, reports, retiredBy } of registrationExceptions) { + const actual = counts.get(file) + if (actual === undefined) problems.push(`${file}: listed, but not linted`) + else if (actual !== reports) + problems.push( + `${file}: ${actual} oracle/no-unit-test report(s), but the exception list records ${reports}. ${actual < reports ? `Lower the count, or remove the entry when it reaches 0` : `Register the new tests through oracleProperty`} (retired by ${retiredBy}).`, + ) +} +const base = await baseExceptions() +if (base !== undefined) { + const before = new Map(base.map(({ file, reports }) => [file, reports])) + for (const { file, reports } of registrationExceptions) { + if (!before.has(file)) + problems.push( + `${file}: added to the exception list; the list may only shrink.`, + ) + else if (reports > before.get(file)) + problems.push( + `${file}: its exception count rose from ${before.get(file)} to ${reports}; the list may only shrink.`, + ) + } +} +if (problems.length > 0) { + console.error(problems.join(`\n`)) + process.exitCode = 1 +} else { + console.log( + `registration exceptions: ${registrationExceptions.length} file(s), every count exact`, + ) +} diff --git a/scripts/oracle-lint-settings.mjs b/scripts/oracle-lint-settings.mjs index 85d6d63183..a5c12774c9 100644 --- a/scripts/oracle-lint-settings.mjs +++ b/scripts/oracle-lint-settings.mjs @@ -21,3 +21,56 @@ export const oracleLintSettings = { replayWitnessModules: [`packages/db/tests/oracle-replay-witness.ts`], }, } + +/** + * Oracle files that may still register plain tests, by maintainer decision: + * "merge sooner and then remove once F33 is in" (2026-11-11), for the + * declareOracle removal (TanStack/db #2421). `oracle/no-unit-test` is off for + * each listed file in eslint.config.js and scripts/agent-lint-feedback.mjs. + * `scripts/check-registration-exceptions.mjs` (part of `pnpm lint:oracles`) + * lints each file with the rule on and fails when its report count differs + * from `reports`, in either direction, and fails when the list grows or a + * count rises against the merge-base. The list may only shrink: remove an + * entry when its retiring work lands, and then the rule applies again. + */ +export const registrationExceptions = [ + { + file: `packages/browser-db-sqlite-persistence/tests/multi-tab-electric-oracle.test.ts`, + reports: 2, + retiredBy: `multi-tab driver extraction`, + why: `The it.fails known-defect witnesses for #2267 and #2209 share the 520-line runGeneratedHistory driver, which closes over describe state; they move to an ordinary known-defects file once the driver is extracted.`, + }, + { + file: `packages/db/tests/collection-state-retention-oracle.property.test.ts`, + reports: 3, + retiredBy: `F33`, + why: `Production-running fixed-seed censuses with count minimums (seeds 86103 and 86104), which need enforced reach minimums.`, + }, + { + file: `packages/db/tests/collection-subscription-lifecycle-publication-oracle.property.test.ts`, + reports: 1, + retiredBy: `F33`, + why: `A census of at least 5 zero-sign source writes over fixed seed 1657005.`, + }, + { + file: `packages/db/tests/collection-subscription-replay-oracle.property.test.ts`, + reports: 1, + retiredBy: `F33`, + why: `A census of more than 0 over fixed seed 1756.`, + }, + { + file: `packages/db/tests/d2-source-reconciliation-oracle.property.test.ts`, + reports: 1, + retiredBy: `F33`, + why: `A census of changed restarts over 500 samples of seed 1780.`, + }, +] + +export const registrationExceptionDecision = `"merge sooner and then remove once F33 is in" (maintainer, 2026-11-11)` + +/** The flat-config block that turns oracle/no-unit-test off for the listed files only. */ +export const registrationExceptionConfig = { + name: `db/oracle-registration-exceptions`, + files: registrationExceptions.map(({ file }) => file), + rules: { 'oracle/no-unit-test': `off` }, +} From bce80f72487fe36014d593d2b1119a6cb680c356 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 16:15:54 -0700 Subject: [PATCH 114/116] test: fix the f8a58a3 type checks, and register the merged sync-metadata fixed histories through oracleProperty - change-event-history, the lifecycle publication product module, and custom-index-order-selection import OpKind, DemandName, and MaterializedRow from the companion modules their definitions moved to; custom-index-order-selection keeps its cached! assertion. - ivm.distinct-representatives-fixed names its history type: the shared check takes any plan, so the driver's plan was inferred as unknown. - electron's default config leaves reload-class-oracle.electron.e2e.test.ts out of the typecheck collector, which finds no suite in an oracleProperty-only file. - #2418 landed on main after this branch's base, with plain fixed tests in sync-metadata-session-persisted-oracle.test.ts (18 sites) and sync-metadata-session-sqlite-oracle.test.ts (1). Each history now runs as an enumerated property, through its production path's model, driver, and check from the generated campaigns: sync-metadata-session.persisted-fixed and sync-metadata-session.sqlite-seed-reinsert-reload-replay. Both files leave the typecheck collector, as the other oracleProperty-only files do. Co-authored-by: Isaac --- ...nct-representative-oracle.property.test.ts | 7 +- ...-metadata-session-persisted-oracle.test.ts | 567 ++++++++++-------- ...ync-metadata-session-sqlite-oracle.test.ts | 54 +- .../db-sqlite-persistence-core/vite.config.ts | 2 + .../tests/change-event-history-oracle.test.ts | 7 +- ...on-lifecycle-publication-product-oracle.ts | 5 +- packages/db/tests/property-registry/sqlite.ts | 12 + ...ustom-index-order-selection-oracle.test.ts | 3 +- .../vite.config.ts | 8 +- 9 files changed, 393 insertions(+), 272 deletions(-) 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 05ab37eff7..d10d325ea6 100644 --- a/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts +++ b/packages/db-ivm/tests/distinct-representative-oracle.property.test.ts @@ -190,7 +190,12 @@ oracleProperty({ // comparison as the generated campaign; no hand-authored expected answer. // The recipe grammar cannot take a resolved history back, so it is one // enumerated property. -oracleProperty({ +// The shared check takes any plan, so the history type is given here rather +// than inferred from it. +oracleProperty< + { name: string; worlds: DistinctBucketHistory }, + ReadonlyArray +>({ id: `ivm.distinct-representatives-fixed`, law: cell0.law, owns: [`src/operators/distinct.ts#distinct`], diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-oracle.test.ts index 745c8b6d65..22f0da8a12 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-persisted-oracle.test.ts @@ -1,7 +1,5 @@ -import { describe, it } from 'vitest' import { constructRegime, - describeMismatch, historyRegimes, persistedCachedGrammar, persistedConflictGrammar, @@ -86,233 +84,6 @@ import type { OwedSessionRun } from './sync-metadata-session-runs' * next one in the stream. */ -async function assertPersistedHistory( - history: ReadonlyArray, - productionPath: SessionGrammar = persistedFlatGrammar, -): Promise { - const { mismatch } = await runSessionHistory(productionPath, history, () => - createPersistedDriver( - 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)) -} - -describe(`sync metadata session oracle through the persisted wrapper`, () => { - it.each([false, true])( - `keeps a held cursor visible after a later commit (store hold: %s)`, - async (holdStore) => { - await assertPersistedHistory([ - { type: `hold`, store: holdStore }, - { type: `begin` }, - { type: `collectionSet`, key: `cursor` }, - { type: `commit` }, - { type: `begin` }, - { type: `upsert`, key: 1, metadata: false }, - { type: `commit` }, - ]) - }, - ) - - // The reported path: once the wrapper has handed transactions to core and - // core holds them, a read after a third transaction loses the earliest - // write. A store hold keeps them in the wrapper's staging instead; that - // neighbour passes on 30175be48. - it.each([false, true])( - `keeps a held write visible after a third transaction (store hold: %s)`, - async (holdStore) => { - await assertPersistedHistory([ - { type: `hold`, store: holdStore }, - { type: `begin` }, - { type: `upsert`, key: 1, metadata: true }, - { type: `commit` }, - { type: `begin` }, - { type: `commit` }, - { type: `begin` }, - { type: `collectionSet`, key: `cursor` }, - { type: `commit` }, - ]) - }, - ) - - // A source insert equal to a seeded row is still an insert: the source never - // wrote the row, so it names the row's metadata and clears the seed's. - it(`clears a seed's metadata when the source inserts an equal row`, async () => { - await assertPersistedHistory( - [ - { type: `seed`, key: 1 }, - { type: `begin` }, - { type: `reinsert`, key: 1, metadata: false }, - ], - persistedSeedGrammar, - ) - }) - - // An equal re-insert keeps metadata as an update does, without writing it, - // so the latest seed still governs the row's metadata after it commits. - it(`lets a later seed replace metadata that an accepted re-insert kept`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `seed`, key: 1, bare: true }, - { type: `upsert`, key: 1, metadata: false }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `seed`, key: 1 }, - { type: `commit` }, - { type: `seed`, key: 1, bare: true }, - ], - persistedSeedGrammar, - ) - }) - - // The same holds when the re-insert commits ahead of the enclosing - // transaction whose row it re-inserted and lands on a seeded row. - it(`lets a later seed replace metadata that an inner re-insert kept`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `seed`, key: 2 }, - { type: `upsert`, key: 2, metadata: false }, - { type: `begin` }, - { type: `reinsert`, key: 2, metadata: false }, - { type: `commit` }, - { type: `seed`, key: 2, bare: true }, - ], - persistedNestedSeedGrammar, - ) - }) - - // A persisted cache row is a seed (decision C), so a later DbClient seed - // replaces its metadata until the source writes the metadata. - it(`lets a later seed replace a cached row's metadata`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `upsert`, key: 1, metadata: false }, - { type: `seed`, key: 1, bare: true }, - ], - persistedNestedSeedGrammar, - ) - }) - - // An update without metadata keeps metadata, so the insert before it still - // decides the row's metadata. When an inner transaction commits ahead and - // changes the row that insert lands on, the insert clears the metadata. - it(`rejudges an enclosing re-insert that a later update kept`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `upsert`, key: 1, metadata: false }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `upsert`, key: 1, metadata: false }, - { type: `begin` }, - { type: `upsert`, key: 1, metadata: true }, - { type: `commit` }, - ], - persistedNestedSeedGrammar, - ) - }) - - // Every insert without metadata in an enclosing transaction is re-judged - // when an inner one commits ahead: an earlier re-insert that now lands on a - // different row clears metadata, and a later equal re-insert keeps that. - it(`rejudges every enclosing re-insert of a key, not only the last`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `insertOverSeed`, key: 1, metadata: false }, - { type: `commit` }, - { type: `begin` }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `begin` }, - { type: `upsert`, key: 1, metadata: true }, - { type: `commit` }, - ], - persistedNestedSeedGrammar, - ) - }) - - // An enclosing insert that cleared metadata can become an equal re-insert - // of the row an inner transaction committed ahead; it then keeps the - // metadata a seed supplied beneath. - it(`lets an enclosing clear become an equal re-insert after an inner commit`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `insertOverSeed`, key: 1, metadata: false }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `begin` }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `seed`, key: 1 }, - { type: `commit` }, - ], - persistedNestedSeedGrammar, - ) - }) - - // A later seed beneath an open re-insert does not change its kind, but an - // inner update that merges over that seed and commits ahead does: the - // re-insert then lands on a different row and clears metadata (decision 10). - it(`lets an inner update merged over a later seed change an enclosing re-insert`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `upsert`, key: 1, metadata: true }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `begin` }, - { type: `seed`, key: 1, bare: true }, - { type: `upsert`, key: 1, metadata: false }, - { type: `commit` }, - ], - persistedNestedSeedGrammar, - ) - }) - - // An inner transaction reads the open writes of the transactions it began - // inside: one session reads all its open writes. - it(`reads an enclosing transaction's write inside a nested transaction`, async () => { - await assertPersistedHistory( - [{ type: `begin` }, { type: `rowSet`, key: 1 }, { type: `begin` }], - persistedNestedGrammar, - ) - }) - - // An inner transaction's equal re-insert keeps the row's metadata. When it - // commits ahead of the outer transaction whose row it re-inserted, it lands - // where that row is absent; it is still the re-insert it was written as. - it(`keeps an inner re-insert's metadata when it commits ahead of the row it re-inserted`, async () => { - await assertPersistedHistory( - [ - { type: `begin` }, - { type: `rowSet`, key: 1 }, - { type: `commit` }, - { type: `begin` }, - { type: `upsert`, key: 1, metadata: false }, - { type: `begin` }, - { type: `reinsert`, key: 1, metadata: false }, - { type: `commit` }, - ], - persistedConflictGrammar, - ) - }) - - // An insert without metadata clears the row's metadata, even inside the - // transaction that set it (2026-10-05 rules). - it(`reads an insert's implicit metadata clear in the open transaction`, async () => { - await assertPersistedHistory([ - { type: `begin` }, - { type: `rowSet`, key: 2 }, - { type: `upsert`, key: 2, metadata: false }, - ]) - }) -}) - /** * 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 @@ -766,32 +537,6 @@ 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, persistedPeerGrammar), - ) - it.each(peerFullReplays)(`replays under full rows: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerFullGrammar), - ) - // Fixed history found by the earlier raw persisted-peers-full random campaign - // (seed 654774679; the native campaign draws other histories for that seed): - // 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, persistedPeerFullGrammar)) - it.each(peerOnDemandReplays)(`replays on demand: %s`, (_name, history) => - assertPersistedHistory(history, persistedPeerOnDemandGrammar), - ) - it.each(readSeedReplays)( - `replays with reads and seeds: %s`, - (_name, history) => - assertPersistedHistory(history, persistedReadSeedGrammar), - ) -}) - // The broad peer campaigns can already generate this interaction. This constructive // grammar measures its scarce overlap and adjacent legal histories directly; // it uses the existing independent model, driver, and per-action comparison. @@ -985,3 +730,315 @@ oracleProperty< calibrations: sessionRunCalibrations(), timeout: 120_000, }) + +// Fixed regressions and peer replays, each replayed through the same +// production path, model, driver, and comparison as its generated campaign. +const fixedSessionHistories: Array<{ + name: string + path: string + history: ReadonlyArray +}> = [ + { + name: `keeps a held cursor visible after a later commit (store hold: false)`, + path: persistedFlatGrammar.name, + history: [ + { type: `hold`, store: false }, + { type: `begin` }, + { type: `collectionSet`, key: `cursor` }, + { type: `commit` }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: false }, + { type: `commit` }, + ], + }, + { + name: `keeps a held cursor visible after a later commit (store hold: true)`, + path: persistedFlatGrammar.name, + history: [ + { type: `hold`, store: true }, + { type: `begin` }, + { type: `collectionSet`, key: `cursor` }, + { type: `commit` }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: false }, + { type: `commit` }, + ], + }, + // The reported path: once the wrapper has handed transactions to core and + // core holds them, a read after a third transaction loses the earliest + // write. A store hold keeps them in the wrapper's staging instead; that + // neighbour passes on 30175be48. + { + name: `keeps a held write visible after a third transaction (store hold: false)`, + path: persistedFlatGrammar.name, + history: [ + { type: `hold`, store: false }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: true }, + { type: `commit` }, + { type: `begin` }, + { type: `commit` }, + { type: `begin` }, + { type: `collectionSet`, key: `cursor` }, + { type: `commit` }, + ], + }, + // The reported path: once the wrapper has handed transactions to core and + // core holds them, a read after a third transaction loses the earliest + // write. A store hold keeps them in the wrapper's staging instead; that + // neighbour passes on 30175be48. + { + name: `keeps a held write visible after a third transaction (store hold: true)`, + path: persistedFlatGrammar.name, + history: [ + { type: `hold`, store: true }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: true }, + { type: `commit` }, + { type: `begin` }, + { type: `commit` }, + { type: `begin` }, + { type: `collectionSet`, key: `cursor` }, + { type: `commit` }, + ], + }, + // A source insert equal to a seeded row is still an insert: the source never + // wrote the row, so it names the row's metadata and clears the seed's. + { + name: `clears a seed's metadata when the source inserts an equal row`, + path: persistedSeedGrammar.name, + history: [ + { type: `seed`, key: 1 }, + { type: `begin` }, + { type: `reinsert`, key: 1, metadata: false }, + ], + }, + // An equal re-insert keeps metadata as an update does, without writing it, + // so the latest seed still governs the row's metadata after it commits. + { + name: `lets a later seed replace metadata that an accepted re-insert kept`, + path: persistedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `seed`, key: 1, bare: true }, + { type: `upsert`, key: 1, metadata: false }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `seed`, key: 1 }, + { type: `commit` }, + { type: `seed`, key: 1, bare: true }, + ], + }, + // The same holds when the re-insert commits ahead of the enclosing + // transaction whose row it re-inserted and lands on a seeded row. + { + name: `lets a later seed replace metadata that an inner re-insert kept`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `seed`, key: 2 }, + { type: `upsert`, key: 2, metadata: false }, + { type: `begin` }, + { type: `reinsert`, key: 2, metadata: false }, + { type: `commit` }, + { type: `seed`, key: 2, bare: true }, + ], + }, + // A persisted cache row is a seed (decision C), so a later DbClient seed + // replaces its metadata until the source writes the metadata. + { + name: `lets a later seed replace a cached row's metadata`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `upsert`, key: 1, metadata: false }, + { type: `seed`, key: 1, bare: true }, + ], + }, + // An update without metadata keeps metadata, so the insert before it still + // decides the row's metadata. When an inner transaction commits ahead and + // changes the row that insert lands on, the insert clears the metadata. + { + name: `rejudges an enclosing re-insert that a later update kept`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `upsert`, key: 1, metadata: false }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `upsert`, key: 1, metadata: false }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: true }, + { type: `commit` }, + ], + }, + // Every insert without metadata in an enclosing transaction is re-judged + // when an inner one commits ahead: an earlier re-insert that now lands on a + // different row clears metadata, and a later equal re-insert keeps that. + { + name: `rejudges every enclosing re-insert of a key, not only the last`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `insertOverSeed`, key: 1, metadata: false }, + { type: `commit` }, + { type: `begin` }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: true }, + { type: `commit` }, + ], + }, + // An enclosing insert that cleared metadata can become an equal re-insert + // of the row an inner transaction committed ahead; it then keeps the + // metadata a seed supplied beneath. + { + name: `lets an enclosing clear become an equal re-insert after an inner commit`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `insertOverSeed`, key: 1, metadata: false }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `begin` }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `seed`, key: 1 }, + { type: `commit` }, + ], + }, + // A later seed beneath an open re-insert does not change its kind, but an + // inner update that merges over that seed and commits ahead does: the + // re-insert then lands on a different row and clears metadata (decision 10). + { + name: `lets an inner update merged over a later seed change an enclosing re-insert`, + path: persistedNestedSeedGrammar.name, + history: [ + { type: `begin` }, + { type: `upsert`, key: 1, metadata: true }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `begin` }, + { type: `seed`, key: 1, bare: true }, + { type: `upsert`, key: 1, metadata: false }, + { type: `commit` }, + ], + }, + // An inner transaction reads the open writes of the transactions it began + // inside: one session reads all its open writes. + { + name: `reads an enclosing transaction's write inside a nested transaction`, + path: persistedNestedGrammar.name, + history: [{ type: `begin` }, { type: `rowSet`, key: 1 }, { type: `begin` }], + }, + // An inner transaction's equal re-insert keeps the row's metadata. When it + // commits ahead of the outer transaction whose row it re-inserted, it lands + // where that row is absent; it is still the re-insert it was written as. + { + name: `keeps an inner re-insert's metadata when it commits ahead of the row it re-inserted`, + path: persistedConflictGrammar.name, + history: [ + { type: `begin` }, + { type: `rowSet`, key: 1 }, + { type: `commit` }, + { type: `begin` }, + { type: `upsert`, key: 1, metadata: false }, + { type: `begin` }, + { type: `reinsert`, key: 1, metadata: false }, + { type: `commit` }, + ], + }, + // An insert without metadata clears the row's metadata, even inside the + // transaction that set it (2026-10-05 rules). + { + name: `reads an insert's implicit metadata clear in the open transaction`, + path: persistedFlatGrammar.name, + history: [ + { type: `begin` }, + { type: `rowSet`, key: 2 }, + { type: `upsert`, key: 2, metadata: false }, + ], + }, + ...peerReplays.map(([name, history]) => ({ + name: `replays: ${name}`, + path: persistedPeerGrammar.name, + history, + })), + ...peerFullReplays.map(([name, history]) => ({ + name: `replays under full rows: ${name}`, + path: persistedPeerFullGrammar.name, + history, + })), + // Fixed history found by the earlier raw persisted-peers-full random campaign + // (seed 654774679; the native campaign draws other histories for that seed): + // 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. + { + name: `replays under full rows: keeps source metadata reads consistent across an inner re-insert and reload`, + path: persistedPeerFullGrammar.name, + history: seedReinsertReloadReplay, + }, + ...peerOnDemandReplays.map(([name, history]) => ({ + name: `replays on demand: ${name}`, + path: persistedPeerOnDemandGrammar.name, + history, + })), + ...readSeedReplays.map(([name, history]) => ({ + name: `replays with reads and seeds: ${name}`, + path: persistedReadSeedGrammar.name, + history, + })), +] + +// The production paths that the fixed histories name. +const fixedSessionPaths = new Map( + [ + persistedConflictGrammar, + persistedFlatGrammar, + persistedNestedGrammar, + persistedNestedSeedGrammar, + persistedPeerFullGrammar, + persistedPeerGrammar, + persistedPeerOnDemandGrammar, + persistedReadSeedGrammar, + persistedSeedGrammar, + ].map((productionPath) => [productionPath.name, productionPath]), +) + +oracleProperty({ + id: `sync-metadata-session.persisted-fixed`, + law: { + statement: `A source read through the persisted wrapper gives what the same read gives on a bare Collection, for each named fixed regression and peer replay on its production path: source reads after every action, consumer rows at the final checkpoint, and in the peer production paths the stored rows, agree with the session model.`, + authority: `The sync metadata session oracle's laws and decisions (packages/db/tests/sync-metadata-session-oracle.ts header).`, + limits: `The named histories below, each on one production path; restart and resume reads are outside them.`, + }, + owns: [`src/persisted.ts`], + histories: fixedSessionHistories, + label: ({ name }) => name, + resolve: ({ path, history }) => ({ + productionPath: fixedSessionPaths.get(path)!, + history, + }), + model: ({ productionPath, history }) => [ + { path: productionPath.name, name: `history`, actions: history.length }, + ], + driver: ({ productionPath, history }, observe) => + observeSessionRun( + observe, + { path: productionPath.name, name: `history`, actions: history.length }, + history, + () => + runSessionHistory(productionPath, history, () => + createPersistedDriver( + productionPath.seeds, + productionPath.cached === true, + productionPath.readHold === true, + productionPath.peers === true, + productionPath.fullRows === true, + productionPath.onDemand === true, + ), + ), + ), + check: (_plan, observations, owed) => + checkSessionRuns(observations, owed, { constructive: false }), + timeout: 240_000, +}) diff --git a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-oracle.test.ts b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-oracle.test.ts index 5e4099bc6e..9ccfd10897 100644 --- a/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-oracle.test.ts +++ b/packages/db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-oracle.test.ts @@ -1,19 +1,21 @@ // @vitest-environment node -import { it } from 'vitest' import { - describeMismatch, persistedPeerFullGrammar, persistedPeerGrammar, persistedPeerOnDemandGrammar, runSessionHistory, seedReinsertReloadReplay, } from '../../db/tests/sync-metadata-session-oracle' -import { oracleReplayTest } from '../../db/tests/oracle-config' +import { oracleProperty } from '../../db/tests/oracle-property' import { registerKeptMetadataDurabilityProperty, registerSeedReinsertReloadProperty, } from './sync-metadata-session-campaign-oracle' import { createSQLiteDriver } from './sync-metadata-session-sqlite-driver' +import { + checkSessionRuns, + observeSessionRun, +} from './sync-metadata-session-runs' import type { SessionAction, SessionGrammar, @@ -32,16 +34,44 @@ async function check( createSQLiteDriver(productionPath), ) } -oracleReplayTest(it).each(productionPaths)( - `receives the seed/re-insert/reload replay through SQLite ($name)`, - async (productionPath) => { - const result = await check(seedReinsertReloadReplay, productionPath) - if (result.mismatch) - throw new Error( - describeMismatch(seedReinsertReloadReplay, result.mismatch), - ) +// The seed/re-insert/reload replay through SQLite on each peer production +// path, with the model, driver, and comparison of the generated campaigns. +oracleProperty({ + id: `sync-metadata-session.sqlite-seed-reinsert-reload-replay`, + law: { + statement: `A source read through SQLite persistence gives what the same read gives on a bare Collection for the seed/re-insert/reload replay on every peer production path.`, + authority: `The sync metadata session oracle's laws and decisions (packages/db/tests/sync-metadata-session-oracle.ts header).`, + limits: `The one named replay on the three peer production paths.`, }, -) + owns: [`src/persisted.ts`, `src/sqlite-core-adapter.ts`], + histories: productionPaths.map(({ name }) => name), + label: (name) => + `receives the seed/re-insert/reload replay through SQLite (${name})`, + resolve: (name) => ({ + productionPath: productionPaths.find((path) => path.name === name)!, + }), + model: ({ productionPath }) => [ + { + path: productionPath.name, + name: `history`, + actions: seedReinsertReloadReplay.length, + }, + ], + driver: ({ productionPath }, observe) => + observeSessionRun( + observe, + { + path: productionPath.name, + name: `history`, + actions: seedReinsertReloadReplay.length, + }, + seedReinsertReloadReplay, + () => check(seedReinsertReloadReplay, productionPath), + ), + check: (_plan, observations, owed) => + checkSessionRuns(observations, owed, { constructive: false }), + timeout: 240_000, +}) // The peer action asserts actual requiresFullReload delivery before the // shared comparison passes; the property changes no host premise. diff --git a/packages/db-sqlite-persistence-core/vite.config.ts b/packages/db-sqlite-persistence-core/vite.config.ts index f32d6a329d..510969b2a5 100644 --- a/packages/db-sqlite-persistence-core/vite.config.ts +++ b/packages/db-sqlite-persistence-core/vite.config.ts @@ -23,6 +23,8 @@ const config = defineConfig({ `tests/collection-setup-reopen-oracle.test.ts`, `tests/persisted-demand-coverage-campaign-oracle.test.ts`, + `tests/sync-metadata-session-persisted-oracle.test.ts`, + `tests/sync-metadata-session-sqlite-oracle.test.ts`, `tests/shared-logical-scheduling-oracle.test.ts`, `tests/persisted-reload-replacement-oracle.test.ts`, `tests/persisted-readiness-oracle.test.ts`, diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 0b524fe104..87921f1428 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -30,7 +30,12 @@ import { expectedRowsAfter, perKeyEventTrace, } from './change-event-history-model-oracle.js' -import type { Key, Op, TestItem } from './change-event-history-fixture.js' +import type { + Key, + Op, + OpKind, + TestItem, +} from './change-event-history-fixture.js' import type { LocalOnlyCollectionUtils } from '../src/local-only' import type { Collection } from '../src/index' import type { BaseIndex } from '../src/indexes/base-index' diff --git a/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts b/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts index 980956f922..2af0809052 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication-product-oracle.ts @@ -7,7 +7,10 @@ * These definitions moved here unchanged so that the ordinary test can check * the product's size without importing the oracle test file. */ -import type { LifecycleCommand } from './collection-subscription-lifecycle-grammar-oracle.js' +import type { + DemandName, + LifecycleCommand, +} from './collection-subscription-lifecycle-grammar-oracle.js' import type { PublicationPhase, Row, diff --git a/packages/db/tests/property-registry/sqlite.ts b/packages/db/tests/property-registry/sqlite.ts index 6aca3b60a8..0e63c61d2a 100644 --- a/packages/db/tests/property-registry/sqlite.ts +++ b/packages/db/tests/property-registry/sqlite.ts @@ -19,6 +19,8 @@ export const properties: ReadonlyArray = [ `persisted-demand-coverage.next-page`, `persisted-demand-coverage.storage-fault-sweep`, `persisted-demand-coverage.release-sweep`, + `sync-metadata-session.persisted-fixed`, + `sync-metadata-session.sqlite-seed-reinsert-reload-replay`, `browser.shared-driver-fairness.neutral`, `browser.shared-driver-fairness.persist-storm`, `browser.shared-driver-fairness.persist-first`, @@ -64,6 +66,16 @@ export const properties: ReadonlyArray = [ ] export const owners: ReadonlyArray = [ + [ + `db-sqlite-persistence-core/tests/sync-metadata-session-persisted-oracle.test.ts`, + `sync-metadata-session`, + `persisted-fixed`, + ], + [ + `db-sqlite-persistence-core/tests/sync-metadata-session-sqlite-oracle.test.ts`, + `sync-metadata-session`, + `sqlite-seed-reinsert-reload-replay`, + ], [ `browser-db-sqlite-persistence/tests/opfs-open-outcome-oracle.test.ts`, `opfs`, 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 290ad8a00f..2b0b5de85e 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 @@ -55,6 +55,7 @@ import { expectedMaterializedIndexes, orderedReads, } from './custom-index-order-selection-model-oracle.js' +import type { MaterializedRow } from './custom-index-order-selection-model-oracle.js' import type { LoadSubsetOptions } from '../../src/types.js' import type { InitialQueryBuilder } from '../../src/query/builder/index.js' @@ -428,7 +429,7 @@ async function runMaterializedIndexHistory( } else { await cached!.preload() oracleAssertion(`output indexing configuration`, () => - expectIndexingConfiguration(cached, settings), + expectIndexingConfiguration(cached!, settings), ) for (const updated of [false, true]) { if (updated) { diff --git a/packages/electron-db-sqlite-persistence/vite.config.ts b/packages/electron-db-sqlite-persistence/vite.config.ts index 22f3802e58..a7c44dce15 100644 --- a/packages/electron-db-sqlite-persistence/vite.config.ts +++ b/packages/electron-db-sqlite-persistence/vite.config.ts @@ -17,7 +17,13 @@ const config = defineConfig({ typecheck: { enabled: true, include: [`tests/**/*.test.ts`, `tests/**/*.test-d.ts`], - exclude: [`**/node_modules/**`, `tests/reload-class-oracle.test.ts`], + // These oracles register their tests at run time through + // oracleProperty, so the typecheck collector finds no suite in them. + exclude: [ + `**/node_modules/**`, + `tests/reload-class-oracle.test.ts`, + `tests/reload-class-oracle.electron.e2e.test.ts`, + ], }, }, }) From 2a7eb94ea9b22c282dddfb91bc852da13d56230f Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 16:22:01 -0700 Subject: [PATCH 115/116] test(convex-db-collection): give the public-pagination determinism test the time of its reruns E2E (browser) failed on convex.public-pagination's framework determinism test, which timed out at the property's 45 s. That test runs each of the six histories twice in one test, against an append-only ledger that every run extends by 21 rows with page size 3; each history's own test passed. The timeout is now 180 s, as the other Convex backend properties use 60-120 s. Co-authored-by: Isaac --- .../e2e/public-pagination-oracle.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts index 4cd4236cc7..9912a83ce0 100644 --- a/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts +++ b/packages/convex-db-collection/e2e/public-pagination-oracle.test.ts @@ -268,5 +268,9 @@ oracleProperty({ mirrorThroughReconnect(plan.mirror), ), check: forwardAssertionJudgment, - timeout: 45000, + // Each history's own test takes a few seconds. The framework's determinism + // test runs every history twice in one test, against a ledger that each + // run extends, so it needs the time of twelve histories over a growing + // ledger (45 s timed out in CI). + timeout: 180_000, }) From 125055920d9de9af00d7b3b85df6a385875362aa Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 11 Nov 2026 17:12:40 -0700 Subject: [PATCH 116/116] test: type the converted properties' plans, and the lint-scope oracle reads the D2 message - tsc found 17 more errors in the db tests that the Vitest typecheck hid behind the earlier missing-type errors. Where a property's model is recordedAssertionJudgment, whose plan is `object`, the plan was inferred as `object`: the truncate-readiness, effect-disposal, subscription and sync reentrancy, btree history, and IndexedDB persistence properties name their history and plan types. Judges that returned a value now await it (state-retention, load-subset replay), mutantRejection takes any promise, the ordered-lifecycle reach coordinates may hold undefined as their producer does, and a mapped source-health history names its type. - scripts/lint-scope-oracle.property.test.js marks the rule's message for its generated plain-test files. Under D2 that message is "An oracle file registers its tests only through oracleProperty.", not the removed "declares no oracle part." Co-authored-by: Isaac --- packages/db/tests/btree-map-oracle.test.ts | 7 +++- ...on-state-retention-oracle.property.test.ts | 4 +- ...ion-subscription-reentrancy-oracle.test.ts | 3 +- .../collection-sync-reentrancy-oracle.test.ts | 2 +- .../db/tests/effect-disposal-oracle.test.ts | 3 +- .../indexed-db/persistence-oracle.test.ts | 3 +- ...y-observer-history-oracle.property.test.ts | 2 +- ...ad-subset-replay-refinement-oracle.test.ts | 12 +++++- .../ordered-lifecycle-oracle.property.test.ts | 2 +- .../db/tests/source-health-oracle.test.ts | 40 ++++++++++--------- .../tests/truncate-readiness-oracle.test.ts | 5 ++- scripts/lint-scope-oracle.property.test.js | 7 ++-- 12 files changed, 56 insertions(+), 34 deletions(-) diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index eb2798c416..d39a75f92d 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -2,6 +2,7 @@ import { describe, expect, vi } from 'vitest' import { fc } from '@fast-check/vitest' import { BTree } from '../src/utils/btree.js' import { oracleProperty } from './oracle-config.js' +import type { Verdict } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, @@ -201,7 +202,11 @@ describe(`BTree Map oracle`, () => { timeout: 30_000, }) - oracleProperty({ + oracleProperty< + [number, Array], + Verdict, + { nodeSize: number; actions: Array } + >({ id: `btree-map.history`, law: { statement: `Numeric BTree point, range, fallback and neighbor observations agree with the independent sorted Map after every action.`, 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 c70f4d6af6..3975a260ff 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -912,7 +912,9 @@ oracleProperty({ `collection-state.optimistic-fixed`, plan, observe, - () => runOptimisticHistory(plan.history.initial, plan.history.steps), + async () => { + await runOptimisticHistory(plan.history.initial, plan.history.steps) + }, ), check: forwardAssertionJudgment, }) diff --git a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts index 5f99bf97a6..79b2d4e376 100644 --- a/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-subscription-reentrancy-oracle.test.ts @@ -5,6 +5,7 @@ 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 { oracleProperty } from './oracle-config.js' +import type { Verdict } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, @@ -169,7 +170,7 @@ async function runSnapshotReentry({ } describe(`Collection subscription callback reentry oracle`, () => { - oracleProperty({ + oracleProperty<(typeof reentrantSnapshotHistories)[number], Verdict>({ id: `subscription-reentrancy.direct-snapshot`, law: { statement: `Once a callback that interrupts a direct snapshot retires its demand or subscription, the request returns false, publishes no local snapshot, releases an accepted acquisition once, and starts no fallback read.`, diff --git a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts index 2c5ff4d514..6c88944aa5 100644 --- a/packages/db/tests/collection-sync-reentrancy-oracle.test.ts +++ b/packages/db/tests/collection-sync-reentrancy-oracle.test.ts @@ -468,7 +468,7 @@ describe(`sync publication reentrancy`, () => { } } - oracleProperty({ + oracleProperty<(typeof readyCases)[number], Verdict>({ id: `collection-sync.ready-truncate`, law: { statement: `A ready callback that writes during a truncate publishes, with the truncate batch, valid messages for every subscriber after the Collection is ready, ending at the overlay of active intents on the replacement rows.`, diff --git a/packages/db/tests/effect-disposal-oracle.test.ts b/packages/db/tests/effect-disposal-oracle.test.ts index 94fbdb0382..cc186d09f2 100644 --- a/packages/db/tests/effect-disposal-oracle.test.ts +++ b/packages/db/tests/effect-disposal-oracle.test.ts @@ -3,6 +3,7 @@ import { createCollection, createEffect } from '../src/index.js' import { createDeferred } from '../src/deferred.js' import { captureMicrotaskErrors, flushPromises } from './utils.js' import { oracleProperty } from './oracle-config.js' +import type { Verdict } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, @@ -200,7 +201,7 @@ async function runDisposalScenario({ } describe(`Effect disposal outcome oracle`, () => { - oracleProperty({ + oracleProperty<(typeof scenarios)[number], Verdict>({ id: `effect-disposal.joined-callers`, law: { statement: `Every dispose() call during one cleanup attempt stays pending until the handler settles, then fulfills with the same outcome; the source is released once, and a release that throws is reported uncaught once with its own identity.`, diff --git a/packages/db/tests/indexed-db/persistence-oracle.test.ts b/packages/db/tests/indexed-db/persistence-oracle.test.ts index 4b3ced9b11..e53a6ce1f3 100644 --- a/packages/db/tests/indexed-db/persistence-oracle.test.ts +++ b/packages/db/tests/indexed-db/persistence-oracle.test.ts @@ -24,6 +24,7 @@ import { fc } from '@fast-check/vitest' import { describe, expect } from 'vitest' import { createTransaction } from '../../src' import { oracleProperty } from '../oracle-config' +import type { Verdict } from '../oracle-config' import { forwardAssertionJudgment, oracleAssertion, @@ -284,7 +285,7 @@ describe('settled histories', () => { // A fixed campaign and a random campaign share one grammar and budget. A path // replay of this property selects only its random campaign. const settledProperty = `indexeddb.settled-history` - oracleProperty({ + oracleProperty, Verdict, { steps: Array }>({ id: settledProperty, law: { statement: `Each settled authored history reconstructs the same public, subscription, durable, export, peer and reopened rows with key-owned versions.`, 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 b68378852f..8e64be432e 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 @@ -1267,7 +1267,7 @@ const listenerReachStates = [ // It returns that rejection, so the caller can check its premises first. async function mutantRejection( name: string, - mutantRun: () => Promise, + mutantRun: () => Promise, ): Promise { try { await mutantRun() 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 10877c90c2..b914f27714 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 @@ -3381,8 +3381,16 @@ describe(`Ordered windows across truncate replays`, () => { resolve: (regression) => ({ regression }), model: recordedAssertionJudgment, driver: (plan, observe) => - recordAssertionJudgment(`ordered replay refinement`, plan, observe, () => - checkOrderedReplay(plan.regression.history, plan.regression.scenario), + recordAssertionJudgment( + `ordered replay refinement`, + plan, + observe, + async () => { + await checkOrderedReplay( + plan.regression.history, + plan.regression.scenario, + ) + }, ), check: forwardAssertionJudgment, }) 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 39c50d261c..9fb995dd7a 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -1024,7 +1024,7 @@ describe(`ordered lifecycle product`, () => { resolve: (history) => ({ history }), model: (plan, observations) => recordedComparison(plan, observations), driver: async (plan, observe) => { - let coordinates: Array | undefined + let coordinates: ReadonlyArray | undefined await observeComparison(plan, observe, async () => { const result = await assertHistory(plan.history) coordinates = result.coordinates diff --git a/packages/db/tests/source-health-oracle.test.ts b/packages/db/tests/source-health-oracle.test.ts index aaf00b192f..1118ad39ae 100644 --- a/packages/db/tests/source-health-oracle.test.ts +++ b/packages/db/tests/source-health-oracle.test.ts @@ -7298,25 +7298,27 @@ describe(`lazy join demand grammar`, () => { // 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. - ...([`throws`, `rejects`] as const).map((replacement) => ({ - name: `a churn replacement that ${replacement} leaves its key to the older load after the next demand change`, - steps: [ - { kind: `settle-ok` }, - { kind: `parent`, key: 2 }, - { kind: `parent`, key: 3 }, - ...(replacement === `throws` - ? [ - { kind: `throw-next` } as const, - { kind: `unparent`, key: 2 } as const, - ] - : [ - { kind: `unparent`, key: 2 } as const, - { kind: `settle-last-fail` } as const, - ]), - { kind: `unparent`, key: 1 }, - { kind: `settle-first-fail` }, - ], - })), + ...([`throws`, `rejects`] as const).map( + (replacement): LazyFixedHistory => ({ + name: `a churn replacement that ${replacement} leaves its key to the older load after the next demand change`, + steps: [ + { kind: `settle-ok` }, + { kind: `parent`, key: 2 }, + { kind: `parent`, key: 3 }, + ...(replacement === `throws` + ? [ + { kind: `throw-next` } as const, + { kind: `unparent`, key: 2 } as const, + ] + : [ + { kind: `unparent`, key: 2 } as const, + { kind: `settle-last-fail` } as const, + ]), + { 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 diff --git a/packages/db/tests/truncate-readiness-oracle.test.ts b/packages/db/tests/truncate-readiness-oracle.test.ts index 7b1559ba78..f4c77f7cb4 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 } from 'vitest' import { createCollection } from '../src/collection/index.js' import { createDeferred } from '../src/deferred.js' import { oracleProperty } from './oracle-config.js' +import type { Verdict } from './oracle-config.js' import { forwardAssertionJudgment, recordAssertionJudgment, @@ -145,7 +146,7 @@ async function runReplacementCell({ // 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. -oracleProperty({ +oracleProperty<(typeof cells)[number], Verdict>({ id: `truncate-readiness.replacement-intent`, law: { statement: `A truncate's replacement makes the Collection ready unless its final authored intent is markReady:false, which keeps the current status, at every publication and after commit.`, @@ -223,7 +224,7 @@ async function runReentrantPair({ } } -oracleProperty({ +oracleProperty<(typeof reentrantPairs)[number], Verdict>({ id: `truncate-readiness.reentrant-batches`, law: { statement: `Reentrant replacement commits queue behind the current publication, and the final replacement's readiness intent alone decides the next batch's status.`, diff --git a/scripts/lint-scope-oracle.property.test.js b/scripts/lint-scope-oracle.property.test.js index 4c4df6f347..da6ffb8437 100644 --- a/scripts/lint-scope-oracle.property.test.js +++ b/scripts/lint-scope-oracle.property.test.js @@ -1070,9 +1070,10 @@ function predictFaults(program) { const WORKTREE = `.worktrees/wt` const SCRATCH = `.worktrees/scratch/${APP}/tests/scratch-oracle.test.ts` -// The rule's message for these generated files, and the mark that an edited -// copy of the rule adds to it. -const RULE_MESSAGE = `registers tests and declares no oracle part.` +// The rule's message for these generated files, whose plain tests an oracle +// file may not register (an oracle file registers its tests only through +// oracleProperty), and the mark that an edited copy of the rule adds to it. +const RULE_MESSAGE = `registers its tests only through oracleProperty.` const RULE_MARK = `[branch rule]` // The start of the rule's `create`, where a lint error is thrown. const RULE_CREATE = `create(context) {`