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 fbea9cd98c..c1d17a1013 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -2864,6 +2864,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 @@ -7473,3 +7495,20 @@ requests while joined work defers a reentrant pass, then check the real subscription receiving path. This remains separate from PR #2346's observer metadata-sharing and discarded-publication scope. The test, grammar and run budget remain unchanged; a green later random campaign does not close it. + +## 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 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 +[CI repair evidence](oracle-reviews/2026-11-04-gc-startup-reach.md). 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..4953a3cbc2 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-01-angular-snapshot.md @@ -0,0 +1,144 @@ +# 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. + +## 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. + +## Main integration, 2026-11-04 + +The merge combines Angular branch `c098d577d241042bc247ac41a770b85e50c0b48f` +with main `8feab6ea3eaae4456f738da39fa93508ddd5cc01`. Main added ordered-window +reuse and shared result-shape/window-history receiving tests after the original +PR base. The resolution retains main's `resolveLiveQueryValue` window ownership +and deferred move effect, and the PR's synchronous observer snapshot reads and +effect-owned subscription cleanup. The conformance-driver comment now names +both its pre-effect shape reads and its effect cleanup accurately. + +All replay property/owner entries and both sets of coverage-map additions are +retained. No oracle model, assertion, history grammar, run budget, or expected +result was changed to resolve these conflicts. The merge commit first adding +this section binds the evaluated combined source. + +Validation on Node 24.8.0: the full Angular suite passed 129 tests with one +existing TODO, including window history, result shape, and the PR's current-query +snapshot campaigns. Angular typecheck, build, focused ESLint and `lint:changed` +passed. The replay manifest's owner, file mapping and command-location checks +passed (three selected tests), and guarded `angular.current-snapshot` replay +1101/path `0` executed one matching witness. The GC seed-706 campaign passed and +reached `write-started-sync` ten times. Existing SSR/browser and controlled-host +limits remain unchanged; this resolution adds no client/SSR provider code. 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..a6bd84d453 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-11-04-gc-startup-reach.md @@ -0,0 +1,119 @@ +# 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 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. +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. + + +## 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/docs/framework/angular/overview.md b/docs/framework/angular/overview.md index 9162623132..a32de52c33 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 @@ -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 @@ -115,7 +117,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..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,13 +293,13 @@ 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 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 3ac4da5f7b..f540bdd2b8 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": { diff --git a/packages/angular-db/src/index.ts b/packages/angular-db/src/index.ts index 67fd0a1167..072f2e949b 100644 --- a/packages/angular-db/src/index.ts +++ b/packages/angular-db/src/index.ts @@ -8,10 +8,7 @@ import { import { configPool, createLiveQueryObserver, - getLiveQueryStatusFlags, - getPublicCollection, isCollection, - isSingleResultCollection, resolveLiveQueryValue, } from '@tanstack/db' import type { @@ -22,7 +19,6 @@ import type { InferResultType, InitialQueryBuilder, LiveQueryHookConfig, - LiveQuerySnapshot, LiveQueryStatusFlags, NonSingleResult, PooledLiveQueries, @@ -202,82 +198,50 @@ export function injectLiveQuery(opts: any) { untracked(() => move?.()) }) - // The shared observer already captures one coherent view per publication. - // Signals project that snapshot instead of copying its rows and health again. - const snapshot = signal | null>(null) - const state = computed( - () => (snapshot()?.state ?? new Map()) as Map, + // 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` }), ) - const status = computed(() => { - const value = snapshot()?.status ?? `idle` - return value === `idle` && !collection() ? `disabled` : value - }) - const health = computed(() => snapshot()?.health) - // Every status flag comes from the shared rule; a disabled query is ready. - const flags = computed(() => - getLiveQueryStatusFlags(status() as CollectionStatus, health()), - ) - - // Between input resolution and observer attachment, the snapshot can belong - // to the previous query. Reapply the current query's projection to its rows. - const data = computed(() => { - // Read the current input even when a snapshot exists: a compile rejection - // must propagate instead of exposing the previous query's rows. - const currentCollection = collection() - const currentSnapshot = snapshot() - if ( - currentSnapshot && - currentSnapshot.collection === getPublicCollection(currentCollection) - ) - return currentSnapshot.data - // Public pooled Collections materialize when their properties are read. - // The captured keyed rows let us reproject without inspecting that facade. - const rows = Array.from(currentSnapshot?.state?.values() ?? []) - return currentCollection && isSingleResultCollection(currentCollection) - ? rows[0] - : rows + const revision = signal(0) + const invalidate = () => revision.update((value) => value + 1) + const snapshot = computed(() => { + revision() + return observer().getSnapshot() }) + const rowState = computed(() => snapshot().state) - // The effect owns this observer through reruns and injector destruction. effect((onCleanup) => { - const currentCollection = collection() - - // Handle null collection (disabled query) - if (!currentCollection) { - snapshot.set(null) - return - } - - // Wholesale mode preserves Angular's loading policy: attaching requests no - // initial-state snapshot from the source. - const observer = createLiveQueryObserver(currentCollection, { - mode: `wholesale`, + 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() }) - - const syncSnapshot = () => snapshot.set(observer.getSnapshot()) - observer.subscribe(syncSnapshot) - // Wholesale attach suppresses listener calls raised by synchronous sync - // startup. Read once after subscribe returns to capture that final state. - syncSnapshot() - onCleanup(() => observer.dispose()) }) 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 958697faa6..8e6bd2b965 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, and the shared shape oracle also reads them synchronously * after a parameter change before effects. Controllable inputs use `{ 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/collection-gc-retention-oracle.property.test.ts b/packages/db/tests/collection-gc-retention-oracle.property.test.ts index d227564d55..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({ @@ -496,171 +505,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,27 +870,57 @@ 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) + // 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, (history) => + runBoundaryHistory(history, directStartReach), + ), + { + ...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 }), }, ) 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`, ) }, ) diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index aa495fee01..77b5b1e790 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -76,6 +76,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 635bfa2149..b795749df4 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`, `pagination.limit-admission-replay-pending-negative`, `pagination.limit-admission-replay-pending-valid`, `pagination.limit-admission-builder-negative`, @@ -294,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 927d86081d..c00ca5adb4 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`, + ], [ `db/tests/query/pagination-oracle.property.test.ts`, `pagination`, @@ -853,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.`)) { @@ -984,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`,