From e3e8a9b5bce0a99d889b73f6eb7578b6d7ef3769 Mon Sep 17 00:00:00 2001 From: jp-knj Date: Mon, 26 Oct 2026 16:33:06 +0900 Subject: [PATCH 1/6] fix: infer types for schema-free persisted options Return never for the schema type in both schema-free overloads. Law: Wrapped options must give createCollection the config's row, key, and utility types, as in docs/guides/sqlite-persistence.md's wrapping example and the browser package README. Reach: Eight fixed core cases cover local/synced options, inferred/explicit type arguments, and string/number keys. Two browser cases check the public export. Assertions check insert, get, and utility types. RED (original verification): The expanded checks report TS2769 in four core calls and two browser calls against d2935d0fc. GREEN: 17 core and 2 browser type tests pass. Mutant (original verification): Using TSchema in either result type makes both inferred-key tests fail. Limits: Fixed source-level type checks do not establish runtime behavior or published npm resolution. The full pnpm test attempt hit Electron fixture failures with navigator.locks unavailable and was stopped. Type-check commands from the repository root: pnpm --dir packages/db-sqlite-persistence-core exec vitest run --typecheck.only --coverage.enabled=false tests/persisted.test-d.ts tests/persisted-options-type-oracle.test-d.ts pnpm --dir packages/browser-db-sqlite-persistence exec vitest run --typecheck.only --coverage.enabled=false tests/persisted-options.test-d.ts --- .changeset/bright-cups-infer.md | 5 + .../tests/persisted-options.test-d.ts | 85 ++++++++++ .../src/persisted.ts | 4 +- .../persisted-options-type-oracle.test-d.ts | 157 +++++++++++++++++- 4 files changed, 246 insertions(+), 5 deletions(-) create mode 100644 .changeset/bright-cups-infer.md create mode 100644 packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts diff --git a/.changeset/bright-cups-infer.md b/.changeset/bright-cups-infer.md new file mode 100644 index 0000000000..68cef7a59b --- /dev/null +++ b/.changeset/bright-cups-infer.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +--- + +Fix type inference for schema-free collection options wrapped with persistedCollectionOptions and passed to createCollection. diff --git a/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts b/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts new file mode 100644 index 0000000000..4435f3ac65 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts @@ -0,0 +1,85 @@ +import { describe, expectTypeOf, it } from 'vitest' +import { createCollection } from '@tanstack/db' +import { QueryClient } from '@tanstack/query-core' +import { queryCollectionOptions } from '@tanstack/query-db-collection' +import { persistedCollectionOptions } from '../src' +import type { CollectionConfig, WithVirtualProps } from '@tanstack/db' +import type { QueryCollectionUtils } from '@tanstack/query-db-collection' +import type { PersistedCollectionPersistence } from '../src' + +/** + * Browser export checks for the core persisted-options-type-oracle.test-d.ts contract. + * Type checks only. + */ +type Row = { id: string; title: string; completed: boolean } +declare const persistence: PersistedCollectionPersistence + +describe(`Browser persisted option composition`, () => { + it(`wraps a schema-free CollectionConfig without explicit type arguments`, () => { + const utils = { titleLength: (title: string) => title.length } + const config: CollectionConfig = { + id: `ordinary-config`, + getKey: (row) => row.id, + sync: { sync: () => {} }, + utils, + } + const collection = createCollection( + persistedCollectionOptions({ ...config, persistence }), + ) + + collection.insert({ id: `row`, title: `input`, completed: false }) + expectTypeOf(collection.get(`row`)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() + expectTypeOf(collection.utils.titleLength).toEqualTypeOf< + typeof utils.titleLength + >() + // @ts-expect-error title must be a string + collection.insert({ id: `row`, title: 1, completed: false }) + // @ts-expect-error keys must be strings + collection.get(1) + // @ts-expect-error titleLength takes a string + collection.utils.titleLength(1) + }) + + it(`infers row, key, and utility types from Query Collection options`, () => { + const queryOptions = queryCollectionOptions({ + id: `query-config`, + queryClient: new QueryClient(), + queryKey: [`rows`], + queryFn: (): Promise> => Promise.resolve([]), + getKey: (row) => row.id, + }) + const collection = createCollection( + persistedCollectionOptions({ ...queryOptions, persistence }), + ) + + collection.insert({ id: `row`, title: `input`, completed: false }) + expectTypeOf(collection.get(`row`)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() + expectTypeOf(collection.utils).toEqualTypeOf< + QueryCollectionUtils + >() + expectTypeOf(collection.utils.refetch).toEqualTypeOf< + QueryCollectionUtils[`refetch`] + >() + collection.utils.refetch({ throwOnError: true }) + collection.utils.writeInsert({ + id: `row`, + title: `input`, + completed: false, + }) + collection.utils.writeDelete(`row`) + // @ts-expect-error writeInsert requires a string title + collection.utils.writeInsert({ id: `row`, title: 1, completed: false }) + // @ts-expect-error writeDelete requires a string key + collection.utils.writeDelete(1) + // @ts-expect-error throwOnError must be a boolean + collection.utils.refetch({ throwOnError: `yes` }) + // @ts-expect-error Query Collection has no awaitTxId method + collection.utils.awaitTxId(1) + }) +}) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index 69b1b3f2b4..baec198065 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -6773,7 +6773,7 @@ export function persistedCollectionOptions< options: PersistedSyncWrappedOptions & { schema?: never }, -): PersistedSyncOptionsResult +): PersistedSyncOptionsResult export function persistedCollectionOptions< T extends object, @@ -6784,7 +6784,7 @@ export function persistedCollectionOptions< options: PersistedLocalOnlyOptions & { schema?: never }, -): PersistedLocalOnlyOptionsResult +): PersistedLocalOnlyOptionsResult export function persistedCollectionOptions< T extends object, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts index 2095e005e0..9b02d548c8 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts @@ -1,8 +1,9 @@ -import { describe, it } from 'vitest' +import { describe, expectTypeOf, it } from 'vitest' import { createCollection } from '@tanstack/db' import { persistedCollectionOptions } from '../src' import type { PersistenceAdapter } from '../src' import type { StandardSchemaV1 } from '@standard-schema/spec' +import type { CollectionConfig, WithVirtualProps } from '@tanstack/db' const adapter: PersistenceAdapter = { loadSubset: () => Promise.resolve([]), @@ -112,14 +113,163 @@ describe(`persisted collection option type oracle`, () => { } }) + type NumericRow = { id: number; rank: number; label: string } + + const utils = { labelLength: (label: string) => label.length } + const stringConfig: Omit< + CollectionConfig, + `sync` + > = { + id: `string-rows`, + getKey: (row: RowOutput) => row.id, + } + const numericConfig: Omit< + CollectionConfig, + `sync` + > = { + id: `numeric-rows`, + getKey: (row: NumericRow) => row.id, + } + const stringOptions = { ...stringConfig, persistence: { adapter } } + const numericOptions = { ...numericConfig, persistence: { adapter } } + const sync = { sync: () => {} } + + it(`infers row and utility types with string keys`, () => { + const localOptions = persistedCollectionOptions({ ...stringOptions, utils }) + const syncedOptions = persistedCollectionOptions({ + ...stringOptions, + utils, + sync, + }) + const local = createCollection(localOptions) + const synced = createCollection(syncedOptions) + + for (const collection of [local, synced]) { + collection.insert({ id: `row`, rank: 1, label: `input` }) + expectTypeOf(collection.get(`row`)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() + expectTypeOf(collection.utils.labelLength).toEqualTypeOf< + typeof utils.labelLength + >() + // @ts-expect-error rank must be a number + collection.insert({ id: `row`, rank: `1`, label: `input` }) + // @ts-expect-error keys must be strings + collection.get(1) + // @ts-expect-error labelLength takes a string + collection.utils.labelLength(1) + } + }) + + it(`infers row and utility types with numeric keys`, () => { + const localOptions = persistedCollectionOptions({ + ...numericOptions, + utils, + }) + const syncedOptions = persistedCollectionOptions({ + ...numericOptions, + utils, + sync, + }) + const local = createCollection(localOptions) + const synced = createCollection(syncedOptions) + + for (const collection of [local, synced]) { + collection.insert({ id: 1, rank: 1, label: `input` }) + expectTypeOf(collection.get(1)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[number]>() + expectTypeOf(collection.utils.labelLength).toEqualTypeOf< + typeof utils.labelLength + >() + // @ts-expect-error id must be a number + collection.insert({ id: `row`, rank: 1, label: `input` }) + // @ts-expect-error keys must be numbers + collection.get(`row`) + } + }) + + it(`accepts explicit row and string key types`, () => { + const local = createCollection( + persistedCollectionOptions(stringOptions), + ) + const synced = createCollection( + persistedCollectionOptions({ ...stringOptions, sync }), + ) + + for (const collection of [local, synced]) { + collection.insert({ id: `row`, rank: 1, label: `input` }) + expectTypeOf(collection.get(`row`)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() + // @ts-expect-error rank must be a number + collection.insert({ id: `row`, rank: `1`, label: `input` }) + // @ts-expect-error keys must be strings + collection.get(1) + } + }) + + it(`accepts explicit row and numeric key types`, () => { + const local = createCollection( + persistedCollectionOptions(numericOptions), + ) + const synced = createCollection( + persistedCollectionOptions({ + ...numericOptions, + sync, + }), + ) + + for (const collection of [local, synced]) { + collection.insert({ id: 1, rank: 1, label: `input` }) + expectTypeOf(collection.get(1)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.get).parameters.toEqualTypeOf<[number]>() + // @ts-expect-error id must be a number + collection.insert({ id: `row`, rank: 1, label: `input` }) + // @ts-expect-error keys must be numbers + collection.get(`row`) + } + }) + + it(`accepts explicit never and utility type arguments`, () => { + const local = createCollection( + persistedCollectionOptions({ + ...stringOptions, + utils, + }), + ) + const synced = createCollection( + persistedCollectionOptions({ + ...stringOptions, + utils, + sync, + }), + ) + + for (const collection of [local, synced]) { + expectTypeOf(collection.get(`row`)).toEqualTypeOf< + WithVirtualProps | undefined + >() + expectTypeOf(collection.utils.labelLength).toEqualTypeOf< + typeof utils.labelLength + >() + } + }) + it(`discriminates sync mode from the presence of the sync key`, () => { - persistedCollectionOptions({ + const localOptions = persistedCollectionOptions({ id: `local`, getKey: (row: RowOutput) => row.id, persistence: { adapter }, }) + createCollection(localOptions) - persistedCollectionOptions({ + const syncedOptions = persistedCollectionOptions({ id: `synced`, getKey: (row: RowOutput) => row.id, sync: { @@ -129,6 +279,7 @@ describe(`persisted collection option type oracle`, () => { }, persistence: { adapter }, }) + createCollection(syncedOptions) persistedCollectionOptions({ id: `invalid-undefined-sync`, From 4ea6b76634ef3b16ec8461fb29bb4aebb51819da Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 10:27:15 -0600 Subject: [PATCH 2/6] test: replace persisted option examples with a generated type oracle Delete the two fixed type-test files and replay their inputs through one independent model and compiler comparison. Add contextual schema inference alongside the existing construction paths and prohibit unit tests in oracles. Law: persistence wrapping preserves schema input/output, exact keys and user utilities under the public composition contract. Reach: all declared paths, including schema-contextual-key, reach 12 histories per campaign on each receiver. Four campaigns exercise 48 histories and 1,464 generated constructions, plus shared regression inputs. RED: the parent implementation fails TS2769. A contextual-row mutant survives the initial migration but fails the expanded callback type comparison. GREEN: core and browser fixed/fresh campaigns and harness typechecks pass; all nine neighboring core persistence type tests pass. Mutant: ten wrong designs reject across thirteen receiving checks, including key widening, utility erasure and contextual-row inference loss. Limits: source-level TypeScript 5.9.3 evidence only. Runtime, built declarations and package resolution are outside this claim. The cached baseline dependency setup prevents bundle-size measurement; emitted runtime JavaScript is identical. See docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md. --- .agents/skills/oracle-authoring/SKILL.md | 7 + AGENTS.md | 22 + docs/contributing/oracle-coverage.md | 28 +- .../pr-2259-persisted-option-types.md | 268 +++++++++ docs/contributing/oracle-rubric.md | 27 +- .../tests/persisted-options.test-d.ts | 85 --- .../tests/persisted-options.test.ts | 4 + .../persisted-options-type-oracle.test-d.ts | 292 --------- .../tests/persisted-options-type-oracle.ts | 562 ++++++++++++++++++ .../tests/persisted-options.test.ts | 5 + packages/db/tests/oracle-config.ts | 2 + 11 files changed, 919 insertions(+), 383 deletions(-) create mode 100644 docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md delete mode 100644 packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts create mode 100644 packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts delete mode 100644 packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts create mode 100644 packages/db-sqlite-persistence-core/tests/persisted-options.test.ts diff --git a/.agents/skills/oracle-authoring/SKILL.md b/.agents/skills/oracle-authoring/SKILL.md index e40686211f..2c45096d1e 100644 --- a/.agents/skills/oracle-authoring/SKILL.md +++ b/.agents/skills/oracle-authoring/SKILL.md @@ -36,6 +36,13 @@ Read [the oracle guide](../../../docs/contributing/oracle-rubric.md), and limits in [the coverage map](../../../docs/contributing/oracle-coverage.md). Follow the subsystem reading requirements in AGENTS.md. +Keep unit tests and standalone concrete type assertions out of oracle files +(ORC-016). Fixed regressions are inputs to the same model, driver, and +comparison as the broader campaign, without their own expected answers. +Compile-time oracles generate legal TypeScript inputs and compare public +types with a reusable independent law; a matrix of fixed assertions does not +satisfy this requirement. Supplemental examples belong in ordinary test files. + Use the [instrument library](../../../docs/contributing/instruments/index.md) when there is a material uncertainty. Select cards within the authorized task; there is no required sequence or instrument quota. diff --git a/AGENTS.md b/AGENTS.md index 458b57c2aa..75b9b1c9b3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -106,6 +106,9 @@ is not ready: the owner and the maintainer decision. - **Pinned-only coverage:** a named example where the law needs a generator dimension. +- **Unit tests inside oracles:** ordinary examples or concrete type assertions + placed in an oracle file. A fixed regression must replay the shared model, + production driver, and comparison without its own expected answer. - **"Pre-existing" as a dismissal:** a reachable counterexample that an agent labels as older than the change, but does not fix, record in the coverage map with its trace, or bring to the maintainer for a decision. @@ -694,6 +697,25 @@ explain why extending its owner, creating a new independent oracle, or adding a receiving witness is not practical. Bug fixes cannot use this focused-test-only exception. They must provide evidence that closes the entire declared bug class. +### Keep Unit Tests Out of Oracles + +Oracle files contain reusable laws, independent models, input or history +grammars, shared comparisons, and their execution. Do not put ordinary unit +tests or concrete type assertions in an oracle file. A filename, coverage-map +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. + +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. + ### Name Oracle Files for Discovery Include `oracle` in filenames that own an independent reference computation, diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index a085dd75d9..285c4c829e 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -123,9 +123,31 @@ of all four public virtual fields directly after implicit, expression, and functional root publication. Its fixed controlled-Collection cases do not establish every nested projection shape or framework receiving boundary. -Three type-only owners check compile-time boundaries: -`packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts` -checks local and synced persisted option input/output and key types; +The generated compile-time persistence owner is +`packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts`. +It generates row shapes (1–4 scalar, nullable, array, or nested fields), six +key domains, field optionality, and utility signatures. Every generated shape +crosses local/synced, schema-free/transforming schema, typed-spread/inline +arguments, and inferred/partial/full explicit type arguments. Six additional +inline schema forms infer the unannotated `getKey` callback's row, preserving +all 24 annotated construction forms. One independent +type law compares insert input, complete published row types (including the +documented virtual fields), exact keys, and supplied +utility signatures at TypeScript compilation. Contextual callbacks also compare +their parameter with the independent output row. Partial explicit arguments use +TypeScript's utility default rather than promising inference of unspecified +generic arguments. The core `persisted-options.test.ts` runner and the browser +`persisted-options.test.ts` receiving runner share that judgment; browser also +passes real Query Collection options through it and compares adapter utilities +before/after wrapping. Both run 12 histories with seed 2259 and 12 with a fresh +seed, measure per-path reach, and accept the shared seed/path/property replay +interface (`persisted-options.types.core` / `.browser`). This owner does not +execute runtime parsing, adapter I/O, sync, or publication, and does not check +built declarations, npm resolution, other host packages, or every TS version. +The [type-preservation audit](oracle-reviews/pr-2259-persisted-option-types.md) +records parent RED, GREEN, legacy-suite survival, and hostile-mutant evidence. + +Other existing fixed type checks cover adjacent compile-time boundaries: `packages/db/tests/mutation-handler-type-oracle.test-d.ts` checks sibling Collection/key/utilities in mutation handlers; and `packages/db/tests/query/virtual-row-fields-oracle.test-d.ts` checks all four diff --git a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md new file mode 100644 index 0000000000..cabcb93382 --- /dev/null +++ b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md @@ -0,0 +1,268 @@ +# Persisted option type preservation: PR #2259 + +Reviewed PR head: `e3e8a9b5bce0a99d889b73f6eb7578b6d7ef3769`. +Original defective parent: `d2935d0fcac164163adc231c6a796b8a4c234d9b`. +This self-audit covers the oracle migration built on that head. Production +`persisted.ts` is unchanged from the reviewed PR after all temporary mutants. +The review ledger and raw GitHub responses are retained under `.context/`; +this record preserves the verdict-critical evidence for version control. + +The initial migration record below precedes the prep-pr follow-up at the end. +That follow-up supersedes its owner hash, construction counts, and scoped +readiness assessment while preserving the earlier experiments. + +The migrated executable owner has SHA-256 +`c164515bf9705864bd5f0b27dc6e7769d1f7214cfba8158130fb12b36c04c9a0`. +The replay registry has SHA-256 +`53837b427c149964cd6dda52c7f87d71de57626790c48012527d1d2028f90889`. +These identify the repaired instrument while its changes remain uncommitted. + +## Law, experiment, and inference + +The SQLite guide's **Add persistence to a synced Collection** example composes +adapter options with `persistedCollectionOptions` and `createCollection`. +The public `createCollection` contract assigns schema input to insert, schema +output to reads, and the configured key and user utilities to callers. +Wrapping must preserve those roles, including when the caller has no schema. +The existing public persistence option types also require a present `sync` +property to contain a `SyncConfig`. + +| Existing evidence | Decisive experiment | Inference and repair | +| --- | --- | --- | +| Eight fixed core configurations and two browser configurations checked broad string/number keys. | Change only the schema-free result key types to `TKey extends string ? string : number`. All eight original core tests and both original browser tests pass. | Their expected answers do not generalize key preservation. Generate key domains and compare exact public key types with the independent input model. | +| Browser tests restated their expected types. | Run browser source exports and real Query Collection options through the same generated law as core. The parent implementation fails with TS2769 at `createCollection`; the PR implementation passes. | A receiving driver can share the model and comparison. Separate browser expectations are unnecessary. | +| Schema-free results must retain their row/key/utility contract without damaging schema-bearing options. | Restore either original overload, erase utilities, or return `never` from both schema-bearing overload results. Each mutant fails the generated comparison. | The repair must preserve both persistence modes and the neighboring schema-input/output law. | + +The primary executable owner is now +[`persisted-options-type-oracle.ts`](../../../packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts). +Its generated TypeScript model declares input/output rows, keys, and utility +signatures without importing production inference helpers. Its comparison +checks exact insert input, exact `get` parameters, the optional non-null read +result, the complete documented published-row shape, absence +of `any` reads, and supplied utility signatures. Generated invalid row, key, +utility, and sync expressions must remain compiler errors. The real Query +Collection driver additionally checks exact utility namespace preservation before/after +wrapping and exercises its write/refetch signatures. + +Each generated shape crosses 24 related construction forms: two persistence +modes, schema-free/transforming schema, typed-spread/inline arguments, and +inferred/partial/full explicit arguments. Browser adds the actual Query +Collection path. Partial explicit arguments select TypeScript's utility +default; they do not promise inference for unspecified later type parameters. +The original string, numeric, and browser row shapes survive as replay data +through the same checker, without separate expected answers. + +## Evidence block + +- **Law:** The source-level composition law above, from the SQLite guide and + public `createCollection`/persistence option contracts. +- **Reach:** Each receiver runs 12 generated histories with seed 2259 and 12 + with a fresh seed. In both campaigns, each of `schema-free-local`, + `schema-free-synced`, `schema-input-output`, `typed-spread`, + `inferred-arguments`, `explicit-arguments`, and `row-key-arguments` reaches + 12 histories. Browser's `query-adapter` also reaches 12 in each campaign. + Counts derive from the construction paths actually compiled and compared. + The three fixed replay inputs do not contribute to generated reach. +- **RED:** With the exact parent `persisted.ts` temporarily restored, both + receivers fail their first generated history with TS2769 at the public + `createCollection(persistedCollectionOptions(...))` call. The fixed-campaign + command is `../../node_modules/.bin/vitest run --coverage.enabled=false + --typecheck.enabled=false tests/persisted-options.test.ts -t fixed`, run + from each receiving package. Source restoration uses `finally`. +- **GREEN:** With PR production restored, both fixed and fresh-seed campaigns + pass through core and browser. There are 48 generated histories and 1,176 + consumer constructions across these four campaigns, plus shared replays. + Core's nine surrounding `persisted.test-d.ts` tests also pass. The runner + and shared harness typecheck in both packages without source errors. +- **Mutants:** All outcomes below are compiler comparison failures, not + timeouts or setup failures. All eleven receiving-path checks were replayed + again against the final generator after restoring its original names. +- **Limits:** This is source execution with TypeScript 5.9.3, Vitest 3.2.4, + and fast-check 3.23.2. It establishes no runtime parsing, I/O, publication, + generated declaration, npm resolution, other host re-export, or compiler + version claim. The grammar has 1–4 non-key fields, scalar/nullable/array/ + nested field types, six key domains, field optionality, and scalar utility + signatures. Domain values are sampled; there is no claim that every key + domain or every input appears in each fresh campaign. There is no known + counterexample in the declared grammar. The coverage-map entry retains the + owner and these limits for future extension. + +| Hostile implementation | Receiving path | Rejection | +| --- | --- | --- | +| Exact defective parent, both schema-free results retain `TSchema` | Core and browser | TS2769; seed 2259, path `0:0:0:0:0:0:0:0:0` | +| Restore only the synced schema-free result | Core | TS2769 at the synced construction | +| Restore only the local schema-free result | Core | TS2769 at the local construction | +| Widen literal/branded keys to broad string/number | Core | TS2344 at exact key equality; seed 2259, path `3:1:1:1:1:1:1`; reduced key is `"alpha" \| "beta"` | +| Erase schema-free user utilities to `UtilsRecord` | Core | TS2344 at utility equality and unused negative utility directive | +| Apply `never` to schema-bearing result types too | Core | TS2344 and unused negative schema-input directives | +| Remove `undefined` from the public getter result and assert the implementation return non-null | Core | TS2344 at optional read-result equality | +| Add a required `leaked` field to the public getter result | Core | TS2344 at complete published-row type equality | +| Add an overload that admits a present `sync: undefined` | Core and browser | Unused negative sync directives (TS2578); persistence is supplied so the error cannot arise from a missing adapter | + +The original ten fixed tests pass the widened-key mutant. This is an executed +false-green witness, not an inference from the file's name. The other mutants +reject at the reduced path `0:0:0:0:0:0:0:0:0` as well. + +Direct replay was verified against the parent: exactly one history ran, with +the captured seed and complete shrink path, and failed without further +shrinking. The same replay passed after restoration of PR production. The +shared replay witness recorded `numRuns: 1`, `failed: true` for the parent and +`failed: false` for the repair. To reproduce the repaired replay: + +```sh +# From packages/db-sqlite-persistence-core +TANSTACK_DB_ORACLE_SEED=2259 \ +TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0 \ +TANSTACK_DB_ORACLE_PROPERTY=persisted-options.types.core \ +pnpm exec vitest run --typecheck.enabled=false --coverage.enabled=false tests/persisted-options.test.ts +``` + +Normal verification from the respective package directories: + +```sh +# Core: campaigns, harness typecheck, and surrounding persistence type contracts. +pnpm exec vitest run --coverage.enabled=false tests/persisted-options.test.ts tests/persisted.test-d.ts + +# Browser: campaigns; the next command separately checks the harness types. +pnpm exec vitest run --typecheck.enabled=false --coverage.enabled=false tests/persisted-options.test.ts +pnpm exec vitest run --typecheck.only --coverage.enabled=false tests/persisted-options.test.ts +``` + +Local commands invoked the same Vitest executable directly, with workspace +`node_modules/.bin` first on PATH. The configured registry returned 403 and +the public fallback returned 503 during installation; existing installed +external dependencies were reused without changing the lockfile. Explicit +compiler aliases target this checkout's production sources. Early failures +from jsdom import URLs, missing worker/schema declarations, and an accidentally +selected global TypeScript 7 compiler were setup failures and supply no RED +evidence. Final compiler runs retain all diagnostics rather than suppressing +source errors. ESLint, formatting, `pnpm lint:changed`, and `git diff --check` +also pass. + +## Requirement audit + +| Requirement | Outcome | +| --- | --- | +| ORC-001 | Authority, source-level observation, finite grammar, and receiving limits are stated above and beside the model. | +| ORC-002 | Expected rows/keys/user utilities are independently declared; the published virtual shape comes from the public WithVirtualProps/VirtualRowProps documentation. No production schema inference or virtual-row helper computes expectations. PublicShape flattens type intersections while retaining every key, readonly/optional modifier, and undefined. The Query utility relation trusts the unchanged adapter contract. | +| ORC-003 | Contract, grammar, independent row model, construction drivers, and compiler comparison have adjacent prose in the owner. | +| ORC-004 | Reconstruction: replay shapes preserve the original row/key/utility cases. Ablation: typed spreads and inferred arguments expose the parent; mode variants reject single-overload repairs; key domains reject widening; schema neighbors reject blanket-never; utility signatures reject erasure. Range: 1–4 non-key fields, six keys, optional/nullable/array/nested fields, scalar utility signatures. Exclusion: duplicate names cannot be generated; boolean/symbol keys are outside the legal domains. | +| ORC-005 | Real source entries feed the compiler. Diagnostics and exact type constraints observe the public call boundary. The original and mutant diagnostics positively identify those paths. There is no runtime information-delivery claim. | +| ORC-006 | Original implementation and eight additional wrong designs reject at the intended compiler comparison; the legacy-suite survivor demonstrates the missing distinction. | +| ORC-007 | Fixed/fresh campaigns share one grammar, comparison, and budget. Seed/path/property replay runs directly, and the shared reporter records one failed/passing replay. No command-model replay path applies. | +| ORC-008 | Not applicable: the reference computes static type shapes and introduces no stateful runtime abstraction. | +| ORC-009 | A history here is a model-only declaration/construction/compilation sequence, explicitly mapped to the source boundary. It is not a Collection sync run. | +| ORC-010 | Full compiler diagnostics retain the violated type constraint and source expression. Fast-check preserves the generated/shrunk shape and path. Generated consumers live in memory; temporary mutation experiments restore source in `finally`. | +| ORC-011 | No additional shared-semantic-fault hypothesis was identified for the independent shape model. The Query driver's before/after utility relation is supplemental; it does not independently specify Query Collection internals. | +| ORC-012 | This record identifies the reviewed head, scope, experiments, dispositions, and applicable/non-applicable requirements. No universal bug-class proof is claimed. | +| ORC-013 | Typed/untyped and schema-free/schema-bearing neighbors distinguish omission of the schema premise from blanket schema erasure. Exact broad/literal key cases reject widening. | +| ORC-014 | The browser public source export and actual Query adapter are receiving drivers of the shared law. No SQLite/provider/runtime-host premise is transferred by this source-only claim. | +| ORC-015 | Shared `createOracleReach` counters require every declared path to appear, count histories once, and report the measured counts above. Fixed replay data does not inflate them. | +| ORC-016 | No standalone unit/type examples remain in this owner. Fixed cases are model input data; both receiving runners register the same generated law. Negative type checks are emitted by the reusable comparison from each shape. | + +## Lossless finding ledger and reviewer assessment + +The raw sources are the preceding assistant review, the user's correction, +and CodeRabbit's PR comment. The GitHub review and inline-comment lists were +empty at capture. There were no additional capped findings or footnotes. + +| ID | Claim | Technical verdict and evidence | PR action | Durable value/destination | +| --- | --- | --- | --- | --- | +| R1 | The PR does not edit its oracle. | Refuted literally: the original diff edits the coverage-map owner. This does not answer R2. | `refuted`; no separate code change. | Preserve the distinction between editing an owner and enforcing a reusable law. | +| R2 | Fixed assertions do not provide a generated type-preservation oracle; ban unit tests in oracle files. | Confirmed by the widened-key mutant passing all original tests. | `fixed-now`: generated owner, shared replay inputs, reach/replay/calibration; AGENTS, authoring skill, and ORC-016 prohibit standalone unit tests in oracle files. | Primary owner and this evidence record. | +| R3 | Browser checks repeat separate fixed expectations. | Confirmed by source inspection and the same legacy-suite survivor. | `fixed-now`: browser/Query receiving paths use the identical model/checker. | Receiving runner and primary owner. | +| R4 | CodeRabbit reports no actionable issue and says the fix is ready for normal merge checks. | Refuted as an oracle-sufficiency assessment: it missed R2/R3 and the executed false green. This does not establish a new defect in the PR's two-line production fix. | `refuted`; repair the actual coverage gap. | Keep whole-PR readiness separate from a green fixture matrix. | +| R5 | CodeRabbit's 0% docstring warning requires missing docstrings. | Refuted as an actionable documentation defect: changed public overload return types retain the same API; the warning identifies no incorrect/missing public contract. Its metric itself was not reimplemented. | `refuted`; no metric-driven docstrings. | The executable owner now explains the law and limits; retain useful prose rather than chasing the percentage. | + +CodeRabbit's overload summary was technically accurate. Its oracle analysis +had low depth and prioritized a documentation metric over a real enforcement +gap. **No hire for an oracle-review role on this evidence.** The preceding +assistant review located the right owner and distinguished compile-time from +runtime coverage, but was too generous in treating fixed expected shapes as +sufficient independent oracle judgment. The surviving mutant corrects that +assessment. This is a self-audit, not an independent external review. + +Loss audit: **5 raw items = 2 fixed-now + 3 refuted**. No confirmed-open, +deferred, duplicate, stale, or unresolved design-decision items remain in this +scoped migration. The unit-test prohibition is an explicit review requirement; +the existing syntactic lint rules cannot prove semantic model independence. + +Whole-PR readiness: **not assessed**. The focused instrument and type contract +checks pass; full runtime CI, published package resolution, and integration +outside the named source receiving paths were not rerun or claimed. + +## Prep-pr follow-up: contextual schema inference + +Reviewed production remains `e3e8a9b5bce0a99d889b73f6eb7578b6d7ef3769`. +The final executable owner has SHA-256 +`5fa47aeaec4790188ee46194e687ca269c507d47d97555d475edc219d45dabf4`. +The replay registry is unchanged from the initial migration above. + +The independent prep-pr reviewer found one P2 gap: all generated core `getKey` +callbacks annotated their row as `Output`. The deleted schema examples used +an inline unannotated callback. A spread of an annotated callback cannot test +whether the schema context supplies the output row. + +An in-memory compiler-host mutant changed only the two schema-bearing input +overloads to accept `getKey: (row: any) => TKey`. The baseline accepted both +construction paths. The mutant passed the annotated path and the entire +initial fixed core campaign, including its replays and all declared reaches. +The original unannotated path rejected it: an invalid numeric key became +accepted, producing TS2578 for the unused negative directive. This is an +executed false-green witness for the migration, not a production defect on +the repaired PR. + +The repair adds six generated contextual schema forms: local/synced crossed +with inferred/partial/full explicit arguments. They put the unannotated +callback directly inside the wrapper argument. The callback's public row +parameter must equal the independently declared schema output. All 24 +annotated forms remain. Core therefore compiles 30 forms per shape; browser +compiles the same 30 plus its actual Query Collection receiving path. +Fixed regressions remain inputs to that same model, driver, and comparison. + +- **Law:** The public schema-output contract also supplies the contextual + `getKey` row. It must preserve the inferred key. Authority is the schema + overload's documented output-row role and the original schema composition + examples, rather than the mutant's behavior. +- **Reach:** `schema-contextual-key` reaches 12 generated histories in each + fixed and fresh campaign on both receivers. Every prior declared state also + reaches 12. Across four campaigns, 48 generated histories compile 1,464 + constructions, plus shared regressions excluded from generated reach. +- **RED:** The initial fixed core campaign survives the contextual-row mutant. + The expanded comparison rejects it with TS2344 at `CallbackInput`, with + additional inferred-key/result mismatches. Direct replay uses seed 2259, + path `0:0:0:0:0:0:0:0:0`, and the appropriate receiver property. +- **GREEN:** The expanded core and browser campaigns and harness typechecks + pass. Core also passes all nine neighboring persistence type tests. The + commands retain the initial verification selectors and limit Vitest to two + CPUs. +- **Mutant:** The same compiler-host overlay supplies the wrong contextual + type to the unchanged expanded comparison. Both core and browser reject it + at the callback parameter equality. The nine earlier wrong designs were + also replayed against the expanded owner: all eleven receiving checks still + reject at their compiler comparisons. In total, ten wrong designs reject + across thirteen receiving checks. Production on disk remains the PR's + two-overload correction after temporary calibration experiments. +- **Limits:** The initial source-only and compiler-version limits still apply. + This adds no runtime, built-declaration, or package-resolution claim. + +ORC-004 reconstruction now includes the original contextual schema path. +Its ablation is decisive: removing the six contextual cells lets this mutant +pass. ORC-005 observes the callback parameter at compilation as well as the +resulting Collection. ORC-006 rejects the previously surviving wrong design. +ORC-015 declares and measures the added path. The other ORC outcomes above +retain their scope, with the expanded counts and owner hash stated here. + +Finding ledger: **one raw prep-pr finding = one repaired coverage regression**. +The simplifier made no changes and proposed no additional suggestions. +Reviewer assessment: **hire for this scoped oracle review**. The reviewer +identified a lost public path and proved its significance with a full-campaign +survivor and a distinguishing old-path failure. Root independently reran the +four-way probe and inspected the full-campaign overlay and diagnostics. +The reviewer then inspected the expanded owner and confirmed the repair, +retained paths, and declared reach, with no additional supported findings. + +The bundle-size comparison could not build the cached baseline because that +worktree lacks Vitest/TypeScript dependencies. A separate TypeScript 5.9.3 +transpile comparison emits identical JavaScript for the parent and repaired +`persisted.ts`. This is emit evidence, not a completed bundle measurement. diff --git a/docs/contributing/oracle-rubric.md b/docs/contributing/oracle-rubric.md index 4b3fc0f6dc..ade6d4eb70 100644 --- a/docs/contributing/oracle-rubric.md +++ b/docs/contributing/oracle-rubric.md @@ -308,6 +308,26 @@ presence of a preferred heading, class, comment template, or helper. - **Not required:** A declared state for every modeled transition. Declare the states that the cited laws need, so that reach stays honest about scope. +### ORC-016: Law-only oracle files + +- **Trigger:** A file defines an oracle, its input grammar, or a generated + campaign, including a compile-time type oracle. +- **Obligation:** The file MUST NOT contain ordinary example-based unit tests + or standalone concrete type assertions. Fixed regressions MUST be inputs to + the same independent model, production driver, and comparison used by the + broader campaign. They MUST NOT restate their expected answers. A type + oracle MUST apply a reusable independent type law to generated legal inputs. +- **Acceptance evidence:** A reviewer can vary the input without adding an + expectation. Every retained regression and receiving driver uses the shared + judgment. Checker calibration supplies deliberately wrong observations to + that judgment. +- **Does not satisfy:** Renaming a unit test, adding contract prose, enumerating + fixtures with separate assertions, or wrapping fixed expectations in a + property runner. +- **Not required:** Removing supplemental unit tests from ordinary test files, + randomizing a deliberately exhaustive finite grammar, or executing runtime + behavior to establish a source-level type law. + ## A quick start One way to supply that judgment is a small reference model. For a simple stateful test, choose a legal action, apply it to production and the reference, and compare the promised result at the agreed point. “After the action returns” may be the right point for one API; a callback during that action may matter for another. @@ -369,9 +389,10 @@ Two small examples show the form: Use `oracle` in filenames that own an independent expected-result computation, 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. A mixed -test file can qualify through a specific oracle inside it; its other tests do -not thereby become separate oracles. +qualifies. Identify the mechanism in the code before applying the name. +ORC-016 keeps ordinary unit tests and concrete type assertions out of oracle +files. Fixed regressions replay the shared law; supplemental examples live in +ordinary test files. An ordinary example or regression does not need this suffix merely because it asserts a contract. The same applies to concrete type assertions, fixtures, diff --git a/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts b/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts deleted file mode 100644 index 4435f3ac65..0000000000 --- a/packages/browser-db-sqlite-persistence/tests/persisted-options.test-d.ts +++ /dev/null @@ -1,85 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { createCollection } from '@tanstack/db' -import { QueryClient } from '@tanstack/query-core' -import { queryCollectionOptions } from '@tanstack/query-db-collection' -import { persistedCollectionOptions } from '../src' -import type { CollectionConfig, WithVirtualProps } from '@tanstack/db' -import type { QueryCollectionUtils } from '@tanstack/query-db-collection' -import type { PersistedCollectionPersistence } from '../src' - -/** - * Browser export checks for the core persisted-options-type-oracle.test-d.ts contract. - * Type checks only. - */ -type Row = { id: string; title: string; completed: boolean } -declare const persistence: PersistedCollectionPersistence - -describe(`Browser persisted option composition`, () => { - it(`wraps a schema-free CollectionConfig without explicit type arguments`, () => { - const utils = { titleLength: (title: string) => title.length } - const config: CollectionConfig = { - id: `ordinary-config`, - getKey: (row) => row.id, - sync: { sync: () => {} }, - utils, - } - const collection = createCollection( - persistedCollectionOptions({ ...config, persistence }), - ) - - collection.insert({ id: `row`, title: `input`, completed: false }) - expectTypeOf(collection.get(`row`)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() - expectTypeOf(collection.utils.titleLength).toEqualTypeOf< - typeof utils.titleLength - >() - // @ts-expect-error title must be a string - collection.insert({ id: `row`, title: 1, completed: false }) - // @ts-expect-error keys must be strings - collection.get(1) - // @ts-expect-error titleLength takes a string - collection.utils.titleLength(1) - }) - - it(`infers row, key, and utility types from Query Collection options`, () => { - const queryOptions = queryCollectionOptions({ - id: `query-config`, - queryClient: new QueryClient(), - queryKey: [`rows`], - queryFn: (): Promise> => Promise.resolve([]), - getKey: (row) => row.id, - }) - const collection = createCollection( - persistedCollectionOptions({ ...queryOptions, persistence }), - ) - - collection.insert({ id: `row`, title: `input`, completed: false }) - expectTypeOf(collection.get(`row`)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() - expectTypeOf(collection.utils).toEqualTypeOf< - QueryCollectionUtils - >() - expectTypeOf(collection.utils.refetch).toEqualTypeOf< - QueryCollectionUtils[`refetch`] - >() - collection.utils.refetch({ throwOnError: true }) - collection.utils.writeInsert({ - id: `row`, - title: `input`, - completed: false, - }) - collection.utils.writeDelete(`row`) - // @ts-expect-error writeInsert requires a string title - collection.utils.writeInsert({ id: `row`, title: 1, completed: false }) - // @ts-expect-error writeDelete requires a string key - collection.utils.writeDelete(1) - // @ts-expect-error throwOnError must be a boolean - collection.utils.refetch({ throwOnError: `yes` }) - // @ts-expect-error Query Collection has no awaitTxId method - collection.utils.awaitTxId(1) - }) -}) diff --git a/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts b/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts new file mode 100644 index 0000000000..088b2a4830 --- /dev/null +++ b/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts @@ -0,0 +1,4 @@ +// Browser re-export and Query Collection receiving paths use the core law. +import { registerPersistedOptionsTypeOracle } from '../../db-sqlite-persistence-core/tests/persisted-options-type-oracle.js' + +registerPersistedOptionsTypeOracle(`browser`) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts deleted file mode 100644 index 9b02d548c8..0000000000 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.test-d.ts +++ /dev/null @@ -1,292 +0,0 @@ -import { describe, expectTypeOf, it } from 'vitest' -import { createCollection } from '@tanstack/db' -import { persistedCollectionOptions } from '../src' -import type { PersistenceAdapter } from '../src' -import type { StandardSchemaV1 } from '@standard-schema/spec' -import type { CollectionConfig, WithVirtualProps } from '@tanstack/db' - -const adapter: PersistenceAdapter = { - loadSubset: () => Promise.resolve([]), - loadResumeSnapshot: () => - Promise.resolve({ - rows: [], - collectionMetadata: [], - latestTerm: 0, - latestSeq: 0, - latestRowVersion: 0, - resetEpoch: 0, - }), - applyCommittedTx: () => Promise.resolve(), - ensureIndex: () => Promise.resolve(), -} - -type RowInput = { id: string; rank: string; label?: string } -type RowOutput = { id: string; rank: number; label: string } - -const rowSchema = null as unknown as StandardSchemaV1 - -/** - * # What does `persistedCollectionOptions` preserve? - * - * Law and source: the public `persistedCollectionOptions` utility must preserve - * `createCollection`'s Standard Schema input, output, and inferred key contract. - * Its own-key `sync` split comes from the persisted local-only and sync-wrapped - * option overloads; a present `sync` key must contain a `SyncConfig`. - * - * Legal forms here are transforming-schema collections with and without - * external sync, plus schema-free local and synced option objects. The public - * type path is `persistedCollectionOptions(...)` into `createCollection(...)`. - * The checkpoint is the resulting `insert`, `get`, and inferred key types. - * - * Positive observations accept schema input on insert, expose schema output on - * read, infer string keys, and accept both sync modes. Hostile observations - * reject output-as-input, numeric keys, and a present-but-undefined `sync`. - * The valid local and synced cells control against rejecting every option. - * - * This oracle does not exercise schema parsing, adapter I/O, the returned - * `PersistedCollectionUtils`, sync execution, or schema-validation failures. - */ -describe(`persisted collection option type oracle`, () => { - it(`composes an inferred transforming schema with createCollection`, () => { - const localOptions = persistedCollectionOptions({ - id: `local-schema`, - schema: rowSchema, - schemaVersion: 1, - getKey: (row) => row.id, - persistence: { adapter }, - }) - - const localCollection = createCollection(localOptions) - localCollection.insert({ id: `row`, rank: `1` } satisfies RowInput) - const localOutput: RowOutput | undefined = localCollection.get(`row`) - void localOutput - - // @ts-expect-error getKey inferred string keys from the schema output - localCollection.get(1) - - const syncedOptions = persistedCollectionOptions({ - id: `synced-schema`, - schema: rowSchema, - schemaVersion: 1, - getKey: (row) => row.id, - sync: { - sync: ({ markReady }) => { - markReady() - }, - }, - persistence: { adapter }, - }) - - const syncedCollection = createCollection(syncedOptions) - syncedCollection.insert({ id: `row`, rank: `1` } satisfies RowInput) - const syncedOutput: RowOutput | undefined = syncedCollection.get(`row`) - void syncedOutput - - // @ts-expect-error transformed output values are not valid schema input - syncedCollection.insert({ id: `row`, rank: 1, label: `output` }) - - // @ts-expect-error getKey inferred string keys from the schema output - syncedCollection.get(1) - }) - - it(`keeps schema input and output roles distinct`, () => { - const collection = createCollection( - persistedCollectionOptions({ - id: `schema-roles`, - schema: rowSchema, - getKey: (row) => row.id, - persistence: { adapter }, - }), - ) - - collection.insert({ id: `row`, rank: `1` }) - - // @ts-expect-error transformed output values are not valid schema input - collection.insert({ id: `row`, rank: 1, label: `output` }) - - const output = collection.get(`row`) - if (output) { - const rank: number = output.rank - const label: string = output.label - void rank - void label - } - }) - - type NumericRow = { id: number; rank: number; label: string } - - const utils = { labelLength: (label: string) => label.length } - const stringConfig: Omit< - CollectionConfig, - `sync` - > = { - id: `string-rows`, - getKey: (row: RowOutput) => row.id, - } - const numericConfig: Omit< - CollectionConfig, - `sync` - > = { - id: `numeric-rows`, - getKey: (row: NumericRow) => row.id, - } - const stringOptions = { ...stringConfig, persistence: { adapter } } - const numericOptions = { ...numericConfig, persistence: { adapter } } - const sync = { sync: () => {} } - - it(`infers row and utility types with string keys`, () => { - const localOptions = persistedCollectionOptions({ ...stringOptions, utils }) - const syncedOptions = persistedCollectionOptions({ - ...stringOptions, - utils, - sync, - }) - const local = createCollection(localOptions) - const synced = createCollection(syncedOptions) - - for (const collection of [local, synced]) { - collection.insert({ id: `row`, rank: 1, label: `input` }) - expectTypeOf(collection.get(`row`)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() - expectTypeOf(collection.utils.labelLength).toEqualTypeOf< - typeof utils.labelLength - >() - // @ts-expect-error rank must be a number - collection.insert({ id: `row`, rank: `1`, label: `input` }) - // @ts-expect-error keys must be strings - collection.get(1) - // @ts-expect-error labelLength takes a string - collection.utils.labelLength(1) - } - }) - - it(`infers row and utility types with numeric keys`, () => { - const localOptions = persistedCollectionOptions({ - ...numericOptions, - utils, - }) - const syncedOptions = persistedCollectionOptions({ - ...numericOptions, - utils, - sync, - }) - const local = createCollection(localOptions) - const synced = createCollection(syncedOptions) - - for (const collection of [local, synced]) { - collection.insert({ id: 1, rank: 1, label: `input` }) - expectTypeOf(collection.get(1)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[number]>() - expectTypeOf(collection.utils.labelLength).toEqualTypeOf< - typeof utils.labelLength - >() - // @ts-expect-error id must be a number - collection.insert({ id: `row`, rank: 1, label: `input` }) - // @ts-expect-error keys must be numbers - collection.get(`row`) - } - }) - - it(`accepts explicit row and string key types`, () => { - const local = createCollection( - persistedCollectionOptions(stringOptions), - ) - const synced = createCollection( - persistedCollectionOptions({ ...stringOptions, sync }), - ) - - for (const collection of [local, synced]) { - collection.insert({ id: `row`, rank: 1, label: `input` }) - expectTypeOf(collection.get(`row`)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[string]>() - // @ts-expect-error rank must be a number - collection.insert({ id: `row`, rank: `1`, label: `input` }) - // @ts-expect-error keys must be strings - collection.get(1) - } - }) - - it(`accepts explicit row and numeric key types`, () => { - const local = createCollection( - persistedCollectionOptions(numericOptions), - ) - const synced = createCollection( - persistedCollectionOptions({ - ...numericOptions, - sync, - }), - ) - - for (const collection of [local, synced]) { - collection.insert({ id: 1, rank: 1, label: `input` }) - expectTypeOf(collection.get(1)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.get).parameters.toEqualTypeOf<[number]>() - // @ts-expect-error id must be a number - collection.insert({ id: `row`, rank: 1, label: `input` }) - // @ts-expect-error keys must be numbers - collection.get(`row`) - } - }) - - it(`accepts explicit never and utility type arguments`, () => { - const local = createCollection( - persistedCollectionOptions({ - ...stringOptions, - utils, - }), - ) - const synced = createCollection( - persistedCollectionOptions({ - ...stringOptions, - utils, - sync, - }), - ) - - for (const collection of [local, synced]) { - expectTypeOf(collection.get(`row`)).toEqualTypeOf< - WithVirtualProps | undefined - >() - expectTypeOf(collection.utils.labelLength).toEqualTypeOf< - typeof utils.labelLength - >() - } - }) - - it(`discriminates sync mode from the presence of the sync key`, () => { - const localOptions = persistedCollectionOptions({ - id: `local`, - getKey: (row: RowOutput) => row.id, - persistence: { adapter }, - }) - createCollection(localOptions) - - const syncedOptions = persistedCollectionOptions({ - id: `synced`, - getKey: (row: RowOutput) => row.id, - sync: { - sync: ({ markReady }) => { - markReady() - }, - }, - persistence: { adapter }, - }) - createCollection(syncedOptions) - - persistedCollectionOptions({ - id: `invalid-undefined-sync`, - getKey: (row: RowOutput) => row.id, - // @ts-expect-error a present sync key must hold a SyncConfig - sync: undefined, - persistence: { adapter }, - }) - }) -}) diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts new file mode 100644 index 0000000000..5566c082cc --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -0,0 +1,562 @@ +/** + * # Persistence wrapping preserves the caller's type contract + * + * Authority: docs/guides/sqlite-persistence.md, "Add persistence to a synced + * Collection", composes adapter options with persistedCollectionOptions and + * createCollection. The createCollection API assigns schema input to insert, + * schema output to reads, and the configured key and utilities to callers. + * Wrapping must preserve those distinctions, including without a schema. + * docs/reference/type-aliases/WithVirtualProps.md and the VirtualRowProps + * interface specify the readonly fields on a published row. + * + * This is a source-level type oracle. A history declares a row/key/utility + * shape, constructs options, wraps them, and observes the resulting public + * types at compilation. It does not execute Collection mutations, parse a + * schema, use SQLite, or establish published declaration/package resolution. + * Compiler acceptance is the observation; the independently declared shapes + * below supply the expected result, not production's inference helpers. + */ +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import fc from 'fast-check' +import ts from 'typescript' +import { it } from 'vitest' +import { + oraclePropertyOptions, + oracleRuns, + readOracleRunConfig, +} from '../../db/tests/oracle-config.js' +import { createOracleReach } from '../../db/tests/oracle-reach.js' + +type Receiver = `core` | `browser` +type Shape = { + key: string + keyName: string + fields: Array<{ + name: string + type: string + optional: boolean + inputOptional: boolean + }> + utilityName: string + utilityArgument: string + utilityResult: string +} + +const primitives = [`string`, `number`, `boolean`] as const +const fieldTypes = [ + ...primitives, + `string | null`, + `ReadonlyArray`, + `{ value: string }`, +] as const + +/** + * Grammar: rows have one string/number-compatible key and 1–4 other fields; + * fields may be optional, nullable, arrays, or a nested record. Each history + * varies property names, key domains, and a utility's argument/return types. + * Its construction paths cross schema-free/transforming schema, local/synced, + * inline/typed-spread config, and inferred/explicit type arguments. Those + * crossings matter: a fix to one overload or only untyped literals is wrong. + * + * Names are disjoint by construction. Boolean keys are excluded as illegal. + * Partial explicit type arguments select TypeScript's utility default, so + * that path deliberately supplies no custom utility. The full and inferred + * paths check utility preservation. Query Collection is always synced and + * schema-free here; it supplies its real adapter utility namespace. + * Inline schema forms also leave getKey's row unannotated. The schema must + * contextually supply its output type without help from a caller annotation. + */ +const shapeArbitrary: fc.Arbitrary = fc.record({ + key: fc.constantFrom( + `string`, + `number`, + `string | number`, + `"alpha" | "beta"`, + `1 | 2`, + `string & { readonly brand: "key" }`, + ), + keyName: fc.constantFrom(`id`, `key`, `identifier`), + fields: fc.uniqueArray( + fc.record({ + name: fc.constantFrom(`rank`, `title`, `completed`, `payload`, `label`), + type: fc.constantFrom(...fieldTypes), + optional: fc.boolean(), + inputOptional: fc.boolean(), + }), + { selector: (field) => field.name, minLength: 1, maxLength: 4 }, + ), + utilityName: fc.constantFrom( + `labelLength`, + `titleLength`, + `measure`, + `convert`, + ), + utilityArgument: fc.constantFrom(...primitives), + utilityResult: fc.constantFrom(...primitives), +}) + +// Original regressions are inputs, never separate expected answers. They use +// the same model, construction paths, and comparison as generated histories. +const replayShapes: ReadonlyArray = [ + { + key: `string`, + keyName: `id`, + fields: [ + { name: `rank`, type: `number`, optional: false, inputOptional: false }, + { name: `label`, type: `string`, optional: false, inputOptional: true }, + ], + utilityName: `labelLength`, + utilityArgument: `string`, + utilityResult: `number`, + }, + { + key: `number`, + keyName: `id`, + fields: [ + { name: `rank`, type: `number`, optional: false, inputOptional: false }, + { name: `label`, type: `string`, optional: false, inputOptional: false }, + ], + utilityName: `labelLength`, + utilityArgument: `string`, + utilityResult: `number`, + }, + { + key: `string`, + keyName: `id`, + fields: [ + { name: `title`, type: `string`, optional: false, inputOptional: false }, + { + name: `completed`, + type: `boolean`, + optional: false, + inputOptional: false, + }, + ], + utilityName: `titleLength`, + utilityArgument: `string`, + utilityResult: `number`, + }, +] + +const packageRoot = fileURLToPath(new URL(`../`, import.meta.url)) +const repositoryRoot = resolve(packageRoot, `../..`) + +/** + * Independent model: insert accepts the declared input; reads retain every + * declared output field; get takes exactly the declared key; user utilities + * keep their declared signatures. A transforming schema changes one required + * field from string to number, making input/output reversal distinguishable. + * A public get result includes undefined for a missing row, and excludes null. + * Other schema fields may become required, retaining the original oracle's + * optional-input/required-output histories as instances of this grammar. + * The model does not reproduce persistence or Collection's overload logic. + * An unannotated schema callback receives exactly the declared output row. + */ +function rowType(shape: Shape, transformed: boolean, output: boolean): string { + const fields = shape.fields.map((field, index) => { + const changed = transformed && index === 0 + const optional = changed + ? false + : transformed && !output + ? field.inputOptional + : field.optional + const type = changed ? (output ? `number` : `string`) : field.type + return `${field.name}${optional ? `?` : ``}: ${type}` + }) + return `{ ${shape.keyName}: Key; ${fields.join(`; `)} }` +} + +/** + * One shared refinement check for every generated form and receiving path. + * Exact equality rejects widening as well as narrowing; the IsAny guard also + * rejects a public read erased to any. The expected published row spells out + * the documented virtual fields without importing production's type helper. + * PublicShape flattens intersection presentation while retaining every key, + * readonly/optional modifier, and undefined in the public result. + * Negative insert/key/utility/sync observations are derived from the shape, + * so @ts-expect-error cannot silently survive a checker that admits everything. + */ +function comparison(shape: Shape, utilities: string, transformed: boolean) { + const field = shape.fields[0]! + return ` + declare const input: Input; + declare const key: Key; + collection.insert(input); + collection.get(key); + type Result = ReturnType; + type Read = NonNullable; + type Published = Output & { + readonly $hasPendingWrites: boolean; + readonly $synced: boolean; + readonly $origin: "local" | "remote"; + readonly $key: Key; + readonly $collectionId: string; + }; + type Laws = [ + Assert[0], Input | Array>>, + Assert, [Key]>>, + Assert, PublicShape>>, + Assert, false>>, + Assert`}, ${utilities}>> + ]; + // @ts-expect-error a symbol is outside every generated field's input type + collection.insert({ ...input, ${field.name}: Symbol() }); + // @ts-expect-error a symbol is outside every legal key domain + collection.get(Symbol()); + ${ + utilities === `Utils` + ? `// @ts-expect-error a symbol is outside the declared utility argument + collection.utils.${shape.utilityName}(Symbol());` + : `` + } + ${ + transformed + ? `declare const output: Output; + // @ts-expect-error schema output cannot stand in for schema input + collection.insert(output);` + : `` + } + // @ts-expect-error a present sync key must contain a SyncConfig + persistedCollectionOptions({ ...options, persistence, sync: undefined }); + ` +} + +type Cell = { + transformed: boolean + synced: boolean + typed: boolean + contextualKey: boolean + arguments: `inferred` | `row-key` | `full` +} + +function declarations(shape: Shape, transformed: boolean): string { + return ` + type Key = ${shape.key}; + type Input = ${rowType(shape, transformed, false)}; + type Output = ${rowType(shape, transformed, true)}; + type Utils = { ${shape.utilityName}: (value: ${shape.utilityArgument}) => ${shape.utilityResult} }; + declare const utils: Utils; + declare const schema: StandardSchemaV1; + ` +} + +/** + * Production driver: call the real wrapper and createCollection in TS source. + * Contextual callbacks occur directly inside the schema-bearing argument; + * spreading a pretyped callback cannot observe contextual inference. Their + * parameter type is another public observation of the same output-row law. + */ +function collectionSource(shape: Shape, cell: Cell): string { + const hasUtils = cell.arguments !== `row-key` + const schemaType = cell.transformed ? `typeof schema` : `never` + const utilsType = hasUtils ? `Utils` : `{}` + const configProperties = (contextualKey: boolean) => ` + id: "generated", + getKey: ${ + contextualKey + ? `(row) => { + type CallbackInput = Assert>; + return row.${shape.keyName}; + }` + : `(row: Output) => row.${shape.keyName}` + }, + ${cell.transformed ? `schema,` : ``} + ${hasUtils ? `utils,` : ``} + ` + const annotation = cell.typed + ? `: Omit, "sync">${ + cell.transformed ? ` & { schema: typeof schema }` : `` + }` + : `` + const typeArguments = + cell.arguments === `inferred` + ? `` + : cell.transformed + ? `` + : `` + const syncProperty = cell.synced ? `sync: { sync: () => {} },` : `` + const wrappedOptions = cell.contextualKey + ? `{ ${configProperties(true)} persistence, ${syncProperty} }` + : cell.typed + ? `options` + : `{ ...config, persistence, ${syncProperty} }` + return ` + ${declarations(shape, cell.transformed)} + const config${annotation} = { ${configProperties(false)} }; + const options = { + ...config, persistence, + ${syncProperty} + }; + const collection = createCollection(persistedCollectionOptions${typeArguments}(${wrappedOptions})); + ${comparison(shape, hasUtils ? `Utils` : `{}`, cell.transformed)} + ` +} + +/** + * Query receiving driver: use the actual adapter's options and compare its + * utility namespace before/after wrapping. Row/key/input laws still come from + * the independent shape model. No browser-specific expected answers exist. + */ +function querySource(shape: Shape): string { + return ` + ${declarations(shape, false)} + const options = queryCollectionOptions({ + id: "query-generated", queryClient: new QueryClient(), + queryKey: ["generated"], + queryFn: (): Promise> => Promise.resolve([]), + getKey: (row) => row.${shape.keyName}, + }); + const direct = createCollection(options); + type AdapterUtils = typeof direct.utils; + const collection = createCollection(persistedCollectionOptions({ ...options, persistence })); + ${comparison(shape, `AdapterUtils`, false)} + collection.utils.writeInsert(input); + collection.utils.writeDelete(key); + collection.utils.refetch({ throwOnError: true }); + // @ts-expect-error generated row fields retain their input types at the adapter boundary + collection.utils.writeInsert({ ...input, ${shape.fields[0]!.name}: Symbol() }); + // @ts-expect-error adapter keys cannot be symbols + collection.utils.writeDelete(Symbol()); + // @ts-expect-error refetch options retain their boolean contract + collection.utils.refetch({ throwOnError: "yes" }); + // @ts-expect-error Query Collection does not supply an authoritative-sync utility + collection.utils.awaitTxId(1); + ` +} + +/** + * The compiler host caches unchanged source files between generated histories. + * Only the generated consumer exists in memory. Module resolution uses source + * aliases, so core, browser re-exports, and Query Collection are real entries. + * Diagnostics are never filtered by error code or expected failing call. + */ +function createCompiler(receiver: Receiver) { + const configPath = resolve(packageRoot, `tsconfig.json`) + const config = ts.readConfigFile(configPath, ts.sys.readFile) + if (config.error) + throw new Error( + ts.flattenDiagnosticMessageText(config.error.messageText, `\n`), + ) + const parsed = ts.parseJsonConfigFileContent( + config.config, + ts.sys, + packageRoot, + ) + const options: ts.CompilerOptions = { + ...parsed.options, + noEmit: true, + noUnusedLocals: false, + noUnusedParameters: false, + paths: { + ...parsed.options.paths, + '@standard-schema/spec': [ + resolve(packageRoot, `node_modules/@standard-schema/spec`), + ], + '@tanstack/db-sqlite-persistence-core': [ + resolve(packageRoot, `src/index.ts`), + ], + '@tanstack/db-sqlite-persistence-core/*': [resolve(packageRoot, `src/*`)], + '@tanstack/query-db-collection': [ + resolve(repositoryRoot, `packages/query-db-collection/src/index.ts`), + ], + }, + } + const consumerRoot = + receiver === `core` + ? packageRoot + : resolve(repositoryRoot, `packages/browser-db-sqlite-persistence`) + const filename = resolve( + consumerRoot, + `tests/__generated-persisted-options.ts`, + ) + const host = ts.createCompilerHost(options) + const originalGetSourceFile = host.getSourceFile.bind(host) + const files = new Map() + let generated = `` + host.getSourceFile = ( + name, + languageVersion, + onError, + shouldCreateNewSourceFile, + ) => { + if (name === filename) + return ts.createSourceFile(name, generated, languageVersion, true) + let source = files.get(name) + if (!source) { + source = originalGetSourceFile( + name, + languageVersion, + onError, + shouldCreateNewSourceFile, + ) + if (source) files.set(name, source) + } + return source + } + let previous: ts.Program | undefined + return (source: string) => { + generated = source + const roots = [filename] + if (receiver === `browser`) { + roots.push(resolve(consumerRoot, `src/vite-env.d.ts`)) + } + const program = ts.createProgram(roots, options, host, previous) + previous = program + const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] + if (diagnostics.length > 0) { + throw new Error( + ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (name) => name, + getCurrentDirectory: () => repositoryRoot, + getNewLine: () => `\n`, + }), + ) + } + } +} + +const states = [ + `schema-free-local`, + `schema-free-synced`, + `schema-input-output`, + `typed-spread`, + `inferred-arguments`, + `explicit-arguments`, + `row-key-arguments`, + `schema-contextual-key`, + `query-adapter`, +] as const + +/** + * Every generated history crosses the structurally required forms. Reach is + * recorded only after the generated consumer passes compilation. Replay uses + * the shared seed/path/property interface and bypasses both normal campaigns. + * Fixed and fresh-seed campaigns use the identical grammar, budget, and check. + */ +export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { + const propertyName = `persisted-options.types.${receiver}` + const replay = readOracleRunConfig() + if ( + replay.replayPath !== undefined && + replay.replayProperty !== propertyName + ) { + it.skip(`replay selects another type-preservation receiver`, () => {}) + return + } + const campaigns = + replay.replayPath === undefined ? [`fixed`, `random`] : [`replay`] + for (const campaign of campaigns) { + it(`preserves generated option types through ${receiver} (${campaign})`, async () => { + const compile = createCompiler(receiver) + const reach = createOracleReach( + propertyName, + states.filter( + (state) => receiver === `browser` || state !== `query-adapter`, + ), + ) + const check = (shape: Shape) => { + const witnesses: Array<{ + source: string + states: Array<(typeof states)[number]> + }> = [] + for (const transformed of [false, true]) { + for (const synced of [false, true]) { + for (const typed of [false, true]) { + for (const arguments_ of [ + `inferred`, + `row-key`, + `full`, + ] as const) { + // Preserve annotated cells; add contextual forms only where + // an inline schema can supply the callback's output row. + for (const contextualKey of transformed && !typed + ? [false, true] + : [false]) { + witnesses.push({ + source: collectionSource(shape, { + transformed, + synced, + typed, + contextualKey, + arguments: arguments_, + }), + states: [ + transformed + ? `schema-input-output` + : synced + ? `schema-free-synced` + : `schema-free-local`, + ...(typed ? ([`typed-spread`] as const) : []), + ...(contextualKey + ? ([`schema-contextual-key`] as const) + : []), + arguments_ === `inferred` + ? `inferred-arguments` + : arguments_ === `row-key` + ? `row-key-arguments` + : `explicit-arguments`, + ], + }) + } + } + } + } + } + if (receiver === `browser`) { + witnesses.push({ + source: querySource(shape), + states: [`query-adapter`], + }) + } + const entry = + receiver === `core` + ? resolve(packageRoot, `src/index.js`) + : resolve( + repositoryRoot, + `packages/browser-db-sqlite-persistence/src/index.js`, + ) + compile(` + import { createCollection } from "@tanstack/db"; + import type { CollectionConfig } from "@tanstack/db"; + import type { StandardSchemaV1 } from "@standard-schema/spec"; + import { persistedCollectionOptions } from ${JSON.stringify(entry)}; + import type { PersistedCollectionPersistence } from ${JSON.stringify(entry)}; + ${ + receiver === `browser` + ? `import { QueryClient } from "@tanstack/query-core"; + import { queryCollectionOptions } from "@tanstack/query-db-collection";` + : `` + } + declare const persistence: PersistedCollectionPersistence; + type Equal = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; + type Assert = T; + type IsAny = 0 extends (1 & T) ? true : false; + type PublicShape = { [K in keyof T]: T[K] }; + ${witnesses.map(({ source }, index) => `namespace Witness${index} { ${source} }`).join(`\n`)} + `) + return witnesses.flatMap((witness) => witness.states) + } + const property = fc.asyncProperty(shapeArbitrary, (shape) => { + reach.beginHistory() + for (const state of check(shape)) reach.reach(state) + return Promise.resolve() + }) + const parameters = + campaign === `fixed` + ? { numRuns: oracleRuns(12), seed: 2259 } + : oraclePropertyOptions(12, propertyName) + await fc.assert(property, parameters) + // Replays use the same check but do not inflate generated reach. An + // explicitly requested seed/path goes directly to its generated history. + if (campaign !== `replay`) { + for (const shape of replayShapes) check(shape) + } + reach.assertReached() + console.info( + `${propertyName} ${campaign} reach`, + Object.fromEntries(reach.counts()), + ) + }, 120_000) + } +} diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts new file mode 100644 index 0000000000..318cfd688a --- /dev/null +++ b/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts @@ -0,0 +1,5 @@ +// @vitest-environment node +// Core entry; the shared oracle owns the grammar, model, and comparison. +import { registerPersistedOptionsTypeOracle } from './persisted-options-type-oracle.js' + +registerPersistedOptionsTypeOracle(`core`) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 46e6efb6b2..de351740f0 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -3,6 +3,8 @@ import { oracleReplayReporter } from './oracle-replay-witness.js' type OracleEnvironment = Record const staticOracleProperties = [ + `persisted-options.types.core`, + `persisted-options.types.browser`, `cursor-pagination.no-peek`, `cursor-pagination.history`, `cursor-pagination.cache`, From 892bf55b8a67c04695b580e820d243962e52e356 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 11:08:31 -0600 Subject: [PATCH 3/6] test: strengthen persisted option type oracle coverage --- docs/contributing/oracle-coverage.md | 50 +- .../pr-2259-persisted-option-types.md | 168 ++++++- package.json | 2 +- .../package.json | 2 +- .../db-sqlite-persistence-core/package.json | 3 +- .../tests/persisted-options-type-oracle.ts | 443 +++++++++++++----- scripts/calibrate-persisted-option-types.mjs | 229 +++++++++ 7 files changed, 743 insertions(+), 154 deletions(-) create mode 100644 scripts/calibrate-persisted-option-types.mjs diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 285c4c829e..4186ca42e3 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -126,26 +126,40 @@ establish every nested projection shape or framework receiving boundary. The generated compile-time persistence owner is `packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts`. It generates row shapes (1–4 scalar, nullable, array, or nested fields), six -key domains, field optionality, and utility signatures. Every generated shape -crosses local/synced, schema-free/transforming schema, typed-spread/inline -arguments, and inferred/partial/full explicit type arguments. Six additional -inline schema forms infer the unannotated `getKey` callback's row, preserving -all 24 annotated construction forms. One independent -type law compares insert input, complete published row types (including the -documented virtual fields), exact keys, and supplied -utility signatures at TypeScript compilation. Contextual callbacks also compare -their parameter with the independent output row. Partial explicit arguments use -TypeScript's utility default rather than promising inference of unspecified -generic arguments. The core `persisted-options.test.ts` runner and the browser -`persisted-options.test.ts` receiving runner share that judgment; browser also -passes real Query Collection options through it and compares adapter utilities -before/after wrapping. Both run 12 histories with seed 2259 and 12 with a fresh +key domains, field optionality, utility signatures, numeric schema versions, +and network deadlines. Each shape retains 30 construction forms across +local/synced, schema-free/transforming schema, typed-spread/inline arguments, +and inferred/partial/full type arguments, including contextual schema callbacks. +Twelve configured forms cross eager/on-demand/network-first options with schema +and sync modes; network-first remains eager. Two schema-free forms supply a +phantom schema generic without a schema value and still preserve row input. + +Independent type laws compare insert input, complete published row types, +exact keys, user utilities, added local persistence capabilities (including +optional leadership/reload signatures), option types, and contextual getKey / +markReady callbacks. The browser driver uses real Query Collection options. +Its independent closed capability model checks direct-write signatures, +observer state, refetch parameters and a non-any Promise-array envelope. A +supplemental before/after relation preserves the full Query utility types; +QueryObserverResult's internal payload has no independent model here. +Fresh-object sync rejection uses an exact unsuppressed overload diagnostic and +a legal neighbor that changes only sync, so a collateral option error cannot +substitute for the promised rejection. + +Core and browser `persisted-options.test.ts` runners share the grammar/model +and use their respective receiver tsconfig. Package and root `test:oracles` +commands select them. Both run 12 histories with seed 2259 and 12 with a fresh seed, measure per-path reach, and accept the shared seed/path/property replay -interface (`persisted-options.types.core` / `.browser`). This owner does not -execute runtime parsing, adapter I/O, sync, or publication, and does not check -built declarations, npm resolution, other host packages, or every TS version. +interface (`persisted-options.types.core` / `.browser`). A shared semantic +builder retains source and declaration diagnostics while caching dependencies; +timeouts scale with the campaign multiplier, and histories yield for worker RPC. +This owner does not execute runtime parsing, adapter I/O, sync, or publication, +and does not check built declarations, npm resolution, other host packages, +every TS version, arbitrary extra-property structural assignment, or the +internal semantics of trusted PendingMutation/LoadSubsetOptions carriers. The [type-preservation audit](oracle-reviews/pr-2259-persisted-option-types.md) -records parent RED, GREEN, legacy-suite survival, and hostile-mutant evidence. +and `scripts/calibrate-persisted-option-types.mjs` preserve parent RED, GREEN, +legacy-suite survival, hostile-mutant evidence, and runnable overlay recipes. Other existing fixed type checks cover adjacent compile-time boundaries: `packages/db/tests/mutation-handler-type-oracle.test-d.ts` checks sibling diff --git a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md index cabcb93382..b05822afed 100644 --- a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md +++ b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md @@ -4,8 +4,8 @@ Reviewed PR head: `e3e8a9b5bce0a99d889b73f6eb7578b6d7ef3769`. Original defective parent: `d2935d0fcac164163adc231c6a796b8a4c234d9b`. This self-audit covers the oracle migration built on that head. Production `persisted.ts` is unchanged from the reviewed PR after all temporary mutants. -The review ledger and raw GitHub responses are retained under `.context/`; -this record preserves the verdict-critical evidence for version control. +Optional raw logs remain in local scratch storage. This versioned record and +the calibration command below preserve the verdict-critical reproduction steps. The initial migration record below precedes the prep-pr follow-up at the end. That follow-up supersedes its owner hash, construction counts, and scoped @@ -15,7 +15,8 @@ The migrated executable owner has SHA-256 `c164515bf9705864bd5f0b27dc6e7769d1f7214cfba8158130fb12b36c04c9a0`. The replay registry has SHA-256 `53837b427c149964cd6dda52c7f87d71de57626790c48012527d1d2028f90889`. -These identify the repaired instrument while its changes remain uncommitted. +These identify the historical initial instrument. Later sections explicitly +supersede them. They are not hashes of the current owner. ## Law, experiment, and inference @@ -176,7 +177,7 @@ empty at capture. There were no additional capped findings or footnotes. CodeRabbit's overload summary was technically accurate. Its oracle analysis had low depth and prioritized a documentation metric over a real enforcement -gap. **No hire for an oracle-review role on this evidence.** The preceding +gap. The preceding assistant review located the right owner and distinguished compile-time from runtime coverage, but was too generous in treating fixed expected shapes as sufficient independent oracle judgment. The surviving mutant corrects that @@ -255,7 +256,7 @@ retain their scope, with the expanded counts and owner hash stated here. Finding ledger: **one raw prep-pr finding = one repaired coverage regression**. The simplifier made no changes and proposed no additional suggestions. -Reviewer assessment: **hire for this scoped oracle review**. The reviewer +Technical reviewer assessment: the reviewer identified a lost public path and proved its significance with a full-campaign survivor and a distinguishing old-path failure. Root independently reran the four-way probe and inspected the full-campaign overlay and diagnostics. @@ -266,3 +267,160 @@ The bundle-size comparison could not build the cached baseline because that worktree lacks Vitest/TypeScript dependencies. A separate TypeScript 5.9.3 transpile comparison emits identical JavaScript for the parent and repaired `persisted.ts`. This is emit evidence, not a completed bundle measurement. + +## External review follow-up at 4ea6b7663 + +The supplied review read PR head +`4ea6b76634ef3b16ec8461fb29bb4aebb51819da` without running its tests. +This follow-up evaluates all ten items and repairs confirmed oracle gaps. +Production remains the original two schema-free result corrections; no new +production defect was demonstrated. This section supersedes earlier descriptions +of the current grammar, Query expectation, diagnostics, counts, and replay path. +The follow-up owner SHA-256 is `8f206d9195231239989e2a2c3370fd4abaf4fe4bab32717689c0b5e04834905c`. +The containing Git commit identifies the complete follow-up tree. Historical +hashes above describe earlier experiments, rather than a mutable current file. + +### Law, experiment, inference, and enforcement + +| Law and authority | Experiment and inference | Final enforcement and limits | +| --- | --- | --- | +| Public persistence utilities retain required/optional signatures, alongside caller utilities. Authority: PersistedCollectionUtils API and SQLite guide. | Removing the local result intersection passes the reviewed full campaign. The expanded comparison rejects TS2344 at LocalCapabilities. | Independent signatures observe acceptMutations, optional getLeadershipState and optional forceReloadSubset. PendingMutation/LoadSubsetOptions are trusted domain carriers, not expected-result helpers. | +| Documented persistence options and contextual callbacks remain usable across schema/sync configurations. Authority: SQLite guide and public SyncConfig/option contracts. | Old grammar omits those options and uses no-argument sync functions. Widening schemaVersion, initialRender strategy, syncMode or contextual sync input now rejects TS2344. | Twelve extra configurations per shape cross schema and sync modes with eager/on-demand/network-first. Network-first is eager. Generated schemaVersion is 1–100 and timeout is 0–10000. This checks types, not runtime option validation. | +| Query composition preserves its documented closed capabilities. Authority: QueryCollectionUtils public reference and the guide's adapter composition. | isLoading:any survives the reviewed full browser campaign. The independent model rejects it, widened refetch options, and an extra optional adapter capability. | Generated row/key write signatures plus exact state fields and refetch parameters. Non-any Promise-array envelope is independent; supplemental before/after equality preserves the full return payload. No independent QueryObserverResult payload semantics are claimed. | +| A fresh sync:undefined argument must fail for sync admission, rather than collateral corruption. | Undefined-sync admission alone already failed the old directive. Adding unrelated getKey:42 to the negative expressions masks it and passes the old full campaign. New paired observations reject that compound corruption. | Unsuppressed TS2769 must point at the exact fresh sync property. The legal neighbor changes only sync and must compile. Every other diagnostic remains fatal. Structural extra properties carried through variables have different TypeScript freshness semantics and are not this rejection claim. | +| Run budget, receiving configuration and diagnostic fidelity must remain effective. | Multiplier10 originally registers 120 runs but 120000ms. Revised registration gives 1200000ms. Source-only TS2322 and declaration-only TS4094 controls reject. | Receiver tsconfig, one compiler per receiver across campaigns, dependency caching, all original diagnostic classes, and event-loop yields between generated histories. No generated-file-only diagnostic filtering. | + +### Lossless finding ledger + +| ID | Supplied claim | Technical verdict / evidence | PR action | Durable value | +| --- | --- | --- | --- | --- | +| E1 | Local-added utilities are unchecked; removing their result intersection survives. | Confirmed by full reviewed-campaign survivor and new TS2344 rejection. | fixed-now: independent LocalCapabilities comparison. | Primary owner and local-utils recipe. | +| E2 | Deleted schemaVersion/markReady examples lost coverage; initialRender/syncMode absent. | Confirmed grammar omissions. No new runtime defect claimed. | fixed-now: generated legal options, exact declared option law, contextual readiness signature and reaches. | Primary grammar; four option/callback mutants. | +| E3 | Unused schema generic silently drops a schema; using never in inputs fixes it. | Refuted as stated. Eighteen compiler probes show current and proposed inputs both accept no-value phantom generic, reject an actual schema value (TS2769), and retain correct schema-input composition. Suggested change has no effect. | refuted: no production edit. | Two phantom-generic paths preserve useful normalization coverage. | +| E4 | Whole-program work repeats; timeout ignores multiplier; check only generated file and share compiler. | Timeout confirmed. Repeated dependency checking is avoidable. Generated-only diagnostics would hide source errors. | fixed-now: semantic builder shared across campaigns, scaled timeout, retained source/declaration diagnostics. | Deterministic affected-file telemetry and budget/source controls. | +| E5 | Fixed refetch/nonexistent-method examples violate law-only policy. | Confirmed standalone concrete expressions in Query driver. | fixed-now: reusable closed capability model replaces them. | Model parameterized by generated rows/keys, not fixed call examples. | +| E6 | Expected Query utils derive from production; shared erasure can pass. | Independent judgment gap confirmed by isLoading:any survivor. Whole-namespace any was overstated: old negative writes already detect some erasure. | fixed-now: independent model plus supplemental transparency relation. | State, namespace and refetch corruption controls. | +| E7 | Named campaigns omit runners; tests can only get12 runs. | Registration omission confirmed. Hardcoded12 claim refuted: multiplier3 helper experiment executes36 fixed and36 random checks; normal package test globs already include runners. | fixed-now: package/root test:oracles registration. | Distinguish selection, count, reach and completion. | +| E8 | Browser uses core tsconfig, invalidating browser witness. | Config selection confirmed; current semantic compiler settings match. Both configs compile the same consumer and resolve the same browser/Query source modules. | fixed-now: select receiving package config to prevent drift. | Preserve accurate source-only browser scope. | +| E9 | Any unrelated error can satisfy bare sync directive. | Checker fragility confirmed with the two-factor control above; original admission-only mutant was already detected. | fixed-now: exact unsuppressed property diagnostic and legal same-candidate neighbor. | Reusable admission observation rejects collateral corruption. | +| E10 | Audit depends on inaccessible scratch, mutable hashes, personnel verdicts. | Mixed: versioned record already contains authority, commands, outcomes and limits, and historical hashes match. Exact overlay recipes and stale wording needed improvement. | fixed-now: versioned runnable calibration command, historical-hash clarification, technical-only reviewer assessment. | Others can reproduce decisive results without prior scratch files. | + +Loss audit: **10 supplied items = 9 fixed-now + 1 refuted**. No raw item, +footnote, or suggested fix was dropped. Proposed remedies rejected as ineffective +or unsafe remain recorded in their original item. No deferrals or unresolved +product decisions remain in this scoped evaluation. + +The review had high signal for missing observations and grammar dimensions, +and clearly disclosed that its findings were unexecuted. Its weaker points were +the ineffective input-never proposal, generated-only diagnostics suggestion, +and overstatements about multiplier use, compiler settings, and missing evidence. +Execution supports the oracle repairs, not a newly discovered production bug. +Personnel recommendations remain in the local review ledger. + +### Reproducible calibration + +From the repository root, with the workspace dependencies installed: + +```sh +# Unmodified current fixed campaign: must succeed. +node scripts/calibrate-persisted-option-types.mjs none + +# Reviewed full campaigns: both accept these wrong implementations. +node scripts/calibrate-persisted-option-types.mjs local-utils 4ea6b7663 +node scripts/calibrate-persisted-option-types.mjs query-status 4ea6b7663 +node scripts/calibrate-persisted-option-types.mjs collateral-sync 4ea6b7663 + +# Current owner rejects each at the named comparison. +node scripts/calibrate-persisted-option-types.mjs local-utils +node scripts/calibrate-persisted-option-types.mjs query-status +node scripts/calibrate-persisted-option-types.mjs collateral-sync + +# Exact original production, without touching source files on disk: rejects TS2769. +node scripts/calibrate-persisted-option-types.mjs parent +ORACLE_RECEIVER=browser node scripts/calibrate-persisted-option-types.mjs parent + +# Retained source and declaration diagnostic fidelity: reject TS2322 / TS4094. +node scripts/calibrate-persisted-option-types.mjs source-error +node scripts/calibrate-persisted-option-types.mjs declaration-error + +# Budget must report 120 runs and two 1200000ms registrations. +ORACLE_REGISTRATION_ONLY=1 TANSTACK_DB_ORACLE_RUNS_MULTIPLIER=10 node scripts/calibrate-persisted-option-types.mjs none + +# Observe builder invalidation and worker progress through both campaigns. +ORACLE_COMPILER_WORK=1 ORACLE_ALL_CAMPAIGNS=1 node scripts/calibrate-persisted-option-types.mjs none +``` + +Calibration failures intentionally exit nonzero. Classify the reported compiler +constraint or missing exact admission rejection, rather than treating any +nonzero exit as a killed mutant. The script creates disposable files under +`.context` from versioned sources; it needs no existing scratch evidence and +never mutates production on disk. Its optional second argument pins only the +historical owner; production starts from the current checkout unless the +selected overlay replaces it. To reproduce the precise historical whole tree, +run the command from a checkout of the reviewed commit with this calibration +script available. + +The command also supports sync-regression, local-regression, widened-key, +erased-utils, blanket-never, contextual-key, missing-undefined, extra-read-field, +undefined-sync, query-refetch, query-namespace, schema-version, initial-render, +sync-mode and contextual-sync. The widened-key control requires normal generated +sampling; the other observed controls reject at seed 2259 and reduced path +`0:0:0:0:0:0:0:0:0:0:0`. The property and replay interface are the same as the +normal receiver runners. Explicit replay starts at the requested generated path and bypasses normal +campaigns and fixed examples. Fast-check 3.23 can also visit remaining sibling +shrinks after a passing colon-path replay, despite numRuns: 1; a calibrated +repaired replay visited 14 histories. Failing minimal replays stop after the +first rejected history. No single-history claim is made for successful replays. + +### Follow-up evidence and requirement audit + +- **Law:** The original composition law remains, with independent capability, + option and contextual-callback observations from the public references above. +- **Reach:** Each core campaign compiles 44 constructions per shape; browser + compiles 45. Across four 12-history campaigns this is 2136 generated constructions, + plus 534 fixed-replay constructions outside generated reach. Every prior state + and each new local-capabilities, configured-options, on-demand-options, + network-first-options, contextual-sync and phantom-schema-argument state + reaches 12 histories per campaign; query-adapter reaches 12 on browser. +- **RED / mutants:** Original production still fails at public composition. + Local utility removal, Query state widening and collateral-error masking are + demonstrated false greens on the reviewed owner and reject on the expanded + owner. Twenty-one original/hostile/collateral controls reject, including source and + declaration-only controls. Outcomes are compiler comparison failures or the + exact missing-admission observation, not timeouts or setup failures. +- **GREEN:** Both receiver campaigns and neighboring core type contracts pass + on unchanged PR production (11 core tests and two browser campaigns, no type + errors). Both named package commands also pass at multiplier 2, with 24 + histories per declared state in each campaign. Browser's command filters to + the changed type property; its 99 older runtime tests are not rerun by that + check. Semantic affected-file instrumentation records 290 files for the first + core history and 1 for every later history across both campaigns. Declaration + diagnostics are still collected separately, so this count does not claim all + compiler work is eliminated. The worker heartbeat advances 24 times across + the 24 generated histories. Formatting, ESLint, lint:changed and diff checks pass. +- **Limits:** Source compilation with TS 5.9.3 / Vitest 3.2.4 / fast-check 3.23.2 and the + stated finite grammar. No runtime, package/declaration publication, or other + host/compiler-version claim. Declaration diagnostics are retained for source + fidelity, not proof of emitted artifact consumption. + +The independent follow-up challenge found sibling S1: the initial builder +rewrite omitted declaration diagnostics. An exported anonymous class with a +private field survives that gate but fails TS4094 when declaration diagnostics +return. The final gate retains them and the declaration-error recipe. This was +an oracle-repair regression, not a production defect. + +ORC-001/002 now cover independent local/Query capability and option laws. +ORC-003 prose follows the revised grammar, model, driver and exact diagnostic +cut. ORC-004 reconstruction retains all 30 old forms and adds configured/phantom +forms; option ablations and the documented calibration controls distinguish +their contribution. ORC-005/006 observe real receivers and reject the controls +above. ORC-007 retains campaign parity and the shared direct replay interface. +ORC-008 remains inapplicable to a static model. ORC-009's type-history mapping +is unchanged. ORC-010 retains unexpected source/declaration diagnostics and +original shrinking evidence; no disk restoration is needed for the overlays. +ORC-011's shared Query fault is answered by the independent capability model. +ORC-012 is this versioned evidence. ORC-013 observes legal option neighbors and +invalid sync with a same-candidate legal neighbor. ORC-014 retains the real +browser/Query receiving source paths, with no runtime provider claim. +ORC-015 measures every new declared state. ORC-016 contains no standalone fixed +API examples; fixed regressions remain shared model inputs. diff --git a/package.json b/package.json index 85af542017..37de2b28a8 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "lint-all": "eslint . --fix", "prepare": "husky", "test": "pnpm --filter \"./packages/**\" test && pnpm --filter @tanstack/db test:indexed-db:package", - "test:oracles": "pnpm --filter @tanstack/db test:oracles && pnpm --filter @tanstack/query-db-collection test:oracles && pnpm --filter @tanstack/db test:indexed-db:oracles", + "test:oracles": "pnpm --filter @tanstack/db test:oracles && pnpm --filter @tanstack/query-db-collection test:oracles && pnpm --filter @tanstack/db test:indexed-db:oracles && pnpm --filter @tanstack/db-sqlite-persistence-core test:oracles && pnpm --filter @tanstack/browser-db-sqlite-persistence test:oracles", "test:minified-db": "node scripts/test-minified-db.mjs", "test:production-errors": "node scripts/generate-error-docs.mjs --check && node scripts/test-production-errors.mjs", "check:mangle": "node scripts/mangle-private-members.mjs", diff --git a/packages/browser-db-sqlite-persistence/package.json b/packages/browser-db-sqlite-persistence/package.json index 9a31af0bd9..df480b0cbc 100644 --- a/packages/browser-db-sqlite-persistence/package.json +++ b/packages/browser-db-sqlite-persistence/package.json @@ -23,7 +23,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest --run", - "test:oracles": "vitest --run --coverage.enabled=false tests/shared-driver-fairness-oracle.test.ts tests/opfs-page-lifecycle-oracle.test.ts tests/opfs-worker-diagnostics-oracle.test.ts tests/opfs-request-order-oracle.test.ts tests/per-collection-coordinator-oracle.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/shared-driver-fairness-oracle.test.ts tests/opfs-page-lifecycle-oracle.test.ts tests/opfs-worker-diagnostics-oracle.test.ts tests/opfs-request-order-oracle.test.ts tests/per-collection-coordinator-oracle.test.ts tests/persisted-options.test.ts", "test:opfs-fairness": "playwright test --config playwright.opfs.config.ts", "test:opfs-electric": "playwright test --config playwright.opfs-electric.config.ts", "test:e2e": "pnpm --filter @tanstack/db-ivm build && pnpm --filter @tanstack/db build && pnpm --filter @tanstack/db-sqlite-persistence-core build && pnpm --filter @tanstack/browser-db-sqlite-persistence build && vitest --config vitest.e2e.config.ts --run" diff --git a/packages/db-sqlite-persistence-core/package.json b/packages/db-sqlite-persistence-core/package.json index fe1c3ae45f..9d1c15a4a9 100644 --- a/packages/db-sqlite-persistence-core/package.json +++ b/packages/db-sqlite-persistence-core/package.json @@ -20,7 +20,8 @@ "build": "vite build", "dev": "vite build --watch", "lint": "eslint . --fix", - "test": "vitest --run" + "test": "vitest --run", + "test:oracles": "vitest --run --coverage.enabled=false tests/persisted-options.test.ts" }, "type": "module", "main": "dist/cjs/index.cjs", diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts index 5566c082cc..4aaceb4a44 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -6,6 +6,8 @@ * createCollection. The createCollection API assigns schema input to insert, * schema output to reads, and the configured key and utilities to callers. * Wrapping must preserve those distinctions, including without a schema. + * The public PersistedCollectionUtils and QueryCollectionUtils references + * specify the added local capabilities and the closed Query utility namespace. * docs/reference/type-aliases/WithVirtualProps.md and the VirtualRowProps * interface specify the readonly fields on a published row. * @@ -18,6 +20,7 @@ */ import { resolve } from 'node:path' import { fileURLToPath } from 'node:url' +import { setImmediate } from 'node:timers/promises' import fc from 'fast-check' import ts from 'typescript' import { it } from 'vitest' @@ -41,6 +44,8 @@ type Shape = { utilityName: string utilityArgument: string utilityResult: string + schemaVersion?: number + networkTimeoutMs?: number } const primitives = [`string`, `number`, `boolean`] as const @@ -94,6 +99,8 @@ const shapeArbitrary: fc.Arbitrary = fc.record({ ), utilityArgument: fc.constantFrom(...primitives), utilityResult: fc.constantFrom(...primitives), + schemaVersion: fc.integer({ min: 1, max: 100 }), + networkTimeoutMs: fc.integer({ min: 0, max: 10_000 }), }) // Original regressions are inputs, never separate expected answers. They use @@ -150,6 +157,12 @@ const repositoryRoot = resolve(packageRoot, `../..`) * A public get result includes undefined for a missing row, and excludes null. * Other schema fields may become required, retaining the original oracle's * optional-input/required-output histories as instances of this grammar. + * Public PendingMutation and LoadSubsetOptions are trusted parameter-domain + * carriers, never used to compute capability names, modifiers, or signatures. + * Local additions are observed separately because UtilsRecord has an index + * signature; comparing just the caller's keys cannot observe those additions. + * Configured cells independently specify numeric schema versions/timeouts, + * eager/on-demand mode and network-first strategy from the SQLite guide. * The model does not reproduce persistence or Collection's overload logic. * An unannotated schema callback receives exactly the declared output row. */ @@ -177,7 +190,12 @@ function rowType(shape: Shape, transformed: boolean, output: boolean): string { * Negative insert/key/utility/sync observations are derived from the shape, * so @ts-expect-error cannot silently survive a checker that admits everything. */ -function comparison(shape: Shape, utilities: string, transformed: boolean) { +function comparison( + shape: Shape, + utilities: string, + transformed: boolean, + local = false, +) { const field = shape.fields[0]! return ` declare const input: Input; @@ -193,12 +211,24 @@ function comparison(shape: Shape, utilities: string, transformed: boolean) { readonly $key: Key; readonly $collectionId: string; }; + type LocalCapabilities = { + acceptMutations: (transaction: { + mutations: Array>> + }) => Promise | void; + getLeadershipState?: () => { nodeId: string; isLeader: boolean }; + forceReloadSubset?: (options: LoadSubsetOptions) => Promise | void; + }; type Laws = [ Assert[0], Input | Array>>, Assert, [Key]>>, Assert, PublicShape>>, Assert, false>>, - Assert`}, ${utilities}>> + Assert` : `Pick`}, ${utilities}>>${ + local + ? `, + Assert, LocalCapabilities>>` + : `` + } ]; // @ts-expect-error a symbol is outside every generated field's input type collection.insert({ ...input, ${field.name}: Symbol() }); @@ -217,8 +247,11 @@ function comparison(shape: Shape, utilities: string, transformed: boolean) { collection.insert(output);` : `` } - // @ts-expect-error a present sync key must contain a SyncConfig - persistedCollectionOptions({ ...options, persistence, sync: undefined }); + const rejectedSyncOptions = { ...options, persistence, sync: undefined }; + // The positive neighbor changes only sync. A collateral invalid property + // must fail here rather than masquerading as the expected rejection below. + persistedCollectionOptions({ ...rejectedSyncOptions, sync: { sync: ({ markReady }) => markReady() } }); + persistedCollectionOptions({ ...rejectedSyncOptions, sync: undefined }); ` } @@ -227,7 +260,8 @@ type Cell = { synced: boolean typed: boolean contextualKey: boolean - arguments: `inferred` | `row-key` | `full` + arguments: `inferred` | `row-key` | `full` | `phantom-schema` + configuration?: `eager` | `on-demand` | `network-first` } function declarations(shape: Shape, transformed: boolean): string { @@ -251,8 +285,12 @@ function collectionSource(shape: Shape, cell: Cell): string { const hasUtils = cell.arguments !== `row-key` const schemaType = cell.transformed ? `typeof schema` : `never` const utilsType = hasUtils ? `Utils` : `{}` + const configuration = cell.configuration + ? `schemaVersion: ${shape.schemaVersion ?? 1}, syncMode: "${cell.configuration === `on-demand` ? `on-demand` : `eager`}" as const, ${cell.configuration === `network-first` ? `initialRender: { strategy: "network-first" as const, networkTimeoutMs: ${shape.networkTimeoutMs ?? 0} },` : ``}` + : `` const configProperties = (contextualKey: boolean) => ` id: "generated", + ${configuration} getKey: ${ contextualKey ? `(row) => { @@ -274,8 +312,13 @@ function collectionSource(shape: Shape, cell: Cell): string { ? `` : cell.transformed ? `` - : `` - const syncProperty = cell.synced ? `sync: { sync: () => {} },` : `` + : `` + const syncProperty = cell.synced + ? `sync: { sync: ({ markReady }) => { + type ReadinessCallback = Assert void>>; + markReady(); + } },` + : `` const wrappedOptions = cell.contextualKey ? `{ ${configProperties(true)} persistence, ${syncProperty} }` : cell.typed @@ -283,24 +326,61 @@ function collectionSource(shape: Shape, cell: Cell): string { : `{ ...config, persistence, ${syncProperty} }` return ` ${declarations(shape, cell.transformed)} + ${ + cell.configuration + ? ` + type DeclaredOptions = ${cell.synced ? `PersistedSyncWrappedOptions` : `PersistedLocalOnlyOptions`}; + type ConfigurationLaw = [ + Assert>, + Assert>, + Assert> + ];` + : `` + } const config${annotation} = { ${configProperties(false)} }; const options = { ...config, persistence, ${syncProperty} - }; + } satisfies typeof config & { persistence: PersistedCollectionPersistence; sync?: SyncConfig }; const collection = createCollection(persistedCollectionOptions${typeArguments}(${wrappedOptions})); - ${comparison(shape, hasUtils ? `Utils` : `{}`, cell.transformed)} + ${comparison(shape, hasUtils ? `Utils` : `{}`, cell.transformed, !cell.synced)} ` } /** - * Query receiving driver: use the actual adapter's options and compare its - * utility namespace before/after wrapping. Row/key/input laws still come from - * the independent shape model. No browser-specific expected answers exist. + * Query's documented utility capability model is independent of adapter output. + * Direct write signatures vary with generated rows/keys; state fields and the + * closed namespace follow the public QueryCollectionUtils reference. Refetch + * compares its exact parameter tuple and a non-any Promise-of-array envelope. + * A supplemental transparency relation preserves the full payload across the + * wrapper; QueryObserverResult internals have no independent model here. + * This single model replaces standalone fixed refetch/nonexistent-method calls. */ function querySource(shape: Shape): string { return ` ${declarations(shape, false)} + type QueryCapabilities = { + [K in keyof U]: K extends "refetch" + ? U[K] extends (...args: infer P) => unknown ? P : never + : U[K] + }; + type AdapterUtils = { + writeInsert: (data: Input | Array) => Promise; + writeUpdate: (data: Partial | Array>) => Promise; + writeDelete: (keys: Key | Array) => Promise; + writeUpsert: (data: Partial | Array>) => Promise; + writeBatch: (callback: () => void) => Promise; + clearError: () => Promise; + refetch: [opts?: { throwOnError?: boolean }]; + lastError: unknown; + isError: boolean; + errorCount: number; + isFetching: boolean; + isRefetching: boolean; + isLoading: boolean; + dataUpdatedAt: number; + fetchStatus: "fetching" | "paused" | "idle"; + }; const options = queryCollectionOptions({ id: "query-generated", queryClient: new QueryClient(), queryKey: ["generated"], @@ -308,20 +388,20 @@ function querySource(shape: Shape): string { getKey: (row) => row.${shape.keyName}, }); const direct = createCollection(options); - type AdapterUtils = typeof direct.utils; const collection = createCollection(persistedCollectionOptions({ ...options, persistence })); ${comparison(shape, `AdapterUtils`, false)} - collection.utils.writeInsert(input); - collection.utils.writeDelete(key); - collection.utils.refetch({ throwOnError: true }); - // @ts-expect-error generated row fields retain their input types at the adapter boundary - collection.utils.writeInsert({ ...input, ${shape.fields[0]!.name}: Symbol() }); - // @ts-expect-error adapter keys cannot be symbols - collection.utils.writeDelete(Symbol()); - // @ts-expect-error refetch options retain their boolean contract - collection.utils.refetch({ throwOnError: "yes" }); - // @ts-expect-error Query Collection does not supply an authoritative-sync utility - collection.utils.awaitTxId(1); + // Supplemental transparency relation retains the full refetch payload. + // The independent model above prevents a shared wrong answer passing it. + type AdapterPreservation = Assert>; + type RefetchResult = ReturnType; + type RefetchValues = Awaited; + type RefetchValue = RefetchValues[number]; + type RefetchLaw = [ + Assert, false>>, + Assert, false>>, + Assert, false>>, + Assert> ? true : false, true>> + ]; ` } @@ -329,10 +409,16 @@ function querySource(shape: Shape): string { * The compiler host caches unchanged source files between generated histories. * Only the generated consumer exists in memory. Module resolution uses source * aliases, so core, browser re-exports, and Query Collection are real entries. - * Diagnostics are never filtered by error code or expected failing call. + * A semantic builder reuses dependency diagnostics across histories and both + * campaigns. It retains every source diagnostic, apart from the exact expected + * sync-admission observations paired with an otherwise identical legal call. */ function createCompiler(receiver: Receiver) { - const configPath = resolve(packageRoot, `tsconfig.json`) + const consumerRoot = + receiver === `core` + ? packageRoot + : resolve(repositoryRoot, `packages/browser-db-sqlite-persistence`) + const configPath = resolve(consumerRoot, `tsconfig.json`) const config = ts.readConfigFile(configPath, ts.sys.readFile) if (config.error) throw new Error( @@ -341,7 +427,7 @@ function createCompiler(receiver: Receiver) { const parsed = ts.parseJsonConfigFileContent( config.config, ts.sys, - packageRoot, + consumerRoot, ) const options: ts.CompilerOptions = { ...parsed.options, @@ -362,10 +448,6 @@ function createCompiler(receiver: Receiver) { ], }, } - const consumerRoot = - receiver === `core` - ? packageRoot - : resolve(repositoryRoot, `packages/browser-db-sqlite-persistence`) const filename = resolve( consumerRoot, `tests/__generated-persisted-options.ts`, @@ -374,6 +456,7 @@ function createCompiler(receiver: Receiver) { const originalGetSourceFile = host.getSourceFile.bind(host) const files = new Map() let generated = `` + let revision = 0 host.getSourceFile = ( name, languageVersion, @@ -381,7 +464,10 @@ function createCompiler(receiver: Receiver) { shouldCreateNewSourceFile, ) => { if (name === filename) - return ts.createSourceFile(name, generated, languageVersion, true) + return Object.assign( + ts.createSourceFile(name, generated, languageVersion, true), + { version: String(revision) }, + ) let source = files.get(name) if (!source) { source = originalGetSourceFile( @@ -390,23 +476,66 @@ function createCompiler(receiver: Receiver) { onError, shouldCreateNewSourceFile, ) - if (source) files.set(name, source) + if (source) { + Object.assign(source, { version: `0` }) + files.set(name, source) + } } return source } - let previous: ts.Program | undefined + let previous: ts.SemanticDiagnosticsBuilderProgram | undefined return (source: string) => { generated = source + revision++ const roots = [filename] if (receiver === `browser`) { roots.push(resolve(consumerRoot, `src/vite-env.d.ts`)) } - const program = ts.createProgram(roots, options, host, previous) + const program = ts.createSemanticDiagnosticsBuilderProgram( + roots, + options, + host, + previous, + ) previous = program - const diagnostics = [...parsed.errors, ...ts.getPreEmitDiagnostics(program)] - if (diagnostics.length > 0) { + const diagnostics = [ + ...parsed.errors, + ...program.getOptionsDiagnostics(), + ...program.getGlobalDiagnostics(), + ...program.getSyntacticDiagnostics(), + ...program.getSemanticDiagnostics(), + ...program.getDeclarationDiagnostics(), + ] + // Each expected error has its own unsuppressed call. Match the explicit + // sync property span, require one overload rejection, and retain every other + // diagnostic (including dependency errors). The positive neighbor above + // proves that changing only sync admits this exact candidate. + const rejected = [ + ...source.matchAll( + /persistedCollectionOptions\((\{ \.\.\.rejectedSyncOptions, sync: undefined \})\);/g, + ), + ] + const unexpected = [...diagnostics] + for (const match of rejected) { + const start = + match.index + + `persistedCollectionOptions(`.length + + match[1]!.indexOf(`sync:`) + const index = unexpected.findIndex( + (diagnostic) => + diagnostic.file?.fileName === filename && + diagnostic.start === start && + diagnostic.length === `sync`.length && + diagnostic.code === 2769, + ) + if (index < 0) + throw new Error(`Missing exact sync admission rejection at ${start}`) + unexpected.splice(index, 1) + } + if (rejected.length === 0) throw new Error(`No sync admission observations`) + if (unexpected.length > 0) { throw new Error( - ts.formatDiagnosticsWithColorAndContext(diagnostics, { + ts.formatDiagnosticsWithColorAndContext(unexpected, { getCanonicalFileName: (name) => name, getCurrentDirectory: () => repositoryRoot, getNewLine: () => `\n`, @@ -426,6 +555,12 @@ const states = [ `row-key-arguments`, `schema-contextual-key`, `query-adapter`, + `local-capabilities`, + `configured-options`, + `on-demand-options`, + `network-first-options`, + `contextual-sync`, + `phantom-schema-argument`, ] as const /** @@ -446,82 +581,131 @@ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { } const campaigns = replay.replayPath === undefined ? [`fixed`, `random`] : [`replay`] + const compile = createCompiler(receiver) for (const campaign of campaigns) { - it(`preserves generated option types through ${receiver} (${campaign})`, async () => { - const compile = createCompiler(receiver) - const reach = createOracleReach( - propertyName, - states.filter( - (state) => receiver === `browser` || state !== `query-adapter`, - ), - ) - const check = (shape: Shape) => { - const witnesses: Array<{ - source: string - states: Array<(typeof states)[number]> - }> = [] - for (const transformed of [false, true]) { - for (const synced of [false, true]) { - for (const typed of [false, true]) { - for (const arguments_ of [ - `inferred`, - `row-key`, - `full`, - ] as const) { - // Preserve annotated cells; add contextual forms only where - // an inline schema can supply the callback's output row. - for (const contextualKey of transformed && !typed - ? [false, true] - : [false]) { - witnesses.push({ - source: collectionSource(shape, { - transformed, - synced, - typed, - contextualKey, - arguments: arguments_, - }), - states: [ - transformed - ? `schema-input-output` - : synced - ? `schema-free-synced` - : `schema-free-local`, - ...(typed ? ([`typed-spread`] as const) : []), - ...(contextualKey - ? ([`schema-contextual-key`] as const) - : []), - arguments_ === `inferred` - ? `inferred-arguments` - : arguments_ === `row-key` - ? `row-key-arguments` - : `explicit-arguments`, - ], - }) + it( + `preserves generated option types through ${receiver} (${campaign})`, + async () => { + const reach = createOracleReach( + propertyName, + states.filter( + (state) => receiver === `browser` || state !== `query-adapter`, + ), + ) + const check = (shape: Shape) => { + const witnesses: Array<{ + source: string + states: Array<(typeof states)[number]> + }> = [] + for (const transformed of [false, true]) { + for (const synced of [false, true]) { + for (const typed of [false, true]) { + for (const arguments_ of [ + `inferred`, + `row-key`, + `full`, + ] as const) { + // Preserve annotated cells; add contextual forms only where + // an inline schema can supply the callback's output row. + for (const contextualKey of transformed && !typed + ? [false, true] + : [false]) { + witnesses.push({ + source: collectionSource(shape, { + transformed, + synced, + typed, + contextualKey, + arguments: arguments_, + }), + states: [ + transformed + ? `schema-input-output` + : synced + ? `schema-free-synced` + : `schema-free-local`, + ...(typed ? ([`typed-spread`] as const) : []), + ...(contextualKey + ? ([`schema-contextual-key`] as const) + : []), + arguments_ === `inferred` + ? `inferred-arguments` + : arguments_ === `row-key` + ? `row-key-arguments` + : `explicit-arguments`, + ], + }) + } } } } } - } - if (receiver === `browser`) { - witnesses.push({ - source: querySource(shape), - states: [`query-adapter`], - }) - } - const entry = - receiver === `core` - ? resolve(packageRoot, `src/index.js`) - : resolve( - repositoryRoot, - `packages/browser-db-sqlite-persistence/src/index.js`, - ) - compile(` + // Keep all original construction cells, then cross the option domain + // with both schema and persistence modes. initialRender is eager-only. + for (const transformed of [false, true]) { + for (const synced of [false, true]) { + for (const configuration of [ + `eager`, + `on-demand`, + `network-first`, + ] as const) { + witnesses.push({ + source: collectionSource(shape, { + transformed, + synced, + configuration, + typed: false, + contextualKey: transformed, + arguments: `inferred`, + }), + states: [ + `configured-options`, + ...(configuration === `on-demand` + ? [`on-demand-options` as const] + : []), + ...(configuration === `network-first` + ? [`network-first-options` as const] + : []), + ...(synced + ? [`contextual-sync` as const] + : [`local-capabilities` as const]), + ], + }) + } + } + } + for (const synced of [false, true]) { + witnesses.push({ + source: collectionSource(shape, { + transformed: false, + synced, + typed: false, + contextualKey: false, + arguments: `phantom-schema`, + }), + states: [`phantom-schema-argument`], + }) + } + if (receiver === `browser`) { + witnesses.push({ + source: querySource(shape), + states: [`query-adapter`], + }) + } + const entry = + receiver === `core` + ? resolve(packageRoot, `src/index.js`) + : resolve( + repositoryRoot, + `packages/browser-db-sqlite-persistence/src/index.js`, + ) + compile(` import { createCollection } from "@tanstack/db"; - import type { CollectionConfig } from "@tanstack/db"; + import type { CollectionConfig, PendingMutation, LoadSubsetOptions, SyncConfig } from "@tanstack/db"; import type { StandardSchemaV1 } from "@standard-schema/spec"; import { persistedCollectionOptions } from ${JSON.stringify(entry)}; import type { PersistedCollectionPersistence } from ${JSON.stringify(entry)}; + import type { PersistedSyncWrappedOptions, PersistedLocalOnlyOptions } from "@tanstack/db-sqlite-persistence-core"; ${ receiver === `browser` ? `import { QueryClient } from "@tanstack/query-core"; @@ -535,28 +719,31 @@ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { type PublicShape = { [K in keyof T]: T[K] }; ${witnesses.map(({ source }, index) => `namespace Witness${index} { ${source} }`).join(`\n`)} `) - return witnesses.flatMap((witness) => witness.states) - } - const property = fc.asyncProperty(shapeArbitrary, (shape) => { - reach.beginHistory() - for (const state of check(shape)) reach.reach(state) - return Promise.resolve() - }) - const parameters = - campaign === `fixed` - ? { numRuns: oracleRuns(12), seed: 2259 } - : oraclePropertyOptions(12, propertyName) - await fc.assert(property, parameters) - // Replays use the same check but do not inflate generated reach. An - // explicitly requested seed/path goes directly to its generated history. - if (campaign !== `replay`) { - for (const shape of replayShapes) check(shape) - } - reach.assertReached() - console.info( - `${propertyName} ${campaign} reach`, - Object.fromEntries(reach.counts()), - ) - }, 120_000) + return witnesses.flatMap((witness) => witness.states) + } + const property = fc.asyncProperty(shapeArbitrary, async (shape) => { + reach.beginHistory() + for (const state of check(shape)) reach.reach(state) + // Let Vitest service worker RPC between CPU-bound compiler histories. + await setImmediate() + }) + const parameters = + campaign === `fixed` + ? { numRuns: oracleRuns(12), seed: 2259 } + : oraclePropertyOptions(12, propertyName) + await fc.assert(property, parameters) + // Replays use the same check but do not inflate generated reach. An + // explicitly requested seed/path goes directly to its generated history. + if (campaign !== `replay`) { + for (const shape of replayShapes) check(shape) + } + reach.assertReached() + console.info( + `${propertyName} ${campaign} reach`, + Object.fromEntries(reach.counts()), + ) + }, + 120_000 * replay.multiplier, + ) } } diff --git a/scripts/calibrate-persisted-option-types.mjs b/scripts/calibrate-persisted-option-types.mjs new file mode 100644 index 0000000000..a1466c5251 --- /dev/null +++ b/scripts/calibrate-persisted-option-types.mjs @@ -0,0 +1,229 @@ +/** Run compiler-host mutants without changing production files on disk. */ +import { execFileSync, spawnSync } from 'node:child_process' +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const [mutation = `none`, ownerRef] = process.argv.slice(2) +const root = process.cwd() +const packageRoot = resolve(root, `packages/db-sqlite-persistence-core`) +const ownerPath = `packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts` +let source = ownerRef + ? execFileSync(`git`, [`show`, `${ownerRef}:${ownerPath}`], { + encoding: `utf8`, + }) + : readFileSync(ownerPath, `utf8`) +const mutations = { + none: ``, + parent: `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, ${JSON.stringify(execFileSync(`git`, [`show`, `d2935d0fcac164163adc231c6a796b8a4c234d9b:packages/db-sqlite-persistence-core/src/persisted.ts`], { encoding: `utf8` }))}, languageVersion, true); + }`, + 'local-utils': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + const start = source.text.indexOf('type PersistedLocalOnlyOptionsResult<'); + const end = source.text.indexOf('const REQUIRED_COORDINATOR_METHODS', start); + const section = source.text.slice(start, end).replaceAll('TUtils & PersistedCollectionUtils', 'TUtils'); + source = ts.createSourceFile(name, source.text.slice(0,start) + section + source.text.slice(end), languageVersion, true); + }`, + 'query-status': `if (name.endsWith('/query-db-collection/src/query.ts')) { + source = ts.createSourceFile(name, source.text.replace(' isLoading: boolean', ' isLoading: any'), languageVersion, true); + }`, + 'query-refetch': `if (name.endsWith('/query-db-collection/src/query.ts')) { + source = ts.createSourceFile(name, source.text.replace('throwOnError?: boolean', 'throwOnError?: any'), languageVersion, true); + }`, + 'query-namespace': `if (name.endsWith('/query-db-collection/src/query.ts')) { + source = ts.createSourceFile(name, source.text.replace(' refetch: RefetchFn', ' awaitTxId?: (id: number) => Promise\\n refetch: RefetchFn'), languageVersion, true); + }`, + 'schema-version': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text.replaceAll('schemaVersion?: number', 'schemaVersion?: any'), languageVersion, true); + }`, + 'initial-render': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text.replace('strategy: \`network-first\`', 'strategy: any'), languageVersion, true); + }`, + 'sync-mode': `if (name.endsWith('/db/src/types.ts')) { + source = ts.createSourceFile(name, source.text.replace('syncMode?: SyncMode', 'syncMode?: any'), languageVersion, true); + }`, + 'contextual-sync': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text.replace('sync: SyncConfig\\n persistence:', 'sync: { sync: (params: any) => void }\\n persistence:'), languageVersion, true); + }`, + 'declaration-error': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text + '\\nexport const __oracle_declaration_error = class { private secret = 1 };\\n', languageVersion, true); + }`, + 'source-error': `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text + '\\nconst __oracle_source_error: number = "wrong";\\n', languageVersion, true); + }`, +} +// Earlier calibration controls remain reproducible after follow-up commits. +const syncResult = `): PersistedSyncOptionsResult` +const localResult = `): PersistedLocalOnlyOptionsResult` +for (const [name, transform] of Object.entries({ + 'sync-regression': (text) => + text.replace(syncResult, syncResult.replace(`never`, `TSchema`)), + 'local-regression': (text) => + text.replace(localResult, localResult.replace(`never`, `TSchema`)), + 'widened-key': (text) => + [syncResult, localResult].reduce( + (result, target) => + result.replace( + target, + target.replace( + `TKey, never`, + `TKey extends string ? string : number, never`, + ), + ), + text, + ), + 'erased-utils': (text) => + [syncResult, localResult].reduce( + (result, target) => + result.replace( + target, + target.replace(`never, TUtils`, `never, UtilsRecord`), + ), + text, + ), + 'blanket-never': (text) => + text.replace( + /(\): Persisted(?:Sync|LocalOnly)OptionsResult<\n InferSchemaOutput,\n TKey,\n )TSchema,/g, + `$1never,`, + ), + 'contextual-key': (text) => + text.replaceAll( + `schema: TSchema\n },`, + `schema: TSchema\n getKey: (row: any) => TKey\n },`, + ), +})) { + const original = readFileSync( + resolve(packageRoot, `src/persisted.ts`), + `utf8`, + ) + const altered = transform(original) + if (original === altered) throw new Error(`Missing mutation target: ${name}`) + mutations[name] = + `if (name === resolve(packageRoot, 'src/persisted.ts')) { source = ts.createSourceFile(name, ${JSON.stringify(altered)}, languageVersion, true); }` +} +for (const [name, transform] of Object.entries({ + 'missing-undefined': (text) => + text + .replace( + `public get(key: TKey): WithVirtualProps | undefined {`, + `public get(key: TKey): WithVirtualProps {`, + ) + .replace( + `return this._state.getWithVirtualProps(key)`, + `return this._state.getWithVirtualProps(key)!`, + ), + 'extra-read-field': (text) => + text + .replace( + `public get(key: TKey): WithVirtualProps | undefined {`, + `public get(key: TKey): (WithVirtualProps & { leaked: boolean }) | undefined {`, + ) + .replace( + `return this._state.getWithVirtualProps(key)`, + `return this._state.getWithVirtualProps(key) as (WithVirtualProps & { leaked: boolean }) | undefined`, + ), +})) { + const original = readFileSync( + resolve(root, `packages/db/src/collection/index.ts`), + `utf8`, + ) + mutations[name] = + `if (name.endsWith('/db/src/collection/index.ts')) { source = ts.createSourceFile(name, ${JSON.stringify(transform(original))}, languageVersion, true); }` +} +const admission = `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, source.text.replace('export function persistedCollectionOptions<', ${JSON.stringify(`export function persistedCollectionOptions< + T extends object, TKey extends string | number, TUtils extends UtilsRecord = UtilsRecord, +>(options: PersistedLocalOnlyOptions & { sync: undefined }): PersistedLocalOnlyOptionsResult +export function persistedCollectionOptions<`)}), languageVersion, true); +}` +mutations['undefined-sync'] = admission +mutations['collateral-sync'] = admission +if (!(mutation in mutations)) throw new Error(`Unknown mutant: ${mutation}`) +const receiver = + process.env.ORACLE_RECEIVER ?? + (mutation.startsWith(`query-`) ? `browser` : `core`) +if (![`core`, `browser`].includes(receiver)) + throw new Error(`Unknown receiver: ${receiver}`) +source = source.replace( + "import { it } from 'vitest'", + `const callbacks: Array<{name: string; fn: () => Promise; timeout: number}> = []; +const it = Object.assign((name: string, fn: () => Promise, timeout: number) => callbacks.push({name, fn, timeout}), {skip: () => {}});`, +) +for (const name of [`oracle-config`, `oracle-reach`]) { + source = source.replace( + `'../../db/tests/${name}.js'`, + JSON.stringify(resolve(root, `packages/db/tests/${name}.ts`)), + ) +} +source = source.replace( + 'const packageRoot = fileURLToPath(new URL(`../`, import.meta.url))', + `const packageRoot = ${JSON.stringify(packageRoot)}`, +) +const oldInsertion = ` if (source) files.set(name, source)` +const newInsertion = ` if (source) {\n Object.assign(source, { version: \`0\` })` +if (source.includes(oldInsertion)) { + source = source.replace( + oldInsertion, + ` if (source) { ${mutations[mutation]} }\n${oldInsertion}`, + ) +} else if (source.includes(newInsertion)) { + source = source.replace( + newInsertion, + ` if (source) { ${mutations[mutation]}\n Object.assign(source, { version: \`0\` })`, + ) +} else throw new Error(`Compiler-host calibration seam changed`) +if (mutation === `collateral-sync`) { + source = source.replace( + ' generated = source', + ` source = source.replaceAll( + 'const rejectedSyncOptions = { ...options, persistence, sync: undefined };', + 'const rejectedSyncOptions = { ...options, persistence, sync: undefined, getKey: 42 };' + ).replaceAll( + 'persistedCollectionOptions({ ...options, persistence, sync: undefined });', + 'persistedCollectionOptions({ ...options, persistence, sync: undefined, getKey: 42 });' + );\n generated = source`, + ) +} +if (process.env.ORACLE_COMPILER_WORK === `1`) { + source = source.replace( + ` const diagnostics = [`, + ` let affectedFiles = 0; + while (program.getSemanticDiagnosticsOfNextAffectedFile()) affectedFiles++; + const diagnostics = [`, + ) + source = source.replace( + ` // Each expected error`, + ` console.info('COMPILER_WORK', revision, affectedFiles); + // Each expected error`, + ) +} +source += `\nregisterPersistedOptionsTypeOracle(${JSON.stringify(receiver)}); +const selected = callbacks.find(({name}) => name.endsWith('(replay)') || name.endsWith('(fixed)')); +if (!selected) throw new Error('Missing calibration campaign'); +if (process.env.ORACLE_REGISTRATION_ONLY === '1') { + const multiplier = readOracleRunConfig().multiplier; + console.info(JSON.stringify({multiplier, runs: oracleRuns(12), timeouts: callbacks.map(({timeout}) => timeout)})); + if (callbacks.some(({timeout}) => timeout !== 120_000 * multiplier)) throw new Error('Timeout does not scale with campaign budget'); +} else { + let beats = 0; + const heartbeat = setInterval(() => beats++, 1); + try { + for (const campaign of process.env.ORACLE_ALL_CAMPAIGNS === '1' ? callbacks : [selected]) await campaign.fn(); + } finally { clearInterval(heartbeat); } + if (process.env.ORACLE_COMPILER_WORK === '1') console.info('WORKER_HEARTBEATS', beats); +} +console.info('CALIBRATION ACCEPTED: ${mutation}');\n` +mkdirSync(`.context`, { recursive: true }) +const filename = resolve(root, `.context/calibrate-persisted-${mutation}.ts`) +writeFileSync(filename, source) +const result = spawnSync(resolve(root, `node_modules/.bin/tsx`), [filename], { + stdio: `inherit`, + env: { + ...process.env, + TANSTACK_DB_ORACLE_SEED: `2259`, + ...(process.env.TANSTACK_DB_ORACLE_PATH + ? { TANSTACK_DB_ORACLE_PROPERTY: `persisted-options.types.${receiver}` } + : {}), + }, +}) +if (result.error) throw result.error +process.exitCode = result.status ?? 1 From 39065e399894913874d5f6965f75322cbac82ad7 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 11:23:47 -0600 Subject: [PATCH 4/6] test: isolate persisted type calibration controls --- .../pr-2259-persisted-option-types.md | 28 ++++++++++++++++++- scripts/calibrate-persisted-option-types.mjs | 22 ++++++++++++--- 2 files changed, 45 insertions(+), 5 deletions(-) diff --git a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md index b05822afed..49aa7a3511 100644 --- a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md +++ b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md @@ -300,7 +300,7 @@ hashes above describe earlier experiments, rather than a mutable current file. | E4 | Whole-program work repeats; timeout ignores multiplier; check only generated file and share compiler. | Timeout confirmed. Repeated dependency checking is avoidable. Generated-only diagnostics would hide source errors. | fixed-now: semantic builder shared across campaigns, scaled timeout, retained source/declaration diagnostics. | Deterministic affected-file telemetry and budget/source controls. | | E5 | Fixed refetch/nonexistent-method examples violate law-only policy. | Confirmed standalone concrete expressions in Query driver. | fixed-now: reusable closed capability model replaces them. | Model parameterized by generated rows/keys, not fixed call examples. | | E6 | Expected Query utils derive from production; shared erasure can pass. | Independent judgment gap confirmed by isLoading:any survivor. Whole-namespace any was overstated: old negative writes already detect some erasure. | fixed-now: independent model plus supplemental transparency relation. | State, namespace and refetch corruption controls. | -| E7 | Named campaigns omit runners; tests can only get12 runs. | Registration omission confirmed. Hardcoded12 claim refuted: multiplier3 helper experiment executes36 fixed and36 random checks; normal package test globs already include runners. | fixed-now: package/root test:oracles registration. | Distinguish selection, count, reach and completion. | +| E7 | Named campaigns omit runners; tests can only get 12 runs. | Registration omission confirmed. Hardcoded 12 claim refuted: multiplier 3 helper experiment executes 36 fixed and 36 random checks; normal package test globs already include runners. | fixed-now: package/root test:oracles registration. | Distinguish selection, count, reach and completion. | | E8 | Browser uses core tsconfig, invalidating browser witness. | Config selection confirmed; current semantic compiler settings match. Both configs compile the same consumer and resolve the same browser/Query source modules. | fixed-now: select receiving package config to prevent drift. | Preserve accurate source-only browser scope. | | E9 | Any unrelated error can satisfy bare sync directive. | Checker fragility confirmed with the two-factor control above; original admission-only mutant was already detected. | fixed-now: exact unsuppressed property diagnostic and legal same-candidate neighbor. | Reusable admission observation rejects collateral corruption. | | E10 | Audit depends on inaccessible scratch, mutable hashes, personnel verdicts. | Mixed: versioned record already contains authority, commands, outcomes and limits, and historical hashes match. Exact overlay recipes and stale wording needed improvement. | fixed-now: versioned runnable calibration command, historical-hash clarification, technical-only reviewer assessment. | Others can reproduce decisive results without prior scratch files. | @@ -424,3 +424,29 @@ invalid sync with a same-candidate legal neighbor. ORC-014 retains the real browser/Query receiving source paths, with no runtime provider claim. ORC-015 measures every new declared state. ORC-016 contains no standalone fixed API examples; fixed regressions remain shared model inputs. + +### Calibration portability follow-up + +CodeRabbit review5656171283 of892bf55b8 identified four Minor items, preserved +as C2-1 through C2-4 in source order. C2-1 corrected missing spaces in E7's +numbers. C2-2 removed eager parent-history loading from unrelated calibrations. +C2-3 restricted transform/target validation to the selected mutation. C2-4 added +the missing changed-source guard for the two Collection getter controls. + +The law here is reproduction integrity: selecting one control must require +only that control's inputs, and a requested semantic edit must actually apply. +Executing the helper's actual setup prefix with filesystem/process overlays +reproduced three failures: `none` attempted unavailable parent history; `none` +failed an unrelated changed overload target; and a selected getter control +constructed an unchanged overlay after both its signature and return-helper +targets disappeared. The same probes now avoid all historical reads for `none` +and report `Missing mutation target: missing-undefined` for the no-op control. +Real commands then confirm `none` registers normally, `parent` rejects TS2769, +and `missing-undefined` rejects the public-read equality with TS2344. +No production, grammar, model, observation or compiler-gate edit was needed. + +All four items are fixed-now. The script's lazy parent getter and selected-only +transform loops preserve the intended overlays and make independent controls +usable when unrelated historical sources drift. The review accurately found +portable-calibration weaknesses; no production claim follows from those setup +failures. The previous owner hash and oracle evidence remain unchanged. diff --git a/scripts/calibrate-persisted-option-types.mjs b/scripts/calibrate-persisted-option-types.mjs index a1466c5251..00f6ff6cb3 100644 --- a/scripts/calibrate-persisted-option-types.mjs +++ b/scripts/calibrate-persisted-option-types.mjs @@ -14,9 +14,19 @@ let source = ownerRef : readFileSync(ownerPath, `utf8`) const mutations = { none: ``, - parent: `if (name === resolve(packageRoot, 'src/persisted.ts')) { - source = ts.createSourceFile(name, ${JSON.stringify(execFileSync(`git`, [`show`, `d2935d0fcac164163adc231c6a796b8a4c234d9b:packages/db-sqlite-persistence-core/src/persisted.ts`], { encoding: `utf8` }))}, languageVersion, true); - }`, + get parent() { + const parent = execFileSync( + `git`, + [ + `show`, + `d2935d0fcac164163adc231c6a796b8a4c234d9b:packages/db-sqlite-persistence-core/src/persisted.ts`, + ], + { encoding: `utf8` }, + ) + return `if (name === resolve(packageRoot, 'src/persisted.ts')) { + source = ts.createSourceFile(name, ${JSON.stringify(parent)}, languageVersion, true); + }` + }, 'local-utils': `if (name === resolve(packageRoot, 'src/persisted.ts')) { const start = source.text.indexOf('type PersistedLocalOnlyOptionsResult<'); const end = source.text.indexOf('const REQUIRED_COORDINATOR_METHODS', start); @@ -91,6 +101,7 @@ for (const [name, transform] of Object.entries({ `schema: TSchema\n getKey: (row: any) => TKey\n },`, ), })) { + if (name !== mutation) continue const original = readFileSync( resolve(packageRoot, `src/persisted.ts`), `utf8`, @@ -122,12 +133,15 @@ for (const [name, transform] of Object.entries({ `return this._state.getWithVirtualProps(key) as (WithVirtualProps & { leaked: boolean }) | undefined`, ), })) { + if (name !== mutation) continue const original = readFileSync( resolve(root, `packages/db/src/collection/index.ts`), `utf8`, ) + const altered = transform(original) + if (original === altered) throw new Error(`Missing mutation target: ${name}`) mutations[name] = - `if (name.endsWith('/db/src/collection/index.ts')) { source = ts.createSourceFile(name, ${JSON.stringify(transform(original))}, languageVersion, true); }` + `if (name.endsWith('/db/src/collection/index.ts')) { source = ts.createSourceFile(name, ${JSON.stringify(altered)}, languageVersion, true); }` } const admission = `if (name === resolve(packageRoot, 'src/persisted.ts')) { source = ts.createSourceFile(name, source.text.replace('export function persistedCollectionOptions<', ${JSON.stringify(`export function persistedCollectionOptions< From fc51cb35a01d8f491843b9761a291e8936102840 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:39:05 -0600 Subject: [PATCH 5/6] fix: reject reserved persisted utility names --- .changeset/bright-cups-infer.md | 2 + docs/contributing/oracle-coverage.md | 28 +- .../pr-2259-persisted-option-types.md | 130 +++++++ docs/guides/sqlite-persistence.md | 8 + .../tests/persisted-options.test.ts | 7 +- .../src/persisted.ts | 30 +- .../tests/persisted-options-type-oracle.ts | 344 +++++++++++++++++- .../tests/persisted-options.test.ts | 7 +- packages/db/tests/oracle-config.ts | 2 + scripts/calibrate-persisted-option-types.mjs | 117 +++++- 10 files changed, 637 insertions(+), 38 deletions(-) diff --git a/.changeset/bright-cups-infer.md b/.changeset/bright-cups-infer.md index 68cef7a59b..9e56befd92 100644 --- a/.changeset/bright-cups-infer.md +++ b/.changeset/bright-cups-infer.md @@ -3,3 +3,5 @@ --- Fix type inference for schema-free collection options wrapped with persistedCollectionOptions and passed to createCollection. + +Reject caller utilities that use the reserved local persistence names acceptMutations, getLeadershipState, or forceReloadSubset, instead of silently replacing them. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 4186ca42e3..d87d5d16e1 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -123,21 +123,29 @@ of all four public virtual fields directly after implicit, expression, and functional root publication. Its fixed controlled-Collection cases do not establish every nested projection shape or framework receiving boundary. -The generated compile-time persistence owner is +The generated persistence option type/admission owner is `packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts`. It generates row shapes (1–4 scalar, nullable, array, or nested fields), six -key domains, field optionality, utility signatures, numeric schema versions, +key domains, field optionality/readonly modifiers, utility signatures, numeric schema versions, and network deadlines. Each shape retains 30 construction forms across local/synced, schema-free/transforming schema, typed-spread/inline arguments, and inferred/partial/full type arguments, including contextual schema callbacks. Twelve configured forms cross eager/on-demand/network-first options with schema and sync modes; network-first remains eager. Two schema-free forms supply a phantom schema generic without a schema value and still preserve row input. +Eight modifier neighbors cross mutable/readonly first fields with schema and +persistence modes, bringing each shape to 52 core or 53 browser constructions. +Transforming schemas accept their output as input, as required by the schema +guide: their distinguished field narrows string | number to number, and output +optional fields remain optional on input. Exact input equality distinguishes +the two domains; inserting an output row is a legal neighboring observation. Independent type laws compare insert input, complete published row types, exact keys, user utilities, added local persistence capabilities (including optional leadership/reload signatures), option types, and contextual getKey / markReady callbacks. The browser driver uses real Query Collection options. +Declared schema-compatible-input, readonly-input, readonly-output and +mutable-fields reaches occur structurally in every generated history. Its independent closed capability model checks direct-write signatures, observer state, refetch parameters and a non-any Promise-array envelope. A supplemental before/after relation preserves the full Query utility types; @@ -160,6 +168,22 @@ internal semantics of trusted PendingMutation/LoadSubsetOptions carriers. The [type-preservation audit](oracle-reviews/pr-2259-persisted-option-types.md) and `scripts/calibrate-persisted-option-types.mjs` preserve parent RED, GREEN, legacy-suite survival, hostile-mutant evidence, and runnable overlay recipes. +The readonly-row overlay strips user-property readonly modifiers from get's +return while retaining readonly virtual fields. The prior grammar accepted it; +the modifier histories reject it at the shared exact published-row comparison. + +The maintainer chose rejection for local caller utility collisions. The same +owner's runtime admission grammar checks the three reserved names +acceptMutations, getLeadershipState and forceReloadSubset, individually and in +combinations, against both public core/browser wrappers. It crosses local/synced +options, schema presence, function/undefined values, and enumerable/non-enumerable/inherited +properties. Rejection must be an InvalidPersistedCollectionConfigError before +persistence resolution; ordinary local utilities and synced adapter utilities +remain available. Each fixed/fresh campaign measures all ten admission states +under `persisted-options.utilities.core` / `.browser`. No Collection is created, +and no sync, SQLite I/O, native-host or publication claim follows from this path. +The versioned audit records the user decision, original overwrite RED, GREEN, +and guards that check only own/truthy properties or omit a reserved name. Other existing fixed type checks cover adjacent compile-time boundaries: `packages/db/tests/mutation-handler-type-oracle.test-d.ts` checks sibling diff --git a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md index 49aa7a3511..8538a34e21 100644 --- a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md +++ b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md @@ -450,3 +450,133 @@ transform loops preserve the intended overlays and make independent controls usable when unrelated historical sources drift. The review accurately found portable-calibration weaknesses; no production claim follows from those setup failures. The previous owner hash and oracle evidence remain unchanged. + +## Adversarial round two and reserved utility decision + +Reviewed head: `39065e399894913874d5f6965f75322cbac82ad7`. The containing +follow-up commit identifies the repaired tree. Three fresh reviewers examined +enforcement, authority, and compiler infrastructure. This section supersedes +the earlier current-grammar counts and the claim that production contains only +the schema-free return correction. The original experiments remain historical. + +### Law, experiment, inference, and repair + +| Law and authority | Decisive experiment | Repair | +| --- | --- | --- | +| Schema input accepts output, so already-transformed rows remain legal input. Authority: schema guide, Core Concepts and Critical Design Principle. | A real persisted Collection with input `string \| number` and output `number` accepts an output row. Restoring the old negative directive gives TS2578. The old disjoint grammar also allowed output-optional/input-required fields. | Generate the superset relation, retain exact input equality, and observe legal output insertion. No production change for this finding. | +| Public read types retain declared readonly user properties. Authority: schema-free createCollection return type and WithVirtualProps. | A getter mutant strips user-property readonly while retaining virtual modifiers. It passes the old path-0 history. A schema-free readonly row passes production and rejects the mutant at the shared published-row equality. | Generate independent modifiers and eight structural mutable/readonly neighbors across schema and sync modes. Declare and measure their reach. | +| Local persistence owns its utility names and rejects caller collisions. Authority: the user's decision, “let's just throw an error as those names are reserved,” now documented in the SQLite guide. | Before the guard, both real receivers accept a reserved caller property and resolve persistence. TypeScript permits a caller leadership return type of `'caller'`, while runtime returns the persistence leadership object. | Reject acceptMutations, getLeadershipState and forceReloadSubset before persistence resolution. The guard uses the existing utility object's keys. Synced wrapping preserves adapter utilities because that path adds none of these local capabilities. | + +The runtime law is an explicit contract decision, rather than an inference from +the previous overwrite behavior. It applies to a present reserved property, +including inherited/non-enumerable properties and undefined values. The wrapper +throws InvalidPersistedCollectionConfigError. This is runtime admission, not a +new compile-time rejection. Successful local results cannot retain a conflicting +caller signature because the conflicting construction does not return. + +The existing option owner now includes a runtime receiving driver for this +namespace boundary. Its independent rule uses reserved names and sync presence. +The production driver records return/throw, error identity, persistence resolver +calls, caller-function calls, and preservation of admitted utilities. Each +history crosses schema presence, both sync modes, each reserved name, their +union, a generated subset, ordinary names, three descriptor forms, and +undefined/function values. A rejected configuration must do zero resolver work. +No Collection is created and no SQLite operation or sync starts. + +### Lossless round-two ledger + +| ID | Claim | Technical verdict and PR action | Durable value | +| --- | --- | --- | --- | +| R2-A1 | Caller utility preservation conflicts with local reserved-name overwrites. | Confirmed type/runtime mismatch. User chose rejection. fixed-now: runtime guard, shared generated admission judgment, real core/browser RED and GREEN, guide and changeset. | Explicit admission contract and reserved-name controls. | +| R2-A2 | Phantom schema generic acceptance lacks a guide-level meaning. | Refuted as a current correctness defect. Existing public signatures accept this source form; executable prose now labels the compatibility basis. | Distinguish source compatibility from runtime schema semantics. | +| R2-A3 | Exact Query utility equality also rejects future typed additions. | Refuted as a current defect. The current API remains the independent expected shape. Prose clarifies that an intentional documented addition updates the model. | Distinguish a current namespace check from a prohibition on API evolution. | +| R2-A4 | The transformation grammar misses the documented update-safe input domain. | Confirmed. fixed-now: compatible transform, optionality relation, and legal output admission. Exact input equality still rejects reversed schema roles. | Schema-output-rejection and blanket-never controls. | +| R2-A5 | The header implies a generated persistence utility reference page that does not exist. | Confirmed citation issue. fixed-now: cite the exported PersistedCollectionUtils interface directly. | Trace the law to the actual public API. | +| R2-E1 | Readonly user fields are absent from the grammar. | Confirmed old-owner survivor. fixed-now: generated modifiers and measured structural neighbors. | Readonly-row control, including a schema-free witness. | +| R2-S1 | Concurrent calibration processes can overwrite the same scratch runner. | Found during reconciliation: core/browser parent controls shared a filename, causing a missing-campaign setup error. fixed-now: receiver and process ID in type-runner names, process ID in runtime bundles. Repeated concurrent controls both reject TS2769. | Calibration isolation across receivers and owner versions. | + +Loss audit: six supplied items plus one reconciliation finding equal five +fixed-now and two refuted. No deferrals, unresolved policy decisions, or known +in-scope counterexamples remain in this bounded option review. Technical reviewer +assessment: the enforcement review demonstrated a concrete survivor and supplied +an independent schema-free witness. The authority review verified the contract +conflict and runtime mismatch. The infrastructure review accurately separated +executed controls from source inspection and found no additional supported bug. + +### Evidence block and reproduction + +- **Law:** Input/output and modifier preservation follow the existing public + contracts. Reserved local utility rejection follows the explicit decision + above. The runtime comparison does not import production's merge/classifier. +- **Reach:** Each type shape now compiles 52 core or 53 browser constructions. + Four default campaigns cover 48 generated histories and 2520 constructions, + plus 630 fixed replay constructions outside reach. Every type state reaches + 12 histories per campaign, including schema-compatible-input, readonly-input, + readonly-output and mutable-fields. Four runtime campaigns cover 48 histories + and 6912 wrapper constructions; all ten admission states reach 12 histories + in each campaign, including schema-collision. +- **RED:** Before production edits, both real entry points fail admission at + the intended return/throw comparison: a reserved property is admitted, no + config error appears, and the resolver runs once. Schema-free and + schema-bearing histories both demonstrate this. The type owner rejects the + original schema-free overloads with TS2769 on both receivers. +- **GREEN:** The type campaigns and nine neighboring core type tests pass. + The final expanded admission campaigns pass on both receivers with no source + errors. Three existing runtime checks also pass: local loopback utilities, + manual acceptMutations, and terminal-failure fencing of direct acceptance. + Formatting, ESLint, lint:changed and diff checks pass. +- **Mutants:** The readonly-stripping getter rejects on both receivers. The + obsolete schema-output rejection fails TS2578, and blanket schema erasure + fails the exact type laws. Runtime controls reject the old production, + removed guard, own-properties-only guard, truthiness-only guard, and a guard + missing forceReloadSubset. Failures identify admission or type comparisons, + not setup errors or timeouts. The initial concurrent-runner setup error is + R2-S1 above and is not counted as a mutant rejection. +- **Weight and limits:** The production bundle grows 64 gzip bytes and 29 + brotli bytes against the merge-base on fetched origin/main. Reusing the + existing utility object and inlining its acceptance callback avoids a second + name registry and helper. The new rejection branch accounts for the growth. + Type evidence remains source-only on TS 5.9.3. Runtime evidence stops at + wrapper admission and does not establish database I/O, schema parsing, + Collection mutation behavior, native hosts, or published package resolution. + +From each receiving package, the normal persisted-options.test.ts command runs +both type and admission campaigns. To select admission alone, add +`-t 'reserved local utility'`. Direct runtime replay uses seed 2259, path +`0:0:0`, and property `persisted-options.utilities.core` or `.browser`. + +The following repository-root commands use only versioned inputs: + +```sh +# Old grammar accepts the readonly-stripping getter. +TANSTACK_DB_ORACLE_PATH=0 node scripts/calibrate-persisted-option-types.mjs readonly-row 39065e399 + +# Current grammar rejects these controls. Use the browser receiver for readonly-row too. +TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs readonly-row +TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs schema-output-rejection +TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs blanket-never + +# Runtime controls bundle the same shared admission model and the real core entry. +node scripts/calibrate-persisted-option-types.mjs utility-parent +node scripts/calibrate-persisted-option-types.mjs utility-no-guard +node scripts/calibrate-persisted-option-types.mjs utility-own-only +node scripts/calibrate-persisted-option-types.mjs utility-truthy +node scripts/calibrate-persisted-option-types.mjs utility-missing-name +``` + +The runtime controls use esbuild and write isolated bundles under .context. +They leave tracked production files unchanged. A failure must show the intended +comparison and selected input, rather than merely exit nonzero. + +ORC-001/002 tie the revised input rule and new admission policy to their actual +authorities. ORC-003/004 retain adjacent prose and add modifier, schema and +property-descriptor neighbors. ORC-005/006 now include real wrapper admission +and the calibrated rejection boundary. ORC-007/009/010 preserve campaign parity, +replay, source/type diagnostics and isolated calibration artifacts. ORC-008 is +still inapplicable: neither model adds a state machine. ORC-011 retains the +independent Query formulation and identifies no new shared semantic-fault +hypothesis. ORC-012 is this versioned audit. ORC-013 checks ordinary local and +synced collisions beside rejected local collisions. ORC-014 uses both real +receiving exports without transferring a SQLite/provider premise. ORC-015 +measures the new type and admission states. ORC-016 keeps all observations in +shared judgments over generated inputs, without standalone regression answers. diff --git a/docs/guides/sqlite-persistence.md b/docs/guides/sqlite-persistence.md index ca0ccf2cc5..6ff697ffad 100644 --- a/docs/guides/sqlite-persistence.md +++ b/docs/guides/sqlite-persistence.md @@ -77,6 +77,14 @@ This Collection has no `sync` option. Its normal insert, update, and delete hand ### Manual transactions on a local Collection +Local persistence reserves the utility names `acceptMutations`, +`getLeadershipState`, and `forceReloadSubset`. If caller-supplied `utils` +contains one of these properties, `persistedCollectionOptions` throws +`InvalidPersistedCollectionConfigError` before it resolves persistence. +This also applies to inherited properties and properties whose value is +`undefined`. Other custom utility names remain available. Collections with a +`sync` option retain their adapter utilities without this local-only restriction. + For a Collection without a `sync` option, normal Collection mutations persist automatically. A manual transaction must call `acceptMutations` in its mutation function: ```ts diff --git a/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts b/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts index 088b2a4830..e9cb1f337b 100644 --- a/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/persisted-options.test.ts @@ -1,4 +1,9 @@ // Browser re-export and Query Collection receiving paths use the core law. -import { registerPersistedOptionsTypeOracle } from '../../db-sqlite-persistence-core/tests/persisted-options-type-oracle.js' +import { persistedCollectionOptions } from '../src/index.js' +import { + registerPersistedOptionsTypeOracle, + registerPersistedUtilityAdmissionOracle, +} from '../../db-sqlite-persistence-core/tests/persisted-options-type-oracle.js' registerPersistedOptionsTypeOracle(`browser`) +registerPersistedUtilityAdmissionOracle(`browser`, persistedCollectionOptions) diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index baec198065..1a024506b1 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -789,6 +789,7 @@ export type PersistedCollectionLeadershipState = { isLeader: boolean } +/** Local persistence owns these names; caller utility collisions throw. */ export interface PersistedCollectionUtils extends UtilsRecord { acceptMutations: (transaction: { mutations: Array>> @@ -6888,6 +6889,22 @@ export function persistedCollectionOptions< ) } + const persistedUtils: PersistedCollectionUtils = { + acceptMutations: async (transaction) => { + await runtime.acceptTransactionMutations(transaction) + }, + getLeadershipState: () => runtime.getLeadershipState(), + forceReloadSubset: (subsetOptions) => + runtime.forceReloadSubset(subsetOptions), + } + for (const name of Object.keys(persistedUtils)) { + if (options.utils && name in options.utils) { + throw new InvalidPersistedCollectionConfigError( + `utils.${name} is reserved for local-only persisted Collections`, + ) + } + } + const { schemaVersion, initialRender: _initialRender, @@ -6968,19 +6985,6 @@ export function persistedCollectionOptions< return handlerResult ?? {} } - const acceptMutations = async (transaction: { - mutations: Array>> - }) => { - await runtime.acceptTransactionMutations(transaction) - } - - const persistedUtils: PersistedCollectionUtils = { - acceptMutations, - getLeadershipState: () => runtime.getLeadershipState(), - forceReloadSubset: (subsetOptions: LoadSubsetOptions) => - runtime.forceReloadSubset(subsetOptions), - } - const mergedUtils = { ...(localOnlyOptions.utils ?? ({} as TUtils)), ...persistedUtils, diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts index 4aaceb4a44..7e2461b381 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options-type-oracle.ts @@ -1,24 +1,29 @@ /** - * # Persistence wrapping preserves the caller's type contract + * # Persistence options preserve admitted caller contracts * * Authority: docs/guides/sqlite-persistence.md, "Add persistence to a synced * Collection", composes adapter options with persistedCollectionOptions and * createCollection. The createCollection API assigns schema input to insert, * schema output to reads, and the configured key and utilities to callers. * Wrapping must preserve those distinctions, including without a schema. - * The public PersistedCollectionUtils and QueryCollectionUtils references - * specify the added local capabilities and the closed Query utility namespace. + * The exported PersistedCollectionUtils interface in src/persisted.ts and the + * QueryCollectionUtils reference specify the local and adapter capabilities. + * docs/guides/schemas.md requires schema input to accept schema output, so + * already-transformed rows remain legal mutation input. * docs/reference/type-aliases/WithVirtualProps.md and the VirtualRowProps * interface specify the readonly fields on a published row. * - * This is a source-level type oracle. A history declares a row/key/utility - * shape, constructs options, wraps them, and observes the resulting public - * types at compilation. It does not execute Collection mutations, parse a - * schema, use SQLite, or establish published declaration/package resolution. - * Compiler acceptance is the observation; the independently declared shapes - * below supply the expected result, not production's inference helpers. + * Each type history declares a row/key/utility shape, constructs options, + * wraps them, and observes public types at compilation. A utility-admission + * driver observes wrapping + * through the real core/browser entry before persistence resolution. Neither + * driver executes Collection mutations, parses a schema, uses SQLite, or + * establishes published declaration/package resolution. Compiler acceptance + * observes type preservation; return/throw observes utility admission. The + * declared shapes and reserved-name rule supply independent expected results. */ import { resolve } from 'node:path' +import assert from 'node:assert/strict' import { fileURLToPath } from 'node:url' import { setImmediate } from 'node:timers/promises' import fc from 'fast-check' @@ -30,6 +35,12 @@ import { readOracleRunConfig, } from '../../db/tests/oracle-config.js' import { createOracleReach } from '../../db/tests/oracle-reach.js' +import type { + PersistedCollectionPersistence, + PersistenceAdapter, + persistedCollectionOptions, +} from '../src/index.js' +import type { StandardSchemaV1 } from '@standard-schema/spec' type Receiver = `core` | `browser` type Shape = { @@ -40,6 +51,8 @@ type Shape = { type: string optional: boolean inputOptional: boolean + readonly?: boolean + inputReadonly?: boolean }> utilityName: string utilityArgument: string @@ -58,7 +71,7 @@ const fieldTypes = [ /** * Grammar: rows have one string/number-compatible key and 1–4 other fields; - * fields may be optional, nullable, arrays, or a nested record. Each history + * fields may be optional, readonly, nullable, arrays, or a nested record. Each history * varies property names, key domains, and a utility's argument/return types. * Its construction paths cross schema-free/transforming schema, local/synced, * inline/typed-spread config, and inferred/explicit type arguments. Those @@ -71,6 +84,10 @@ const fieldTypes = [ * schema-free here; it supplies its real adapter utility namespace. * Inline schema forms also leave getKey's row unannotated. The schema must * contextually supply its output type without help from a caller annotation. + * Every history includes mutable and readonly first-field neighbors in both + * persistence modes, with and without a schema. Other modifiers vary freely. + * A phantom schema generic without a schema value preserves an accepted source + * form; it does not establish a runtime schema promise. */ const shapeArbitrary: fc.Arbitrary = fc.record({ key: fc.constantFrom( @@ -88,6 +105,8 @@ const shapeArbitrary: fc.Arbitrary = fc.record({ type: fc.constantFrom(...fieldTypes), optional: fc.boolean(), inputOptional: fc.boolean(), + readonly: fc.boolean(), + inputReadonly: fc.boolean(), }), { selector: (field) => field.name, minLength: 1, maxLength: 4 }, ), @@ -153,10 +172,13 @@ const repositoryRoot = resolve(packageRoot, `../..`) * Independent model: insert accepts the declared input; reads retain every * declared output field; get takes exactly the declared key; user utilities * keep their declared signatures. A transforming schema changes one required - * field from string to number, making input/output reversal distinguishable. + * field from string | number to number, distinguishing input/output reversal + * while accepting output as input, as required by the schema guide. * A public get result includes undefined for a missing row, and excludes null. * Other schema fields may become required, retaining the original oracle's * optional-input/required-output histories as instances of this grammar. + * An optional output field stays optional on input. Property readonly modifiers + * vary independently; publication and insert input retain the declared types. * Public PendingMutation and LoadSubsetOptions are trusted parameter-domain * carriers, never used to compute capability names, modifiers, or signatures. * Local additions are observed separately because UtilsRecord has an index @@ -166,16 +188,27 @@ const repositoryRoot = resolve(packageRoot, `../..`) * The model does not reproduce persistence or Collection's overload logic. * An unannotated schema callback receives exactly the declared output row. */ -function rowType(shape: Shape, transformed: boolean, output: boolean): string { +function rowType( + shape: Shape, + transformed: boolean, + output: boolean, + modifiers?: `mutable` | `readonly`, +): string { const fields = shape.fields.map((field, index) => { const changed = transformed && index === 0 const optional = changed ? false : transformed && !output - ? field.inputOptional + ? field.optional || field.inputOptional : field.optional - const type = changed ? (output ? `number` : `string`) : field.type - return `${field.name}${optional ? `?` : ``}: ${type}` + const type = changed ? (output ? `number` : `string | number`) : field.type + const readonly = + index === 0 && modifiers !== undefined + ? modifiers === `readonly` + : transformed && !output + ? field.inputReadonly + : field.readonly + return `${readonly ? `readonly ` : ``}${field.name}${optional ? `?` : ``}: ${type}` }) return `{ ${shape.keyName}: Key; ${fields.join(`; `)} }` } @@ -243,7 +276,7 @@ function comparison( ${ transformed ? `declare const output: Output; - // @ts-expect-error schema output cannot stand in for schema input + type SchemaInputLaw = Assert>; collection.insert(output);` : `` } @@ -262,13 +295,18 @@ type Cell = { contextualKey: boolean arguments: `inferred` | `row-key` | `full` | `phantom-schema` configuration?: `eager` | `on-demand` | `network-first` + modifiers?: `mutable` | `readonly` } -function declarations(shape: Shape, transformed: boolean): string { +function declarations( + shape: Shape, + transformed: boolean, + modifiers?: Cell[`modifiers`], +): string { return ` type Key = ${shape.key}; - type Input = ${rowType(shape, transformed, false)}; - type Output = ${rowType(shape, transformed, true)}; + type Input = ${rowType(shape, transformed, false, modifiers)}; + type Output = ${rowType(shape, transformed, true, modifiers)}; type Utils = { ${shape.utilityName}: (value: ${shape.utilityArgument}) => ${shape.utilityResult} }; declare const utils: Utils; declare const schema: StandardSchemaV1; @@ -325,7 +363,7 @@ function collectionSource(shape: Shape, cell: Cell): string { ? `options` : `{ ...config, persistence, ${syncProperty} }` return ` - ${declarations(shape, cell.transformed)} + ${declarations(shape, cell.transformed, cell.modifiers)} ${ cell.configuration ? ` @@ -355,6 +393,8 @@ function collectionSource(shape: Shape, cell: Cell): string { * A supplemental transparency relation preserves the full payload across the * wrapper; QueryObserverResult internals have no independent model here. * This single model replaces standalone fixed refetch/nonexistent-method calls. + * Exact namespace equality pins the current API. An intentional documented + * utility addition must update this model; closure does not prohibit additions. */ function querySource(shape: Shape): string { return ` @@ -561,6 +601,10 @@ const states = [ `network-first-options`, `contextual-sync`, `phantom-schema-argument`, + `schema-compatible-input`, + `readonly-input`, + `readonly-output`, + `mutable-fields`, ] as const /** @@ -686,6 +730,33 @@ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { states: [`phantom-schema-argument`], }) } + // Generated modifier neighbors guarantee reach without requiring a + // fortunate random boolean. Their row domains and other fields still + // come from the same grammar, model, driver, and refinement check. + for (const transformed of [false, true]) { + for (const synced of [false, true]) { + for (const modifiers of [`mutable`, `readonly`] as const) { + witnesses.push({ + source: collectionSource(shape, { + transformed, + synced, + typed: false, + contextualKey: transformed, + arguments: `inferred`, + modifiers, + }), + states: [ + ...(transformed + ? ([`schema-compatible-input`] as const) + : []), + ...(modifiers === `readonly` + ? ([`readonly-input`, `readonly-output`] as const) + : ([`mutable-fields`] as const)), + ], + }) + } + } + } if (receiver === `browser`) { witnesses.push({ source: querySource(shape), @@ -747,3 +818,236 @@ export function registerPersistedOptionsTypeOracle(receiver: Receiver): void { ) } } + +/** + * Local persistence owns three utility names. The maintainer chose rejection + * rather than silent replacement; see the SQLite guide's manual-transaction + * section and the round-two decision in the versioned type-preservation audit. + * Synced wrapping installs none of these local utilities, so it preserves the + * adapter namespace. This judgment depends on names and sync presence, not on + * production's merge code, the value's truthiness, or its property descriptor. + */ +const reservedUtilityNames = [ + `acceptMutations`, + `getLeadershipState`, + `forceReloadSubset`, +] as const + +const utilityAdmissionArbitrary = fc.record({ + token: fc.string(), + extraNames: fc.subarray([ + `measure`, + `convert`, + `AcceptMutations`, + `acceptMutations2`, + `getLeadershipStates`, + `forceReloadSubsetLater`, + ]), + names: fc.subarray([...reservedUtilityNames]), +}) + +/** + * The runtime production driver calls only the supplied public wrapper. Its + * resolver records whether admission reached persistence setup. Histories vary + * ordinary names and caller identity; structural neighbors include each reserved + * name, their union, both sync modes, schema presence, undefined/function values, + * and own enumerable, + * non-enumerable or inherited properties. Those forms all reserve the same name. + * At return/throw, the shared comparison checks admission, config-error identity, + * zero resolver work on rejection, and caller utility preservation on success. + * No Collection is constructed, so no sync, database I/O or cleanup starts. + */ +export function registerPersistedUtilityAdmissionOracle( + receiver: Receiver, + construct: typeof persistedCollectionOptions, +): void { + const propertyName = `persisted-options.utilities.${receiver}` + const replay = readOracleRunConfig() + if (replay.replayPath !== undefined && replay.replayProperty !== propertyName) + return + for (const campaign of replay.replayPath === undefined + ? [`fixed`, `random`] + : [`replay`]) { + it(`rejects reserved local utility names through ${receiver} (${campaign})`, () => { + const reach = createOracleReach(propertyName, [ + ...reservedUtilityNames, + `local-ordinary`, + `synced-collision`, + `multiple-names`, + `undefined-value`, + `non-enumerable`, + `inherited`, + `schema-collision`, + ]) + fc.assert( + fc.property( + utilityAdmissionArbitrary, + ({ token, extraNames, names }) => { + reach.beginHistory() + const schemas: Array | undefined> = + [ + { + '~standard': { + version: 1, + vendor: `oracle`, + validate: (value) => ({ value: value as { id: string } }), + }, + }, + undefined, + ] + for (const schema of schemas) { + for (const selected of [ + [], + ...reservedUtilityNames.map((name) => [name]), + reservedUtilityNames, + names, + ]) { + for (const synced of [false, true]) { + for (const descriptor of [ + `enumerable`, + `non-enumerable`, + `inherited`, + ] as const) { + for (const defined of [false, true]) { + let resolverCalls = 0 + let callerCalls = 0 + const caller = () => { + callerCalls++ + return token + } + const utils: Record = Object.fromEntries( + extraNames.map((name) => [name, caller]), + ) + const properties = Object.fromEntries( + selected.map((name) => [ + name, + { + value: defined ? caller : undefined, + enumerable: descriptor === `enumerable`, + }, + ]), + ) + if (descriptor === `inherited`) + Object.setPrototypeOf( + utils, + Object.defineProperties({}, properties), + ) + else Object.defineProperties(utils, properties) + const adapter: PersistenceAdapter = { + loadSubset: () => Promise.resolve([]), + loadResumeSnapshot: () => + Promise.resolve({ + rows: [], + keySet: { status: `consistent` }, + collectionMetadata: [], + latestTerm: 0, + latestSeq: 0, + latestRowVersion: 0, + resetEpoch: 0, + }), + applyCommittedTx: async () => {}, + ensureIndex: async () => {}, + } + const persistence: PersistedCollectionPersistence = { + adapter, + resolvePersistenceForCollection: () => { + resolverCalls++ + return { adapter } + }, + } + const options = { + id: `utility-admission`, + getKey: (row: { id: string }) => row.id, + utils, + persistence, + } + const rejected = + !synced && + selected.some((name) => + reservedUtilityNames.includes(name), + ) + let accepted = false + let errorName: string | undefined + let preserved = false + try { + const result = schema + ? synced + ? construct({ + ...options, + schema, + sync: { sync: () => {} }, + }) + : construct({ ...options, schema }) + : synced + ? construct({ + ...options, + sync: { sync: () => {} }, + }) + : construct(options) + accepted = true + preserved = [...extraNames, ...selected].every( + (name) => + result.utils !== undefined && + name in result.utils && + result.utils[name] === utils[name], + ) + } catch (error) { + errorName = + error instanceof Error ? error.name : `non-error` + } + assert.deepEqual( + { + accepted, + errorName, + resolverCalls, + callerCalls, + preserved, + }, + { + accepted: !rejected, + errorName: rejected + ? `InvalidPersistedCollectionConfigError` + : undefined, + resolverCalls: rejected ? 0 : 1, + callerCalls: 0, + preserved: !rejected, + }, + JSON.stringify({ + names: selected, + synced, + descriptor, + defined, + schema: schema !== undefined, + }), + ) + if (!synced && selected.length === 0) + reach.reach(`local-ordinary`) + if (synced && selected.length > 0) + reach.reach(`synced-collision`) + if (selected.length > 1) reach.reach(`multiple-names`) + if (schema && !synced && selected.length > 0) + reach.reach(`schema-collision`) + if (selected.length > 0) { + for (const name of selected) reach.reach(name) + if (!defined) reach.reach(`undefined-value`) + if (descriptor !== `enumerable`) reach.reach(descriptor) + } + } + } + } + } + } + }, + ), + campaign === `fixed` + ? { numRuns: oracleRuns(12), seed: 2259 } + : oraclePropertyOptions(12, propertyName), + ) + reach.assertReached() + console.info( + `${propertyName} ${campaign} reach`, + Object.fromEntries(reach.counts()), + ) + }) + } +} diff --git a/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts b/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts index 318cfd688a..4c308d8c33 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted-options.test.ts @@ -1,5 +1,10 @@ // @vitest-environment node // Core entry; the shared oracle owns the grammar, model, and comparison. -import { registerPersistedOptionsTypeOracle } from './persisted-options-type-oracle.js' +import { persistedCollectionOptions } from '../src/index.js' +import { + registerPersistedOptionsTypeOracle, + registerPersistedUtilityAdmissionOracle, +} from './persisted-options-type-oracle.js' registerPersistedOptionsTypeOracle(`core`) +registerPersistedUtilityAdmissionOracle(`core`, persistedCollectionOptions) diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index de351740f0..8126598276 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -5,6 +5,8 @@ type OracleEnvironment = Record const staticOracleProperties = [ `persisted-options.types.core`, `persisted-options.types.browser`, + `persisted-options.utilities.core`, + `persisted-options.utilities.browser`, `cursor-pagination.no-peek`, `cursor-pagination.history`, `cursor-pagination.cache`, diff --git a/scripts/calibrate-persisted-option-types.mjs b/scripts/calibrate-persisted-option-types.mjs index 00f6ff6cb3..34cc8d1813 100644 --- a/scripts/calibrate-persisted-option-types.mjs +++ b/scripts/calibrate-persisted-option-types.mjs @@ -12,8 +12,106 @@ let source = ownerRef encoding: `utf8`, }) : readFileSync(ownerPath, `utf8`) +// Runtime controls use the same generated admission model and real core entry. +// Bundle only this selected experiment; type-only controls need no bundler. +if (mutation.startsWith(`utility-`)) { + const { build } = await import(`esbuild`) + const original = readFileSync( + resolve(packageRoot, `src/persisted.ts`), + `utf8`, + ) + const guard = `options.utils && name in options.utils` + const controls = { + 'utility-parent': () => + execFileSync( + `git`, + [ + `show`, + `39065e399894913874d5f6965f75322cbac82ad7:packages/db-sqlite-persistence-core/src/persisted.ts`, + ], + { encoding: `utf8` }, + ), + 'utility-no-guard': () => original.replace(guard, `false`), + 'utility-own-only': () => + original.replace( + guard, + `options.utils && Object.prototype.hasOwnProperty.call(options.utils, name)`, + ), + 'utility-truthy': () => + original.replace(guard, `${guard} && options.utils[name]`), + 'utility-missing-name': () => + original.replace(guard, `${guard} && name !== "forceReloadSubset"`), + } + if (!(mutation in controls)) + throw new Error(`Unknown runtime control: ${mutation}`) + const altered = controls[mutation]() + if (altered === original) + throw new Error(`Missing mutation target: ${mutation}`) + mkdirSync(`.context`, { recursive: true }) + const filename = resolve( + root, + `.context/calibrate-persisted-${mutation}-${process.pid}.mjs`, + ) + await build({ + stdin: { + contents: `import { registerPersistedUtilityAdmissionOracle } from ${JSON.stringify(resolve(root, ownerPath))}; + import { persistedCollectionOptions } from ${JSON.stringify(resolve(packageRoot, `src/index.ts`))}; + import { callbacks } from 'vitest'; + registerPersistedUtilityAdmissionOracle('core', persistedCollectionOptions); + for (const { fn } of callbacks) await fn();`, + resolveDir: root, + loader: `ts`, + }, + outfile: filename, + bundle: true, + platform: `node`, + format: `esm`, + packages: `external`, + alias: Object.fromEntries( + [`db`, `db-ivm`, `store`].map((name) => [ + `@tanstack/${name}`, + resolve(root, `packages/${name}/src/index.ts`), + ]), + ), + plugins: [ + { + name: `utility-admission-calibration`, + setup(builder) { + builder.onResolve({ filter: /^vitest$/ }, () => ({ + path: `vitest`, + namespace: `calibration`, + })) + builder.onLoad({ filter: /.*/, namespace: `calibration` }, () => ({ + contents: `export const callbacks = []; export const it = Object.assign((name, fn) => callbacks.push({ name, fn }), { skip() {} });`, + })) + builder.onLoad({ filter: /\/persisted\.ts$/ }, ({ path }) => + path === resolve(packageRoot, `src/persisted.ts`) + ? { contents: altered, loader: `ts` } + : undefined, + ) + builder.onLoad( + { filter: /\/persisted-options-type-oracle\.ts$/ }, + () => ({ contents: source, loader: `ts` }), + ) + }, + }, + ], + }) + const result = spawnSync(process.execPath, [filename], { + stdio: `inherit`, + env: { + ...process.env, + TANSTACK_DB_ORACLE_SEED: `2259`, + TANSTACK_DB_ORACLE_PATH: process.env.TANSTACK_DB_ORACLE_PATH ?? `0:0:0`, + TANSTACK_DB_ORACLE_PROPERTY: `persisted-options.utilities.core`, + }, + }) + if (result.error) throw result.error + process.exit(result.status ?? 1) +} const mutations = { none: ``, + 'schema-output-rejection': ``, get parent() { const parent = execFileSync( `git`, @@ -112,6 +210,11 @@ for (const [name, transform] of Object.entries({ `if (name === resolve(packageRoot, 'src/persisted.ts')) { source = ts.createSourceFile(name, ${JSON.stringify(altered)}, languageVersion, true); }` } for (const [name, transform] of Object.entries({ + 'readonly-row': (text) => + text.replace( + `public get(key: TKey): WithVirtualProps | undefined {`, + `public get(key: TKey): WithVirtualProps<{-readonly [P in keyof TOutput]: TOutput[P]}, TKey> | undefined {`, + ), 'missing-undefined': (text) => text .replace( @@ -152,6 +255,15 @@ export function persistedCollectionOptions<`)}), languageVersion, true); mutations['undefined-sync'] = admission mutations['collateral-sync'] = admission if (!(mutation in mutations)) throw new Error(`Unknown mutant: ${mutation}`) +if (mutation === `schema-output-rejection`) { + const target = ` collection.insert(output);` + if (!source.includes(target)) + throw new Error(`Missing mutation target: ${mutation}`) + source = source.replace( + target, + ` // @ts-expect-error schema output cannot stand in for schema input\n${target}`, + ) +} const receiver = process.env.ORACLE_RECEIVER ?? (mutation.startsWith(`query-`) ? `browser` : `core`) @@ -227,7 +339,10 @@ if (process.env.ORACLE_REGISTRATION_ONLY === '1') { } console.info('CALIBRATION ACCEPTED: ${mutation}');\n` mkdirSync(`.context`, { recursive: true }) -const filename = resolve(root, `.context/calibrate-persisted-${mutation}.ts`) +const filename = resolve( + root, + `.context/calibrate-persisted-${mutation}-${receiver}-${process.pid}.ts`, +) writeFileSync(filename, source) const result = spawnSync(resolve(root, `node_modules/.bin/tsx`), [filename], { stdio: `inherit`, From 6826665af042e1562111a15c414118cde6d423e9 Mon Sep 17 00:00:00 2001 From: Test Date: Mon, 26 Oct 2026 13:49:26 -0600 Subject: [PATCH 6/6] docs: select browser for readonly calibration --- .../oracle-reviews/pr-2259-persisted-option-types.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md index 8538a34e21..0f0107877b 100644 --- a/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md +++ b/docs/contributing/oracle-reviews/pr-2259-persisted-option-types.md @@ -553,6 +553,7 @@ TANSTACK_DB_ORACLE_PATH=0 node scripts/calibrate-persisted-option-types.mjs read # Current grammar rejects these controls. Use the browser receiver for readonly-row too. TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs readonly-row +ORACLE_RECEIVER=browser TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs readonly-row TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs schema-output-rejection TANSTACK_DB_ORACLE_PATH=0:0:0:0:0:0:0:0:0:0:0:0:0 node scripts/calibrate-persisted-option-types.mjs blanket-never