From 3e41ffc4feaa2ccd116e711d1470126678ee4640 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 1 Nov 2026 19:49:20 -0300 Subject: [PATCH 1/6] refactor(angular-db): own observer teardown in effect cleanup --- packages/angular-db/src/index.ts | 24 ++++-------------------- 1 file changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 84e2848209..59cd2baeff 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -1,9 +1,7 @@ import { - DestroyRef, assertInInjectionContext, computed, effect, - inject, signal, } from '@angular/core' import { @@ -150,7 +148,6 @@ export function injectLiveQuery< ): InjectLiveQueryResultWithSingleResultCollection export function injectLiveQuery(opts: any) { assertInInjectionContext(injectLiveQuery) - const destroyRef = inject(DestroyRef) const collection = computed(() => { // Check if it's an existing collection @@ -240,12 +237,6 @@ export function injectLiveQuery(opts: any) { health.set(snapshot.health) } - let unsub: (() => void) | null = null - const cleanup = () => { - unsub?.() - unsub = null - } - effect((onCleanup) => { const currentCollection = collection() @@ -255,12 +246,9 @@ export function injectLiveQuery(opts: any) { health.set(undefined) state.set(new Map()) internalData.set([]) - cleanup() return } - cleanup() - // The shared observer owns sync start, subscription, the ready-race, and // status transitions; Angular re-reads the whole collection on each notify // (wholesale) into its signals. @@ -273,19 +261,15 @@ export function injectLiveQuery(opts: any) { const unsubscribe = observer.subscribe(() => { syncDataFromCollection(currentCollection, observer) }) + onCleanup(() => { + unsubscribe() + observer.dispose() + }) // Wholesale attach suppresses listener calls raised by synchronous sync // startup. Read once after subscribe returns to capture that final state. syncDataFromCollection(currentCollection, observer) - unsub = () => { - unsubscribe() - observer.dispose() - } - - onCleanup(cleanup) }) - destroyRef.onDestroy(cleanup) - return { state, data, From 95dbf663877cc0040ae8a502d34ee2448391379c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 1 Nov 2026 19:49:33 -0300 Subject: [PATCH 2/6] fix(angular-db): require Angular 20 or later --- docs/framework/angular/overview.md | 4 ++-- docs/installation.md | 2 +- packages/angular-db/README.md | 2 +- packages/angular-db/package.json | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index 9162623132..21e711e6e5 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -52,7 +52,7 @@ export class TodoListComponent { **Note:** All return values (`data`, `isLoading`, `status`, etc.) are Angular signals, so call them with `()` in your template: `query.data()`, `query.isLoading()`. -> **Template Syntax:** Examples use Angular 17+ control flow (`@if`, `@for`). For Angular 16, use `*ngIf` and `*ngFor` instead. +> **Template Syntax:** Examples use Angular's built-in control flow (`@if`, `@for`). ### Reactive Parameters @@ -115,7 +115,7 @@ import { eq, and } from '@tanstack/db' template: `
{{ query.data().length }} todos
` }) export class TodoListComponent { - // Angular 16+ compatible input + // Required component input @Input({ required: true }) userId!: number status = signal('active') diff --git a/docs/installation.md b/docs/installation.md index 070a005f8e..c44990b0fc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -47,7 +47,7 @@ TanStack DB is compatible with Vue v3.3.0+ npm install @tanstack/angular-db ``` -TanStack DB is compatible with Angular v16.0.0+ +TanStack DB is compatible with Angular v20.0.0+ ## Vanilla JS diff --git a/packages/angular-db/README.md b/packages/angular-db/README.md index 11b8b19ea3..0c41cba86c 100644 --- a/packages/angular-db/README.md +++ b/packages/angular-db/README.md @@ -299,6 +299,6 @@ Parameters Requirements -- Angular 16+ (requires signals support) +- Angular 20+ - Must be called within an Angular injection context - Automatically handles cleanup when the injector is destroyed diff --git a/packages/angular-db/package.json b/packages/angular-db/package.json index 63cc9bf028..6694ca795f 100644 --- a/packages/angular-db/package.json +++ b/packages/angular-db/package.json @@ -49,7 +49,7 @@ "@tanstack/db": "workspace:*" }, "peerDependencies": { - "@angular/core": ">=16.0.0", + "@angular/core": ">=20.0.0", "rxjs": ">=6.0.0" }, "devDependencies": { From 1344ee84901bb797f98bfb486aaf3480822b0f44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Sun, 1 Nov 2026 20:43:33 -0300 Subject: [PATCH 3/6] fix(angular-db): keep live query signals coherent Expose current-query snapshots before subscription effects run, keep connection ownership in the effect, and project row state before making its defensive Map copy. Document signal tracking and add an independent generated receiving oracle. Law: Angular signals describe the current query at the synchronous pre-effect cut (shared conformance contract, maintainer decision 2026-11-01). Reach: 36 fixed and 36 random histories cover callback, params, config, precreated, findOne, pooling, enable/disable, and source writes around effect attachment. RED: the oracle on 95dbf6638 exposes empty data beside a populated Collection; seed 1101, path 0:0:0:0. GREEN: 97 Angular tests pass (one existing TODO), with typecheck, ESLint, oracle lint, production build, and release-script tests on Node 24.8.0. Mutant: omitting post-subscribe invalidation fails the source-write/setup-gap cut. Limits: finite synchronous equality fixtures in Angular JIT; no new SSR, asynchronous-provider, or cross-framework scheduling guarantee. BREAKING CHANGE: the Angular package requires Angular 20 or later, recorded by the companion major Changeset and the earlier minimum-version commit. --- .changeset/angular-query-snapshots.md | 5 + docs/contributing/oracle-coverage.md | 22 ++ .../2026-11-01-angular-snapshot.md | 100 ++++++ docs/framework/angular/overview.md | 10 +- packages/angular-db/README.md | 17 +- packages/angular-db/src/index.ts | 119 ++----- packages/angular-db/tests/conformance.test.ts | 2 +- .../tests/snapshot-conformance-oracle.test.ts | 311 ++++++++++++++++++ .../tests/snapshot-performance.test.ts | 300 +++++++++++++++++ packages/db/tests/conformance/contract.ts | 9 + packages/db/tests/oracle-config.ts | 1 + 11 files changed, 800 insertions(+), 96 deletions(-) create mode 100644 .changeset/angular-query-snapshots.md create mode 100644 docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md create mode 100644 packages/angular-db/tests/snapshot-conformance-oracle.test.ts create mode 100644 packages/angular-db/tests/snapshot-performance.test.ts diff --git a/.changeset/angular-query-snapshots.md b/.changeset/angular-query-snapshots.md new file mode 100644 index 0000000000..f997e0cb9b --- /dev/null +++ b/.changeset/angular-query-snapshots.md @@ -0,0 +1,5 @@ +--- +'@tanstack/angular-db': major +--- + +Require Angular 20 or later. Keep live-query result signals coherent with the current query before effects run, and release observer subscriptions through effect cleanup. Track the observer's row-state identity separately so unchanged row state does not rebuild the adapter's defensive Map copy. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 6e5ba7182e..064bdd83d3 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2637,6 +2637,28 @@ Angular's JIT test environment. It does not exercise Angular's AOT compiler, older Angular runtimes, or every scheduler cut. The same driver checks that an explicitly disabled query reports `disabled` and ready before effects run. +The [Angular snapshot conformance oracle](https://github.com/TanStack/db/blob/main/packages/angular-db/tests/snapshot-conformance-oracle.test.ts) +extends the framework owner to synchronous reads before effects run (maintainer +decision 2026-11-01, stated in the shared conformance contract). Generated +histories cross callback, params, config, and precreated Collection inputs; +list and `findOne` results; opt-in pooling through params; populated, empty, +and disabled selections; and source writes before attachment and while mounted. +An independent fixture-row filter judges selected rows. A refinement relation +also checks keyed state, data shape, Collection rows, status, readiness, and +health meaning at each read. Initial reads, input replacements before effects, +and settled source updates use the same checker. The original copied-signal +adapter fails at the first read; omitting post-subscribe invalidation fails +after a source write between an early read and attachment. Declared reach +measures those checkpoints, including direct Collection, single-result, and +pooled receiving paths. The oracle does not compare pooled view health-node +identities or Collection IDs with their lazily materialized public Collection: +`getPublicCollection` represents these as different objects; it compares health +meaning instead. This does not retire the shared suite's within-query health +identity checks. Limits: synchronous eager sources in Angular's JIT TestBed; +no browser rendering, SSR, asynchronous provider schedules, or new synchronous +read promise for another framework. Existing shared conformance retains +ownership of their settled behavior and teardown laws. + The query identity owner generates both equal and unequal primitive string pairs. Its fixed pairs also check public `groupBy` rows after initial publication. End-to-end subset-demand reuse is still unproven: the demand-key diff --git a/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md new file mode 100644 index 0000000000..6bd2c46497 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md @@ -0,0 +1,100 @@ +# Angular synchronous snapshot evidence + +This self-review covers the Angular changes on `angular-fixes`, based on +`95dbf663877cc0040ae8a502d34ee2448391379c`. The branch also includes the +effect-cleanup refactor and breaking Angular 20 minimum. Tested source SHA-256: +`94a4dddccbb3048fee3c0660319951159a530cdb899188b3519b3a467f1e0f70`. +Test SHA-256: +`730c8aa77c20435c7cf7fadff011e027c942bbbf1431e24538e63c02ee93266d`. + +- **Law:** synchronous Angular reads describe the current query, including + before subscription effects run. Rows, keyed state, Collection, status, and + health must agree. The maintainer authorized this change on 2026-11-01; + `packages/db/tests/conformance/contract.ts` records its Angular-only cut. +- **Reach:** fixed and random campaigns each ran 36 histories: 16 shared + regressions plus 20 generated histories. In fixed/random order, initial reads + and pre-attachment source writes reached 36/36 histories each; direct + Collections 9/7; findOne 18/17; pooled reads 2/2; enabled replacements 22/26; + disabled reads 15/16; re-enabling 12/13; settled updates 36/36. +- **RED:** from `packages/angular-db`, run + `../../node_modules/.bin/vitest --run tests/snapshot-conformance-oracle.test.ts` + with the adapter from the base commit. Both campaigns fail by row assertion + before attachment: `data()` is empty while `collection()` contains rows. + Fixed seed `1101`, shrink path `0:0:0:0` reduces to callback/list/unpooled, + initial value 0, selections `[0, 0]`. + Direct replay with `TANSTACK_DB_ORACLE_PROPERTY=angular.current-snapshot`, + `TANSTACK_DB_ORACLE_SEED=1101`, and `TANSTACK_DB_ORACLE_PATH=0:0:0:0` + executes only that history and reproduces the row mismatch on the base adapter. +- **GREEN:** the same command passes with the snapshot adapter. The whole + package passes 97 tests with one existing TODO. `tsc --noEmit -p + packages/angular-db/tsconfig.json`, changed-file lint, ESLint for changed + TypeScript, and formatting checks pass. +- **Mutant:** deleting the post-subscribe `invalidate()` preserves the early + cached value after a source write before attachment. Both campaigns fail the + row comparison after effect setup; this is an assertion failure after the + driver's bounded wait, not the test runner's timeout. Seed `1101`, path + `0:0:2:1:0` reduces to the same two-selection history. +- **Limits:** three synchronous eager fixture rows, finite equality groups, + one ordered query, no joins/includes, and Angular JIT TestBed. No SSR, + browser-rendering, asynchronous-provider, or cross-framework synchronous + claim. Existing shared conformance remains the owner for settled behavior, + errors, required inputs, and teardown. Pooled view and materialized public + Collection IDs/health-node identities differ by design; the new oracle + compares health meaning across them, preserving existing identity checks. + +The size comparison against the base commit reports 1023 to 881 gzip bytes +(-142 bytes), and -134 brotli bytes for the Angular package. This is the +reactivity patch's production bundle, excluding dependency bundles and tests. + +## Oracle rubric assessment + +| Requirement | Evidence or limit | +| --- | --- | +| ORC-001 | Shared contract, maintainer authority, and limits above and beside the oracle. | +| ORC-002 | Fixture-row filtering supplies membership; public Collection observations supply the snapshot-coherence relation. No adapter state or query evaluator computes expected membership. | +| ORC-003 | Opening contract, pure row model, legal histories, real Angular driver, and shared comparison remain distinguishable in the test. | +| ORC-004 | Regressions reconstruct populated swap, disable, empty re-enable, and return. Removing forms, shape, pooling, or the pre-attachment write loses a named path. Groups are 0/1/empty-2/null; initial values are -2 through 2; histories have 2 through 6 selections. Updates after destruction are outside the legal grammar. | +| ORC-005 | Public injectLiveQuery and real environment-injector effects; reads before effects, after attachment, and after mounted writes. Source rows already exist at the synchronous cut. | +| ORC-006 | Original copied-signal adapter and missing-invalidation mutant fail row assertions. | +| ORC-007 | Fixed seed 1101 and unseeded campaigns share options, regressions, grammar, checker, and budget. Shared oracle replay variables select the property and shrink path. | +| ORC-008 | No adapter state machine is mirrored: membership is stateless filtering of fixture rows. | +| ORC-009 | Uses glossary terms. Health meaning explicitly omits identity across pooled view/materialization, not within one query. | +| ORC-010 | ScenarioLifetime preserves primary and cleanup failures and ends the injector before source cleanup. | +| ORC-011 | No named shared semantic fault requiring another formulation; numeric equality on three fixture rows is independent of adapter lifecycle code. | +| ORC-012 | This record describes the tested patch and bounded evidence, not universal correctness. | +| ORC-013 | Populated versus empty/disabled selections and a write between read/attach distinguish stale-data and missed-invalidation designs. | +| ORC-014 | The receiving boundary is Angular's actual JIT scheduler. External-provider and browser claims remain outside scope. | +| ORC-015 | createOracleReach checks all nine declared receiving states; counts above. | +| ORC-016 | Fixed regressions and both campaigns share the same model, driver, and comparison; no separate expected answers. | + +## Stability, work, and the separate observer PR + +The diagnostic `snapshot-performance.test.ts` instruments real Angular computed +executions and core snapshot reads without replacing snapshot behavior. It +tracks container identities across warmup and measurement phases, so reading an +existing container through another snapshot is not counted as a new allocation. +For 1000 synchronous rows, unchanged reads reuse the same core snapshot, data, +and Map and execute no additional Angular computations. Ten row writes before +reading build one full snapshot; reading after every write builds ten. + +This branch includes an intermediate `rowState` computed. Angular's defensive +Map copy follows the observer's row-state reference rather than every full +snapshot. Against the current main-branch observer, metadata snapshots still +supply new row containers, so health reports still rerun row consumers. Keeping +those containers stable belongs to the independent `observer-snapshot-caching` +branch. This PR does not require that change or promise metadata-only identity +that the current core cannot supply. Both branches together were exercised by +the generated Angular metadata receiving checks before the split; those +integration results do not stand in for either branch's standalone evidence. + +The defensive copy remains because the public Angular state type is mutable; +this PR does not expose the observer's cached Map as writable state. Scalar +field computations may reevaluate after a notification while unchanged +readiness/Collection consumers and the readiness effect skip execution. These +are finite work counts, not heap bytes, elapsed runtime, or browser rendering. + +Validation uses Node 24.8.0 from `.nvmrc` and pnpm 11.1.0 from `package.json`. +The changeset declares a major Angular package change because raising the +minimum supported framework version is breaking. No observer production code, +observer cache changeset, or core metadata-identity requirement belongs to this +branch. diff --git a/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index 21e711e6e5..a32de52c33 100644 --- a/docs/framework/angular/overview.md +++ b/docs/framework/angular/overview.md @@ -92,13 +92,15 @@ Use the reactive `params` option when your query depends on: When any reactive value accessed in the `params` function changes, the query is recreated and re-executed. +Plain query callbacks and configuration query callbacks also track signals they read. The `params` form is useful for keeping the reactive inputs explicit. + #### What Happens When Parameters Change When a parameter value changes: -1. The previous live-query collection is disposed -2. A new query is created with the updated parameter values -3. `status()`/`isLoading()` reflect the new query's lifecycle -4. `data()` updates automatically when the new results arrive +1. The next read resolves the query with the updated parameter values. +2. All result signals describe that query's snapshot, even before Angular runs effects. While it loads, they never expose the previous query's rows. +3. The effect disconnects the previous observer and subscribes to the new one. +4. Notifications invalidate the snapshot, so subsequent reads see new results. #### Best Practices diff --git a/packages/angular-db/README.md b/packages/angular-db/README.md index 0c41cba86c..14f0ec9287 100644 --- a/packages/angular-db/README.md +++ b/packages/angular-db/README.md @@ -82,9 +82,9 @@ The simplest way to use injectLiveQuery is to pass a collection directly: allTodos = injectLiveQuery(todosCollection) } -Static Query Functions +Query Functions -You can create filtered queries using a query function. Note: The query function is evaluated once and is not reactive to signal changes: +You can create filtered queries using a query function. Signals read inside the function are tracked; changing one rebuilds the query: import { Component } from "@angular/core" import { injectLiveQuery } from "@tanstack/angular-db" @@ -109,8 +109,7 @@ You can create filtered queries using a query function. Note: The query function `, }) export class TodosComponent { - // Static query - filters for incomplete todos - // This will not react to signal changes within the function + // With no signal reads, this query keeps the same definition. todoQuery = injectLiveQuery((q) => q .from({ todo: todosCollection }) @@ -211,9 +210,11 @@ Important Notes Reactivity Behavior - Direct collection: Automatically reactive to collection changes -- Static query function: Query is built once and is not reactive to signals read within the function +- Query function: Query rebuilds when signals read within the function change - Reactive parameters: Query rebuilds when any signal read in params() changes -- Collection configuration: Static, not reactive to external signals +- Collection configuration: Signals read while building its query trigger a rebuild + +All result signals read the current query's snapshot, including immediately after a parameter changes and before Angular runs effects. A replacement query may be loading, but its results never contain the previous query's rows. The effect owns subscriptions and disconnects the previous observer when it attaches the replacement. Lifecycle Management @@ -253,7 +254,7 @@ Overloads collection: Collection ): LiveQueryResult - // Static query function - NOT reactive to signals within function + // Query function - tracks signals read within the function function injectLiveQuery( queryFn: (q: InitialQueryBuilder) => QueryBuilder ): LiveQueryResult @@ -292,7 +293,7 @@ An object with Angular signals: Parameters - collection - Existing collection to observe directly -- queryFn - Function that builds a static query using the query builder +- queryFn - Function that builds a query and tracks any signals it reads - options.params - Reactive function that returns parameters; triggers query rebuild when accessed signals change - options.query - Function that builds a query using parameters and query builder - config - Configuration object for creating a live query collection diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 59cd2baeff..aa1a2080ac 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -3,15 +3,13 @@ import { computed, effect, signal, + untracked, } from '@angular/core' import { BaseQueryBuilder, configPool, createLiveQueryObserver, - getLiveQueryStatusFlags, - getPublicCollection, isCollection, - isSingleResultCollection, resolveLiveQueryValue, } from '@tanstack/db' import type { @@ -19,11 +17,9 @@ import type { CollectionStatus, Context, GetResult, - HealthNode, InferResultType, InitialQueryBuilder, LiveQueryHookConfig, - LiveQueryObserver, LiveQueryStatusFlags, NonSingleResult, PooledLiveQueries, @@ -199,93 +195,50 @@ export function injectLiveQuery(opts: any) { throw new Error(`Invalid options provided to injectLiveQuery`) }) - const state = signal(new Map()) - const internalData = signal>([]) - const statusValue = signal(`idle`) - const status = computed(() => { - const value = statusValue() - return value === `idle` && !collection() ? `disabled` : value - }) - const health = signal(undefined) - // Every status flag comes from the shared rule; a disabled query is ready. - const flags = computed(() => - getLiveQueryStatusFlags(status() as CollectionStatus, health()), + // Observers are inert until subscribed. A read can therefore follow the + // current query synchronously without acquiring an Angular-owned connection. + const observer = computed(() => + createLiveQueryObserver(collection(), { mode: `wholesale` }), ) - - // Returns single item for singleResult collections, array otherwise - const data = computed(() => { - const currentCollection = collection() - if (!currentCollection) { - return internalData() - } - return isSingleResultCollection(currentCollection) - ? internalData()[0] - : internalData() + const revision = signal(0) + const invalidate = () => revision.update((value) => value + 1) + const snapshot = computed(() => { + revision() + return observer().getSnapshot() }) - - const syncDataFromCollection = ( - currentCollection: Collection, - observer: LiveQueryObserver, - ) => { - const newState = new Map(currentCollection.entries()) - const newData = Array.from(newState.values()) - - state.set(newState) - internalData.set(newData) - const snapshot = observer.getSnapshot() - statusValue.set(snapshot.status) - health.set(snapshot.health) - } + const rowState = computed(() => snapshot().state) effect((onCleanup) => { - const currentCollection = collection() - - // Handle null collection (disabled query) - if (!currentCollection) { - statusValue.set(`disabled` as const) - health.set(undefined) - state.set(new Map()) - internalData.set([]) - return - } - - // The shared observer owns sync start, subscription, the ready-race, and - // status transitions; Angular re-reads the whole collection on each notify - // (wholesale) into its signals. - // Angular re-reads the collection on notify; wholesale mode preserves its - // pre-observer loading policy (no initial-state snapshot request). - const observer = createLiveQueryObserver(currentCollection, { - mode: `wholesale`, - }) - - const unsubscribe = observer.subscribe(() => { - syncDataFromCollection(currentCollection, observer) - }) - onCleanup(() => { - unsubscribe() - observer.dispose() + const current = observer() + untracked(() => { + const unsubscribe = current.subscribe(invalidate) + onCleanup(() => { + unsubscribe() + current.dispose() + }) + // Close the gap after an early snapshot read. Wholesale attach also + // suppresses notifications raised by synchronous sync startup. + invalidate() }) - // Wholesale attach suppresses listener calls raised by synchronous sync - // startup. Read once after subscribe returns to capture that final state. - syncDataFromCollection(currentCollection, observer) }) return { - state, - data, + state: computed(() => new Map(rowState())), + // Angular keeps empty containers for disabled queries, including findOne. + data: computed(() => + snapshot().status === `disabled` ? [] : snapshot().data, + ), // Loosely typed so the impl return stays compatible with every overload // (the shared `isCollection` guard narrows the computed to `Collection | null`). - collection: computed(() => - getPublicCollection(collection()), - ) as Signal, - status, - isLoading: computed(() => flags().isLoading), - isReady: computed(() => flags().isReady || status() === `disabled`), - isIdle: computed(() => flags().isIdle), - isLoadingError: computed(() => flags().isLoadingError), - isCrashed: computed(() => flags().isCrashed), - isCleanedUp: computed(() => flags().isCleanedUp), - isSyncError: computed(() => flags().isSyncError), - health, + collection: computed(() => snapshot().collection ?? null) as Signal, + status: computed(() => snapshot().status), + isLoading: computed(() => snapshot().isLoading), + isReady: computed(() => snapshot().isReady), + isIdle: computed(() => snapshot().isIdle), + isLoadingError: computed(() => snapshot().isLoadingError), + isCrashed: computed(() => snapshot().isCrashed), + isCleanedUp: computed(() => snapshot().isCleanedUp), + isSyncError: computed(() => snapshot().isSyncError), + health: computed(() => snapshot().health), } } diff --git a/packages/angular-db/tests/conformance.test.ts b/packages/angular-db/tests/conformance.test.ts index 170ebe1f0b..78bb3bada4 100644 --- a/packages/angular-db/tests/conformance.test.ts +++ b/packages/angular-db/tests/conformance.test.ts @@ -3,7 +3,7 @@ * * `injectLiveQuery` needs an injection context, so each mount runs inside a * child `EnvironmentInjector` created off TestBed's; `unmount` calls - * `injector.destroy()`, firing the `DestroyRef` cleanup. Result signals are read + * `injector.destroy()`, running the effect cleanup. Result signals are read * after settling. Controllable inputs use Angular's reactive `{ params, query }` * form driven by a signal. * diff --git a/packages/angular-db/tests/snapshot-conformance-oracle.test.ts b/packages/angular-db/tests/snapshot-conformance-oracle.test.ts new file mode 100644 index 0000000000..9bbd08686e --- /dev/null +++ b/packages/angular-db/tests/snapshot-conformance-oracle.test.ts @@ -0,0 +1,311 @@ +/** + * Angular reads describe the current query before its subscription effect runs. + * + * Authority: the current-input refinement law in db/tests/conformance/contract.ts, + * extended to this synchronous checkpoint by the maintainer on 2026-11-01. + * Rows, keyed state, Collection, status, and health must describe one query. + * Changing input may expose an empty loading result, but never the old input's + * rows. A disabled query exposes empty containers, no Collection, and readiness. + * + * This receiving oracle extends the framework conformance owner at Angular's + * previously unobserved pre-effect cut. Its model filters fixture rows without + * query-engine helpers. The real Angular injector/effect scheduler supplies the + * host boundary. It does not establish browser rendering, SSR, asynchronous + * providers, or another framework's synchronous-read policy. + */ +import { fc } from '@fast-check/vitest' +import { describe, expect, it, vi } from 'vitest' +import { + EnvironmentInjector, + createEnvironmentInjector, + runInInjectionContext, + signal, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { + BasicIndex, + createCollection, + createLiveQueryCollection, + eq, + pooledLiveQueries, +} from '@tanstack/db' +import { injectLiveQuery } from '../src/index' +import { mockSyncCollectionOptions } from '../../db/tests/utils' +import { declareOracle } from '../../db/tests/oracle-declaration' +import { + oraclePropertyOptions, + oracleReplayTest, +} from '../../db/tests/oracle-config' +import { createOracleReach } from '../../db/tests/oracle-reach' +import { expectOrderedRows } from '../../db/tests/conformance/result-laws' +import { ScenarioLifetime } from '../../db/tests/conformance/scenario-lifetime' +import type { HealthNode, InitialQueryBuilder } from '@tanstack/db' + +// Three groups include a legal empty result. Removing disabled, shape, pooling, +// or input-form variation loses a distinct representation or connection path. +// Only signal writes and source updates before owner destruction are legal. +const selection = fc.constantFrom(0, 1, 2, null) +const histories = fc.record({ + form: fc.constantFrom(`callback`, `params`, `config`, `collection`), + single: fc.boolean(), + pool: fc.boolean(), + value: fc.integer({ min: -2, max: 2 }), + selections: fc.array(selection, { minLength: 2, maxLength: 6 }), +}) +type History = typeof histories extends fc.Arbitrary ? T : never +type Row = { id: string; group: number; value: number } + +// The source model is fixture data. Selection is ordinary equality; findOne +// takes the first ID. Neither rule reads the adapter or query compiler. +function modelRows(rows: ReadonlyArray, group: number | null): Array { + return rows.filter((row) => row.group === group) +} + +type Observation = { + rows: Array + arrayData: boolean + keyedRows: Array + keys: Array + collectionRows: Array | null + status: string + collectionStatus: string | null + isReady: boolean + health: HealthNode | undefined + collectionHealth: HealthNode | undefined +} + +// A pooled view and its lazily built public Collection have different IDs and +// health-node identities. The promise here is their health meaning, not those +// identities (getPublicCollection's documented view/materialization boundary). +function healthMeaning(node: HealthNode | undefined): unknown { + if (!node) return undefined + return { + alias: node.alias, + kind: node.kind, + status: node.status, + hasErrors: node.hasErrors, + failureReason: node.failureReason, + error: node.error, + reloading: node.reloading, + sources: node.sources.map(healthMeaning), + } +} + +function expectSnapshot( + actual: Observation, + rows: ReadonlyArray, + group: number | null, + single: boolean, + settled: boolean, +): void { + const selected = modelRows(rows, group) + const expected = single ? selected.slice(0, 1) : selected + expect(actual.arrayData).toBe(group === null || !single) + expect(actual.keys).toEqual(actual.rows.map((row) => row.id)) + expectOrderedRows(actual.rows, actual.keyedRows) + expectOrderedRows(actual.rows, actual.collectionRows ?? []) + expect(actual.status).toBe(actual.collectionStatus ?? `disabled`) + expect(healthMeaning(actual.health)).toEqual( + healthMeaning(actual.collectionHealth), + ) + expect(actual.isReady).toBe( + actual.status === `ready` || actual.status === `disabled`, + ) + if (group === null) { + expect(actual.collectionRows).toBeNull() + expectOrderedRows(actual.rows, []) + } else { + expect(actual.collectionRows).not.toBeNull() + if (settled || actual.isReady) expectOrderedRows(actual.rows, expected) + else { + // A new query may still load. Every exposed row must nevertheless belong + // to this input; accepting arbitrary rows here would conceal the defect. + for (const row of actual.rows) expect(expected).toContainEqual(row) + } + } +} + +// Each regression replays the same model and driver. The prefix reconstructs +// a nonempty swap, disable, re-enable to empty, and return to a prior input. +const examples: Array<[History]> = [ + `callback`, + `params`, + `config`, + `collection`, +].flatMap((form) => + [false, true].flatMap((single) => + [false, true].map( + (pool) => + [{ form, single, pool, value: 1, selections: [0, 1, null, 2, 0] }] as [ + History, + ], + ), + ), +) + +declareOracle({ + check: [expectSnapshot], + model: [modelRows], + grammar: [histories], +}) + +describe(`Angular current-query snapshot conformance`, () => { + for (const campaign of [`fixed`, `random`] as const) { + oracleReplayTest( + it, + ...(campaign === `fixed` ? [] : [`angular.current-snapshot`]), + )( + `keeps synchronous reads coherent (${campaign})`, + async () => { + const reach = createOracleReach(`angular.current-snapshot`, [ + `read before attach`, + `direct Collection read before attach`, + `findOne read before attach`, + `pooled read before attach`, + `source write before attach`, + `enabled input replaced before effect`, + `disabled before effect`, + `enabled after disabled`, + `settled source update`, + ]) + await fc.assert( + fc.asyncProperty(histories, async (history) => { + reach.beginHistory() + const lifetime = new ScenarioLifetime() + await lifetime.run(async () => { + const rows: Array = [ + { id: `a`, group: 0, value: history.value }, + { id: `b`, group: 0, value: history.value }, + { id: `c`, group: 1, value: history.value }, + ] + const source = createCollection( + mockSyncCollectionOptions({ + id: `angular-snapshot-source`, + getKey: (row) => row.id, + initialData: rows, + gcTime: 0, + }), + ) + source.createIndex((row) => row.id, { indexType: BasicIndex }) + const owner = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector), + ) + lifetime.defer(() => owner.destroy()) + lifetime.defer(() => source.cleanup({ dependents: true })) + const current = signal(history.selections[0]!) + const build = (q: InitialQueryBuilder, group: number | null) => { + if (group === null) return undefined + const query = q + .from({ row: source }) + .where(({ row }) => eq(row.group, group)) + .orderBy(({ row }) => row.id) + return history.single ? query.findOne() : query + } + const result = runInInjectionContext(owner, () => { + if (history.form === `collection`) { + const precreated = createLiveQueryCollection({ + query: (q) => build(q, 0)!, + gcTime: 0, + }) + lifetime.defer(() => precreated.cleanup()) + return injectLiveQuery(precreated) + } + if (history.form === `callback`) + return injectLiveQuery((q) => build(q, current())) + if (history.form === `params`) + return injectLiveQuery({ + params: current, + query: ({ params, q }) => build(q, params), + pool: history.pool ? pooledLiveQueries : undefined, + }) + // Config queries stay enabled; null selects a legal empty group. + return injectLiveQuery({ + query: (q) => build(q, current() ?? 2)!, + }) + }) + const selectedGroup = () => + history.form === `collection` + ? 0 + : history.form === `config` + ? (current() ?? 2) + : current() + const observe = (): Observation => { + const collection = result.collection() + const data: unknown = result.data() + return { + rows: (Array.isArray(data) + ? data + : data + ? [data] + : []) as Array, + arrayData: Array.isArray(data), + keyedRows: [...result.state().values()], + keys: [...result.state().keys()], + collectionRows: collection ? [...collection.values()] : null, + status: result.status(), + collectionStatus: collection?.status ?? null, + isReady: result.isReady(), + health: result.health(), + collectionHealth: collection?.health, + } + } + const check = (settled: boolean) => + expectSnapshot( + observe(), + rows, + selectedGroup(), + history.single, + settled, + ) + const update = () => { + rows[0] = { ...rows[0]!, value: rows[0]!.value + 1 } + source.utils.begin() + source.utils.write({ type: `update`, value: rows[0] }) + source.utils.commit() + } + check(false) + reach.reach(`read before attach`) + if (history.form === `collection`) + reach.reach(`direct Collection read before attach`) + if (history.single) reach.reach(`findOne read before attach`) + if (history.form === `params` && history.pool && !history.single) + reach.reach(`pooled read before attach`) + update() + reach.reach(`source write before attach`) + TestBed.tick() + await vi.waitFor(() => check(true)) + for (const next of history.selections.slice(1)) { + const previous = selectedGroup() + current.set(next) + check(false) + if (selectedGroup() === null) + reach.reach(`disabled before effect`) + else if (previous === null) + reach.reach(`enabled after disabled`) + else if (previous !== selectedGroup()) + reach.reach(`enabled input replaced before effect`) + TestBed.tick() + await vi.waitFor(() => check(true)) + update() + check(true) + reach.reach(`settled source update`) + } + }) + }), + { + ...oraclePropertyOptions(20, `angular.current-snapshot`, { + examples, + }), + ...(campaign === `fixed` ? { seed: 1101 } : {}), + }, + ) + reach.assertReached() + process.stdout.write( + `angular.current-snapshot ${campaign}: ${JSON.stringify(Object.fromEntries(reach.counts()))}\n`, + ) + }, + 60_000, + ) + } +}) diff --git a/packages/angular-db/tests/snapshot-performance.test.ts b/packages/angular-db/tests/snapshot-performance.test.ts new file mode 100644 index 0000000000..c21590b2b0 --- /dev/null +++ b/packages/angular-db/tests/snapshot-performance.test.ts @@ -0,0 +1,300 @@ +/** + * Diagnostic measurements, not a runtime speed benchmark or an oracle. + * + * Count real Angular computed executions and observer snapshot/container + * materializations. Adapter Map copies follow directly from the implementation: + * one per state computation in the snapshot bridge; one per snapshot read in + * the earlier eager-copy bridge. These are allocation counts, not heap bytes. + * Each measurement starts after initial attachment, with 1000 installed rows. + */ +import { describe, expect, it, vi } from 'vitest' +import { + EnvironmentInjector, + computed, + createEnvironmentInjector, + effect, + runInInjectionContext, +} from '@angular/core' +import { TestBed } from '@angular/core/testing' +import { createCollection, createLiveQueryObserver } from '@tanstack/db' +import { injectLiveQuery } from '../src/index' +import type { CreateComputedOptions, Signal } from '@angular/core' +import type * as AngularCore from '@angular/core' +import type * as DbCore from '@tanstack/db' + +const metrics = vi.hoisted(() => ({ + nodes: new WeakMap(), + snapshotReads: 0, + snapshots: new Set(), + arrays: new Set(), + maps: new Set(), + knownSnapshots: new WeakSet(), + knownArrays: new WeakSet(), + knownMaps: new WeakSet(), +})) + +vi.mock(`@angular/core`, async (importOriginal) => { + const angular = await importOriginal() + return { + ...angular, + computed: ( + read: () => T, + options?: CreateComputedOptions, + ): Signal => { + const node = { executions: 0 } + const result = angular.computed(() => { + node.executions++ + return read() + }, options) + metrics.nodes.set(result, node) + return result + }, + } +}) + +vi.mock(`@tanstack/db`, async (importOriginal) => { + const db = await importOriginal() + return { + ...db, + createLiveQueryObserver: ( + ...args: Parameters + ) => { + const observer = db.createLiveQueryObserver(...args) + const getSnapshot = observer.getSnapshot.bind(observer) + const instrumented = new WeakSet() + observer.getSnapshot = () => { + metrics.snapshotReads++ + const snapshot = getSnapshot() + if (!metrics.knownSnapshots.has(snapshot)) { + metrics.knownSnapshots.add(snapshot) + metrics.snapshots.add(snapshot) + } + if ( + Array.isArray(snapshot.data) && + !metrics.knownArrays.has(snapshot.data) + ) { + metrics.knownArrays.add(snapshot.data) + metrics.arrays.add(snapshot.data) + } + // Observe the lazy state getter without forcing it to materialize. + const descriptor = Object.getOwnPropertyDescriptor(snapshot, `state`) + const getState = descriptor?.get + if (getState && !instrumented.has(snapshot)) { + instrumented.add(snapshot) + Object.defineProperty(snapshot, `state`, { + ...descriptor, + get() { + const map: unknown = getState.call(snapshot) + if ( + map && + typeof map === `object` && + !metrics.knownMaps.has(map) + ) { + metrics.knownMaps.add(map) + metrics.maps.add(map) + } + return map + }, + }) + } + return snapshot + } + return observer + }, + } +}) + +type Row = { id: number; value: number } + +async function profile( + kind: + | `unchanged reads` + | `row burst` + | `row updates with reads` + | `health change` + | `unread rows`, +) { + let update: ((value: number) => void) | undefined + let reportError: ((error: Error) => void) | undefined + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + startSync: true, + sync: { + sync: ({ begin, write, commit, markReady, reportError: report }) => { + begin() + for (let id = 0; id < 1000; id++) + write({ type: `insert`, value: { id, value: 0 } }) + commit() + markReady() + update = (value) => { + begin() + write({ type: `update`, value: { id: 0, value } }) + commit() + } + reportError = report + }, + }, + }) + const owner = createEnvironmentInjector( + [], + TestBed.inject(EnvironmentInjector), + ) + const result = runInInjectionContext(owner, () => injectLiveQuery(source)) + const runs = { data: 0, state: 0, ready: 0, collection: 0 } + const consumers = { + data: computed(() => { + runs.data++ + return result.data() + }), + state: computed(() => { + runs.state++ + return result.state() + }), + ready: computed(() => { + runs.ready++ + return result.isReady() + }), + collection: computed(() => { + runs.collection++ + return result.collection() + }), + } + const read = () => { + consumers.ready() + consumers.collection() + if (kind !== `unread rows`) { + consumers.data() + consumers.state() + } + } + const effectRuns = { count: 0 } + runInInjectionContext(owner, () => + effect(() => { + effectRuns.count++ + consumers.ready() + }), + ) + try { + TestBed.tick() + read() + const initialData = kind !== `unread rows` ? result.data() : undefined + const initialState = kind !== `unread rows` ? result.state() : undefined + const initialCollection = result.collection() + const beforeRuns = { ...runs } + const beforeEffect = effectRuns.count + const fields = { + data: result.data, + state: result.state, + ready: result.isReady, + collection: result.collection, + } + const beforeFields = Object.fromEntries( + Object.entries(fields).map(([name, field]) => [ + name, + metrics.nodes.get(field)?.executions ?? 0, + ]), + ) + metrics.snapshotReads = 0 + metrics.snapshots.clear() + metrics.arrays.clear() + metrics.maps.clear() + + if ( + kind === `row burst` || + kind === `unread rows` || + kind === `row updates with reads` + ) { + for (let value = 1; value <= 10; value++) { + update!(value) + if (kind === `row updates with reads`) read() + } + } else if (kind === `health change`) reportError!(new Error(`offline`)) + for (let count = 0; count < 100; count++) read() + TestBed.tick() + + // A core metadata snapshot may currently replace its row containers. + // The separate observer-cache PR owns cross-snapshot container sharing. + if (kind === `unchanged reads`) { + expect(result.data()).toBe(initialData) + expect(result.state()).toBe(initialState) + } + expect(result.collection()).toBe(initialCollection) + expect(consumers.ready()).toBe(true) + const fieldComputations = Object.fromEntries( + Object.entries(fields).map(([name, field]) => [ + name, + (metrics.nodes.get(field)?.executions ?? 0) - beforeFields[name]!, + ]), + ) + const report = { + kind, + observerSnapshotReads: metrics.snapshotReads, + newSnapshots: metrics.snapshots.size, + observedDataArrays: metrics.arrays.size, + observerMapMaterializations: metrics.maps.size, + adapterMapMaterializations: metrics.nodes.has(result.state) + ? fieldComputations.state + : metrics.snapshotReads, + adapterDataArrayMaterializations: metrics.nodes.has(result.state) + ? 0 + : metrics.snapshotReads, + fieldComputations, + consumerComputations: Object.fromEntries( + Object.entries(runs).map(([name, count]) => [ + name, + count - beforeRuns[name as keyof typeof beforeRuns], + ]), + ), + readyEffectExecutions: effectRuns.count - beforeEffect, + dataReferenceChanged: + kind === `unread rows` ? undefined : result.data() !== initialData, + stateReferenceChanged: + kind === `unread rows` ? undefined : result.state() !== initialState, + } + process.stdout.write(`angular-snapshot-profile ${JSON.stringify(report)}\n`) + } finally { + owner.destroy() + await source.cleanup() + } +} + +describe(`Angular snapshot stability and work`, () => { + it(`reuses the core snapshot and its row containers on unchanged reads`, async () => { + const source = createCollection({ + getKey: (row) => row.id, + gcTime: 0, + sync: { + sync: ({ begin, write, commit, markReady }) => { + begin() + write({ type: `insert`, value: { id: 0, value: 0 } }) + commit() + markReady() + }, + }, + }) + const observer = createLiveQueryObserver(source, { mode: `wholesale` }) + observer.subscribe(() => {}) + try { + const snapshot = observer.getSnapshot() + const data = snapshot.data + const state = snapshot.state + for (let count = 0; count < 100; count++) { + const next = observer.getSnapshot() + expect(next).toBe(snapshot) + expect(next.data).toBe(data) + expect(next.state).toBe(state) + } + } finally { + observer.dispose() + await source.cleanup() + } + }) + it.each([ + `unchanged reads`, + `row burst`, + `row updates with reads`, + `health change`, + `unread rows`, + ] as const)(`%s`, profile) +}) diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 6469f4f085..f05a6e7244 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -47,6 +47,15 @@ * A `DbClient` matches queries by identity hash, so a binding backed by one * follows the comparing law; that boundary has its own receiving witnesses. * + * Angular synchronous reads (maintainer decision 2026-11-01). After a reactive + * input changes, a read before the subscription effect runs already describes + * the current query. Data, keyed state, Collection, status, and health come + * from its public snapshot. An empty loading result is legal; the previous + * input's rows are not. Subscription ownership still changes in the effect. + * Disabled queries expose Angular's empty-reactive representation immediately. + * The Angular snapshot conformance oracle owns this additional observation cut; + * other frameworks retain their drivers' documented scheduling cuts. + * * Rejection is observed at the adapter's native compile point. The driver * surfaces it as a throw from `mount*`, a rejected `setParam`, or a throw from * the next `current()` read. After a rejection, no later read exposes rows: diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index f851ae34e8..ce4e5fb7e6 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -5,6 +5,7 @@ export { recordOracleReplay } from './oracle-replay-witness.js' type OracleEnvironment = Record const staticOracleProperties = [ + `angular.current-snapshot`, `lint.part-reach`, `oracle.declared-reach`, `scripts.lint-scope`, From 239ef3ed3dc668413312abc173a2914a1e5a0c77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Wed, 4 Nov 2026 09:46:46 -0300 Subject: [PATCH 4/6] test(angular-db): register snapshot oracle replay owner Map angular.current-snapshot to its Angular test owner. Validate the full replay suite and a guarded Angular seed/path replay. See the appended oracle evidence for the original CI failure and receiving witness. --- .../2026-11-01-angular-snapshot.md | 20 +++++++++++++++++++ packages/db/tests/oracle-replay-manifest.ts | 5 +++++ 2 files changed, 25 insertions(+) diff --git a/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md index 6bd2c46497..7c218db089 100644 --- a/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md +++ b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md @@ -98,3 +98,23 @@ The changeset declares a major Angular package change because raising the minimum supported framework version is breaking. No observer production code, observer cache changeset, or core metadata-identity requirement belongs to this branch. + +## CI replay registration follow-up, 2026-11-04 + +CI exposed a missing entry in `oracle-replay-manifest.ts` for the registered +`angular.current-snapshot` property. The oracle itself passed in the framework +job, but the central replay-owner check correctly rejected an unowned name. +The fix maps it to `angular-db/tests/snapshot-conformance-oracle.test.ts`. + +Before the fix, `vitest run tests/oracle-replay.test.ts -t 'names an owner for +every registered property' --coverage.enabled=false` failed with +`['angular.current-snapshot']` instead of an empty unowned-property list. After +the fix, the complete replay suite passed all 41 tests. The guarded replay from +`packages/angular-db` also passed with property `angular.current-snapshot`, seed +1101 and path `0`: one completed matching witness and one execution, with the +fixed campaign skipped. The replay guard observed the actual Angular owner, +rather than merely accepting a manifest entry. The existing oracle model, +grammar, assertions and run budgets did not change. + +Node 24.8.0 was used. This follow-up changes replay discovery only and adds no +new Angular behavior or observation checkpoint. diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 498cfb0ad8..a60f1de0d3 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -13,6 +13,11 @@ import { registeredOracleProperties } from './oracle-config.js' const ownerGroups: ReadonlyArray< readonly [string, string, string] | readonly [string, string, string, string] > = [ + [ + `angular-db/tests/snapshot-conformance-oracle.test.ts`, + `angular`, + `current-snapshot`, + ], [ `../scripts/eslint-plugin-oracle/part-reach-oracle.property.test.js`, `lint`, From c098d577d241042bc247ac41a770b85e50c0b48f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Wed, 4 Nov 2026 09:47:08 -0300 Subject: [PATCH 5/6] test(db): guarantee direct-start GC coverage Generate ten direct-start histories before each normal GC boundary campaign while preserving the original 250 mixed histories and path-replay coordinates. Seed 706 fails without the prefix and passes with it. See the GC reach evidence for law, reach, RED, GREEN, mutant, and limits. --- docs/contributing/oracle-coverage.md | 13 + .../2026-11-04-gc-startup-reach.md | 82 ++++ ...ction-gc-retention-oracle.property.test.ts | 364 ++++++++++-------- 3 files changed, 294 insertions(+), 165 deletions(-) create mode 100644 docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 064bdd83d3..17964012df 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -6326,3 +6326,16 @@ useful extension: the hash identity oracle owns those semantic distinctions, but its helper-level evidence does not by itself exercise distinct storage. The separate Index structural-collision obligation above remains open. See the appended [review evidence](oracle-reviews/2026-10-30-distinct-collisions.md). + +## GC direct-start reach, 2026-11-04 + +The existing `collection-lifecycle.gc-retention-boundaries` owner now begins +each normal campaign with ten generated direct-start histories, then runs its +unchanged 250 mixed histories. This removes the known sampling gap where no +history starts sync through direct insertion. The model, comparisons, all +declared reach states, and both mixed run budgets remain intact. Explicit path +replays retain the original mixed generator and skip the prefix. Seed 706 +reproduces the old gap and rejects omission of the prefix; the repaired fixed +and random campaigns reached the state 15 and 17 times. Existing controlled +idle-host and native-browser limits remain in force. See the +[CI repair evidence](oracle-reviews/2026-11-04-gc-startup-reach.md). diff --git a/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md new file mode 100644 index 0000000000..448ec96be7 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md @@ -0,0 +1,82 @@ +# GC direct-start reach repair + +The commit first adding this record binds its evaluated test source. The +baseline for the CI reproduction is `1344ee84901bb797f98bfb486aaf3480822b0f44`. + +The Angular PR's core test shard failed because a random GC campaign generated +no `write-started-sync` history. The source and model were unchanged by that +PR. This follow-up repairs the existing oracle's sampling gap without changing +the retention contract, production code, assertions, or reach requirements. + +## Law and repair + +Authority remains `BaseCollectionConfig.gcTime` and the shared model in +`collection-gc-retention-oracle.ts`. Direct insertion can start an idle +Collection's sync; persisting transactions retain it, and their release respects +the startup handoff deadline. The existing driver compares public retirement, +rows, and transaction states against independent logical holds and deadlines. + +Previously, the mixed generator selected one of 15 focused scenarios and then +a direct-insertion boolean, inside a weighted mixture with two other grammars. +A legal run could omit direct startup entirely. Each normal fixed/random +campaign now first generates ten direct-start histories using the existing +startup scenario and its random parameters. The original 250 mixed histories +still run with the same generator and seed/path coordinates. Explicit path +replays skip the extra prefix and run only the requested mixed history. + +This adds coverage of an existing receiving path. It does not relax the model, +count fixed examples as generated reach, or replace interaction histories. +Configured delays, ages, outcomes, and sibling transactions still vary in the +prefix. The same model, production driver, and comparisons execute both parts. + +## Evidence block + +- **Law:** the existing retention and startup-handoff law applies to a + Collection whose first direct insert starts sync. +- **Reach:** the required state remains in `boundaryStates`, with minimum one. + The normal fixed/random campaigns reached it 15 and 17 times respectively; + all other declared states also reached their minimum. Each campaign adds ten + generated direct-start histories while preserving its 250 mixed histories. +- **RED:** on the unchanged oracle, seed 706 reproduced CI's reach failure: + `write-started-sync (0)`. All sampled observations passed; the failure was the + independent reach guard, not a production mismatch or a timeout. +- **GREEN:** the GC oracle file passed all nine tests, including fixed/random + campaigns and reconstruction witnesses. Seed 706 passed after the repair. + The replay suite and GC file together passed 50 tests. Focused ESLint and + a typecheck of the two changed test modules passed on Node 24.8.0. +- **Mutant:** temporarily omit the generated prefix while retaining the + refactored mixed generator. Seed 706 again fails `write-started-sync (0)`. + This confirms that the prefix repairs reach and that the original mixed + generator still reproduces the missing-state trace. The mutant was restored. +- **Limits:** this repairs generation reach for one existing path, not a new + product defect. It does not guarantee every other random reach state by + construction, change the current controlled idle-host assumptions, or expand + native-browser/provider coverage. + +Reproduction command from the repository root: + +```sh +TANSTACK_DB_ORACLE_SEED=706 pnpm --filter @tanstack/db exec vitest run tests/collection-gc-retention-oracle.property.test.ts -t 'preserves retention across transaction phases' --coverage.enabled=false +``` + +Guarded path replay also passed from `packages/db` with property +`collection-lifecycle.gc-retention-boundaries`, seed 706 and path `0`: +one completed matching witness, one execution, and no generated prefix. + +The grammar refactor only adds an optional startup selector to the existing +scenario factory. Its default produces the original mixed arbitrary. No model +state, model rule, legal action, comparison checkpoint, or fixed reconstruction +input changed. The ten-case prefix participates in both normal campaigns with +the same budget; the 250-case mixed budget is preserved. This supplements the +earlier GC owner records rather than replacing their evidence or limits. + +Grammar controls preserve gcTime 1/10/49/50/51/100ms, ages 0–80ms, sibling +holds, and completion/failure/rollback outcomes from the original startup lane. +Removing the prefix reconstructs the seed-706 miss. Removing forced direct +insertion loses this receiving path; removing parameter variation would reduce +it to a pinned trace. The prefix excludes unrelated focused lanes and keeps +the existing action-legality checks and cleanup. There is no new model state +or second expectation, and no concrete shared-model fault requiring another +formulation was identified. The native-host premise is unchanged and remains +outside this sampling repair. Ordinary reconstruction tests still use the +shared model/driver/comparison; no unit tests were added to the oracle. diff --git a/packages/db/tests/collection-gc-retention-oracle.property.test.ts b/packages/db/tests/collection-gc-retention-oracle.property.test.ts index d227564d55..b76a0610d4 100644 --- a/packages/db/tests/collection-gc-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.property.test.ts @@ -496,171 +496,186 @@ const boundaryParameters = fc.record({ ), siblingPending: fc.boolean(), }) -const boundaryLaneHistoryArbitrary: fc.Arbitrary = fc.oneof( - ...[ - `startup`, - `unsubscribe`, - `idle`, - `pending`, - `pending-alone`, - `pending-subscribed`, - `delayed-commit`, - `preload`, - `disabled`, - `warm-preload`, - `failed-preload-held`, - `failed-preload-last`, - `async-cleanup-held`, - `async-cleanup-retired`, - `async-cleanup-single`, - ].map((lane) => - boundaryParameters.map((parameters): BoundaryHistory => { - const { - gcTime, - age, - outcome, - sibling, - direct, - disabledTime, - noTimePeriods, - timeout, - oldestFirst, - preloadCount, - releaseGap, - siblingOutcome, - siblingPending, - } = parameters - const actions: Array = [] - const asyncCleanup = lane.startsWith(`async-cleanup`) - // The two cleanup timing lanes always preserve a sibling. A separate - // single-transaction lane keeps the nearby no-survivor boundary. - const hasSibling = asyncCleanup - ? lane !== `async-cleanup-single` - : sibling - const settleAfterCleanup = lane === `async-cleanup-retired` - const failedPreload = lane.startsWith(`failed-preload`) - const preloadLast = lane === `failed-preload-last` - const settleTransaction = (slot: number, result = outcome): GcAction => - result === `rollback` - ? { kind: `rollback`, slot } - : { kind: `settle`, slot, outcome: result } - const pendingSibling = asyncCleanup && siblingPending - const firstSlot = hasSibling && !oldestFirst && !pendingSibling ? 1 : 0 - const lastSlot = firstSlot === 0 ? 1 : 0 - const preload = () => { - for (let count = 0; count < preloadCount; count++) { - if (count > 0) - actions.push({ kind: `advance`, elapsedMs: releaseGap }) - actions.push({ kind: `preload` }) - } - } - if (lane === `warm-preload`) - actions.push( - { kind: `advance`, elapsedMs: Math.max(gcTime, 50) }, - { kind: `preload` }, - ) - if (lane === `unsubscribe`) actions.push({ kind: `subscribe` }) - if (lane === `idle`) { - actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) + 1 }) - for (let i = 0; i < noTimePeriods; i++) - actions.push({ kind: `idle`, hasTime: false, didTimeout: false }) - } - actions.push({ - kind: `insert`, - commit: !lane.startsWith(`pending`) && lane !== `delayed-commit`, - }) - if (lane === `pending-subscribed`) { - actions.push( - { kind: `subscribe` }, - { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + age + 1 }, - { kind: `idle`, hasTime: true, didTimeout: false }, - { kind: `unsubscribe` }, - { kind: `advance`, elapsedMs: gcTime - 1 }, - { kind: `idle`, hasTime: true, didTimeout: false }, - { kind: `advance`, elapsedMs: 1 }, - { kind: `idle`, hasTime: true, didTimeout: false }, - ) - } else if (lane === `pending` || lane === `pending-alone`) { - actions.push({ kind: `advance`, elapsedMs: Math.max(gcTime, 50) - 1 }) - if (lane === `pending`) actions.push({ kind: `rollback`, slot: 0 }) - actions.push({ kind: `advance`, elapsedMs: 2 }) - } else { - if (lane === `delayed-commit`) { - actions.push( - { kind: `advance`, elapsedMs: age }, - { kind: `commit`, slot: 0 }, - ) - } - if (lane === `preload` || failedPreload) preload() - if (hasSibling) - actions.push({ kind: `insert`, commit: !pendingSibling }) - if (lane === `unsubscribe`) actions.push({ kind: `unsubscribe` }) - actions.push({ kind: `advance`, elapsedMs: age }) - if (lane === `warm-preload`) preload() - if (asyncCleanup) actions.push({ kind: `cleanup-start` }) - if (asyncCleanup && settleAfterCleanup) - actions.push({ kind: `cleanup-settle` }) - if (failedPreload && !preloadLast) - actions.push({ kind: `preload-failure` }) - actions.push(settleTransaction(firstSlot)) - if (hasSibling) - actions.push( - { kind: `advance`, elapsedMs: releaseGap }, - { kind: `idle`, hasTime: true, didTimeout: false }, - pendingSibling - ? { kind: `rollback`, slot: lastSlot } - : settleTransaction(lastSlot, siblingOutcome), +const boundaryLaneHistoryArbitrary = ( + onlyLane?: `startup`, +): fc.Arbitrary => + fc.oneof( + ...[ + `startup`, + `unsubscribe`, + `idle`, + `pending`, + `pending-alone`, + `pending-subscribed`, + `delayed-commit`, + `preload`, + `disabled`, + `warm-preload`, + `failed-preload-held`, + `failed-preload-last`, + `async-cleanup-held`, + `async-cleanup-retired`, + `async-cleanup-single`, + ] + .filter((lane) => onlyLane === undefined || lane === onlyLane) + .map((lane) => + boundaryParameters.map((parameters): BoundaryHistory => { + const { + gcTime, + age, + outcome, + sibling, + direct, + disabledTime, + noTimePeriods, + timeout, + oldestFirst, + preloadCount, + releaseGap, + siblingOutcome, + siblingPending, + } = parameters + const actions: Array = [] + const asyncCleanup = lane.startsWith(`async-cleanup`) + // The two cleanup timing lanes always preserve a sibling. A separate + // single-transaction lane keeps the nearby no-survivor boundary. + const hasSibling = asyncCleanup + ? lane !== `async-cleanup-single` + : sibling + const settleAfterCleanup = lane === `async-cleanup-retired` + const failedPreload = lane.startsWith(`failed-preload`) + const preloadLast = lane === `failed-preload-last` + const settleTransaction = ( + slot: number, + result = outcome, + ): GcAction => + result === `rollback` + ? { kind: `rollback`, slot } + : { kind: `settle`, slot, outcome: result } + const pendingSibling = asyncCleanup && siblingPending + const firstSlot = + hasSibling && !oldestFirst && !pendingSibling ? 1 : 0 + const lastSlot = firstSlot === 0 ? 1 : 0 + const preload = () => { + for (let count = 0; count < preloadCount; count++) { + if (count > 0) + actions.push({ kind: `advance`, elapsedMs: releaseGap }) + actions.push({ kind: `preload` }) + } + } + if (lane === `warm-preload`) + actions.push( + { kind: `advance`, elapsedMs: Math.max(gcTime, 50) }, + { kind: `preload` }, + ) + if (lane === `unsubscribe`) actions.push({ kind: `subscribe` }) + if (lane === `idle`) { + actions.push({ + kind: `advance`, + elapsedMs: Math.max(gcTime, 50) + 1, + }) + for (let i = 0; i < noTimePeriods; i++) + actions.push({ kind: `idle`, hasTime: false, didTimeout: false }) + } + actions.push({ + kind: `insert`, + commit: !lane.startsWith(`pending`) && lane !== `delayed-commit`, + }) + if (lane === `pending-subscribed`) { + actions.push( + { kind: `subscribe` }, + { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + age + 1 }, + { kind: `idle`, hasTime: true, didTimeout: false }, + { kind: `unsubscribe` }, + { kind: `advance`, elapsedMs: gcTime - 1 }, + { kind: `idle`, hasTime: true, didTimeout: false }, + { kind: `advance`, elapsedMs: 1 }, + { kind: `idle`, hasTime: true, didTimeout: false }, + ) + } else if (lane === `pending` || lane === `pending-alone`) { + actions.push({ + kind: `advance`, + elapsedMs: Math.max(gcTime, 50) - 1, + }) + if (lane === `pending`) actions.push({ kind: `rollback`, slot: 0 }) + actions.push({ kind: `advance`, elapsedMs: 2 }) + } else { + if (lane === `delayed-commit`) { + actions.push( + { kind: `advance`, elapsedMs: age }, + { kind: `commit`, slot: 0 }, + ) + } + if (lane === `preload` || failedPreload) preload() + if (hasSibling) + actions.push({ kind: `insert`, commit: !pendingSibling }) + if (lane === `unsubscribe`) actions.push({ kind: `unsubscribe` }) + actions.push({ kind: `advance`, elapsedMs: age }) + if (lane === `warm-preload`) preload() + if (asyncCleanup) actions.push({ kind: `cleanup-start` }) + if (asyncCleanup && settleAfterCleanup) + actions.push({ kind: `cleanup-settle` }) + if (failedPreload && !preloadLast) + actions.push({ kind: `preload-failure` }) + actions.push(settleTransaction(firstSlot)) + if (hasSibling) + actions.push( + { kind: `advance`, elapsedMs: releaseGap }, + { kind: `idle`, hasTime: true, didTimeout: false }, + pendingSibling + ? { kind: `rollback`, slot: lastSlot } + : settleTransaction(lastSlot, siblingOutcome), + ) + if (lane === `preload`) actions.push({ kind: `ready` }) + if (failedPreload && preloadLast) + actions.push({ kind: `preload-failure` }) + if (asyncCleanup) + actions.push( + { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + 1 }, + { kind: `idle`, hasTime: true, didTimeout: false }, + ...(!settleAfterCleanup + ? ([{ kind: `cleanup-settle` }] as const) + : []), + ) + } + actions.push({ kind: `idle`, hasTime: true, didTimeout: timeout }) + // Test just below the startup floor and after every finite deadline. + if (lane === `startup` && age < 49) + actions.push( + { kind: `advance`, elapsedMs: 49 - age }, + { kind: `idle`, hasTime: true, didTimeout: false }, + ) + // Derive boundary cuts from the independent law, not production timers. + const reference = createGcModel( + lane === `disabled` ? disabledTime : gcTime, + lane !== `preload` && !failedPreload, ) - if (lane === `preload`) actions.push({ kind: `ready` }) - if (failedPreload && preloadLast) - actions.push({ kind: `preload-failure` }) - if (asyncCleanup) + for (const action of actions) + if (gcActionLegal(reference, action)) stepGcModel(reference, action) + const remaining = gcDeadline(reference) - reference.now + if (!reference.retired && Number.isFinite(remaining)) { + actions.push( + { kind: `advance`, elapsedMs: Math.max(0, remaining - 1) }, + { kind: `idle`, hasTime: true, didTimeout: false }, + { kind: `advance`, elapsedMs: 1 }, + { kind: `idle`, hasTime: true, didTimeout: false }, + ) + } actions.push( - { kind: `advance`, elapsedMs: Math.max(gcTime, 50) + 1 }, - { kind: `idle`, hasTime: true, didTimeout: false }, - ...(!settleAfterCleanup - ? ([{ kind: `cleanup-settle` }] as const) - : []), + { kind: `advance`, elapsedMs: 200 }, + { kind: `idle`, hasTime: !timeout, didTimeout: timeout }, + { kind: `cleanup` }, ) - } - actions.push({ kind: `idle`, hasTime: true, didTimeout: timeout }) - // Test just below the startup floor and after every finite deadline. - if (lane === `startup` && age < 49) - actions.push( - { kind: `advance`, elapsedMs: 49 - age }, - { kind: `idle`, hasTime: true, didTimeout: false }, - ) - // Derive boundary cuts from the independent law, not production timers. - const reference = createGcModel( - lane === `disabled` ? disabledTime : gcTime, - lane !== `preload` && !failedPreload, - ) - for (const action of actions) - if (gcActionLegal(reference, action)) stepGcModel(reference, action) - const remaining = gcDeadline(reference) - reference.now - if (!reference.retired && Number.isFinite(remaining)) { - actions.push( - { kind: `advance`, elapsedMs: Math.max(0, remaining - 1) }, - { kind: `idle`, hasTime: true, didTimeout: false }, - { kind: `advance`, elapsedMs: 1 }, - { kind: `idle`, hasTime: true, didTimeout: false }, - ) - } - actions.push( - { kind: `advance`, elapsedMs: 200 }, - { kind: `idle`, hasTime: !timeout, didTimeout: timeout }, - { kind: `cleanup` }, - ) - return { - gcTime: lane === `disabled` ? disabledTime : gcTime, - direct: direct && lane === `startup`, - ready: lane !== `preload` && !failedPreload, - asyncCleanup, - actions, - } - }), - ), -) + return { + gcTime: lane === `disabled` ? disabledTime : gcTime, + direct: direct && lane === `startup`, + ready: lane !== `preload` && !failedPreload, + asyncCleanup, + actions, + } + }), + ), + ) // These histories choose each next command from the reference's legal actions. // The prefix establishes subscriber multiplicity, three persisting siblings, @@ -846,17 +861,36 @@ const automaticCleanupHistoryArbitrary: fc.Arbitrary = fc }) const boundaryHistoryArbitrary: fc.Arbitrary = fc.oneof( - { arbitrary: boundaryLaneHistoryArbitrary, weight: 5 }, + { arbitrary: boundaryLaneHistoryArbitrary(), weight: 5 }, { arbitrary: interactionHistoryArbitrary, weight: 1 }, { arbitrary: automaticCleanupHistoryArbitrary, weight: 1 }, ) +// The mixed grammar reaches direct insertion only through one lane and a +// boolean choice. Generate this receiving path in each normal campaign so +// reach does not depend on that conjunction being sampled. Parameters and +// outcomes still vary, and the same model, driver and checks judge the history. +const writeStartedSyncHistoryArbitrary = boundaryLaneHistoryArbitrary( + `startup`, +).map((history) => ({ ...history, direct: true })) + it.each( runConfig.replaySeed === undefined ? [20261026, undefined] : [undefined], )( `preserves retention across transaction phases and idle delivery (seed %s)`, async (seed) => { boundaryReach = createOracleReach(boundaryPropertyName, boundaryStates) + // Keep the original mixed grammar and its seed/path coordinates intact. + // An explicit path replay executes only the requested mixed history. + if (!oracleReplayRequested()) { + await fc.assert( + fc.asyncProperty(writeStartedSyncHistoryArbitrary, runBoundaryHistory), + { + ...oraclePropertyOptions(10, boundaryPropertyName), + ...(seed === undefined ? {} : { seed }), + }, + ) + } await fc.assert( fc.asyncProperty(boundaryHistoryArbitrary, runBoundaryHistory), { @@ -865,8 +899,8 @@ it.each( }, ) boundaryReach.assertReached() - console.info( - `GC boundary reach: ${JSON.stringify(Object.fromEntries(boundaryReach.counts()))}`, + process.stdout.write( + `GC boundary reach: ${JSON.stringify(Object.fromEntries(boundaryReach.counts()))}\n`, ) }, ) From 8bb8c5cfbeadb960c9ae40247b512874ba2e4864 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjam=C3=ADn=20Vicente?= Date: Wed, 4 Nov 2026 10:09:56 -0300 Subject: [PATCH 6/6] test(db): make direct-start GC histories replayable --- docs/contributing/oracle-coverage.md | 6 ++- .../2026-11-04-gc-startup-reach.md | 39 ++++++++++++++++++- ...ction-gc-retention-oracle.property.test.ts | 34 ++++++++++++---- packages/db/tests/oracle-config.ts | 1 + packages/db/tests/oracle-replay-manifest.ts | 8 ++++ packages/db/tests/oracle-replay.test.ts | 10 +++++ 6 files changed, 89 insertions(+), 9 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index f7e9102e13..c1d17a1013 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -7503,7 +7503,11 @@ each normal campaign with ten generated direct-start histories, then runs its unchanged 250 mixed histories. This removes the known sampling gap where no history starts sync through direct insertion. The model, comparisons, all declared reach states, and both mixed run budgets remain intact. Explicit path -replays retain the original mixed generator and skip the prefix. Seed 706 +replays of the mixed property retain its original generator and skip the prefix. +The separately registered +`collection-lifecycle.gc-retention-boundaries.direct-start` property replays +only the prefix, with its own reach recorder. Both identities use the same +model, driver, and comparisons. Seed 706 reproduces the old gap and rejects omission of the prefix; the repaired fixed and random campaigns reached the state 15 and 17 times. Existing controlled idle-host and native-browser limits remain in force. See the diff --git a/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md index 448ec96be7..a6bd84d453 100644 --- a/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md +++ b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md @@ -22,7 +22,8 @@ A legal run could omit direct startup entirely. Each normal fixed/random campaign now first generates ten direct-start histories using the existing startup scenario and its random parameters. The original 250 mixed histories still run with the same generator and seed/path coordinates. Explicit path -replays skip the extra prefix and run only the requested mixed history. +replays of the mixed property skip the extra prefix and run only the requested +mixed history. The review follow-up below adds a separate prefix replay target. This adds coverage of an existing receiving path. It does not relax the model, count fixed examples as generated reach, or replace interaction histories. @@ -80,3 +81,39 @@ or second expectation, and no concrete shared-model fault requiring another formulation was identified. The native-host premise is unchanged and remains outside this sampling repair. Ordinary reconstruction tests still use the shared model/driver/comparison; no unit tests were added to the oracle. + + +## Review follow-up: independent direct-start replay + +[The review finding](https://github.com/TanStack/db/pull/2327#discussion_r4467712429) +identified a replay gap: prefix failures shared the mixed property's identity, +but replaying their seed/path selected a different grammar. The prefix now has +its own registered identity, +`collection-lifecycle.gc-retention-boundaries.direct-start`, owner, selector, +and reach recorder. Each assertion runs only for its requested identity during +path replay. Ordinary runs still execute both and retain aggregate reach. + +- **Law:** the guarded replay protocol documented in `oracle-replay.test.ts` + requires failure coordinates to execute the named property's grammar and + produce exactly one matching completed witness. +- **Reach:** both normal campaigns generated ten direct-start histories each. + Aggregate direct-start reach was 15 for the fixed campaign and 19 for the + random campaign; all other declared states met their existing minimum. +- **RED:** the added replay-selection check failed before registration with + unknown property `collection-lifecycle.gc-retention-boundaries.direct-start`. +- **GREEN:** the targeted replay protocol suite passed five checks, including + real guarded child-process replays of both identities at seed 42, path `0`. + The GC file passed all nine tests with the original ten-prefix and 250-mixed + budgets per campaign. +- **Mutant:** temporarily reject every prefix history with a calibration error. + The direct-start replay failed with that error; the mixed replay still passed + without executing the prefix. The source was restored after the experiment. +- **Limits:** this repairs replay selection and reach attribution. It changes + neither the retention model nor production behavior and adds no native-host + coverage. The original GC evidence and controlled-host limits remain valid. + +Replay command from `packages/db` (substitute the mixed property to replay it): + +```sh +TANSTACK_DB_ORACLE_SEED=42 TANSTACK_DB_ORACLE_PATH=0 TANSTACK_DB_ORACLE_PROPERTY=collection-lifecycle.gc-retention-boundaries.direct-start node --import tsx tests/oracle-replay.ts tests/collection-gc-retention-oracle.property.test.ts --maxWorkers=1 --coverage.enabled=false +``` diff --git a/packages/db/tests/collection-gc-retention-oracle.property.test.ts b/packages/db/tests/collection-gc-retention-oracle.property.test.ts index b76a0610d4..6c9322a5b4 100644 --- a/packages/db/tests/collection-gc-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-gc-retention-oracle.property.test.ts @@ -6,6 +6,7 @@ import { createTransaction } from '../src/transactions.js' import { oraclePropertyOptions, oracleReplayRequested, + oracleReplaySkips, readOracleRunConfig, } from './oracle-config.js' import { createOracleReach } from './oracle-reach.js' @@ -77,6 +78,9 @@ type BoundaryHistory = { actions: Array } const boundaryPropertyName = `collection-lifecycle.gc-retention-boundaries` +const directStartPropertyName = `collection-lifecycle.gc-retention-boundaries.direct-start` +const createDirectStartReach = () => + createOracleReach(directStartPropertyName, [`write-started-sync`]) const boundaryStates = [ `queued-idle-renewal`, `startup-release-before-floor`, @@ -114,8 +118,12 @@ const boundaryStates = [ ] as const let boundaryReach = createOracleReach(boundaryPropertyName, boundaryStates) -async function runBoundaryHistory(history: BoundaryHistory): Promise { +async function runBoundaryHistory( + history: BoundaryHistory, + directStartReach?: ReturnType, +): Promise { boundaryReach.beginHistory() + directStartReach?.beginHistory() vi.useFakeTimers() vi.setSystemTime(0) resetCleanupQueue() @@ -271,6 +279,7 @@ async function runBoundaryHistory(history: BoundaryHistory): Promise { handlers.push(handler) if (history.direct && action.commit) { boundaryReach.reach(`write-started-sync`) + directStartReach?.reach(`write-started-sync`) transactions.push(collection.insert({ id: transactions.length + 1 })) } else { const transaction = createTransaction({ @@ -880,19 +889,30 @@ it.each( `preserves retention across transaction phases and idle delivery (seed %s)`, async (seed) => { boundaryReach = createOracleReach(boundaryPropertyName, boundaryStates) - // Keep the original mixed grammar and its seed/path coordinates intact. - // An explicit path replay executes only the requested mixed history. - if (!oracleReplayRequested()) { + // Each assertion has its own identity so its failure coordinates select + // the same grammar. Normal campaigns still aggregate both parts' reach. + if (!oracleReplaySkips(directStartPropertyName)) { + const directStartReach = createDirectStartReach() await fc.assert( - fc.asyncProperty(writeStartedSyncHistoryArbitrary, runBoundaryHistory), + fc.asyncProperty(writeStartedSyncHistoryArbitrary, (history) => + runBoundaryHistory(history, directStartReach), + ), { - ...oraclePropertyOptions(10, boundaryPropertyName), + ...oraclePropertyOptions(10, directStartPropertyName), ...(seed === undefined ? {} : { seed }), }, ) + directStartReach.assertReached() + process.stdout.write( + `GC direct-start reach: ${JSON.stringify(Object.fromEntries(directStartReach.counts()))}\n`, + ) } + // Preserve the mixed grammar's existing seed/path coordinates and budget. + if (oracleReplaySkips(boundaryPropertyName)) return await fc.assert( - fc.asyncProperty(boundaryHistoryArbitrary, runBoundaryHistory), + fc.asyncProperty(boundaryHistoryArbitrary, (history) => + runBoundaryHistory(history), + ), { ...oraclePropertyOptions(250, boundaryPropertyName), ...(seed === undefined ? {} : { seed }), diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index 0fd07aafcd..b795749df4 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -295,6 +295,7 @@ const staticOracleProperties = [ `collection-sync.reentrant-drain`, `collection-lifecycle.gc-persisting-transaction`, `collection-lifecycle.gc-retention-boundaries`, + `collection-lifecycle.gc-retention-boundaries.direct-start`, `collection-lifecycle.gc-mutation-shapes`, `collection-state.retention`, `collection-state.accepted-snapshot.before-delete`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 6dbde936a7..c00ca5adb4 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -858,6 +858,10 @@ owners.set( `collection-lifecycle.gc-retention-boundaries`, `packages/db/tests/collection-gc-retention-oracle.property.test.ts`, ) +owners.set( + `collection-lifecycle.gc-retention-boundaries.direct-start`, + `packages/db/tests/collection-gc-retention-oracle.property.test.ts`, +) for (const property of registeredOracleProperties) { if (property.startsWith(`includes.matrix.`)) { @@ -989,6 +993,10 @@ const replayTestNames = new Map([ `collection-lifecycle.gc-retention-boundaries`, `preserves retention across transaction phases.*seed undefined`, ], + [ + `collection-lifecycle.gc-retention-boundaries.direct-start`, + `preserves retention across transaction phases.*seed undefined`, + ], [ `ssr.stream-record-lifetime`, `settles each record demand at its transport checkpoint.*random`, diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index e79e90a43e..b4fff88b5e 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -109,6 +109,16 @@ describe(`guarded oracle replay`, () => { }, 40_000) it.each([ + [ + `collection-lifecycle.gc-retention-boundaries.direct-start`, + `tests/collection-gc-retention-oracle.property.test.ts`, + 1, + ], + [ + `collection-lifecycle.gc-retention-boundaries`, + `tests/collection-gc-retention-oracle.property.test.ts`, + 1, + ], [ `index-update.reference-model`, `tests/index-update-oracle.property.test.ts`,