From 62e3e22eb00334b53db55a8927324f196aba846c Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 08:18:59 +0100 Subject: [PATCH 1/6] test: make oracle models executable documentation --- AGENTS.md | 40 ++ docs/contributing/glossary.md | 175 ++++++ docs/contributing/oracle-coverage.md | 60 +- docs/contributing/oracle-test-notes.md | 27 +- docs/contributing/oracle-tests.md | 243 ++++++- docs/guides/collection-options-creator.md | 8 +- docs/guides/error-handling.md | 8 +- docs/reference/classes/CollectionImpl.md | 2 +- docs/reference/interfaces/Collection.md | 2 +- .../tests/opfs-page-lifecycle-oracle.test.ts | 19 + .../opfs-worker-diagnostics-oracle.test.ts | 13 + .../persisted-conformance-manifest.ts | 14 +- .../tests/hash-failure-retry.property.test.ts | 11 + .../db-ivm/tests/hash-graph.property.test.ts | 10 + .../tests/hash-mixed-graph.property.test.ts | 10 + packages/db-ivm/tests/hash.property.test.ts | 18 +- .../incrementalization-law.property.test.ts | 507 ++++++++------- .../db-ivm/tests/incrementalization-law.ts | 26 + .../operators/topk-relation-oracle.test.ts | 13 + .../tests/operators/topk-relation-oracle.ts | 28 +- .../topk-support-window-oracle.test.ts | 15 + ...rsisted-collection-conformance-contract.ts | 10 + .../tests/contracts/sqlite-driver-contract.ts | 15 + .../tests/persisted.test.ts | 21 + packages/db/package.json | 2 +- .../db/skills/db-core/custom-adapter/SKILL.md | 14 +- packages/db/src/collection/index.ts | 2 +- packages/db/src/collection/lifecycle.ts | 2 +- packages/db/src/collection/state.ts | 10 +- packages/db/src/collection/subscription.ts | 307 ++++----- packages/db/src/collection/sync.ts | 22 +- packages/db/src/query/live/ARCHITECTURE.md | 67 +- .../query/live/collection-config-builder.ts | 70 ++- .../src/query/live/collection-subscriber.ts | 16 +- .../src/query/live/ordered-source-loader.ts | 60 +- packages/db/tests/SortedMap.test.ts | 17 + packages/db/tests/btree-map-oracle.test.ts | 188 ++++-- .../db/tests/cleanup-queue.property.test.ts | 122 +++- .../collection-cleanup-restart-oracle.test.ts | 13 + packages/db/tests/collection-errors.test.ts | 36 +- ...tadata-publication-oracle.property.test.ts | 14 + ...collection-mutation-startup-oracle.test.ts | 29 +- ...on-state-retention-oracle.property.test.ts | 41 +- ...llection-subscription-lifecycle-grammar.ts | 100 ++- ...ription-lifecycle-history.property.test.ts | 52 +- ...tion-subscription-lifecycle-oracle.test.ts | 591 ++++++++++-------- ...ion-lifecycle-publication.property.test.ts | 94 ++- ...ubscription-replay-oracle.property.test.ts | 194 +++--- packages/db/tests/collection.test.ts | 2 +- packages/db/tests/comparison.property.test.ts | 20 +- packages/db/tests/conformance/contract.ts | 14 +- .../db/tests/conformance/infinite-contract.ts | 11 +- .../tests/conformance/infinite-on-demand.ts | 10 + .../db/tests/conformance/infinite-suite.ts | 16 +- packages/db/tests/conformance/suite.ts | 18 +- packages/db/tests/cursor.property.test.ts | 11 + ...rce-reconciliation-oracle.property.test.ts | 16 + .../db/tests/effect-disposal-oracle.test.ts | 16 +- .../db/tests/index-update.property.test.ts | 10 + ...ve-query-observer-history.property.test.ts | 26 +- .../db/tests/optimistic-history-oracle.ts | 29 +- .../tests/optimistic-history-outcomes.test.ts | 14 + .../optimistic-history-publication.test.ts | 11 + ...mistic-transaction-oracle.property.test.ts | 16 + packages/db/tests/oracle-config.ts | 6 + packages/db/tests/oracle-replay-manifest.ts | 19 +- packages/db/tests/oracle-replay-witness.ts | 8 +- .../db/tests/oracle-replay.fixture.test.ts | 11 +- packages/db/tests/oracle-replay.test.ts | 17 + packages/db/tests/oracle-replay.ts | 13 +- .../tests/proxy-detachment-contract.test.ts | 17 + .../db/tests/proxy-iteration-contract.test.ts | 17 +- packages/db/tests/proxy.test.ts | 20 + .../cold-join-reconciliation-oracle.test.ts | 22 + .../identity-output-shape-oracle.test.ts | 15 + ...ncludes-collection-oracle.property.test.ts | 35 +- .../includes-context-transport-oracle.test.ts | 38 ++ ...-cross-formulation-oracle.property.test.ts | 61 +- ...ludes-functional-projection-oracle.test.ts | 37 +- ...ncludes-optimistic-oracle.property.test.ts | 41 +- .../db/tests/query/includes-oracle-helpers.ts | 9 + .../query/includes-oracle.property.test.ts | 186 +++--- .../query/includes-publication-oracle.test.ts | 36 +- .../query/includes-query-shape-oracle.test.ts | 134 ++-- .../query/includes-space-oracle-fixture.ts | 2 + .../tests/query/includes-space-oracle.test.ts | 72 ++- .../query/includes-temporal-oracle.test.ts | 42 +- .../includes-work-counter-oracle.test.ts | 153 +++-- .../query/load-subset-oracle.property.test.ts | 19 + ...ad-subset-replay-refinement-oracle.test.ts | 22 +- ...source-readiness-refinement-oracle.test.ts | 13 + ...bset-transaction-refinement-oracle.test.ts | 68 +- .../ordered-lifecycle-oracle.property.test.ts | 114 ++-- .../ordered-work-oracle.property.test.ts | 18 + .../query/pagination-oracle.property.test.ts | 24 +- packages/db/tests/utils.property.test.ts | 18 +- .../tests/electric-oracle-lifecycle.test.ts | 7 + .../tests/electric-oracle-lifecycle.ts | 11 +- .../tests/electric-oracle.property.test.ts | 36 +- .../tests/electric-recovery-oracle.test.ts | 15 + .../electric-sdk-delivery.property.test.ts | 17 +- .../tests/pg-serializer.property.test.ts | 18 +- .../tests/KeyScheduler.property.test.ts | 61 +- .../tests/fifo-retry.property.test.ts | 206 ++++-- .../tests/leadership-replay.property.test.ts | 16 + .../tests/oracle-config.ts | 44 ++ .../tests/oracle-lifecycle.test.ts | 6 + .../tests/oracle-lifecycle.ts | 10 +- .../transaction-serializer.property.test.ts | 48 +- .../transaction-settlement.property.test.ts | 16 + .../tests/correctness-oracle.test.ts | 27 +- packages/query-db-collection/package.json | 2 +- .../src/cursor-pagination.ts | 8 +- packages/query-db-collection/src/query.ts | 40 +- .../cursor-pagination.boundary-oracle.test.ts | 14 + .../cursor-pagination.cache-oracle.test.ts | 17 +- .../cursor-pagination.integration.test.ts | 9 +- ...sor-pagination.no-peek.integration.test.ts | 38 +- .../tests/cursor-pagination.no-peek.test.ts | 134 ++-- .../tests/cursor-pagination.oracle.test.ts | 15 + ...rsor-pagination.publication-oracle.test.ts | 16 +- .../tests/cursor-pagination/model.ts | 9 +- .../tests/cursor-pagination/no-peek.ts | 43 +- .../includes-work-counter-oracle.test.ts | 15 + .../load-subset-lifecycle-oracle.test.ts | 19 +- .../tests/ownership-lifecycle.oracle.test.ts | 21 + .../tests/infinite-query-conformance.test.tsx | 9 +- .../infinite-query-conformance.svelte.test.ts | 9 +- .../trailbase-db-collection/tests/ORACLE.md | 9 +- .../tests/lifecycle-oracle.property.test.ts | 60 +- .../tests/infinite-query-conformance.test.ts | 8 +- 131 files changed, 4208 insertions(+), 1730 deletions(-) create mode 100644 docs/contributing/glossary.md create mode 100644 packages/offline-transactions/tests/oracle-config.ts diff --git a/AGENTS.md b/AGENTS.md index a379183b22..077580c31a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,41 @@ Treat that document's component boundaries and normative laws as constraints. If a change intentionally revises an architectural contract, update the architecture document in the same pull request. +## Required reading: executable subsystem models + +Read `docs/contributing/glossary.md` before naming or renaming a subsystem +state, action, boundary, or observation. Production code, executable models, +tests, and design documents must use the same term for the same concept. A +model may stay structurally independent, but it must declare any abstraction +that combines, splits, or does not correspond to production concepts. + +Some oracle files are also the shortest documentation for their subsystem. +Before changing a covered contract or its machinery, use +`docs/contributing/oracle-coverage.md` to find the primary executable owner and +read its stated limits. Do not infer authority from a filename alone. + +Read these stable entry points before the narrower owner: + +- For correlated live-query materialization, read the architecture document + required above. +- For Collection mutation admission, subscription ownership, replay, + publication, or disposal, read + `packages/db/tests/collection-subscription-lifecycle-grammar.ts`. +- For optimistic snapshots and settlement, read + `packages/db/tests/optimistic-history-oracle.ts`. +- For opaque cursor pagination, read + `packages/query-db-collection/tests/cursor-pagination/model.ts`. +- For TrailBase lifecycle work, read + `packages/trailbase-db-collection/tests/ORACLE.md`. +- For cross-framework behavior, read the shared contract under + `packages/db/tests/conformance/` and the receiving framework's driver. One + framework's scheduling cut does not prove another's. + +The opening prose states the contract. The small model states the expected +behavior. The production driver proves that the implementation refines the +model. If a change revises one of these contracts, update all three in the same +pull request. Do not update only the assertions to match new production output. + ## Table of Contents 1. [Type Safety](#type-safety) @@ -368,6 +403,11 @@ Use the [coverage map](docs/contributing/oracle-coverage.md) to find an existing owner and its limits before adding another model. The guide explains testing methods; it does not authorize new product behavior or retire existing laws. +When an oracle has a stable subsystem model, write it as executable subsystem +documentation. Follow the guide's section on literate oracle files. Keep the +contract, model, generated history grammar, production driver, and observations +close enough that a reviewer can compare them directly. + ### Always Add Tests for Bugs **Key Principle:** Reproduce a bug in a test before fixing it. Prefer extending diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md new file mode 100644 index 0000000000..7e27d8de6a --- /dev/null +++ b/docs/contributing/glossary.md @@ -0,0 +1,175 @@ +# Project glossary + +Use one term for one concept across production code, models, tests, and design +documents. Do not rotate synonyms for style. Shared words let a reader move +between the contract, the model, and the implementation without translating. + +This glossary owns terms that cross subsystem boundaries. A subsystem may add +narrower terms in its architecture document. It must not redefine a term here. +When an independent model needs a different abstraction, state the mapping next +to the model. + +Aligned vocabulary does not mean copied logic. A reference model should not use +production queues, caches, or semantic helpers merely to share their names. + +## Runtime terms + +| Term | Meaning | Do not use as a synonym | +| --- | --- | --- | +| Collection | The public keyed data container. Capitalize it when referring to the TanStack DB type. | Relation, table, or query result. | +| source Collection | A Collection read by a query or adapter. | Source relation when the value is a public Collection. | +| live-query Collection | A Collection whose rows are produced by a live query. | Query, observer, or result set. | +| relation | An internal weighted multiset maintained by D2. | Collection. | +| row | One keyed public Collection value or one relation value. Qualify source row, relation row, or public row when more than one kind appears. | Event or transaction. | +| change message | One insert, update, or delete delivered through the Collection sync boundary. | Transaction or publication. | +| sync transaction | The changes between one `begin()` and `commit()` pair. | Optimistic transaction or publication. | +| optimistic transaction | A local mutation transaction whose intent may later complete or roll back. | Sync transaction. | +| Collection status | One public Collection lifecycle value: `idle`, `loading`, `ready`, `error`, or `cleaned-up`. | Subscription status. | +| subscription status | One subscription value: `ready` or `loadingSubset`. | Collection status. | +| subscription | A consumer of Collection changes with its own subset demands and lifecycle. | Collection, query, or transport. | +| sync adapter | The code supplied through Collection sync configuration that starts a sync run and translates between TanStack DB and a provider. | Provider or source Collection. | +| provider | The external database, service, or SDK from which a sync adapter acquires data. | Sync adapter or source Collection. | +| sync run | One invocation of a Collection's sync function, plus the callbacks and resources installed by that invocation, until cleanup invalidates them. A run may own zero or more provider requests and may outlive any one request. The internal `syncRunGeneration` counter fences this lifetime. | Provider session, replay, generation, or request. | +| provider session | A provider-defined remote stream, connection, or SDK lifetime. Always qualify it with the provider. | Sync run. | +| cleanup | The transition that ends the current sync run and releases its resources. The Collection object remains available for cleanup or restart. | Collection destruction, restart, or replay. | +| restart | Starting a new sync run after the prior sync run has ended. | Same-run recovery or replay. | +| truncate replay | An authoritative source replacement after a truncate, inside the current sync run. The Collection subscription remains active; any dependent live-query graph remains active behind its publication barrier. | Restart, reload, retry, or repair. | +| source snapshot | The source rows established by one snapshot operation within its declared predicate and window. It proves full-source state only when the provider says that scope is authoritative and exhausted. | Public snapshot or proof of full-source coverage. | +| public snapshot | The last coherent set of rows exposed to reads, events, and downstream queries. | Private replacement or source snapshot. | +| private replacement | Source or graph state withheld while an authoritative replay or repair is incomplete. | Public snapshot. | +| publication | The boundary that makes one coherent result observable to reads, events, and downstream queries. | Provider return, request settlement, or sync commit by itself. | +| coherent commit | One publication in which state, events, and consumers see the same complete result. | Any individual `commit()` call. | +| readiness | Evidence that a named consumer may proceed. Always qualify Collection readiness, subscription readiness, or initial-query readiness; they settle at different boundaries. | Provider completion or publication in general. | + +`source` names a role in a data flow; it does not own a lifecycle. Do not coin a +`source session`. Use `sync run` for the local Collection sync invocation +and a provider-qualified `provider session` for a remote lifecycle. + +## Demand and pagination terms + +These terms form a graph, not a one-to-one pipeline. Keep the ownership, work, +evidence, and visibility boundaries visible: + +```text +logical subset owner --retains--> demand +demand --starts or replaces--> 0..n acquisition attempts +accepted attempt --establishes--> physical acquisition + acquisition lease +physical acquisition --may drive--> 0..n sync transactions +commit(sync transaction) --returns--> applied receipt +acquisitions + receipts + graph work --may gate--> publication +publication --advances or replaces--> public snapshot +``` + +One demand may need several acquisitions. One acquisition may write through +several sync transactions. One publication may wait for several acquisitions, +receipts, or graph participants. Do not infer success or visibility from an +earlier node alone. + +| Term | Meaning | Do not use as a synonym | +| --- | --- | --- | +| demand | The logical need for source data. Demand may outlive or replace physical work. | Request, transport, or row ownership. | +| logical subset owner | One subscription claim that keeps a subset demand active. | Physical acquisition. | +| request data | The immutable `LoadSubsetOptions` and attached signal passed to an adapter. | Demand or established coverage. | +| acquisition attempt | One invocation that asks a sync adapter to start physical work. A synchronous throw ends the attempt before it establishes an acquisition lease. | Physical acquisition or transport. | +| physical acquisition | Request-scoped work accepted by a sync adapter for a demand. | Acquisition attempt, logical demand, or transport. | +| lease | An ownership token that requires a matching release. Always qualify which resource it owns. | Demand, request, or data coverage. | +| acquisition lease | The release obligation created when an adapter accepts a physical acquisition. | The acquisition attempt itself. | +| window lease | One window-controller caller's contribution to the requested window. | Acquisition lease. | +| observer lease | One Query DB owner's claim that retains a query observer or its rows. | Subscription or acquisition lease. | +| demand retirement | Removing one logical owner's claim on demand. | Acquisition release or row deletion. | +| acquisition release | Fulfilling one acquisition lease by aborting its signal and giving the owning adapter one unload opportunity. | Demand retirement, guaranteed transport cancellation, or row deletion. | +| adapter unload | The adapter callback invoked during acquisition release. | Demand retirement or proof that transport stopped. | +| abort | A cancellation signal sent to work that may still be in flight. | Release, rollback, or guaranteed transport cancellation. | +| transport | Provider work such as an HTTP request or stream. A transport may be shared, may outlive abort, and need not map one-to-one to a physical acquisition. | Acquisition attempt or demand. | +| settlement | A promise becoming fulfilled or rejected. Qualify what settled. | Success, application, or publication. | +| applied receipt | The `SyncAppliedReceipt` returned by `commit()`. It settles when that sync transaction's writes and events become visible, or rejects if they are abandoned. | Provider completion or source exhaustion. | +| applied settlement | The law that a successful subset load waits for every applied receipt that establishes its result. | Publication of a larger graph result. | +| window | The requested ordered view described by offset and limit. | Loaded prefix or established source extent. | +| prefix | Source rows from the start of one declared order through a finite boundary. | Page, window, or proof of exhaustion. | +| cursor | Values and expressions that continue an ordered request from a boundary. | Offset or general request identity. | +| boundary | The last value that an ordered acquisition has safely established for continuation. | Any last local row. | +| tie group | Rows equal under the order terms used by a continuation boundary. | Page. | +| source exhaustion | Authoritative evidence that no more matching source rows exist. | A short response unless the provider says it is authoritative. | +| generation | A monotonic token used to reject obsolete asynchronous work. Qualify the clock it fences, such as sync-run, replay, ordered-load, window-operation, cursor-sequence, publication, post-write-refetch, graph, or demand generation. | Session, replay, or request. | +| repair | Work that tries to restore an authoritative source result after finite coverage becomes invalid or an acquisition fails. Repair may use a replay, but the terms are not synonyms. | Retry, replay, or restart. | +| recovery | Regaining a named capability after failure. Always qualify what recovered and whether it stayed in the same sync run. | Restart or repair in general. | + +## Live-query materialization terms + +The live-query architecture defines the full laws and local vocabulary. These +cross-file terms keep their exact meanings: + +| Term | Meaning | +| --- | --- | +| data plane | The D2 graph that joins, reduces, orders, and materializes relations. | +| demand plane | The asynchronous adapter that starts acquisition attempts and releases acquisition leases. | +| bucket key | The canonical identity of one correlated child partition. | +| active bucket | A bucket referenced by at least one current route. | +| route relation | Weighted links from bucket keys to materialization cells. | +| materialization cell | One include field on one parent-row occurrence. | +| Collection facade | The stable public Collection shared by parents routed to one active bucket. | +| weighted delta | A positive or negative change to a relation row. | + +See +[`packages/db/src/query/live/ARCHITECTURE.md`](../../packages/db/src/query/live/ARCHITECTURE.md) +for bucket relations, bucket values, arrangements, reductions, and the normative +materialization laws. + +## Oracle and model terms + +| Term | Meaning | +| --- | --- | +| contract or law | The promised behavior and its declared boundary. | +| oracle | The rule or mechanism that judges production behavior. | +| reference model | A simpler, independent computation or state machine used as an oracle. | +| history | Starting conditions plus a sequence of actions or events, including relevant dependencies and timing. | +| history grammar | The values, actions, constraints, and schedules that can form legal histories. | +| production driver | Test code that invokes the real entry point and controls relevant external events. | +| observation | One recorded public fact. | +| trace | An ordered sequence of observations. | +| checkpoint or observation cut | The exact point where the contract requires comparison. | +| projection or abstraction | The parts of concrete state retained for one judgment. | +| refinement check | A check that observed production behavior is allowed by the model. A sampled check is not a proof for every behavior. | +| partial oracle | An oracle that judges only named parts of the contract. | +| reach witness | Evidence that the production driver reached the claimed boundary. | +| fault control or mutant | A deliberate wrong answer or implementation used to test oracle sensitivity. | +| shrinking | Reducing a failing generated history while preserving the failure. | +| bounded exhaustiveness | Executing every case inside stated finite bounds. | +| held-out challenge | A challenge not used to shape the tested design. After it guides a change, retain it but stop calling it held out. | + +## Grammar rules + +Use nouns for state and verbs for transitions: + +- A demand **becomes active** or **retires**. +- Core **starts an acquisition attempt**. If the adapter accepts it, the attempt + **establishes a physical acquisition** and its **acquisition lease**. A + synchronous throw **fails the attempt** before either is established. +- A logical owner **retires**. Core **releases an acquisition lease**. The + adapter **unloads** its acquisition. +- An abort signal **requests cancellation**; transport **stops** only when the + provider does so. +- A promise **settles**; it **fulfills** or **rejects**. +- A sync transaction **applies** when its writes and events become visible. +- A replay or repair **publishes** one coherent public snapshot. +- Cleanup **ends** a sync run. Restart **starts** a new sync run. +- Truncate replay **replaces** source state inside the current sync run and + any current dependent graph. + +Use `complete` only when fulfillment versus rejection does not matter. Use +`success` only for fulfillment. Qualify overloaded words such as `state`, +`request`, `snapshot`, `session`, `owner`, `release`, `recovery`, and +`generation` at first use. + +## Model alignment rule + +For every model state, action, and observation, apply one of these rules: + +1. If it represents a production concept, use the canonical production term. +2. If it combines production concepts, name the abstraction and list what it + combines. +3. If it splits one production concept, qualify each part and state the split. +4. If it exists only in the model, say so. Do not give it a production name. + +The same check applies in reverse. New production terminology should update +this glossary and every model that represents that concept in the same change. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 763060ab54..9efcd14145 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -4,6 +4,10 @@ Use [Writing reliable oracle tests](oracle-tests.md) when adding or reviewing a law. This map identifies existing owners, their judgment, and their limits. It is not a claim that every state or every test has been audited. +The [project glossary](glossary.md) owns terms shared by these executable +models and their production subsystems. A coverage owner may define narrower +local terms, but it must not silently rename a production concept. + ## Scope of the oracle repair project [Issue #1808](https://github.com/TanStack/db/issues/1808) commissioned a bounded @@ -13,6 +17,58 @@ received routing recommendations, not 197 full semantic reviews. Focused examples, type tests, and host-wiring tests remain useful; converting them all to generated tests is not a completion criterion. +## Literate model audit + +The literate-model pass audits every primary executable owner in +[Find an owner](#find-an-owner) and every repository file explicitly named +`oracle` or `property`. Derive that strict inventory from the repository rather +than copying a count into this document: + +```sh +git ls-files -co --exclude-standard packages \ + | rg '/[^/]*(oracle|property)[^/]*$' \ + | sort -u +``` + +The command includes tracked and untracked package files so an in-progress +audit cannot hide a new owner. A surface is complete only when each primary +owner does one of these things: + +- states its contract, model, history grammar, production path, and observations + in the executable file, or +- records why that structure would add no useful information to a focused test. + +The pass must not change product behavior. If clearer prose exposes a missing +model rule or assertion, strengthen the oracle and run it against unchanged +production first. Track any production failure as separate follow-up work. + +The same pass audits vocabulary. Shared concepts use the production names in +the project glossary. The audit corrected model prose that collapsed a physical +acquisition into a request or called an acquisition lease merely a lease. +Model-only terms such as an appointment ledger or fault tape remain local and +say what production facts they abstract. This is a semantic review, not a rule +that test identifiers must copy production's private data structures. + +| Surface | Status | Completed or next owner | +| --- | --- | --- | +| Ordered relations and BTree | Complete | The signed top-K relation, BTree/Map refinement model, and DBSP incrementalization laws are literate. | +| Includes and publication | Complete | The central recomputation model plus cross-formulation, temporal demand, layered publication, Collection facade lifecycle and space bounds, route-context transport, functional projection, query-shape, optimistic, and source-work owners are literate. They keep the architecture document as their contract source. | +| Collection lifecycle | Complete | The shared logical-owner/acquisition-attempt/sync-run grammar plus mutation admission, lifecycle trace, publication, replay, disposal, and transaction-refinement boundaries are literate. | +| Optimistic state | Complete | The independent base/intent/source-queue graph plus outcome, transaction-payload, and publication drivers are literate. | +| Drafts and native values | Complete | Native differential behavior, draft change tracking, detachment and its class-instance exception, hostile keys, aliases, cycles, and Map/Set live iteration are literate. | +| Query DB and observer | Complete | Query-scope row ownership, subset identity and cancellation, failure and recovery, and the per-listener eligibility ledger are literate. | +| Ordered acquisition | Complete | Exact demand identity, applied settlement, independent pagination recomputation, request work, lifecycle products, replay authority, source-generation readiness, and transaction-refinement abort boundaries are literate. | +| Join equality and cold acquisition | Complete | Independent cold relational recomputation, acquisition evidence, established equality domains, replacements, and scan/index routes are literate. | +| Opaque backend pagination | Complete | The full-relation value model plus opaque token, cache generation, publication, browser acquisition, and live-window integration owners are literate. | +| Electric and TrailBase | Complete | Electric replica and recovery models, installed-SDK HTTP delivery, PostgreSQL serialization, and TrailBase's controlled RecordApi/native-stream lifecycle are literate with their real-provider limits intact. | +| PowerSync | Complete | Patch conservation, effective-update receipts, metadata and falsey changes, declared-view keys, transformed schema output, logging, cleanup, and native SQLite reach are literate. | +| SQLite persistence and native hosts | Complete | Persisted hydration/replay and ownership, shared driver transaction laws, OPFS page and diagnostic state machines, and the 113-law native conformance manifest are literate. Native execution remains distinct from registration and shim evidence. | +| Offline execution | Complete | FIFO retry, scheduler eligibility, leadership replay, transaction settlement, and typed wire serialization are literate. | +| Frameworks | Complete | Shared live-query and infinite-query models are literate. Each framework keeps its own realm, ownership, and scheduling driver. | +| Structural values and ordered primitives | Complete | Structural hashing, deep equality, comparison, cursor denotation, index refinement, and query-identity output equivalence are literate. | +| Boundary refinements | Complete | Cleanup/restart admission, metadata publication, retained state, acquisition cells, D2 source reconciliation, top-K support windows, and nested Query work bounds are literate. | +| Small structures and test mechanics | Complete | SortedMap, cleanup appointments, and guarded replay are literate. | + ### Recent fix-wave authority inventory This inventory records the permanent authority for the September 17 fix wave. It @@ -57,7 +113,9 @@ comment and the current API/architecture contract before extending its model. | SQLite persistence and native hosts | [persisted histories](../../packages/db-sqlite-persistence-core/tests/persisted.test.ts), [driver contracts](../../packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [browser OPFS lifecycle](../../packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](../../packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [113-law manifest](../../packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Cache/remote rejection/peer/reopen histories, exact driver results, controlled page/worker ownership, and diagnostic-cause retention. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The manifest excludes progressive and move suites; registration and shim runs are not device execution. | | Offline execution | [scheduler](../../packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](../../packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](../../packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](../../packages/offline-transactions/tests/transaction-serializer.property.test.ts) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | | Frameworks | [React conformance](../../packages/react-db/tests/conformance.test.tsx), [React pagination](../../packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](../../packages/db-collection-e2e/src/suites) | Exact exposed rows/pages and each framework's own lifecycle cuts. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | -| Small structures and test mechanics | [SortedMap](../../packages/db/tests/SortedMap.test.ts), [cleanup queue](../../packages/db/tests/cleanup-queue.property.test.ts), [guarded replay](../../packages/db/tests/oracle-replay.test.ts) | Map/full-sort and appointment-list models; executed target/seed/path checks. Callback-reentrant scheduling is outside the initial cleanup-queue domain. | +| Structural values and ordered primitives | [hash values](../../packages/db-ivm/tests/hash.property.test.ts), [hash graphs](../../packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](../../packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](../../packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](../../packages/db/tests/comparison.property.test.ts), [deep equality](../../packages/db/tests/utils.property.test.ts), [cursor](../../packages/db/tests/cursor.property.test.ts), [indexes](../../packages/db/tests/index-update.property.test.ts), [query identity](../../packages/db/tests/query/identity-output-shape-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, and compiled output bags. Hash collision freedom is not promised. Unsupported composite cursors reject. | +| Boundary refinements | [cleanup/restart](../../packages/db/tests/collection-cleanup-restart-oracle.test.ts), [metadata publication](../../packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [state retention](../../packages/db/tests/collection-state-retention-oracle.property.test.ts), [acquisition cells](../../packages/db/tests/collection-subscription-lifecycle-oracle.test.ts), [D2 source reconciliation](../../packages/db/tests/d2-source-reconciliation-oracle.property.test.ts), [top-K support windows](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts), [nested Query work](../../packages/query-db-collection/tests/includes-work-counter-oracle.test.ts) | Explicit lifecycle products, independent source maps and weighted relations, exact publication cuts, support/multiplicity, and value-plus-work observations. These refine the larger subsystem models; they do not replace them. | +| Small structures and test mechanics | [SortedMap](../../packages/db/tests/SortedMap.test.ts), [cleanup queue](../../packages/db/tests/cleanup-queue.property.test.ts), [guarded replay](../../packages/db/tests/oracle-replay.test.ts) | Map/full-sort and appointment-list models with executed target/seed/path checks. Callback-reentrant scheduling is outside the initial cleanup-queue domain. | ## Acceptance map diff --git a/docs/contributing/oracle-test-notes.md b/docs/contributing/oracle-test-notes.md index a1a5259906..888333faf5 100644 --- a/docs/contributing/oracle-test-notes.md +++ b/docs/contributing/oracle-test-notes.md @@ -2,26 +2,9 @@ Companion to [Writing reliable oracle tests](oracle-tests.md). These notes keep evidence and deeper reading off the quick-start path. The historical entries describe archived reports and audits, not current failing tests or reruns performed for this guide. -## A short glossary - -| Term | Meaning in this guide | -| --- | --- | -| Oracle | The rule or mechanism that judges behavior. | -| Property or law | A claim expected to hold for the declared inputs and histories. | -| Reference model | A simpler computation or state machine used to judge production. | -| History | Starting conditions and a sequence of actions/events; may include their dependencies and timing. | -| Driver | Test code that invokes production and controls relevant external events. | -| Observation / trace | Recorded behavior; a trace preserves a sequence, not just its final state. | -| Checkpoint / observation cut | The specific point at which a promise is checked. | -| Projection / abstraction | Retaining the parts of concrete state relevant to a chosen judgment. | -| Refinement | Implementation-visible behaviors are permitted by the specification; a sampled test is not a proof of this for all behaviors. | -| Metamorphic relation | A justified relation between executions derived by a transformation. | -| Partial oracle | A check of some promises, not the entire correct result. | -| Reach witness | Evidence that the scenario exercised its claimed boundary. | -| Fault control / mutant | A deliberate wrong answer or implementation change used to test sensitivity. | -| Shrinking | Reducing a failing input/history while retaining the relevant failure. | -| Bounded exhaustiveness | Every case within explicitly stated finite bounds, not every possible execution. | -| Held-out challenge | A challenge not used to shape the design being tested. Once used for tuning, keep it but change the claim. | +The shared terms used here are defined in the +[project glossary](glossary.md). That glossary is the vocabulary owner for +production code, models, tests, and design documents. ## Historical cases @@ -34,7 +17,7 @@ These retained case notes come from the September 11–14, 2026 research archive | FIFO waiting versus a test's proposed liveness | Direct historical user choice accepted FIFO waiting. The expectation had to respect that choice; the passage does not establish a final execution result. | *August 24 history* (research archive), lines 247–285 | | Finite scores omit the predicate's tail | A model limited to scores ≤3 made `score > 3` falsely empty. Truly empty IN was a separate question. | *Early loss report* (research archive), lines 135–139 | | Source-owned ordering | Reported withdrawn accusation: sorting visible optimistic values modeled the wrong state for the promised position. | *Middle loss report* (research archive), lines 176–178 | -| Ownership after rejected Promise | Reported lease obligation remained after Promise rejection; clearing ownership was not a valid repair for that API. A separate identity proposal compared a wrapper rather than the detached snapshot. Neither is a universal adapter rule. | *September 10 history* (research archive), lines 132–158 | +| Ownership after rejected Promise | Reported acquisition-lease obligation remained after Promise rejection; clearing ownership was not a valid repair for that API. A separate identity proposal compared a wrapper rather than the detached snapshot. Neither is a universal adapter rule. | *September 10 history* (research archive), lines 132–158 | | Four receipt positions, two timing questions | Reported correction retained later pending peers for rejection timing, exact rejection identity and intact partial-prefix rows. The `allSettled` mutant died by timeout; terminal position alone cannot expose waiting for a later peer. | *August 29 history* (research archive), lines 564–611 | | Helpful repeated-page provider | Reported provider prefilter supplied unrequested progress. This followed a distinct joined-alias underfill finding; they are not one mechanism. | *September 8 history* (research archive), lines 207–225 | | Old-left/new-right publication | Reported asymmetric update exposed mixed graph state; copying evidence before cleanup preserved the local witness, not a universal deep snapshot. | *September 7 history* (research archive), lines 192–222 | @@ -109,5 +92,3 @@ A supplied [testing-design gist](https://gist.github.com/KyleAMathews/72e0cb6f5f The guide's reference and ordered-comparison helpers are dependency-free JavaScript. The TLP partition rendering is an illustration; the archived SQLancer inventory establishes the strategy's broad query/recomposition relation, not an executed implementation of this example. The production loop is explicitly pseudocode. There is no runnable TanStack/fast-check integration recipe yet, and this guide must not be advertised as supplying one. The guide is accompanied by the repository's [coverage and closeout record](oracle-coverage.md). Historical archive titles above preserve provenance without pretending to be public permalinks. Runnable production examples are linked from that record; the guide's illustrative loop is not an executable integration recipe. - - diff --git a/docs/contributing/oracle-tests.md b/docs/contributing/oracle-tests.md index 37cd358d07..628b6e117c 100644 --- a/docs/contributing/oracle-tests.md +++ b/docs/contributing/oracle-tests.md @@ -12,7 +12,7 @@ For an incremental query engine, production might maintain complex indexes while The payoff is broader bug detection and a stable check during refactoring. The challenge is making sure the reference, generated cases, and observations actually represent the behavior we promise. -Start with [one small oracle](#build-one-small-oracle). Follow the later sections when your contract needs [richer state](#keep-only-state-that-can-matter), [controlled timing](#generate-histories-that-reach-the-problem), or [more observations](#observe-what-the-contract-promises). The [review card](#a-review-card) is a short way to apply the guide to an existing test. Historical cases and research are collected in the [companion notes](oracle-test-notes.md). +Start with [one small oracle](#build-one-small-oracle). Follow the later sections when your contract needs [richer state](#keep-only-state-that-can-matter), [controlled timing](#generate-histories-that-reach-the-problem), or [more observations](#observe-what-the-contract-promises). The [review card](#a-review-card) is a short way to apply the guide to an existing test. Historical cases and research are collected in the [companion notes](oracle-test-notes.md). Use the [project glossary](glossary.md) for terms shared with production code. ## A quick start @@ -50,6 +50,215 @@ An intentionally partial oracle is still useful. A rule that rejects duplicate c Some concurrent contracts allow several results. In those cases, the reference must allow that freedom rather than invent one required order. We will return to that after establishing how to record an execution. +## Write the oracle as executable subsystem documentation + +A good oracle can do more than catch regressions. Its model can give humans and +agents a short, executable theory of the subsystem. Production code shows how +the system works. The oracle should state what the system promises and why each +observable result follows. + +This does not make the model the source of product policy. Derive the contract +from an approved API, architecture document, established behavior, or design +decision. The file then keeps that contract, its model, and its production +evidence together. + +Two small examples show the form: + +- [`load-subset-transaction-refinement-oracle.test.ts`](../../packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts) + explains when an abort can still cancel an on-demand load. +- [`fifo-retry.property.test.ts`](../../packages/offline-transactions/tests/fifo-retry.property.test.ts) + explains why a ready transaction waits behind a delayed FIFO head. + +### Use five visible layers + +Keep these layers distinct even when they share one file: + +| Layer | What it must answer | +| --- | --- | +| Contract | What does the subsystem promise, and where is the boundary? | +| Model | What is the smallest independent rule that predicts public results? | +| History grammar | Which values, actions, relationships, and schedules can occur? | +| Production driver | Which real entry point and event boundary does the test exercise? | +| Refinement check | Which public observations must agree with the model, and when? | + +Do not create five classes merely to match this table. A short file can use one +opening comment, one pure model function, a generated input, a production +fixture, and assertions. A large state machine can split these layers into +separate modules when that makes each layer easier to review. + +### Lead with the law + +Start with a question or a direct statement of the problem. Explain why a +normal example can miss the failure. State the contract before introducing test +mechanics. + +Put the central law beside the model too. The opening comment supplies context. +The local comment lets a reader check the model without searching the file. +These comments are not duplicates when they serve those separate jobs. + +For example: + +```ts +// Publication is the boundary. An abort before publication rejects the load +// and discards the row. An abort after publication starts resolves the load +// and keeps the row visible. +function expectedOutcome(phase: AbortPhase): ExpectedOutcome { + // ... +} +``` + +### Make the model easy to distrust + +Prefer a pure function or a small state transition. Keep production queues, +caches, classifiers, and helpers out of the expected result. A reader should be +able to challenge the model without first learning the implementation. + +Name partial models honestly. If a model predicts rows but not callback counts, +say so. If several outcomes are legal, return the permitted set or relation. Do +not hide policy in scattered assertions outside the model. + +Check every modeled public fact at each relevant boundary. During the FIFO +rewrite, prose exposed that the first draft modeled calls but checked outbox +ownership only at the end. Moving both observations into one state model made +the test and the documentation agree. + +### Decompose the model by law + +A reference model can become as hard to trust as production. Do not keep adding +state until it becomes a second implementation of the subsystem. + +Treat the oracle as a small graph when the contract has independent laws: + +- A node owns one coherent rule and the least state needed to predict it. +- An edge records a real dependency between two rules. +- The production driver can feed the same action to several nodes. +- The refinement check composes their observations at the named checkpoint. + +For example, demand generations, facade identity, and callback coherence can +use separate models. They share actions, but they do not need one controller +that reproduces every production queue and cache. A failure then names the law +that diverged. A reviewer can also inspect each rule without learning the rest +of the subsystem. + +Do not split state that a legal next action can observe only as a whole. Use the +distinguishing-history test: if two states look equal to the proposed nodes, +can one legal next action produce different promised results? If yes, add the +missing edge or keep that state in one model. + +Decomposition does not mean one model per assertion. Group facts that form one +state machine. Keep independent policies separate. When an integrated promise +spans several nodes, add a relational check across their outputs instead of +merging their internal machinery. + +This graph is a reasoning tool, not a required test framework. Plain functions +and Maps are often enough. + +### Treat the input domain as a grammar + +Before choosing arbitraries, describe the language of legal cases. A useful +grammar separates three kinds of fact: + +- **Dynamics:** actions and transformations that move the system. +- **Constraints:** invariants, interfaces, and forbidden combinations. +- **Boundary conditions:** starting state, scale, provider behavior, and value + domains that limit where the rules apply. + +Freeze the contract first. Mark which rules come from an API or architecture +document and which rules the test author inferred. Then map containment, +overlap, and dependency. Do not force overlapping concerns into a tree merely +to make the generator neat. + +Use four controls before trusting the grammar: + +1. **Reconstruction:** Can the grammar rebuild every known valid witness? +2. **Ablation:** Does removing each axis, rule, or overlap lose a promised case + or admit a forbidden one? +3. **Range:** Does an independent marginal case require only new parameter + values, or does it expose a missing rule? +4. **Exclusion:** Can the grammar reject a nearby invalid history or state? + +Generate adjacent valid forms from this grammar. Do not take a flat Cartesian +product of unrelated axes merely because the tool makes that easy. When the +contract does require a product, add a calibration assertion that proves every +declared cell appears once. + +This approach exposes two common false greens. A grammar that cannot reconstruct +a known bug omits a path. A grammar that generates valid and invalid histories +without distinction makes skips and classifiers carry hidden policy. + +### Use controlled technical English + +Write prose that another agent can parse without asking what a term means: + +- Use active voice and short sentences. +- Give one state or event one name. Do not rotate synonyms. +- Define necessary domain terms when they first appear. +- Keep `must`, `may`, and `does not` exact. They state different contracts. +- Use a list for three or more phases, actions, or conditions. +- Avoid metaphors when a boundary or transition has a precise name. +- Explain laws, causes, omissions, and checkpoints. Do not narrate clear code. + +Aim for no more than 25 words in a descriptive sentence. Keep a longer sentence +when splitting it would lose a condition or change its force. Clarity is the +goal. A low word count is not. + +### Use production vocabulary + +The model and production code must use the same term for the same concept. Read +the [project glossary](glossary.md) and the subsystem architecture before naming +model states, actions, or observations. + +Shared vocabulary does not weaken model independence. Reuse a production term, +not its queue, cache, transition helper, or semantic implementation. If the +model deliberately combines or splits production concepts, declare that mapping +beside the model. Never give a model-only convenience the name of a stronger +production concept. + +Check the causal grammar as well as the nouns. Demand starts an acquisition +attempt. Adapter acceptance establishes a physical acquisition and its lease. +Committing source writes produces an applied receipt. Publication exposes one +coherent public snapshot. Adapter acceptance, promise settlement, applied +settlement, and publication are different boundaries even when one synchronous +execution crosses all four. + +Avoid unqualified words that hide those distinctions. Name a `sync run`, +`public snapshot`, `logical subset owner`, or `window-operation generation` +rather than a generic session, state, owner, or generation. When production +renames a concept, update its glossary entry and the models that represent it in +the same change. + +### Audit prose, model, driver, and observations together + +Treat the layers as separate claims. Compare each pair during review: + +1. Does every promise in the prose have a model rule? +2. Does the model retain every distinction that a legal next action can expose? +3. Can the history grammar reach each modeled transition? +4. Does the driver exercise the named production path and event boundary? +5. Does each modeled output reach an assertion at the promised checkpoint? +6. Can the recorder represent duplicate, missing, reordered, or partial output? +7. Does any assertion impose behavior that the contract and model do not state? +8. Do prose, model, driver, and production use the glossary's canonical term + and transition grammar for each shared concept? +9. Does every model-only term declare how it maps to production, or that it has + no production counterpart? + +This comparison is a useful audit instrument. If the prose cannot explain an +assertion through the model, the model may be incomplete. If the model predicts +a fact that the test never observes, the test may be false green. If the driver +cannot create a named phase, the prose claims more reach than the test has. + +### Keep the prose proportional + +Do not turn every regression test into an essay. Use this form when a file owns +a reusable law, state machine, lifecycle boundary, or reference model. A focused +test can remain short when its name and setup already state the whole contract. + +As a starting budget, add only prose that helps a reader answer one of the five +layer questions. After the first draft, remove comments that only translate the +next line of code into English. Run the test after the rewrite. A documentation +edit that weakens the executable law changes behavior. + ## Build one small oracle We'll build a small reference model as one example of the approach. The following is an **illustrative contract**, not a specification for every TanStack query: @@ -131,6 +340,35 @@ Shrinking means reducing a failing input or action list while keeping its failur Pin the structural cases that matter: no rows, width zero, a boundary tie, and repeated changes to one key. Exhaust a small domain where that is cheap. Then randomize values and longer legal histories. Fixed cases, bounded enumeration and random exploration do different jobs; overlap between them is not a defect. +### Run fixed and random seed lanes + +A fixed fast-check seed always generates the same cases. This makes a useful +history stable, but it does not explore new histories on later runs. Do not +describe a fixed-seed property as random coverage. + +Run important properties in two lanes: + +1. Run a fixed seed that preserves a known useful campaign. +2. Run without a seed so fast-check chooses a new seed. + +When the random lane fails, retain the reported seed and shrink path. Give the +suite environment variables or another checked replay interface when practical. +The replay input must select both the seed and the path. A seed alone reruns the +campaign but might not stop at the same reduced counterexample. + +The fixed and random lanes should use the same property, generators, +observations, and run budget. Only their seed source should differ. This keeps a +random failure eligible for promotion into a pinned example or fixed campaign. + +[`fifo-retry.property.test.ts`](../../packages/offline-transactions/tests/fifo-retry.property.test.ts) +shows this shape. Its fixed lane preserves one scheduler campaign. Its second +lane uses a random seed by default and accepts `OFFLINE_ORACLE_SEED` with +`OFFLINE_ORACLE_PATH` for replay. + +Do not use a larger random run count as a substitute for structural reach. +Pinned examples force rare boundaries. Bounded enumeration proves small finite +domains. A stress job increases sampling depth. Record each form separately. + ### Make the comparison prove its usefulness Suppose the update test only asserts that the result changed. Returning `[]` passes that assertion. Keep the update—it is a useful cause—but compare against `[1, 2]`. @@ -193,7 +431,7 @@ An example from the archive makes the risk concrete: a finite model represented Before changing production to satisfy a red test, check the contract, representation and comparison. A genuinely unresolved product choice belongs with its owner. A missing value in a finite model belongs in the model. References—including newly written specifications—can be wrong. -Review the accusation and suggested fix separately. In one recorded adapter episode, returning a Promise transferred a lease even when it later rejected. Clearing ownership on rejection looked like cleanup but reportedly leaked the release obligation. The concern was useful; that repair did not follow from it. Other adapters may use different acquisition rules. +Review the accusation and suggested fix separately. In one recorded adapter episode, returning a Promise created an acquisition lease even when it later rejected. Clearing ownership on rejection looked like cleanup but reportedly leaked that release obligation. The concern was useful; that repair did not follow from it. Other adapters may use different acquisition rules. Projection deserves the same care. Removing internal metadata from a public-value comparison can be valid. Removing a contractual virtual field because it is called “metadata” cannot. Write down one difference the comparison may ignore and one it must retain. @@ -383,4 +621,3 @@ For a new oracle or a claimed repair, ask: 7. Which larger promises remain outside this test, and where are they tracked? The payoff is not a bigger test framework. It is a smaller distance between “this test is green” and a precise account of what that green result protects. - diff --git a/docs/guides/collection-options-creator.md b/docs/guides/collection-options-creator.md index 8ad914acc6..9efc82899e 100644 --- a/docs/guides/collection-options-creator.md +++ b/docs/guides/collection-options-creator.md @@ -65,9 +65,13 @@ interface MyCollectionConfig ### 2. Sync Implementation -The sync function is the heart of your collection. It must: +Each call to the sync function starts a **sync run**. The run owns the callbacks +and resources installed by that call until its returned cleanup ends them. A +sync run may make several backend requests or open a longer-lived provider +session, so it is not itself a request or provider session. -The sync function must return a cleanup function for proper garbage collection: +The sync function is the heart of your collection. It must return a cleanup +function for proper garbage collection: ```typescript const sync: SyncConfig['sync'] = (params) => { diff --git a/docs/guides/error-handling.md b/docs/guides/error-handling.md index 0beb5fc8ce..7a3020f1c3 100644 --- a/docs/guides/error-handling.md +++ b/docs/guides/error-handling.md @@ -576,14 +576,16 @@ await collection.cleanup() // Resolves successfully ### Collection Cleanup and Restart -Clean up collections in error states: +Cleanup ends the current sync run and releases the resources installed by its +`sync()` call. A later access can start a new sync run. Clean up collections in +error states like this: ```ts if (todoCollection.status === "error") { - // Cleanup will stop sync and reset the collection + // Cleanup ends the current sync run and resets the collection await todoCollection.cleanup() - // Collection will automatically restart on next access + // The next access starts a new sync run todoCollection.preload() // Or any other operation } ``` diff --git a/docs/reference/classes/CollectionImpl.md b/docs/reference/classes/CollectionImpl.md index 888e17566f..15f2abb321 100644 --- a/docs/reference/classes/CollectionImpl.md +++ b/docs/reference/classes/CollectionImpl.md @@ -502,7 +502,7 @@ Defined in: [packages/db/src/collection/index.ts:1122](https://github.com/TanSta Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection Cleanup callbacks must not restart this collection or call its preload(). -Wait until cleanup completes before starting a new sync session. +Wait until cleanup completes before starting a new sync run. #### Returns diff --git a/docs/reference/interfaces/Collection.md b/docs/reference/interfaces/Collection.md index 5585c24e85..72f6896ab8 100644 --- a/docs/reference/interfaces/Collection.md +++ b/docs/reference/interfaces/Collection.md @@ -593,7 +593,7 @@ Defined in: [packages/db/src/collection/index.ts:1122](https://github.com/TanSta Clean up the collection by stopping sync and clearing data This can be called manually or automatically by garbage collection Cleanup callbacks must not restart this collection or call its preload(). -Wait until cleanup completes before starting a new sync session. +Wait until cleanup completes before starting a new sync run. #### Returns diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts index df405daaf3..44671cfda5 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts @@ -7,6 +7,25 @@ import type { BrowserOPFSWorkerResponse, } from '../src/opfs-worker-protocol' +/** + * # Who owns an OPFS worker when the browser page leaves? + * + * A database request, pagehide, worker response, terminal worker event, close, + * and bfcache admission can race. Every accepted request must settle once. + * Non-persisted pagehide closes and terminates the worker after pending work; + * persisted pagehide keeps the database eligible for restoration. Listener and + * worker resources release exactly once even when init or close fails. + * + * Controlled page and worker objects implement only the browser event and + * message grammar. Generated products vary pending request kind, delivery + * stage, persisted flag, execute count, release direction, and terminal event. + * The driver observes exact requests, settlements, errors, listener counts, + * held responses, close, and termination against this state machine. + * + * This proves page/worker ownership in jsdom. It does not prove native OPFS + * locking, real bfcache admission, or browser process teardown. + */ + type Database = Awaited> type RequestKind = BrowserOPFSWorkerRequest[`type`] type DeliveryStage = diff --git a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts index 9156bf86d5..2d254c4fef 100644 --- a/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts +++ b/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts @@ -5,6 +5,19 @@ import type { BrowserOPFSWorkerResponse, } from '../src/opfs-worker-protocol' +/** + * # Does an OPFS worker retain the useful cause of initialization failure? + * + * Opening may fail with an Error or TypeError while the VFS exposes a separate + * Error or DOMException cause. The worker response must preserve the primary + * message and, when present, the cause name and message in stable order. + * + * A tiny string-concatenation model supplies the expected diagnostic. Generated + * error classes and messages drive the real worker module through mocked + * wa-sqlite and OPFS boundaries. Exact response comparison catches a dropped, + * reordered, or replaced cause without claiming native lock behavior. + */ + type DiagnosticInput = { primaryKind: `Error` | `TypeError` primaryMessage: string diff --git a/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts b/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts index cdd47088b5..6ccc6436f5 100644 --- a/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts +++ b/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts @@ -1,6 +1,14 @@ -/** Literal required shared laws, reviewed independently of the native registry. - * The nine output-only label changes retain the original 113-law portfolio. - * Query-only boundaries, hooks, moves and unsupported capabilities are not members. +/** + * # Which shared Collection laws must every persisted adapter execute? + * + * This literal manifest is independent of each host's test registry. It names + * the 113 predicate, pagination, join, deduplication, collation, mutation, and + * live-update laws that persisted adapters promise. Registration checks compare + * native suites with this list so a renamed, skipped, or missing test is visible. + * + * Query-only boundaries, framework hooks, moves, progressive sync, and other + * unsupported capabilities are excluded deliberately. A matching manifest + * proves suite registration, not native execution. */ export const persistedConformanceLaws = [ 'Predicates Suite > Equality Operators > should filter with eq() on string field', diff --git a/packages/db-ivm/tests/hash-failure-retry.property.test.ts b/packages/db-ivm/tests/hash-failure-retry.property.test.ts index 0d260d8fb0..2fe0e5f1a0 100644 --- a/packages/db-ivm/tests/hash-failure-retry.property.test.ts +++ b/packages/db-ivm/tests/hash-failure-retry.property.test.ts @@ -2,6 +2,17 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +/** + * A failed structural traversal must publish no reusable hash state. + * + * The model is a left-to-right row of observable getters. One getter rejects + * for several attempts, then succeeds. Each failure may read only the prefix + * through that getter; a later retry must read that prefix again, finish the + * untouched tail, and agree with a plain structural copy. Only the successful + * traversal may make a later call read nothing. This separates atomic cache + * publication from cycle, depth, and work-limit rejection. + */ + type History = { completed: number tail: number diff --git a/packages/db-ivm/tests/hash-graph.property.test.ts b/packages/db-ivm/tests/hash-graph.property.test.ts index f9fa09f9ba..140da9a9fd 100644 --- a/packages/db-ivm/tests/hash-graph.property.test.ts +++ b/packages/db-ivm/tests/hash-graph.property.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +/** + * Structural hash follows the reachable value graph, not object identity. + * + * A small adjacency list is the model. Kahn's algorithm independently decides + * whether the root-reachable graph is acyclic. Acyclic sharing is unfolded into + * equal fresh trees and must hash the same; a reachable cycle must reject on + * every attempt, while an unreachable cycle is irrelevant. Named witnesses + * keep rare topology classes present even when random generation misses them. + */ + // Kahn's algorithm checks the reachable graph without using the hasher's // recursive active-path algorithm. Unreachable cycles do not affect the root. function isAcyclic(edges: Array>): boolean { diff --git a/packages/db-ivm/tests/hash-mixed-graph.property.test.ts b/packages/db-ivm/tests/hash-mixed-graph.property.test.ts index 9ee6abfbd8..cd39f3e913 100644 --- a/packages/db-ivm/tests/hash-mixed-graph.property.test.ts +++ b/packages/db-ivm/tests/hash-mixed-graph.property.test.ts @@ -2,6 +2,16 @@ import { describe, expect, it } from 'vitest' import { fc } from '@fast-check/vitest' import { hash } from '../src/hashing/hash' +/** + * Mixed-carrier graphs extend the graph law across every recursive container. + * + * The abstract graph stores only labels, targets, and edge-carrier names. + * Separate construction turns each edge into an object field, array slot, Map + * key/value, Set member, or symbol-keyed field. Reachability and acyclicity are + * still judged on the abstract graph. Thus a carrier bug cannot be copied into + * the oracle's cycle decision, and equal unfolded DAGs remain the value model. + */ + type Carrier = `object` | `array` | `map-key` | `map-value` | `set` | `symbol` type Edge = Readonly<{ target: number; carrier: Carrier }> type Node = Readonly<{ label: number; edges: ReadonlyArray }> diff --git a/packages/db-ivm/tests/hash.property.test.ts b/packages/db-ivm/tests/hash.property.test.ts index c57da3a954..66f386fad7 100644 --- a/packages/db-ivm/tests/hash.property.test.ts +++ b/packages/db-ivm/tests/hash.property.test.ts @@ -7,14 +7,18 @@ const nativeSession = await captureHashSession() const { hash } = nativeSession /** - * Property-based tests for hash function + * The hash is a deterministic fingerprint of its declared value domain. * - * Key properties: - * 1. Determinism: hash(x) always returns the same value - * 2. Structural equality: equal structures should have the same hash - * 3. Property order independence: objects with same properties in different order have same hash - * 4. Number normalization: -0 and 0 have same hash, NaN has consistent hash - * 5. Type markers: different types should generally produce different hashes + * Independent constructors build equivalent values with different allocation, + * property order, container order, and normalized numeric representations. + * They must agree. Deliberately different type/value pairs must disagree only + * where the contract promises separation; a sampled 32-bit collision alone is + * not a defect. `HashSession` records environment and calls so a failure can be + * replayed without turning random frequency into a semantic claim. + * + * Graph reachability, mixed carriers, failed traversal, and retry atomicity are + * separate owners. Keeping them separate makes this file's flat-value model + * small enough to inspect. */ // Arbitraries for generating test values diff --git a/packages/db-ivm/tests/incrementalization-law.property.test.ts b/packages/db-ivm/tests/incrementalization-law.property.test.ts index fc1116c89a..68a5af04ec 100644 --- a/packages/db-ivm/tests/incrementalization-law.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law.property.test.ts @@ -27,13 +27,68 @@ import { } from './incrementalization-law.js' import type { Weighted } from './incrementalization-law.js' +/** + * # Does incremental execution equal full recomputation? + * + * D2 operators keep state and emit only changes. A wrong delta can leave the + * final rows looking right after a later batch, so final-state examples are not + * enough. This suite compares every emitted delta and retained result with a + * direct recomputation from the complete logical input. + * + * The plain evaluators below define the expected relations for reductions, + * joins, groups, and ordered windows. They use arrays and Maps, not D2 + * operators. The shared checker then delivers each generated logical change as + * one atomic batch and as legal one-row steps. + * + * The generated domain uses small JSON tuples with integer weights. Named + * cases force empty batches, duplicate weights, replacements, cancellation, + * presence changes, boundary ties, and a zero-width window. Fault controls + * prove that the checker rejects missing, sign-flipped, and wrong-member output. + */ + type Keyed = [number, number] type JoinOutput = [number, [number, number]] type OuterJoinOutput = [number, [number | null, number | null]] type GroupedOutput = [string, { bucket: number; total: number }] -const SEED = Number(process.env.TANSTACK_DB_IVM_ORACLE_SEED ?? 1741) +const FIXED_SEED = 1741 +const replaySeedText = process.env.TANSTACK_DB_IVM_ORACLE_SEED +const replaySeed = + replaySeedText === undefined ? undefined : Number(replaySeedText) const RUNS = Number(process.env.TANSTACK_DB_IVM_ORACLE_RUNS ?? 100) +const REPLAY_PATH = process.env.TANSTACK_DB_IVM_ORACLE_PATH +if ( + replaySeedText !== undefined && + (replaySeedText.trim() === `` || !Number.isSafeInteger(replaySeed)) +) { + throw new Error(`TANSTACK_DB_IVM_ORACLE_SEED must be an integer`) +} +if (!Number.isSafeInteger(RUNS) || RUNS <= 0) { + throw new Error(`TANSTACK_DB_IVM_ORACLE_RUNS must be a positive integer`) +} +if (REPLAY_PATH !== undefined && replaySeed === undefined) { + throw new Error( + `TANSTACK_DB_IVM_ORACLE_PATH requires TANSTACK_DB_IVM_ORACLE_SEED`, + ) +} + +const generatedCampaigns = [ + { name: `fixed`, seed: FIXED_SEED, path: undefined }, + { + name: replaySeed === undefined ? `random` : `replay`, + seed: replaySeed, + path: REPLAY_PATH, + }, +] as const + +function campaignParameters(campaign: (typeof generatedCampaigns)[number]) { + return { + numRuns: RUNS, + ...(campaign.seed === undefined ? {} : { seed: campaign.seed }), + ...(campaign.path === undefined ? {} : { path: campaign.path }), + } +} + const CANCELLATION_ROW: Keyed = [99, 99] const keyedPolicy = jsonLawValuePolicy() const joinPolicy = jsonLawValuePolicy() @@ -43,18 +98,14 @@ const uniqueRowSplitDomain = { uniqueKey: ([key]: Keyed) => String(key), } -/* -Law/source: DBSP incrementalization, as scoped by issue #1741. -Domain: finite JSON tuples with integer weights; batches preserve nonnegative -logical input states and split lanes preserve that law after every delivery. -Reference/path/checkpoint: full recomputation is independent of each D2 -operator implementation and is compared with exact emitted deltas and retained -output after every logical batch. -Observed: weighted relation membership and multiplicity. Plain orderBy exposes -selected membership, not sequence indices. Publication and SQL meaning are out -of scope. Fixed cells witness named structural paths; fault controls challenge -missing/sign-flipped/wrong-member output. FastCheck reports replay seed/path. -*/ +/** + * The model observes relation membership and multiplicity. Plain `orderBy` + * exposes selected members, not their sequence indices. Publication timing and + * SQL meaning are separate contracts. + * + * The fixed generated lane preserves one stable campaign. The second lane uses + * a new seed unless replay variables select a prior seed and shrink path. + */ const weightedWorld = weightedStateArbitrary( fc.tuple(fc.integer({ min: 0, max: 3 }), fc.integer({ min: -3, max: 3 })), @@ -223,219 +274,229 @@ function firstTwoPerParity(input: Weighted): Weighted { } describe(`DBSP incrementalization laws`, () => { - it(`checks consolidate, reduce, and grouped top-K against full recomputation`, async () => { - await fc.assert( - fc.asyncProperty( - fc.array(weightedWorld, { minLength: 2, maxLength: 7 }), - async (worlds) => { - // Yield between complete histories so stress runs can report progress. - // Graph delivery and every observation within a history stay synchronous. - await setImmediate() - const batches = transitions(worlds) - assertUnaryIncrementalization({ - name: `consolidate`, - initial: worlds[0]!, - batches, - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => input.pipe(consolidate()), - evaluate: keyedIdentity, - }) - assertUnaryIncrementalization({ - name: `reduce`, - initial: worlds[0]!, - batches, - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe( - reduce((values) => { - if (values.length === 0) return [] - return [ - [ - values.reduce( - (sum, [value, weight]) => sum + value * weight, - 0, - ), - 1, - ], - ] - }), - ), - evaluate: summed, - }) - assertUnaryIncrementalization({ - name: `groupBy reduction`, - initial: worlds[0]!, - batches, - inputPolicy: keyedPolicy, - outputPolicy: groupedPolicy, - build: (input) => - input.pipe( - groupBy(([key]) => ({ bucket: key % 2 }), { - total: groupByOperators.sum(([, value]) => value), - }), - ), - evaluate: groupedSums, - }) - assertUnaryIncrementalization({ - name: `top-K`, - initial: worlds[0]!, - batches, - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (input) => - input.pipe(topK((left, right) => left - right, { limit: 2 })), - evaluate: topTwo, - }) - }, - ), - { seed: SEED, numRuns: RUNS }, - ) - }) + it.each(generatedCampaigns)( + `checks consolidate, reduce, and grouped top-K against full recomputation ($name campaign)`, + async (campaign) => { + await fc.assert( + fc.asyncProperty( + fc.array(weightedWorld, { minLength: 2, maxLength: 7 }), + async (worlds) => { + // Yield between complete histories so stress runs can report progress. + // Graph delivery and every observation within a history stay synchronous. + await setImmediate() + const batches = transitions(worlds) + assertUnaryIncrementalization({ + name: `consolidate`, + initial: worlds[0]!, + batches, + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => input.pipe(consolidate()), + evaluate: keyedIdentity, + }) + assertUnaryIncrementalization({ + name: `reduce`, + initial: worlds[0]!, + batches, + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe( + reduce((values) => { + if (values.length === 0) return [] + return [ + [ + values.reduce( + (sum, [value, weight]) => sum + value * weight, + 0, + ), + 1, + ], + ] + }), + ), + evaluate: summed, + }) + assertUnaryIncrementalization({ + name: `groupBy reduction`, + initial: worlds[0]!, + batches, + inputPolicy: keyedPolicy, + outputPolicy: groupedPolicy, + build: (input) => + input.pipe( + groupBy(([key]) => ({ bucket: key % 2 }), { + total: groupByOperators.sum(([, value]) => value), + }), + ), + evaluate: groupedSums, + }) + assertUnaryIncrementalization({ + name: `top-K`, + initial: worlds[0]!, + batches, + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (input) => + input.pipe(topK((left, right) => left - right, { limit: 2 })), + evaluate: topTwo, + }) + }, + ), + campaignParameters(campaign), + ) + }, + ) - it(`checks simultaneous binary join deltas and split delivery`, async () => { - await fc.assert( - fc.asyncProperty( - fc.array(fc.tuple(weightedWorld, weightedWorld), { - minLength: 2, - maxLength: 7, - }), - async (generated) => { - await setImmediate() - const worlds = generated.map(([left, right]) => ({ left, right })) - const batches = worlds.slice(1).map((next, index) => ({ - left: weightedDifference( - worlds[index]!.left, - next.left, - keyedPolicy, - CANCELLATION_ROW, - ), - right: weightedDifference( - worlds[index]!.right, - next.right, - keyedPolicy, - CANCELLATION_ROW, - ), - })) - assertBinaryIncrementalization({ - name: `inner join`, - initialLeft: worlds[0]!.left, - initialRight: worlds[0]!.right, - batches, - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: joinPolicy, - build: (left, right) => left.pipe(innerJoin(right)), - evaluate: joined, - }) - assertBinaryIncrementalization({ - name: `full outer join`, - initialLeft: worlds[0]!.left, - initialRight: worlds[0]!.right, - batches, - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: outerJoinPolicy, - build: (left, right) => left.pipe(fullJoin(right)), - evaluate: fullJoined, - }) - assertBinaryIncrementalization({ - name: `left outer join`, - initialLeft: worlds[0]!.left, - initialRight: worlds[0]!.right, - batches, - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: outerJoinPolicy, - build: (left, right) => left.pipe(leftJoin(right)), - evaluate: leftJoined, - }) - assertBinaryIncrementalization({ - name: `right outer join`, - initialLeft: worlds[0]!.left, - initialRight: worlds[0]!.right, - batches, - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: outerJoinPolicy, - build: (left, right) => left.pipe(rightJoin(right)), - evaluate: rightJoined, - }) - assertBinaryIncrementalization({ - name: `join then grouped reduction`, - initialLeft: worlds[0]!.left, - initialRight: worlds[0]!.right, - batches, - leftPolicy: keyedPolicy, - rightPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - build: (left, right) => - left.pipe( - innerJoin(right), - map(([key, [leftValue, rightValue]]) => [ - key, - leftValue * rightValue, - ]), - reduce((values) => { - if (values.length === 0) return [] - return [ - [ - values.reduce( - (sum, [value, weight]) => sum + value * weight, - 0, - ), - 1, - ], - ] - }), + it.each(generatedCampaigns)( + `checks simultaneous binary join deltas and split delivery ($name campaign)`, + async (campaign) => { + await fc.assert( + fc.asyncProperty( + fc.array(fc.tuple(weightedWorld, weightedWorld), { + minLength: 2, + maxLength: 7, + }), + async (generated) => { + await setImmediate() + const worlds = generated.map(([left, right]) => ({ left, right })) + const batches = worlds.slice(1).map((next, index) => ({ + left: weightedDifference( + worlds[index]!.left, + next.left, + keyedPolicy, + CANCELLATION_ROW, ), - evaluate: joinedSums, - }) - }, - ), - { seed: SEED, numRuns: RUNS }, - ) - }) - - it(`checks global ordering and window membership`, async () => { - await fc.assert( - fc.asyncProperty( - fc.array(orderedWorld, { minLength: 2, maxLength: 7 }), - async (worlds) => { - await setImmediate() - assertUnaryIncrementalization({ - name: `orderBy`, - initial: worlds[0]!, - batches: transitions(worlds), - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => input.pipe(orderBy((rank) => rank, { limit: 3 })), - evaluate: firstThree, - }) - assertUnaryIncrementalization({ - name: `grouped orderBy`, - initial: worlds[0]!, - batches: transitions(worlds), - inputPolicy: keyedPolicy, - outputPolicy: keyedPolicy, - splitDomain: uniqueRowSplitDomain, - build: (input) => - input.pipe( - groupedOrderByWithFractionalIndex((rank) => rank, { - groupKeyFn: (key) => key % 2, - limit: 2, - }), - map(([key, [rank]]) => [key, rank] as Keyed), + right: weightedDifference( + worlds[index]!.right, + next.right, + keyedPolicy, + CANCELLATION_ROW, ), - evaluate: firstTwoPerParity, - }) - }, - ), - { seed: SEED, numRuns: RUNS }, - ) - }) + })) + assertBinaryIncrementalization({ + name: `inner join`, + initialLeft: worlds[0]!.left, + initialRight: worlds[0]!.right, + batches, + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: joinPolicy, + build: (left, right) => left.pipe(innerJoin(right)), + evaluate: joined, + }) + assertBinaryIncrementalization({ + name: `full outer join`, + initialLeft: worlds[0]!.left, + initialRight: worlds[0]!.right, + batches, + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: outerJoinPolicy, + build: (left, right) => left.pipe(fullJoin(right)), + evaluate: fullJoined, + }) + assertBinaryIncrementalization({ + name: `left outer join`, + initialLeft: worlds[0]!.left, + initialRight: worlds[0]!.right, + batches, + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: outerJoinPolicy, + build: (left, right) => left.pipe(leftJoin(right)), + evaluate: leftJoined, + }) + assertBinaryIncrementalization({ + name: `right outer join`, + initialLeft: worlds[0]!.left, + initialRight: worlds[0]!.right, + batches, + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: outerJoinPolicy, + build: (left, right) => left.pipe(rightJoin(right)), + evaluate: rightJoined, + }) + assertBinaryIncrementalization({ + name: `join then grouped reduction`, + initialLeft: worlds[0]!.left, + initialRight: worlds[0]!.right, + batches, + leftPolicy: keyedPolicy, + rightPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + build: (left, right) => + left.pipe( + innerJoin(right), + map(([key, [leftValue, rightValue]]) => [ + key, + leftValue * rightValue, + ]), + reduce((values) => { + if (values.length === 0) return [] + return [ + [ + values.reduce( + (sum, [value, weight]) => sum + value * weight, + 0, + ), + 1, + ], + ] + }), + ), + evaluate: joinedSums, + }) + }, + ), + campaignParameters(campaign), + ) + }, + ) + + it.each(generatedCampaigns)( + `checks global ordering and window membership ($name campaign)`, + async (campaign) => { + await fc.assert( + fc.asyncProperty( + fc.array(orderedWorld, { minLength: 2, maxLength: 7 }), + async (worlds) => { + await setImmediate() + assertUnaryIncrementalization({ + name: `orderBy`, + initial: worlds[0]!, + batches: transitions(worlds), + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe(orderBy((rank) => rank, { limit: 3 })), + evaluate: firstThree, + }) + assertUnaryIncrementalization({ + name: `grouped orderBy`, + initial: worlds[0]!, + batches: transitions(worlds), + inputPolicy: keyedPolicy, + outputPolicy: keyedPolicy, + splitDomain: uniqueRowSplitDomain, + build: (input) => + input.pipe( + groupedOrderByWithFractionalIndex((rank) => rank, { + groupKeyFn: (key) => key % 2, + limit: 2, + }), + map(([key, [rank]]) => [key, rank] as Keyed), + ), + evaluate: firstTwoPerParity, + }) + }, + ), + campaignParameters(campaign), + ) + }, + ) it(`replays empty, duplicate, replacement, cancellation, and presence-flip cells`, () => { const unaryReach = assertUnaryIncrementalization({ diff --git a/packages/db-ivm/tests/incrementalization-law.ts b/packages/db-ivm/tests/incrementalization-law.ts index 339d364b48..c6784312a1 100644 --- a/packages/db-ivm/tests/incrementalization-law.ts +++ b/packages/db-ivm/tests/incrementalization-law.ts @@ -5,6 +5,32 @@ import { output } from '../src/operators/output.js' import type { IStreamBuilder } from '../src/types.js' import type { Arbitrary } from 'fast-check' +/** + * # How does the checker judge an incremental operator? + * + * A relation is a set of values with signed integer weights. A positive weight + * adds copies. A negative weight removes copies. For a query `Q`, a change from + * state `x` to state `y` must emit the weighted difference `Q(y) - Q(x)`. + * + * The caller supplies two independent parts: + * + * 1. `build` constructs the production D2 graph. + * 2. `evaluate` computes the complete expected relation from plain data. + * + * The checker applies each logical batch in two ways. The atomic run delivers + * the batch in one graph step. The split run delivers the same change as legal + * one-row steps. After each logical batch, both runs must satisfy three laws: + * + * 1. The emitted delta equals the difference between full recomputations. + * 2. The retained output equals the new full recomputation. + * 3. Atomic and split delivery end with the same retained output. + * + * Split delivery does not invent invalid intermediate input states. It orders + * unit changes so weights stay nonnegative. A caller can also declare a unique + * row key so a replacement retracts the occupied row before it inserts a new + * row with the same key. + */ + export type Weighted = Array<[T, number]> type StateEntry = { value: T; weight: number } diff --git a/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts b/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts index 6321b212e3..4f84f002a0 100644 --- a/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from 'vitest' import fc from 'fast-check' import { TopKRelation } from './topk-relation-oracle.js' +/** + * # How do we know the top-K oracle can detect a fault? + * + * These tests calibrate the oracle. They do not test the production top-K + * operator. Each test gives the relation a valid history, then introduces one + * wrong change or expectation. The checker must reject that fault. + * + * The controls cover duplicate weight, missing retraction, wrong index, wrong + * payload, negative residue, shared-object mutation, and wrong numeric offset. + * The first control uses a fixed seed because it tests shrink and replay of one + * deliberate failure. It is not a random production campaign. + */ + describe(`Signed top-K observation controls`, () => { it(`shrinks and replays duplicate-weight detection through the same checker`, () => { const property = fc.property(fc.integer({ min: 10, max: 100 }), (id) => { diff --git a/packages/db-ivm/tests/operators/topk-relation-oracle.ts b/packages/db-ivm/tests/operators/topk-relation-oracle.ts index dc16585889..09e0bee8a0 100644 --- a/packages/db-ivm/tests/operators/topk-relation-oracle.ts +++ b/packages/db-ivm/tests/operators/topk-relation-oracle.ts @@ -2,14 +2,28 @@ import { expect } from 'vitest' import { MessageTracker } from '../test-utils.js' import type { MultiSet } from '../../src/multiset.js' +/** + * # What does the signed top-K oracle remember? + * + * A top-K operator emits signed changes. A positive weight inserts one entry. + * A negative weight retracts one matching entry. The oracle adds these weights + * across all messages and keeps nonzero residue. + * + * A valid visible result has weight one for every live entry. Numeric indexes + * must equal the requested offset and position. Fractional string indexes are + * opaque, so the oracle checks only their strict order. + * + * This model covers finite scalar keys, scalar indexes, and rows with numeric + * IDs and string values. It does not define identity for rich or cyclic values. + */ + type Row = { id: number; value: string } type Entry = [K, [Row, I]] type Expected = Array<[K, number, string]> -/** Independent signed relation for the finite scalar, unit-live-row tests. - * Unlike the work tracker, it never resets, drops negatives or merges weight 2 - * into one row. Exact fractional tokens are not an expected-result authority. - */ +// Identity includes every scalar field that can distinguish two modeled +// entries. A zero weight removes an entry. Every other weight stays visible to +// the checker, including negative and duplicate residue. export class TopKRelation< K extends string | number | null, I extends number | string, @@ -19,7 +33,7 @@ export class TopKRelation< add(messages: Array<[Entry, number]>) { for (const [[key, [row, index]], weight] of messages) { expect(Number.isInteger(weight)).toBe(true) - // This finite-scalar oracle must not silently serialize NaN as null. + // JSON encodes NaN as null, so reject it outside the model's domain. if (typeof key === `number`) expect(Number.isFinite(key)).toBe(true) const identity = JSON.stringify([key, row.id, row.value, index]) const next = (this.entries.get(identity)?.weight ?? 0) + weight @@ -56,8 +70,8 @@ export class TopKRelation< } } -// Keep existing resettable transfer-work observations. Only the separate -// semantic relation persists through reset; it uses no MultiSet consolidation. +// MessageTracker measures transfer work and can reset. The semantic relation +// must persist across that reset so later checks still include earlier changes. export class TopKMessageTracker< K extends string | number | null, I extends number | string, diff --git a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts index 064971db38..2fe4d2fae7 100644 --- a/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts +++ b/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts @@ -12,6 +12,21 @@ import { import { topK, topKWithIndex } from '../../src/operators/topK.js' import { TopKRelation } from './topk-relation-oracle.js' +/** + * A top-K window selects support from a signed, typed-key relation. + * + * `TopKRelation` is the independent relation model: integrate each weight by + * exact key/value identity, discard zero support, sort by the declared order, + * then slice offset/limit. Generated histories change both multiplicity and + * window position after each graph turn. Array, grouped, BTree, indexed, and + * fractional-index operators must expose the same selected support even though + * their move messages and internal indexes differ. + * + * The driver checks accumulated output and reported size at every cut. This is + * stronger than comparing the final rows, which would miss a bad retract, + * duplicate multiplicity, or a window move that later happened to self-heal. + */ + const keys = [1, `1`, 2, `2`, `a`] as const type Key = (typeof keys)[number] type Row = { id: number; value: string } diff --git a/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts index 907065634a..699e7b232c 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/persisted-collection-conformance-contract.ts @@ -8,6 +8,16 @@ import { createPaginationTestSuite } from '../../../db-collection-e2e/src/suites import { createPredicatesTestSuite } from '../../../db-collection-e2e/src/suites/predicates.suite' import type { E2ETestConfig } from '../../../db-collection-e2e/src/types' +/** + * Shared semantic portfolio for a native persisted-collection host. + * + * The imported suites own predicates, pagination, joins, deduplication, + * collation, mutations, and live-update laws. A host supplies only a real + * platform harness and explicit capability exclusions. Registering this + * function proves no native behavior by itself; credit belongs to a completed + * host run with its actual driver, storage, and lifecycle. + */ + export type PersistedCollectionConformanceGetConfig = () => Promise diff --git a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts index d45b9ef613..cae17dd008 100644 --- a/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts +++ b/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts @@ -2,6 +2,21 @@ import { describe, expect, it } from 'vitest' import { expectAdmissionHistory } from './driver-admission-laws' import type { SQLiteDriver } from '../../src' +/** + * # What must every SQLite persistence driver mean? + * + * Drivers bind parameters, return exact query rows, roll back failed + * transactions, serialize outside work behind an active transaction, admit it + * after rollback, and use savepoints for nesting. The callback must receive the + * transaction-scoped driver so implementations cannot silently run nested work + * on the outer connection. + * + * Each platform supplies only a harness. This shared suite executes the same + * SQL histories and compares both values and rowid admission order. A passing + * shim proves the TypeScript driver boundary; it does not replace native-device + * evidence for the host runtime. + */ + export type SQLiteDriverContractHarness = { driver: SQLiteDriver cleanup: () => void | Promise diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 0895e52375..313cdad860 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -29,6 +29,27 @@ import type { } from '../src' import type { LoadSubsetOptions, SyncConfig } from '@tanstack/db' +/** + * # Does persisted wrapping preserve one Collection history? + * + * Persistence adds a durable replica beneath an optional upstream sync source. + * Startup hydrates rows and metadata, buffers concurrent remote work, then + * publishes one coherent state. Committed transactions persist in sequence; + * gaps recover through deltas or reload. Subset demands keep local and upstream + * ownership separate so cancellation, release, offline mode, and retry cannot + * steal a sibling acquisition lease. + * + * The recording adapter is a plain durable-state model: Maps for rows and + * metadata plus ordered transaction, index, load, and reload calls. Tests drive + * the real wrapper, Collection, coordinator, receipts, transactions, indexes, + * cleanup, and restart. They compare durable state, public rows, metadata, + * request options, sequence evidence, errors, and late-work fencing. + * + * Driver SQL behavior, browser page ownership, native runtimes, and the shared + * conformance portfolio have separate owners. This file models persistence + * protocol state, not a particular SQLite engine. + */ + type Todo = { id: string title: string diff --git a/packages/db/package.json b/packages/db/package.json index 400431147f..1b9c8a8e8a 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -22,7 +22,7 @@ "lint": "eslint . --fix", "test": "vitest --run", "test:facade-retention": "node --expose-gc --import tsx tests/facade-retention.probe.ts", - "test:oracles": "vitest --run tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts", + "test:oracles": "vitest --run --coverage.enabled=false tests/collection-mutation-startup-oracle.test.ts tests/collection-cleanup-restart-oracle.test.ts tests/effect-disposal-oracle.test.ts tests/optimistic-transaction-oracle.property.test.ts tests/optimistic-settlement-boundaries.test.ts tests/optimistic-history-publication.test.ts tests/optimistic-history-outcomes.test.ts tests/collection-metadata-publication-oracle.property.test.ts tests/collection-state-retention-oracle.property.test.ts tests/collection-subscription-lifecycle-history.property.test.ts tests/collection-subscription-lifecycle-oracle.test.ts tests/collection-subscription-lifecycle-publication.property.test.ts tests/collection-subscription-replay-oracle.property.test.ts tests/d2-source-reconciliation-oracle.property.test.ts tests/live-query-observer-history.property.test.ts tests/query/cold-join-reconciliation-oracle.test.ts tests/query/includes-collection-oracle.property.test.ts tests/query/includes-functional-projection-oracle.test.ts tests/query/includes-functional-input-boundary.test.ts tests/query/includes-context-transport-oracle.test.ts tests/query/includes-cross-formulation-oracle.property.test.ts tests/query/includes-optimistic-oracle.property.test.ts tests/query/includes-oracle.property.test.ts tests/query/includes-publication-oracle.test.ts tests/query/includes-query-shape-oracle.test.ts tests/query/includes-temporal-oracle.test.ts tests/query/includes-work-counter-oracle.test.ts tests/query/load-subset-oracle.property.test.ts tests/query/load-subset-replay-refinement-oracle.test.ts tests/query/load-subset-source-readiness-refinement-oracle.test.ts tests/query/load-subset-transaction-refinement-oracle.test.ts tests/query/ordered-source-loader-state.test.ts tests/query/ordered-demand-retirement.test.ts tests/query/ordered-default-work.test.ts tests/query/ordered-lifecycle-oracle.property.test.ts tests/query/ordered-work-oracle.property.test.ts tests/query/pagination-oracle.property.test.ts tests/query/includes-space-oracle.test.ts", "bench:nested-includes": "vitest bench tests/query/includes-performance.bench.ts --run" }, "type": "module", diff --git a/packages/db/skills/db-core/custom-adapter/SKILL.md b/packages/db/skills/db-core/custom-adapter/SKILL.md index 212f3662c2..022485e31b 100644 --- a/packages/db/skills/db-core/custom-adapter/SKILL.md +++ b/packages/db/skills/db-core/custom-adapter/SKILL.md @@ -9,8 +9,8 @@ description: > (where, orderBy, limit, offset, cursor). Expression parsing: parseWhereExpression, parseOrderByExpression, extractSimpleComparisons, parseLoadSubsetOptions. Collection options creator - pattern. rowUpdateMode (partial vs full). Subscription lifecycle and cleanup - functions. Persisted sync metadata API (metadata.row and metadata.collection) + pattern. rowUpdateMode (partial vs full). Sync run, subscription lifecycle, + and cleanup functions. Persisted sync metadata API (metadata.row and metadata.collection) for storing per-row and per-collection adapter state. type: sub-skill library: db @@ -24,6 +24,12 @@ This skill builds on db-core and db-core/collection-setup. Read those first. # Custom Adapter Authoring +Each call to an adapter's `sync()` function starts a **sync run**. The run owns +the callbacks and resources installed by that call until its returned cleanup +ends them. A sync run may make several backend requests or open a longer-lived +provider session, so do not use “request” or “session” as a synonym for the +run. + ## Setup ```ts @@ -344,7 +350,9 @@ If initial sync fails before it produces a usable snapshot, call `markError(error)` instead. This rejects readiness waits with the supplied cause and moves dependent live queries to the error state. Calling `markError()` without a cause remains supported and rejects with a generic collection-state -error. A later successful sync can call `markReady()` to recover. +error. Later successful work in the same sync run can call `markReady()` to +recover. Cleanup ends that run; a restart invokes `sync()` again and starts a +new one. Source: docs/guides/collection-options-creator.md diff --git a/packages/db/src/collection/index.ts b/packages/db/src/collection/index.ts index 354da9941b..16e7a4428c 100644 --- a/packages/db/src/collection/index.ts +++ b/packages/db/src/collection/index.ts @@ -1144,7 +1144,7 @@ export class CollectionImpl< * Clean up the collection by stopping sync and clearing data * This can be called manually or automatically by garbage collection * Cleanup callbacks must not restart this collection or call its preload(). - * Wait until cleanup completes before starting a new sync session. + * Wait until cleanup completes before starting a new sync run. */ public async cleanup(): Promise { this._lifecycle.cleanup() diff --git a/packages/db/src/collection/lifecycle.ts b/packages/db/src/collection/lifecycle.ts index 505a6ede19..2a48e4c9dc 100644 --- a/packages/db/src/collection/lifecycle.ts +++ b/packages/db/src/collection/lifecycle.ts @@ -217,7 +217,7 @@ export class CollectionLifecycleManager< this.setStatus(`error`) } - /** Return the cause supplied by the current sync session, if any. */ + /** Return the cause supplied by the current sync run, if any. */ public getSyncError(): unknown { return this.syncError } diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 5da7b8aa2b..0555a0bad3 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -143,7 +143,7 @@ export class CollectionStateManager< public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false private isDrainingSyncTransactions = false - private syncSessionGeneration = 0 + private syncRunGeneration = 0 public isLocalOnly = false /** @@ -930,7 +930,7 @@ export class CollectionStateManager< processed: boolean failure?: { error: unknown } } { - const syncSessionGeneration = this.syncSessionGeneration + const syncRunGeneration = this.syncRunGeneration // Check if there are any persisting transaction let hasPersistingTransaction = false for (const transaction of this.transactions.values()) { @@ -1511,11 +1511,11 @@ export class CollectionStateManager< failure = { error } } - if (this.syncSessionGeneration === syncSessionGeneration) { + if (this.syncRunGeneration === syncRunGeneration) { this.preSyncVisibleState.clear() this.preSyncVirtualState.clear() Promise.resolve().then(() => { - if (this.syncSessionGeneration === syncSessionGeneration) { + if (this.syncRunGeneration === syncRunGeneration) { this.recentlySyncedKeys.clear() } }) @@ -1660,7 +1660,7 @@ export class CollectionStateManager< * This can be called manually or automatically by garbage collection */ public cleanup(): void { - this.syncSessionGeneration++ + this.syncRunGeneration++ for (const transaction of this.pendingSyncedTransactions) { transaction.applied.reject(new SyncTransactionAbortedError()) } diff --git a/packages/db/src/collection/subscription.ts b/packages/db/src/collection/subscription.ts index 8d4e0489ad..5faf36d444 100644 --- a/packages/db/src/collection/subscription.ts +++ b/packages/db/src/collection/subscription.ts @@ -87,9 +87,10 @@ type TruncatePublicationState = { lastSentKey: string | number | undefined } -type SubsetAcquisition = { +/** Internal record spanning a tentative attempt and any accepted acquisition. */ +type SubsetAcquisitionRecord = { options: LoadSubsetOptions - loadSubsetSession: number + syncRunGeneration: number abortController?: AbortController removeRequestAbortListener?: () => void releaseAttempted?: true @@ -97,7 +98,7 @@ type SubsetAcquisition = { type SubsetDemand = { requestOptions: LoadSubsetOptions - acquisition: SubsetAcquisition + acquisition: SubsetAcquisitionRecord acquisitionState: `starting` | `active` | `detached` initialResult?: Deferred } @@ -107,8 +108,8 @@ type TruncateReplayAttempt = { setupComplete: boolean } -type TruncateReplaySession = { - loadSubsetSession: number +type TruncateReplayState = { + syncRunGeneration: number publicationState: TruncatePublicationState /** Direct subscribers buffer the replacement here; delegated publication has no buffer. */ privateRows: Map | undefined @@ -125,7 +126,7 @@ function createReplayCompletion(): Deferred { return completion } -function cancelAcquisition(acquisition: SubsetAcquisition): void { +function cancelAcquisition(acquisition: SubsetAcquisitionRecord): void { acquisition.abortController?.abort() acquisition.removeRequestAbortListener?.() } @@ -185,9 +186,9 @@ export class CollectionSubscription private collectionCleanup: (() => void) | undefined private collectionRestartCleanup: (() => void) | undefined - // One replay session owns the publication baseline, overlapping attempts, + // One replay state owns the publication baseline, overlapping attempts, // and buffered changes until every attempt settles. - private truncateReplaySession: TruncateReplaySession | undefined + private truncateReplayState: TruncateReplayState | undefined private readonly loadSubsetPromiseErrors = new WeakMap< Promise, Error @@ -252,8 +253,8 @@ export class CollectionSubscription `status:change`, ({ status }) => { if (status !== `loading` && status !== `ready`) return - const loadSubsetSession = this.collection._sync.getLoadSubsetSession() - const replaySession = this.truncateReplaySession + const syncRunGeneration = this.collection._sync.getSyncRunGeneration() + const replayState = this.truncateReplayState if ( this.subsetDemands.some( (demand) => demand.acquisitionState === `detached`, @@ -262,15 +263,15 @@ export class CollectionSubscription this.setStatus(`loadingSubset`) } queueMicrotask(() => { - if (this.truncateReplaySession === replaySession) { - this.restartDetachedDemands(loadSubsetSession) + if (this.truncateReplayState === replayState) { + this.restartDetachedDemands(syncRunGeneration) } }) }, ) } - /** Detach logical demand from work owned by a discarded sync session. */ + /** Detach logical demand from work owned by a discarded sync run. */ private handleCollectionCleanup(): void { this.discardTruncateReplay() this.stalePublishedRows = new Map(this.publishedRows) @@ -286,7 +287,7 @@ export class CollectionSubscription demand.acquisitionState = `detached` demand.acquisition = { options: demand.requestOptions, - loadSubsetSession: demand.acquisition.loadSubsetSession, + syncRunGeneration: demand.acquisition.syncRunGeneration, } } } @@ -294,10 +295,10 @@ export class CollectionSubscription } /** Acquire detached demand after startup or initial-error recovery. */ - private restartDetachedDemands(loadSubsetSession: number): void { + private restartDetachedDemands(syncRunGeneration: number): void { if ( this.unsubscribed || - !this.isLoadSubsetSessionCurrent(loadSubsetSession) + !this.isSyncRunGenerationCurrent(syncRunGeneration) ) { return } @@ -318,23 +319,26 @@ export class CollectionSubscription return } - const session = this.createTruncateReplaySession(loadSubsetSession, () => { - const currentRows = this.collection.currentStateAsChanges({ - optimizedOnly: false, - }) - return new Map( - // The API returns void for unavailable snapshots, not just undefined. - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - (currentRows ?? []) - .filter((change) => change.type !== `delete`) - .map((change) => [change.key, change.value]), - ) - }) - const attempt = session.currentAttempt - this.truncateReplaySession = session + const replayState = this.createTruncateReplayState( + syncRunGeneration, + () => { + const currentRows = this.collection.currentStateAsChanges({ + optimizedOnly: false, + }) + return new Map( + // The API returns void for unavailable snapshots, not just undefined. + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + (currentRows ?? []) + .filter((change) => change.type !== `delete`) + .map((change) => [change.key, change.value]), + ) + }, + ) + const attempt = replayState.currentAttempt + this.truncateReplayState = replayState this.setStatus(`loadingSubset`) - if (this.truncateReplaySession !== session) return - this.startTruncateReplayAttempt(session, attempt, demands) + if (this.truncateReplayState !== replayState) return + this.startTruncateReplayAttempt(replayState, attempt, demands) } /** @@ -357,28 +361,28 @@ export class CollectionSubscription return } - let session = this.truncateReplaySession - if (session) { - if (!session.completion.isPending()) { - session.completion = createReplayCompletion() + let replayState = this.truncateReplayState + if (replayState) { + if (!replayState.completion.isPending()) { + replayState.completion = createReplayCompletion() } // Setup itself holds publication: adapter/status callbacks may reenter // before a request returns its promise and joins the pending set. - session.pendingSetups++ - session.failures.clear() - session.currentAttempt = { pendingCount: 0, setupComplete: false } + replayState.pendingSetups++ + replayState.failures.clear() + replayState.currentAttempt = { pendingCount: 0, setupComplete: false } } else { // Every overlapping attempt shares one publication baseline and buffer. - session = this.createTruncateReplaySession( - this.collection._sync.getLoadSubsetSession(), + replayState = this.createTruncateReplayState( + this.collection._sync.getSyncRunGeneration(), () => new Map(this.publishedRows), ) - this.truncateReplaySession = session + this.truncateReplayState = replayState } - const attempt = session.currentAttempt + const attempt = replayState.currentAttempt this.setStatus(`loadingSubset`) - if (this.truncateReplaySession !== session) return + if (this.truncateReplayState !== replayState) return if (this.options.truncateReplayPublication) { this.truncateReplacementPending = true @@ -386,7 +390,7 @@ export class CollectionSubscription } // A newer replay replaces every prior acquisition for these demands. Abort - // the old work before it can install rows into the new generation. + // the old work before it can install rows into the replacement replay. for (const demand of demandsToReload) { demand.acquisition.abortController?.abort() } @@ -396,49 +400,49 @@ export class CollectionSubscription // either replaces them or proves they are absent. this.resetSnapshotTracking() - // Defer the requests so the truncate commit's deletes enter the session + // Defer the requests so the truncate commit's deletes enter the replay state // buffer before a synchronous adapter can publish replacement rows. queueMicrotask(() => { - if (this.truncateReplaySession !== session) return - if (!this.isLoadSubsetSessionCurrent(session.loadSubsetSession)) { - this.retireStaleTruncateReplay(session) + if (this.truncateReplayState !== replayState) return + if (!this.isSyncRunGenerationCurrent(replayState.syncRunGeneration)) { + this.retireStaleTruncateReplay(replayState) return } // A newer truncate that arrived before this attempt began source work // already captured the active demands. Starting them now would place the // obsolete acquisition outside the newer abort sweep. this.startTruncateReplayAttempt( - session, + replayState, attempt, - session.currentAttempt === attempt ? demandsToReload : [], + replayState.currentAttempt === attempt ? demandsToReload : [], ) }) } /** Make tentative replay ownership visible before adapter code can reenter. */ private startTruncateReplayDemand( - session: TruncateReplaySession, + replayState: TruncateReplayState, attempt: TruncateReplayAttempt, demand: SubsetDemand, ): void { const isCurrentAttempt = () => - this.truncateReplaySession === session && - session.currentAttempt === attempt + this.truncateReplayState === replayState && + replayState.currentAttempt === attempt const isCurrent = () => isCurrentAttempt() && - this.isLoadSubsetSessionCurrent(session.loadSubsetSession) && + this.isSyncRunGenerationCurrent(replayState.syncRunGeneration) && this.isDemandActive(demand) const fail = (error: unknown) => { - if (isCurrent()) session.failures.set(demand, normalizeError(error)) + if (isCurrent()) replayState.failures.set(demand, normalizeError(error)) } if (demand.initialResult) { - void session.completion.promise.then( + void replayState.completion.promise.then( demand.initialResult.resolve, demand.initialResult.reject, ) } - // Sequential handoff: retire the old physical lease while retaining its + // Sequential handoff: retire the old acquisition lease while retaining its // logical demand. Callback reentry cannot release that lease twice. const previous = demand.acquisition const hadPreviousAcquisition = demand.acquisitionState === `active` @@ -453,7 +457,7 @@ export class CollectionSubscription } if (!isCurrent() || demand.requestOptions.signal?.aborted) return - const next = this.createSubsetAcquisition(demand) + const next = this.createSubsetAcquisitionRecord(demand) demand.acquisition = next demand.acquisitionState = `starting` let result: LoadSubsetRequestResult @@ -477,7 +481,7 @@ export class CollectionSubscription } demand.acquisitionState = `active` - this.trackTruncateReplayParticipant(session, attempt, demand, result) + this.trackTruncateReplayParticipant(replayState, attempt, demand, result) this.observeLoadSubsetResult( result, demand, @@ -488,17 +492,17 @@ export class CollectionSubscription } private settleTruncateReplay( - session: TruncateReplaySession, + replayState: TruncateReplayState, pending: { demand: SubsetDemand; attempt: TruncateReplayAttempt }, ): void { try { - if (this.truncateReplaySession !== session) return - if (!this.isLoadSubsetSessionCurrent(session.loadSubsetSession)) { - this.retireStaleTruncateReplay(session) + if (this.truncateReplayState !== replayState) return + if (!this.isSyncRunGenerationCurrent(replayState.syncRunGeneration)) { + this.retireStaleTruncateReplay(replayState) return } - if (session.pending.delete(pending)) pending.attempt.pendingCount-- - this.checkTruncateReplayComplete(session) + if (replayState.pending.delete(pending)) pending.attempt.pendingCount-- + this.checkTruncateReplayComplete(replayState) } catch (error) { // Replay settlement runs from a Promise callback, so throwing here would // create an unobserved derived rejection. Surface subscriber errors like @@ -511,14 +515,14 @@ export class CollectionSubscription /** Keep every acquisition begun during recovery inside its publication barrier. */ private trackTruncateReplayParticipant( - session: TruncateReplaySession, + replayState: TruncateReplayState, attempt: TruncateReplayAttempt, demand: SubsetDemand, result: LoadSubsetRequestResult, ): void { if ( - this.truncateReplaySession !== session || - (session.currentAttempt !== attempt && + this.truncateReplayState !== replayState || + (replayState.currentAttempt !== attempt && attempt.setupComplete && attempt.pendingCount === 0) || !(result instanceof Promise) @@ -531,52 +535,52 @@ export class CollectionSubscription // promises still get one participant per logical acquisition. const pending = { demand, attempt } attempt.pendingCount++ - session.pending.add(pending) + replayState.pending.add(pending) void result.then( - () => this.settleTruncateReplay(session, pending), + () => this.settleTruncateReplay(replayState, pending), (error: unknown) => { // A released demand no longer participates in this replacement. Its // cooperative AbortError must not discard rows from active demands. if ( - this.truncateReplaySession === session && - session.currentAttempt === attempt && - this.isLoadSubsetSessionCurrent(session.loadSubsetSession) && + this.truncateReplayState === replayState && + replayState.currentAttempt === attempt && + this.isSyncRunGenerationCurrent(replayState.syncRunGeneration) && this.subsetDemands.includes(demand) ) { const normalized = this.normalizeLoadSubsetPromiseError(result, error) - session.failures.set(demand, normalized) + replayState.failures.set(demand, normalized) } - this.settleTruncateReplay(session, pending) + this.settleTruncateReplay(replayState, pending) }, ) } /** Stop obsolete logical demand from pinning a replay barrier. */ private removeTruncateReplayParticipant(demand: SubsetDemand): void { - const session = this.truncateReplaySession - if (!session) return - session.failures.delete(demand) - for (const pending of session.pending) { + const replayState = this.truncateReplayState + if (!replayState) return + replayState.failures.delete(demand) + for (const pending of replayState.pending) { if (pending.demand === demand) { - session.pending.delete(pending) + replayState.pending.delete(pending) pending.attempt.pendingCount-- } } } /** Publish only after every overlapping replay attempt has settled. */ - private checkTruncateReplayComplete(session: TruncateReplaySession): void { - if (this.truncateReplaySession !== session) return - if (session.pendingSetups > 0 || session.pending.size > 0) return + private checkTruncateReplayComplete(replayState: TruncateReplayState): void { + if (this.truncateReplayState !== replayState) return + if (replayState.pendingSetups > 0 || replayState.pending.size > 0) return - const activeFailure = [...session.failures].find(([demand]) => + const activeFailure = [...replayState.failures].find(([demand]) => this.subsetDemands.includes(demand), ) try { if (activeFailure) { - this.abandonTruncateReplay(session, activeFailure[1]) + this.abandonTruncateReplay(replayState, activeFailure[1]) } else { - this.flushTruncateReplay(session) + this.flushTruncateReplay(replayState) } } finally { this.setReadyIfIdle() @@ -588,16 +592,16 @@ export class CollectionSubscription * state, so only a later successful truncate replay may reopen publication. */ private abandonTruncateReplay( - session: TruncateReplaySession, + replayState: TruncateReplayState, failure: Error, ): void { - if (this.truncateReplaySession !== session) return - session.completion.reject(failure) + if (this.truncateReplayState !== replayState) return + replayState.completion.reject(failure) // Delegated publication already delivered its rows. Only a private buffer // returns the caller's pagination position to the public snapshot; the // private rows and their sent-key tracking stay together for a retry. - if (!session.privateRows) return - const publicationState = session.publicationState + if (!replayState.privateRows) return + const publicationState = replayState.publicationState this.loadedInitialState = publicationState.loadedInitialState this.snapshotSent = publicationState.snapshotSent this.limitedSnapshotRowCount = publicationState.limitedSnapshotRowCount @@ -605,13 +609,13 @@ export class CollectionSubscription } /** Publish the buffered replacement as one batch, or release the delegate. */ - private flushTruncateReplay(session: TruncateReplaySession): void { - if (this.truncateReplaySession !== session) return - this.truncateReplaySession = undefined + private flushTruncateReplay(replayState: TruncateReplayState): void { + if (this.truncateReplayState !== replayState) return + this.truncateReplayState = undefined this.truncateReplacementPending = false // Retained rows the source never re-delivered leave the replacement. - const { privateRows } = session + const { privateRows } = replayState for (const key of this.stalePublishedRows.keys()) privateRows?.delete(key) this.stalePublishedRows.clear() try { @@ -626,7 +630,7 @@ export class CollectionSubscription } finally { // Restore tracking even when a subscriber rejects the replacement. this.restorePublishedSnapshotTracking() - session.completion.resolve() + replayState.completion.resolve() this.options.truncateReplayPublication?.succeed() } } @@ -647,7 +651,7 @@ export class CollectionSubscription private bufferPrivately( changes: ReadonlyArray>, ): boolean { - const privateRows = this.truncateReplaySession?.privateRows + const privateRows = this.truncateReplayState?.privateRows if (!privateRows) return false for (const change of changes) { if (change.type === `delete`) privateRows.delete(change.key) @@ -685,13 +689,14 @@ export class CollectionSubscription } private get isBufferingForTruncate(): boolean { - return this.truncateReplaySession !== undefined + return this.truncateReplayState !== undefined } private setReadyIfIdle(): void { - const session = this.truncateReplaySession + const replayState = this.truncateReplayState const hasPendingReplayWork = - session && (session.pendingSetups > 0 || session.pending.size > 0) + replayState && + (replayState.pendingSetups > 0 || replayState.pending.size > 0) if ( this.pendingLoadSubsetParticipants.size === 0 && !hasPendingReplayWork @@ -700,23 +705,23 @@ export class CollectionSubscription } } - private isLoadSubsetSessionCurrent(session: number): boolean { - return session === this.collection._sync.getLoadSubsetSession() + private isSyncRunGenerationCurrent(syncRunGeneration: number): boolean { + return syncRunGeneration === this.collection._sync.getSyncRunGeneration() } - private retireStaleTruncateReplay(session: TruncateReplaySession): void { - if (this.truncateReplaySession !== session) return + private retireStaleTruncateReplay(replayState: TruncateReplayState): void { + if (this.truncateReplayState !== replayState) return this.discardTruncateReplay() this.stalePublishedRows.clear() } /** Drop the replay without publishing; an unfinished wait rejects as aborted. */ private discardTruncateReplay(): void { - const session = this.truncateReplaySession - if (session?.completion.isPending()) { - session.completion.reject(new LoadSubsetOperationAbortedError()) + const replayState = this.truncateReplayState + if (replayState?.completion.isPending()) { + replayState.completion.reject(new LoadSubsetOperationAbortedError()) } - this.truncateReplaySession = undefined + this.truncateReplayState = undefined this.truncateReplacementPending = false } @@ -727,13 +732,13 @@ export class CollectionSubscription this.lastSentKey = undefined } - /** One replay session; only direct subscribers buffer a private replacement. */ - private createTruncateReplaySession( - loadSubsetSession: number, + /** One replay state; only direct subscribers buffer a private replacement. */ + private createTruncateReplayState( + syncRunGeneration: number, privateRows: () => Map, - ): TruncateReplaySession { + ): TruncateReplayState { return { - loadSubsetSession, + syncRunGeneration, publicationState: { loadedInitialState: this.loadedInitialState, snapshotSent: this.snapshotSent, @@ -755,23 +760,23 @@ export class CollectionSubscription /** Start one attempt's demands, then release the setup hold on publication. */ private startTruncateReplayAttempt( - session: TruncateReplaySession, + replayState: TruncateReplayState, attempt: TruncateReplayAttempt, demands: ReadonlyArray, ): void { for (const demand of demands) { if (!this.subsetDemands.includes(demand)) continue - this.startTruncateReplayDemand(session, attempt, demand) + this.startTruncateReplayDemand(replayState, attempt, demand) if ( - this.truncateReplaySession !== session || - session.currentAttempt !== attempt + this.truncateReplayState !== replayState || + replayState.currentAttempt !== attempt ) { break } } attempt.setupComplete = true - session.pendingSetups-- - this.checkTruncateReplayComplete(session) + replayState.pendingSetups-- + this.checkTruncateReplayComplete(replayState) } public get hasPendingTruncateReplacement(): boolean { @@ -779,12 +784,12 @@ export class CollectionSubscription } public get pendingTruncateReplacement(): Promise | undefined { - const completion = this.truncateReplaySession?.completion + const completion = this.truncateReplayState?.completion return completion?.isPending() ? completion.promise : undefined } public get hasFailedTruncateReplacement(): boolean { - const completion = this.truncateReplaySession?.completion + const completion = this.truncateReplayState?.completion return ( this.truncateReplacementPending && completion !== undefined && @@ -849,7 +854,7 @@ export class CollectionSubscription ): void { if (!(syncResult instanceof Promise)) return - const loadSubsetSession = this.collection._sync.getLoadSubsetSession() + const syncRunGeneration = this.collection._sync.getSyncRunGeneration() const participant = { demand, promise: syncResult } if (trackStatus) { @@ -860,7 +865,7 @@ export class CollectionSubscription const finish = () => { if (trackStatus) { this.pendingLoadSubsetParticipants.delete(participant) - if (this.isLoadSubsetSessionCurrent(loadSubsetSession)) { + if (this.isSyncRunGenerationCurrent(syncRunGeneration)) { this.setReadyIfIdle() } } @@ -868,7 +873,7 @@ export class CollectionSubscription void syncResult.then(finish, (error: unknown) => { if ( - this.isLoadSubsetSessionCurrent(loadSubsetSession) && + this.isSyncRunGenerationCurrent(syncRunGeneration) && shouldReportError() ) { this.recordLoadSubsetError( @@ -914,10 +919,10 @@ export class CollectionSubscription } } - /** Create a fresh, abortable adapter acquisition for a replay generation. */ - private createSubsetAcquisition( + /** Create the record for a fresh, abortable acquisition attempt. */ + private createSubsetAcquisitionRecord( demand: SubsetDemand, - ): SubsetAcquisition & { abortController: AbortController } { + ): SubsetAcquisitionRecord & { abortController: AbortController } { const abortController = new AbortController() const requestSignal = demand.requestOptions.signal let removeRequestAbortListener: (() => void) | undefined @@ -936,7 +941,7 @@ export class CollectionSubscription ...demand.requestOptions, signal: abortController.signal, }, - loadSubsetSession: this.collection._sync.getLoadSubsetSession(), + syncRunGeneration: this.collection._sync.getSyncRunGeneration(), abortController, removeRequestAbortListener, } @@ -944,14 +949,14 @@ export class CollectionSubscription /** Retire an acquisition before user code; failed cleanup is not retryable. */ private releaseAcquisition( - acquisition: SubsetAcquisition, + acquisition: SubsetAcquisitionRecord, reportReleaseError = this.primaryFailureDeliveryDepth === 0, ): void { if (acquisition.releaseAttempted) return acquisition.releaseAttempted = true try { acquisition.abortController?.abort() - if (this.isLoadSubsetSessionCurrent(acquisition.loadSubsetSession)) { + if (this.isSyncRunGenerationCurrent(acquisition.syncRunGeneration)) { this.collection._sync.unloadSubset(acquisition.options) } } catch (error) { @@ -978,7 +983,7 @@ export class CollectionSubscription requestOptions, acquisition: { options: requestOptions, - loadSubsetSession: this.collection._sync.getLoadSubsetSession(), + syncRunGeneration: this.collection._sync.getSyncRunGeneration(), }, acquisitionState: `starting`, } @@ -1005,11 +1010,11 @@ export class CollectionSubscription void initialResult.promise.then(finish, finish) return { demand, result: initialResult.promise, started: false } } - const acquisition = this.createSubsetAcquisition(demand) + const acquisition = this.createSubsetAcquisitionRecord(demand) demand.acquisition = acquisition - const replaySession = this.truncateReplaySession - const replayAttempt = replaySession?.currentAttempt - const loadSubsetSession = this.collection._sync.getLoadSubsetSession() + const replayState = this.truncateReplayState + const replayAttempt = replayState?.currentAttempt + const syncRunGeneration = this.collection._sync.getSyncRunGeneration() // Reentrant release must see the exact acquisition before adapter work // starts. A genuine load throw removes this tentative logical owner below. this.subsetDemands.push(demand) @@ -1018,22 +1023,22 @@ export class CollectionSubscription result = this.loadSubset( acquisition.options, () => - this.isLoadSubsetSessionCurrent(loadSubsetSession) && + this.isSyncRunGenerationCurrent(syncRunGeneration) && this.subsetDemands.includes(demand) && - (replaySession === undefined || - (this.truncateReplaySession === replaySession && - replaySession.currentAttempt === replayAttempt)), + (replayState === undefined || + (this.truncateReplayState === replayState && + replayState.currentAttempt === replayAttempt)), ) } catch (error) { const demandIndex = this.subsetDemands.indexOf(demand) if (demandIndex !== -1) { if ( - replaySession && + replayState && replayAttempt && - this.truncateReplaySession === replaySession && - replaySession.currentAttempt === replayAttempt + this.truncateReplayState === replayState && + replayState.currentAttempt === replayAttempt ) { - replaySession.failures.set(demand, normalizeError(error)) + replayState.failures.set(demand, normalizeError(error)) } this.subsetDemands.splice(demandIndex, 1) } @@ -1041,7 +1046,7 @@ export class CollectionSubscription throw error } - if (!this.isLoadSubsetSessionCurrent(loadSubsetSession)) { + if (!this.isSyncRunGenerationCurrent(syncRunGeneration)) { const demandIndex = this.subsetDemands.indexOf(demand) if (demandIndex !== -1) this.subsetDemands.splice(demandIndex, 1) cancelAcquisition(acquisition) @@ -1054,9 +1059,9 @@ export class CollectionSubscription return { demand, result, started: true } } - if (replaySession && replayAttempt) { + if (replayState && replayAttempt) { this.trackTruncateReplayParticipant( - replaySession, + replayState, replayAttempt, demand, result, @@ -1220,7 +1225,7 @@ export class CollectionSubscription // Skip known rows, except retained rows from an abandoned replay: a new // snapshot must reconcile those with the source, not suppress their update. const knownRows = - this.truncateReplaySession?.privateRows ?? this.publishedRows + this.truncateReplayState?.privateRows ?? this.publishedRows const filteredSnapshot = snapshot.filter( (change) => (!this.isBufferingForTruncate && @@ -1285,7 +1290,7 @@ export class CollectionSubscription ): void { const demand = this.subsetDemands[index] if (!demand) return - const replaySession = this.truncateReplaySession + const replayState = this.truncateReplayState const acquisition = demand.acquisition this.subsetDemands.splice(index, 1) demand.initialResult?.reject(new LoadSubsetOperationAbortedError()) @@ -1300,7 +1305,7 @@ export class CollectionSubscription : []), () => this.retireEmptyReplay(), () => { - if (replaySession) this.checkTruncateReplayComplete(replaySession) + if (replayState) this.checkTruncateReplayComplete(replayState) }, // Ready follows replacement publication, never the delete half of it. () => this.stopDemandStatusParticipants(demand), @@ -1310,7 +1315,7 @@ export class CollectionSubscription /** A replay with no remaining logical demand cannot establish more rows. */ private retireEmptyReplay(): void { - if (this.subsetDemands.length !== 0 || !this.truncateReplaySession) { + if (this.subsetDemands.length !== 0 || !this.truncateReplayState) { return } this.discardTruncateReplay() diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index a2822f0946..d667b4f354 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -74,7 +74,7 @@ export class CollectionSyncManager< private syncStartRequested = false private deferredLoadSubsets: Array = [] private syncEpoch = 0 - private loadSubsetSession = 0 + private syncRunGeneration = 0 /** * Creates a new CollectionSyncManager instance @@ -395,7 +395,7 @@ export class CollectionSyncManager< this.syncStartRequested = false const deferredLoadSubsets = this.deferredLoadSubsets this.deferredLoadSubsets = [] - const loadSubsetSession = this.loadSubsetSession + const syncRunGeneration = this.syncRunGeneration try { if (shouldStart) { @@ -412,7 +412,7 @@ export class CollectionSyncManager< const loadSubset = this.syncLoadSubsetFn try { if ( - loadSubsetSession !== this.loadSubsetSession || + syncRunGeneration !== this.syncRunGeneration || options.signal?.aborted ) { throw new LoadSubsetOperationAbortedError() @@ -782,7 +782,7 @@ export class CollectionSyncManager< * @internal This is for internal coordination (e.g., live-query glue code), not for general use. */ public trackLoadPromise(promise: Promise): void { - const loadSubsetSession = this.loadSubsetSession + const syncRunGeneration = this.syncRunGeneration const loadingStarting = !this.isLoadingSubset this.pendingLoadSubsetPromises.add(promise) this.trackLoadSubsetOperationPromise(promise) @@ -798,7 +798,7 @@ export class CollectionSyncManager< } const finish = () => { - if (loadSubsetSession !== this.loadSubsetSession) return + if (syncRunGeneration !== this.syncRunGeneration) return const loadingEnding = this.pendingLoadSubsetPromises.size === 1 && @@ -819,8 +819,8 @@ export class CollectionSyncManager< } /** @internal Generation fence for subscription-owned async work. */ - public getLoadSubsetSession(): number { - return this.loadSubsetSession + public getSyncRunGeneration(): number { + return this.syncRunGeneration } /** @@ -885,10 +885,10 @@ export class CollectionSyncManager< } public cleanup(): void { - // Invalidate callbacks retained by asynchronous work from this session - // before invoking adapter cleanup or allowing a new session to start. + // Invalidate callbacks retained by asynchronous work from this sync run + // before invoking adapter cleanup or allowing a new sync run to start. const cleanupEpoch = ++this.syncEpoch - this.loadSubsetSession++ + this.syncRunGeneration++ this.rejectPreload?.(new CollectionPreloadAbortedError()) const cleanup = this.syncCleanupFn this.syncCleanupFn = null @@ -898,7 +898,7 @@ export class CollectionSyncManager< cleanup?.() } catch (error) { // Keep failed cleanup retryable, but never overwrite a replacement - // session installed by reentrant adapter code. + // sync run installed by reentrant adapter code. if (this.syncEpoch === cleanupEpoch) this.syncCleanupFn = cleanup // Re-throw in a microtask to surface the error after cleanup completes queueMicrotask(() => { diff --git a/packages/db/src/query/live/ARCHITECTURE.md b/packages/db/src/query/live/ARCHITECTURE.md index c8b9896093..9dd741fcaa 100644 --- a/packages/db/src/query/live/ARCHITECTURE.md +++ b/packages/db/src/query/live/ARCHITECTURE.md @@ -105,17 +105,18 @@ reduction that enforces public-key congruence and multiplicity. These owners cooperate; they are not phases of one exclusive state machine. The detailed loading and publication laws below still apply. -| Owner | Accepts / retires | Does not establish | -| --------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | -| Subscription acquisition | Retires the old physical lease before replay acquisition; installs tentative ownership before adapter callbacks; each lease gets one cleanup attempt | Replay completion or permission to publish | -| OrderedSourceLoader | Tracks request settlement, safe continuation and repair debt; reset discards the cursor, disposal ignores late settlement | Provider exhaustion or acceptance of an imperative window | -| Subscription replay | Counts setup and logical acquisition participants; checks completion after reentrant release callbacks; success releases the source replacement hold | Success of a previously failed window operation | -| Query builder | Tracks ordered publication participants in one sync session and accepts a window only for its operation generation | Physical adapter ownership or cancellation | -| D2 and public Collection boundary | D2 accumulates private result changes; the builder flushes root and child changes when the existing gates allow it | Source completeness merely because graph work drained | - -Session and participant checks precede changes to the builder's ordered failure -state, not just scheduling. An obsolete rejection cannot close a replacement -session's publication gate. Loader-local stale-result guards are separate. +| Owner | Accepts / retires | Does not establish | +| --------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------- | +| Subscription acquisition | Retires the old acquisition lease before replay acquisition; installs tentative ownership before adapter callbacks; each lease gets one cleanup attempt | Replay completion or permission to publish | +| OrderedSourceLoader | Tracks request settlement, safe continuation and repair debt; reset discards the cursor, disposal ignores late settlement | Provider exhaustion or acceptance of an imperative window | +| Subscription replay | Counts setup and logical demand participants; checks completion after reentrant release callbacks; success releases the source replacement hold | Success of a previously failed window operation | +| Query builder | Tracks ordered publication participants in one sync run and accepts a window only for its operation generation | Physical adapter ownership or cancellation | +| D2 and public Collection boundary | D2 accumulates private result changes; the builder flushes root and child changes when the existing gates allow it | Source completeness merely because graph work drained | + +Sync-run and participant checks precede changes to the builder's ordered +failure state, not just scheduling. An obsolete rejection cannot close a +replacement sync run's publication gate. Loader-local stale-result guards are +separate. `hasPendingTruncateReplacement` means publication is still withheld, including after replay failure. `pendingTruncateReplacement` exposes only an unsettled @@ -280,7 +281,7 @@ A failed hash does not publish partial structural cache entries, so retrying the same value cannot bypass a guard. A graph-run failure marks the current live query as errored and preserves the thrown error. It must not continue publishing from a partly advanced graph; -recovery requires a fresh query session. +recovery requires a fresh live-query sync run. Opaque reference-hashed leaves are resolved before structural recursion; their own properties, including self-references, are not traversed. Hash inputs must remain immutable once successfully cached, as with other retained D2 values. @@ -606,7 +607,7 @@ repeated teardown cannot repeat it. Other acquisitions still receive cleanup, and a cleanup failure cannot replace an earlier request failure. Core reports the error but retains no retry debt: a broken adapter can leak external resources if it throws before freeing them. Adapters must make their own cleanup reliable. -Replay replaces physical leases sequentially: detach and release the old lease, +Replay replaces acquisition leases sequentially: detach and release the old lease, then acquire a fresh one only if the logical demand and replay are still current. A release failure fails that replay without starting a replacement. A load throw leaves the logical demand detached; a later authoritative replay can @@ -625,7 +626,7 @@ successful authoritative replacement reconciles the retained public snapshot. This rule also applies when another demand overlaps the released predicate or an independent source write happens to match it. Source deletions during replay stay private until successful publication; failure preserves the last complete -snapshot. Query filters and routes, not request release, decide which retained +snapshot. Query filters and routes, not demand retirement, decide which retained source rows belong in a query result. ### Cleanup, restart, and detached waiters @@ -639,20 +640,20 @@ including from its final `cleaned-up` status event, remains supported. This avoids letting old teardown clear a replacement graph or its source ownership. Collection cleanup detaches surviving logical demand from the discarded sync -session. It aborts that session's physical work and rejects its replay barrier, +run. It aborts that sync run's physical work and rejects its replay barrier, and rejects an unfinished initial preload with `AbortError`. Cleanup never invokes first-ready callbacks; those callbacks belong to the discarded run. -Physical acquisitions belong to the sync session that created them; cleanup +Physical acquisitions belong to the sync run that created them; cleanup retires them instead of sending an old release to a replacement adapter. Unlike individual subset releases, a failed sync adapter cleanup callback remains retryable only while that -retirement is current; it cannot replace a newer session's cleanup callback. +retirement is current; it cannot replace a newer sync run's cleanup callback. Demand requested while the Collection is cleaned up remains detached rather than pretending that a physical acquisition succeeded. When the -Collection starts a new sync session, the subscription enters `loadingSubset` +Collection starts a new sync run, the subscription enters `loadingSubset` before it queues reacquisition, then reacquires all detached demand through a -fresh private publication barrier. Settlements from the old session cannot -publish rows, report errors, or change readiness in the new session. +fresh private publication barrier. Settlements from the old sync run cannot +publish rows, report errors, or change readiness in the new sync run. This is the direct subscription's restart contract, not automatic recovery of a dependent live query. Manually cleaning up a source puts its live queries in @@ -662,7 +663,7 @@ live query itself. This differs from a source truncate, which keeps the live query active behind its replay publication barrier. An initial sync error also leaves newly requested demand detached, even when -the adapter has installed a loader. Same-session `markReady()` resumes that +the adapter has installed a loader. `markReady()` in the same sync run resumes that demand; releasing it before recovery creates no physical acquisition or unload. Queued reacquisition must not retry a failed attempt merely because both loading and ready notifications scheduled it. @@ -674,7 +675,7 @@ its result before the snapshot request returns. This promise waits for the recovery's publication barrier, not just adapter return. Failure rejects it with the replay error; release, external abort, unsubscribe, or another cleanup rejects it with `AbortError`. Later transport settlement cannot change that -outcome. Cleanup may retain logical demand for the next session, but it does +outcome. Cleanup may retain logical demand for the next sync run, but it does not retain the old caller's unfinished wait. Eager collections have no subset reacquisition barrier. After cleanup, their @@ -796,8 +797,8 @@ It also rejects window changes during graph publication, before mutating top-K. A synchronous result callback is provisional until the whole snapshot request returns: a later local read or publication throw fails and retires that acquisition instead of letting its queued success erase the failure. -A later explicit window operation has a new generation and may retry from the -safe source boundary. +A later explicit window operation has a new window-operation generation and +may retry from the safe source boundary. The ordered loader retains one settled loading boundary, independently of live rows sent to D2. It derives invalidation from the existing contribution @@ -866,15 +867,15 @@ after discarding the graph and requested window. That error belongs to the operation even if cleanup precedes registration of its waiter. Cleanup does not retroactively cancel an already completed operation. Window-operation generations stay monotonic across cleanup and restart, so a -late rejection from an abandoned session cannot reset the replacement -session's requested window. +late rejection from an abandoned sync run cannot reset the replacement sync +run's requested window. A window move started during an active source replay waits for that replay and applies only after its replacement is complete. A failed replay rejects the move without advancing the reported window. Replay completion callbacks carry -their sync-session identity and become no-ops after cleanup or restart. +their sync-run identity and become no-ops after cleanup or restart. Cleanup rejects the replay barrier, and therefore every window move waiting on it, with `AbortError`; no waiter may outlive the discarded subscription. -Subscription-owned Promise observers carry the Collection's load-session +Subscription-owned Promise observers carry the Collection's sync-run generation. Cleanup invalidates that generation before adapter teardown, so an obsolete replay cannot publish its private rows, report a late error, or emit a late `ready` transition even when the transport ignores cancellation. @@ -928,7 +929,7 @@ later listener when reentry supersedes it, including an ABA transition back to the same status label. Subscription teardown is a one-shot logical transition: it stops the listener set already being walked, emits no later status, and removes subscriber ownership once. A later `unsubscribe()` is a no-op, including -after a physical subset release failed. +after an acquisition release failed. Failure keeps the last complete result visible and partly replayed source state private for both direct subscribers and query graphs. Ordinary source deltas or snapshot requests do not reopen that gate because they cannot prove the source @@ -956,8 +957,8 @@ collection demand. This project uses a single graph-run order rather than multi-dimensional timely-dataflow frontiers. Do not introduce a general timestamp or frontier -framework unless a source contract proves that the generation and up-to-date -protocol cannot express its ordering. +framework unless a source contract proves that the sync-run generation and +source up-to-date protocol cannot express its ordering. **Initial readiness:** preload is complete when every demand currently reachable from the initial query graph is covered by a settled request. Demand @@ -1092,6 +1093,10 @@ create recursive Collection machinery. ## Glossary +This subsystem glossary extends the shared +[project glossary](../../../../../docs/contributing/glossary.md). Shared terms +keep the meanings defined there. + - **Relation:** an internal weighted multiset maintained by D2, not a public TanStack Collection. - **Weighted delta:** a positive or negative change to a relation row. diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index dd29f95636..9d4e1b4384 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -71,7 +71,7 @@ export type LiveQueryCollectionUtils = UtilsRecord & { } type PendingGraphRun = { - syncSession: number + syncRunGeneration: number loadCallbacks: Set<() => void> } @@ -105,7 +105,7 @@ export class CollectionConfigBuilder< private isGraphRunning = false - // Current sync session state (set when sync starts, cleared when it stops) + // Current sync run state (set when sync starts, cleared when it stops) // Public for testing purposes (CollectionConfigBuilder is internal, not public API) public currentSyncConfig: | Parameters[`sync`]>[0] @@ -175,7 +175,7 @@ export class CollectionConfigBuilder< private orderedLoadFailed = false // Source replay cannot settle a failed imperative window operation. private windowFailed = false - private syncSession = 0 + private syncRunGeneration = 0 private windowOperationGeneration = 0 // Map of lexical source IDs to optimizable ORDER BY state optimizableOrderByCollections: Record = {} @@ -449,9 +449,9 @@ export class CollectionConfigBuilder< return this.activeWindowOperation?.generation } - scheduleGraphRunForSession(syncSession: number): void { + scheduleGraphRunForSyncRunGeneration(syncRunGeneration: number): void { if ( - syncSession !== this.syncSession || + syncRunGeneration !== this.syncRunGeneration || !this.currentSyncConfig || !this.currentSyncState ) { @@ -475,13 +475,13 @@ export class CollectionConfigBuilder< ) { return } - const syncSession = this.syncSession + const syncRunGeneration = this.syncRunGeneration if (this.pendingOrderedLoads.size === 0) this.orderedLoadFailed = false this.pendingOrderedLoads.add(promise) const finish = (succeeded: boolean) => { - // Admission precedes mutation: cleanup retires this session's participants. + // Admission precedes mutation: cleanup retires this sync run's participants. if ( - syncSession !== this.syncSession || + syncRunGeneration !== this.syncRunGeneration || !this.pendingOrderedLoads.delete(promise) ) { return @@ -526,8 +526,8 @@ export class CollectionConfigBuilder< ) } - getSyncSession(): number { - return this.syncSession + getSyncRunGeneration(): number { + return this.syncRunGeneration } // The callback function is called after the graph has run. @@ -549,19 +549,19 @@ export class CollectionConfigBuilder< // Should only be called when sync is active if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( - `maybeRunGraph called without active sync session. This should not happen.`, + `maybeRunGraph called without active sync run. This should not happen.`, ) } this.isGraphRunning = true try { - const syncSession = this.syncSession + const syncRunGeneration = this.syncRunGeneration const config = this.currentSyncConfig const { begin, commit } = config const syncState = this.currentSyncState - const isCurrentSession = () => - syncSession === this.syncSession && + const isCurrentSyncRun = () => + syncRunGeneration === this.syncRunGeneration && this.currentSyncConfig === config && this.currentSyncState === syncState @@ -578,14 +578,14 @@ export class CollectionConfigBuilder< try { syncState.graph.run() } catch (error) { - if (isCurrentSession()) { + if (isCurrentSyncRun()) { this.transitionToError(`Live query graph failed`, error) } throw error } - if (!isCurrentSession()) return false + if (!isCurrentSyncRun()) return false callback?.() - if (!isCurrentSession()) return false + if (!isCurrentSyncRun()) return false callbackCalled = true } return true @@ -600,7 +600,7 @@ export class CollectionConfigBuilder< // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition if (!callbackCalled) { callback?.() - if (!isCurrentSession()) return + if (!isCurrentSyncRun()) return } // A synchronous loader can write while this graph run is active. Its @@ -612,7 +612,7 @@ export class CollectionConfigBuilder< // change can reach sibling materializations in different graph steps; // flushing between those steps would expose a mixed root snapshot. syncState.flushPendingChanges?.() - if (!isCurrentSession()) return + if (!isCurrentSyncRun()) return // On the initial run, we may need to do an empty commit to ensure that // the collection is initialized @@ -642,7 +642,7 @@ export class CollectionConfigBuilder< * Dependencies are auto-discovered from subscribed live queries, or can be overridden. * Load callbacks are combined when entries merge. * - * Uses the current sync session's config and syncState from instance properties. + * Uses the current sync run's config and syncState from instance properties. * * @param callback - Optional callback to load more data if needed * @param options - Optional scheduling configuration @@ -686,15 +686,15 @@ export class CollectionConfigBuilder< if (!this.currentSyncConfig || !this.currentSyncState) { throw new Error( - `scheduleGraphRun called without active sync session. This should not happen.`, + `scheduleGraphRun called without active sync run. This should not happen.`, ) } // Manage our own state - get or create pending callbacks for this context let pending = contextId ? this.pendingGraphRuns.get(contextId) : undefined - if (!pending || pending.syncSession !== this.syncSession) { + if (!pending || pending.syncRunGeneration !== this.syncRunGeneration) { pending = { - syncSession: this.syncSession, + syncRunGeneration: this.syncRunGeneration, loadCallbacks: new Set(), } if (contextId) { @@ -761,9 +761,9 @@ export class CollectionConfigBuilder< return } - // If sync session has ended, don't execute (graph is finalized, subscriptions cleared) + // If sync run has ended, don't execute (graph is finalized, subscriptions cleared) if ( - pending.syncSession !== this.syncSession || + pending.syncRunGeneration !== this.syncRunGeneration || !this.currentSyncConfig || !this.currentSyncState ) { @@ -781,15 +781,15 @@ export class CollectionConfigBuilder< } private syncFn(config: SyncMethods) { - const syncSession = ++this.syncSession + const syncRunGeneration = ++this.syncRunGeneration // Store reference to the live query collection for error state transitions this.liveQueryCollection = config.collection - // Reset error state from any previous sync session so a restarted sync can become ready again. + // Reset error state from any previous sync run so a restarted sync can become ready again. this.isInErrorState = false this.fatalQueryError = false this.erroredSourceIds.clear() this.lastSubsetError = undefined - // Store config and syncState as instance properties for the duration of this sync session + // Store config and syncState as instance properties for the duration of this sync run this.currentSyncConfig = config const syncState: SyncState = { @@ -802,7 +802,9 @@ export class CollectionConfigBuilder< const teardown = () => { if (tornDown) return tornDown = true - if (this.syncSession === syncSession) this.syncSession++ + if (this.syncRunGeneration === syncRunGeneration) { + this.syncRunGeneration++ + } // Release every source in one attempt; the first failure wins after the // peers finish. Each subscription release is itself one-shot, so the @@ -811,7 +813,7 @@ export class CollectionConfigBuilder< runAllCallbacks(syncState.unsubscribeCallbacks) } finally { syncState.unsubscribeCallbacks.clear() - this.clearSyncSessionState() + this.clearSyncRunState() } } @@ -824,7 +826,7 @@ export class CollectionConfigBuilder< this.currentSyncState = fullSyncState // Listen for scheduler context clears to clean up our pending state - // Re-register on each sync start so the listener is active for the sync session's lifetime + // Re-register on each sync start so the listener is active for the sync run's lifetime this.unsubscribeFromSchedulerClears = transactionScopedScheduler.onClear( (contextId) => { this.clearPendingGraphRun(contextId) @@ -869,10 +871,10 @@ export class CollectionConfigBuilder< return teardown } - private clearSyncSessionState(): void { + private clearSyncRunState(): void { // Late window settlement belongs to the discarded graph, not its restart. this.windowOperationGeneration++ - // Clear current sync session state + // Clear current sync run state this.currentSyncConfig = undefined this.currentSyncState = undefined this.maybeRunGraphFn = undefined @@ -883,7 +885,7 @@ export class CollectionConfigBuilder< this.erroredSourceIds.clear() // Clear all pending graph runs to prevent memory leaks from in-flight transactions - // that may flush after the sync session ends + // that may flush after the sync run ends this.pendingGraphRuns.clear() // Reset caches so a fresh graph/pipeline is compiled on next start diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index 4b1bcb9c72..dd2e0e757e 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -171,7 +171,7 @@ export class CollectionSubscriber< } } // currentSyncState is always defined when subscribe() is called - // (called during sync session setup) + // (called during sync run setup) this.collectionConfigBuilder.currentSyncState!.unsubscribeCallbacks.add( unsubscribe, ) @@ -228,7 +228,7 @@ export class CollectionSubscriber< this.sentToD2Rows, ) // currentSyncState and input are always defined when this method is called - // (only called from active subscriptions during a sync session) + // (only called from active subscriptions during a sync run) const input = this.collectionConfigBuilder.currentSyncState!.inputs[this.sourceId]! const sentChanges = sendChangesToInput(input, reconciledChanges) @@ -365,17 +365,23 @@ export class CollectionSubscriber< private truncateReplayPublicationControl( onStart?: () => void, ): TruncateReplayPublicationControl { - const syncSession = this.collectionConfigBuilder.getSyncSession() + const syncRunGeneration = + this.collectionConfigBuilder.getSyncRunGeneration() return { start: () => { onStart?.() }, succeed: () => { - if (syncSession !== this.collectionConfigBuilder.getSyncSession()) { + if ( + syncRunGeneration !== + this.collectionConfigBuilder.getSyncRunGeneration() + ) { return } this.orderedLoader?.settleFullSourceReplay() - this.collectionConfigBuilder.scheduleGraphRunForSession(syncSession) + this.collectionConfigBuilder.scheduleGraphRunForSyncRunGeneration( + syncRunGeneration, + ) }, } } diff --git a/packages/db/src/query/live/ordered-source-loader.ts b/packages/db/src/query/live/ordered-source-loader.ts index af680ec560..7d54ffe657 100644 --- a/packages/db/src/query/live/ordered-source-loader.ts +++ b/packages/db/src/query/live/ordered-source-loader.ts @@ -28,7 +28,7 @@ export class OrderedSourceLoader { private hasSettledSourceRequest = false private settledSourceBoundary: Record | undefined // Independent of finite success: only full-source success repairs ordering. - private needsFullSourceRecovery = false + private needsFullSourceRepair = false private requesting = false // Retaining a demand does not prove it succeeded. Async failure retains it // (`failed`) for replay; a synchronous startup failure retains nothing. @@ -46,7 +46,7 @@ export class OrderedSourceLoader { | undefined private failedAcquisitions = new Map() private active = true - private generation = 0 + private orderedLoadGeneration = 0 private lastPage: { count: number; boundary: unknown } | undefined private lastPrefixCount: number | undefined private lastBoundary: unknown @@ -120,7 +120,7 @@ export class OrderedSourceLoader { this.cancelRepairRetry() this.repairRetries = 0 // Move ownership to the explicit replacement before releasing the old - // lease. Adapter cleanup may reenter the loader. + // acquisition lease. Adapter cleanup may reenter the loader. if (this.failedRequest) { this.failedRequest.windowOperationGeneration = windowOperationGeneration } @@ -131,7 +131,7 @@ export class OrderedSourceLoader { } if (this.fullSource === `failed`) this.fullSource = `none` else if (this.fullSource !== `none`) return this.pending - if (this.needsFullSourceRecovery || this.info.requiresFullSource) { + if (this.needsFullSourceRepair || this.info.requiresFullSource) { this.loadFullSource(windowOperationGeneration) return this.pending } @@ -150,7 +150,7 @@ export class OrderedSourceLoader { return this.pending } if (!this.info.dataNeeded || this.pending) return this.pending - // A recorded failure always carries recovery debt, so it cannot reach this + // A recorded failure always carries repair debt, so it cannot reach this // finite path; only the first request needs the whole prefix here. let count = Math.max( this.info.dataNeeded(), @@ -210,7 +210,7 @@ export class OrderedSourceLoader { resetCursor(): void { this.cancelRepairRetry() this.repairRetries = 0 - this.generation++ + this.orderedLoadGeneration++ if (this.fullSource === `complete`) this.fullSource = `held` this.pending = undefined this.lastBoundary = undefined @@ -243,10 +243,14 @@ export class OrderedSourceLoader { this.subscription.hasPendingTruncateReplacement ) return - const generation = this.generation + const orderedLoadGeneration = this.orderedLoadGeneration runAllCallbacks( Array.from(this.settledFiniteAcquisitions, ([release, count]) => () => { - if (!this.active || generation !== this.generation) return + if ( + !this.active || + orderedLoadGeneration !== this.orderedLoadGeneration + ) + return if ( this.fullSource !== `complete` && (!prefix || @@ -268,7 +272,7 @@ export class OrderedSourceLoader { invalidateSourceOrdering(): void { this.invalidateCursor() - this.requireFullSourceRecovery() + this.requireFullSourceRepair() } dispose(): void { @@ -346,9 +350,9 @@ export class OrderedSourceLoader { const retryRepair = isFullSource && this.hasSettledSourceRequest && - this.needsFullSourceRecovery && + this.needsFullSourceRepair && windowOperationGeneration === undefined - const generation = this.generation + const orderedLoadGeneration = this.orderedLoadGeneration const complete = (): void => { if (this.pending === tracked) this.pending = undefined if (!this.active) return @@ -357,13 +361,17 @@ export class OrderedSourceLoader { runAllCallbacks([ () => { if (!isFullSource) { - // A replay can replace the physical lease while this older transport - // finishes. Retire its logical owner only outside the replay barrier. + // A replay can replace the acquisition lease while this older + // transport finishes. Retire its logical owner only outside the + // replay barrier. const prefixCount = options?.orderBy && !options.cursor ? options.limit : undefined this.settledFiniteAcquisitions.set(releaseAcquisition, prefixCount) this.retireSettledFiniteAcquisitions() - if (generation === this.generation && prefixCount !== undefined) { + if ( + orderedLoadGeneration === this.orderedLoadGeneration && + prefixCount !== undefined + ) { this.retireSettledFiniteAcquisitions({ release: releaseAcquisition, count: prefixCount, @@ -372,7 +380,7 @@ export class OrderedSourceLoader { } }, () => { - if (generation !== this.generation) return + if (orderedLoadGeneration !== this.orderedLoadGeneration) return // A finite request may finish behind an authoritative repair. It cannot // clear that repair's failure or resume finite refinement around it. if ( @@ -400,7 +408,7 @@ export class OrderedSourceLoader { if (isFullSource) { this.cancelRepairRetry() this.repairRetries = 0 - this.needsFullSourceRecovery = false + this.needsFullSourceRepair = false this.fullSource = `complete` this.retireSettledFiniteAcquisitions() } @@ -422,8 +430,8 @@ export class OrderedSourceLoader { if (!this.active) return // A failed request may already have written only part of its result. // None of those rows is a safe continuation boundary. - this.requireFullSourceRecovery() - if (generation !== this.generation) return + this.requireFullSourceRepair() + if (orderedLoadGeneration !== this.orderedLoadGeneration) return // A failed request proves no full-source coverage. An explicit window // move or later replay may retry it, but an ordinary graph pass must // not start an eager retry loop. @@ -441,7 +449,7 @@ export class OrderedSourceLoader { // pending without retaining every ancestor promise until the final page. this.onResult( tracked, - settlesAsync && isFullSource && this.needsFullSourceRecovery, + settlesAsync && isFullSource && this.needsFullSourceRepair, ) return tracked } @@ -481,9 +489,9 @@ export class OrderedSourceLoader { ) } - private requireFullSourceRecovery(): void { + private requireFullSourceRepair(): void { this.settledSourceBoundary = undefined - this.needsFullSourceRecovery = true + this.needsFullSourceRepair = true } private cancelRepairRetry(): void { @@ -510,7 +518,7 @@ export class OrderedSourceLoader { this.repairRetries >= 2 ) return - const generation = this.generation + const orderedLoadGeneration = this.orderedLoadGeneration const failedRequest = this.failedRequest this.repairTimer = setTimeout( () => { @@ -519,7 +527,7 @@ export class OrderedSourceLoader { if ( !this.active || !this.canRetryRepair() || - generation !== this.generation || + orderedLoadGeneration !== this.orderedLoadGeneration || this.failedRequest !== failedRequest || this.failedRequest?.windowOperationGeneration !== undefined ) @@ -529,7 +537,7 @@ export class OrderedSourceLoader { if ( // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- release callbacks can dispose the loader !this.active || - generation !== this.generation || + orderedLoadGeneration !== this.orderedLoadGeneration || this.subscription.hasPendingTruncateReplacement ) return @@ -558,10 +566,10 @@ export class OrderedSourceLoader { cancelObservedSettlement = false, ): Error { if (cancelObservedSettlement) { - this.generation++ + this.orderedLoadGeneration++ this.pending = undefined } - this.requireFullSourceRecovery() + this.requireFullSourceRepair() this.recordRequestFailure(windowOperationGeneration) if (isFullSource) this.fullSource = `none` try { diff --git a/packages/db/tests/SortedMap.test.ts b/packages/db/tests/SortedMap.test.ts index 5d96cda51d..3e9e09ef5a 100644 --- a/packages/db/tests/SortedMap.test.ts +++ b/packages/db/tests/SortedMap.test.ts @@ -3,6 +3,23 @@ import fc from 'fast-check' import { SortedMap } from '../src/SortedMap' import { oraclePropertyOptions, oracleRuns } from './oracle-config' +/** + * SortedMap is an ordered view over ordinary map semantics. + * + * The contract has two independent parts. Keys still have Map ownership: + * setting a key replaces its value, deleting reports whether the key existed, + * and clearing removes everything. Iteration then presents the surviving + * entries in either key order or the caller's value order. Equal values use + * key order as a stable tie-breaker, so no entry disappears inside a tie. + * + * The reference model is deliberately dull: a native Map owns the values and + * a fresh full sort derives every observation. The production structure may + * update its ordered index incrementally, but it must refine that recomputation + * after every generated command. The driver observes size, point lookup, every + * iterator, and forEach; checking only the final values would miss stale keys, + * lost ties, and inconsistent views of the same state. + */ + type Order = `key` | `ascending` | `descending` function expectMap( diff --git a/packages/db/tests/btree-map-oracle.test.ts b/packages/db/tests/btree-map-oracle.test.ts index b8e5586b05..dbd32f9b4b 100644 --- a/packages/db/tests/btree-map-oracle.test.ts +++ b/packages/db/tests/btree-map-oracle.test.ts @@ -2,11 +2,36 @@ import { describe, expect, it, vi } from 'vitest' import { fc, test as fcTest } from '@fast-check/vitest' import { BTree } from '../src/utils/btree.js' +/** + * # When does the B+ tree behave like a sorted Map? + * + * The B+ tree can split, merge, collapse, and reuse private nodes. None of + * those shapes are public. For numeric keys, the public contract is a Map with + * one added rule: traversal and neighbor operations use numeric key order. + * + * After each action, this oracle checks four laws: + * + * 1. Point operations return the same result as the Map model. + * 2. A full range scan returns each modeled key once in sorted order. + * 3. Neighbor operations return the closest strict key and its exact payload. + * 4. A missing lookup returns the fallback object supplied by the caller. + * + * The model stores both the payload reference and its original value. This + * detects a wrong payload and later mutation of a shared payload. A key-only + * model would miss both faults. + * + * Generated histories vary puts, overwrites, deletes, reads, and clears. Small + * node sizes force structural changes. A fixed long campaign gives stable + * depth, while the fast-check lane supplies new and shrinkable histories. + */ + type Payload = { v: number } type Stored = Readonly<{ reference: Payload; v: number }> +type ReferenceModel = Map type Action = | { type: `put`; key: number; v: number } | { type: `delete` | `read` | `clear`; key: number } +type ActionResult = boolean | undefined const arbitraryAction: fc.Arbitrary = fc.oneof( fc.record({ @@ -20,25 +45,50 @@ const arbitraryAction: fc.Arbitrary = fc.oneof( }), ) +// The model uses only Map operations and the declared numeric order. It does +// not use the tree's nodes, search helpers, or traversal code. +function stepModel( + model: ReferenceModel, + action: Action, + value: Payload | undefined, +): ActionResult { + if (action.type === `put`) { + const inserted = !model.has(action.key) + model.set(action.key, Object.freeze({ reference: value!, v: action.v })) + return inserted + } + if (action.type === `delete`) return model.delete(action.key) + if (action.type === `clear`) model.clear() + return undefined +} + +// The driver invokes the public BTree API. It shares only the generated input +// payload with the model. +function stepTree( + tree: BTree, + action: Action, + value: Payload | undefined, +): ActionResult { + if (action.type === `put`) return tree.set(action.key, value!) + if (action.type === `delete`) return tree.delete(action.key) + if (action.type === `clear`) tree.clear() + return undefined +} + function applyAction( tree: BTree, - oracle: Map, + model: ReferenceModel, action: Action, ): void { - if (action.type === `put`) { - const value = { v: action.v } - expect(tree.set(action.key, value)).toBe(!oracle.has(action.key)) - oracle.set(action.key, Object.freeze({ reference: value, v: action.v })) - } else if (action.type === `delete`) { - expect(tree.delete(action.key)).toBe(oracle.delete(action.key)) - } else if (action.type === `clear`) { - tree.clear() - oracle.clear() - } + const value = action.type === `put` ? { v: action.v } : undefined + const expectedResult = stepModel(model, action, value) + const actualResult = stepTree(tree, action, value) + expect(actualResult).toBe(expectedResult) + // Mutation return values alone cannot expose stale deletes/overwrites. - expectValue(tree.get(action.key), oracle.get(action.key)) - expect(tree.has(action.key)).toBe(oracle.has(action.key)) - expect(tree.size).toBe(oracle.size) + expectValue(tree.get(action.key), model.get(action.key)) + expect(tree.has(action.key)).toBe(model.has(action.key)) + expect(tree.size).toBe(model.size) } function expectValue( @@ -52,29 +102,31 @@ function expectValue( function expectPair( actual: [number, Payload] | undefined, key: number | undefined, - oracle: Map, + model: ReferenceModel, ): void { expect(actual).toEqual( - key === undefined ? undefined : [key, oracle.get(key)!.reference], + key === undefined ? undefined : [key, model.get(key)!.reference], ) - if (key !== undefined) expectValue(actual?.[1], oracle.get(key)) + if (key !== undefined) expectValue(actual?.[1], model.get(key)) } -function expectTree( +// This is the refinement check. It compares all public observations used by +// the index code with the independent Map model at the current history cut. +function expectRefinement( tree: BTree, - oracle: Map, + model: ReferenceModel, probe: number, ): void { - const sorted = [...oracle.keys()].sort((a, b) => a - b) + const sorted = [...model.keys()].sort((a, b) => a - b) // A missing key must return the caller's fallback on either side of the // tree, including when growth splits the root or deletion collapses it. const fallback = { v: probe } for (const key of [probe, (sorted[0] ?? 0) - 1, (sorted.at(-1) ?? 0) + 1]) { expect(tree.get(key, fallback)).toBe( - oracle.has(key) ? oracle.get(key)!.reference : fallback, + model.has(key) ? model.get(key)!.reference : fallback, ) } - expect(tree.size).toBe(oracle.size) + expect(tree.size).toBe(model.size) expect(tree.minKey()).toBe(sorted[0]) expect(tree.maxKey()).toBe(sorted[sorted.length - 1]) const seen: Array = [] @@ -85,27 +137,28 @@ function expectTree( true, (key, value) => { seen.push(key) - expectValue(value, oracle.get(key)) + expectValue(value, model.get(key)) }, ) expect(seen).toEqual(sorted) expectPair( tree.nextHigherPair(probe), sorted.find((key) => key > probe), - oracle, + model, ) expectPair( tree.nextLowerPair(probe), [...sorted].reverse().find((key) => key < probe), - oracle, + model, ) - expectPair(tree.nextHigherPair(undefined), sorted[0], oracle) - expectPair(tree.nextLowerPair(undefined), sorted[sorted.length - 1], oracle) + expectPair(tree.nextHigherPair(undefined), sorted[0], model) + expectPair(tree.nextLowerPair(undefined), sorted[sorted.length - 1], model) } describe(`BTree Map oracle`, () => { // The 120,000-operation corpus takes about nine seconds with CI coverage. - // Keep every operation and assertion; this is not a five-second perf budget. + // This fixed lane is a stable depth campaign, not random discovery. Keep + // every operation and assertion. This is not a five-second performance budget. it( `matches a Map oracle under random insert/delete/overwrite with small nodes`, { timeout: 30_000 }, @@ -116,10 +169,10 @@ describe(`BTree Map oracle`, () => { for (let round = 0; round < 40; round++) { const nodeSize = 4 + Math.floor(rnd() * 5) const tree = new BTree((a, b) => a - b, nodeSize) - const oracle = new Map() + const model: ReferenceModel = new Map() const history: Array = [] try { - expectTree(tree, oracle, 0) + expectRefinement(tree, model, 0) for (let step = 0; step < 3000; step++) { const key = Math.floor(rnd() * 200) const op = rnd() @@ -131,11 +184,11 @@ describe(`BTree Map oracle`, () => { key, } history.push(action) - applyAction(tree, oracle, action) + applyAction(tree, model, action) if (step % 97 === 0) - expectTree(tree, oracle, Math.floor(rnd() * 200)) + expectRefinement(tree, model, Math.floor(rnd() * 200)) } - expectTree(tree, oracle, 100) + expectRefinement(tree, model, 100) } catch (cause) { throw new Error( JSON.stringify({ @@ -160,11 +213,11 @@ describe(`BTree Map oracle`, () => { `matches complete Map cuts across shrinkable histories`, (nodeSize, actions) => { const tree = new BTree((a, b) => a - b, nodeSize) - const oracle = new Map() - expectTree(tree, oracle, 0) + const model: ReferenceModel = new Map() + expectRefinement(tree, model, 0) for (const action of actions) { - applyAction(tree, oracle, action) - expectTree(tree, oracle, action.key) + applyAction(tree, model, action) + expectRefinement(tree, model, action.key) } }, ) @@ -173,68 +226,68 @@ describe(`BTree Map oracle`, () => { `preserves every cut through dense growth, retirement and reuse with node size %s`, (nodeSize) => { const tree = new BTree((a, b) => a - b, nodeSize) - const oracle = new Map() - expectTree(tree, oracle, 0) + const model: ReferenceModel = new Map() + expectRefinement(tree, model, 0) // 64 entries exceed every selected leaf capacity. Deleting all then // reinserting exercises collapse/reuse without asserting private shape. const keys = Array.from({ length: 64 }, (_, key) => key) for (const key of keys) { const value = { v: key } expect(tree.set(key, value)).toBe(true) - oracle.set(key, Object.freeze({ reference: value, v: key })) - expectTree(tree, oracle, key) + model.set(key, Object.freeze({ reference: value, v: key })) + expectRefinement(tree, model, key) } for (const key of [...keys].reverse()) { expect(tree.delete(key)).toBe(true) - oracle.delete(key) + model.delete(key) expectValue(tree.get(key), undefined) - expectTree(tree, oracle, key) + expectRefinement(tree, model, key) } for (const key of keys) { const value = { v: key + 100 } expect(tree.set(key, value)).toBe(true) - oracle.set(key, Object.freeze({ reference: value, v: key + 100 })) - expectTree(tree, oracle, key) + model.set(key, Object.freeze({ reference: value, v: key + 100 })) + expectRefinement(tree, model, key) const replacement = { v: key + 200 } expect(tree.set(key, replacement)).toBe(false) - oracle.set(key, Object.freeze({ reference: replacement, v: key + 200 })) - expectTree(tree, oracle, key) + model.set(key, Object.freeze({ reference: replacement, v: key + 200 })) + expectRefinement(tree, model, key) } tree.clear() - oracle.clear() - expectTree(tree, oracle, 0) + model.clear() + expectRefinement(tree, model, 0) const value = { v: 500 } tree.set(0, value) - oracle.set(0, Object.freeze({ reference: value, v: 500 })) - expectTree(tree, oracle, 0) + model.set(0, Object.freeze({ reference: value, v: 500 })) + expectRefinement(tree, model, 0) }, ) it(`rejects wrong pair payloads, shared payload mutation and transient stale state`, () => { const tree = new BTree((a, b) => a - b, 4) const value = { v: 1 } - const oracle = new Map([ + const model: ReferenceModel = new Map([ [1, Object.freeze({ reference: value, v: 1 })], ]) tree.set(1, value) - expectTree(tree, oracle, 0) + expectRefinement(tree, model, 0) const wrongPair = vi .spyOn(tree, `nextHigherPair`) .mockReturnValue([1, { v: 2 }]) try { - expect(() => expectTree(tree, oracle, 0)).toThrowError(/expected/) + expect(() => expectRefinement(tree, model, 0)).toThrowError(/expected/) } finally { wrongPair.mockRestore() } value.v = 2 expect(tree.get(1)).toBe(value) // The former identity-only check stays green. - expect(() => expectTree(tree, oracle, 0)).toThrowError(/expected/) + expect(() => expectRefinement(tree, model, 0)).toThrowError(/expected/) value.v = 1 - expectTree(tree, oracle, 0) - // A stale delete result can vanish at a later clear; judge the earlier cut. - expect(() => expectTree(tree, new Map(), 0)).toThrowError(/expected/) + expectRefinement(tree, model, 0) + // A stale delete result can vanish at a later clear. Judge the earlier cut. + expect(() => expectRefinement(tree, new Map(), 0)).toThrowError(/expected/) tree.clear() - expectTree(tree, new Map(), 0) + expectRefinement(tree, new Map(), 0) const staleScan = vi .spyOn(tree, `forRange`) .mockImplementation((_low, _high, _inclusive, callback) => { @@ -242,11 +295,13 @@ describe(`BTree Map oracle`, () => { return 1 }) try { - expect(() => expectTree(tree, new Map(), 0)).toThrowError(/expected/) + expect(() => expectRefinement(tree, new Map(), 0)).toThrowError( + /expected/, + ) } finally { staleScan.mockRestore() } - expectTree(tree, new Map(), 0) + expectRefinement(tree, new Map(), 0) }) it(`shrinks and replays a wrong neighbor payload without losing its pair key`, () => { @@ -254,15 +309,16 @@ describe(`BTree Map oracle`, () => { fc.array(fc.integer({ min: 0, max: 20 }), { minLength: 1, maxLength: 8 }), (values) => { const tree = new BTree((a, b) => a - b, 4) - const oracle = new Map() + const model: ReferenceModel = new Map() values.forEach((v, key) => - applyAction(tree, oracle, { type: `put`, key, v }), + applyAction(tree, model, { type: `put`, key, v }), ) - expectTree(tree, oracle, -1) - // Correct key, wrong payload: the previous key-only law accepted this. + expectRefinement(tree, model, -1) + // This pair keeps the expected key but has a wrong payload. The former + // key-only law accepted it. const wrongPair: [number, Payload] = [0, { v: values[0]! + 1 }] expect(wrongPair[0]).toBe(0) - expectPair(wrongPair, 0, oracle) + expectPair(wrongPair, 0, model) }, ) const failed = fc.check(property, { seed: 303102, numRuns: 1 }) diff --git a/packages/db/tests/cleanup-queue.property.test.ts b/packages/db/tests/cleanup-queue.property.test.ts index 98fc5a9895..bba0f9dedc 100644 --- a/packages/db/tests/cleanup-queue.property.test.ts +++ b/packages/db/tests/cleanup-queue.property.test.ts @@ -4,11 +4,38 @@ import { CleanupQueue } from '../src/collection/cleanup-queue' import { oraclePropertyOptions, oracleRuns } from './oracle-config' import { resetCleanupQueue } from './utils' +/** + * # Which cleanup callback must run? + * + * A cleanup appointment binds one key to one callback and one deadline. + * Scheduling the same key replaces its appointment. Cancellation removes its + * appointment. Advancing the clock runs each due callback exactly once. + * + * A callback error must not stop another due callback. The contract does not + * set callback order when one clock advance makes several callbacks due. The + * queue must use at most one root timer. + * + * `stepModel` stores only appointments and public deliveries. It does not copy + * the production timer, microtask, or wake-up logic. + */ + type Action = | { kind: `schedule`; key: number; delay: number; throws: boolean } | { kind: `cancel`; key: number } | { kind: `advance`; elapsed: number } type Delivery = { id: number; at: number } +type Appointment = { + id: number + key: number + at: number + throws: boolean +} +type Model = { + now: number + appointments: Array + deliveries: Array + errors: Array +} type Fault = | `none` | `ignore-cancel` @@ -16,10 +43,54 @@ type Fault = | `duplicate` | `late` -// The model is a list of current appointments, not a model of the root timer, -// its microtask or its early wakeups. Equal-deadline callback order is free. -// Callbacks may throw, but do not schedule/cancel other callbacks: reentrant -// delivery needs a separate contract, not assumptions from today's Map loop. +// Each key has at most one appointment. A schedule replaces the old +// appointment. An advance moves the clock and delivers all due appointments. +function stepModel(model: Model, id: number, action: Action): Model { + if (action.kind === `schedule`) { + return { + ...model, + appointments: [ + ...model.appointments.filter((entry) => entry.key !== action.key), + { + id, + key: action.key, + at: model.now + action.delay, + throws: action.throws, + }, + ], + } + } + + if (action.kind === `cancel`) { + return { + ...model, + appointments: model.appointments.filter( + (entry) => entry.key !== action.key, + ), + } + } + + const now = model.now + action.elapsed + const due = model.appointments.filter((entry) => entry.at <= now) + return { + now, + appointments: model.appointments.filter((entry) => entry.at > now), + deliveries: [ + ...model.deliveries, + ...due.map((entry) => ({ id: entry.id, at: entry.at })), + ], + errors: [ + ...model.errors, + ...due + .filter((entry) => entry.throws) + .map((entry) => `callback:${entry.id}`), + ], + } +} + +// Callbacks in this model do not schedule or cancel other callbacks. Reentrant +// callbacks need a separate contract. They must not inherit current Map-loop +// behavior by accident. async function runHistory(actions: Array, fault: Fault = `none`) { vi.useFakeTimers() vi.setSystemTime(0) @@ -30,19 +101,16 @@ async function runHistory(actions: Array, fault: Fault = `none`) { const keys = [0, `0`, {}, {}] const errors = vi.spyOn(console, `error`).mockImplementation(() => {}) const actual: Array = [] - const expected: Array = [] - const expectedErrors: Array = [] - let appointments: Array<{ - id: number - key: number - at: number - throws: boolean - }> = [] - let now = 0 + let model: Model = { + now: 0, + appointments: [], + deliveries: [], + errors: [], + } const canonical = (rows: Array) => [...rows].sort((a, b) => a.id - b.id) const check = () => { - expect(canonical(actual)).toEqual(canonical(expected)) + expect(canonical(actual)).toEqual(canonical(model.deliveries)) expect( errors.mock.calls .map(([label, error]) => { @@ -51,22 +119,15 @@ async function runHistory(actions: Array, fault: Fault = `none`) { return (error as Error).message }) .sort(), - ).toEqual([...expectedErrors].sort()) + ).toEqual([...model.errors].sort()) } const advance = async (elapsed: number) => { await Promise.resolve() // Admit the whole synchronous registration batch. expect(vi.getTimerCount()).toBeLessThanOrEqual(1) - now += elapsed - for (const entry of appointments.filter( - (appointment) => appointment.at <= now, - )) { - expected.push({ id: entry.id, at: entry.at }) - if (entry.throws) expectedErrors.push(`callback:${entry.id}`) - } - appointments = appointments.filter((entry) => entry.at > now) + model = stepModel(model, -1, { kind: `advance`, elapsed }) vi.advanceTimersByTime(elapsed) check() - expect(vi.getTimerCount()).toBe(appointments.length ? 1 : 0) + expect(vi.getTimerCount()).toBe(model.appointments.length ? 1 : 0) } try { check() @@ -74,16 +135,10 @@ async function runHistory(actions: Array, fault: Fault = `none`) { if (action.kind === `advance`) { await advance(action.elapsed) } else if (action.kind === `cancel`) { - appointments = appointments.filter((entry) => entry.key !== action.key) + model = stepModel(model, id, action) if (fault !== `ignore-cancel`) queue.cancel(keys[action.key]) } else { - appointments = appointments.filter((entry) => entry.key !== action.key) - appointments.push({ - id, - key: action.key, - at: now + action.delay, - throws: action.throws, - }) + model = stepModel(model, id, action) queue.schedule( fault === `lose-replacement` ? { key: action.key } : keys[action.key], action.delay + (fault === `late` ? 1 : 0), @@ -123,6 +178,8 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( }), ) +// Run one stable campaign and one random campaign. The shared oracle config +// accepts a seed and shrink path for replay of the random lane. it.each([20260913, undefined])( `obeys appointment histories (seed %s)`, async (seed) => { @@ -155,6 +212,7 @@ it.each([20260913, undefined])( }, ) +// These controls prove that the oracle rejects four plausible wrong queues. it.each([`ignore-cancel`, `lose-replacement`, `duplicate`, `late`] as const)( `rejects the %s faulty queue`, async (fault) => { diff --git a/packages/db/tests/collection-cleanup-restart-oracle.test.ts b/packages/db/tests/collection-cleanup-restart-oracle.test.ts index b131d67c0f..ecd1b3624d 100644 --- a/packages/db/tests/collection-cleanup-restart-oracle.test.ts +++ b/packages/db/tests/collection-cleanup-restart-oracle.test.ts @@ -2,6 +2,19 @@ import { describe, expect, it } from 'vitest' import { createCollection, createLiveQueryCollection } from '../src' import type { SyncConfig } from '../src/types' +/** + * Cleanup is a closed admission interval, not merely a final status value. + * + * The history enters cleanup, re-enters start/preload from abort or release + * callbacks, and may request nested cleanup. The model admits no replacement + * owner until the first cleanup promise settles: every reentrant start must + * fail, the original load and release occur once, and subscriber count reaches + * zero. A later ordinary preload is a new generation and must work. + * + * Counts, errors, status, rows, and ownership are all observed. Checking only + * `cleaned-up` would miss leaked or duplicated physical resources. + */ + type Row = { id: number; rank: number } const cleanupError = { name: `CollectionStateError`, diff --git a/packages/db/tests/collection-errors.test.ts b/packages/db/tests/collection-errors.test.ts index 1422e89f9b..df4125f728 100644 --- a/packages/db/tests/collection-errors.test.ts +++ b/packages/db/tests/collection-errors.test.ts @@ -79,16 +79,16 @@ describe(`Collection Error Handling`, () => { async (restart) => { const failure = new Error(`cleanup failed`) const cleanups: Array = [] - let session = 0 + let syncRunCount = 0 const collection = createCollection<{ id: string }>({ - id: `failed-cleanup-session-${restart}`, + id: `failed-cleanup-sync-run-${restart}`, getKey: ({ id }) => id, sync: { sync: ({ markReady }) => { - const currentSession = session++ + const currentSyncRun = syncRunCount++ markReady() return () => { - cleanups.push(currentSession) + cleanups.push(currentSyncRun) if (cleanups.length !== 1) return if (restart) { void collection.cleanup() @@ -116,7 +116,7 @@ describe(`Collection Error Handling`, () => { expect(reportedError).toBeInstanceOf(SyncCleanupError) expect((reportedError as Error).cause).toBe(failure) - expect(session).toBe(1) + expect(syncRunCount).toBe(1) if (restart) collection.startSyncImmediate() await collection.cleanup() expect(cleanups).toEqual(restart ? [0, 1] : [0, 0]) @@ -349,7 +349,7 @@ describe(`Collection Error Handling`, () => { }) }) - describe(`Sync Session Isolation`, () => { + describe(`Sync Run Isolation`, () => { it(`preserves an asynchronous sync error and removes its first-ready waiter`, async () => { let markError: (error?: unknown) => void = () => { throw new Error(`Sync has not started`) @@ -430,8 +430,8 @@ describe(`Collection Error Handling`, () => { expect(collection.status).toBe(`cleaned-up`) }) - it(`ignores an error callback retained by an earlier sync session`, async () => { - const sessions: Array<{ + it(`ignores an error callback retained by an earlier sync run`, async () => { + const syncRuns: Array<{ markError: () => void markReady: () => void }> = [] @@ -441,23 +441,23 @@ describe(`Collection Error Handling`, () => { startSync: false, sync: { sync: ({ markError, markReady }) => { - sessions.push({ markError, markReady }) + syncRuns.push({ markError, markReady }) }, }, }) await collection.cleanup() const preload = collection.preload() - expect(sessions).toHaveLength(1) - const first = sessions[0]! + expect(syncRuns).toHaveLength(1) + const first = syncRuns[0]! const cancelled = expect(preload).rejects.toMatchObject({ name: `AbortError`, }) await collection.cleanup() await cancelled const restartedPreload = collection.preload() - expect(sessions).toHaveLength(2) - const second = sessions[1]! + expect(syncRuns).toHaveLength(2) + const second = syncRuns[1]! first.markError() expect(collection.status).toBe(`loading`) @@ -467,23 +467,23 @@ describe(`Collection Error Handling`, () => { expect(collection.status).toBe(`ready`) }) - it(`ignores transaction callbacks retained by an earlier sync session`, async () => { + it(`ignores transaction callbacks retained by an earlier sync run`, async () => { type Item = { id: string } type SyncMethods = Parameters[`sync`]>[0] - const sessions: Array = [] + const syncRuns: Array = [] const collection = createCollection({ id: `stale-transaction-after-restart`, getKey: (item) => item.id, startSync: false, sync: { sync: (sync) => { - sessions.push(sync) + syncRuns.push(sync) }, }, }) const firstPreload = collection.preload() - const first = sessions[0]! + const first = syncRuns[0]! const cancelled = expect(firstPreload).rejects.toMatchObject({ name: `AbortError`, }) @@ -491,7 +491,7 @@ describe(`Collection Error Handling`, () => { await cancelled const secondPreload = collection.preload() - const second = sessions[1]! + const second = syncRuns[1]! first.begin() first.write({ type: `insert`, value: { id: `stale` } }) first.commit() diff --git a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts index e1e70df116..30e9e01574 100644 --- a/packages/db/tests/collection-metadata-publication-oracle.property.test.ts +++ b/packages/db/tests/collection-metadata-publication-oracle.property.test.ts @@ -9,6 +9,20 @@ import { oraclePropertyOptions } from './oracle-config.js' import type { Collection } from '../src/collection/index.js' import type { ChangeMessage, SyncConfig } from '../src/types.js' +/** + * One transaction publishes one coherent row/metadata world. + * + * The model keeps rows and metadata as independent maps, applies an ordered + * transaction to private copies, and either commits both or aborts both. + * Multiple writes to one key collapse to one published change. Structured + * metadata is cloned once per model/driver world so aliases inside a world are + * preserved without letting production mutate the reference expectation. + * + * The driver observes direct collection state, live-query rows, metadata, + * exact change batches, and cancellation. Agreement at all of those cuts rules + * out torn publication that a final row comparison could hide. + */ + type PublicationRow = { id: number position: number diff --git a/packages/db/tests/collection-mutation-startup-oracle.test.ts b/packages/db/tests/collection-mutation-startup-oracle.test.ts index 59541a35b0..739bd8a316 100644 --- a/packages/db/tests/collection-mutation-startup-oracle.test.ts +++ b/packages/db/tests/collection-mutation-startup-oracle.test.ts @@ -17,19 +17,22 @@ import type { Collection } from '../src/collection/index.js' import type { SyncConfig } from '../src/types.js' /** - * Oracle review card - * Owner: core Collection insert/update/delete admission while startSync:false is idle. - * Sources: #918's regular-mutation path, #929's batch-key guard, and CodeRabbit's - * #1840 pre-start/post-start duplicate review. - * Model: finite rejection, synchronous hydration, duplicate-visibility, accepted, - * and startup-failure cells; rejected cells compare ready and throwing adapters. - * Path: public mutations through their production validation and sync entry points. - * Observations: exact error class/identity, starts, handler calls, status, rows, - * mutation type/key, and persistence. - * Mutants: eager/omitted/repeated/late startup, removed or over-broad duplicate - * checks, batch-key loss, wrong handler dispatch, and application before failure. - * Limits: no Query write utilities, deferred startup, ambient transactions, - * cleanup, asynchronous providers, reconciliation, publication, or settlement. + * # What may an idle Collection mutation start? + * + * A Collection with `startSync: false` delays its source until a valid public + * mutation needs it. Invalid mutations must fail before startup and before a + * handler runs. A valid insert starts the source once, waits for synchronous + * hydration, then checks the hydrated rows for duplicates before it applies + * optimistic state. + * + * This is a finite admission model rather than a second Collection. Each case + * belongs to one of five cells: reject before startup, hydrate then reject a + * duplicate, hydrate then accept, reuse an existing start, or surface startup + * failure unchanged. The production driver observes starts, handler calls, + * status, rows, mutation type and key, and persistence through the public API. + * + * Deferred providers, ambient transactions, reconciliation, publication, and + * settlement belong to the subscription and optimistic-history oracles. */ type Row = { id: string; value: string } type IdleCollection = Collection diff --git a/packages/db/tests/collection-state-retention-oracle.property.test.ts b/packages/db/tests/collection-state-retention-oracle.property.test.ts index 8d7832738e..2dfb5f995c 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -11,6 +11,21 @@ import type { CollectionChangesManager } from '../src/collection/changes.js' import type { Collection } from '../src/collection/index.js' import type { SyncConfig, TransactionState } from '../src/types.js' +/** + * Retained collection state is the last accepted source snapshot plus local + * whole-row intent; restart changes ownership, not that value contract. + * + * A plain Map models source insert/update/delete and truncate-replace batches. + * A separate lifecycle driver stops and restarts sync, including reentrant + * commits before and after the old callback returns. The optimistic companion + * model owns accepted local snapshots, rollback, and settlement. Neither model + * borrows CollectionState's merge bookkeeping. + * + * The oracle compares retained source data, public rows, indexes, events, and + * sync-run ownership after every cut. This makes stale-sync-run writes and rows + * that vanish or reappear only after unrelated work observable. + */ + type RetainedRow = { id: number value: number @@ -267,7 +282,7 @@ async function runRetentionHistory( expect(restarted).toBe(true) expect(restartedSync).toBeDefined() if (restartedSync === undefined) { - throw new Error(`restarted sync session was not captured`) + throw new Error(`restarted sync run was not captured`) } if (action.commitPhase === `insideListener`) { expect(restartedReceipt).toBeDefined() @@ -293,7 +308,7 @@ async function runRetentionHistory( }, { // This subscriber observed the trigger, but did not request the - // earlier initial state. Restart retracts its known old-session row. + // earlier initial state. Restart retracts its known old-sync-run row. changes: [ { type: `delete`, @@ -353,7 +368,7 @@ it.each( ), ), )( - `retains an old-session %s and a restarted row committed %s`, + `retains an old-sync-run %s and a restarted row committed %s`, async (triggerType, commitPhase) => { await runRetentionHistory([ ...(triggerType === `update` @@ -379,7 +394,7 @@ it(`releases retained keys after long unique-key churn`, async () => { await runRetentionHistory(actions) }) -it(`starts a new sync session without retained publication state`, async () => { +it(`starts a new sync run without retained publication state`, async () => { let sync!: SyncActions const collection = createCollection({ getKey: (row) => row.id, @@ -442,7 +457,7 @@ it(`starts a new sync session without retained publication state`, async () => { } }) -it(`keeps a restarted session's publication state after the old listener returns`, async () => { +it(`keeps a restarted sync run's publication state after the old listener returns`, async () => { let sync!: SyncActions const collection = createCollection({ getKey: (row) => row.id, @@ -540,7 +555,7 @@ it(`does not let an old publication microtask clear restarted sync state`, async it(`publishes a virtual-state update when a restarted optimistic row is confirmed`, async () => { let sync!: SyncActions - let syncSession = 0 + let syncRunCount = 0 let releaseMutation!: () => void const mutationHold = new Promise((resolve) => { releaseMutation = resolve @@ -552,8 +567,8 @@ it(`publishes a virtual-state update when a restarted optimistic row is confirme rowUpdateMode: `full`, sync: (actions) => { sync = actions - syncSession++ - if (syncSession === 1) actions.markReady() + syncRunCount++ + if (syncRunCount === 1) actions.markReady() }, }, }) @@ -1377,9 +1392,9 @@ it(`does not publish an authoritative update hidden by an optimistic overlay`, a } }) -it(`does not carry previous-value state across a failed sync session`, async () => { +it(`does not carry previous-value state across a failed sync run`, async () => { let sync!: Parameters[`sync`]>[0] - let session = 0 + let syncRunCount = 0 let liveValue: LivePreviousRow[`value`] = 0 const failure = new Error(`live value read failed`) const liveRow = { @@ -1395,9 +1410,9 @@ it(`does not carry previous-value state across a failed sync session`, async () rowUpdateMode: `full`, sync: (actions) => { sync = actions - session++ + syncRunCount++ actions.begin() - if (session === 1) { + if (syncRunCount === 1) { actions.write({ type: `insert`, value: liveRow }) actions.write({ type: `insert`, value: { id: 2, value: 0 } }) } else { @@ -1458,7 +1473,7 @@ it(`does not carry previous-value state across a failed sync session`, async () await collection.cleanup() collection.startSyncImmediate() - expect(session, `replacement sync session started`).toBe(2) + expect(syncRunCount, `replacement sync run started`).toBe(2) const observation = observeValuePublications(collection) subscription = observation.subscription sync.begin() diff --git a/packages/db/tests/collection-subscription-lifecycle-grammar.ts b/packages/db/tests/collection-subscription-lifecycle-grammar.ts index 548740d230..db1dbb7665 100644 --- a/packages/db/tests/collection-subscription-lifecycle-grammar.ts +++ b/packages/db/tests/collection-subscription-lifecycle-grammar.ts @@ -1,5 +1,41 @@ import { fc } from '@fast-check/vitest' +/** + * # Which subscription histories are legal? + * + * A logical demand owns at most one current physical acquisition. Acquisition + * work may outlive that demand after abort, release, restart, or cleanup. The + * subscription must therefore track logical owners and acquisition attempts as + * separate nodes. A sync run groups attempts that can publish one private + * replacement. A replay generation orders replacements in that sync run. + * + * Commands form the history grammar. The model's `request` command combines a + * new logical owner with any acquisition attempt it starts. Its `release` + * command combines demand retirement with release of that owner's acquisition + * lease. `abort` only requests cancellation; it does not retire the owner. + * `settle` changes an acquisition attempt. `truncate` starts a private + * replacement. `restart`, `cleanup`, and `unsubscribe` change the enclosing + * sync run. The reducer is a pure reference machine: it never calls an + * adapter and never reads production state. + * + * The model is intentionally one graph rather than one model per assertion. + * Ownership decides which attempts are live; attempts decide readiness and + * replacement authority; authority decides publication. Splitting those laws + * would hide the cross-boundary histories this oracle exists to test. Row + * contents remain outside this grammar and have their own publication model. + * + * Pinned histories name important paths. Generated histories explore adjacent + * legal commands with fixed and random seeds. The drivers compare production + * loads, unloads, results, status, errors, and publication against this trace. + * + * `LifecycleAttempt` is deliberately broader than production's + * `SubsetAcquisition`: it begins when `loadSubset` is invoked, including a + * synchronous throw that creates no acquisition or acquisition lease. Its + * `syncRunGeneration` and `replayGeneration` map to production's + * sync-run and truncate-replay generations. These are model projections, + * not renamed production states. + */ + export type DemandName = `a` | `b` export type AttemptScope = `current` | `obsolete` export type AttemptAge = `oldest` | `newest` @@ -57,8 +93,8 @@ export type LifecycleAttempt = { id: number ownerId: number demand: DemandName - session: number - replay: number + syncRunGeneration: number + replayGeneration: number outcome?: `resolve` | `reject` gating: boolean inReplacement: boolean @@ -69,11 +105,11 @@ export type LifecycleAttempt = { export type LifecycleLoadEvent = Pick< LifecycleAttempt, - `id` | `demand` | `session` | `replay` + `id` | `demand` | `syncRunGeneration` | `replayGeneration` > export type LifecycleUnloadEvent = { attemptId: number - handlerSession: number + handlerSyncRunGeneration: number } export type LifecycleErrorEvent = { attemptId: number; error: Error } export type LifecycleResultKind = `promise` | `true` @@ -94,8 +130,8 @@ export type LifecycleModel = { cancellation: `manual` | `reject` active: boolean unsubscribed: boolean - session: number - replay: number + syncRunGeneration: number + replayGeneration: number publicationBarrierOpen: boolean nextOwnerId: number nextAttemptId: number @@ -132,8 +168,8 @@ export function createLifecycleModel( cancellation, active: true, unsubscribed: false, - session: 0, - replay: 0, + syncRunGeneration: 0, + replayGeneration: 0, publicationBarrierOpen: false, nextOwnerId: 0, nextAttemptId: 0, @@ -192,8 +228,8 @@ function startAttempt( id, ownerId: owner.id, demand: owner.demand, - session: model.session, - replay: model.replay, + syncRunGeneration: model.syncRunGeneration, + replayGeneration: model.replayGeneration, ...(model.acquisitionMode === `sync-success` ? { outcome: `resolve` as const } : {}), @@ -207,27 +243,27 @@ function startAttempt( } model.attempts.push(attempt) model.reach.add( - `attempt-session:${attempt.session === 0 ? `initial` : `restarted`}`, + `attempt-sync-run:${attempt.syncRunGeneration === 0 ? `initial` : `restarted`}`, ) model.reach.add( - `attempt-replay:${attempt.replay === 0 ? `initial` : `replayed`}`, + `attempt-replay:${attempt.replayGeneration === 0 ? `initial` : `replayed`}`, ) model.reach.add( - `attempt-location:${attempt.session === 0 ? `initial` : `restarted`}:${attempt.replay === 0 ? `initial` : `replayed`}`, + `attempt-location:${attempt.syncRunGeneration === 0 ? `initial` : `restarted`}:${attempt.replayGeneration === 0 ? `initial` : `replayed`}`, ) model.loads.push({ id, demand: attempt.demand, - session: attempt.session, - replay: attempt.replay, + syncRunGeneration: attempt.syncRunGeneration, + replayGeneration: attempt.replayGeneration, }) if (trace) { model.trace.push({ type: `load`, id, demand: attempt.demand, - session: attempt.session, - replay: attempt.replay, + syncRunGeneration: attempt.syncRunGeneration, + replayGeneration: attempt.replayGeneration, }) } owner.attemptId = id @@ -247,12 +283,15 @@ function retireAttempt( attempt.reportable = false abortAttempt(model, attempt) if (options.unload) { - model.unloads.push({ attemptId: attempt.id, handlerSession: model.session }) + model.unloads.push({ + attemptId: attempt.id, + handlerSyncRunGeneration: model.syncRunGeneration, + }) if (options.trace !== false) { model.trace.push({ type: `unload`, attemptId: attempt.id, - handlerSession: model.session, + handlerSyncRunGeneration: model.syncRunGeneration, }) } } @@ -300,8 +339,8 @@ export function reduceLifecycle( } else if (command.type === `restart` && !model.active) { model.reach.add(`effective:restart`) model.active = true - model.session++ - model.replay = 0 + model.syncRunGeneration++ + model.replayGeneration = 0 model.publicationBarrierOpen = false model.collectionStatus = `ready` } else { @@ -424,15 +463,16 @@ export function reduceLifecycle( } model.reach.add(`effective:truncate`) if ( - model.replay > 0 && + model.replayGeneration > 0 && model.attempts.some( - ({ session, outcome }) => - session === model.session && outcome === undefined, + ({ syncRunGeneration, outcome }) => + syncRunGeneration === model.syncRunGeneration && + outcome === undefined, ) ) { model.reach.add(`overlapping-replay`) } - model.replay++ + model.replayGeneration++ // Replay setup is asynchronous even when every acquisition is synchronous // or canceled. Logical owners queue setup; live owners start acquisitions. setStatus(model, model.owners.length > 0) @@ -468,7 +508,7 @@ export function reduceLifecycle( } model.reach.add(`effective:cleanup`) const current = model.attempts.filter( - ({ session }) => session === model.session, + ({ syncRunGeneration }) => syncRunGeneration === model.syncRunGeneration, ) if ( current.some(({ outcome }) => outcome !== undefined) && @@ -493,8 +533,8 @@ export function reduceLifecycle( } model.reach.add(`effective:restart`) model.active = true - model.session++ - model.replay = 0 + model.syncRunGeneration++ + model.replayGeneration = 0 model.publicationBarrierOpen = model.owners.some(({ aborted }) => !aborted) model.collectionStatus = `ready` setStatus(model, model.owners.length > 0) @@ -512,8 +552,8 @@ export function reduceLifecycle( type: `load`, id: load.id, demand: load.demand, - session: load.session, - replay: load.replay, + syncRunGeneration: load.syncRunGeneration, + replayGeneration: load.replayGeneration, }) } setStatus(model) diff --git a/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts index dbef3b27c6..a2ef85871e 100644 --- a/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts @@ -34,6 +34,24 @@ import type { LifecycleUnloadEvent, } from './collection-subscription-lifecycle-grammar.js' +/** + * # Does the runtime follow the subscription lifecycle grammar? + * + * The shared grammar models logical owners, acquisition attempts, sync runs, + * and the publication barrier. This driver gives the same command history to a + * real on-demand Collection and compares the exact lifecycle trace: loads, + * unloads, returned results, readiness, and reported errors. + * + * Returned promises need their own observation rule. They belong to the + * transport attempt that created them, while a caller waiting for publication + * belongs to a cancellable logical owner. A retired attempt may still settle; + * it must not revive its old owner or replace a newer result. + * + * Row contents are checked by the publication oracle. Keeping them out of this + * driver makes ownership faults visible instead of masking them behind final + * state equality. + */ + type RuntimeAttempt = { id: number ownerId: number @@ -185,7 +203,11 @@ async function runHistory( const attemptByOptions = new Map() const observedLoads: Array = [] const observedUnloads: Array< - LifecycleUnloadEvent | { attemptId: `unacquired`; handlerSession: number } + | LifecycleUnloadEvent + | { + attemptId: `unacquired` + handlerSyncRunGeneration: number + } > = [] const observedErrors: Array<{ attemptId: number | `unacquired` @@ -203,14 +225,18 @@ async function runHistory( const observedStatuses: Array = [] const observedTrace: Array< | LifecycleTraceEvent - | { type: `unload`; attemptId: `unacquired`; handlerSession: number } + | { + type: `unload` + attemptId: `unacquired` + handlerSyncRunGeneration: number + } | { type: `error`; attemptId: `unacquired` } | { type: `result`; attemptId: `unacquired`; resultKind: string } > = [] let nextObservedAttemptId = 0 let nextObservedOwnerId = 0 - let observedReplay = 0 - let observedSession = -1 + let observedReplayGeneration = 0 + let observedSyncRunGeneration = -1 let observedActive = true let observedUnsubscribed = false let syncOps: @@ -223,7 +249,7 @@ async function runHistory( syncMode: `on-demand`, sync: { sync: (operations) => { - const handlerSession = ++observedSession + const handlerSyncRunGeneration = ++observedSyncRunGeneration syncOps = operations operations.markReady() return { @@ -240,8 +266,8 @@ async function runHistory( const observed: LifecycleLoadEvent = { id: nextObservedAttemptId++, demand, - session: handlerSession, - replay: observedReplay, + syncRunGeneration: handlerSyncRunGeneration, + replayGeneration: observedReplayGeneration, } const deferred = acquisitionMode === `async-pending` @@ -279,7 +305,7 @@ async function runHistory( unloadSubset: (options) => { const unload = { attemptId: attemptByOptions.get(options) ?? `unacquired`, - handlerSession, + handlerSyncRunGeneration, } as const observedUnloads.push(unload) observedTrace.push({ type: `unload`, ...unload }) @@ -518,7 +544,7 @@ async function runHistory( } } else if (command.type === `truncate`) { if (observedActive) { - observedReplay++ + observedReplayGeneration++ for (const owner of runtimeOwners) { if (owner.attemptId !== undefined) { runtimeAttempts.get(owner.attemptId)!.current = false @@ -548,7 +574,7 @@ async function runHistory( const queuesReplay = !observedActive && !observedUnsubscribed && model.owners.length > 0 if (!observedActive) { - observedReplay = 0 + observedReplayGeneration = 0 observedActive = true } collection.startSyncImmediate() @@ -569,7 +595,7 @@ async function runHistory( check(runtimeAttempts.size).toBe(1) const attempt = runtimeAttempts.get(0) if (!attempt?.deferred) - throw new Error(`Missing retired physical attempt`) + throw new Error(`Missing retired acquisition attempt`) check(attempt.settled).toBe(false) check(attempt.options.signal?.aborted).toBe(true) check(returnedResults).toHaveLength(1) @@ -846,8 +872,8 @@ describe(`CollectionSubscription async lifecycle history oracle`, () => { ), ), ), - `attempt-session:initial`, - `attempt-session:restarted`, + `attempt-sync-run:initial`, + `attempt-sync-run:restarted`, `attempt-replay:initial`, `attempt-replay:replayed`, `attempt-location:initial:initial`, diff --git a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts index 768ee87f44..0f790b9016 100644 --- a/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts @@ -13,6 +13,22 @@ import { import type { CollectionSubscription } from '../src/collection/subscription.js' import type { LoadSubsetOptions, SyncConfig } from '../src/types.js' +/** + * This is the boundary matrix for subscription acquisition and retirement. + * + * The model is a product of a small physical-acquisition state, logical owners, + * sync run, and callback entry point. The explicit cell table names every + * legal phase/entry pair and gives a reason for every excluded pair. Scenario + * drivers then add sync return/throw/resolve/reject, abort, release, peer loss, + * unsubscribe, cleanup, restart, and reentry in controlled orders. + * + * Each case declares the cells it may reach; undeclared reach is a failure and + * the final audit fails if a legal cell was never observed. Counts, signals, + * errors, readiness, owners, and rows are checked separately. This suite is the + * exhaustive boundary companion to the generated lifecycle histories, not a + * second copy of their transition implementation. + */ + type StartOutcome = `return` | `throw` | `resolve` | `reject` type StartReentry = | `none` @@ -110,11 +126,11 @@ const acquisitionCellDefinitions = { }, 'retiring:markReady': { kind: `excluded`, - reason: `callbacks from a retiring session cannot restore availability`, + reason: `callbacks from a retiring sync run cannot restore availability`, }, 'retiring:markError': { kind: `excluded`, - reason: `callbacks from a retiring session are obsolete`, + reason: `callbacks from a retiring sync run are obsolete`, }, 'retiring:syncReturn': { kind: `excluded`, @@ -123,7 +139,7 @@ const acquisitionCellDefinitions = { 'unavailable:request': { kind: `covered` }, 'unavailable:resume': { kind: `excluded`, - reason: `same-session recovery uses markReady rather than defer resume`, + reason: `recovery in the same sync run uses markReady rather than defer resume`, }, 'unavailable:markReady': { kind: `covered` }, 'unavailable:markError': { @@ -243,7 +259,7 @@ const physicalInteractionCellDefinitions = { }, 'obsolete:cleanup': { kind: `excluded`, - reason: `source cleanup retires the current session; obsolete work was retired once`, + reason: `sync-run cleanup retires the current sync run; obsolete work was retired once`, }, 'obsolete:unsubscribe': { kind: `excluded`, @@ -251,7 +267,7 @@ const physicalInteractionCellDefinitions = { }, 'failed-release:release': { kind: `excluded`, - reason: `logical release already happened; the physical attempt is final`, + reason: `logical release already happened; the acquisition attempt is final`, }, 'failed-release:abort': { kind: `excluded`, @@ -294,18 +310,18 @@ function observePhysicalInteraction( observedPhysicalInteractions.set(cell, interaction) } -const requiredSourceSessionBoundaries = new Set([ +const requiredSyncRunBoundaries = new Set([ `active-cleanup`, `restart-installed`, `cleanup-callback-reentry`, `obsolete-resource-return`, ] as const) -type SourceSessionBoundary = - typeof requiredSourceSessionBoundaries extends Set ? T : never -const observedSourceSessionBoundaries = new Set() +type SyncRunBoundary = + typeof requiredSyncRunBoundaries extends Set ? T : never +const observedSyncRunBoundaries = new Set() -function observeSourceSessionBoundary(boundary: SourceSessionBoundary): void { - observedSourceSessionBoundaries.add(boundary) +function observeSyncRunBoundary(boundary: SyncRunBoundary): void { + observedSyncRunBoundaries.add(boundary) } const startOutcomes = [`return`, `throw`, `resolve`, `reject`] as const @@ -364,24 +380,24 @@ const threeGenerationScenarios = ([`resolve`, `reject`] as const).flatMap( type AsyncRestartScenario = { demands: ReadonlyArray<`a` | `b`> - generationOutcomes: ReadonlyArray> + syncRunOutcomes: ReadonlyArray> settlementOrder: `obsolete-first` | `current-first` | `interleaved` } const asyncRestartCoverageScenarios = [ { demands: [`a`], - generationOutcomes: [[`reject`]], + syncRunOutcomes: [[`reject`]], settlementOrder: `current-first`, }, { demands: [`a`], - generationOutcomes: [[`resolve`], [`resolve`]], + syncRunOutcomes: [[`resolve`], [`resolve`]], settlementOrder: `obsolete-first`, }, { demands: [`a`, `b`], - generationOutcomes: [ + syncRunOutcomes: [ [`reject`, `resolve`], [`resolve`, `reject`], ], @@ -389,7 +405,7 @@ const asyncRestartCoverageScenarios = [ }, { demands: [`a`, `b`], - generationOutcomes: [ + syncRunOutcomes: [ [`resolve`, `resolve`], [`reject`, `reject`], [`resolve`, `resolve`], @@ -406,7 +422,7 @@ const asyncRestartScenarioArbitrary: fc.Arbitrary = fc .chain((demands) => fc.record({ demands: fc.constant(demands), - generationOutcomes: fc.array( + syncRunOutcomes: fc.array( fc.array(fc.constantFrom(`resolve` as const, `reject` as const), { minLength: demands.length, maxLength: demands.length, @@ -424,17 +440,17 @@ const asyncRestartScenarioArbitrary: fc.Arbitrary = fc if (process.env.TANSTACK_DB_ORACLE_STATISTICS === `1`) { fc.statistics( asyncRestartScenarioArbitrary, - ({ demands, generationOutcomes, settlementOrder }) => { + ({ demands, syncRunOutcomes, settlementOrder }) => { const realizesInterleaving = settlementOrder === `interleaved` && demands.length > 1 && - generationOutcomes.length > 1 + syncRunOutcomes.length > 1 return [ `demands=${demands.length}`, - `generations=${generationOutcomes.length + 1}`, - `current=${generationOutcomes.at(-1)?.join(`+`)}`, - `mixed-current=${new Set(generationOutcomes.at(-1)).size > 1}`, - `obsolete-reject=${generationOutcomes + `sync-run-generations=${syncRunOutcomes.length + 1}`, + `current=${syncRunOutcomes.at(-1)?.join(`+`)}`, + `mixed-current=${new Set(syncRunOutcomes.at(-1)).size > 1}`, + `obsolete-reject=${syncRunOutcomes .slice(0, -1) .some((outcomes) => outcomes.includes(`reject`))}`, `requested-order=${ @@ -454,17 +470,17 @@ async function runAsyncRestartScenario( type DemandName = `a` | `b` type Row = { id: DemandName; version: number } type Attempt = { - session: number + syncRunGeneration: number demand: DemandName options: LoadSubsetOptions deferred: ReturnType> } type SettlementEvent = { attempt: Attempt - session: number + syncRunGeneration: number demand: DemandName outcome: `resolve` | `reject` - activeSession: number + activeSyncRunGeneration: number } const where = { a: new Func(`eq`, [new PropRef([`id`]), new Value(`a`)]), @@ -479,23 +495,25 @@ async function runAsyncRestartScenario( const publications: Array> = [] const statuses: Array = [] const visible = new Map() - const unloads: Array<{ session: number; demand: DemandName }> = [] + const unloads: Array<{ syncRunGeneration: number; demand: DemandName }> = [] const settlements: Array = [] const settledAttempts = new Set() const publishedBeforeRetirement = new Set() - const failures = scenario.generationOutcomes.map((_, generation) => + const failures = scenario.syncRunOutcomes.map((_, restartIndex) => scenario.demands.map( - (demand) => new Error(`session ${generation + 1} ${demand} failed`), + (demand) => new Error(`sync run ${restartIndex + 1} ${demand} failed`), ), ) - let session = -1 + let syncRunGeneration = -1 const outcomeFor = (attempt: Attempt) => - scenario.generationOutcomes[attempt.session - 1]![ + scenario.syncRunOutcomes[attempt.syncRunGeneration - 1]![ scenario.demands.indexOf(attempt.demand) ]! const failureFor = (attempt: Attempt) => - failures[attempt.session - 1]![scenario.demands.indexOf(attempt.demand)]! + failures[attempt.syncRunGeneration - 1]![ + scenario.demands.indexOf(attempt.demand) + ]! const settleAttempt = async (attempt: Attempt): Promise => { const outcome = outcomeFor(attempt) @@ -504,10 +522,10 @@ async function runAsyncRestartScenario( await flushPromises() settlements.push({ attempt, - session: attempt.session, + syncRunGeneration: attempt.syncRunGeneration, demand: attempt.demand, outcome, - activeSession: session, + activeSyncRunGeneration: syncRunGeneration, }) settledAttempts.add(attempt) } @@ -516,8 +534,8 @@ async function runAsyncRestartScenario( id: `async-restart-lifecycle`, sync: { sync: (operations) => { - session++ - const ownSession = session + syncRunGeneration++ + const ownSyncRunGeneration = syncRunGeneration operations.markReady() return { loadSubset: (options) => { @@ -526,7 +544,7 @@ async function runAsyncRestartScenario( const deferred = createDeferred() void deferred.promise.catch(() => {}) attempts.push({ - session: ownSession, + syncRunGeneration: ownSyncRunGeneration, demand, options, deferred, @@ -535,7 +553,7 @@ async function runAsyncRestartScenario( operations.begin() operations.write({ type: `insert`, - value: { id: demand, version: ownSession + 1 }, + value: { id: demand, version: ownSyncRunGeneration + 1 }, }) const receipt = operations.commit() if (receipt !== true) return receipt @@ -545,7 +563,10 @@ async function runAsyncRestartScenario( unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown async demand`) - unloads.push({ session: ownSession, demand }) + unloads.push({ + syncRunGeneration: ownSyncRunGeneration, + demand, + }) }, } }, @@ -584,7 +605,7 @@ async function runAsyncRestartScenario( subscription.requestSnapshot({ where: where[demand] }) } for (const attempt of attempts.filter( - ({ session: value }) => value === 0, + ({ syncRunGeneration: value }) => value === 0, )) { attempt.deferred.resolve() } @@ -598,11 +619,11 @@ async function runAsyncRestartScenario( ) for ( - let generation = 0; - generation < scenario.generationOutcomes.length; - generation++ + let restartIndex = 0; + restartIndex < scenario.syncRunOutcomes.length; + restartIndex++ ) { - const discardedSession = session + const discardedSyncRunGeneration = syncRunGeneration const prefixStart = publications.length const retainedVersion = publishedBeforeRetirement.size ? Math.max(...publishedBeforeRetirement) + 1 @@ -624,7 +645,8 @@ async function runAsyncRestartScenario( await collection.cleanup() assertRetainedPrefix() for (const attempt of attempts.filter( - ({ session: attemptSession }) => attemptSession === discardedSession, + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration === discardedSyncRunGeneration, )) { expect(attempt.options.signal?.aborted).toBe(true) } @@ -632,72 +654,80 @@ async function runAsyncRestartScenario( assertRetainedPrefix() await flushPromises() assertRetainedPrefix() - const expectedSession = generation + 1 + const expectedSyncRunGeneration = restartIndex + 1 expect( attempts .filter( - ({ session: attemptSession }) => attemptSession <= expectedSession, + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration <= expectedSyncRunGeneration, ) - .map(({ session: attemptSession, demand }) => ({ - session: attemptSession, + .map(({ syncRunGeneration: attemptSyncRunGeneration, demand }) => ({ + syncRunGeneration: attemptSyncRunGeneration, demand, })), ).toEqual( - Array.from({ length: expectedSession + 1 }, (_, attemptSession) => - scenario.demands.map((demand) => ({ - session: attemptSession, - demand, - })), + Array.from( + { length: expectedSyncRunGeneration + 1 }, + (_, attemptSyncRunGeneration) => + scenario.demands.map((demand) => ({ + syncRunGeneration: attemptSyncRunGeneration, + demand, + })), ).flat(), ) const publishesBeforeLaterRestart = scenario.settlementOrder === `interleaved` && - generation === 0 && - scenario.generationOutcomes.length > 1 && - scenario.generationOutcomes[generation]!.every( + restartIndex === 0 && + scenario.syncRunOutcomes.length > 1 && + scenario.syncRunOutcomes[restartIndex]!.every( (outcome) => outcome === `resolve`, ) if (publishesBeforeLaterRestart) { const publicationCount = publications.length for (const attempt of attempts.filter( - ({ session: attemptSession }) => attemptSession === expectedSession, + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration === expectedSyncRunGeneration, )) { await settleAttempt(attempt) } const expectedRows = [...scenario.demands] .sort((a, b) => a.localeCompare(b)) - .map((id) => ({ id, version: expectedSession + 1 })) + .map((id) => ({ id, version: expectedSyncRunGeneration + 1 })) expect( [...visible.values()].sort((a, b) => a.id.localeCompare(b.id)), ).toEqual(expectedRows) expect(publications.slice(publicationCount)).toEqual([expectedRows]) - publishedBeforeRetirement.add(expectedSession) + publishedBeforeRetirement.add(expectedSyncRunGeneration) } } - const currentSession = scenario.generationOutcomes.length + const currentSyncRunGeneration = scenario.syncRunOutcomes.length expect( - attempts.map(({ session: attemptSession, demand }) => ({ - session: attemptSession, - demand, - })), - ).toEqual( - Array.from({ length: currentSession + 1 }, (_, attemptSession) => - scenario.demands.map((demand) => ({ - session: attemptSession, + attempts.map( + ({ syncRunGeneration: attemptSyncRunGeneration, demand }) => ({ + syncRunGeneration: attemptSyncRunGeneration, demand, - })), + }), + ), + ).toEqual( + Array.from( + { length: currentSyncRunGeneration + 1 }, + (_, attemptSyncRunGeneration) => + scenario.demands.map((demand) => ({ + syncRunGeneration: attemptSyncRunGeneration, + demand, + })), ).flat(), ) const obsolete = attempts.filter( (attempt) => - attempt.session > 0 && - attempt.session < currentSession && + attempt.syncRunGeneration > 0 && + attempt.syncRunGeneration < currentSyncRunGeneration && !settledAttempts.has(attempt), ) const current = attempts.filter( - ({ session: value }) => value === currentSession, + ({ syncRunGeneration: value }) => value === currentSyncRunGeneration, ) const orderedAttempts = scenario.settlementOrder === `obsolete-first` @@ -705,11 +735,11 @@ async function runAsyncRestartScenario( : scenario.settlementOrder === `current-first` ? [...current, ...obsolete] : attempts - .filter(({ session: value }) => value > 0) + .filter(({ syncRunGeneration: value }) => value > 0) .filter((attempt) => !settledAttempts.has(attempt)) .sort((left, right) => left.demand === right.demand - ? right.session - left.session + ? right.syncRunGeneration - left.syncRunGeneration : left.demand.localeCompare(right.demand), ) @@ -726,7 +756,7 @@ async function runAsyncRestartScenario( ) const visibleVersion = currentComplete && currentSucceeded - ? currentSession + 1 + ? currentSyncRunGeneration + 1 : retainedVersion const expectedRows = [...scenario.demands] .sort((a, b) => a.localeCompare(b)) @@ -761,7 +791,8 @@ async function runAsyncRestartScenario( for (const attempt of orderedAttempts) { await settleAttempt(attempt) - if (attempt.session === currentSession) settledCurrent.push(attempt) + if (attempt.syncRunGeneration === currentSyncRunGeneration) + settledCurrent.push(attempt) assertObservableState() } @@ -769,7 +800,7 @@ async function runAsyncRestartScenario( (attempt) => outcomeFor(attempt) === `resolve`, ) const expectedVersion = currentSucceeded - ? currentSession + 1 + ? currentSyncRunGeneration + 1 : retainedVersion expect( [...visible.values()].sort((a, b) => a.id.localeCompare(b.id)), @@ -800,8 +831,11 @@ async function runAsyncRestartScenario( expect(attempt.options.signal?.aborted).toBe(false) } - const finalScopes = settlements.map(({ session: attemptSession }) => - attemptSession === currentSession ? `current` : `obsolete`, + const finalScopes = settlements.map( + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration === currentSyncRunGeneration + ? `current` + : `obsolete`, ) const firstCurrent = finalScopes.indexOf(`current`) const lastCurrent = finalScopes.lastIndexOf(`current`) @@ -817,31 +851,38 @@ async function runAsyncRestartScenario( : `interleaved` const currentOutcomes = settlements .filter( - ({ session: attemptSession }) => attemptSession === currentSession, + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration === currentSyncRunGeneration, ) .map(({ outcome }) => outcome) expect(new Set(settlements.map(({ attempt }) => attempt)).size).toBe( settlements.length, ) expect(settlements).toHaveLength( - attempts.filter(({ session: attemptSession }) => attemptSession > 0) - .length, + attempts.filter( + ({ syncRunGeneration: attemptSyncRunGeneration }) => + attemptSyncRunGeneration > 0, + ).length, ) const reach = new Set([ `demands:${new Set(attempts.map(({ demand }) => demand)).size}`, - `sessions:${new Set(attempts.map(({ session: attemptSession }) => attemptSession)).size}`, + `sync-run-generations:${new Set(attempts.map(({ syncRunGeneration: attemptSyncRunGeneration }) => attemptSyncRunGeneration)).size}`, ...[...new Set(currentOutcomes)].map((outcome) => `current:${outcome}`), `mixed-current:${new Set(currentOutcomes).size > 1}`, `obsolete-reject:${settlements.some( - ({ session: attemptSession, outcome }) => - attemptSession < currentSession && outcome === `reject`, + ({ syncRunGeneration: attemptSyncRunGeneration, outcome }) => + attemptSyncRunGeneration < currentSyncRunGeneration && + outcome === `reject`, )}`, ...(observedOrder ? [`order:${observedOrder}`] : []), `real-interleaving:${settlements.some( - ({ session: attemptSession, activeSession }) => - attemptSession < currentSession && - activeSession === attemptSession && - publishedBeforeRetirement.has(attemptSession), + ({ + syncRunGeneration: attemptSyncRunGeneration, + activeSyncRunGeneration, + }) => + attemptSyncRunGeneration < currentSyncRunGeneration && + activeSyncRunGeneration === attemptSyncRunGeneration && + publishedBeforeRetirement.has(attemptSyncRunGeneration), )}`, ]) @@ -851,7 +892,7 @@ async function runAsyncRestartScenario( } expect(unloads).toEqual( scenario.demands.map((demand) => ({ - session: currentSession, + syncRunGeneration: currentSyncRunGeneration, demand, })), ) @@ -874,7 +915,7 @@ async function runAsyncRestartScenario( collection.status === `cleaned-up` && attempts.every(({ options }) => options.signal?.aborted === true) ) { - // Retire captured sessions before releasing any still-pending transport. + // Retire captured sync runs before releasing any still-pending transport. for (const { deferred } of attempts) deferred.resolve() try { await flushPromises() @@ -906,7 +947,7 @@ async function runAsyncRestartScenario( /** * Exhaust the synchronous adapter-start boundary before adding more runtime * special cases. Logical demand is visible during this callback, but a - * physical lease exists only if the callback returns. + * physical acquisition and its lease exist only if the callback returns. */ describe(`CollectionSubscription demand lifecycle oracle`, () => { it(`executes every required async restart regime`, async () => { @@ -919,9 +960,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const required = [ `demands:1`, `demands:2`, - `sessions:2`, - `sessions:3`, - `sessions:4`, + `sync-run-generations:2`, + `sync-run-generations:3`, + `sync-run-generations:4`, `current:resolve`, `current:reject`, `mixed-current:true`, @@ -982,9 +1023,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { afterAll(() => { expect(observedAcquisitionCells).toEqual(legalAcquisitionCells) expect(observedPhysicalInteractions).toEqual(requiredPhysicalInteractions) - expect(observedSourceSessionBoundaries).toEqual( - requiredSourceSessionBoundaries, - ) + expect(observedSyncRunBoundaries).toEqual(requiredSyncRunBoundaries) expect(observedFailureDeliverySuffixes).toEqual( requiredFailureDeliverySuffixes, ) @@ -1170,12 +1209,12 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const pending = createDeferred() const loads: Array = [] const attempts: Array<{ - session: number + syncRunGeneration: number options: LoadSubsetOptions result: `peer-return` | `throw` | `pending` | `replay-return` }> = [] const unloads: Array = [] - const sourceCleanupSessions: Array = [] + const sourceCleanupSyncRuns: Array = [] const errors: Array = [] const statuses: Array = [] const controller = new AbortController() @@ -1200,7 +1239,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { loads.push(options) if (options.where === peerWhere) { attempts.push({ - session: 0, + syncRunGeneration: 0, options, result: `peer-return`, }) @@ -1209,21 +1248,29 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { targetLoadCount++ if (targetLoadCount > 1) { attempts.push({ - session: 0, + syncRunGeneration: 0, options, result: `replay-return`, }) return true } if (outcome === `throw`) { - attempts.push({ session: 0, options, result: `throw` }) + attempts.push({ + syncRunGeneration: 0, + options, + result: `throw`, + }) throw failure } - attempts.push({ session: 0, options, result: `pending` }) + attempts.push({ + syncRunGeneration: 0, + options, + result: `pending`, + }) return pending.promise }, unloadSubset: (options) => unloads.push(options), - cleanup: () => sourceCleanupSessions.push(0), + cleanup: () => sourceCleanupSyncRuns.push(0), } }, }, @@ -1282,7 +1329,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect.soft(errors).toEqual([failure]) expect.soft(subscription.lastError).toBe(failure) expect.soft(targetAttempts[0]?.options).toBe(targetLoad) - expect.soft(targetAttempts[0]?.session).toBe(0) + expect.soft(targetAttempts[0]?.syncRunGeneration).toBe(0) expect .soft(targetAttempts[0]?.result) .toBe(outcome === `throw` ? `throw` : `pending`) @@ -1330,7 +1377,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { if (outcome === `reject`) { expect.soft(replacement).not.toBe(targetLoad) expect.soft(replacement?.where).toBe(targetWhere) - expect.soft(targetAttempts[1]?.session).toBe(0) + expect.soft(targetAttempts[1]?.syncRunGeneration).toBe(0) expect.soft(targetAttempts[1]?.result).toBe(`replay-return`) } else { expect.soft(replacement).toBeUndefined() @@ -1339,7 +1386,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect.soft(peerAttempts).toHaveLength(2) expect.soft(peerReplacement).not.toBe(peerLoad) expect.soft(peerReplacement?.where).toBe(peerWhere) - expect.soft(peerAttempts[1]?.session).toBe(0) + expect.soft(peerAttempts[1]?.syncRunGeneration).toBe(0) expect.soft(peerAttempts[1]?.result).toBe(`peer-return`) expect.soft(unloads).toHaveLength(outcome === `reject` ? 2 : 1) } @@ -1385,7 +1432,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const terminalUnloads = [...unloads] const terminalStatuses = [...statuses] await collection.cleanup() - expect.soft(sourceCleanupSessions).toEqual([0]) + expect.soft(sourceCleanupSyncRuns).toEqual([0]) expect.soft(collection.status).toBe(`cleaned-up`) expect.soft(errors).toEqual([failure]) expect.soft(subscription.lastError).toBe(failure) @@ -1511,7 +1558,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { let write!: (message: { type: `insert`; value: Row }) => void let commit!: () => void let truncate!: () => void - let syncSession = 0 + let syncRunCount = 0 let loadCount = 0 const visible = new Map() const errors: Array = [] @@ -1521,12 +1568,12 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { id: `cleanup-pending-replay-${outcome}`, sync: { sync: (operations) => { - syncSession++ + syncRunCount++ begin = operations.begin write = operations.write commit = operations.commit truncate = operations.truncate - if (syncSession > 1) { + if (syncRunCount > 1) { begin() write({ type: `insert`, value: { id: `row`, version: 3 } }) commit() @@ -1541,7 +1588,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { value: { id: `row`, version: loadCount }, }) commit() - return loadCount === 1 || syncSession > 1 + return loadCount === 1 || syncRunCount > 1 ? true : replay.promise }, @@ -1577,7 +1624,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() collection.startSyncImmediate() - expect(syncSession).toBe(2) + expect(syncRunCount).toBe(2) expect([...visible.values()]).toEqual([{ id: `row`, version: 3 }]) expect(subscription.status).toBe(`loadingSubset`) await flushPromises() @@ -1602,7 +1649,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { let begin!: () => void let write!: (message: { type: `insert`; value: Row }) => void let commit!: () => void - let syncSession = 0 + let nextSyncRunGeneration = 0 let loadCount = 0 const loads: Array = [] const unloads: Array = [] @@ -1611,7 +1658,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { id: `restart-surviving-demand`, sync: { sync: (operations) => { - syncSession++ + nextSyncRunGeneration++ begin = operations.begin write = operations.write commit = operations.commit @@ -1656,7 +1703,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(loads).toHaveLength(1) await flushPromises() - expect(syncSession).toBe(2) + expect(nextSyncRunGeneration).toBe(2) expect(loads).toHaveLength(2) expect(unloads).toEqual([]) expect([...visible.values()]).toEqual([{ id: `row`, version: 2 }]) @@ -1670,21 +1717,28 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { it(`reacquires demand requested while the collection is cleaned up`, async () => { const oldWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`old`)]) const newWhere = new Func(`eq`, [new PropRef([`id`]), new Value(`new`)]) - let syncSession = 0 - const loads: Array<{ session: number; options: LoadSubsetOptions }> = [] - const unloads: Array<{ session: number; options: LoadSubsetOptions }> = [] + let nextSyncRunGeneration = 0 + const loads: Array<{ + syncRunGeneration: number + options: LoadSubsetOptions + }> = [] + const unloads: Array<{ + syncRunGeneration: number + options: LoadSubsetOptions + }> = [] const collection = createOnDemandCollection<{ id: string }>({ id: `request-while-cleaned-up`, sync: { sync: ({ markReady }) => { - const session = syncSession++ + const syncRunGeneration = nextSyncRunGeneration++ markReady() return { loadSubset: (options) => { - loads.push({ session, options }) + loads.push({ syncRunGeneration, options }) return true }, - unloadSubset: (options) => unloads.push({ session, options }), + unloadSubset: (options) => + unloads.push({ syncRunGeneration, options }), } }, }, @@ -1699,14 +1753,18 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { collection.startSyncImmediate() await flushPromises() - expect(loads.map(({ session }) => session)).toEqual([0, 1, 1]) + expect(loads.map(({ syncRunGeneration }) => syncRunGeneration)).toEqual([ + 0, 1, 1, + ]) expect(loads.slice(1).map(({ options }) => options.where)).toEqual([ oldWhere, newWhere, ]) subscription.unsubscribe() - expect(unloads.map(({ session }) => session)).toEqual([1, 1]) + expect(unloads.map(({ syncRunGeneration }) => syncRunGeneration)).toEqual([ + 1, 1, + ]) expect(unloads.map(({ options }) => options.where)).toEqual([ oldWhere, newWhere, @@ -1764,28 +1822,33 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { [oldWhere, `old`], [newWhere, `new`], ]) - const loads: Array<{ session: number; demand: `old` | `new` }> = [] - const unloads: Array<{ session: number; demand: `old` | `new` }> = [] - let session = -1 + const loads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] + const unloads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] + let nextSyncRunGeneration = -1 let requestOnRestart = false const collection = createOnDemandCollection<{ id: string }>({ id: `restart-status-reentry`, sync: { sync: ({ markReady }) => { - session++ - const adapterSession = session + const syncRunGeneration = ++nextSyncRunGeneration markReady() return { loadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown restart demand`) - loads.push({ session: adapterSession, demand }) + loads.push({ syncRunGeneration, demand }) return true }, unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown restart demand`) - unloads.push({ session: adapterSession, demand }) + unloads.push({ syncRunGeneration, demand }) }, } }, @@ -1808,16 +1871,16 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await flushPromises() expect(loads).toEqual([ - { session: 0, demand: `old` }, - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 0, demand: `old` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) expect(subscription.status).toBe(`ready`) subscription.unsubscribe() expect(unloads).toEqual([ - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) await collection.cleanup() }, @@ -1833,29 +1896,34 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { [oldWhere, `old`], [newWhere, `new`], ]) - const loads: Array<{ session: number; demand: `old` | `new` }> = [] - const unloads: Array<{ session: number; demand: `old` | `new` }> = [] + const loads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] + const unloads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] const observed: Array = [] - let session = -1 + let nextSyncRunGeneration = -1 let requestOnReady = false const collection = createOnDemandCollection<{ id: string }>({ id: `restart-ready-reentry`, sync: { sync: ({ markReady }) => { - session++ - const adapterSession = session + const syncRunGeneration = ++nextSyncRunGeneration markReady() return { loadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown ready demand`) - loads.push({ session: adapterSession, demand }) + loads.push({ syncRunGeneration, demand }) return true }, unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown ready unload`) - unloads.push({ session: adapterSession, demand }) + unloads.push({ syncRunGeneration, demand }) }, } }, @@ -1884,9 +1952,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await flushPromises() expect(loads).toEqual([ - { session: 0, demand: `old` }, - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 0, demand: `old` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) expect(observed).toEqual([expect.any(Promise)]) expect(subscription.status).toBe(`ready`) @@ -1894,8 +1962,8 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { removeReadyListener() subscription.unsubscribe() expect(unloads).toEqual([ - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) await collection.cleanup() }, @@ -1912,30 +1980,35 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { [oldWhere, `old`], [newWhere, `new`], ]) - const loads: Array<{ session: number; demand: `old` | `new` }> = [] - const unloads: Array<{ session: number; demand: `old` | `new` }> = [] + const loads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] + const unloads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] const observed: Array = [] - let session = -1 + let nextSyncRunGeneration = -1 let requestOnError = false const collection = createOnDemandCollection<{ id: string }>({ id: `restart-error-reentry`, sync: { sync: ({ markReady }) => { - session++ - const adapterSession = session - if (session === 1) throw syncFailure + const syncRunGeneration = ++nextSyncRunGeneration + if (syncRunGeneration === 1) throw syncFailure markReady() return { loadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown error demand`) - loads.push({ session: adapterSession, demand }) + loads.push({ syncRunGeneration, demand }) return true }, unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown error unload`) - unloads.push({ session: adapterSession, demand }) + unloads.push({ syncRunGeneration, demand }) }, } }, @@ -1963,23 +2036,23 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(() => collection.startSyncImmediate()).toThrow(syncFailure) expect(observed).toEqual([expect.any(Promise)]) expect(collection.status).toBe(`error`) - expect(loads).toEqual([{ session: 0, demand: `old` }]) + expect(loads).toEqual([{ syncRunGeneration: 0, demand: `old` }]) await collection.cleanup() collection.startSyncImmediate() await flushPromises() expect(loads).toEqual([ - { session: 0, demand: `old` }, - { session: 2, demand: `old` }, - { session: 2, demand: `new` }, + { syncRunGeneration: 0, demand: `old` }, + { syncRunGeneration: 2, demand: `old` }, + { syncRunGeneration: 2, demand: `new` }, ]) expect(subscription.status).toBe(`ready`) removeErrorListener() subscription.unsubscribe() expect(unloads).toEqual([ - { session: 2, demand: `old` }, - { session: 2, demand: `new` }, + { syncRunGeneration: 2, demand: `old` }, + { syncRunGeneration: 2, demand: `new` }, ]) await collection.cleanup() }, @@ -1995,29 +2068,34 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { [oldWhere, `old`], [newWhere, `new`], ]) - const loads: Array<{ session: number; demand: `old` | `new` }> = [] - const unloads: Array<{ session: number; demand: `old` | `new` }> = [] + const loads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] + const unloads: Array<{ + syncRunGeneration: number + demand: `old` | `new` + }> = [] const observed: Array = [] - let session = -1 + let nextSyncRunGeneration = -1 let requestDuringCleanup = false const collection = createOnDemandCollection<{ id: string }>({ id: `adapter-cleanup-reentry`, sync: { sync: ({ markReady }) => { - session++ - const adapterSession = session + const syncRunGeneration = ++nextSyncRunGeneration markReady() return { loadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown cleanup demand`) - loads.push({ session: adapterSession, demand }) + loads.push({ syncRunGeneration, demand }) return true }, unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown cleanup unload`) - unloads.push({ session: adapterSession, demand }) + unloads.push({ syncRunGeneration, demand }) }, cleanup: () => { if (!requestDuringCleanup) return @@ -2027,7 +2105,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { onLoadSubsetResult: (result) => observed.push(result), }) reach(`retiring:request`) - observeSourceSessionBoundary(`cleanup-callback-reentry`) + observeSyncRunBoundary(`cleanup-callback-reentry`) }, } }, @@ -2047,16 +2125,16 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await flushPromises() expect(loads).toEqual([ - { session: 0, demand: `old` }, - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 0, demand: `old` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) expect(observed).toEqual([expect.any(Promise)]) subscription.unsubscribe() expect(unloads).toEqual([ - { session: 1, demand: `old` }, - { session: 1, demand: `new` }, + { syncRunGeneration: 1, demand: `old` }, + { syncRunGeneration: 1, demand: `new` }, ]) await collection.cleanup() }, @@ -2589,15 +2667,15 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { | { status: `fulfilled`; value: unknown } | { status: `rejected`; error: unknown } const outcomes: Array<{ outcome: ResultOutcome }> = [] - let session = 0 + let nextSyncRunGeneration = 0 let requestOnReady = false const collection = createOnDemandCollection<{ id: string }>({ id: `ready-before-invalid-on-demand-return`, sync: { sync: ({ markReady }) => { - const ownSession = session++ + const syncRunGeneration = nextSyncRunGeneration++ markReady() - if (ownSession === 1) return + if (syncRunGeneration === 1) return return { loadSubset: (options) => { loads.push(options) @@ -2712,19 +2790,19 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ) it(`retires resources returned after ready-callback cleanup invalidates sync`, async () => { - const cleanupSessions: Array = [] - let session = 0 + const cleanupSyncRuns: Array = [] + let nextSyncRunGeneration = 0 let cleanOnReady = false const collection = createOnDemandCollection<{ id: string }>({ id: `obsolete-sync-return`, sync: { sync: ({ markReady }) => { - const ownSession = session++ + const syncRunGeneration = nextSyncRunGeneration++ markReady() return { loadSubset: () => true, unloadSubset: () => {}, - cleanup: () => cleanupSessions.push(ownSession), + cleanup: () => cleanupSyncRuns.push(syncRunGeneration), } }, }, @@ -2741,10 +2819,10 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() cleanOnReady = true collection.startSyncImmediate() - observeSourceSessionBoundary(`obsolete-resource-return`) + observeSyncRunBoundary(`obsolete-resource-return`) expect(collection.status).toBe(`cleaned-up`) - expect(cleanupSessions).toEqual([0, 1]) + expect(cleanupSyncRuns).toEqual([0, 1]) removeReadyListener() subscription.unsubscribe() @@ -2764,21 +2842,23 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const cleanups: Array = [] const loads: Array = [] const unloads: Array = [] - let session = -1 + let syncRunGeneration = -1 let retire = false const collection = createOnDemandCollection<{ id: string }>({ sync: { sync: ({ markReady }) => { - const ownSession = ++session + const ownSyncRunGeneration = ++syncRunGeneration markReady() - if (ownSession === 1 && entry === `adapter-throw`) throw failure + if (ownSyncRunGeneration === 1 && entry === `adapter-throw`) { + throw failure + } return { loadSubset: () => { - loads.push(ownSession) + loads.push(ownSyncRunGeneration) return true }, - unloadSubset: () => unloads.push(ownSession), - cleanup: () => cleanups.push(ownSession), + unloadSubset: () => unloads.push(ownSyncRunGeneration), + cleanup: () => cleanups.push(ownSyncRunGeneration), } }, }, @@ -2789,7 +2869,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { let removeListener = () => {} try { await collection.cleanup() - const retireSession = () => { + const retireSyncRun = () => { if (!retire) return retire = false void collection.cleanup() @@ -2798,10 +2878,10 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { } removeListener = entry === `ready-effect-throw` - ? collection.onFirstReady(retireSession) + ? collection.onFirstReady(retireSyncRun) : collection.on( entry === `loading` ? `status:loading` : `status:ready`, - retireSession, + retireSyncRun, ) retire = true if (entry === `adapter-throw` || entry === `ready-effect-throw`) { @@ -2815,7 +2895,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const returnsObsoleteCleanup = entry === `ready` || entry === `ready-effect-throw` expect(cleanups).toEqual(returnsObsoleteCleanup ? [0, 1] : [0]) - expect(session).toBe( + expect(syncRunGeneration).toBe( entry === `loading` ? (restart ? 1 : 0) : restart ? 2 : 1, ) @@ -2823,9 +2903,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { subscription.requestSnapshot({ where: new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]), }) - expect(loads).toEqual([session]) + expect(loads).toEqual([syncRunGeneration]) subscription.unsubscribe() - expect(unloads).toEqual([session]) + expect(unloads).toEqual([syncRunGeneration]) } else { expect(loads).toEqual([]) expect(unloads).toEqual([]) @@ -2840,19 +2920,19 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { acquisitionCase( [`starting:markError`, `unavailable:markReady`], - `retains demand requested during initial error for same-session recovery`, + `retains demand requested during initial error for recovery in the same sync run`, async (reach) => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] const observed: Array = [] - let syncSession = 0 + let syncRunCount = 0 let recover!: () => void const collection = createOnDemandCollection<{ id: string }>({ id: `sync-entry-error-ready-recovery`, startSync: false, sync: { sync: ({ markError, markReady }) => { - if (syncSession++ === 0) { + if (syncRunCount++ === 0) { markReady() return { loadSubset: (options) => { @@ -3100,7 +3180,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { }, ) - it(`re-enables an installed loader after same-session initial recovery`, async () => { + it(`re-enables an installed loader after initial recovery in the same sync run`, async () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const loads: Array = [] let markError!: (error: unknown) => void @@ -3323,12 +3403,12 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { it(`retires restart loading when the replacement sync fails`, async () => { const syncFailure = new Error(`replacement sync failed`) - let session = 0 + let nextSyncRunGeneration = 0 const collection = createOnDemandCollection<{ id: string }>({ id: `failed-sync-restart`, sync: { sync: ({ markReady }) => { - if (session++ > 0) throw syncFailure + if (nextSyncRunGeneration++ > 0) throw syncFailure markReady() return { loadSubset: () => true } }, @@ -3350,7 +3430,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() }) - it(`retires failed physical release with its source session cleanup`, async () => { + it(`retires failed acquisition release with its sync run cleanup`, async () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) const releaseFailure = new Error(`release failed`) let unloads = 0 @@ -3382,7 +3462,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() expect(unloads).toBe(1) expect(sourceCleanups).toBe(1) - observeSourceSessionBoundary(`active-cleanup`) + observeSyncRunBoundary(`active-cleanup`) await collection.cleanup() expect(unloads).toBe(1) @@ -3585,22 +3665,22 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await collection.cleanup() }) - it(`does not repeat failed cleanup through a replacement adapter session`, async () => { + it(`does not repeat failed cleanup through a replacement sync run`, async () => { const where = new Func(`eq`, [new PropRef([`id`]), new Value(`row`)]) - let syncSession = 0 - const unloadSessions: Array = [] - const releaseFailure = new Error(`old session release failed`) + let nextSyncRunGeneration = 0 + const unloadSyncRunGenerations: Array = [] + const releaseFailure = new Error(`old sync run release failed`) const collection = createOnDemandCollection<{ id: string }>({ - id: `cleanup-debt-session`, + id: `cleanup-debt-sync-run`, sync: { sync: ({ markReady }) => { - const session = syncSession++ + const syncRunGeneration = nextSyncRunGeneration++ markReady() return { loadSubset: () => true, unloadSubset: () => { - unloadSessions.push(session) - if (session === 0) throw releaseFailure + unloadSyncRunGenerations.push(syncRunGeneration) + if (syncRunGeneration === 0) throw releaseFailure }, } }, @@ -3617,7 +3697,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { await flushPromises() subscription.unsubscribe() - expect(unloadSessions).toEqual([0]) + expect(unloadSyncRunGenerations).toEqual([0]) await collection.cleanup() }) @@ -3637,26 +3717,31 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { const failure = new Error(`restart acquisition failed`) const pending = createDeferred() void pending.promise.catch(() => {}) - const loads: Array<{ session: number; demand: DemandName }> = [] - const unloads: Array<{ session: number; demand: DemandName }> = [] + const loads: Array<{ + syncRunGeneration: number + demand: DemandName + }> = [] + const unloads: Array<{ + syncRunGeneration: number + demand: DemandName + }> = [] const sourceCleanups: Array = [] const errors: Array = [] - let session = -1 + let nextSyncRunGeneration = -1 let ranReentry = false const collection = createOnDemandCollection<{ id: string }>({ id: `restart-${outcome}-${reentry}`, sync: { sync: ({ markReady }) => { - session++ - const adapterSession = session + const syncRunGeneration = ++nextSyncRunGeneration markReady() return { loadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown restart demand`) - loads.push({ session: adapterSession, demand }) - if (adapterSession === 0 || demand === `peer`) return true + loads.push({ syncRunGeneration, demand }) + if (syncRunGeneration === 0 || demand === `peer`) return true if (!ranReentry) { ranReentry = true if (reentry === `release-self`) { @@ -3676,9 +3761,9 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { unloadSubset: (options) => { const demand = demandForWhere.get(options.where) if (!demand) throw new Error(`unknown restart demand`) - unloads.push({ session: adapterSession, demand }) + unloads.push({ syncRunGeneration, demand }) }, - cleanup: () => sourceCleanups.push(adapterSession), + cleanup: () => sourceCleanups.push(syncRunGeneration), } }, }, @@ -3697,7 +3782,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(sourceCleanups).toEqual([0]) observePhysicalInteraction(`active:cleanup`, `retire`) collection.startSyncImmediate() - observeSourceSessionBoundary(`restart-installed`) + observeSyncRunBoundary(`restart-installed`) await flushPromises() if (outcome === `resolve`) pending.resolve() if (outcome === `reject`) pending.reject(failure) @@ -3710,10 +3795,12 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { reentry !== `unsubscribe` && reentry !== `cleanup` expect(loads).toEqual([ - { session: 0, demand: `target` }, - { session: 0, demand: `peer` }, - { session: 1, demand: `target` }, - ...(peerStarts ? [{ session: 1, demand: `peer` as const }] : []), + { syncRunGeneration: 0, demand: `target` }, + { syncRunGeneration: 0, demand: `peer` }, + { syncRunGeneration: 1, demand: `target` }, + ...(peerStarts + ? [{ syncRunGeneration: 1, demand: `peer` as const }] + : []), ]) expect(errors).toEqual( (outcome === `throw` || outcome === `reject`) && targetSurvives @@ -3724,12 +3811,14 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { if (reentry !== `unsubscribe`) subscription.unsubscribe() expect(unloads).toEqual([ ...(targetEstablished && !targetSurvives - ? [{ session: 1, demand: `target` as const }] + ? [{ syncRunGeneration: 1, demand: `target` as const }] : []), ...(targetEstablished && targetSurvives - ? [{ session: 1, demand: `target` as const }] + ? [{ syncRunGeneration: 1, demand: `target` as const }] + : []), + ...(peerStarts + ? [{ syncRunGeneration: 1, demand: `peer` as const }] : []), - ...(peerStarts ? [{ session: 1, demand: `peer` as const }] : []), ]) await collection.cleanup() expect(sourceCleanups).toEqual([0, 1]) @@ -3893,30 +3982,30 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { ) it.each(threeGenerationScenarios)( - `fences three generations for $obsoleteOutcome/$currentOutcome settled $settlementOrder`, + `fences three sync-run generations for $obsoleteOutcome/$currentOutcome settled $settlementOrder`, async ({ obsoleteOutcome, currentOutcome, settlementOrder }) => { type Row = { id: string; version: number } const obsolete = createDeferred() const current = createDeferred() void obsolete.promise.catch(() => {}) void current.promise.catch(() => {}) - const obsoleteFailure = new Error(`obsolete generation failed`) - const currentFailure = new Error(`current generation failed`) + const obsoleteFailure = new Error(`obsolete sync run failed`) + const currentFailure = new Error(`current sync run failed`) const visible = new Map() const errors: Array = [] - const unloadSessions: Array = [] - let session = -1 + const unloadSyncRunGenerations: Array = [] + let syncRunGeneration = -1 const collection = createOnDemandCollection({ - id: `three-generation-${obsoleteOutcome}-${currentOutcome}-${settlementOrder}`, + id: `three-sync-run-generations-${obsoleteOutcome}-${currentOutcome}-${settlementOrder}`, sync: { sync: (operations) => { - session++ - const ownSession = session + syncRunGeneration++ + const ownSyncRunGeneration = syncRunGeneration operations.markReady() return { loadSubset: (options) => { - if (ownSession === 0) { + if (ownSyncRunGeneration === 0) { operations.begin() operations.write({ type: `insert`, @@ -3925,24 +4014,28 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { operations.commit(options.signal) return true } - const gate = ownSession === 1 ? obsolete : current + const gate = ownSyncRunGeneration === 1 ? obsolete : current const outcome = - ownSession === 1 ? obsoleteOutcome : currentOutcome + ownSyncRunGeneration === 1 ? obsoleteOutcome : currentOutcome const failure = - ownSession === 1 ? obsoleteFailure : currentFailure + ownSyncRunGeneration === 1 ? obsoleteFailure : currentFailure return gate.promise.then(() => { if (outcome === `reject`) throw failure operations.begin() operations.write({ type: `insert`, - value: { id: `row`, version: ownSession + 1 }, + value: { + id: `row`, + version: ownSyncRunGeneration + 1, + }, }) const receipt = operations.commit(options.signal) if (receipt !== true) return receipt return undefined }) }, - unloadSubset: () => unloadSessions.push(ownSession), + unloadSubset: () => + unloadSyncRunGenerations.push(ownSyncRunGeneration), } }, }, @@ -4007,7 +4100,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { expect(subscription.status).toBe(`ready`) subscription.unsubscribe() - expect(unloadSessions).toEqual([2]) + expect(unloadSyncRunGenerations).toEqual([2]) await collection.cleanup() }, ) @@ -4228,7 +4321,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { numRuns: 30 * multiplier, seed: 1_657_002, })( - `fences async demand settlements across restart generations for a fixed seed`, + `fences async demand settlements across sync-run generations for a fixed seed`, async (scenario) => { await runAsyncRestartScenario(scenario) }, @@ -4243,7 +4336,7 @@ describe(`CollectionSubscription demand lifecycle oracle`, () => { `subscription-lifecycle.async-restart`, ), )( - `fences async demand settlements across restart generations for a random or replayed seed`, + `fences async demand settlements across sync-run generations for a random or replayed seed`, async (scenario) => { await runAsyncRestartScenario(scenario) }, diff --git a/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts b/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts index fabcde2347..d0c002f984 100644 --- a/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts +++ b/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts @@ -20,6 +20,25 @@ import type { LifecycleModel, } from './collection-subscription-lifecycle-grammar.js' +/** + * # Which lifecycle work may become public rows? + * + * The lifecycle grammar decides which acquisition attempt is current. This + * file adds the row-publication node. A truncate opens a private replacement; + * source writes accumulate there until every gating attempt succeeds. Failure + * retires that replacement. Independent live source work remains public. + * + * The reference state uses plain Maps and batches. It does not copy the + * subscription implementation. After every command, the driver compares exact + * insert, update, and delete batches, visible rows, status, syncRuns, and + * unloads. It also records the phase before each observation so a final-state + * match cannot hide an early or duplicate publication. + * + * Ownership and caller-promise outcomes stay in the lifecycle-history driver. + * This separation keeps the row model small while the shared command grammar + * still exercises their real dependency edge. + */ + type RowKey = DemandName | `c` | `d` type Row = { id: RowKey; value: number } type PublicationChange = { @@ -43,7 +62,7 @@ type RuntimeAttempt = { id: number ownerId: number demand: DemandName - session: number + syncRunGeneration: number operations: SyncOperations deferred: ReturnType> signal: AbortSignal | undefined @@ -58,8 +77,8 @@ type RuntimeOwner = { attemptId?: number } type Replacement = { - session: number - replay: number + syncRunGeneration: number + replayGeneration: number rows: Map failed: boolean } @@ -88,7 +107,7 @@ type PublicationObservation = { settlement?: `resolve` | `reject` publications: number unloads: number - sessions: number + syncRuns: number collectionStatus: string } type PublicationMismatch = { @@ -120,7 +139,7 @@ type TerminalPublicationSnapshot = { subscriberCount: number attempts: number unloads: Array - sessions: number + syncRuns: number } function assertTerminalPublication( @@ -307,8 +326,8 @@ function projectPublication( } if (lifecycle.publicationBarrierOpen) { publication.replacement = { - session: lifecycle.session, - replay: lifecycle.replay, + syncRunGeneration: lifecycle.syncRunGeneration, + replayGeneration: lifecycle.replayGeneration, rows: new Map(publication.source), failed: false, } @@ -348,8 +367,8 @@ function projectPublication( lifecycle.publicationBarrierOpen ) { publication.replacement = { - session: lifecycle.session, - replay: lifecycle.replay, + syncRunGeneration: lifecycle.syncRunGeneration, + replayGeneration: lifecycle.replayGeneration, rows: new Map(), failed: false, } @@ -407,8 +426,8 @@ function projectPublication( const replacement = publication.replacement if ( replacement && - replacement.session === attempt.session && - replacement.replay === attempt.replay + replacement.syncRunGeneration === attempt.syncRunGeneration && + replacement.replayGeneration === attempt.replayGeneration ) { publication.source.set(row.id, row) replacement.rows.set(row.id, row) @@ -520,7 +539,7 @@ async function runPublicationHistory( const unloads: Array = [] const owners: Array = [] const sourceRows = new Map>() - const operationsBySession = new Map() + const operationsBySyncRun = new Map() const outcomes = new Map< number, { state: `resolved` } | { state: `rejected`; error: unknown } @@ -528,7 +547,7 @@ async function runPublicationHistory( const outcomeObservers = new Map>() let nextAttemptId = 0 let nextOwnerId = 0 - let session = -1 + let syncRun = -1 let active = true let unsubscribed = false @@ -538,9 +557,9 @@ async function runPublicationHistory( syncMode: runOptions.withoutLoader ? `eager` : `on-demand`, sync: { sync: (operations) => { - const ownSession = ++session - operationsBySession.set(ownSession, operations) - sourceRows.set(ownSession, new Map()) + const ownSyncRun = ++syncRun + operationsBySyncRun.set(ownSyncRun, operations) + sourceRows.set(ownSyncRun, new Map()) operations.markReady() if (runOptions.withoutLoader) return return { @@ -574,7 +593,7 @@ async function runPublicationHistory( id, ownerId: owner.id, demand, - session: ownSession, + syncRunGeneration: ownSyncRun, operations, deferred, signal: options.signal, @@ -641,14 +660,14 @@ async function runPublicationHistory( subscriberCount: collection.subscriberCount, attempts: attempts.size, unloads: [...unloads], - sessions: operationsBySession.size, + syncRuns: operationsBySyncRun.size, }) const writeAttempt = async (attempt: RuntimeAttempt): Promise => { // Cancellation fences request-scoped writes at the adapter boundary. // Transport may settle later; it must not publish canceled snapshot rows. if (attempt.signal?.aborted) return - const rows = sourceRows.get(attempt.session) + const rows = sourceRows.get(attempt.syncRunGeneration) const previous = rows?.get(attempt.demand) const value = { id: attempt.demand, value: attempt.id } attempt.operations.begin() @@ -720,7 +739,7 @@ async function runPublicationHistory( const observedPublicationCount = observedBatches.length const expectedPublicationCount = publication.batches.length const unloadCount = unloads.length - const sessionCount = operationsBySession.size + const syncRunCount = operationsBySyncRun.size let executed = false let sourceEffect: SourceEffect | undefined let settlement: `resolve` | `reject` | undefined @@ -749,8 +768,8 @@ async function runPublicationHistory( : reduceLifecycle(lifecycle, command) if (command.type === `source` && active) { - const operations = operationsBySession.get(session) - const rows = sourceRows.get(session) + const operations = operationsBySyncRun.get(syncRun) + const rows = sourceRows.get(syncRun) const previous = rows?.get(command.key) executed = operations !== undefined && rows !== undefined sourceEffect = @@ -824,7 +843,7 @@ async function runPublicationHistory( } owner.attemptId = undefined } - const operations = operationsBySession.get(session) + const operations = operationsBySyncRun.get(syncRun) operations?.begin() operations?.truncate() if (command.replacement) { @@ -835,10 +854,10 @@ async function runPublicationHistory( } const receipt = operations?.commit() if (receipt !== true) await receipt - sourceRows.get(session)?.clear() + sourceRows.get(syncRun)?.clear() if (command.replacement) { sourceRows - .get(session) + .get(syncRun) ?.set(command.replacement.id, cloneRow(command.replacement)) } } else if (command.type === `cleanup` && active) { @@ -912,7 +931,7 @@ async function runPublicationHistory( ...(settlement ? { settlement } : {}), publications: observedBatches.length - observedPublicationCount, unloads: unloads.length - unloadCount, - sessions: operationsBySession.size - sessionCount, + syncRuns: operationsBySyncRun.size - syncRunCount, collectionStatus: collection.status, }) } @@ -1146,6 +1165,14 @@ const publicationControlCases = publicationProductCases.filter( !replacementOrderingCases.includes(scenario) && !replacementRetirementCases.includes(scenario), ) +// Keep each semantic cell intact while giving the aggregate campaign several +// scheduler cuts. One 204-history test can exceed Vitest's per-test deadline +// when the full oracle portfolio runs concurrently, even though no history is +// slow or stuck. +const publicationControlSlices = Array.from({ length: 4 }, (_, index) => ({ + name: `${index * 51 + 1}-${(index + 1) * 51}`, + scenarios: publicationControlCases.slice(index * 51, (index + 1) * 51), +})) async function runPublicationProduct( scenarios: ReadonlyArray, @@ -1193,7 +1220,7 @@ async function runPublicationProduct( } else if (scenario.suffix === `cleanup`) { expect(suffix.collectionStatus, scenario.name).toBe(`cleaned-up`) } else if (scenario.suffix === `restart`) { - expect(suffix.sessions, scenario.name).toBe(1) + expect(suffix.syncRuns, scenario.name).toBe(1) } else { const ownerCount = scenario.phase === `private-settling` || @@ -1289,7 +1316,7 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { subscriberCount: 0, attempts: 2, unloads: [0, 1], - sessions: 1, + syncRuns: 1, } const actual: TerminalPublicationSnapshot = { ...retired, @@ -1623,11 +1650,12 @@ describe(`CollectionSubscription lifecycle publication oracle`, () => { expect(publicationControlCases).toHaveLength(204) }) - it(`matches row publications for lifecycle control cells`, async () => { - expectNoPublicationMismatches( - await runPublicationProduct(publicationControlCases), - ) - }) + it.each(publicationControlSlices)( + `matches row publications for lifecycle control cells $name`, + async ({ scenarios }) => { + expectNoPublicationMismatches(await runPublicationProduct(scenarios)) + }, + ) it(`preserves independent source work when a successful replay publishes`, async () => { expectNoPublicationMismatches( diff --git a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts index ac09a533bd..53b106e38b 100644 --- a/packages/db/tests/collection-subscription-replay-oracle.property.test.ts +++ b/packages/db/tests/collection-subscription-replay-oracle.property.test.ts @@ -19,6 +19,27 @@ import type { } from '../src/types.js' import type { Scheduler } from 'fast-check' +/** + * # Does replay replace a source atomically under hostile timing? + * + * Replay rebuilds a subscription while old acquisitions, source writes, + * optimistic state, callbacks, and cleanup may still be in flight. The public + * snapshot must change only when the newest complete replay earns authority. + * Older, failed, aborted, or released work may settle and release resources, + * but it must not leak rows or overwrite a newer snapshot. + * + * Small scenario records describe source rows, logical demands, replay + * attempts, settlement order, release points, and later source actions. Plain + * Map-based reducers compute the expected snapshot and event batches. The + * production driver uses real Collection subscriptions, indexes, transactions, + * and callback reentry. It compares every intermediate publication, not only + * the final rows. + * + * This file owns replay-specific data and timing. General owner/attempt status + * is modeled in the lifecycle grammar, and optimistic transaction semantics + * are modeled by the optimistic-history oracle. + */ + type ReplayRow = { id: `one` | `two` value: number @@ -368,7 +389,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { let truncate!: () => void let loadCount = 0 let unloadCount = 0 - const leases = new Map< + const acquisitionCounts = new Map< LoadSubsetOptions, { acquisitions: number; releases: number } >() @@ -446,12 +467,12 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { return { loadSubset: (options) => { loadCount++ - const lease = leases.get(options) ?? { + const counts = acquisitionCounts.get(options) ?? { acquisitions: 0, releases: 0, } - lease.acquisitions++ - leases.set(options, lease) + counts.acquisitions++ + acquisitionCounts.set(options, counts) const demandId = options.where === undefined ? undefined @@ -499,12 +520,12 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { }, unloadSubset: (options) => { unloadCount++ - const lease = leases.get(options) ?? { + const counts = acquisitionCounts.get(options) ?? { acquisitions: 0, releases: 0, } - lease.releases++ - leases.set(options, lease) + counts.releases++ + acquisitionCounts.set(options, counts) }, } }, @@ -573,7 +594,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { assertSource() let expectedPublicationCount = publicationCount let lastReportedError: Error | undefined - let modelSession: + let modelReplayState: | { baseline: Map pending: Set @@ -613,11 +634,11 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { const settleReplay = async (replayIndex: number) => { const pending = pendingReplays[replayIndex]! - const session = modelSession + const replayState = modelReplayState const load = pending.load const isCurrent = - session !== undefined && - pending.attemptIndex === session.currentAttemptIndex && + replayState !== undefined && + pending.attemptIndex === replayState.currentAttemptIndex && activeDemandIds.has(load.demandId) pending.settled = true if (load.outcome === `resolve`) { @@ -631,31 +652,34 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { } pending.deferred.reject(pending.error) } - session?.pending.delete(replayIndex) + replayState?.pending.delete(replayIndex) await flushPromises() assertSource() - if (!session) { + if (!replayState) { expect(subscription.status).toBe(`ready`) assertPublished(expectedPublished) expect(subscription.lastError).toBe(lastReportedError) return } - const hasPendingReplay = session.pending.size > 0 + const hasPendingReplay = replayState.pending.size > 0 expect(subscription.status).toBe( hasPendingReplay ? `loadingSubset` : `ready`, ) - if (session.pending.size === 0) { - const currentAttempt = scenario.attempts[session.currentAttemptIndex]! + if (replayState.pending.size === 0) { + const currentAttempt = + scenario.attempts[replayState.currentAttemptIndex]! const currentAttemptSucceeds = currentAttempt.loads.every( ({ demandId, outcome }) => !activeDemandIds.has(demandId) || outcome === `resolve`, ) const previousPublication = new Map(expectedPublished) expectedPublished.clear() - const nextRows = currentAttemptSucceeds ? sourceRows : session.baseline + const nextRows = currentAttemptSucceeds + ? sourceRows + : replayState.baseline for (const [id, row] of nextRows) { expectedPublished.set(id, { ...row }) } @@ -665,7 +689,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { previousPublication, expectedPublished, ) - expect(publicationCount - session.publicationCount).toBe( + expect(publicationCount - replayState.publicationCount).toBe( Number(expectedBatch.length > 0), ) if (expectedBatch.length > 0) { @@ -673,13 +697,13 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { sortedChanges(expectedBatch), ) } - modelSession = undefined + modelReplayState = undefined } else { - expect(publicationCount).toBe(session.publicationCount) + expect(publicationCount).toBe(replayState.publicationCount) } expectedPublicationCount = publicationCount } else { - expect(publicationCount).toBe(session.publicationCount) + expect(publicationCount).toBe(replayState.publicationCount) } assertPublished(expectedPublished) @@ -688,13 +712,13 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { } for (const [attemptIndex, attempt] of scenario.attempts.entries()) { - modelSession ??= { + modelReplayState ??= { baseline: new Map(expectedPublished), pending: new Set(), currentAttemptIndex: attemptIndex, publicationCount: expectedPublicationCount, } - modelSession.currentAttemptIndex = attemptIndex + modelReplayState.currentAttemptIndex = attemptIndex for (const load of attempt.loads) { queuedLoads.push({ attemptIndex, load }) @@ -710,7 +734,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { replayIndex < pendingReplays.length; replayIndex++ ) { - modelSession.pending.add(replayIndex) + modelReplayState.pending.add(replayIndex) const pending = pendingReplays[replayIndex]! if (pending.load.writeBeforeSettlement) { writeReplayRows(pending, true) @@ -723,26 +747,26 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { const releasedDemand = scenario.releaseOnLastAttempt subscription.releaseSnapshot(demandWheres.get(releasedDemand)!) activeDemandIds.delete(releasedDemand) - for (const replayIndex of modelSession.pending) { + for (const replayIndex of modelReplayState.pending) { if (pendingReplays[replayIndex]?.load.demandId === releasedDemand) { - modelSession.pending.delete(replayIndex) + modelReplayState.pending.delete(replayIndex) } } // A released request does not retract rows already applied by the // source, nor change the retained baseline of an unfinished replay. - if (modelSession.pending.size === 0 && activeDemandIds.size === 0) { + if (modelReplayState.pending.size === 0 && activeDemandIds.size === 0) { expectedPublicationCount = publicationCount - modelSession = undefined + modelReplayState = undefined } } assertSource() assertPublished(expectedPublished) expect(publicationCount).toBe( - modelSession?.publicationCount ?? expectedPublicationCount, + modelReplayState?.publicationCount ?? expectedPublicationCount, ) expect(subscription.lastError).toBe(lastReportedError) expect(subscription.status).toBe( - modelSession && modelSession.pending.size > 0 + modelReplayState && modelReplayState.pending.size > 0 ? `loadingSubset` : `ready`, ) @@ -759,7 +783,7 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { } } - expect(modelSession?.pending.size ?? 0).toBe(0) + expect(modelReplayState?.pending.size ?? 0).toBe(0) for (const action of scenario.afterSettlement) { const countBeforeAction = publicationCount @@ -772,17 +796,17 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { where: demandWheres.get(action.demandId), }) const row = sourceRows.get(action.demandId) - if (!modelSession && row) { + if (!modelReplayState && row) { expectedPublished.set(action.demandId, { ...row }) } } const applied = applySourceAction(action) if (applied && action.type === `delete`) { - if (!modelSession) expectedPublished.delete(action.id) + if (!modelReplayState) expectedPublished.delete(action.id) } else if (applied && action.type === `put`) { recordExpectedSourceWrite([action.row], { type: `ordinary` }, true) assertSourceWrites() - if (!modelSession) { + if (!modelReplayState) { expectedPublished.set(action.row.id, { ...action.row }) } } @@ -793,7 +817,8 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { expectedPublished, ) const expectsPublication = - !modelSession && (action.type === `request` || expectedBatch.length > 0) + !modelReplayState && + (action.type === `request` || expectedBatch.length > 0) expect(publicationCount).toBe( countBeforeAction + Number(expectsPublication), ) @@ -807,8 +832,8 @@ async function runReplayScenario(scenario: ReplayScenario): Promise { subscription.unsubscribe() unsubscribed = true expect(unloadCount).toBe(loadCount) - for (const lease of leases.values()) { - expect(lease).toEqual({ acquisitions: 1, releases: 1 }) + for (const counts of acquisitionCounts.values()) { + expect(counts).toEqual({ acquisitions: 1, releases: 1 }) } assertSourceWrites() } finally { @@ -834,7 +859,7 @@ async function runSequentialReplayScenario( let nextError: Error | undefined let initialLoad = true const sourceRows = new Map() - const leases = new Map< + const acquisitionCounts = new Map< LoadSubsetOptions, { acquisitions: number; releases: number } >() @@ -873,7 +898,10 @@ async function runSequentialReplayScenario( if (initialLoad) { initialLoad = false applyRows(scenario.initialRows) - leases.set(options, { acquisitions: 1, releases: 0 }) + acquisitionCounts.set(options, { + acquisitions: 1, + releases: 0, + }) return true } @@ -889,18 +917,21 @@ async function runSequentialReplayScenario( throw error } - leases.set(options, { acquisitions: 1, releases: 0 }) + acquisitionCounts.set(options, { + acquisitions: 1, + releases: 0, + }) if (load.outcome === `return`) return true const deferred = createDeferred() pending.push({ load, deferred, error }) return deferred.promise }, unloadSubset: (options) => { - const lease = leases.get(options) - if (!lease) { + const counts = acquisitionCounts.get(options) + if (!counts) { throw new Error(`Released an acquisition that never returned`) } - lease.releases++ + counts.releases++ }, } }, @@ -982,8 +1013,8 @@ async function runSequentialReplayScenario( subscription.unsubscribe() unsubscribed = true - for (const lease of leases.values()) { - expect(lease).toEqual({ acquisitions: 1, releases: 1 }) + for (const counts of acquisitionCounts.values()) { + expect(counts).toEqual({ acquisitions: 1, releases: 1 }) } } finally { for (const load of pending) load.deferred.resolve() @@ -996,16 +1027,16 @@ async function runSequentialReplayScenario( async function runCleanupRestartScenario( scenario: CleanupRestartScenario, ): Promise { - const sessions: Array<{ + const syncRuns: Array<{ begin: () => void write: (message: ChangeMessageOrDeleteKeyMessage) => void commit: () => void }> = [] const loads: Array<{ - session: number + syncRunGeneration: number deferred: ReturnType> }> = [] - let session = 0 + let nextSyncRunGeneration = 0 const collection = createCollection({ id: `cleanup-restart-oracle`, getKey: (row) => row.id, @@ -1013,13 +1044,13 @@ async function runCleanupRestartScenario( startSync: true, sync: { sync: ({ begin, write, commit, markReady }) => { - const currentSession = session++ - sessions.push({ begin, write, commit }) + const syncRunGeneration = nextSyncRunGeneration++ + syncRuns.push({ begin, write, commit }) markReady() return { loadSubset: () => { const deferred = createDeferred() - loads.push({ session: currentSession, deferred }) + loads.push({ syncRunGeneration, deferred }) return deferred.promise }, } @@ -1030,7 +1061,11 @@ async function runCleanupRestartScenario( const settle = async (loadIndex: number, outcome: `resolve` | `reject`) => { const load = loads[loadIndex]! if (outcome === `resolve`) load.deferred.resolve() - else load.deferred.reject(new Error(`session ${load.session} failed`)) + else { + load.deferred.reject( + new Error(`sync run ${load.syncRunGeneration} failed`), + ) + } await flushPromises() } @@ -1047,19 +1082,21 @@ async function runCleanupRestartScenario( const newResult = collection._sync.loadSubset({}) expect(newResult).toBeInstanceOf(Promise) if (newResult instanceof Promise) void newResult.catch(() => {}) - expect(loads.map(({ session: loadSession }) => loadSession)).toEqual([0, 1]) + expect(loads.map(({ syncRunGeneration }) => syncRunGeneration)).toEqual([ + 0, 1, + ]) expect(collection.isLoadingSubset).toBe(true) - const oldSession = sessions[0]! - oldSession.begin() - oldSession.write({ type: `insert`, value: { id: `one`, value: 1 } }) - oldSession.commit() + const oldSyncRun = syncRuns[0]! + oldSyncRun.begin() + oldSyncRun.write({ type: `insert`, value: { id: `one`, value: 1 } }) + oldSyncRun.commit() expect(collection.toArray).toEqual([]) - const currentSession = sessions[1]! - currentSession.begin() - currentSession.write({ type: `insert`, value: { id: `two`, value: 2 } }) - currentSession.commit() + const currentSyncRun = syncRuns[1]! + currentSyncRun.begin() + currentSyncRun.write({ type: `insert`, value: { id: `two`, value: 2 } }) + currentSyncRun.commit() expect(collection.toArray.map(({ id, value }) => ({ id, value }))).toEqual([ { id: `two`, value: 2 }, ]) @@ -1108,15 +1145,18 @@ async function expectScheduledReplaySettlementIsGenerationSafe( actions.markReady() return { loadSubset: ({ signal }) => { - const generation = loads.length + 1 + const replayAttemptGeneration = loads.length + 1 const outcome = scheduler - .schedule(Promise.resolve(), `generation-${generation}`) + .schedule( + Promise.resolve(), + `replay-attempt-${replayAttemptGeneration}`, + ) .then(() => { if (signal?.aborted) return begin() write({ type: `insert`, - value: { id: `one`, value: generation }, + value: { id: `one`, value: replayAttemptGeneration }, }) commit() }) @@ -2420,20 +2460,20 @@ describe(`CollectionSubscription replay oracle`, () => { // Narrow retention witness for old and new representations. Follow // stored replay frames, not a captured map that the source discarded. type Frame = { failures?: Map } - const session = ( + const replayState = ( subscription as unknown as { - truncateReplaySession: Frame & { + truncateReplayState: Frame & { currentAttempt: Frame attempts?: Set pending?: Set<{ attempt: Frame }> } } - ).truncateReplaySession + ).truncateReplayState const frames = new Set([ - session, - session.currentAttempt, - ...(session.attempts ?? []), - ...[...(session.pending ?? [])].map(({ attempt }) => attempt), + replayState, + replayState.currentAttempt, + ...(replayState.attempts ?? []), + ...[...(replayState.pending ?? [])].map(({ attempt }) => attempt), ]) return [...frames].flatMap((frame) => [ ...(frame.failures?.values() ?? []), @@ -3260,9 +3300,11 @@ describe(`CollectionSubscription replay oracle`, () => { const loads: Array = [] const unloads: Array = [] let reentered = false - const releaseFailure = new Error(`old replay lease release failed`) + const releaseFailure = new Error( + `old replay acquisition lease release failed`, + ) const collection = createCollection({ - id: `reentrant-replay-lease-replacement`, + id: `reentrant-replay-acquisition-lease-replacement`, getKey: ({ id }) => id, syncMode: `on-demand`, sync: { @@ -3954,7 +3996,7 @@ describe(`CollectionSubscription replay oracle`, () => { } }) - it(`waits for a new async demand acquired while unloading a replay lease`, async () => { + it(`waits for a new async demand acquired while unloading a replay acquisition lease`, async () => { let begin!: () => void let write!: ( message: ChangeMessageOrDeleteKeyMessage, @@ -4496,7 +4538,7 @@ describe(`CollectionSubscription replay oracle`, () => { numRuns: generatedRuns, seed: 1757, })( - `isolates cleanup and restart sessions for a fixed seed`, + `isolates cleanup and restart sync runs for a fixed seed`, runCleanupRestartScenario, ) @@ -4508,7 +4550,7 @@ describe(`CollectionSubscription replay oracle`, () => { `subscription-replay.restart`, ), )( - `isolates cleanup and restart sessions for a random or replayed seed`, + `isolates cleanup and restart sync runs for a random or replayed seed`, runCleanupRestartScenario, ) diff --git a/packages/db/tests/collection.test.ts b/packages/db/tests/collection.test.ts index 09237d7fa3..ae44bfd3a8 100644 --- a/packages/db/tests/collection.test.ts +++ b/packages/db/tests/collection.test.ts @@ -2225,7 +2225,7 @@ describe(`Collection isLoadingSubset property`, () => { expect(collection.isLoadingSubset).toBe(false) }) - it(`cleanup isolates subset loading state from a later sync session`, async () => { + it(`cleanup isolates subset loading state from a later sync run`, async () => { const resolveLoads: Array<() => void> = [] const collection = createCollection<{ id: string; value: string }>({ id: `cleanup-isolates-subset-loading`, diff --git a/packages/db/tests/comparison.property.test.ts b/packages/db/tests/comparison.property.test.ts index d629f355f6..d52e2f02ac 100644 --- a/packages/db/tests/comparison.property.test.ts +++ b/packages/db/tests/comparison.property.test.ts @@ -10,17 +10,17 @@ import { import type { CompareOptions } from '../src/query/builder/types' /** - * Property-based tests for comparison functions + * Comparison defines the order and equality domains used by queries and + * indexes. The laws are reflexivity, deterministic sign, antisymmetry, and + * transitivity under one resolved option set. Null placement, direction, and + * lexical/locale string modes are independent axes. * - * A valid comparator must satisfy: - * 1. Consistency: compare(a, b) always returns the same value - * 2. Antisymmetry: sign(compare(a, b)) === -sign(compare(b, a)) - * 3. Transitivity: if compare(a, b) <= 0 and compare(b, c) <= 0 then compare(a, c) <= 0 - * 4. Reflexivity: compare(a, a) === 0 - * - * Note: Object comparison uses stable IDs based on creation order, which means - * comparing two different object instances has order-dependent behavior. - * These tests focus on primitives, dates, and arrays where comparison is deterministic. + * The model uses direct primitive, Date, array, and byte comparisons from the + * declared domain. It excludes unrelated object identities because production + * intentionally assigns those a creation-order ID. Equality laws separately + * cover the normalized value classes and changed-byte controls. Invalid + * non-finite comparator results are rejected before sign reduction, so NaN + * cannot masquerade as equality. */ const defaultOpts: CompareOptions = { diff --git a/packages/db/tests/conformance/contract.ts b/packages/db/tests/conformance/contract.ts index 0655c3bcf1..d945e089a5 100644 --- a/packages/db/tests/conformance/contract.ts +++ b/packages/db/tests/conformance/contract.ts @@ -1,9 +1,12 @@ /** * Cross-adapter live-query conformance harness — shared contract. * - * ONE behavioral spec for `useLiveQuery`, run against every framework adapter. - * Each adapter provides a thin `LiveQueryDriver` and the shared suite in - * `suite.ts` does the rest. + * A framework hook is a scheduler-specific view of one live-query contract. + * The shared suite owns the semantic laws: rows, order, keyed state, readiness, + * errors, enablement, recompilation, and teardown. Each adapter owns only the + * bridge from its native scheduler into the small `LiveQueryDriver` language. + * Running one shared scenario through every bridge separates engine semantics + * from React, Vue, Solid, Angular, and Svelte timing. * * Realm safety: the driver — not the scenarios — creates source collections and * supplies query operators, both imported from the *adapter's* copy of @@ -11,6 +14,11 @@ * same module realm as the adapter's hook, avoiding the dual-package * `instanceof CollectionImpl` mismatch. Scenarios never import `@tanstack/db`. * + * The model is observational rather than a second hook implementation. Each + * scenario states a source history and the exact normalized result that must be + * visible after the driver's documented flush cut. Result helpers read raw + * fields and selected rows; they do not repair malformed adapter output. + * * All current scenarios must pass. A future known bug needs an independently * approved exact failure signature; a whole-test waiver could hide another bug. */ diff --git a/packages/db/tests/conformance/infinite-contract.ts b/packages/db/tests/conformance/infinite-contract.ts index 78d4c5ead4..6fd9364b40 100644 --- a/packages/db/tests/conformance/infinite-contract.ts +++ b/packages/db/tests/conformance/infinite-contract.ts @@ -1,12 +1,21 @@ /** * Cross-adapter contract for `useLiveInfiniteQuery`. * + * The model is an ordered source plus a visible prefix split into pages. A + * fetch may extend that prefix; changing the query, collection, or page shape + * creates a new demand generation. The public observation includes flattened + * rows, page boundaries and params, continuation, in-flight state, errors, and + * the backing collection. Those facts must agree; final rows alone cannot show + * a stale page ledger or a duplicate request. + * * Drivers preserve native framework scheduling and package-realm details. * Controllable handles allow setter-to-fetch calls without an explicit driver * flush; this does not prove one shared invalidation-to-subscription interval. * Current React act and Vue synchronous effects attach during the setter. * Svelte's public fetch can start before its queued effect attaches, then waits - * internally. Preserve those distinct measured cuts, not a universal timing law. + * internally. Preserve those distinct measured cuts, not a universal timing + * law. This is why each driver remains separate even though the semantic model + * and scenario grammar are shared. */ import type { Collection } from '@tanstack/db' import type { QueryBuild, SourceHandle } from './contract' diff --git a/packages/db/tests/conformance/infinite-on-demand.ts b/packages/db/tests/conformance/infinite-on-demand.ts index c6ee5ac1bc..8050b1e107 100644 --- a/packages/db/tests/conformance/infinite-on-demand.ts +++ b/packages/db/tests/conformance/infinite-on-demand.ts @@ -2,6 +2,16 @@ import { evaluateReferenceExpression } from '../reference-expression.js' import type { Collection, LoadSubsetOptions } from '../../src/index.js' import type { BasicExpression } from '../../src/query/ir.js' +/** + * Finite on-demand provider for the framework infinite-query model. + * + * The provider snapshots a complete ordered source, validates the narrow query + * language it promises, and independently evaluates cursor predicates before + * slicing the requested window. It records exact loadSubset calls for work and + * deduplication laws. Unsupported expressions reject even on an empty source; + * silently returning no rows would turn a weak fixture into a false oracle. + */ + interface Runtime { BTreeIndex: unknown createCollection: ( diff --git a/packages/db/tests/conformance/infinite-suite.ts b/packages/db/tests/conformance/infinite-suite.ts index e7d1a70b01..d495b31d8b 100644 --- a/packages/db/tests/conformance/infinite-suite.ts +++ b/packages/db/tests/conformance/infinite-suite.ts @@ -1,4 +1,18 @@ -/** Shared behavioral suite for every `useLiveInfiniteQuery` adapter. */ +/** + * Shared behavioral suite for every `useLiveInfiniteQuery` adapter. + * + * Histories vary source rows, visible page count, query parameters, collection + * identity, input form, request timing, failure, and unmount. The independent + * expectation is a finite ordered prefix sliced into fixed-size pages; the + * on-demand fixture recomputes requests from immutable source data rather than + * borrowing the hook's page cache. Every checkpoint checks the whole public + * page ledger, not only its newest page. + * + * A driver may differ in how its framework reaches the checkpoint. It may not + * differ in the value and lifecycle facts visible there. The suite tracks all + * handles, sources, and pending fetches so cleanup is itself part of the tested + * history instead of test-runner housekeeping. + */ import { describe, expect, it, vi } from 'vitest' import { expectPageRows } from './page-laws' import { ScenarioLifetime } from './scenario-lifetime' diff --git a/packages/db/tests/conformance/suite.ts b/packages/db/tests/conformance/suite.ts index 33775ec2cd..48f82da302 100644 --- a/packages/db/tests/conformance/suite.ts +++ b/packages/db/tests/conformance/suite.ts @@ -1,9 +1,21 @@ /** * Shared live-query conformance suite. * - * Sourced bottom-up from the union of the five adapters' existing test suites - * (the "spine" + framework-agnostic "gap-closers"), plus a small tail of - * behaviors that all adapters should support. + * Think of each scenario as a short state-machine trace: + * + * create sources -> mount -> publish or reconfigure -> flush -> observe + * + * Sources and handles form an ownership graph. `ScenarioSources` and + * `ScenarioLifetime` release that graph even when an assertion fails, while + * each framework driver chooses the native scheduling operation that defines + * `flush`. The suite compares public observations, not framework internals. + * This keeps the reference smaller than any adapter and makes disagreements + * between adapters visible without copying their implementations. + * + * The scenario set came from the union of the five adapters' existing suites + * plus framework-neutral gap closers. It covers value, order, cardinality, + * lifecycle, reactivity, failure, and disposal as separate laws so a passing + * final row assertion cannot hide a wrong intermediate state. * * Each scenario has a unique stable key. All current scenarios must pass; * future known bugs need exact failure signatures, not whole-test waivers. diff --git a/packages/db/tests/cursor.property.test.ts b/packages/db/tests/cursor.property.test.ts index 901d54ead5..c90e5c9b6b 100644 --- a/packages/db/tests/cursor.property.test.ts +++ b/packages/db/tests/cursor.property.test.ts @@ -6,6 +6,17 @@ import { buildCursor } from '../src/utils/cursor.js' import { evaluateReferenceExpression } from './reference-expression.js' import type { OrderBy } from '../src/query/ir.js' +/** + * A cursor denotes the strict suffix after one ordered boundary. + * + * The reference compares candidate and boundary tuples directly, with explicit + * direction and null placement. Production builds an expression; the driver + * evaluates that expression against candidate rows and requires the same + * Boolean answer. Unsupported composite cursor construction must reject rather + * than silently approximate it. A retained local-snapshot path still checks + * multi-term nullable ordering without claiming composite cursor support. + */ + type Term = { direction: `asc` | `desc` nulls: `first` | `last` diff --git a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts index 1915d80d2c..5e21b8251f 100644 --- a/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts +++ b/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts @@ -12,6 +12,22 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import { flushPromises } from './utils.js' import type { ChangeMessage, SyncConfig } from '../src/types.js' +/** + * D2 source reconciliation turns source-key snapshots into exact signed row + * changes across graph lifetimes. + * + * The model has three separate nodes: authoritative source rows, production's + * sent-row memory, and an independently integrated weighted relation. Generated + * batches may lie about previous values, reuse equal keys of different JS + * types, replay rows, delete, truncate, tear down, and restart. The reference + * derives truth from the source-key map, never from reported previous values. + * + * After each cut the raw reconciled messages must integrate to the source + * relation, and a real live query must publish the same rows through both scan + * and index routes. This catches locally balanced messages that still leave the + * downstream graph wrong. + */ + type SourceRow = { id: number revision: number diff --git a/packages/db/tests/effect-disposal-oracle.test.ts b/packages/db/tests/effect-disposal-oracle.test.ts index 7428721eb7..52eea06af9 100644 --- a/packages/db/tests/effect-disposal-oracle.test.ts +++ b/packages/db/tests/effect-disposal-oracle.test.ts @@ -3,8 +3,18 @@ import { createCollection, createEffect } from '../src/index.js' import { createDeferred } from '../src/deferred.js' import { flushPromises } from './utils.js' -// One disposal attempt has one outcome, even when abort/release callbacks -// reenter it. Counting physical releases alone misses divergent caller results. +/** + * # What does concurrent effect disposal mean? + * + * Every call to `dispose()` joins one disposal attempt. Abort and release + * callbacks may call `dispose()` again while that attempt is running. All + * callers must then observe the same fulfillment or the same normalized error, + * and the source acquisition lease must release exactly once. + * + * This finite matrix crosses the two reentry sites, a pending or synchronous + * batch handler, and success, Error, or `undefined` failure. Counting releases + * alone would miss callers that disagree about the outcome. + */ const scenarios = ([`abort`, `release`] as const).flatMap((reentry) => [false, true].flatMap((pendingHandler) => ([`success`, `error`, `undefined`] as const).map((outcome) => ({ @@ -89,7 +99,7 @@ describe(`Effect disposal outcome oracle`, () => { } expect(result).toEqual(observed[0]) } - // A settled failed attempt does not make the source lease retryable. + // A settled failed attempt does not make the acquisition lease retryable. await effect.dispose() expect(releases).toBe(1) } finally { diff --git a/packages/db/tests/index-update.property.test.ts b/packages/db/tests/index-update.property.test.ts index 94f83019f7..86e7dfd50e 100644 --- a/packages/db/tests/index-update.property.test.ts +++ b/packages/db/tests/index-update.property.test.ts @@ -9,6 +9,16 @@ import { makeComparator } from '../src/utils/comparison.js' import { indexedKeysSet, orderedEntriesArray, valueMapData } from './utils' import type { BaseIndex, IndexInterface } from '../src/indexes/base-index.js' +/** + * An index is a derived multimap from indexed value to source keys. + * + * A native Map is the sole ownership model. After put, delete, or full build, + * the oracle groups that Map by value and freshly sorts the groups. BasicIndex + * and BTreeIndex must agree on key count, key membership, equality/range lookup, + * ordered entries, and rebuild behavior. Small duplicate values and signed zero + * force collisions in the value groups without copying either index structure. + */ + type IndexValue = number type IndexConstructor = new ( diff --git a/packages/db/tests/live-query-observer-history.property.test.ts b/packages/db/tests/live-query-observer-history.property.test.ts index 6b313ee76f..3879545978 100644 --- a/packages/db/tests/live-query-observer-history.property.test.ts +++ b/packages/db/tests/live-query-observer-history.property.test.ts @@ -65,8 +65,26 @@ type RunOptions = { reach?: Reach } -/* -Contract and ownership table (source: issue #1813 and the named focused tests): +/** + * # Which listeners may receive each live-query publication? + * + * The model is an eligibility ledger, not a second dispatch queue. Each + * listener is active or inactive and may hold one reconstructed row version. + * A publication snapshots eligible listeners in subscription order. Reentrant + * publications join the FIFO behind it. Removing, adding, throwing, or + * disposing during delivery affects later work according to the table below. + * + * Generated subscribe, unsubscribe, publish, and dispose histories drive both + * granular and wholesale observers. The driver compares exact listener order, + * multiplicity, raw batches, reconstructed rows, bootstrap flags, and the first + * surfaced error after every command. Fixed histories prove every command and + * reaction is reachable; injected faults calibrate the observations. + * + * Hydration, status scheduling, resource ownership, and multi-row layout have + * separate focused owners. This model stays one-row on purpose: listener + * eligibility is independent of query layout. + * + * ## Contract and ownership table | Contract | History/domain | Production path | Observation and checkpoint | Owner and limits | | --- | --- | --- | --- | --- | @@ -83,10 +101,10 @@ Contract and ownership table (source: issue #1813 and the named focused tests): | source authority | abandoned read, consumed seed, or later observer | syncHydrationState -> markLiveResultAuthoritative/_consumeLiveQueryResult | which server/live result remains authoritative | focused `live-query-observer.test.ts` tests `does not consume...abandoned render`, `does not replay...later observer`, and the stale-server tests | | client resource lifecycle | preload/dehydrate/cleanup and streamed results | DbClient live-query registry | result/error/cleanup ownership | focused `db-client.test.ts`; framework/native wiring remains outside this oracle | -The generated owner compares one-row insert/update histories. It preserves the + * The generated owner compares one-row insert/update histories. It preserves the raw callback batch, but does not claim multi-row layout/order, hydration, status scheduling, preload, framework wiring, or native boundaries. -*/ + */ const reactions: ReadonlyArray = [ `none`, diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 4273e49078..eb35987e46 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -6,6 +6,29 @@ import { createDeferred } from '../src/deferred.js' import { createLiveQueryCollection } from '../src/query/index.js' import type { CollectionConfig, SyncConfig } from '../src/types.js' +/** + * # Which rows should optimistic and synced histories expose? + * + * A mutation authors a whole-row snapshot. That snapshot does not rebase onto + * later synced data. While active, an optimistic intent overlays the synced + * base. After success it remains as an accepted local snapshot until source + * acknowledgement retires it. Failure removes it. An update authored from an + * unacknowledged insert depends on that insert's existence. + * + * The reference graph has three small nodes: a synced base Map, an ordered list + * of authored intents, and a queue of source batches. `visible()` folds accepted + * intents before active intents over the base. Settlement changes intent state; + * source drain changes the base and acknowledgement. It does not reuse + * production caches, pending-mutation mergers, or publication code. + * + * `runOptimisticHistory` gives the same edit, delete, settle, and sync history + * to this model and a real Collection. After every step it compares rows, + * metadata, immutable handler payloads, promise outcomes, downstream query + * state, and complete publication cuts. Fault injection proves those + * observations can reject wrong keys, partial batches, stale previous values, + * and transient fields. + */ + export type HistoryRow = { id: number; a: number; b: number; c: number } type Fields = Partial> export type OptimisticStep = @@ -43,11 +66,7 @@ type ObservedRow = HistoryRow & { $synced: boolean } -/** Specification state is an event history, never a copy of production caches. - * Mutations own whole-row snapshots, never patches over changing synced rows. - * Accepted snapshots precede active snapshots. An insert supplies row existence; - * accepted updates dependent on it survive its success, but not its failed birth. - */ +/** Pure event-history model. It never reads production caches or callbacks. */ class HistoryModel { base = new Map() origins = new Map() diff --git a/packages/db/tests/optimistic-history-outcomes.test.ts b/packages/db/tests/optimistic-history-outcomes.test.ts index 17a08331db..aae9bbcfa3 100644 --- a/packages/db/tests/optimistic-history-outcomes.test.ts +++ b/packages/db/tests/optimistic-history-outcomes.test.ts @@ -13,6 +13,20 @@ import type { OptimisticStep, } from './optimistic-history-oracle.js' +/** + * # Do callers observe the exact optimistic request outcome? + * + * Row correctness is not enough. A request may remain pending, fulfill with a + * specific value, reject with the original reason, or roll back while an + * independent peer survives. This driver crosses those outcomes with existing + * or absent rows, optimistic visibility, queued source work, and peer + * settlement, then delegates row and event truth to the history model. + * + * The second group calibrates the outcome observer itself. It proves premature + * success, copied values, replaced errors, and missing errors are detectable, + * and that a shrunk failure can be replayed by seed and path. + */ + type Scenario = { existing: boolean optimistic: boolean diff --git a/packages/db/tests/optimistic-history-publication.test.ts b/packages/db/tests/optimistic-history-publication.test.ts index c91d450e5b..90126a512c 100644 --- a/packages/db/tests/optimistic-history-publication.test.ts +++ b/packages/db/tests/optimistic-history-publication.test.ts @@ -2,6 +2,17 @@ import { expect, it } from 'vitest' import { runOptimisticHistory } from './optimistic-history-oracle.js' import type { HistoryRow, OptimisticStep } from './optimistic-history-oracle.js' +/** + * # Does each optimistic history publish complete source cuts? + * + * These short distinguishing histories focus on publication boundaries that a + * final-row assertion can miss: deletes of prior rows, truncate replacement, + * origin retention, rollback followed by queued source drain, and exact event + * semantics. The shared history model supplies expected rows and batches. + * Corruption cases prove the driver rejects wrong keys, partial or reversed + * cuts, stale previous values, and updates mislabeled as inserts. + */ + const initial: Array = [ { id: 1, a: 0, b: 0, c: 0 }, { id: 2, a: 0, b: 0, c: 0 }, diff --git a/packages/db/tests/optimistic-transaction-oracle.property.test.ts b/packages/db/tests/optimistic-transaction-oracle.property.test.ts index 0990d0133e..801e15f77a 100644 --- a/packages/db/tests/optimistic-transaction-oracle.property.test.ts +++ b/packages/db/tests/optimistic-transaction-oracle.property.test.ts @@ -14,6 +14,22 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import type { Collection } from '../src/collection/index.js' import type { ChangeMessage, SyncConfig } from '../src/types.js' +/** + * # Are transaction payloads and publications whole and ordered? + * + * A transaction may author insert, update, and delete operations in any order. + * Same-key operations collapse to one net request, but the request must retain + * every authored field and the correct original and modified snapshots. + * Mixed-key transactions publish one complete cut; observers must not see a + * prefix of the transaction. + * + * Small finite matrices cover operation order, optimistic visibility, and + * resolve, reject, or rollback. Generated payloads vary values and strings. + * The independent history model judges rows and outcomes, while this driver + * also inspects the real mutation payload and callback batches. Deliberate + * corruptions calibrate each observation. + */ + type Row = { id: number; value: number; note: string } type Operation = `insert` | `update` | `delete` type Scenario = { diff --git a/packages/db/tests/oracle-config.ts b/packages/db/tests/oracle-config.ts index d9b4d3749b..00b8eab32d 100644 --- a/packages/db/tests/oracle-config.ts +++ b/packages/db/tests/oracle-config.ts @@ -59,6 +59,12 @@ const staticOracleProperties = [ `includes-optimistic.rekey-rollback`, `includes-optimistic.repeated-history`, `includes-optimistic.sibling-route-rollback`, + `includes-query-shape.correlation`, + `includes-query-shape.multiplicity`, + `includes-query-shape.nullable`, + `includes-work.correlated-links`, + `includes-work.join-free`, + `includes-work.join-targets`, `includes-publication.atomic-parent-replacement`, `includes-publication.child-scalar`, `includes-publication.optimistic-rollback`, diff --git a/packages/db/tests/oracle-replay-manifest.ts b/packages/db/tests/oracle-replay-manifest.ts index 191c46663b..7b1eeae867 100644 --- a/packages/db/tests/oracle-replay-manifest.ts +++ b/packages/db/tests/oracle-replay-manifest.ts @@ -1,7 +1,12 @@ import { registeredOracleProperties } from './oracle-config.js' -// This is the named replay portfolio, not all DB laws or every test: fixed, -// work, provider, and unnamed generated suites remain separate campaign gates. +/** + * This manifest maps a replay name to the source file that can prove it ran. + * It is a named replay portfolio, not a catalog of every DB law. Fixed cases, + * work bounds, provider suites, and unnamed generated properties remain + * separate campaign gates. An absent or statistics-only entry must not be + * promoted to assertion evidence merely because a process exited cleanly. + */ const ownerGroups: ReadonlyArray = [ [`db/tests/cleanup-queue.property.test.ts`, `cleanup-queue`, `history`], [`db/tests/SortedMap.test.ts`, `sorted-map`, `key ascending descending`], @@ -111,6 +116,16 @@ const ownerGroups: ReadonlyArray = [ `includes`, `scenario-statistics incremental-history nested-scalar-materialization alpha-renaming optimistic-convergence`, ], + [ + `db/tests/query/includes-query-shape-oracle.test.ts`, + `includes-query-shape`, + `correlation multiplicity nullable`, + ], + [ + `db/tests/query/includes-work-counter-oracle.test.ts`, + `includes-work`, + `correlated-links join-free join-targets`, + ], [ `db/tests/query/includes-publication-oracle.test.ts`, `includes-publication`, diff --git a/packages/db/tests/oracle-replay-witness.ts b/packages/db/tests/oracle-replay-witness.ts index 3c961e22bc..26c264e6a9 100644 --- a/packages/db/tests/oracle-replay-witness.ts +++ b/packages/db/tests/oracle-replay-witness.ts @@ -10,7 +10,13 @@ export type OracleReplayWitness = { failed: boolean } -/** Only the guarded runner supplies this channel. Constructing options is not reach. */ +/** + * Attach a witness only when the guarded runner's property, seed, and path all + * match. Constructing fast-check options is not evidence that a property ran. + * The reporter records fast-check's completed run details, then preserves its + * native failure formatting and cause policy instead of turning instrumentation + * into a second assertion engine. + */ export function oracleReplayReporter( property: string, seed: number, diff --git a/packages/db/tests/oracle-replay.fixture.test.ts b/packages/db/tests/oracle-replay.fixture.test.ts index facbd7852e..32d0b51924 100644 --- a/packages/db/tests/oracle-replay.fixture.test.ts +++ b/packages/db/tests/oracle-replay.fixture.test.ts @@ -2,8 +2,15 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { beforeEach, expect, it } from 'vitest' import { oraclePropertyOptions } from './oracle-config.js' -// Ordinary discovery runs a real positive property and an unrelated assertion. -// Only subprocess calibration chooses a fault, skip, or zero-run variant. +/** + * Calibration fixture for the guarded replay protocol. + * + * Ordinary discovery runs one real positive property and one unrelated test. + * A child process may inject exactly one fault: setup, property, unrelated test, + * skip, zero runs, expected failure, precondition exhaustion, or wrong replay + * coordinates. The parent uses these variants to prove it neither accepts a + * false-green run nor rewrites fast-check's real failure. + */ const fault = process.env.TANSTACK_DB_ORACLE_REPLAY_CALIBRATION beforeEach(() => { if (fault === `setup`) throw new Error(`replay setup sentinel`) diff --git a/packages/db/tests/oracle-replay.test.ts b/packages/db/tests/oracle-replay.test.ts index b7c41bfb70..ecae306a69 100644 --- a/packages/db/tests/oracle-replay.test.ts +++ b/packages/db/tests/oracle-replay.test.ts @@ -13,6 +13,23 @@ import { registeredOracleProperties, } from './oracle-config.js' +/** + * Guarded replay is the evidence boundary for a named property run. + * + * A green child Vitest process is not enough. A filter can select the wrong + * test, a property can be skipped, or a caller can override the requested + * seed/path. The replay wrapper therefore treats a run as a small protocol: + * the requested property must be known and available, its exact owner must + * execute, fast-check must receive the requested replay coordinates, and the + * witness must report a completed property with nonzero runs. + * + * These tests drive real child processes because process arguments, filtering, + * environment variables, reporter failures, and exit status are the behavior + * under test. The model is the manifest plus one expected witness record. The + * negative cases remove or corrupt one protocol fact at a time and require a + * nonzero exit even when Vitest itself would otherwise report success. + */ + const packageDirectory = resolve(dirname(fileURLToPath(import.meta.url)), `..`) const repositoryDirectory = resolve(packageDirectory, `../..`) const fixture = `tests/oracle-replay.fixture.test.ts` diff --git a/packages/db/tests/oracle-replay.ts b/packages/db/tests/oracle-replay.ts index 0d9cfeab97..44d77bd498 100644 --- a/packages/db/tests/oracle-replay.ts +++ b/packages/db/tests/oracle-replay.ts @@ -7,10 +7,15 @@ import { readOracleRunConfig } from './oracle-config.js' import { oracleReplayManifest } from './oracle-replay-manifest.js' import type { OracleReplayWitness } from './oracle-replay-witness.js' -// Run from the target package, with ordinary Vitest file/name filters: -// TANSTACK_DB_ORACLE_SEED=42 TANSTACK_DB_ORACLE_PATH=0 \ -// TANSTACK_DB_ORACLE_PROPERTY=... node --import tsx tests/oracle-replay.ts tests/... -// Legacy direct Vitest commands are unchanged and do not provide this guard. +/** + * Guard one named replay in a real Vitest child process. + * + * The child inherits ordinary file/name filters but receives a private witness + * channel. Success requires both a zero Vitest exit and at least one completed, + * nonzero run for the exact manifest property, seed, and path. A skipped or + * filtered property therefore cannot look green. Direct Vitest commands remain + * valid discovery runs, but they do not make this replay-evidence claim. + */ const config = readOracleRunConfig() const property = config.replayProperty if (property === undefined) { diff --git a/packages/db/tests/proxy-detachment-contract.test.ts b/packages/db/tests/proxy-detachment-contract.test.ts index b8222ad0fa..4f38784df6 100644 --- a/packages/db/tests/proxy-detachment-contract.test.ts +++ b/packages/db/tests/proxy-detachment-contract.test.ts @@ -5,6 +5,23 @@ import { createChangeProxy, withChangeTracking } from '../src/proxy.js' import type { StandardSchemaV1 } from '@standard-schema/spec' import type { CollectionConfig } from '../src/types.js' +/** + * # What crosses the draft-to-stored-row boundary by value? + * + * Plain objects, arrays, Maps, Sets, Dates, regular expressions, typed arrays, + * and their cycles become detached snapshots. Later caller mutation must not + * rewrite the stored row. Arbitrary class instances are the explicit exception: + * they retain reference identity because their hidden state cannot be cloned + * faithfully. Dangerous data keys remain own data properties and may not alter + * prototypes. + * + * The model uses native construction, `structuredClone` where its domain + * applies, explicit cycle-shape checks, and fresh Collection reads. Generated + * sparse arrays and adversarial keys cover shapes that JSON equality erases. + * Alias and back-reference tests observe both identity and stored values, so a + * flattened or accidentally shared result cannot pass. + */ + class Label { #text: string constructor(text: string) { diff --git a/packages/db/tests/proxy-iteration-contract.test.ts b/packages/db/tests/proxy-iteration-contract.test.ts index e34268d9b6..974c3e60b3 100644 --- a/packages/db/tests/proxy-iteration-contract.test.ts +++ b/packages/db/tests/proxy-iteration-contract.test.ts @@ -6,8 +6,21 @@ import { withChangeTracking, } from '../src/proxy.js' -// Drafts preserve native live membership, even if a snapshot iterator would -// make mutation tracking simpler. Nested field edits have separate laws. +/** + * # Do draft Map and Set iterators preserve native live semantics? + * + * Iterators and `forEach` see membership changes made before an entry is + * consumed. They visit each entry once, use the draft collection as the + * callback receiver, and report no change for reads alone. New caller-owned + * members remain shared during the callback, then detach at publication. + * Existing members reuse one draft identity across values, entries, iterator, + * and `forEach` paths. + * + * Each operation also runs against a native Map or Set where possible. The + * oracle compares membership, key order, callback arguments, nested writes, + * aliases, cycles, rollback after throw, and the final detached result. Whole- + * object detachment rules live in the companion contract, not this model. + */ describe.each([`Map`, `Set`] as const)(`%s draft iteration`, (kind) => { it(`calls a read-only forEach callback once per entry without reporting changes`, () => { const values = diff --git a/packages/db/tests/proxy.test.ts b/packages/db/tests/proxy.test.ts index 2a21da1d3e..2678cad546 100644 --- a/packages/db/tests/proxy.test.ts +++ b/packages/db/tests/proxy.test.ts @@ -9,6 +9,26 @@ import { withChangeTracking, } from '../src/proxy' +/** + * # Does a draft behave like the native value it represents? + * + * A mutation draft promises ordinary JavaScript object, array, Map, Set, Date, + * RegExp, class, and Temporal behavior while recording the smallest correct + * change set. Reads must not create changes. Writes and deletes must preserve + * descriptors, keys, iteration, cycles, aliases, and revert-to-original rules. + * The input object must remain unchanged. + * + * Most examples pin individual language operations. The array callback oracle + * is differential: it runs the same callback history on a plain native row and + * on a draft, then compares callback results, visit order, reconstructed rows, + * change records, peers, and the untouched baseline. Generated two-step + * histories explore interactions that single method tests cannot reach. + * + * Detachment and Map/Set iterator identity have deeper contract owners in the + * companion files. Keeping those graphs separate prevents this broad language + * conformance suite from growing another copy of their models. + */ + const callbackMethods = [ `forEach`, `some`, diff --git a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts index 8c657f4d22..73cc1ebcfe 100644 --- a/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts +++ b/packages/db/tests/query/cold-join-reconciliation-oracle.test.ts @@ -12,6 +12,28 @@ import type { SyncConfig, } from '../../src/types.js' +/** + * # Does a cold join reconcile to independent relational truth? + * + * A live join may begin before its on-demand child source contains rows. The + * compiled query must acquire that source, publish the complete join, then keep + * it equal to a fresh relational recomputation through child deletion, + * restoration, and route moves. Join equality must follow the same established + * value classes as predicate equality without merging binary, string, or + * nullish domains. + * + * Plain parent and child arrays plus a Map-based backend form the model. The + * reference joins matching keys from scratch after each command. The production + * driver starts with a cold child Collection and observes the actual acquisition, + * raw batches, reconstructed replica, and live rows across scan and indexed + * paths. Faults prove a hidden acquisition, dropped delete, or wrong result is + * detected independently. + * + * Compound join syntax and demand minimization are outside this contract. The + * cold witness requires real acquisition and correct rows, not a particular + * optimization plan. + */ + type Parent = { id: number; name: string } type Child = { id: number; parentId: number; amount: number } type Joined = { id: number; parentId: number; name: string; amount: number } diff --git a/packages/db/tests/query/identity-output-shape-oracle.test.ts b/packages/db/tests/query/identity-output-shape-oracle.test.ts index ef96eb2ef9..d214e81fa5 100644 --- a/packages/db/tests/query/identity-output-shape-oracle.test.ts +++ b/packages/db/tests/query/identity-output-shape-oracle.test.ts @@ -16,6 +16,21 @@ import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' import type { CollectionImpl } from '../../src/collection/index.js' import type { QueryIR } from '../../src/query/ir.js' +/** + * Query identity may erase syntax only when compiled output stays observable- + * equivalent. + * + * The model builds pairs of query IRs across explicit projection, nested query, + * implicit join/union, and empty grouping forms. Fresh D2 graphs materialize + * both queries over the same finite source rows. Equal identity is permitted + * only when complete output bags—including lexical keys and multiplicity—match; + * output-sensitive aliases remain part of identity. + * + * Fault drivers remove peers, collapse identity, corrupt aliases or weights, + * and require the checker to fail. This calibrates the implication that matters: + * equal QueryIdentity implies equal compiled results, not merely equal hashes. + */ + type User = { id: number; label: string } type Post = { id: number; userId: number; title: string } type Row = User | Post diff --git a/packages/db/tests/query/includes-collection-oracle.property.test.ts b/packages/db/tests/query/includes-collection-oracle.property.test.ts index 8432a47c86..05d0962ae2 100644 --- a/packages/db/tests/query/includes-collection-oracle.property.test.ts +++ b/packages/db/tests/query/includes-collection-oracle.property.test.ts @@ -22,6 +22,33 @@ import type { Collection } from '../../src/collection/index.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' import type { ControlledCollection } from './includes-oracle-helpers.js' +/** + * # What does a Collection-valued include promise? + * + * A bare child query materializes as a public Collection facade. That facade + * has lifecycle and identity rules that inline arrays do not have: + * + * 1. Parents on the same active route share one facade. + * 2. Child changes update the facade without forcing a parent value change. + * 3. Moving a parent route gives it the destination route's facade. + * 4. A held facade becomes empty and ready when its last route retires. + * 5. A later active interval gets a new facade instead of retargeting the old + * facade. + * + * The main Map model recomputes parent rows and ordered children after each + * generated action. It compares the bare Collection form with its `toArray` + * and `materialize` siblings. It also derives expected parent events from the prior + * and current model rows. Specialized models check facade sharing, retirement, + * reactivation, event identity, optimistic settlement, and callback-time root + * snapshots. + * + * The history grammar varies parent and child writes, route movement, shared + * routes, dormant buckets, ordering, parent-dependent filters, and joins. It + * also varies optimistic confirmation and rollback. A bounded exhaustive lane covers every + * two-step history in the smallest relationship domain. Random histories add + * longer combinations and shrink failures. + */ + type ParentRow = { id: number group: number @@ -231,6 +258,8 @@ function projectLive( }) } +// This value model uses only current source Maps and the declared relation and +// child order. Facade identity has separate focused models below. function recompute(context: CollectionContext): Array { return [...context.model.parents.values()] .sort((left, right) => left.id - right.id) @@ -497,6 +526,8 @@ const collectionProjection: TraceProjection< CollectionContext, CollectionObservation > = { + // Keep final reads, callback-time publications, and event payloads separate. + // A later repair of one channel cannot hide an earlier disagreement. observe: (context) => ({ rows: projectLive(context.live), publications: context.publications, @@ -514,7 +545,7 @@ const collectionProjection: TraceProjection< expect(observed.rows).toEqual(expected.rows) expect(observed.publications).toEqual(expected.publications) // The mixed fixture has array/materialized siblings, so user-value changes - // require root events. Bare-facade identity is tested separately. Do not + // require root events. Separate tests cover bare-facade identity. Do not // prohibit metadata-only events for otherwise unchanged rows here. const changedKeys = new Set(expected.events.map((event) => event.key)) expect( @@ -565,6 +596,8 @@ const orderSwapArbitrary = fc.integer({ min: 2, max: 8 }).chain((length) => })), ) +// Enumeration proves the small finite domain. The generated lane explores +// longer histories over a wider value domain. Neither replaces the other. function enumerateActionSequences( actions: ReadonlyArray, maxLength: number, diff --git a/packages/db/tests/query/includes-context-transport-oracle.test.ts b/packages/db/tests/query/includes-context-transport-oracle.test.ts index 694274e121..5a97d0da42 100644 --- a/packages/db/tests/query/includes-context-transport-oracle.test.ts +++ b/packages/db/tests/query/includes-context-transport-oracle.test.ts @@ -23,6 +23,35 @@ import type { Collection } from '../../src/collection/index.js' import type { Context, QueryBuilder } from '../../src/query/builder/index.js' import type { ControlledCollection } from './includes-oracle-helpers.js' +/** + * # How can a parent value cross a nested query without becoming user data? + * + * A correlated include needs values from its lexical parent. The compiler + * carries that route context through filters, projections, joins, aggregates, + * unions, query references, and windows. The context belongs to the control + * plane. User callbacks and query results must never see its private metadata. + * + * This oracle states five laws: + * + * 1. Every supported query boundary preserves the parent correlation. + * 2. Different parents keep different routes, even when they share child rows. + * 3. Collection, `toArray`, and `materialize` produce the same public values. + * 4. Parent and child updates recompute only from the current public sources. + * 5. Private route metadata never leaks through rows, callbacks, symbols, + * opaque objects, cycles, or adversarial property names. + * + * One reference compiler would repeat the production compiler and risk the + * same bugs. This file instead declares a grammar of transport boundaries. + * Each grammar family has a small JavaScript model for its own result. The + * production side compiles the matching live query and crosses the full + * boundary. The suite expands the grammar across three materialization forms + * and three checkpoints for 819 explicit observations. + * + * This suite owns route-context transport and public-data hygiene. The temporal + * oracle owns demand lifetime. The publication oracle owns coherent callbacks. + * The Collection oracle owns facade identity and retirement. + */ + type Cleanable = { cleanup: () => Promise } type MaterializationForm = (typeof materializationForms)[number] @@ -35,6 +64,9 @@ type MaterializedForms = { const materializationForms = [`collection`, `array`, `materialized`] as const const checkpoints = [`initial`, `parent-update`, `child-update`] as const +// These axes name semantic boundaries, not implementation functions. Adding a +// compiler feature means adding its boundary here or stating why it cannot +// carry correlated context. const routeContextGrammar = { parentProjection: { shapes: [`field`, `whole-row`] as const, @@ -171,6 +203,8 @@ type GrammarCell = | NamespaceCollisionCell | PublicSurfaceCell +// Expand every declared product once. The calibration test below rejects both +// missing cells and duplicate cells. const grammarCells: Array = [ ...routeContextGrammar.parentProjection.shapes.map( (shape): ParentProjectionCell => ({ @@ -271,6 +305,8 @@ function includeInEveryForm( } } +// Read each public form through the same projection. This keeps normalization +// outside the production query while preserving exact row counts and values. function readEveryForm( forms: MaterializedForms, project: (rows: Iterable) => U, @@ -1774,6 +1810,8 @@ class PublicSurfaceBox { } } +// Public-surface cells deliberately retain callback values and opaque wrappers. +// A cleaner that only fixes the final array will still fail these observations. function expectNoPrivateSymbolsDeep( value: unknown, allowedSymbols: ReadonlySet, diff --git a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts index 69863b9591..332e300c53 100644 --- a/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts +++ b/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts @@ -23,6 +23,36 @@ import type { LoadSubsetOptions } from '../../src/types.js' import type { BasicExpression } from '../../src/query/ir.js' import type { ControlledCollection } from './includes-oracle-helpers.js' +/** + * # Why ask the same relationship question five ways? + * + * A nested include can be wrong even when its result looks plausible. A model + * copied from the include pipeline can repeat the same mistake. This oracle + * therefore compares five independent formulations after every source change: + * + * 1. A plain Map-and-array recomputation supplies the expected rows. + * 2. A correlated nested include exercises materialization routes. + * 3. A flat left join reconstructs the same parent-child relation. + * 4. Fresh child queries run once for each current parent. + * 5. Ternary logic partitioning makes true, false, and unknown partitions. It + * then unions the three results. + * + * Every formulation must produce the same parent rows and child membership. + * Formulations with a promised order must also preserve that exact order. Raw + * capture checks run before normalization so an unexpected field, symbol, + * placeholder, duplicate, or wrong sequence cannot disappear during cleanup. + * + * Generated histories vary parent and child writes, route sharing, route + * movement, null predicate values, ordering, offsets, and limits. Separate + * controls cover opaque reference identity, query equality, + * lazy demand, grouping, and user aliases that resemble compiler metadata. + * + * The architecture contract is in + * `packages/db/src/query/live/ARCHITECTURE.md`. This file supplies executable + * evidence for cross-formulation equivalence. It does not redefine the wider + * demand, publication, or facade lifecycle contracts. + */ + type ParentRow = { id: number group: number @@ -60,6 +90,8 @@ type FlatRow = { child: ChildRow | undefined } +// Opaque reference routes form a second value domain. Equal object shapes do +// not make two reference-sensitive correlation keys equal. type ReferenceKey = { code: number } type ReferenceParent = { @@ -151,7 +183,8 @@ type ReferenceContextChild = { } // Public VirtualRowProps and docs/guides/live-queries.md name only these keys. -// Their values/presence are a separate metadata law, not part of this projection. +// Their values and presence are a separate metadata law. They are not part of +// this projection. const virtualKeys = new Set([ `$synced`, `$origin`, @@ -309,6 +342,7 @@ function captureOrderedNested( } // Canonicalize only model rows and formulations with no common result order. +// Ordered production observations use `captureOrderedNested` directly. function normalizeNested( rows: ReadonlyArray, ): Array { @@ -361,7 +395,9 @@ function normalizeFlat( return normalizeNested([...parents.values()]) } -function recompute( +// This is the independent relationship model. It uses only current source +// Maps, strict group equality, and the declared total orders. +function recomputeNestedModel( parents: Map, children: Map, ): Array { @@ -375,6 +411,8 @@ function recompute( ) } +// The next three builders are production formulations. They share source rows +// and public result types, but they use different compiler and runtime paths. function createNestedQuery( parents: Collection, children: Collection, @@ -466,6 +504,8 @@ function createFlatQuery( type ChildPartition = `all` | `predicate` | `complement` | `unknown` +// Ternary logic partitioning is complete only with the unknown partition. +// A null score makes `score < pivot` unknown rather than true or false. async function queryChildren( children: Collection, parentGroup: number, @@ -535,8 +575,8 @@ async function queryPerParent( return { ...parent, children: childRows } }), ) - // TLP concatenates three independently ordered partitions. Its union is - // unordered; the standalone query's promised child sequence is not. + // TLP concatenates three independently ordered partitions. Their union has + // no shared order. The standalone query still promises child order. return useTlp ? normalizeNested(rows) : captureOrderedNested(rows, context) } @@ -547,6 +587,8 @@ function applyAction( parents: Map, children: Map, ): void { + // The Map state advances the reference model. The controlled sources deliver + // the same external action to each live production formulation. switch (action.type) { case `putParent`: { const type = parents.has(action.row.id) ? `update` : `insert` @@ -600,14 +642,14 @@ async function expectFormulationsEquivalent( checkpoint, action: scenario.actions[checkpoint - 1], }) - const expected = recompute(parents, children) + const expected = recomputeNestedModel(parents, children) const nestedResult = captureOrderedNested( nested.toArray, `${context} nested`, ) const flatResult = normalizeFlat(flat.toArray, `${context} flat`) // Fresh child queries do not determine parent order. The model chooses - // their invocation order; their returned child order stays untouched. + // their invocation order. It does not change their returned child order. const parentRows = [...parents.values()].sort(compareParents) const standaloneResult = await queryPerParent( parentRows, @@ -748,6 +790,8 @@ const actionArbitrary: fc.Arbitrary = fc.oneof( }), ) +// The grammar keeps the state space small enough to shrink. It still permits +// shared routes, absent ids, overwrites, route movement, ties, and null scores. const scenarioArbitrary: fc.Arbitrary = fc.record({ parents: fc.tuple(parentRowArbitrary(0), parentRowArbitrary(1)), children: fc.tuple( @@ -909,7 +953,7 @@ describe(`includes cross-formulation oracle`, () => { await grouped.preload() expect(requests.length).toBeGreaterThan(0) expect(loaded.size).toBeGreaterThan(0) - // Full-source or broader requests are legal; counts alone decide routing. + // Full-source or broader requests are legal. Counts alone decide routing. assertGroupedRouteCounts(grouped.toArray, [2, 1]) }, [ () => grouped.cleanup(), @@ -1098,7 +1142,8 @@ describe(`includes cross-formulation oracle`, () => { if (level === `root`) wrong.reverse() else wrong[0]!.children.reverse() - // Old checker false green; the new observation retains the violation. + // The old checker was false green. The new observation retains the + // violation. expect(normalizeNested(wrong)).toEqual(expected) expect(captureOrderedNested(expected)).toEqual(expected) expect(() => diff --git a/packages/db/tests/query/includes-functional-projection-oracle.test.ts b/packages/db/tests/query/includes-functional-projection-oracle.test.ts index a268cbd962..6814c59916 100644 --- a/packages/db/tests/query/includes-functional-projection-oracle.test.ts +++ b/packages/db/tests/query/includes-functional-projection-oracle.test.ts @@ -12,6 +12,37 @@ import { import { flushPromises } from '../utils.js' import { createControlledCollection } from './includes-oracle-helpers.js' +/** + * # What may cross a functional projection boundary? + * + * Expression projections stay inside the query graph. Functional projections + * run user JavaScript. Inline arrays and materialized values can enter that + * callback as snapshots. A Collection-valued include is a live handle, so the + * callback cannot turn reads from it into query dependencies. The builder + * rejects that ambiguous input instead of returning a stale derived scalar. + * + * This oracle states five laws: + * + * 1. Query references, recursive references, and unions preserve each supported + * include form and its public row shape. + * 2. Functional callbacks receive complete values from the current graph step. + * 3. Published Collection facades stay live, but reads from them do not become + * hidden scalar dependencies. + * 4. A failed projection publishes no partial facade state or events. + * 5. Cleanup fences the old graph from a later preload and its publications. + * + * The model is a set of small truth Maps and direct JavaScript projections. + * Declared products cross boundary, form, output, initial state, consumer, + * operator, and rename choices. Negative cells prove that unsupported + * Collection inputs fail before user code runs. Callback-time captures prevent + * a later live reference from making an incomplete callback look correct. + * + * This suite owns functional projection compatibility. The route-context suite + * owns hidden metadata transport. The Collection suite owns facade lifecycle. + */ + +// These products are finite contract partitions. Calibration tests below +// reject missing or duplicate cells before the behavior tests run. const boundaries = [`query-ref`, `recursive-query-ref`, `union`] as const const forms = [`collection`, `array`, `materialized`] as const const outputs = [`expression`, `record`, `opaque-root`] as const @@ -91,8 +122,8 @@ function readChildren(value: unknown, form: (typeof forms)[number]): ChildView { } } -// Keep only the selected public fields in row comparisons. Callback-time shape -// and facade readiness have their own assertions rather than being normalized away. +// Keep only the selected public fields in row comparisons. Other assertions +// check callback-time shape and facade readiness. function selectedRows(rows: ReadonlyArray) { return rows.map(({ id, parentGroup, value }) => ({ id, parentGroup, value })) } @@ -186,7 +217,7 @@ describe(`functional projection output compatibility`, () => { } const query = buildQuery() const failure = new Error(`pending child failed`) - // Attach both outcomes immediately; no pending-length assertion may + // Attach both outcomes immediately. A pending-length assertion must not // leave a rejected preload promise unobserved. const preload = () => { const result: { settled: boolean; error?: unknown } = { settled: false } diff --git a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts index a74c2dc12d..d5d0c1034d 100644 --- a/packages/db/tests/query/includes-optimistic-oracle.property.test.ts +++ b/packages/db/tests/query/includes-optimistic-oracle.property.test.ts @@ -12,6 +12,33 @@ import { createControlledCollection as createOracleControlledCollection } from ' import type { TraceDriver, TraceProjection } from '../trace-runner.js' import type { OracleSyncChange as SyncChange } from './includes-oracle-helpers.js' +/** + * # How should optimistic relationship writes affect a nested result? + * + * An optimistic write changes the public relationship tree before sync settles. + * The visible tree follows these laws: + * + * 1. A reparent moves the row to its optimistic parent route immediately. + * 2. A relationship-key change detaches descendants that no longer correlate. + * 3. Rollback removes only that overlay and reveals the latest synced base. + * 4. Confirmation keeps the overlay until settlement, then reveals the + * authoritative row without an intermediate stale route. + * 5. Pending changes at different levels compose, regardless of settlement + * order. The controlled sync cannot settle two changes at one level alone, + * so the grammar rejects that unsupported harness state. + * + * The semantic model is small: three Maps hold synced child levels and pending + * rows overlay them by ID. Full recomputation filters each level by its current + * parent key and sorts it. Transaction promises, sync gates, and cleanup + * receipts belong to the production driver. They do not determine expected + * rows. + * + * Each property supplies a deliberate action history and randomizes disjoint + * route values. This keeps shrinking useful while preserving the relationship + * distinctions under test. Focused controls also inspect the immediate + * optimistic checkpoint, queued sibling delivery, and failure cleanup. + */ + type RootRow = { id: number group: number @@ -132,7 +159,7 @@ function trackRuntimeMutation( entry.settled = true if (entry.syncReleased) context.runtimePending.delete(entry) } - // Both outcomes are handled before a checkpoint or sibling source write. + // Attach both outcome handlers before a checkpoint or sibling source write. entry.receipt = transaction.isPersisted.promise.then(settled, settled) return entry } @@ -289,6 +316,8 @@ function applyPatch(row: ChildRow, patch: ChildPatch): ChildRow { } function visibleLevels(context: OptimisticContext) { + // Model rule: pending rows replace their synced row at the same level. The + // recursive projection below decides their route from the overlaid values. const levels = context.levels.map( (level) => new Map([...level].map(([id, row]) => [id, { ...row }])), ) @@ -364,6 +393,8 @@ function createDriver( roots: ReadonlyArray, levelRows: LevelRows, ): TraceDriver { + // The driver owns transaction settlement. Model Maps change only when the + // corresponding logical sync or optimistic action takes effect. return { setup: () => { const sources = createSources(roots, levelRows) @@ -398,8 +429,8 @@ function createDriver( } // This compound action checks the settled state. The immediate state is // checked separately so its known mismatch cannot abort the rollback. - // Its normal same-source sibling commit is queued during persistence; - // invoking writeBatch does not establish sibling delivery at this cut. + // Persistence queues its normal same-source sibling commit. Invoking + // writeBatch does not establish sibling delivery at this cut. await rollback(source, transaction, context, runtime) return } @@ -480,8 +511,8 @@ function createDriver( ), ) } - // Failed rollback/release can leave a receipt pending. Dispose resources - // even then; receipt rejection handlers are already attached. + // Failed rollback or release can leave a receipt pending. Receipt + // rejection handlers already exist, so dispose resources even then. if (errors.length === 0) await Promise.all(entries.map(({ receipt }) => receipt)) pending.clear() diff --git a/packages/db/tests/query/includes-oracle-helpers.ts b/packages/db/tests/query/includes-oracle-helpers.ts index 77f08543b5..8547a665d7 100644 --- a/packages/db/tests/query/includes-oracle-helpers.ts +++ b/packages/db/tests/query/includes-oracle-helpers.ts @@ -2,6 +2,15 @@ import { createCollection } from '../../src/collection/index.js' import { mockSyncCollectionOptions } from '../utils.js' import type { Collection } from '../../src/collection/index.js' +/** + * A controlled include source exposes only the environment operations the + * oracle grammar needs: atomic source batches and explicit startup settlement. + * Each write snapshots the row before it enters production, so later test code + * cannot mutate both the stimulus and an expected value through one reference. + * Query shape, route state, and expected nested results remain in the owning + * oracle; this helper is a driver, not a second model. + */ + export type OracleSyncChange = { type: `insert` | `update` | `delete` value: T diff --git a/packages/db/tests/query/includes-oracle.property.test.ts b/packages/db/tests/query/includes-oracle.property.test.ts index c5080500a3..0a604cfd4b 100644 --- a/packages/db/tests/query/includes-oracle.property.test.ts +++ b/packages/db/tests/query/includes-oracle.property.test.ts @@ -19,6 +19,38 @@ import type { } from '../trace-runner.js' import type { OracleSyncChange as SyncChange } from './includes-oracle-helpers.js' +/** + * # Does the incremental include graph equal full relationship recomputation? + * + * This is the central structural oracle for inline includes. Production updates + * nested results incrementally. The reference stores source rows in plain Maps + * and rebuilds the whole tree after every meaningful checkpoint. + * + * The suite protects five groups of laws: + * + * 1. Inserts, updates, deletes, and optimistic settlement match recomputation + * at one to four include levels. + * 2. Rekey and reparent histories preserve route ownership through fresh, + * shared, retired, restored, merged, and split routes. + * 3. Atomic and split full-row batches produce the same tree when they encode + * the same logical source state. + * 4. Nested scalar materialization follows reference changes and shared paths. + * 5. Alpha-renaming, sibling order, and unrelated siblings do not change the + * relevant result. + * + * These laws use a model graph, not one universal controller. The structural + * node recomputes relationship trees. A separate scalar-reference node follows + * explicit foreign keys. Metamorphic checks compare equivalent query forms. + * Scenario builders describe legal route histories, but they do not compute + * the expected result. + * + * The trace runner checks each intermediate cut that the contract exposes. + * Focused pinned histories preserve small boundary cases. Random or replayed + * campaigns explore longer action sequences and wider route values. Other + * include suites own Collection facades, demand lifetime, functional callbacks, + * and publication coherence. + */ + type IncludeDepth = 1 | 2 | 3 | 4 type RootRow = { @@ -223,6 +255,8 @@ function ensureActionsTargetRows( }) } +// This grammar constructs legal row histories. The Map model below, not the +// grammar, decides their expected nested result. const scenarioArbitrary: fc.Arbitrary = depthArbitrary.chain( (depth) => fc @@ -1893,6 +1927,8 @@ type RouteDestination = { route: number } +// Route lifecycle descriptors form a constrained design grammar. Their +// constructors reject combinations that do not name a real lifecycle state. type RouteTransitionDescriptor = { row: 0 | 1 stepsBefore?: ReadonlyArray @@ -3056,7 +3092,7 @@ function createRelationshipBatchShapeMatrix( for (const delivery of [`split`, `atomic`] as const) { for (const order of [`delete-insert`, `insert-delete`] as const) { - // Inserting the same public id before its existing row is retired is a + // Inserting the same public id before the batch retires its existing row is a // duplicate-key error, not a valid alternate delivery of the same final // state. The new-id cells exercise insert-before-delete in both forms. if (publicId === `same` && order === `insert-delete`) continue @@ -4399,7 +4435,7 @@ describe(`includes recompute oracle`, () => { recomputeFullRowBatchScenario(candidate, candidate.steps.length), ) - // Delivery boundaries and change order must not alter the final + // Delivery boundaries and change order must not affect the final // recompute semantics for one generated fixture. expect(finalStates.length).toBeGreaterThan(1) for (const finalState of finalStates.slice(1)) { @@ -4861,26 +4897,15 @@ describe(`includes recompute oracle`, () => { }, ) - fcTest.prop([fc.constant(confirmedChildReorderSeed)], { - numRuns: 1, - seed: 2051245230, - })( - `regression seed: confirmed child reorder matches recomputation`, - expectScenarioMatches, + fcTest(`confirmed child reorder matches recomputation`, () => + expectScenarioMatches(confirmedChildReorderSeed), ) - fcTest.prop([fc.constant(sharedMaterializeSeed)], { - numRuns: 1, - seed: 1685, - })( - `shared scalar materialization preserves the deepest row`, - expectMaterializeScenarioMatches, + fcTest(`shared scalar materialization preserves the deepest row`, () => + expectMaterializeScenarioMatches(sharedMaterializeSeed), ) - fcTest.prop([fc.constant(`correlation-key-update`)], { - numRuns: 1, - seed: 1658, - })(`parent correlation-key update rematerializes children`, async () => { + fcTest(`parent correlation-key update rematerializes children`, async () => { const roots = createControlledCollection(`correlation-seed-roots`) const children = createControlledCollection( `correlation-seed-children`, @@ -4929,7 +4954,7 @@ describe(`includes recompute oracle`, () => { } }) - fcTest.prop([fc.constant(`#1454`)], { numRuns: 1, seed: 1454 })( + fcTest( `alpha-renaming a duplicate sibling alias preserves results`, async () => { const roots = createControlledCollection(`alias-seed-roots`, [ @@ -5021,70 +5046,67 @@ describe(`includes recompute oracle`, () => { }, ) - fcTest.prop([fc.constant(`#1444`)], { numRuns: 1, seed: 1444 })( - `regression seed: optimistic child reorder matches recomputation`, - async () => { - const roots = createControlledCollection(`order-seed-roots`, [ - { id: 1, group: 1, value: 0, position: 0 }, - ]) - const children = createControlledCollection( - `order-seed-children`, - [ + fcTest(`optimistic child reorder matches recomputation`, async () => { + const roots = createControlledCollection(`order-seed-roots`, [ + { id: 1, group: 1, value: 0, position: 0 }, + ]) + const children = createControlledCollection( + `order-seed-children`, + [ + { + id: 1, + parentGroup: 1, + group: 1, + value: 1, + position: 0, + }, + { + id: 2, + parentGroup: 1, + group: 1, + value: 2, + position: 1, + }, + ], + ) + const live = createLiveQueryCollection((q) => + q.from({ root: roots.collection }).select(({ root }) => ({ + id: root.id, + children: toArray( + q + .from({ child: children.collection }) + .where(({ child }) => eq(child.parentGroup, root.group)) + .orderBy(({ child }) => child.position) + .select(({ child }) => ({ + id: child.id, + position: child.position, + })), + ), + })), + ) + + const pending = new Set() + await withPendingWorkCleanup( + pending, + [live, roots.collection, children.collection], + async () => { + await live.preload() + const transaction = children.collection.update([1, 2], (drafts) => { + drafts[0]!.position = 1 + drafts[1]!.position = 0 + }) + trackPendingWork(pending, transaction, children.resolveSync) + + expect(stripVirtualProperties(live.toArray)).toEqual([ { id: 1, - parentGroup: 1, - group: 1, - value: 1, - position: 0, - }, - { - id: 2, - parentGroup: 1, - group: 1, - value: 2, - position: 1, + children: [ + { id: 2, position: 0 }, + { id: 1, position: 1 }, + ], }, - ], - ) - const live = createLiveQueryCollection((q) => - q.from({ root: roots.collection }).select(({ root }) => ({ - id: root.id, - children: toArray( - q - .from({ child: children.collection }) - .where(({ child }) => eq(child.parentGroup, root.group)) - .orderBy(({ child }) => child.position) - .select(({ child }) => ({ - id: child.id, - position: child.position, - })), - ), - })), - ) - - const pending = new Set() - await withPendingWorkCleanup( - pending, - [live, roots.collection, children.collection], - async () => { - await live.preload() - const transaction = children.collection.update([1, 2], (drafts) => { - drafts[0]!.position = 1 - drafts[1]!.position = 0 - }) - trackPendingWork(pending, transaction, children.resolveSync) - - expect(stripVirtualProperties(live.toArray)).toEqual([ - { - id: 1, - children: [ - { id: 2, position: 0 }, - { id: 1, position: 1 }, - ], - }, - ]) - }, - ) - }, - ) + ]) + }, + ) + }) }) diff --git a/packages/db/tests/query/includes-publication-oracle.test.ts b/packages/db/tests/query/includes-publication-oracle.test.ts index dab50b442a..9b262ba7ce 100644 --- a/packages/db/tests/query/includes-publication-oracle.test.ts +++ b/packages/db/tests/query/includes-publication-oracle.test.ts @@ -13,6 +13,31 @@ import { flushPromises, withExpectedRejection } from '../utils.js' import { createControlledCollection } from './includes-oracle-helpers.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' +/** + * # What makes a layered publication coherent? + * + * A source write can update a parent, its materialized children, and a query + * that reads the first query. The public contract does not permit a torn row at + * any one layer. When that layer invokes a listener, its installed reads, + * callback rows, and change payloads must describe the same complete result. + * + * A plain Map model recomputes both child arrays from current parent and child + * rows. The production driver builds two live-query layers. Q1 covers direct + * and joined source forms. Q2 covers pass-through, filter, order, and projection. + * After each synchronous source checkpoint, the oracle compares both layers + * with the model and checks each callback since the prior checkpoint. + * + * The action grammar covers parent-only changes, child-only changes, route + * moves, atomic replacement, optimistic confirmation and rollback, and two + * consecutive source writes. Fault controls corrupt transient reads, callback + * snapshots, optimistic state, and source-settled state. This proves that a + * final settled read alone is not the oracle. + * + * Q1 and Q2 can notify at different moments. Coherence applies within each + * callback and its own layer. The test does not require Q2 to advance while a + * Q1 callback is still running. + */ + type ParentRow = { id: number group: number @@ -198,6 +223,8 @@ type PublicationContext = { } } +// This model uses only source Maps, equality, and the declared child order. It +// does not read either live query or any materialization state. function recomputeRows(context: PublicationContext): Array { return [...context.model.parents.values()] .sort((left, right) => left.id - right.id) @@ -218,6 +245,8 @@ const publicationProjection: TraceProjection< PublicationContext, PublicationObservation > = { + // Observe both final reads and rows captured inside each listener. A later + // repair cannot hide a callback-time tear. observe: ({ queries, callbacks }) => ({ q1: stripVirtualProperties(queries.q1.toArray) as Array, q2: stripVirtualProperties(queries.q2.toArray) as Array, @@ -269,6 +298,9 @@ function createPublicationDriver( q2Shape: Q2Shape, checkpointOptimistic = false, ): TraceDriver { + // The driver sends each action through real source, optimistic, graph, and + // Collection publication boundaries. The checkpoint marks each synchronous + // state that a caller can observe before the returned Promise settles. return { setup: () => { const parents = createControlledCollection(`publication-parents`, [ @@ -567,8 +599,8 @@ describe(`layered-query publication oracle`, () => { expect(callback).toBeDefined() callback!.rows[0]!.item.value = -999 reached++ - // The installed final reads still agree; only the captured callback - // was corrupted and then repaired before the write returned. + // The installed final reads still agree. This fault corrupts only the + // captured callback and repairs it before the write returns. const observed = publicationProjection.observe(context) const expected = publicationProjection.recompute(context) expect(observed.q1).toEqual(expected.q1) diff --git a/packages/db/tests/query/includes-query-shape-oracle.test.ts b/packages/db/tests/query/includes-query-shape-oracle.test.ts index 625e983272..1b832a30d5 100644 --- a/packages/db/tests/query/includes-query-shape-oracle.test.ts +++ b/packages/db/tests/query/includes-query-shape-oracle.test.ts @@ -7,10 +7,32 @@ import { materialize, } from '../../src/query/index.js' import { runTrace } from '../trace-runner.js' -import { oracleRuns } from '../oracle-config.js' +import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' import { createControlledCollection } from './includes-oracle-helpers.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' +/** + * # Which distinctions determine the shape of an included result? + * + * Incremental query state can look correct while losing a semantic distinction + * that a later change exposes. This suite isolates three such distinctions: + * + * 1. Join multiplicity keeps a parent visible until its last contributor leaves. + * 2. A correlation through the joined alias differs from one through the source. + * 3. A null or unmatched singleton is absent, but a later valid key reactivates it. + * + * These laws form separate model nodes. Each node uses plain Maps and full + * recomputation. The shared trace runner applies an action to production and to + * the matching node, then compares the complete public result. Combining the + * nodes into one reference query engine would add machinery without making any + * law stronger. + * + * Stable campaigns preserve the histories that exposed these distinctions. + * Fresh random campaigns vary their value domains. Pinned examples cover route + * movement and repeated retirement because those laws need ordered histories, + * not more random scalar values. + */ + function rowsById(rows: Array): Map { return new Map(rows.map((row) => [row.id, row])) } @@ -126,6 +148,7 @@ const multiplicityProjection: TraceProjection< unknown, Array > = { + // A join projects one parent row for any positive contributor count. observe: ({ live }) => stripVirtualProperties(live.toArray), recompute: ({ children, parents }) => [...parents.values()] @@ -262,6 +285,7 @@ const correlationProjection: TraceProjection< unknown, CorrelationResult > = { + // Correlation chooses a route. Join identity only decides which rows meet. observe: ({ live }) => stripVirtualProperties(live.toArray), recompute: ({ orders, parts, productions, target }) => [...parts.values()] @@ -363,6 +387,7 @@ const nullableProjection: TraceProjection< unknown, NullableResult > = { + // SQL equality never matches null. A later non-null key starts a fresh route. observe: ({ live }) => stripVirtualProperties(live.toArray), recompute: ({ authors, posts }) => [...posts.values()] @@ -374,20 +399,32 @@ const nullableProjection: TraceProjection< assertEqual: assertRowsEqual, } -describe(`includes query-shape recompute oracle`, () => { - fcTest.prop([fc.integer({ min: 2, max: 5 })], { - numRuns: oracleRuns(12), - seed: 1703, - })( - `deleting one joined contributor preserves remaining multiplicity (#1703)`, - async (childCount) => { - await runTrace({ - steps: [1], - driver: createMultiplicityDriver(childCount), - projection: multiplicityProjection, - }) +function campaigns(fixedSeed: number, property: string) { + return [ + { + name: `fixed`, + options: { numRuns: oracleRuns(12), seed: fixedSeed }, }, - ) + { + name: `random or replayed`, + options: oraclePropertyOptions(12, property), + }, + ] +} + +describe(`includes query-shape recompute oracle`, () => { + for (const campaign of campaigns(1703, `includes-query-shape.multiplicity`)) { + fcTest.prop([fc.integer({ min: 2, max: 5 })], campaign.options)( + `deleting one joined contributor preserves remaining multiplicity (${campaign.name})`, + async (childCount) => { + await runTrace({ + steps: [1], + driver: createMultiplicityDriver(childCount), + projection: multiplicityProjection, + }) + }, + ) + } fcTest( `matches recomputation when the final joined contributor is deleted`, @@ -399,24 +436,30 @@ describe(`includes query-shape recompute oracle`, () => { }), ) - fcTest.prop( - [ - fc.record({ - correlationId: fc.integer({ min: 1, max: 100 }), - productionId: fc.integer({ min: 101, max: 200 }), - }), - ], - { numRuns: oracleRuns(12), seed: 1704 }, - )( - `materialization follows correlation through a joined alias (#1704)`, - async ({ correlationId, productionId }) => { - await runTrace({ - steps: [], - driver: createCorrelationDriver(`joined`, correlationId, productionId), - projection: correlationProjection, - }) - }, - ) + for (const campaign of campaigns(1704, `includes-query-shape.correlation`)) { + fcTest.prop( + [ + fc.record({ + correlationId: fc.integer({ min: 1, max: 100 }), + productionId: fc.integer({ min: 101, max: 200 }), + }), + ], + campaign.options, + )( + `materialization follows correlation through a joined alias (${campaign.name})`, + async ({ correlationId, productionId }) => { + await runTrace({ + steps: [], + driver: createCorrelationDriver( + `joined`, + correlationId, + productionId, + ), + projection: correlationProjection, + }) + }, + ) + } fcTest( `matches recomputation when materialization correlates through its source alias`, @@ -432,7 +475,7 @@ describe(`includes query-shape recompute oracle`, () => { `keeps %s correlation distinct from join identity through route moves`, (target) => runTrace({ - // Part 1 is the joined correlation; part 7 is the source correlation. + // Part 1 is the joined correlation. Part 7 is the source correlation. // Moving order.partId changes only the joined query, then restores it. steps: [ { id: 7, partId: 7 }, @@ -460,19 +503,18 @@ describe(`includes query-shape recompute oracle`, () => { }, ) - fcTest.prop([fc.integer({ min: 1, max: 100 })], { - numRuns: oracleRuns(12), - seed: 1706, - })( - `findOne maps a null correlation key to undefined (#1706)`, - async (postId) => { - await runTrace({ - steps: [], - driver: createNullableDriver([], [{ id: postId, authorId: null }]), - projection: nullableProjection, - }) - }, - ) + for (const campaign of campaigns(1706, `includes-query-shape.nullable`)) { + fcTest.prop([fc.integer({ min: 1, max: 100 })], campaign.options)( + `findOne maps a null correlation key to undefined (${campaign.name})`, + async (postId) => { + await runTrace({ + steps: [], + driver: createNullableDriver([], [{ id: postId, authorId: null }]), + projection: nullableProjection, + }) + }, + ) + } fcTest( `matches recomputation for an unmatched non-null correlation key`, diff --git a/packages/db/tests/query/includes-space-oracle-fixture.ts b/packages/db/tests/query/includes-space-oracle-fixture.ts index eee56f33e7..c9d451d8c6 100644 --- a/packages/db/tests/query/includes-space-oracle-fixture.ts +++ b/packages/db/tests/query/includes-space-oracle-fixture.ts @@ -11,6 +11,8 @@ type LeafRow = { id: string; twigId: string } let fixtureId = 0 +// This regular tree separates result volume from facade ownership. Every root, +// branch, and twig owns one outgoing relationship bucket. Leaves own none. function createRows(rootCount: number) { const roots: Array = [] const branches: Array = [] diff --git a/packages/db/tests/query/includes-space-oracle.test.ts b/packages/db/tests/query/includes-space-oracle.test.ts index 145ade1f5a..de5ff61e99 100644 --- a/packages/db/tests/query/includes-space-oracle.test.ts +++ b/packages/db/tests/query/includes-space-oracle.test.ts @@ -4,6 +4,27 @@ import { BucketFacadeAdapter } from '../../src/query/live/bucket-facade-adapter. import { withHistoryCleanup } from '../optimistic-history-oracle.js' import { createNestedCollectionFixture } from './includes-space-oracle-fixture.js' +/** + * # Does nested Collection materialization retain only reachable facades? + * + * Collection-valued includes need one facade for each reachable relationship + * bucket. They must not create a facade per leaf, retain a retired bucket at + * startup, or leak partially built sources when setup fails. + * + * The fixture has two branches per root, five twigs per branch, and ten leaves + * per twig. Facades exist for the root-to-branch, branch-to-twig, and + * twig-to-leaf buckets. The expected count is therefore `roots + branches + + * twigs`. Leaf count changes row volume but not facade count. + * + * This oracle also checks setup ownership. All four source preloads must settle + * before cleanup starts. Any synchronous or asynchronous setup failure cleans + * every source, restores test instrumentation, preserves the first failure, + * and reports cleanup failures separately. + * + * Facade-map inspection is a test diagnostic, not a runtime API. The count is a + * bounded space invariant for this fixed topology, not a general heap measure. + */ + // Inspect retained adapter state only in tests; diagnostics need no runtime API. type AdapterState = { getEntry: (...args: Array) => object @@ -106,31 +127,34 @@ describe(`nested Collection materialization space oracle`, () => { } } - it(`constructs exactly one facade per reachable bucket`, async () => { - await withSpaceFixture(20, async (fixture, entries) => { - await fixture.live.preload() + it.each([0, 1, 20])( + `constructs exactly one facade per reachable bucket for %i roots`, + async (rootCount) => { + await withSpaceFixture(rootCount, async (fixture, entries) => { + await fixture.live.preload() - const adapters = new Set(entries.mock.contexts as Array) - const created = new Set( - entries.mock.results - .filter((result) => result.type === `return`) - .map((result) => result.value), - ) - expect(created.size).toBe(fixture.expectedFacadeCount) - expect( - [...adapters].reduce( - (n, adapter) => n + countEntries(adapter.entries), - 0, - ), - ).toBe(fixture.expectedFacadeCount) - expect( - [...adapters].reduce( - (n, adapter) => n + countEntries(adapter.retiredEntries), - 0, - ), - ).toBe(0) - }) - }) + const adapters = new Set(entries.mock.contexts as Array) + const created = new Set( + entries.mock.results + .filter((result) => result.type === `return`) + .map((result) => result.value), + ) + expect(created.size).toBe(fixture.expectedFacadeCount) + expect( + [...adapters].reduce( + (n, adapter) => n + countEntries(adapter.entries), + 0, + ), + ).toBe(fixture.expectedFacadeCount) + expect( + [...adapters].reduce( + (n, adapter) => n + countEntries(adapter.retiredEntries), + 0, + ), + ).toBe(0) + }) + }, + ) for (const phase of [ `preload`, diff --git a/packages/db/tests/query/includes-temporal-oracle.test.ts b/packages/db/tests/query/includes-temporal-oracle.test.ts index 964c210470..74f7745b21 100644 --- a/packages/db/tests/query/includes-temporal-oracle.test.ts +++ b/packages/db/tests/query/includes-temporal-oracle.test.ts @@ -24,6 +24,32 @@ import type { BasicExpression } from '../../src/query/ir.js' import type { TraceDriver, TraceProjection } from '../trace-runner.js' import type { Scheduler } from 'fast-check' +/** + * # Which child demand controls readiness and publication? + * + * Parent routes can appear, disappear, and return. Their correlated child + * demand changes with them. A settled Promise does not identify current work. + * The model follows logical demand incarnations and applies these laws: + * + * 1. Every reachable child demand must settle before initial readiness. + * 2. Demand that is no longer reachable cannot block readiness. + * 3. An obsolete demand cannot publish rows or settle a later incarnation. + * 4. A successful load settles only after its source writes are public. + * 5. Failure belongs to the demand that failed. Retired failure cannot poison + * a later demand or keep unrelated graph work private. + * + * No one state machine mirrors the production controller. The file uses small + * models for readiness, cancellation, scheduled completion, and progressive + * delivery. Each model records only the public facts needed for its law. + * fast-check schedules current and obsolete completions in new orders, while + * fixed scheduler orders pin both directions. + * + * The production drivers use real Collections, compiled includes, applied + * receipts, release callbacks, replay barriers, and source writes. They observe + * readiness, preload settlement, visible rows, request keys, and errors at each + * named boundary. The live-query architecture remains the contract source. + */ + type Post = { id: number authorId: string @@ -60,6 +86,8 @@ type PreloadState = { preloadSettled: boolean } +// A preload can reject before cleanup observes it. This record retains both +// settlement and the original error without changing the production Promise. function startPreload( live: ReturnType, state: PreloadState, @@ -783,6 +811,7 @@ it.each( }, ) +// Model A: initial readiness depends only on currently reachable demand. type ReadinessObservation = { ready: boolean preloadSettled: boolean @@ -881,6 +910,8 @@ async function expectReadinessMatches( }) } +// Model B: retiring the only route removes its child load from readiness, even +// when the physical acquisition cannot settle yet. type DemandCancellationObservation = { ready: boolean rowCount: number @@ -1120,6 +1151,8 @@ async function expectObsoleteDemandCannotPublishAfterReactivation(): Promise { + // The scheduler changes only completion order. Request generations decide + // whether a completion can publish. const { collection: posts, remove, add } = createRemovablePost() const requests: Array<{ outcome: Promise @@ -1180,7 +1213,8 @@ async function expectScheduledDemandCompletionsStayGenerationSafe( await scheduler.waitAll(async (task) => { await task() await flushPromises() - // Copy this completion's public value; later completions must not erase it. + // Copy this completion's public value. Later completions must not replace + // it. observations.push({ completed: scheduler .report() @@ -1816,6 +1850,8 @@ type FastPathEvent = { keys: Array } +// Model C: progressive rows can publish before demand settles. The load still +// starts inside the initial fast-path window and readiness waits for settlement. type ProgressiveObservation = { events: Array ready: boolean @@ -2181,8 +2217,8 @@ describe(`includes temporal oracle`, () => { }, }, }) - // Keep wrappers concrete at compilation; a union of wrapper types is - // not a supported query-builder result type. + // Keep wrappers concrete at compilation. The query builder does not + // accept a union of wrapper types as its result. const live = form === `array` ? createLiveQueryCollection((q) => diff --git a/packages/db/tests/query/includes-work-counter-oracle.test.ts b/packages/db/tests/query/includes-work-counter-oracle.test.ts index 1f0d5da733..598cfa352e 100644 --- a/packages/db/tests/query/includes-work-counter-oracle.test.ts +++ b/packages/db/tests/query/includes-work-counter-oracle.test.ts @@ -1,6 +1,6 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { beforeAll, describe, expect, it } from 'vitest' -import { oracleRuns } from '../oracle-config.js' +import { oraclePropertyOptions, oracleRuns } from '../oracle-config.js' import { createCollection } from '../../src/collection/index.js' import { BTreeIndex } from '../../src/indexes/btree-index.js' import { localOnlyCollectionOptions } from '../../src/local-only.js' @@ -11,6 +11,29 @@ import { } from '../../src/query/index.js' import type { Collection } from '../../src/collection/index.js' +/** + * # Does unrelated source size increase correlated include work? + * + * Correct rows are not enough for a pushed-down include. An implementation can + * return the right tree after scanning or delivering every unrelated source + * row. This oracle protects a bounded work law for indexed correlations: + * + * 1. The query returns the complete expected nested result. + * 2. Adding unmatched link rows does not increase source delivery or reads. + * 3. Adding indexed join targets does not increase source delivery or reads. + * 4. Without the target join, unrelated rows at every level still add no work. + * + * The reference is relational. It compares each scaled fixture with the same + * query on a minimal baseline. Exact baseline counts prove that the intended + * source path ran. The scaled relation, not elapsed time, states the work law. + * Counters observe both delivered changes and collection reads so filtering + * cannot hide a scan. + * + * The boundary conditions matter. Collections preload before they receive + * B-tree indexes. Filler rows never match a selected route. This suite does not + * promise a general runtime bound or cover providers that ignore local indexes. + */ + let nextCollectionId = 0 type TermRow = { id: string; text: string } @@ -99,9 +122,9 @@ function createSourceCollection( ) } -// Count both sides of the source boundary named in #1709's profile. Delivered -// rows show what enters the dataflow graph. entries() visits capture scans and -// get() calls capture keyed reads, so examined work cannot hide behind a filter. +// Count both sides of the source boundary. Delivered rows show what enters the +// dataflow graph. entries() visits capture scans and get() calls capture keyed +// reads, so examined work cannot hide behind a filter. function countSourceWork(collection: Collection) { let deliveredRows = 0 let examinedRows = 0 @@ -169,7 +192,7 @@ function createFixtureRows(filler: FillerCounts): SourceRows { ], links: [ // Keep filler links on one existing target key. Only the left-side input - // grows; the term-filler control probes right-side input growth separately. + // grows. The term-filler control probes right-side input growth separately. { id: `link-0`, groupId: `group-0`, targetId: `term-1` }, { id: `link-1`, @@ -224,7 +247,7 @@ async function observeWork({ try { await Promise.all(Object.values(sources).map((source) => source.preload())) - // Match #1709's reproduction: load first, then add a B-tree index on each + // Match the reported boundary: load first, then add a B-tree index on each // correlation and join column before constructing the live query. sources.terms.createIndex((row) => row.id, { indexType: BTreeIndex }) sources.meanings.createIndex((row) => row.termId, { @@ -385,6 +408,19 @@ async function expectCorrelatedJoinWorkBound( expect(scaled.sourceWork).toEqual(baseline.sourceWork) } +function campaigns(fixedSeed: number, property: string) { + return [ + { + name: `fixed`, + options: { numRuns: oracleRuns(6), seed: fixedSeed }, + }, + { + name: `random or replayed`, + options: oraclePropertyOptions(6, property), + }, + ] +} + describe(`includes deterministic work-counter oracle`, () => { it.each([true, false])( `retains and rejects extra roots with joinTargets=%s`, @@ -413,63 +449,60 @@ describe(`includes deterministic work-counter oracle`, () => { }) it.each([1, 2, 3])( - `pins the #1709 work bound at the small filler boundary (%i)`, + `pins the work bound at the small filler boundary (%i)`, expectCorrelatedJoinWorkBound, ) - fcTest.prop([fc.integer({ min: 1, max: 24 })], { - numRuns: oracleRuns(6), - seed: 1709, - })( - `a join preserves correlated source pushdown (#1709)`, - expectCorrelatedJoinWorkBound, - ) - - fcTest.prop([fc.integer({ min: 1, max: 24 })], { - numRuns: oracleRuns(6), - seed: 170_900, - })( - `indexed join-target growth keeps source work flat (#1709 direction control)`, - async (fillerCount) => { - const baseline = joinedBaselineObservation - const scaled = await observeWork({ - filler: { - terms: fillerCount, - meanings: 0, - groups: 0, - links: 0, - }, - joinTargets: true, - }) - - expect(baseline.result).toEqual(expectedResult({ joinTargets: true })) - expect(scaled.result).toEqual(baseline.result) - expect(baseline.sourceWork).toEqual(joinedBaselineWork) - expect(scaled.sourceWork).toEqual(baseline.sourceWork) - }, - ) + for (const campaign of campaigns(1709, `includes-work.correlated-links`)) { + fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( + `a join preserves correlated source pushdown (${campaign.name})`, + expectCorrelatedJoinWorkBound, + ) + } - fcTest.prop([fc.integer({ min: 1, max: 24 })], { - numRuns: oracleRuns(6), - seed: 17_090, - })( - `join-free correlated includes keep source work flat (#1709 control)`, - async (fillerCount) => { - const baseline = joinFreeBaselineObservation - const scaled = await observeWork({ - filler: { - terms: fillerCount, - meanings: fillerCount, - groups: fillerCount, - links: fillerCount, - }, - joinTargets: false, - }) + for (const campaign of campaigns(170_900, `includes-work.join-targets`)) { + fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( + `indexed join-target growth keeps source work flat (${campaign.name})`, + async (fillerCount) => { + const baseline = joinedBaselineObservation + const scaled = await observeWork({ + filler: { + terms: fillerCount, + meanings: 0, + groups: 0, + links: 0, + }, + joinTargets: true, + }) + + expect(baseline.result).toEqual(expectedResult({ joinTargets: true })) + expect(scaled.result).toEqual(baseline.result) + expect(baseline.sourceWork).toEqual(joinedBaselineWork) + expect(scaled.sourceWork).toEqual(baseline.sourceWork) + }, + ) + } - expect(baseline.result).toEqual(expectedResult({ joinTargets: false })) - expect(scaled.result).toEqual(baseline.result) - expect(baseline.sourceWork).toEqual(joinFreeBaselineWork) - expect(scaled.sourceWork).toEqual(baseline.sourceWork) - }, - ) + for (const campaign of campaigns(17_090, `includes-work.join-free`)) { + fcTest.prop([fc.integer({ min: 1, max: 24 })], campaign.options)( + `join-free correlated includes keep source work flat (${campaign.name})`, + async (fillerCount) => { + const baseline = joinFreeBaselineObservation + const scaled = await observeWork({ + filler: { + terms: fillerCount, + meanings: fillerCount, + groups: fillerCount, + links: fillerCount, + }, + joinTargets: false, + }) + + expect(baseline.result).toEqual(expectedResult({ joinTargets: false })) + expect(scaled.result).toEqual(baseline.result) + expect(baseline.sourceWork).toEqual(joinFreeBaselineWork) + expect(scaled.sourceWork).toEqual(baseline.sourceWork) + }, + ) + } }) diff --git a/packages/db/tests/query/load-subset-oracle.property.test.ts b/packages/db/tests/query/load-subset-oracle.property.test.ts index ffde012980..01c031b89c 100644 --- a/packages/db/tests/query/load-subset-oracle.property.test.ts +++ b/packages/db/tests/query/load-subset-oracle.property.test.ts @@ -20,6 +20,25 @@ import type { SyncAppliedReceipt, } from '../../src/types.js' +/** + * # When are two loadSubset demands the same, applied, or canceled? + * + * Exact demand identity includes predicate values, order terms and comparison + * options, offset, limit, and cursor boundary. Equal demands share one physical + * acquisition; distinct demands do not. Rejection reaches every waiter, then + * leaves the demand retryable. + * + * A separate application law says acquisition settlement is not enough. Loaded + * rows must cross the Collection publication boundary before readiness settles. + * Abort before that boundary rejects and suppresses the rows. Abort after + * publication is too late and the applied rows remain visible. + * + * The identity model uses canonical plain records and an independent SQL + * expression evaluator. The application driver uses real transactions, sync + * receipts, optimistic work, and live queries. Keeping these nodes separate + * prevents a correct key function from hiding a broken settlement boundary. + */ + type PersistedLoadRow = { id: string projectId: string diff --git a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts index 1637094b3e..04950ab9f9 100644 --- a/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-replay-refinement-oracle.test.ts @@ -15,11 +15,29 @@ import type { SyncConfig, } from '../../src/types.js' +/** + * # Which replay may replace a loadSubset publication? + * + * A replay is authoritative only when its full participating source set + * succeeds. Rows written by a failed, obsolete, or incomplete replay remain + * private. The last complete public snapshot stays visible until a newer + * complete replay replaces it. Direct subscribers may restart after source + * cleanup; a dependent live query that entered a terminal source error does not + * revive merely because that source restarts. + * + * Each distinguishing history uses small plain row sets and explicit deferred + * attempts. The driver observes exact publications, readiness, errors, loads, + * unloads, and final rows across single-source, include-route, and joined-source + * replays. These are refinement checks for production replay boundaries, not a + * second general lifecycle model. + */ + type Row = { id: string; version: number } type ObservedRow = { sourceId: string; rowKey: string; version: number } describe(`loadSubset replay refinement`, () => { - // A direct subscriber survives source cleanup. A dependent live query enters + // A direct subscriber survives source Collection cleanup. A dependent live + // query enters // a terminal error instead; restarting only its source must not revive it. it.each( ([`direct`, `live`] as const).flatMap((consumer) => @@ -29,7 +47,7 @@ describe(`loadSubset replay refinement`, () => { })), ), )( - `separates direct restart from fatal live source cleanup: %j`, + `separates direct restart from fatal source Collection cleanup for a live query: %j`, async ({ consumer, outcome }) => { let operations!: Parameters[`sync`]>[0] let loads = 0 diff --git a/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts index 1cc4b5962e..014aed488f 100644 --- a/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-source-readiness-refinement-oracle.test.ts @@ -11,6 +11,19 @@ import { import { flushPromises } from '../utils.js' import type { LoadSubsetOptions } from '../../src/types.js' +/** + * # Can an obsolete source attempt satisfy a fresh correlated demand? + * + * Changing the parent correlation key retires the old child demand and starts + * a new generation. The old request may resolve or reject before or after the + * fresh one, but it may not mark the fresh query ready, publish stale children, + * or release the fresh acquisition lease. + * + * This four-cell refinement crosses old resolve/reject with old-first/fresh- + * first settlement. It drives real parent and on-demand child Collections and + * checks request predicates, abort state at unload, readiness, and visible rows. + */ + type Row = { id: string; group: string } it.each([ diff --git a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts index f92a867d1b..367959afb4 100644 --- a/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts +++ b/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts @@ -3,18 +3,67 @@ import { createCollection } from '../../src/collection/index.js' import { createDeferred } from '../../src/deferred.js' import { createTransaction } from '../../src/transactions.js' +/** + * # When can an abort cancel a load? + * + * An on-demand load has two stages: + * + * 1. The source writes a row to a pending batch. + * 2. The collection publishes the row to readers. + * + * Another transaction can delay the second stage. During this delay, the + * source can finish the first stage. An abort before publication must reject + * the load and discard the row. An abort after publication starts must resolve + * the load and keep the row. + * + * `expectedOutcome` states this rule from public facts. It does not copy the + * production queue. The test creates each timing phase in production. It then + * compares the result with the model. + */ + type Row = { id: string; group: string } +type AbortPhase = `at-commit` | `while-parked` | `after-publication-starts` + +type ExpectedOutcome = { + load: `rejects` | `resolves` + rowIsVisible: boolean +} + +// Publication is the boundary. An abort before publication rejects the load +// and discards the row. An abort after publication starts resolves the load +// and keeps the row visible. +function expectedOutcome(abortPhase: AbortPhase): ExpectedOutcome { + const publicationStarted = abortPhase === `after-publication-starts` + return { + load: publicationStarted ? `resolves` : `rejects`, + rowIsVisible: publicationStarted, + } +} + describe(`loadSubset transaction refinement`, () => { - it.each([`at-commit`, `while-parked`, `after-publication-starts`] as const)( + // These three phases cover the contract: + // - The caller aborts during commit. + // - The caller aborts after commit while publication waits. + // - The caller aborts when the first listener runs. + // This oracle does not vary the schedule within each phase. + it.each([ + `at-commit`, + `while-parked`, + `after-publication-starts`, + ])( `matches the independent receipt and publication model when aborting %s`, async (abortPhase) => { + const expected = expectedOutcome(abortPhase) const sourceId = `transaction-refinement-${abortPhase}` const remoteRow: Row = { id: `remote`, group: `requested` } const controller = new AbortController() const persistence = createDeferred() const publishedBatches: Array> = [] const callbackReads: Array> = [] + + // Keep the local mutation unresolved. This delays publication of the + // remote row and creates the second abort phase. const source = createCollection({ id: sourceId, getKey: (row) => row.id, @@ -40,6 +89,8 @@ describe(`loadSubset transaction refinement`, () => { blocker.mutate(() => source.insert({ id: `local`, group: `outside-request` }), ) + + // Record the change and the collection snapshot at the same publication. const subscription = source.subscribeChanges( (changes) => { const remoteKeys = changes @@ -65,16 +116,21 @@ describe(`loadSubset transaction refinement`, () => { persistence.resolve() await blocker.isPersisted.promise - if (abortPhase !== `after-publication-starts`) { + if (expected.load === `rejects`) { await expect(load).rejects.toMatchObject({ name: `AbortError` }) } else { await expect(load).resolves.toBeUndefined() } - const published = abortPhase === `after-publication-starts` - expect(source.has(remoteRow.id)).toBe(published) - expect(publishedBatches).toEqual(published ? [[remoteRow.id]] : []) - expect(callbackReads).toEqual(published ? [[remoteRow.id]] : []) + // The load promise, change event, and collection snapshot must agree. + // A mismatch would expose a partial publication to callers. + expect(source.has(remoteRow.id)).toBe(expected.rowIsVisible) + expect(publishedBatches).toEqual( + expected.rowIsVisible ? [[remoteRow.id]] : [], + ) + expect(callbackReads).toEqual( + expected.rowIsVisible ? [[remoteRow.id]] : [], + ) } finally { persistence.resolve() await blocker.isPersisted.promise.catch(() => undefined) diff --git a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts index fb52e99c8c..a5f9235bc3 100644 --- a/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts @@ -13,6 +13,26 @@ import { } from '../oracle-config.js' import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' +/** + * # Which ordered request histories are distinct? + * + * Ordered acquisition has six independent control dimensions: acquisition + * path, delivery time, window change, acquisition outcome, sync run, and + * initial-versus-replay barrier. Their 192-cell product is small enough to enumerate. A + * second product adds nullable multi-term ordering, including direction and + * null placement for both terms. + * + * For each cell, a plain finite source supplies the reference order and window. + * The driver records physical acquisitions, application, readiness, errors, + * cleanup, and final rows from a real live query. Reach assertions prove every + * declared cell performs work and reaches terminal cleanup. Deliberate + * secondary-order and null-placement faults calibrate the comparator checks. + * + * `AcquisitionPath` is a model projection over production request kinds. A + * `page` is an indexed ordered request. A `prefix` is an unindexed ordered + * request. `boundary` and `full-source` retain the production names. + */ + type Row = { id: number rank: number | null @@ -20,13 +40,13 @@ type Row = { version: number } type OrderTerm = { direction: `asc` | `desc`; nulls: `first` | `last` } -type Route = `page` | `prefix` | `boundary` | `full-source` +type AcquisitionPath = `page` | `prefix` | `boundary` | `full-source` type Scenario = { - route: Route + acquisitionPath: AcquisitionPath delivery: `before-settlement` | `after-success` window: `keep` | `widen` outcome: `resolve` | `reject` | `abort-error` - session: `retain` | `restart` + syncRun: `retain` | `restart` barrier: `initial` | `replay` rankOffset?: number rankStep?: number @@ -47,7 +67,7 @@ function compareNullable( return (left < right ? -1 : 1) * (term.direction === `asc` ? 1 : -1) } -const routes: ReadonlyArray = [ +const acquisitionPaths: ReadonlyArray = [ `page`, `prefix`, `boundary`, @@ -81,7 +101,7 @@ async function observeHistory( ? finiteValue([null, -2, 2][Math.floor((id - 1) / 3)]!) : (scenario.rankOffset ?? 0) + (scenario.rankStep ?? 1) * - (scenario.route === `boundary` && id === 2 ? 1 : id), + (scenario.acquisitionPath === `boundary` && id === 2 ? 1 : id), })) const primary = scenario.nullable?.primary ?? @@ -107,7 +127,7 @@ async function observeHistory( : new Error(`target rejected`) const requests: Array<{ options: LoadSubsetOptions - session: number + syncRunGeneration: number ids: Array indexed: boolean applied: boolean @@ -116,7 +136,7 @@ async function observeHistory( const sourceCleanups: Array = [] const publications: Array> = [] const deliveredRows = new Map() - let generation = 0 + let syncRunGeneration = 0 let activeSync!: Sync let activeInstalled!: Set let targetOutcome: string | undefined @@ -130,12 +150,12 @@ async function observeHistory( id: `ordered-history-source-${JSON.stringify(scenario)}`, getKey: ({ id }) => id, syncMode: `on-demand`, - autoIndex: scenario.route === `prefix` ? `off` : `eager`, + autoIndex: scenario.acquisitionPath === `prefix` ? `off` : `eager`, defaultIndexType: BTreeIndex, sync: { sync: (sync: Sync) => { activeSync = sync - const session = ++generation + const requestSyncRunGeneration = ++syncRunGeneration const installed = new Set() activeInstalled = installed sync.markReady() @@ -175,22 +195,26 @@ async function observeHistory( ) const request = { options, - session, + syncRunGeneration: requestSyncRunGeneration, ids: rows.map(({ id }) => id), indexed: source.indexes.size > 0, applied: false, } requests.push(request) const matchesRoute = - scenario.route === `boundary` + scenario.acquisitionPath === `boundary` ? options.orderBy === undefined && options.where !== undefined - : scenario.route === `full-source` + : scenario.acquisitionPath === `full-source` ? options.limit === undefined && options.where === undefined : options.orderBy !== undefined && options.limit !== undefined const gated = allowTarget && !target && matchesRoute if (gated) target = request const apply = async () => { - if (options.signal?.aborted || session !== generation) return + if ( + options.signal?.aborted || + requestSyncRunGeneration !== syncRunGeneration + ) + return request.applied = true const fresh = rows.filter(({ id }) => !installed.has(id)) if (fresh.length === 0) return @@ -224,7 +248,7 @@ async function observeHistory( released.push(options) }, cleanup: () => { - sourceCleanups.push(session) + sourceCleanups.push(requestSyncRunGeneration) }, } }, @@ -233,7 +257,7 @@ async function observeHistory( const live = createLiveQueryCollection((q) => { const from = q.from({ row: source }) const ordered = ( - scenario.route === `full-source` && !scenario.nullable + scenario.acquisitionPath === `full-source` && !scenario.nullable ? from.distinct() : from ).orderBy( @@ -350,7 +374,7 @@ async function observeHistory( })), }), ).toBeDefined() - expect(target!.session).toBe(1) + expect(target!.syncRunGeneration).toBe(1) expect(target!.ids.length).toBeGreaterThan(0) await flushPromises() if (scenario.barrier === `initial`) @@ -375,7 +399,7 @@ async function observeHistory( }) expect(publications).toEqual([]) } - if (scenario.session === `restart`) { + if (scenario.syncRun === `restart`) { allowTarget = false await live.cleanup() await source.cleanup() @@ -401,7 +425,7 @@ async function observeHistory( ) } await live.preload() - expect(generation).toBe(2) + expect(syncRunGeneration).toBe(2) check(`restarted-window`, read(), referenceWindow(1)) check(`restarted-window-options`, live.utils.getWindow(), { offset: 0, @@ -417,12 +441,12 @@ async function observeHistory( for (let turn = 0; turn < 8; turn++) await flushPromises() expect(targetOutcome).toBe(scenario.outcome) // Deferred application happens only after a live attempt succeeds. Failure - // and old-session success must not apply its rows through this provider. + // and old-sync-run success must not apply its rows through this provider. expect(target!.applied).toBe( scenario.delivery === `before-settlement` || - (scenario.outcome === `resolve` && scenario.session === `retain`), + (scenario.outcome === `resolve` && scenario.syncRun === `retain`), ) - if (scenario.session === `restart`) { + if (scenario.syncRun === `restart`) { check(`obsolete-status`, live.status, priorStatus) check(`obsolete-error`, live.utils.lastSubsetError === priorError, true) check(`obsolete-rows`, read(), prior) @@ -560,9 +584,9 @@ async function observeHistory( expect(new Set(released).size).toBe(released.length) for (const { options } of requests) expect(released.filter((release) => release === options)).toHaveLength(1) - expect(sourceCleanups).toEqual(scenario.session === `restart` ? [1, 2] : [1]) + expect(sourceCleanups).toEqual(scenario.syncRun === `restart` ? [1, 2] : [1]) expect(requests.every(({ options }) => options.signal?.aborted)).toBe(true) - const route = + const acquisitionPath = target!.options.orderBy !== undefined ? target!.indexed ? `page` @@ -571,12 +595,12 @@ async function observeHistory( ? `boundary` : `full-source` return { - route, + acquisitionPath, authority: target!.options.limit === undefined && target!.options.where === undefined ? `full` : `finite`, - generation, + syncRunGeneration, repaired, orderedRequests: requests.filter( ({ options }) => options.orderBy !== undefined, @@ -592,11 +616,11 @@ async function observeHistory( options.limit === undefined && options.where === undefined, ).length, coordinates: [ - route, + acquisitionPath, appliedBeforeSettlement ? `before-settlement` : `after-success`, move ? `widen` : `keep`, targetOutcome, - generation === 2 ? `restart` : `retain`, + syncRunGeneration === 2 ? `restart` : `retain`, replayStarted ? `replay` : `initial`, ], mismatches, @@ -608,28 +632,28 @@ async function assertHistory( fault?: `secondary-order` | `null-placement`, ) { const result = await observeHistory(scenario, fault) - expect(result.route).toBe(scenario.route) + expect(result.acquisitionPath).toBe(scenario.acquisitionPath) expect(result.authority).toBe( - scenario.route === `full-source` ? `full` : `finite`, + scenario.acquisitionPath === `full-source` ? `full` : `finite`, ) - expect(result.generation).toBe(scenario.session === `restart` ? 2 : 1) + expect(result.syncRunGeneration).toBe(scenario.syncRun === `restart` ? 2 : 1) expect(result.mismatches).toEqual([]) return result } describe(`ordered lifecycle product`, () => { const observed = new Set() - const cells: Array = routes.flatMap((route) => + const cells: Array = acquisitionPaths.flatMap((acquisitionPath) => ([`before-settlement`, `after-success`] as const).flatMap((delivery) => ([`keep`, `widen`] as const).flatMap((window) => ([`resolve`, `reject`, `abort-error`] as const).flatMap((outcome) => - ([`retain`, `restart`] as const).flatMap((session) => + ([`retain`, `restart`] as const).flatMap((syncRun) => ([`initial`, `replay`] as const).map((barrier) => ({ - route, + acquisitionPath, delivery, window, outcome, - session, + syncRun, barrier, })), ), @@ -642,7 +666,7 @@ describe(`ordered lifecycle product`, () => { expect(new Set(cells.map((cell) => JSON.stringify(cell))).size).toBe(192) }) it.each(cells)( - `$route / $delivery / $window / $outcome / $session / $barrier`, + `$acquisitionPath / $delivery / $window / $outcome / $syncRun / $barrier`, async (scenario) => { const result = await assertHistory(scenario) observed.add(JSON.stringify(result.coordinates)) @@ -652,7 +676,7 @@ describe(`ordered lifecycle product`, () => { expect(observed.size).toBe(192) }) const arbitrary = fc.record({ - route: fc.constantFrom(...routes), + acquisitionPath: fc.constantFrom(...acquisitionPaths), delivery: fc.constantFrom( `before-settlement` as const, `after-success` as const, @@ -663,7 +687,7 @@ describe(`ordered lifecycle product`, () => { `reject` as const, `abort-error` as const, ), - session: fc.constantFrom(`retain` as const, `restart` as const), + syncRun: fc.constantFrom(`retain` as const, `restart` as const), barrier: fc.constantFrom(`initial` as const, `replay` as const), rankOffset: fc.integer({ min: -1000, max: 1000 }), rankStep: fc.integer({ min: 1, max: 10 }), @@ -692,7 +716,7 @@ describe(`nullable multi-term lifecycle product`, () => { const cells: Array = ([`first`, `last`] as const) .flatMap((primaryNulls) => ([`first`, `last`] as const).flatMap((secondaryNulls) => - ([`prefix`, `boundary`] as const).flatMap((route) => + ([`prefix`, `boundary`] as const).flatMap((acquisitionPath) => ([`resolve`, `repair`, `restart`] as const).map( (mode) => ({ @@ -700,14 +724,14 @@ describe(`nullable multi-term lifecycle product`, () => { primary: { direction: `asc`, nulls: primaryNulls }, secondary: { direction: `desc`, nulls: secondaryNulls }, }, - route: - route === `boundary` && primaryNulls === `first` + acquisitionPath: + acquisitionPath === `boundary` && primaryNulls === `first` ? `full-source` - : route, + : acquisitionPath, delivery: `before-settlement`, window: `widen`, outcome: mode === `repair` ? `reject` : `resolve`, - session: mode === `restart` ? `restart` : `retain`, + syncRun: mode === `restart` ? `restart` : `retain`, barrier: mode === `repair` ? `replay` : `initial`, repair: mode === `repair`, }) as const, @@ -721,7 +745,7 @@ describe(`nullable multi-term lifecycle product`, () => { (scenario) => !( scenario.nullable.primary.nulls === `first` && - scenario.route === `prefix` && + scenario.acquisitionPath === `prefix` && scenario.repair ), ) @@ -774,7 +798,7 @@ describe(`nullable multi-term lifecycle product`, () => { ) it(`generates the nullable value and lifecycle dimensions`, () => { const sample = fc.sample(arbitrary, { seed: 93472, numRuns: 100 }) - expect(new Set(sample.map((scenario) => scenario.route))).toEqual( + expect(new Set(sample.map((scenario) => scenario.acquisitionPath))).toEqual( new Set([`prefix`, `boundary`, `full-source`]), ) expect( @@ -787,7 +811,7 @@ describe(`nullable multi-term lifecycle product`, () => { new Set(sample.map((scenario) => scenario.nullable!.primary.direction)), ).toEqual(new Set([`asc`, `desc`])) expect(sample.some((scenario) => scenario.repair)).toBe(true) - expect(sample.some((scenario) => scenario.session === `restart`)).toBe(true) + expect(sample.some((scenario) => scenario.syncRun === `restart`)).toBe(true) }) const { multiplier, ...replay } = readOracleRunConfig() fcTest.prop( diff --git a/packages/db/tests/query/ordered-work-oracle.property.test.ts b/packages/db/tests/query/ordered-work-oracle.property.test.ts index c0fd7571fe..7cb5188980 100644 --- a/packages/db/tests/query/ordered-work-oracle.property.test.ts +++ b/packages/db/tests/query/ordered-work-oracle.property.test.ts @@ -18,6 +18,24 @@ import { withHistoryCleanup } from '../optimistic-history-oracle.js' import type { InitialQueryBuilder } from '../../src/query/builder/index.js' import type { LoadSubsetOptions, SyncConfig } from '../../src/types.js' +/** + * # Does ordered acquisition do only the work its result requires? + * + * Row truth and work truth are different laws. An ordered query must match an + * independent filter/sort/window recomputation. It must also avoid duplicate + * finite requests, repeated source scans, partial initial publications, and + * cross-source suppression when joined loads overlap or replay. + * + * The value model is a plain sorted array. The work model records normalized + * page and boundary requests, examined source rows, publications, and errors. + * Live Collections and Effects receive the same scenario and must agree with + * each other and the model. Exhaustive small domains cover ties, eligibility, + * direction, and middle-row count; generated runs vary the same grammar. + * + * Counts are contract bounds, not timing benchmarks. They pin established + * request and scan behavior only where the test names that promise. + */ + type Row = { id: number rank: number diff --git a/packages/db/tests/query/pagination-oracle.property.test.ts b/packages/db/tests/query/pagination-oracle.property.test.ts index 91ffdd45ab..1c187cfbcf 100644 --- a/packages/db/tests/query/pagination-oracle.property.test.ts +++ b/packages/db/tests/query/pagination-oracle.property.test.ts @@ -22,6 +22,28 @@ import type { SyncConfig, } from '../../src/types.js' +/** + * # Does an ordered window equal independent recomputation? + * + * A page is not whatever rows the loader happened to retain. It is the result + * of filtering the authoritative finite source, sorting with the declared + * terms and public-key tie-breaker, then slicing by offset and limit. Window + * changes, source inserts, updates, deletes, ties, nulls, locale order, and + * failed requests must all preserve that definition. + * + * Plain arrays and `makeComparator` form the value oracle. Separate state + * nodes track requested windows, authoritative coverage, pending acquisition, + * and public batches. The production drivers cross scan and indexed routes, + * direct and joined queries, synchronous and asynchronous delivery, reentry, + * restart, rejection, and abort. They compare exact request options, visible + * rows, readiness, errors, and every publication cut. + * + * Finite products pin boundary cells; fixed and random fast-check histories + * explore adjacent legal actions. Fault probes establish that wrong order, + * false coverage, duplicate work, partial publication, and bad delete payloads + * are observable. The model does not infer rows beyond provider evidence. + */ + type PageRow = { id: number rank: number @@ -3718,7 +3740,7 @@ describe(`pagination recomputation oracle`, () => { }) it.each([`return-only`, `write-after-cleanup`])( - `does not settle a window move after its sync session is cleaned up: %s`, + `does not settle a window move after its sync run is cleaned up: %s`, async (delivery) => { const authoritativeRows: Array = [ { id: 1, rank: 0 }, diff --git a/packages/db/tests/utils.property.test.ts b/packages/db/tests/utils.property.test.ts index d8cd48c607..466d849215 100644 --- a/packages/db/tests/utils.property.test.ts +++ b/packages/db/tests/utils.property.test.ts @@ -4,14 +4,18 @@ import { Temporal } from 'temporal-polyfill' import { deepEquals } from '../src/utils' /** - * Custom arbitraries for generating values that deepEquals handles. + * `deepEquals` defines a bounded structural equivalence relation, not a general + * graph isomorphism. * - * Same-type pairs exercise structural equality. The cross-type laws below - * separately require Date/Temporal values of different types to be unequal in - * both directions. They make no claim about ordering those types, or about deep - * equality for object-valued Sets and arbitrary shared/circular graphs. The - * bounded graph laws below construct corresponding rings and acyclic copies; - * they do not define equality for arbitrary different cycle topologies. + * Independent value constructors exercise reflexivity, symmetry, copied + * structure, and changed-leaf inequality for primitives, arrays, records, + * dates, regexes, typed bytes, and selected Temporal types. Cross-type values + * remain unequal. Separate bounded graph laws construct corresponding rings + * and acyclic copies without reusing the production walk. + * + * Object-valued Set matching and arbitrary different cycle topologies are + * outside this model. Naming those exclusions matters: more random examples + * cannot establish semantics the reference relation does not define. */ const arbitraryPrimitive = fc.oneof( fc.string(), diff --git a/packages/electric-db-collection/tests/electric-oracle-lifecycle.test.ts b/packages/electric-db-collection/tests/electric-oracle-lifecycle.test.ts index 4d435008db..16bf461097 100644 --- a/packages/electric-db-collection/tests/electric-oracle-lifecycle.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle-lifecycle.test.ts @@ -1,6 +1,13 @@ import { expect, it, vi } from 'vitest' import { atCheckpoint, withElectricCleanup } from './electric-oracle-lifecycle' +/** + * Calibration for Electric oracle ownership. The cases prove that a timeout is + * only an observation failure, that later resources still release, and that + * cleanup cannot replace a primary semantic failure. Provider properties rely + * on these rules when a held stream or local HTTP server misbehaves. + */ + it(`tries every Electric oracle resource and retains primary and secondary failures`, async () => { const primary = new Error(`primary`) const secondary = new Error(`secondary`) diff --git a/packages/electric-db-collection/tests/electric-oracle-lifecycle.ts b/packages/electric-db-collection/tests/electric-oracle-lifecycle.ts index 4baeb9bd74..885d5cbc7e 100644 --- a/packages/electric-db-collection/tests/electric-oracle-lifecycle.ts +++ b/packages/electric-db-collection/tests/electric-oracle-lifecycle.ts @@ -1,5 +1,12 @@ -// A deadline reports a missing semantic checkpoint. It does not cancel work; -// callers still own and release their SDK stream and HTTP provider. +/** + * Bound oracle observation without changing provider semantics. + * + * A deadline reports a missing semantic checkpoint. It does not cancel work; + * callers still own and release their SDK stream and HTTP provider. Cleanup is + * exhaustive: every registered resource gets one attempt. A primary test error + * remains primary, while cleanup failures stay visible as warnings; without a + * primary error, the first cleanup failure rejects after later releases run. + */ export async function atCheckpoint( promise: Promise, label: string, diff --git a/packages/electric-db-collection/tests/electric-oracle.property.test.ts b/packages/electric-db-collection/tests/electric-oracle.property.test.ts index 49457d1ea6..eb208a5d56 100644 --- a/packages/electric-db-collection/tests/electric-oracle.property.test.ts +++ b/packages/electric-db-collection/tests/electric-oracle.property.test.ts @@ -17,6 +17,28 @@ import type { } from '../../db-sqlite-persistence-core/src' import type { ElectricCollectionUtils, ElectricSyncMode } from '../src/electric' +/** + * # Does the Electric adapter preserve one coherent replica lifecycle? + * + * Electric delivers change messages, reset controls, offsets, handles, and + * snapshot availability through an SDK stream. The adapter must publish atomic + * callback cuts, wait for applied receipts before readiness, persist valid + * resume evidence, reject unseen partial updates, and keep stale callbacks and + * metadata scoped to the lifecycle that created them. + * + * Independent Maps model rows, selected-tag membership, resume evidence, and + * persisted state. A controlled ShapeStream boundary supplies authored message + * partitions and settlement order. The production Collection, Query-backed + * persistence wrapper, metadata APIs, and cleanup all run unchanged. Checks + * cover rows, exact batches, readiness, errors, metadata, waiters, stream + * ownership, and restart. + * + * The suite uses fixed protocol histories, exhaustive contiguous partitions, + * and fixed plus random generated campaigns. The installed-SDK delivery oracle + * separately owns real HTTP framing and pause behavior; PostgreSQL expression + * semantics and service-backed execution remain separate evidence. + */ + type OracleRow = Row & { id: number name: string @@ -1062,7 +1084,7 @@ async function runProcessGrammar( if (!preload.settled) preload.expectedRejection = reason } } - let generation = 0 + let runtimeInstanceSequence = 0 mockSubscribe.mockReset() mockSubscribe.mockImplementation((callback) => { const observed: NonNullable = (messages) => { @@ -1080,10 +1102,10 @@ async function runProcessGrammar( expectPendingRejection(previous, `cleanup`) await previous.collection.cleanup() } - generation++ + runtimeInstanceSequence++ const collection = createCollection( electricCollectionOptions({ - id: `${idPrefix}-${slot}-${generation}`, + id: `${idPrefix}-${slot}-${runtimeInstanceSequence}`, shapeOptions: { url: `http://test-url`, params: { table: `test_table` }, @@ -3152,7 +3174,7 @@ describe(`Electric adapter laws`, () => { trigger: number }) { // Same ownership law, with retirement either outside or inside a callback. - // Previously the grammar only retired a session between complete callbacks. + // Previously the grammar only retired a sync run between complete callbacks. for (const insideCallback of [false, true]) { const subscribers: Array<(messages: Array>) => void> = [] @@ -3196,7 +3218,7 @@ describe(`Electric adapter laws`, () => { restart() return false }, 20) - // Observe the old waiter before any callback can retire its session. + // Observe the old waiter before any callback can retire its sync run. const oldOutcome = waiting.then( () => `resolved`, () => `rejected`, @@ -3239,14 +3261,14 @@ describe(`Electric adapter laws`, () => { } fcTest.prop([reentryHistory], { seed: 42713, numRuns: oracleRuns(6) })( - `replacement sessions reject evidence from callback reentry histories (fixed)`, + `replacement sync runs reject evidence from callback reentry histories (fixed)`, runReentryHistory, ) fcTest.prop( [reentryHistory], oraclePropertyOptions(10, `electric.match-reentry`), )( - `replacement sessions reject evidence from callback reentry histories (random)`, + `replacement sync runs reject evidence from callback reentry histories (random)`, runReentryHistory, ) diff --git a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts index 00b3fc09b4..8a56b825ef 100644 --- a/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts +++ b/packages/electric-db-collection/tests/electric-recovery-oracle.test.ts @@ -14,6 +14,21 @@ import type { } from '../../db-sqlite-persistence-core/src' import type { ElectricCollectionUtils, ElectricSyncMode } from '../src/electric' +/** + * # What remains visible while a persisted Electric replica repairs itself? + * + * Hydrated rows and resume metadata provide the last complete public snapshot. + * A must-refetch starts a private replacement. Until that replacement is fully + * applied, readers may see an earlier permitted snapshot but never a torn mix. + * Failure keeps the old public rows and records repair debt; later success may + * replace them atomically. + * + * A plain persisted row Map and metadata Map form the reference snapshots. The + * driver controls hydration, SDK callbacks, applied receipts, cleanup, restart, + * and eager or progressive mode through the real persistence coordinator and + * Electric adapter. It records every exposed cut, not only the final rows. + */ + type Item = Row & { id: number; name: string; stable: string } type Subscriber = (messages: Array>) => void type Exposure = { cut: string; rows: Array } diff --git a/packages/electric-db-collection/tests/electric-sdk-delivery.property.test.ts b/packages/electric-db-collection/tests/electric-sdk-delivery.property.test.ts index 6ad1de7ff3..221525913f 100644 --- a/packages/electric-db-collection/tests/electric-sdk-delivery.property.test.ts +++ b/packages/electric-db-collection/tests/electric-sdk-delivery.property.test.ts @@ -7,6 +7,20 @@ import { oraclePropertyOptions } from '../../db/tests/oracle-config' import { atCheckpoint, withElectricCleanup } from './electric-oracle-lifecycle' import type { Message } from '@electric-sql/client' +/** + * # Does the installed Electric SDK deliver the adapter's assumed protocol? + * + * The main adapter oracle controls callbacks below the SDK. This driver moves + * the boundary outward: a finite HTTP provider sends real Electric responses, + * and the installed ShapeStream owns framing, pause, snapshot, abort, and + * silent-move behavior. The adapter must reconstruct the same overlapping + * source relation and DNF visibility as the independent model. + * + * Response gates expose ordering without replacing the SDK. Dropping response + * rows or silent reactivation deliberately breaks the driver and proves its + * boundary assertions are live. + */ + type Item = { id: number; name: string } function deferred() { @@ -19,8 +33,7 @@ function deferred() { type Request = { url: URL; respond: (response: Response) => void } -// The real SDK owns message framing and pauses. The finite HTTP provider only -// holds responses, and observes each fetch's own cancellation signal. +// The finite provider only holds responses and observes each fetch's signal. function controlledHttp() { const queued: Array = [] const waiting: Array<{ diff --git a/packages/electric-db-collection/tests/pg-serializer.property.test.ts b/packages/electric-db-collection/tests/pg-serializer.property.test.ts index 5c16a58bef..e4509391c3 100644 --- a/packages/electric-db-collection/tests/pg-serializer.property.test.ts +++ b/packages/electric-db-collection/tests/pg-serializer.property.test.ts @@ -3,15 +3,17 @@ import { fc, test as fcTest } from '@fast-check/vitest' import { serialize } from '../src/pg-serializer' /** - * Property-based tests for pg-serializer + * # Does PostgreSQL text serialization preserve the supported value domain? * - * Key properties: - * 1. Strings pass through unchanged - * 2. Finite numbers preserve their whole numeric value without trailing text - * 3. Booleans serialize to 'true'/'false' - * 4. null and undefined both serialize to empty string - * 5. Dates produce valid ISO strings - * 6. Arrays preserve every element, its type, and its position after decoding + * Scalars have direct laws: strings pass through, finite numbers round-trip + * through strict numeric parsing, booleans use PostgreSQL tokens, nullish values + * become empty text, and Dates produce valid ISO strings. Flat arrays preserve + * every element, type, position, quote, slash, comma, brace, and null marker. + * + * The decoder below is an independent parser for this finite output dialect, + * not PostgreSQL or a copy of the serializer. Generated values compare decoded + * output with the input. Corrupt-output controls and shrink/replay checks prove + * the parser and properties reject omissions and malformed tokens. */ type FiniteArrayValue = string | number | boolean | null diff --git a/packages/offline-transactions/tests/KeyScheduler.property.test.ts b/packages/offline-transactions/tests/KeyScheduler.property.test.ts index 43831d0cb7..89c2ef8706 100644 --- a/packages/offline-transactions/tests/KeyScheduler.property.test.ts +++ b/packages/offline-transactions/tests/KeyScheduler.property.test.ts @@ -1,6 +1,7 @@ import { fc } from '@fast-check/vitest' import { afterEach, describe, expect, it, vi } from 'vitest' import { KeyScheduler } from '../src/executor/KeyScheduler' +import { readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' type Command = @@ -50,36 +51,36 @@ type Snapshot = { } const BASE_TIME = Date.parse(`2026-01-01T00:00:00.000Z`) -const SEED = Number(process.env.TANSTACK_DB_OFFLINE_ORACLE_SEED ?? 1815) -const RUNS = Number(process.env.TANSTACK_DB_OFFLINE_ORACLE_RUNS ?? 150) -const PATH = process.env.TANSTACK_DB_OFFLINE_ORACLE_PATH - -/* -Test card: -- Law and source: KeyScheduler is globally serial in creation order. Equal - creation times retain scheduling order, as owned by KeyScheduler.test.ts's - "processes transactions with identical createdAt in scheduling order" test. -- Domain and legal histories: the event table below, including retry deadline - orderings, payload replacement, clear during either state, and reuse. -- Reference: a declarative ledger, clock, stable creation sequence, and at most - one active transaction. -- Production path and checkpoint: KeyScheduler's executor-facing methods; - compare after every legal event returns. -- Observed result: returned identity/payload, ordered pending records, pending - and running counts, and current eligibility. -- Known omissions: persistence, Promise settlement, timers outside the modeled - clock, and leadership are owned by the retained integration oracles. -- Reach/fault/replay: fixed transition/deadline witnesses, four path-specific - fault controls, and TANSTACK_DB_OFFLINE_ORACLE_{SEED,PATH,RUNS}. - -Legal event table: -- schedule/getNext may occur while idle or active -- start requires the FIFO head to be ready and no active transaction -- complete/fail require one active transaction -- retry update immediately follows fail, as TransactionExecutor invokes it -- bulk update, clock advance, and clear are legal while otherwise reachable -- clear retires active and pending work, after which the scheduler is reusable -*/ +const { + runs: RUNS, + seed: SEED, + path: PATH, +} = readOfflineOracleConfig({ + prefix: `TANSTACK_DB_OFFLINE_ORACLE`, + defaultRuns: 150, + defaultSeed: 1815, +}) + +/** + * # Which offline transaction may run next? + * + * The scheduler is globally serial in creation order. Equal creation times keep + * scheduling order. A delayed FIFO head blocks younger work. At most one entry + * is active. Failure makes that entry retryable; retry updates its deadline and + * payload without changing its place. Clear retires active and pending work and + * leaves the scheduler reusable. + * + * A declarative ledger, fake clock, and stable sequence form the model. Legal + * commands are schedule, inspect, start, complete, fail, retry, bulk update, + * advance time, and clear. The driver calls only executor-facing scheduler + * methods, then compares returned identity and payload, ordered pending records, + * counts, active state, and eligibility after every command. + * + * Fixed histories cover every transition and deadline relation. Generated + * histories add shrinking and replay; four injected faults calibrate the path. + * Persistence, promise settlement, leadership, and real timers have separate + * owners. + */ type CommandToken = { selector: number diff --git a/packages/offline-transactions/tests/fifo-retry.property.test.ts b/packages/offline-transactions/tests/fifo-retry.property.test.ts index cd639caff1..9df27f3122 100644 --- a/packages/offline-transactions/tests/fifo-retry.property.test.ts +++ b/packages/offline-transactions/tests/fifo-retry.property.test.ts @@ -4,77 +4,147 @@ import { KeyScheduler } from '../src/executor/KeyScheduler' import { TransactionExecutor } from '../src/executor/TransactionExecutor' import { OutboxManager } from '../src/outbox/OutboxManager' import { FakeStorageAdapter } from './harness' +import { readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' -it(`wakes at the FIFO head deadline rather than polling ready tails`, async () => { - await fc.assert( - fc.asyncProperty( - fc.integer({ min: 2, max: 5 }), - fc.integer({ min: 2, max: 10000 }), - async (count, delay) => { - vi.useFakeTimers() - const timers = vi.spyOn(globalThis, `setTimeout`) - const scheduler = new KeyScheduler() - const outbox = new OutboxManager(new FakeStorageAdapter(), {}) - const calls: Array = [] - const executor = new TransactionExecutor( - scheduler, - outbox, - { - collections: {}, - mutationFns: { - persist: ({ transaction }) => { - calls.push(transaction.id) - return Promise.resolve() +/** + * # Why does a ready transaction wait? + * + * The scheduler executes transactions in first-in, first-out order. A later + * transaction must not pass the first transaction when the first transaction + * has a retry delay. + * + * The scheduler must set one timer for the first transaction. It must execute + * nothing before that timer expires. At the deadline, it must execute all + * transactions in queue order and remove them from the outbox. + * + * fast-check varies the queue length and the retry delay. `expectedStateAt` + * models time without using the scheduler, timers, or the outbox. + */ + +type ExpectedState = { + calls: Array + outboxCount: number +} + +const { + runs: replayRuns, + seed: replaySeed, + path: replayPath, +} = readOfflineOracleConfig({ + prefix: `OFFLINE_ORACLE`, + defaultRuns: 30, +}) + +function expectedStateAt( + elapsed: number, + delay: number, + transactionIds: ReadonlyArray, +): ExpectedState { + const deadlinePassed = elapsed >= delay + return { + calls: deadlinePassed ? [...transactionIds] : [], + outboxCount: deadlinePassed ? 0 : transactionIds.length, + } +} + +it.each([20260916, undefined])( + `wakes at the FIFO head deadline rather than polling ready tails (seed %s)`, + async (fixedSeed) => { + const seed = fixedSeed ?? replaySeed + await fc.assert( + fc.asyncProperty( + fc.integer({ min: 2, max: 5 }), + fc.integer({ min: 2, max: 10000 }), + async (count, delay) => { + vi.useFakeTimers() + const timers = vi.spyOn(globalThis, `setTimeout`) + const scheduler = new KeyScheduler() + const outbox = new OutboxManager(new FakeStorageAdapter(), {}) + const calls: Array = [] + + // Record each transaction when the production executor runs it. + const executor = new TransactionExecutor( + scheduler, + outbox, + { + collections: {}, + mutationFns: { + persist: ({ transaction }) => { + calls.push(transaction.id) + return Promise.resolve() + }, }, }, - }, - { - isOfflineEnabled: true, - resolveTransaction: () => {}, - rejectTransaction: () => {}, - registerRestorationTransaction: () => {}, - isOnline: () => true, - }, - ) - const transactions: Array = Array.from( - { length: count }, - (_unused, index) => ({ - id: `tx-${index}`, - mutationFnName: `persist`, - mutations: [], - keys: [], - idempotencyKey: `key-${index}`, - createdAt: new Date(index), - nextAttemptAt: Date.now() + (index === 0 ? delay : 0), - retryCount: 0, - version: 1, - }), - ) - try { - for (const transaction of transactions) { - await outbox.add(transaction) - scheduler.schedule(transaction) - } - await executor.executeAll() - expect(calls).toEqual([]) - expect(timers).toHaveBeenCalledTimes(1) - expect(timers.mock.calls[0]![1]).toBe(delay) - await vi.advanceTimersByTimeAsync(delay - 1) - expect(calls).toEqual([]) - expect(timers).toHaveBeenCalledTimes(1) - await vi.advanceTimersByTimeAsync(1) - expect(calls).toEqual( - transactions.map((transaction) => transaction.id), + { + isOfflineEnabled: true, + resolveTransaction: () => {}, + rejectTransaction: () => {}, + registerRestorationTransaction: () => {}, + isOnline: () => true, + }, ) - expect(await outbox.count()).toBe(0) - } finally { - executor.clear() - timers.mockRestore() - vi.useRealTimers() - } + const transactions: Array = Array.from( + { length: count }, + (_unused, index) => ({ + id: `tx-${index}`, + mutationFnName: `persist`, + mutations: [], + keys: [], + idempotencyKey: `key-${index}`, + createdAt: new Date(index), + nextAttemptAt: Date.now() + (index === 0 ? delay : 0), + retryCount: 0, + version: 1, + }), + ) + const transactionIds = transactions.map( + (transaction) => transaction.id, + ) + + try { + for (const transaction of transactions) { + await outbox.add(transaction) + scheduler.schedule(transaction) + } + await executor.executeAll() + + // The ready tail must wait for the delayed head. One timer must wake + // the executor at the head deadline. The executor must not poll. + const beforeDeadline = expectedStateAt(0, delay, transactionIds) + expect(calls).toEqual(beforeDeadline.calls) + expect(await outbox.count()).toBe(beforeDeadline.outboxCount) + expect(timers).toHaveBeenCalledTimes(1) + expect(timers.mock.calls[0]![1]).toBe(delay) + + await vi.advanceTimersByTimeAsync(delay - 1) + const nearDeadline = expectedStateAt( + delay - 1, + delay, + transactionIds, + ) + expect(calls).toEqual(nearDeadline.calls) + expect(await outbox.count()).toBe(nearDeadline.outboxCount) + expect(timers).toHaveBeenCalledTimes(1) + + await vi.advanceTimersByTimeAsync(1) + const atDeadline = expectedStateAt(delay, delay, transactionIds) + expect(calls).toEqual(atDeadline.calls) + expect(await outbox.count()).toBe(atDeadline.outboxCount) + } finally { + executor.clear() + timers.mockRestore() + vi.useRealTimers() + } + }, + ), + { + numRuns: replayRuns, + ...(seed === undefined ? {} : { seed }), + ...(fixedSeed === undefined && replayPath !== undefined + ? { path: replayPath } + : {}), }, - ), - { seed: 20260916, numRuns: 30 }, - ) -}) + ) + }, +) diff --git a/packages/offline-transactions/tests/leadership-replay.property.test.ts b/packages/offline-transactions/tests/leadership-replay.property.test.ts index 6168e59c15..a762c20ad3 100644 --- a/packages/offline-transactions/tests/leadership-replay.property.test.ts +++ b/packages/offline-transactions/tests/leadership-replay.property.test.ts @@ -10,6 +10,22 @@ import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' import type { OfflineTransaction } from '../src/types' +/** + * # May leadership replay an offline transaction more than once? + * + * Only the current leader may read and schedule the outbox. Losing leadership + * or disposing fences startup, provider work, acknowledgement, retry hooks, and + * stale reads. Regaining leadership may replay durable pending rows, but an ID + * already pending, running, completed, or permanently rejected in the current + * boundary must not execute twice. + * + * A fake durable outbox, explicit leadership callbacks, and gated provider and + * delete acknowledgements form the history grammar. The real OfflineExecutor, + * TransactionExecutor, scheduler, and transaction path run unchanged. Checks + * cover mutation calls and idempotency keys, outbox contents, scheduler counts, + * leadership ownership, retry records, restoration, disposal, and cleanup. + */ + function gate() { let resolve!: () => void const promise = new Promise((done) => { diff --git a/packages/offline-transactions/tests/oracle-config.ts b/packages/offline-transactions/tests/oracle-config.ts new file mode 100644 index 0000000000..8384b6da64 --- /dev/null +++ b/packages/offline-transactions/tests/oracle-config.ts @@ -0,0 +1,44 @@ +type OracleEnvironment = Record + +type OfflineOracleConfigOptions = { + prefix: `OFFLINE_ORACLE` | `TANSTACK_DB_OFFLINE_ORACLE` + defaultRuns: number + defaultSeed?: number + environment?: OracleEnvironment +} + +export function readOfflineOracleConfig({ + prefix, + defaultRuns, + defaultSeed, + environment = process.env, +}: OfflineOracleConfigOptions): { + runs: number + seed: number | undefined + path: string | undefined +} { + const runsText = environment[`${prefix}_RUNS`] ?? String(defaultRuns) + const runs = Number(runsText) + if (runsText.trim() === `` || !Number.isSafeInteger(runs) || runs < 1) { + throw new Error(`Invalid ${prefix}_RUNS`) + } + + const seedText = environment[`${prefix}_SEED`] + const seed = seedText === undefined ? defaultSeed : Number(seedText) + if ( + seedText !== undefined && + (seedText.trim() === `` || !Number.isSafeInteger(seed)) + ) { + throw new Error(`Invalid ${prefix}_SEED`) + } + + const path = environment[`${prefix}_PATH`] + if ( + path !== undefined && + (seed === undefined || !/^\d+(?::\d+)*$/.test(path)) + ) { + throw new Error(`${prefix}_PATH requires a seed and numeric shrink path`) + } + + return { runs, seed, path } +} diff --git a/packages/offline-transactions/tests/oracle-lifecycle.test.ts b/packages/offline-transactions/tests/oracle-lifecycle.test.ts index 43ab82de31..d87cd8e50b 100644 --- a/packages/offline-transactions/tests/oracle-lifecycle.test.ts +++ b/packages/offline-transactions/tests/oracle-lifecycle.test.ts @@ -1,6 +1,12 @@ import { expect, it, vi } from 'vitest' import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' +/** + * Calibration for offline-oracle ownership. These cases hold or fail cleanup + * stages to prove deadlines do not claim cancellation, later releases still + * run, and secondary cleanup errors cannot hide the scenario's primary error. + */ + it(`retains a cleanup error as secondary and still releases later resources`, async () => { const secondary = new Error(`cleanup failed`) const warning = vi.spyOn(console, `warn`).mockImplementation(() => {}) diff --git a/packages/offline-transactions/tests/oracle-lifecycle.ts b/packages/offline-transactions/tests/oracle-lifecycle.ts index d296b7adc0..340a058e17 100644 --- a/packages/offline-transactions/tests/oracle-lifecycle.ts +++ b/packages/offline-transactions/tests/oracle-lifecycle.ts @@ -1,4 +1,12 @@ -// Bound an observation, not the underlying work: no cancellation is implied. +/** + * Bound offline-oracle observation and exhaust test-owned cleanup. + * + * A checkpoint timeout says that an expected event was not observed; it does + * not cancel the underlying operation. Cleanup therefore tries every action. + * If the scenario already has a primary failure, cleanup failures are reported + * without replacing it. Otherwise they form one AggregateError after all + * resources have had a release attempt. + */ export async function atOracleCheckpoint( promise: Promise, label: string, diff --git a/packages/offline-transactions/tests/transaction-serializer.property.test.ts b/packages/offline-transactions/tests/transaction-serializer.property.test.ts index 3273001aec..d328ea828c 100644 --- a/packages/offline-transactions/tests/transaction-serializer.property.test.ts +++ b/packages/offline-transactions/tests/transaction-serializer.property.test.ts @@ -3,8 +3,27 @@ import fc from 'fast-check' import { expect, it, vi } from 'vitest' import { TransactionSerializer } from '../src/outbox/TransactionSerializer' import { cleanupOfflineOracle } from './oracle-lifecycle' +import { readOfflineOracleConfig } from './oracle-config' import type { OfflineTransaction } from '../src/types' +/** + * # Does an offline transaction survive durable serialization exactly? + * + * The wire format supports JSON trees plus Date values. User keys that resemble + * codec markers remain data. Insert, update, and delete mutations retain their + * original, modified, changes, collection registry, timestamps, and order across + * restart. Unknown encodings fail rather than creating a zombie transaction. + * + * The generator builds semantic runtime and wire pairs from leaves; it never + * walks a production value with a copy of the serializer. A restarted set of + * Collections with different object identities decodes the record and replays + * it. Encoder and decoder fault modes prove Date/string confusion, wrong + * registry, omitted changes, and unknown versions are observed. + * + * Cycles, undefined, non-finite numbers, and arbitrary native objects are + * outside this declared durable format. + */ + type Value = | null | boolean @@ -29,10 +48,7 @@ type Fault = | `omit-changes` | `unknown-encoding` -// Construct both representations from semantic leaves, not by walking a -// production value with a copy of serializeValue. This is JSON trees + Date, -// not arbitrary JS: cycles, undefined, non-finite numbers and other native -// objects are outside this format. User keys, including codec markers, are data. +// Construct both representations from semantic leaves, not production output. const datePair = (time: number): Pair => ({ runtime: new Date(time), wire: { __type: `Date`, value: new Date(time).toISOString() }, @@ -378,22 +394,14 @@ const pinned: Array = [ ] // This package's test root is separate from core's named replay portfolio. // Keep a local replay entry point rather than importing files outside rootDir. -const numRuns = Number(process.env.OFFLINE_ORACLE_RUNS ?? 100) -if (!Number.isSafeInteger(numRuns) || numRuns < 1) - throw new Error(`Invalid OFFLINE_ORACLE_RUNS`) -const seedText = process.env.OFFLINE_ORACLE_SEED -const replaySeed = seedText === undefined ? undefined : Number(seedText) -if ( - seedText !== undefined && - (seedText.trim() === `` || !Number.isSafeInteger(replaySeed)) -) - throw new Error(`Invalid OFFLINE_ORACLE_SEED`) -const replayPath = process.env.OFFLINE_ORACLE_PATH -if ( - replayPath !== undefined && - (replaySeed === undefined || !/^\d+(?::\d+)*$/.test(replayPath)) -) - throw new Error(`OFFLINE_ORACLE_PATH requires a seed and numeric shrink path`) +const { + runs: numRuns, + seed: replaySeed, + path: replayPath, +} = readOfflineOracleConfig({ + prefix: `OFFLINE_ORACLE`, + defaultRuns: 100, +}) it.each([20260914, undefined])( `preserves mutation wire meaning across restart (seed %s)`, async (seed) => { diff --git a/packages/offline-transactions/tests/transaction-settlement.property.test.ts b/packages/offline-transactions/tests/transaction-settlement.property.test.ts index 3e237f94a0..539d77741d 100644 --- a/packages/offline-transactions/tests/transaction-settlement.property.test.ts +++ b/packages/offline-transactions/tests/transaction-settlement.property.test.ts @@ -7,6 +7,22 @@ import { FakeStorageAdapter, createTestOfflineEnvironment } from './harness' import { atOracleCheckpoint, cleanupOfflineOracle } from './oracle-lifecycle' import type { TestItem } from './harness' +/** + * # Does each offline transaction settle only from its own durable history? + * + * Transactions enter a global FIFO, but commit and wait promises belong to one + * transaction ID. Success applies its server rows and fulfills both promises. + * Permanent failure rejects those promises with the same error and rolls back + * only its local overlay. A peer's provider, retry-record, or durable-admission + * failure cannot settle or erase independently admitted work. + * + * Generated histories vary shared keys, transaction width, and success/failure + * sequences. Gates expose each provider boundary. The driver compares exact + * calls, IDs, promise outcomes, durable outbox state, server state, local rows, + * pending counts, and later progress after every settlement. The simple expected + * Maps do not copy executor or scheduler internals. + */ + function gate() { let resolve!: () => void const promise = new Promise((done) => { diff --git a/packages/powersync-db-collection/tests/correctness-oracle.test.ts b/packages/powersync-db-collection/tests/correctness-oracle.test.ts index b081ec3593..40417cc669 100644 --- a/packages/powersync-db-collection/tests/correctness-oracle.test.ts +++ b/packages/powersync-db-collection/tests/correctness-oracle.test.ts @@ -1,17 +1,22 @@ /** - * Laws: collection updates conserve independent SQLite fields; collection rows - * expose the declared PowerSync view; comparison observes schema output. + * # Does a PowerSync Collection preserve the database's independent truth? * - * Reference: disjoint logical changes are composed from their changed fields, - * PowerSync's declared SQLite view defines the readable keys, and the supplied - * Standard Schema defines collection output values. + * Collection updates change only their authored fields. A newer disjoint + * SQLite field must survive. Persistence waits for the final effective update, + * including metadata-only and falsey changes. Public rows expose exactly the + * declared PowerSync view, and equality compares transformed schema output + * rather than raw SQLite rows. * - * Production path: a real node database, real collection sync, the registered - * watcher callback, PowerSync CRUD rows, and the collection comparator. - * The held watcher is the only timing control; assertions run after explicitly - * releasing it. Each test proves path reach before comparing the full promised - * observation. The comparison-sensitivity test rejects the historical stale - * full-row patch and undeclared public key without invoking production. + * The reference laws are small: compose disjoint patches by changed field, + * derive readable keys from the declared view, and derive public values from + * the supplied Standard Schema. The production path uses a real native SQLite + * database, PowerSync CRUD rows, Collection sync, watcher callbacks, and the + * adapter comparator. A held watcher is the only timing control. + * + * Every test proves the native path was reached before it compares full rows, + * patches, metadata, logging, or cleanup. The suite skips when the native test + * database implementation is unavailable; portable declarations alone are not + * evidence of PowerSync execution. */ import { randomUUID } from 'node:crypto' import { tmpdir } from 'node:os' diff --git a/packages/query-db-collection/package.json b/packages/query-db-collection/package.json index e451088280..350717aec3 100644 --- a/packages/query-db-collection/package.json +++ b/packages/query-db-collection/package.json @@ -21,7 +21,7 @@ "dev": "vite build --watch", "lint": "eslint . --fix", "test": "vitest run", - "test:oracles": "vitest run tests/includes-work-counter-oracle.test.ts tests/load-subset-lifecycle-oracle.test.ts tests/ownership-lifecycle.oracle.test.ts tests/cursor-pagination.oracle.test.ts tests/cursor-pagination.cache-oracle.test.ts tests/cursor-pagination.publication-oracle.test.ts tests/cursor-pagination.boundary-oracle.test.ts tests/cursor-pagination.integration.test.ts", + "test:oracles": "vitest run --coverage.enabled=false tests/includes-work-counter-oracle.test.ts tests/load-subset-lifecycle-oracle.test.ts tests/ownership-lifecycle.oracle.test.ts tests/cursor-pagination.oracle.test.ts tests/cursor-pagination.cache-oracle.test.ts tests/cursor-pagination.publication-oracle.test.ts tests/cursor-pagination.boundary-oracle.test.ts tests/cursor-pagination.integration.test.ts", "test:e2e": "vitest run --config vitest.e2e.config.ts" }, "type": "module", diff --git a/packages/query-db-collection/src/cursor-pagination.ts b/packages/query-db-collection/src/cursor-pagination.ts index 7f9fbfc78a..412b381b00 100644 --- a/packages/query-db-collection/src/cursor-pagination.ts +++ b/packages/query-db-collection/src/cursor-pagination.ts @@ -54,7 +54,7 @@ export function createCursorPager({ staleTime, gcTime, }: CursorPagerOptions): CursorPager { - let generation = 0 + let cursorSequenceGeneration = 0 let tail = Promise.resolve() const sequences = new WeakMap>() type Pages = InfiniteData, string | undefined> @@ -137,10 +137,10 @@ export function createCursorPager({ return { read({ offset = 0, limit }, signal) { - const requestedGeneration = generation + const requestedCursorSequenceGeneration = cursorSequenceGeneration const checkCurrent = () => { if (signal?.aborted) throw signal.reason ?? abortError() - if (requestedGeneration !== generation) { + if (requestedCursorSequenceGeneration !== cursorSequenceGeneration) { throw new DOMException(`Cursor sequence was reset`, `AbortError`) } } @@ -234,7 +234,7 @@ export function createCursorPager({ return abortable(result, signal) }, reset() { - generation++ + cursorSequenceGeneration++ queryClient.removeQueries({ queryKey, exact: true }) }, } diff --git a/packages/query-db-collection/src/query.ts b/packages/query-db-collection/src/query.ts index 7867b6cfe6..14b81e0d20 100644 --- a/packages/query-db-collection/src/query.ts +++ b/packages/query-db-collection/src/query.ts @@ -812,7 +812,7 @@ export function queryCollectionOptions( >(), } - // Query-cache ownership is scoped to this sync generation and keyed by the + // Query-cache ownership is scoped to this sync run and keyed by the // actual Query object. Weak membership survives subset unload without // retaining entries after Query Core garbage-collects them. let ownedCacheQueries = new WeakSet() @@ -874,10 +874,10 @@ export function queryCollectionOptions( nextQueryCollectionFetchStart, ), ) - const generation = + const postWriteRefetchGeneration = (postWriteRefetchGenerations.get(hashedQueryKey) ?? 0) + 1 - postWriteRefetchGenerations.set(hashedQueryKey, generation) - return generation + postWriteRefetchGenerations.set(hashedQueryKey, postWriteRefetchGeneration) + return postWriteRefetchGeneration } const isObserverEnabled = ( @@ -2523,7 +2523,7 @@ export function queryCollectionOptions( const refetchTrackedQuery = async ( query: AnyQuery, logicalHashes: Set, - generations: Map, + postWriteRefetchGenerationByHash: Map, ): Promise => { try { await query.fetch(undefined, { cancelRefetch: false }) @@ -2535,7 +2535,7 @@ export function queryCollectionOptions( const stillNeedsAuthority = [...logicalHashes].some( (hashedQueryKey) => postWriteRefetchGenerations.get(hashedQueryKey) === - generations.get(hashedQueryKey) && + postWriteRefetchGenerationByHash.get(hashedQueryKey) && !hasPostWriteAuthority(hashedQueryKey, query), ) if ( @@ -2563,7 +2563,10 @@ export function queryCollectionOptions( continue } - const generation = requirePostWriteAuthority(hashedQueryKey, query) + const postWriteRefetchGeneration = requirePostWriteAuthority( + hashedQueryKey, + query, + ) revalidatingQueries.add(query) const ownedAtSchedule = ownedCacheQueries.has(query) query.invalidate() @@ -2579,8 +2582,14 @@ export function queryCollectionOptions( query.invalidate() if (!query.isDisabled()) { const logicalHashes = new Set([hashedQueryKey]) - const generations = new Map([[hashedQueryKey, generation]]) - void refetchTrackedQuery(query, logicalHashes, generations) + const postWriteRefetchGenerationByHash = new Map([ + [hashedQueryKey, postWriteRefetchGeneration], + ]) + void refetchTrackedQuery( + query, + logicalHashes, + postWriteRefetchGenerationByHash, + ) } } else { queryClient.getQueryCache().remove(query) @@ -2605,7 +2614,8 @@ export function queryCollectionOptions( const result = await observer.refetch().catch(() => undefined) if ( result?.isError || - postWriteRefetchGenerations.get(hashedQueryKey) !== generation + postWriteRefetchGenerations.get(hashedQueryKey) !== + postWriteRefetchGeneration ) { return } @@ -2647,16 +2657,20 @@ export function queryCollectionOptions( if (ownObservers > 0) continue const logicalHashes = getLogicalHashes(query) - const generations = new Map() + const postWriteRefetchGenerationByHash = new Map() for (const hashedQueryKey of logicalHashes) { - generations.set( + postWriteRefetchGenerationByHash.set( hashedQueryKey, requirePostWriteAuthority(hashedQueryKey, query), ) } query.invalidate() if (!query.isDisabled()) { - void refetchTrackedQuery(query, logicalHashes, generations) + void refetchTrackedQuery( + query, + logicalHashes, + postWriteRefetchGenerationByHash, + ) } continue } diff --git a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts index d1b77a76ee..e414934e3e 100644 --- a/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts @@ -7,6 +7,20 @@ import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions } from '../../db/tests/oracle-config.js' import { createCursorPager, queryCollectionOptions } from '../src/index.js' +/** + * # Do browser-facing readers release the right cursor work? + * + * A nested QueryCollection reader may cancel, be replaced, retry, or lose its + * last owner while cursor pages are pending. Reader abort releases its queue; + * it does not cancel a transport still owned by a peer. A replacement sequence + * may satisfy the nested collection only after its own rows are authoritative. + * + * These jsdom histories cross real QueryClient cancellation and retry defaults, + * QueryCollection preload, shared transports, manual writes, and cleanup. They + * observe fetch status, errors, rows, calls, abort signals, and later recovery. + * Native browser page ownership remains in the OPFS oracle. + */ + const tick = () => new Promise((resolve) => setTimeout(resolve, 0)) const makeClient = () => new QueryClient({ diff --git a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts index e9e9b26826..7936fcfe05 100644 --- a/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts @@ -9,6 +9,20 @@ import { expectedRows } from './cursor-pagination/model.js' import type { CursorPager } from '../src/index.js' import type { Row } from './cursor-pagination/model.js' +/** + * # When may cached cursor pages answer a later read? + * + * Cached pages belong to one query key and one immutable backend sequence. + * Fresh reads may reuse them. Invalidation, expiry, forced refresh, garbage + * collection, or protocol failure starts a new sequence before its rows become + * authoritative. Cancellation stops the reader without poisoning later work. + * + * A fake clock and real QueryClient drive those boundaries. The reference keeps + * only the permitted source snapshot and freshness deadline; it does not model + * Query pages, retryers, observers, or garbage collection. Every returned + * window still compares with the full-relation value model. + */ + const scope = { group: undefined, descending: false } const makeRows = (count: number, version = 0): Array => Array.from({ length: count }, (_, id) => ({ @@ -28,9 +42,6 @@ async function checkWindow( ).toEqual(expectedRows(source, scope, { offset: 0, limit: width })) } -/** Row truth is still a full relation. A fake clock and real QueryClient drive - * freshness; the model keeps only the permitted source snapshot and a deadline, - * not Query's page cache, retryer, observer or garbage-collection state. */ describe(`cursor cache lifecycle`, () => { it.each([ [`global`, `maxPages`], diff --git a/packages/query-db-collection/tests/cursor-pagination.integration.test.ts b/packages/query-db-collection/tests/cursor-pagination.integration.test.ts index 013d5c44e0..e18d63923b 100644 --- a/packages/query-db-collection/tests/cursor-pagination.integration.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.integration.test.ts @@ -14,7 +14,14 @@ import { createBackend } from './cursor-pagination/backend.js' import { expectedWindow } from './cursor-pagination/model.js' import type { Row, Scope } from './cursor-pagination/model.js' -/** Real QueryClient -> QueryCollection -> graph -> shared window controller. +/** + * # Does opaque pagination refine a real live-query window? + * + * This is the end-to-end path: QueryClient -> QueryCollection -> query graph -> + * shared window controller. The visible window and `hasNextPage` must match the + * independent full-relation model through growth, offset, ties, filtering, + * cancellation, replacement, and cache reuse. + * * The fixture endpoint supports prefix and rank-equality tie requests. Other * predicates and cursor expressions reject rather than silently dropping IR. * Each tie filter has its own opaque backend sequence, just like the prefix. diff --git a/packages/query-db-collection/tests/cursor-pagination.no-peek.integration.test.ts b/packages/query-db-collection/tests/cursor-pagination.no-peek.integration.test.ts index 4126a5a0f3..ed16a26b38 100644 --- a/packages/query-db-collection/tests/cursor-pagination.no-peek.integration.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.no-peek.integration.test.ts @@ -7,7 +7,7 @@ import { describe, expect, it } from 'vitest' import { createDeferred } from '../../db/src/deferred.js' import { createLiveQueryWindowController } from '../../db/src/live-query-window-controller.js' import { expectedWindow } from './cursor-pagination/model.js' -import { createNoPeekSession } from './cursor-pagination/no-peek.js' +import { createNoPeekDemandModel } from './cursor-pagination/no-peek.js' import { createFactTransport } from './cursor-pagination/no-peek-transport.js' import type { Row } from './cursor-pagination/model.js' import type { PrefixPublication } from './cursor-pagination/no-peek.js' @@ -128,23 +128,21 @@ describe(`no-peek bridge at real graph publication`, () => { async (count) => { const candidate = createGraphFixture(count, 3) const baseline = createGraphFixture(count, 3) - const session = createNoPeekSession(candidate.acquire) + const model = createNoPeekDemandModel(candidate.acquire) const controller = createLiveQueryWindowController(baseline.live, { pageSize: 3, }) const unsubscribe = controller.subscribe(() => {}) try { - session.request(`a`, 3) - await session.refresh() + model.retainDemand(`a`, 3) + await model.refresh() await controller.preload() - expect(session.get(`a`)).toEqual( - expectedWindow(candidate.rows, scope, 3), - ) + expect(model.get(`a`)).toEqual(expectedWindow(candidate.rows, scope, 3)) expect(controller.getSnapshot().hasNextPage).toBe( - session.get(`a`)?.hasNextPage, + model.get(`a`)?.hasNextPage, ) expect(controller.getSnapshot().data.map(({ id }) => id)).toEqual( - session.get(`a`)?.rows.map(({ id }) => id), + model.get(`a`)?.rows.map(({ id }) => id), ) expect(candidate.transport.requests[0]).toBe(3) expect(baseline.transport.requests[0]).toBe(4) @@ -161,11 +159,11 @@ describe(`no-peek bridge at real graph publication`, () => { it(`holds continuation behind actual source and graph publication`, async () => { const fixture = createGraphFixture(9, 3, true) - const session = createNoPeekSession(fixture.acquire) - session.request(`a`, 3) + const model = createNoPeekDemandModel(fixture.acquire) + model.retainDemand(`a`, 3) let notifications = 0 - session.subscribe(() => notifications++) - const pending = session.refresh() + model.subscribe(() => notifications++) + const pending = model.refresh() const observed = pending.then( () => undefined, (error: unknown) => error, @@ -174,11 +172,11 @@ describe(`no-peek bridge at real graph publication`, () => { await fixture.received.promise expect(fixture.fact()?.fact?.hasMore).toBe(true) expect(fixture.live.toArray).toEqual([]) - expect(session.get(`a`)).toBeUndefined() + expect(model.get(`a`)).toBeUndefined() expect(notifications).toBe(0) fixture.release.resolve() expect(await observed).toBeUndefined() - expect(session.get(`a`)).toEqual(expectedWindow(fixture.rows, scope, 3)) + expect(model.get(`a`)).toEqual(expectedWindow(fixture.rows, scope, 3)) expect(notifications).toBe(1) } finally { fixture.release.resolve() @@ -189,15 +187,15 @@ describe(`no-peek bridge at real graph publication`, () => { it(`falls back for a local filter even though the order is unchanged`, async () => { const fixture = createGraphFixture(9, 3, false, true) - const session = createNoPeekSession(fixture.acquire, false) - session.request(`a`, 3) + const model = createNoPeekDemandModel(fixture.acquire, false) + model.retainDemand(`a`, 3) try { const rawPrefix = await createFactTransport(fixture.rows, 3, scope).read( 3, ) expect(rawPrefix.fact?.hasMore).toBe(true) - await session.refresh() - expect(session.get(`a`)).toEqual( + await model.refresh() + expect(model.get(`a`)).toEqual( expectedWindow( fixture.rows.filter((row) => row.id < 3), scope, @@ -205,7 +203,7 @@ describe(`no-peek bridge at real graph publication`, () => { ), ) expect(fixture.windows).toEqual([4]) - expect(session.get(`a`)?.hasNextPage).toBe(false) + expect(model.get(`a`)?.hasNextPage).toBe(false) // An opaque local filter requires the existing full-source loading path. expect(fixture.transport.requests[0]).toBe(10) } finally { diff --git a/packages/query-db-collection/tests/cursor-pagination.no-peek.test.ts b/packages/query-db-collection/tests/cursor-pagination.no-peek.test.ts index 19154ca889..21f07fe1b9 100644 --- a/packages/query-db-collection/tests/cursor-pagination.no-peek.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.no-peek.test.ts @@ -3,7 +3,7 @@ import fc from 'fast-check' import { createDeferred } from '../../db/src/deferred.js' import { oraclePropertyOptions } from '../../db/tests/oracle-config.js' import { expectedWindow } from './cursor-pagination/model.js' -import { createNoPeekSession } from './cursor-pagination/no-peek.js' +import { createNoPeekDemandModel } from './cursor-pagination/no-peek.js' import { createFactTransport } from './cursor-pagination/no-peek-transport.js' import type { Row, Scope } from './cursor-pagination/model.js' @@ -25,15 +25,15 @@ describe(`experimental publication-bound no-peek pagination`, () => { it(`keeps shallow continuation after a deeper consumer reaches the end`, async () => { const rows = rowsOf(9) const transport = createTransport(rows, 3) - const session = createNoPeekSession(transport.read) - session.request(`shallow`, 3) - session.request(`deep`, 10) - await session.refresh() - expect(session.get(`shallow`)).toEqual(expectedWindow(rows, scope, 3)) - expect(session.get(`deep`)).toEqual(expectedWindow(rows, scope, 10)) - session.release(`deep`) - await session.refresh() - expect(session.get(`shallow`)).toEqual(expectedWindow(rows, scope, 3)) + const model = createNoPeekDemandModel(transport.read) + model.retainDemand(`shallow`, 3) + model.retainDemand(`deep`, 10) + await model.refresh() + expect(model.get(`shallow`)).toEqual(expectedWindow(rows, scope, 3)) + expect(model.get(`deep`)).toEqual(expectedWindow(rows, scope, 10)) + model.retireDemand(`deep`) + await model.refresh() + expect(model.get(`shallow`)).toEqual(expectedWindow(rows, scope, 3)) }) it.each([ @@ -46,7 +46,7 @@ describe(`experimental publication-bound no-peek pagination`, () => { async (fault) => { const rows = rowsOf(9) const transport = createTransport(rows, 3) - const session = createNoPeekSession(async (limit) => { + const model = createNoPeekDemandModel(async (limit) => { const packet = await transport.read(limit) if (fault === `missing`) delete packet.fact if (fault === `foreign-stamp`) packet.fact!.stamp = {} @@ -54,20 +54,20 @@ describe(`experimental publication-bound no-peek pagination`, () => { if (fault === `transformed`) packet.transparent = false return packet }) - session.request(`a`, 3) - await session.refresh() - expect(session.get(`a`)).toEqual(expectedWindow(rows, scope, 3)) + model.retainDemand(`a`, 3) + await model.refresh() + expect(model.get(`a`)).toEqual(expectedWindow(rows, scope, 3)) expect(transport.requests).toEqual([3, 4]) - session.request(`b`, 10) - await session.refresh() - session.release(`b`) - await session.refresh() + model.retainDemand(`b`, 10) + await model.refresh() + model.retireDemand(`b`) + await model.refresh() expect(transport.requests.at(-1)).toBe(4) - expect(session.get(`a`)).toEqual(expectedWindow(rows, scope, 3)) + expect(model.get(`a`)).toEqual(expectedWindow(rows, scope, 3)) }, ) - it(`agrees with full-relation truth across scopes, peers, release and repartition`, async () => { + it(`agrees with full-relation truth across scopes, peers, demand retirement and repartition`, async () => { await fc.assert( fc.asyncProperty( fc.integer({ min: 0, max: 35 }), @@ -89,28 +89,28 @@ describe(`experimental publication-bound no-peek pagination`, () => { const rows = rowsOf(count) const transport = createTransport(rows, size, selected) let metadata = true - const session = createNoPeekSession(async (limit) => { + const model = createNoPeekDemandModel(async (limit) => { const packet = await transport.read(limit) if (!metadata) delete packet.fact return packet }) const windows = new Map() - session.subscribe(() => { + model.subscribe(() => { for (const [id, n] of windows) - expect(session.get(id)).toEqual(expectedWindow(rows, selected, n)) + expect(model.get(id)).toEqual(expectedWindow(rows, selected, n)) }) for (const action of history) { metadata = action.metadata if (action.release) { - session.release(action.id) + model.retireDemand(action.id) windows.delete(action.id) } else { - session.request(action.id, action.count) + model.retainDemand(action.id, action.count) windows.set(action.id, action.count) } - await session.refresh() + await model.refresh() for (const [id, n] of windows) - expect(session.get(id)).toEqual(expectedWindow(rows, selected, n)) + expect(model.get(id)).toEqual(expectedWindow(rows, selected, n)) } }, ), @@ -123,7 +123,7 @@ describe(`experimental publication-bound no-peek pagination`, () => { const applied = createDeferred() const received = createDeferred() let held = false - const session = createNoPeekSession(async (limit) => { + const model = createNoPeekDemandModel(async (limit) => { const packet = await transport.read(limit) if (held) { received.resolve() @@ -131,38 +131,38 @@ describe(`experimental publication-bound no-peek pagination`, () => { } return packet }) - session.request(`a`, 3) - await session.refresh() - const old = session.get(`a`) + model.retainDemand(`a`, 3) + await model.refresh() + const old = model.get(`a`) held = true - session.request(`a`, 10) - const pending = session.refresh() + model.retainDemand(`a`, 10) + const pending = model.refresh() const observed = pending.then( () => undefined, (error: unknown) => error, ) await received.promise - expect(session.get(`a`)).toBe(old) - session.reset() + expect(model.get(`a`)).toBe(old) + model.reset() applied.resolve() expect(await observed).toMatchObject({ name: `AbortError` }) - expect(session.get(`a`)).toBeUndefined() + expect(model.get(`a`)).toBeUndefined() held = false - await session.refresh() - expect(session.get(`a`)).toEqual(expectedWindow(rowsOf(9), scope, 10)) + await model.refresh() + expect(model.get(`a`)).toEqual(expectedWindow(rowsOf(9), scope, 10)) }) it(`notifies when only the published continuation changes`, async () => { let transport = createTransport(rowsOf(3), 3) - const session = createNoPeekSession((limit) => transport.read(limit)) - session.request(`a`, 3) + const model = createNoPeekDemandModel((limit) => transport.read(limit)) + model.retainDemand(`a`, 3) const snapshots: Array = [] - session.subscribe(() => snapshots.push(session.get(`a`)?.hasNextPage)) - await session.refresh() + model.subscribe(() => snapshots.push(model.get(`a`)?.hasNextPage)) + await model.refresh() transport = createTransport(rowsOf(4), 3) - await session.refresh() + await model.refresh() expect(snapshots).toEqual([false, true]) - expect(session.get(`a`)?.rows).toEqual(rowsOf(3)) + expect(model.get(`a`)?.rows).toEqual(rowsOf(3)) }) it.each([`acquisition`, `fallback`] as const)( @@ -171,7 +171,7 @@ describe(`experimental publication-bound no-peek pagination`, () => { const transport = createTransport(rowsOf(9), 3) const failure = new Error(`Failed publication`) let fail = false - const session = createNoPeekSession(async (limit) => { + const model = createNoPeekDemandModel(async (limit) => { if (fail && (boundary === `acquisition` || limit === 7)) { fail = false throw failure @@ -180,55 +180,55 @@ describe(`experimental publication-bound no-peek pagination`, () => { if (boundary === `fallback` && limit >= 6) delete packet.fact return packet }) - session.request(`a`, 3) - await session.refresh() - const old = session.get(`a`) - session.request(`a`, 6) + model.retainDemand(`a`, 3) + await model.refresh() + const old = model.get(`a`) + model.retainDemand(`a`, 6) fail = true let publications = 0 - session.subscribe(() => publications++) - await expect(session.refresh()).rejects.toBe(failure) - expect(session.get(`a`)).toBe(old) + model.subscribe(() => publications++) + await expect(model.refresh()).rejects.toBe(failure) + expect(model.get(`a`)).toBe(old) expect(publications).toBe(0) - session.request(`peer`, 9) + model.retainDemand(`peer`, 9) const results = await Promise.allSettled([ - session.refresh(), - session.refresh(), + model.refresh(), + model.refresh(), ]) expect(results.map((result) => result.status)).toEqual([ `fulfilled`, `fulfilled`, ]) - expect(session.get(`a`)).toEqual(expectedWindow(rowsOf(9), scope, 6)) - expect(session.get(`peer`)).toEqual(expectedWindow(rowsOf(9), scope, 9)) + expect(model.get(`a`)).toEqual(expectedWindow(rowsOf(9), scope, 6)) + expect(model.get(`peer`)).toEqual(expectedWindow(rowsOf(9), scope, 9)) }, ) it(`restores peek when metadata is withdrawn without changing the rows`, async () => { const transport = createTransport(rowsOf(9), 3) let metadata = true - const session = createNoPeekSession(async (limit) => { + const model = createNoPeekDemandModel(async (limit) => { const packet = await transport.read(limit) if (!metadata) delete packet.fact return packet }) - session.request(`a`, 3) - await session.refresh() - const before = session.get(`a`) + model.retainDemand(`a`, 3) + await model.refresh() + const before = model.get(`a`) metadata = false - await session.refresh() + await model.refresh() expect(transport.requests).toEqual([3, 3, 4]) - expect(session.get(`a`)).toEqual(before) - await session.refresh() + expect(model.get(`a`)).toEqual(before) + await model.refresh() expect(transport.requests.at(-1)).toBe(4) }) it(`saves a backend request only when peek crosses a page boundary`, async () => { for (const size of [3, 4, 50]) { const transport = createTransport(rowsOf(10), size) - const session = createNoPeekSession(transport.read) - session.request(`a`, 3) - await session.refresh() + const model = createNoPeekDemandModel(transport.read) + model.retainDemand(`a`, 3) + await model.refresh() expect(transport.requests).toEqual([3]) expect(transport.backend.calls).toHaveLength(1) const peek = createTransport(rowsOf(10), size) diff --git a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts index 8db5a81566..1ed655934b 100644 --- a/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts @@ -7,6 +7,21 @@ import { expectedRows } from './cursor-pagination/model.js' import { createCursorPager } from './cursor-pagination/pager.js' import type { Row, Scope, Window } from './cursor-pagination/model.js' +/** + * # Can opaque pages reconstruct any requested ordered slice? + * + * The backend owns unreadable continuation tokens and one stable filtered, + * totally ordered sequence. The pager may fetch and cache pages, but `read()` + * must equal the independent full-relation model for any offset and limit. It + * must reject invalid windows, foreign tokens, missing continuation, duplicate + * rows, wrong order, and protocol cycles instead of guessing. + * + * Generated sources vary IDs, ties, groups, direction, backend page size, and + * requested windows. The model never sees tokens. The driver observes returned + * rows, fetch order, cancellation, and bounded page traversal. This separation + * prevents a pager and its oracle from sharing the same cursor arithmetic. + */ + const rowsArbitrary = fc.uniqueArray( fc.record({ id: fc.integer({ min: 0, max: 60 }), diff --git a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts index 6f42d992ba..2b489f2581 100644 --- a/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts +++ b/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts @@ -8,6 +8,20 @@ import { createBackend } from './cursor-pagination/backend.js' import { expectedRows } from './cursor-pagination/model.js' import type { Row } from './cursor-pagination/model.js' +/** + * # Which cursor sequence may become the next public result? + * + * Growth, refresh, and nested readers can overlap. A forced refresh must not + * join stale growth unless the old request is deliberately retained. A canceled + * or failed sequence may settle and clean up, but its pages cannot become the + * next public snapshot or clear the authority of a newer sequence. + * + * Real Query observers supply publication and next-use evidence. Immutable + * fixture backends make stale cursor use visible. The full-relation model still + * owns row truth; this file adds generation, cancellation, and publication + * observations rather than copying Query's cache state. + */ + const scope = { group: undefined, descending: false } const rowsFor = (count: number, version = 0): Array => Array.from({ length: count }, (_, id) => ({ id, rank: id, group: version })) @@ -18,8 +32,6 @@ const createClient = () => }, }) -// The reference remains a whole relation. These laws add publication and -// next-use observations, not a model of Query's retryer or page cache. describe(`cursor cache publication`, () => { it.each( [true, false].flatMap((cancel) => diff --git a/packages/query-db-collection/tests/cursor-pagination/model.ts b/packages/query-db-collection/tests/cursor-pagination/model.ts index 7c33c0bdfc..3c7582d644 100644 --- a/packages/query-db-collection/tests/cursor-pagination/model.ts +++ b/packages/query-db-collection/tests/cursor-pagination/model.ts @@ -3,9 +3,12 @@ export type Scope = { group: number | undefined; descending: boolean } export type Window = { offset: number; limit: number | undefined } /** - * Reference authority: an exact ordered request is a slice of the whole - * filtered relation. Backend pages, cursors, caches and promises do not enter - * this model. Numeric rank then ID is the declared total order in this domain. + * # What is an opaque cursor window? + * + * An exact ordered request is a slice of the whole filtered relation. Numeric + * rank then ID is the declared total order in this bounded domain. Backend + * pages, cursor tokens, Query caches, promises, retries, and publication do not + * enter the value model. Their separate drivers must refine this answer. */ export function expectedRows( source: ReadonlyArray, diff --git a/packages/query-db-collection/tests/cursor-pagination/no-peek.ts b/packages/query-db-collection/tests/cursor-pagination/no-peek.ts index 905b49abad..80ba2d21ab 100644 --- a/packages/query-db-collection/tests/cursor-pagination/no-peek.ts +++ b/packages/query-db-collection/tests/cursor-pagination/no-peek.ts @@ -30,24 +30,24 @@ function continuation( return undefined } -/** Test-only coordinator, not a replacement for the production window controller. +/** Test-only demand model, not a replacement for the production window controller. * Acquire must settle at public-window completion. No source-lifecycle machinery * is duplicated here; the missing production bridge remains explicit. */ -export function createNoPeekSession( +export function createNoPeekDemandModel( acquire: (limit: number) => Promise, eligible = true, ) { - const leases = new Map() + const demands = new Map() const snapshots = new Map() const listeners = new Set<() => void>() - let generation = 0 + let publicationGeneration = 0 let tail = Promise.resolve() const desired = () => Math.max( - ...[...leases.values()].map(({ count, peek }) => count + Number(peek)), + ...[...demands.values()].map(({ count, peek }) => count + Number(peek)), ) return { - request(id: string, limit: number) { + retainDemand(id: string, limit: number) { if ( !Number.isSafeInteger(limit) || limit <= 0 || @@ -55,15 +55,18 @@ export function createNoPeekSession( ) { throw new RangeError(`Expected a positive safe window`) } - leases.set(id, { count: limit, peek: leases.get(id)?.peek ?? !eligible }) + demands.set(id, { + count: limit, + peek: demands.get(id)?.peek ?? !eligible, + }) }, - release(id: string) { - leases.delete(id) + retireDemand(id: string) { + demands.delete(id) snapshots.delete(id) }, reset() { - generation++ - for (const lease of leases.values()) lease.peek = !eligible + publicationGeneration++ + for (const demand of demands.values()) demand.peek = !eligible snapshots.clear() }, subscribe(listener: () => void) { @@ -74,14 +77,14 @@ export function createNoPeekSession( return snapshots.get(id) }, refresh() { - const requestedGeneration = generation + const requestedPublicationGeneration = publicationGeneration const checkCurrent = () => { - if (requestedGeneration !== generation) + if (requestedPublicationGeneration !== publicationGeneration) throw new DOMException(`Publication was reset`, `AbortError`) } const result = tail.then(async () => { checkCurrent() - while (leases.size > 0) { + while (demands.size > 0) { const limit = desired() const publication = await acquire(limit) checkCurrent() @@ -92,18 +95,18 @@ export function createNoPeekSession( throw new Error(`Publication does not match acquired prefix`) } const next = new Map() - for (const [id, lease] of leases) { - const more = continuation(publication, lease.count) - if (more === undefined) lease.peek = true + for (const [id, demand] of demands) { + const more = continuation(publication, demand.count) + if (more === undefined) demand.peek = true else next.set(id, { - rows: publication.rows.slice(0, lease.count), + rows: publication.rows.slice(0, demand.count), hasNextPage: more, }) } - // Keep fallback on each consumer's lease, including after deeper peers + // Keep fallback on each consumer's demand, including after deeper peers // release. A snapshot cache must also be invalidated by fact-only changes. - if (next.size !== leases.size) continue + if (next.size !== demands.size) continue for (const [id, snapshot] of next) snapshots.set(id, snapshot) for (const listener of listeners) listener() return diff --git a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts index ed25a87992..66c3c6a26f 100644 --- a/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts +++ b/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts @@ -10,6 +10,21 @@ import { describe, expect, it } from 'vitest' import { queryCollectionOptions } from '../src/query' import type { Collection } from '@tanstack/db' +/** + * Nested Query-backed includes should acquire work proportional to reachable + * demand, not to the cartesian size of the backing tree. + * + * A deterministic four-level tree supplies the value model: traverse parent + * links from the selected roots to compute reachable rows and child collection + * counts. Independent loadSubset counters record physical rows delivered before + * and after public traversal. Generated root counts vary scale while fixed + * branching exposes accidental full-tree acquisition. + * + * The oracle checks both complete nested values and work bounds. Correct rows + * alone would allow an implementation that scans every branch; low counters + * alone could hide missing descendants or leaked child collections. + */ + let nextCollectionId = 0 type RootRow = { id: string; value: string } diff --git a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts index 82b2417608..e2f802d401 100644 --- a/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts +++ b/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts @@ -13,6 +13,23 @@ import { queryCollectionOptions } from '../src/query.js' import type { QueryFunctionContext } from '@tanstack/query-core' import type { LoadSubsetOptions, SyncMetadataApi } from '@tanstack/db' +/** + * # Does Query-backed loadSubset preserve identity and lifecycle? + * + * Canonically equal demands share one Query transport. Distinct predicates, + * values, order, cursor, and scope remain distinct. The final live-query owner + * controls cancellation, and a replacement owner starts fresh after abort. + * Initial failure reaches existing and late dependents; recovery becomes public + * only after every failed source recovers. A later refetch failure retains the + * last ready snapshot. + * + * Small identity forms and lifecycle histories drive a real QueryClient and + * Collection. The oracle records query calls, values, request options, abort + * signals, metadata, source and dependent status, exact errors, and cleanup. + * Fault cases prove correct call counts cannot hide wrong returned values or a + * stale replacement snapshot. + */ + type Row = { id: string group?: string @@ -252,7 +269,7 @@ async function expectRefetchFailureKeepsReadySnapshot(): Promise { async function expectDeferredStartupReadyDoesNotOverrideError(): Promise { // Internal ordering seam: this deliberately reuses old sync controls and // observers. It does not establish a public cleanup/restart path; real - // cleanup clears those observers before a new sync session starts. + // cleanup clears those observers before a new sync run starts. const loggedError = vi.spyOn(console, `error`).mockImplementation(() => {}) const queryClient = createQueryClient() const id = `load-subset-deferred-ready-${collectionSequence++}` diff --git a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts index 3a5958f79e..8d8dcc350c 100644 --- a/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts +++ b/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts @@ -27,6 +27,27 @@ import type { PersistenceAdapter } from '../../db-sqlite-persistence-core/src/in import type { NonSingleResult } from '../../db/src/types.js' import type { QueryCollectionUtils } from '../src/query.js' +/** + * # Who owns a Query-backed row, and when may it disappear? + * + * Query cache entries acquire rows for one or more exact subset demands. A row + * remains public while any committed owner needs it. Provisional ownership + * begins during result application, becomes durable only after publication and + * persistence, and retires when superseded, canceled, cleaned up, or released. + * Mutation refetches add a second authority path but do not bypass those rules. + * + * The reference view is an ownership graph: query scope and demand nodes point + * to row keys; sync generations order competing results; publication + * and persistence are separate commit boundaries. Tests use real QueryClient + * observers, cache events, collection metadata, persisted scans, and live + * queries. They compare source rows, derived rows, cache rows, metadata writes, + * exact request lifetimes, and bounded refetch work at each boundary. + * + * The file is large because it crosses the real Query cache boundary, not + * because it duplicates Query internals. Each history names one ownership edge + * or generation race; shared fixtures provide the graph and observations. + */ + type Item = { id: string category: string diff --git a/packages/react-db/tests/infinite-query-conformance.test.tsx b/packages/react-db/tests/infinite-query-conformance.test.tsx index bf7bd198eb..afcfefac72 100644 --- a/packages/react-db/tests/infinite-query-conformance.test.tsx +++ b/packages/react-db/tests/infinite-query-conformance.test.tsx @@ -1,4 +1,11 @@ -/** React driver for the shared infinite-query conformance suite. */ +/** + * React driver for the shared infinite-query conformance suite. + * + * `renderHook`, `act`, and the hook result are the only React-specific layer. + * Sources and query operators come from this package's module realm. The shared + * suite owns the page model and histories; this driver defines when React has + * committed enough work for those public observations to be read. + */ import { act, renderHook } from '@testing-library/react' import { BTreeIndex, diff --git a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts index cdaa8c24cd..b49daed573 100644 --- a/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts +++ b/packages/svelte-db/tests/infinite-query-conformance.svelte.test.ts @@ -1,4 +1,11 @@ -/** Svelte driver for the shared infinite-query conformance suite. */ +/** + * Svelte driver for the shared infinite-query conformance suite. + * + * `flushSync` and the effect root define Svelte's ownership and observation + * cuts. A public fetch may begin before a queued effect attaches, so the bridge + * preserves that native schedule while the shared suite judges the same + * ordered-prefix and page-ledger contract as the other frameworks. + */ import { BTreeIndex, createCollection, diff --git a/packages/trailbase-db-collection/tests/ORACLE.md b/packages/trailbase-db-collection/tests/ORACLE.md index 33a4c53aeb..f201a2b72b 100644 --- a/packages/trailbase-db-collection/tests/ORACLE.md +++ b/packages/trailbase-db-collection/tests/ORACLE.md @@ -20,18 +20,19 @@ The same interpreter runs a fixed corpus and generated histories. - A startup processing error rejects readiness before asynchronous cancellation settles. Two fixed controls hold cancellation through later list completion, then resolve or reject it; cleanup cannot turn failed startup into readiness. -- Cleanup clears the collection. Late work from an old session cannot publish - into, cancel, or report errors against a replacement session. +- Cleanup clears the collection. Late work from an old provider session cannot + publish into, cancel, or report errors against a replacement provider session. - A late acquired old stream is canceled once and stays unlocked, including when its native cancellation rejects. An unfinished old preload rejects with AbortError before late work settles; an already-ready on-demand preload stays fulfilled. Its direct subset promise settles as a canceled no-op after I/O. - Error and detached-rejection observations are append-only through final gate release and cleanup. Epoch tags record when an observation arrives, not the - hidden session that caused it. Exact console output checks pin the adapter's + hidden provider session that caused it. Exact console output checks pin the + adapter's current compatibility policy, not a general service reporting contract. -Histories contain one to three sessions, eager/on-demand modes, delayed startup +Histories contain one to three provider sessions, eager/on-demand modes, delayed startup and list resolve/reject, zero to eight row edits, five stream endings, and immediate versus settled cleanup. Thirty-two fixed cases pin the boundaries; ordinary runs add 30 fixed-seed and 50 fresh-seed histories with shrinking. diff --git a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts index 2b3a313895..6b629bf853 100644 --- a/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts +++ b/packages/trailbase-db-collection/tests/lifecycle-oracle.property.test.ts @@ -6,6 +6,25 @@ import { trailBaseCollectionOptions } from '../src/trailbase' import { MockRecordApi } from './mock-record-api' import type { Event, ListResponse } from 'trailbase' +/** + * # Does a TrailBase provider session publish and retire one valid row history? + * + * An eager provider session needs both its list and stream before readiness. An + * on-demand provider session does not claim an initial list. Stream events update an independent + * key/value relation. Startup failure rejects; later failure keeps the last + * rows and reports the error. Cleanup clears rows, cancels work, and fences all + * late results from the replacement provider session. + * + * A controlled RecordApi and native ReadableStream provide explicit gates for + * subscribe, list, events, closure, parse failure, cancellation, and cleanup. + * The same interpreter runs a fixed boundary corpus and generated one-to-three + * provider-session histories. It compares rows, readiness, exact reports, reader locks, + * timers, cancellations, and detached rejections after every step. + * + * See `ORACLE.md` for the full domain, replay commands, mutation evidence, and + * the boundary intentionally left to service-backed tests. + */ + type Row = { id: number; value: number } type Change = { operation: `set` | `delete`; id: number; value: number } type Ending = @@ -14,7 +33,7 @@ type Ending = | `read-error` | `parse-error` | `cleanup` -type Session = +type ProviderSessionPlan = | { kind: `cancel-subscribe` late: `resolve` | `reject` @@ -29,7 +48,10 @@ type Session = ending: Ending immediate: boolean } -type Scenario = { mode: `eager` | `on-demand`; sessions: Array } +type Scenario = { + mode: `eager` | `on-demand` + providerSessions: Array +} function deferred() { let resolve!: (value: T) => void @@ -38,7 +60,7 @@ function deferred() { resolve = yes reject = no }) - // Every adapter promise is observed even when its session is abandoned. + // Every adapter promise is observed even when its provider session is abandoned. void promise.catch(() => undefined) return { promise, resolve, reject } } @@ -97,7 +119,7 @@ function source() { * The model never reads adapter bookkeeping, pending transactions or caches. */ async function checkLifecycle( - { mode, sessions }: Scenario, + { mode, providerSessions }: Scenario, providerFault?: `list` | `set` | `buffered`, ) { const api = new MockRecordApi() @@ -176,7 +198,7 @@ async function checkLifecycle( } const failures: Array = [] try { - for (const [epoch, plan] of sessions.entries()) { + for (const [epoch, plan] of providerSessions.entries()) { current = source() const active = current allSources.push(active) @@ -392,7 +414,7 @@ const changeArb = fc.record({ id: fc.integer({ min: 0, max: 3 }), value: fc.integer({ min: -20, max: 20 }), }) -const sessionArb: fc.Arbitrary = fc.oneof( +const providerSessionArb: fc.Arbitrary = fc.oneof( fc.record({ kind: fc.constantFrom(`cancel-subscribe` as const, `cancel-load` as const), late: fc.constantFrom(`resolve` as const, `reject` as const), @@ -415,9 +437,12 @@ const sessionArb: fc.Arbitrary = fc.oneof( ) const scenarioArb = fc.record({ mode: fc.constantFrom(`eager` as const, `on-demand` as const), - sessions: fc.array(sessionArb, { minLength: 1, maxLength: 3 }), + providerSessions: fc.array(providerSessionArb, { + minLength: 1, + maxLength: 3, + }), }) -const live = (ending: Ending, immediate = false): Session => ({ +const live = (ending: Ending, immediate = false): ProviderSessionPlan => ({ kind: `stream`, ending, immediate, @@ -430,24 +455,27 @@ const live = (ending: Ending, immediate = false): Session => ({ // Fixed witnesses ensure every lifecycle boundary is exercised, independently // of the random distribution. The same interpreter runs corpus and fuzz cases. -const corpus: Array<{ name: string; sessions: Array }> = [ +const corpus: Array<{ + name: string + providerSessions: Array +}> = [ ...( [`close`, `buffered-close`, `read-error`, `parse-error`, `cleanup`] as const ).flatMap((ending) => [false, true].map((immediate) => ({ name: `${ending}, immediate=${immediate}`, - sessions: [live(ending, immediate)], + providerSessions: [live(ending, immediate)], })), ), ...([`cancel-subscribe`, `cancel-load`] as const).flatMap((kind) => ([`resolve`, `reject`] as const).map((late) => ({ name: `${kind}, stale ${late} after restart`, - sessions: [{ kind, late }, live(`close`)], + providerSessions: [{ kind, late }, live(`close`)], })), ), ...([`reject-subscribe`, `reject-load`] as const).map((kind) => ({ name: kind, - sessions: [{ kind }, live(`close`)], + providerSessions: [{ kind }, live(`close`)], })), ] it.each([`resolve`, `reject`] as const)( @@ -539,15 +567,15 @@ it.each( corpus.flatMap((entry) => ([`eager`, `on-demand`] as const).map((mode) => ({ ...entry, mode })), ), -)(`preserves lifecycle laws: $mode / $name`, ({ mode, sessions }) => - checkLifecycle({ mode, sessions }), +)(`preserves lifecycle laws: $mode / $name`, ({ mode, providerSessions }) => + checkLifecycle({ mode, providerSessions }), ) it.each([`eager`, `on-demand`] as const)( `releases a late acquired stream even when native cancellation rejects: %s`, (mode) => checkLifecycle({ mode, - sessions: [ + providerSessions: [ { kind: `cancel-subscribe`, late: `resolve`, cancelRejects: true }, live(`close`), ], @@ -558,7 +586,7 @@ it.each([`list`, `set`, `buffered`] as const)( async (cut) => { const scenario: Scenario = { mode: `eager`, - sessions: [live(`buffered-close`)], + providerSessions: [live(`buffered-close`)], } await checkLifecycle(scenario) await expect(checkLifecycle(scenario, cut)).rejects.toMatchObject({ diff --git a/packages/vue-db/tests/infinite-query-conformance.test.ts b/packages/vue-db/tests/infinite-query-conformance.test.ts index 6a1f01cee9..d2780fb92e 100644 --- a/packages/vue-db/tests/infinite-query-conformance.test.ts +++ b/packages/vue-db/tests/infinite-query-conformance.test.ts @@ -1,4 +1,10 @@ -/** Vue driver for the shared infinite-query conformance suite. */ +/** + * Vue driver for the shared infinite-query conformance suite. + * + * The effect scope owns the hook, and `nextTick` defines Vue's observation cut. + * Sources and query operators stay in this package's module realm. The shared + * suite, not this bridge, owns the ordered-prefix and page-ledger model. + */ import { BTreeIndex, createCollection, From 7f1776e7180c48789beb7beb04cdc4a2e69aa2e2 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 08:20:11 +0100 Subject: [PATCH 2/6] chore: add oracle models changeset --- .changeset/clear-oracle-models.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/clear-oracle-models.md diff --git a/.changeset/clear-oracle-models.md b/.changeset/clear-oracle-models.md new file mode 100644 index 0000000000..8ff00ea0e7 --- /dev/null +++ b/.changeset/clear-oracle-models.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +Document oracle tests as executable subsystem models, align their vocabulary with production code, and make deterministic failures easier to replay. From fb9566ecda5194440aabf52045a63ac2636e3ba6 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 08:33:32 +0100 Subject: [PATCH 3/6] test: align replay storage seam --- packages/db/tests/replay-publication-storage.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/db/tests/replay-publication-storage.test.ts b/packages/db/tests/replay-publication-storage.test.ts index 73e16c4e6f..623d393369 100644 --- a/packages/db/tests/replay-publication-storage.test.ts +++ b/packages/db/tests/replay-publication-storage.test.ts @@ -64,16 +64,16 @@ function replaySessions(collection: unknown) { _changes: { changeSubscriptions: Iterable<{ options: { truncateReplayPublication?: unknown } - truncateReplaySession?: { privateRows?: ReadonlyMap } + truncateReplayState?: { privateRows?: ReadonlyMap } }> } } return [...internals._changes.changeSubscriptions].flatMap((s) => - s.truncateReplaySession + s.truncateReplayState ? [ { delegated: Boolean(s.options.truncateReplayPublication), - privateRows: s.truncateReplaySession.privateRows?.size ?? null, + privateRows: s.truncateReplayState.privateRows?.size ?? null, }, ] : [], From a0485c0318120ee556f4923e95b8ee0055560bd8 Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:17:48 +0100 Subject: [PATCH 4/6] docs: clarify oracle replay terminology --- docs/contributing/glossary.md | 3 ++- docs/contributing/oracle-tests.md | 4 ++++ .../incrementalization-law.property.test.ts | 19 +++++++++---------- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 7e27d8de6a..14d347832f 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -72,7 +72,7 @@ earlier node alone. | request data | The immutable `LoadSubsetOptions` and attached signal passed to an adapter. | Demand or established coverage. | | acquisition attempt | One invocation that asks a sync adapter to start physical work. A synchronous throw ends the attempt before it establishes an acquisition lease. | Physical acquisition or transport. | | physical acquisition | Request-scoped work accepted by a sync adapter for a demand. | Acquisition attempt, logical demand, or transport. | -| lease | An ownership token that requires a matching release. Always qualify which resource it owns. | Demand, request, or data coverage. | +| lease | An ownership token that requires a matching release. TanStack DB leases do not expire on a timer. Always qualify which resource the lease owns. | Demand, request, or data coverage. | | acquisition lease | The release obligation created when an adapter accepts a physical acquisition. | The acquisition attempt itself. | | window lease | One window-controller caller's contribution to the requested window. | Acquisition lease. | | observer lease | One Query DB owner's claim that retains a query observer or its rows. | Subscription or acquisition lease. | @@ -128,6 +128,7 @@ materialization laws. | observation | One recorded public fact. | | trace | An ordered sequence of observations. | | checkpoint or observation cut | The exact point where the contract requires comparison. | +| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. | Runtime truncate replay, retry, or a new random campaign. | | projection or abstraction | The parts of concrete state retained for one judgment. | | refinement check | A check that observed production behavior is allowed by the model. A sampled check is not a proof for every behavior. | | partial oracle | An oracle that judges only named parts of the contract. | diff --git a/docs/contributing/oracle-tests.md b/docs/contributing/oracle-tests.md index 628b6e117c..2ab422ec38 100644 --- a/docs/contributing/oracle-tests.md +++ b/docs/contributing/oracle-tests.md @@ -356,6 +356,10 @@ suite environment variables or another checked replay interface when practical. The replay input must select both the seed and the path. A seed alone reruns the campaign but might not stop at the same reduced counterexample. +When replay inputs are present, run only the requested replay lane. Do not spend +time on the fixed campaign before reaching the failure the developer asked to +reproduce. + The fixed and random lanes should use the same property, generators, observations, and run budget. Only their seed source should differ. This keeps a random failure eligible for promotion into a pinned example or fixed campaign. diff --git a/packages/db-ivm/tests/incrementalization-law.property.test.ts b/packages/db-ivm/tests/incrementalization-law.property.test.ts index 68a5af04ec..30a7eb5ad7 100644 --- a/packages/db-ivm/tests/incrementalization-law.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law.property.test.ts @@ -72,14 +72,13 @@ if (REPLAY_PATH !== undefined && replaySeed === undefined) { ) } -const generatedCampaigns = [ - { name: `fixed`, seed: FIXED_SEED, path: undefined }, - { - name: replaySeed === undefined ? `random` : `replay`, - seed: replaySeed, - path: REPLAY_PATH, - }, -] as const +const generatedCampaigns = + replaySeed === undefined + ? [ + { name: `fixed`, seed: FIXED_SEED, path: undefined }, + { name: `random`, seed: undefined, path: undefined }, + ] + : [{ name: `replay`, seed: replaySeed, path: REPLAY_PATH }] function campaignParameters(campaign: (typeof generatedCampaigns)[number]) { return { @@ -103,8 +102,8 @@ const uniqueRowSplitDomain = { * exposes selected members, not their sequence indices. Publication timing and * SQL meaning are separate contracts. * - * The fixed generated lane preserves one stable campaign. The second lane uses - * a new seed unless replay variables select a prior seed and shrink path. + * Normal runs pair a stable campaign with a new random seed. Replay variables + * instead select only the recorded seed and shrink path. */ const weightedWorld = weightedStateArbitrary( From d26ce76ffbdf4a480ba2e3c089adac2d267ae80b Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 09:33:57 +0100 Subject: [PATCH 5/6] fix: preserve oracle replay docs and types --- docs/contributing/glossary.md | 2 +- .../db-ivm/tests/incrementalization-law.property.test.ts | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 14d347832f..41eca2d3da 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -128,7 +128,7 @@ materialization laws. | observation | One recorded public fact. | | trace | An ordered sequence of observations. | | checkpoint or observation cut | The exact point where the contract requires comparison. | -| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. | Runtime truncate replay, retry, or a new random campaign. | +| oracle replay | Re-running a recorded oracle failure from its seed, shrink path, actions, or other replay inputs. Do not use it for runtime truncate replay, retry, or a new random campaign. | | projection or abstraction | The parts of concrete state retained for one judgment. | | refinement check | A check that observed production behavior is allowed by the model. A sampled check is not a proof for every behavior. | | partial oracle | An oracle that judges only named parts of the contract. | diff --git a/packages/db-ivm/tests/incrementalization-law.property.test.ts b/packages/db-ivm/tests/incrementalization-law.property.test.ts index 30a7eb5ad7..3c58913b35 100644 --- a/packages/db-ivm/tests/incrementalization-law.property.test.ts +++ b/packages/db-ivm/tests/incrementalization-law.property.test.ts @@ -52,6 +52,12 @@ type OuterJoinOutput = [number, [number | null, number | null]] type GroupedOutput = [string, { bucket: number; total: number }] const FIXED_SEED = 1741 +type GeneratedCampaign = { + name: `fixed` | `random` | `replay` + seed: number | undefined + path: string | undefined +} + const replaySeedText = process.env.TANSTACK_DB_IVM_ORACLE_SEED const replaySeed = replaySeedText === undefined ? undefined : Number(replaySeedText) @@ -72,7 +78,7 @@ if (REPLAY_PATH !== undefined && replaySeed === undefined) { ) } -const generatedCampaigns = +const generatedCampaigns: Array = replaySeed === undefined ? [ { name: `fixed`, seed: FIXED_SEED, path: undefined }, From d0f3e1de375669588414361eef5512892500c17e Mon Sep 17 00:00:00 2001 From: Kyle Mathews Date: Mon, 21 Sep 2026 18:55:07 +0100 Subject: [PATCH 6/6] docs: align oracle lifecycle terminology --- docs/contributing/glossary.md | 22 ++++++----- docs/contributing/oracle-coverage.md | 38 +++++++++---------- docs/contributing/oracle-tests.md | 8 ++-- packages/db/src/collection/sync.ts | 14 ++++--- .../query/live/collection-config-builder.ts | 2 +- .../src/query/live/collection-subscriber.ts | 2 +- .../tests/replay-publication-storage.test.ts | 16 ++++---- .../tests/cursor-pagination/LOSS-AUDIT.md | 2 +- .../tests/cursor-pagination/README.md | 2 +- 9 files changed, 57 insertions(+), 49 deletions(-) diff --git a/docs/contributing/glossary.md b/docs/contributing/glossary.md index 41eca2d3da..9f03a0c35f 100644 --- a/docs/contributing/glossary.md +++ b/docs/contributing/glossary.md @@ -29,7 +29,7 @@ production queues, caches, or semantic helpers merely to share their names. | subscription | A consumer of Collection changes with its own subset demands and lifecycle. | Collection, query, or transport. | | sync adapter | The code supplied through Collection sync configuration that starts a sync run and translates between TanStack DB and a provider. | Provider or source Collection. | | provider | The external database, service, or SDK from which a sync adapter acquires data. | Sync adapter or source Collection. | -| sync run | One invocation of a Collection's sync function, plus the callbacks and resources installed by that invocation, until cleanup invalidates them. A run may own zero or more provider requests and may outlive any one request. The internal `syncRunGeneration` counter fences this lifetime. | Provider session, replay, generation, or request. | +| sync run | One invocation of a Collection's sync function, plus the callbacks and resources installed by that invocation, until cleanup invalidates them. A run may own zero or more provider requests and may outlive any one request. Component-owned `syncRunGeneration` counters fence this lifetime in Collection state, sync ownership, and live-query graph work. | Provider session, replay, generation, or request. | | provider session | A provider-defined remote stream, connection, or SDK lifetime. Always qualify it with the provider. | Sync run. | | cleanup | The transition that ends the current sync run and releases its resources. The Collection object remains available for cleanup or restart. | Collection destruction, restart, or replay. | | restart | Starting a new sync run after the prior sync run has ended. | Same-run recovery or replay. | @@ -38,7 +38,7 @@ production queues, caches, or semantic helpers merely to share their names. | public snapshot | The last coherent set of rows exposed to reads, events, and downstream queries. | Private replacement or source snapshot. | | private replacement | Source or graph state withheld while an authoritative replay or repair is incomplete. | Public snapshot. | | publication | The boundary that makes one coherent result observable to reads, events, and downstream queries. | Provider return, request settlement, or sync commit by itself. | -| coherent commit | One publication in which state, events, and consumers see the same complete result. | Any individual `commit()` call. | +| atomic publication | One publication boundary at which state, events, and consumers observe the same result without an intermediate public state. | Any individual `commit()` call or source snapshot. | | readiness | Evidence that a named consumer may proceed. Always qualify Collection readiness, subscription readiness, or initial-query readiness; they settle at different boundaries. | Provider completion or publication in general. | `source` names a role in a data flow; it does not own a lifecycle. Do not coin a @@ -70,7 +70,7 @@ earlier node alone. | demand | The logical need for source data. Demand may outlive or replace physical work. | Request, transport, or row ownership. | | logical subset owner | One subscription claim that keeps a subset demand active. | Physical acquisition. | | request data | The immutable `LoadSubsetOptions` and attached signal passed to an adapter. | Demand or established coverage. | -| acquisition attempt | One invocation that asks a sync adapter to start physical work. A synchronous throw ends the attempt before it establishes an acquisition lease. | Physical acquisition or transport. | +| acquisition attempt | One invocation that asks a sync adapter to start physical work. A synchronous throw ends the attempt before acceptance. If cleanup invalidates the captured sync run after the adapter returns but before activation, core cancels the tentative acquisition without retaining its lease; cleanup owns the adapter resources. | Physical acquisition or transport. | | physical acquisition | Request-scoped work accepted by a sync adapter for a demand. | Acquisition attempt, logical demand, or transport. | | lease | An ownership token that requires a matching release. TanStack DB leases do not expire on a timer. Always qualify which resource the lease owns. | Demand, request, or data coverage. | | acquisition lease | The release obligation created when an adapter accepts a physical acquisition. | The acquisition attempt itself. | @@ -90,7 +90,7 @@ earlier node alone. | boundary | The last value that an ordered acquisition has safely established for continuation. | Any last local row. | | tie group | Rows equal under the order terms used by a continuation boundary. | Page. | | source exhaustion | Authoritative evidence that no more matching source rows exist. | A short response unless the provider says it is authoritative. | -| generation | A monotonic token used to reject obsolete asynchronous work. Qualify the clock it fences, such as sync-run, replay, ordered-load, window-operation, cursor-sequence, publication, post-write-refetch, graph, or demand generation. | Session, replay, or request. | +| generation | A monotonic token used to reject obsolete asynchronous work. Qualify the clock it fences, such as sync-run, replay, ordered-load, window-operation, cursor-sequence, post-write-refetch, or demand generation. | Session, replay, or request. | | repair | Work that tries to restore an authoritative source result after finite coverage becomes invalid or an acquisition fails. Repair may use a replay, but the terms are not synonyms. | Retry, replay, or restart. | | recovery | Regaining a named capability after failure. Always qualify what recovered and whether it stayed in the same sync run. | Restart or repair in general. | @@ -111,7 +111,7 @@ cross-file terms keep their exact meanings: | weighted delta | A positive or negative change to a relation row. | See -[`packages/db/src/query/live/ARCHITECTURE.md`](../../packages/db/src/query/live/ARCHITECTURE.md) +[`packages/db/src/query/live/ARCHITECTURE.md`](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md) for bucket relations, bucket values, arrangements, reductions, and the normative materialization laws. @@ -133,7 +133,8 @@ materialization laws. | refinement check | A check that observed production behavior is allowed by the model. A sampled check is not a proof for every behavior. | | partial oracle | An oracle that judges only named parts of the contract. | | reach witness | Evidence that the production driver reached the claimed boundary. | -| fault control or mutant | A deliberate wrong answer or implementation used to test oracle sensitivity. | +| mutant | A deliberate wrong answer or implementation used to test oracle sensitivity. | +| fault injection | A deliberate runtime failure, delay, cancellation, or malformed response used to test a system boundary. | | shrinking | Reducing a failing generated history while preserving the failure. | | bounded exhaustiveness | Executing every case inside stated finite bounds. | | held-out challenge | A challenge not used to shape the tested design. After it guides a change, retain it but stop calling it held out. | @@ -145,7 +146,9 @@ Use nouns for state and verbs for transitions: - A demand **becomes active** or **retires**. - Core **starts an acquisition attempt**. If the adapter accepts it, the attempt **establishes a physical acquisition** and its **acquisition lease**. A - synchronous throw **fails the attempt** before either is established. + synchronous throw **fails the attempt** before either is established. Cleanup + may instead invalidate a returned attempt before activation; core then + **cancels the tentative acquisition** without retaining its lease. - A logical owner **retires**. Core **releases an acquisition lease**. The adapter **unloads** its acquisition. - An abort signal **requests cancellation**; transport **stops** only when the @@ -157,8 +160,9 @@ Use nouns for state and verbs for transitions: - Truncate replay **replaces** source state inside the current sync run and any current dependent graph. -Use `complete` only when fulfillment versus rejection does not matter. Use -`success` only for fulfillment. Qualify overloaded words such as `state`, +Use `settled` for a promise that fulfilled or rejected. Use `complete` for named +coverage, content, history, or a non-promise operation only when no stronger +boundary term applies. Use `success` only for fulfillment. Qualify overloaded words such as `state`, `request`, `snapshot`, `session`, `owner`, `release`, `recovery`, and `generation` at first use. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 9efcd14145..06eb175c2a 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -99,37 +99,37 @@ comment and the current API/architecture contract before extending its model. | Surface | Primary executable owners | Independent judgment and important limit | | --- | --- | --- | -| Ordered relations and BTree | [top-K relation oracle](../../packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [BTree/Map](../../packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](../../packages/db-ivm/tests/incrementalization-law.property.test.ts) | Independent ordered relations and cumulative signed output. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. | -| Includes and publication | [cross-formulation](../../packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](../../packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](../../packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](../../packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Observe raw promised order; fresh queries do not establish continuous publication safety. | -| Collection lifecycle | [mutation startup](../../packages/db/tests/collection-mutation-startup-oracle.test.ts), [history](../../packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](../../packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](../../packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](../../packages/db/tests/effect-disposal-oracle.test.ts) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; ownership and phase histories; exact caller/error/publication evidence; late completion and restart. Query write utilities and effect self-dependent disposal remain separate contracts. | -| Optimistic state | [history model](../../packages/db/tests/optimistic-history-oracle.ts), [generated histories](../../packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](../../packages/db/tests/optimistic-history-outcomes.test.ts), [publication](../../packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | -| Drafts and native values | [proxy](../../packages/db/tests/proxy.test.ts), [detachment](../../packages/db/tests/proxy-detachment-contract.test.ts), [iteration](../../packages/db/tests/proxy-iteration-contract.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. General native-mutator and symbol-write support is not established by a plain-object oracle. | -| Query DB and observer | [ownership](../../packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](../../packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](../../packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary and a per-listener eligibility ledger, not a duplicate dispatch queue. Check reentry, peer survival, FIFO and disposal independently of final rows. | -| Ordered acquisition | [pagination](../../packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](../../packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](../../packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts) | Complete finite provider results, inherited collation with exact own-key request options, real lexical/numeric disagreement, pending windows, ties/nulls, ownership and documented repair timing. Request completion is not proof of unrequested source extent. | +| Ordered relations and BTree | [top-K relation oracle](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-relation-oracle.test.ts), [BTree/Map](https://github.com/TanStack/db/blob/main/packages/db/tests/btree-map-oracle.test.ts), [incrementalization laws](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/incrementalization-law.property.test.ts) | Independent ordered relations and cumulative signed output. Top-K consolidation compares same-key values without hashing, including cyclic replacements and fresh transient cancellation. Other hash-based operators retain hashing's declared domain. Algebra does not specify client readiness. | +| Includes and publication | [cross-formulation](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-cross-formulation-oracle.property.test.ts), [temporal](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-temporal-oracle.test.ts), [Collection includes](https://github.com/TanStack/db/blob/main/packages/db/tests/query/includes-collection-oracle.property.test.ts), [architecture and complete suite map](https://github.com/TanStack/db/blob/main/packages/db/src/query/live/ARCHITECTURE.md#executable-contracts) | Per-parent/flat-join/partition relations, callback-time rows, nested values, and route histories. Observe raw promised order; fresh queries do not establish continuous publication safety. | +| Collection lifecycle | [mutation startup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-mutation-startup-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication.property.test.ts), [replay](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-replay-oracle.property.test.ts), [effect disposal](https://github.com/TanStack/db/blob/main/packages/db/tests/effect-disposal-oracle.test.ts) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; ownership and phase histories; exact caller/error/publication evidence; late completion and restart. Query write utilities and effect self-dependent disposal remain separate contracts. | +| Optimistic state | [history model](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-oracle.ts), [generated histories](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-transaction-oracle.property.test.ts), [outcomes](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-outcomes.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication.test.ts) | Independent whole-row snapshots, rollback dependencies, metadata and prior-value events. Never rebase a pending snapshot merely to simplify the model. | +| Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. General native-mutator and symbol-write support is not established by a plain-object oracle. | +| Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [load lifecycle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/load-subset-lifecycle-oracle.test.ts), [observer histories](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-history.property.test.ts) | Real QueryClient boundary and a per-listener eligibility ledger, not a duplicate dispatch queue. Check reentry, peer survival, FIFO and disposal independently of final rows. | +| Ordered acquisition | [pagination](https://github.com/TanStack/db/blob/main/packages/db/tests/query/pagination-oracle.property.test.ts), [ordered work](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-work-oracle.property.test.ts), [ordered lifecycle](https://github.com/TanStack/db/blob/main/packages/db/tests/query/ordered-lifecycle-oracle.property.test.ts) | Complete finite provider results, inherited collation with exact own-key request options, real lexical/numeric disagreement, pending windows, ties/nulls, ownership and documented repair timing. Request completion is not proof of unrequested source extent. | | Join equality and cold acquisition | `packages/db/tests/query/cold-join-reconciliation-oracle.test.ts` | Independent recomputation for cold acquisition plus direct join/predicate equivalence across established equality domains. Binary/string and nullish classes, replacement histories, raw on-demand values, and both scan/auto-index paths are explicit; compound join syntax is not claimed. | -| Opaque backend pagination | [window oracle](../../packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](../../packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](../../packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](../../packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](../../packages/query-db-collection/tests/cursor-pagination.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | -| Electric and TrailBase | [Electric histories](../../packages/electric-db-collection/tests/electric-oracle.property.test.ts), [PostgreSQL semantics](../../packages/electric-db-collection/e2e/sql-predicate-semantics.e2e.test.ts), [TrailBase contract](../../packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments and late errors. SDK fixtures and a real service test earn different credit. | -| PowerSync | [tests](../../packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | -| SQLite persistence and native hosts | [persisted histories](../../packages/db-sqlite-persistence-core/tests/persisted.test.ts), [driver contracts](../../packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [browser OPFS lifecycle](../../packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](../../packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [113-law manifest](../../packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Cache/remote rejection/peer/reopen histories, exact driver results, controlled page/worker ownership, and diagnostic-cause retention. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The manifest excludes progressive and move suites; registration and shim runs are not device execution. | -| Offline execution | [scheduler](../../packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](../../packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](../../packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](../../packages/offline-transactions/tests/transaction-serializer.property.test.ts) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | -| Frameworks | [React conformance](../../packages/react-db/tests/conformance.test.tsx), [React pagination](../../packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](../../packages/db-collection-e2e/src/suites) | Exact exposed rows/pages and each framework's own lifecycle cuts. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | -| Structural values and ordered primitives | [hash values](../../packages/db-ivm/tests/hash.property.test.ts), [hash graphs](../../packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](../../packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](../../packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](../../packages/db/tests/comparison.property.test.ts), [deep equality](../../packages/db/tests/utils.property.test.ts), [cursor](../../packages/db/tests/cursor.property.test.ts), [indexes](../../packages/db/tests/index-update.property.test.ts), [query identity](../../packages/db/tests/query/identity-output-shape-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, and compiled output bags. Hash collision freedom is not promised. Unsupported composite cursors reject. | -| Boundary refinements | [cleanup/restart](../../packages/db/tests/collection-cleanup-restart-oracle.test.ts), [metadata publication](../../packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [state retention](../../packages/db/tests/collection-state-retention-oracle.property.test.ts), [acquisition cells](../../packages/db/tests/collection-subscription-lifecycle-oracle.test.ts), [D2 source reconciliation](../../packages/db/tests/d2-source-reconciliation-oracle.property.test.ts), [top-K support windows](../../packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts), [nested Query work](../../packages/query-db-collection/tests/includes-work-counter-oracle.test.ts) | Explicit lifecycle products, independent source maps and weighted relations, exact publication cuts, support/multiplicity, and value-plus-work observations. These refine the larger subsystem models; they do not replace them. | -| Small structures and test mechanics | [SortedMap](../../packages/db/tests/SortedMap.test.ts), [cleanup queue](../../packages/db/tests/cleanup-queue.property.test.ts), [guarded replay](../../packages/db/tests/oracle-replay.test.ts) | Map/full-sort and appointment-list models with executed target/seed/path checks. Callback-reentrant scheduling is outside the initial cleanup-queue domain. | +| Opaque backend pagination | [window oracle](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.oracle.test.ts), [cache histories](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.cache-oracle.test.ts), [cache publication](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.publication-oracle.test.ts), [browser acquisition boundaries](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.boundary-oracle.test.ts), [QueryCollection integration](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/cursor-pagination.integration.test.ts) | Full filter/sort/slice reference, opaque token transport, actual Query cache expiry/invalidation/GC, forced refresh during growth, protocol failure publication/recovery, bounded slice work, nested cancellation/replacement, reader abort, browser retry defaults, manual-write cache isolation, and production window publications. Stable backend sequences; not snapshot guarantees for changing endpoints. Peek-ahead remains enabled. | +| Electric and TrailBase | [Electric histories](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/tests/electric-oracle.property.test.ts), [PostgreSQL semantics](https://github.com/TanStack/db/blob/main/packages/electric-db-collection/e2e/sql-predicate-semantics.e2e.test.ts), [TrailBase contract](https://github.com/TanStack/db/blob/main/packages/trailbase-db-collection/tests/ORACLE.md) | Installed SDK delivery/framing, independent predicates, exact subscription arguments and late errors. SDK fixtures and a real service test earn different credit. | +| PowerSync | [tests](https://github.com/TanStack/db/tree/main/packages/powersync-db-collection/tests), `tests/correctness-oracle.test.ts` | Applied receipt positions crossed with held peers, native SQLite/SDK and cleanup evidence. Run the focused owner with the package's `test:oracles` command. A timeout mutant proves a progress failure, not every value assertion. | +| SQLite persistence and native hosts | [persisted histories](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/persisted.test.ts), [driver contracts](https://github.com/TanStack/db/blob/main/packages/db-sqlite-persistence-core/tests/contracts/sqlite-driver-contract.ts), [browser OPFS lifecycle](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-page-lifecycle-oracle.test.ts), [worker diagnostics](https://github.com/TanStack/db/blob/main/packages/browser-db-sqlite-persistence/tests/opfs-worker-diagnostics-oracle.test.ts), [113-law manifest](https://github.com/TanStack/db/blob/main/packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) | Cache/remote rejection/peer/reopen histories, exact driver results, controlled page/worker ownership, and diagnostic-cause retention. Fake workers and synthetic page events do not prove native handle release or real bfcache admission. The manifest excludes progressive and move suites; registration and shim runs are not device execution. | +| Offline execution | [scheduler](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/KeyScheduler.property.test.ts), [leadership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay.property.test.ts), [settlement](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-settlement.property.test.ts), [serialization](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/transaction-serializer.property.test.ts) | Declarative FIFO eligibility, per-transaction outcomes, durable state and typed wire trees. Issued work may finish after ownership loss, but new work must not start. Exactly-once network execution is not promised. | +| Frameworks | [React conformance](https://github.com/TanStack/db/blob/main/packages/react-db/tests/conformance.test.tsx), [React pagination](https://github.com/TanStack/db/blob/main/packages/react-db/tests/infinite-query-conformance.test.tsx), [shared suites](https://github.com/TanStack/db/tree/main/packages/db-collection-e2e/src/suites) | Exact exposed rows/pages and each framework's own lifecycle cuts. A React witness does not prove Vue/Solid/Angular/Svelte scheduling. Preserve their receiving registrations. | +| Structural values and ordered primitives | [hash values](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash.property.test.ts), [hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-graph.property.test.ts), [mixed hash graphs](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-mixed-graph.property.test.ts), [hash retry](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/hash-failure-retry.property.test.ts), [comparison](https://github.com/TanStack/db/blob/main/packages/db/tests/comparison.property.test.ts), [deep equality](https://github.com/TanStack/db/blob/main/packages/db/tests/utils.property.test.ts), [cursor](https://github.com/TanStack/db/blob/main/packages/db/tests/cursor.property.test.ts), [indexes](https://github.com/TanStack/db/blob/main/packages/db/tests/index-update.property.test.ts), [query identity](https://github.com/TanStack/db/blob/main/packages/db/tests/query/identity-output-shape-oracle.test.ts) | Independent flat values, graph topology, algebraic laws, Map/group/sort recomputation, expression denotation, and compiled output bags. Hash collision freedom is not promised. Unsupported composite cursors reject. | +| Boundary refinements | [cleanup/restart](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-cleanup-restart-oracle.test.ts), [metadata publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [state retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts), [acquisition cells](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-oracle.test.ts), [D2 source reconciliation](https://github.com/TanStack/db/blob/main/packages/db/tests/d2-source-reconciliation-oracle.property.test.ts), [top-K support windows](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-support-window-oracle.test.ts), [nested Query work](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/includes-work-counter-oracle.test.ts) | Explicit lifecycle products, independent source maps and weighted relations, exact publication cuts, support/multiplicity, and value-plus-work observations. These refine the larger subsystem models; they do not replace them. | +| Small structures and test mechanics | [SortedMap](https://github.com/TanStack/db/blob/main/packages/db/tests/SortedMap.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue.property.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and appointment-list models with executed target/seed/path checks. Callback-reentrant scheduling is outside the initial cleanup-queue domain. | ## Acceptance map The post-merge review added three missing domains to existing owners: -- [Top-K batch contracts](../../packages/db-ivm/tests/operators/topk-batch-contract.test.ts) +- [Top-K batch contracts](https://github.com/TanStack/db/blob/main/packages/db-ivm/tests/operators/topk-batch-contract.test.ts) cross sparse-array length/holes and RegExp source/flags/position with equal controls, replacement order, hash consolidation, and actual retained graph output. Ordinary replacements also run without the global `File` constructor. -- [Leadership replay](../../packages/offline-transactions/tests/leadership-replay.property.test.ts) +- [Leadership replay](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/leadership-replay.property.test.ts) holds real storage-read delivery across successful and permanently rejected durable removals, with bounded scans, concurrent loads, and unfinished peers. This is distinct from exactly-once execution across independent owners. -- [Accepted-snapshot retention](../../packages/db/tests/collection-state-retention-oracle.property.test.ts) +- [Accepted-snapshot retention](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-state-retention-oracle.property.test.ts) varies truncate before/during/after an optimistic delete, rejection versus rollback, post-capture direct insertion, and later ordinary sync/key reuse. A hidden accepted insert returns after rollback; an uncaptured insert retires, diff --git a/docs/contributing/oracle-tests.md b/docs/contributing/oracle-tests.md index 2ab422ec38..bea51eac49 100644 --- a/docs/contributing/oracle-tests.md +++ b/docs/contributing/oracle-tests.md @@ -64,9 +64,9 @@ evidence together. Two small examples show the form: -- [`load-subset-transaction-refinement-oracle.test.ts`](../../packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts) +- [`load-subset-transaction-refinement-oracle.test.ts`](https://github.com/TanStack/db/blob/main/packages/db/tests/query/load-subset-transaction-refinement-oracle.test.ts) explains when an abort can still cancel an on-demand load. -- [`fifo-retry.property.test.ts`](../../packages/offline-transactions/tests/fifo-retry.property.test.ts) +- [`fifo-retry.property.test.ts`](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry.property.test.ts) explains why a ready transaction waits behind a delayed FIFO head. ### Use five visible layers @@ -364,7 +364,7 @@ The fixed and random lanes should use the same property, generators, observations, and run budget. Only their seed source should differ. This keeps a random failure eligible for promotion into a pinned example or fixed campaign. -[`fifo-retry.property.test.ts`](../../packages/offline-transactions/tests/fifo-retry.property.test.ts) +[`fifo-retry.property.test.ts`](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/fifo-retry.property.test.ts) shows this shape. Its fixed lane preserves one scheduler campaign. Its second lane uses a random seed by default and accepts `OFFLINE_ORACLE_SEED` with `OFFLINE_ORACLE_PATH` for replay. @@ -620,7 +620,7 @@ For a new oracle or a claimed repair, ask: 2. Which legal history distinguishes the proposed model from a weaker one? 3. Does the fixture make production do the work being tested? 4. Which concrete wrong answer can the comparison reject—and which can it miss? -5. What proves the path and assertion ran? What did the fault control actually show? +5. What proves the path and assertion ran? What did the mutant or fault injection actually show? 6. Can capture, cleanup or shrinking turn this into a different failure? 7. Which larger promises remain outside this test, and where are they tracked? diff --git a/packages/db/src/collection/sync.ts b/packages/db/src/collection/sync.ts index d667b4f354..a334c7665f 100644 --- a/packages/db/src/collection/sync.ts +++ b/packages/db/src/collection/sync.ts @@ -73,7 +73,9 @@ export class CollectionSyncManager< private syncStartDeferred = false private syncStartRequested = false private deferredLoadSubsets: Array = [] - private syncEpoch = 0 + // Fences callbacks retained across reentrant sync entry and cleanup. This + // changes at both boundaries; syncRunGeneration changes only at cleanup. + private syncCallbackEpoch = 0 private syncRunGeneration = 0 /** @@ -123,8 +125,8 @@ export class CollectionSyncManager< return } - const syncEpoch = ++this.syncEpoch - const isCurrentSync = () => syncEpoch === this.syncEpoch + const syncCallbackEpoch = ++this.syncCallbackEpoch + const isCurrentSync = () => syncCallbackEpoch === this.syncCallbackEpoch this.lifecycle.setStatus(`loading`) if (!isCurrentSync()) return let syncEntryActive = true @@ -887,7 +889,7 @@ export class CollectionSyncManager< public cleanup(): void { // Invalidate callbacks retained by asynchronous work from this sync run // before invoking adapter cleanup or allowing a new sync run to start. - const cleanupEpoch = ++this.syncEpoch + const cleanupCallbackEpoch = ++this.syncCallbackEpoch this.syncRunGeneration++ this.rejectPreload?.(new CollectionPreloadAbortedError()) const cleanup = this.syncCleanupFn @@ -899,7 +901,9 @@ export class CollectionSyncManager< } catch (error) { // Keep failed cleanup retryable, but never overwrite a replacement // sync run installed by reentrant adapter code. - if (this.syncEpoch === cleanupEpoch) this.syncCleanupFn = cleanup + if (this.syncCallbackEpoch === cleanupCallbackEpoch) { + this.syncCleanupFn = cleanup + } // Re-throw in a microtask to surface the error after cleanup completes queueMicrotask(() => { if (error instanceof Error) { diff --git a/packages/db/src/query/live/collection-config-builder.ts b/packages/db/src/query/live/collection-config-builder.ts index 9d4e1b4384..b50a358a8d 100644 --- a/packages/db/src/query/live/collection-config-builder.ts +++ b/packages/db/src/query/live/collection-config-builder.ts @@ -449,7 +449,7 @@ export class CollectionConfigBuilder< return this.activeWindowOperation?.generation } - scheduleGraphRunForSyncRunGeneration(syncRunGeneration: number): void { + scheduleGraphRunIfSyncRunCurrent(syncRunGeneration: number): void { if ( syncRunGeneration !== this.syncRunGeneration || !this.currentSyncConfig || diff --git a/packages/db/src/query/live/collection-subscriber.ts b/packages/db/src/query/live/collection-subscriber.ts index dd2e0e757e..c1e2739f4e 100644 --- a/packages/db/src/query/live/collection-subscriber.ts +++ b/packages/db/src/query/live/collection-subscriber.ts @@ -379,7 +379,7 @@ export class CollectionSubscriber< return } this.orderedLoader?.settleFullSourceReplay() - this.collectionConfigBuilder.scheduleGraphRunForSyncRunGeneration( + this.collectionConfigBuilder.scheduleGraphRunIfSyncRunCurrent( syncRunGeneration, ) }, diff --git a/packages/db/tests/replay-publication-storage.test.ts b/packages/db/tests/replay-publication-storage.test.ts index 623d393369..a62622c25f 100644 --- a/packages/db/tests/replay-publication-storage.test.ts +++ b/packages/db/tests/replay-publication-storage.test.ts @@ -42,13 +42,13 @@ async function withReplayResources( } function expectDelegatedStorageAbsent( - sessions: Array<{ delegated: boolean; privateRows: number | null }>, + states: Array<{ delegated: boolean; privateRows: number | null }>, ) { - const delegated = sessions.filter((session) => session.delegated) + const delegated = states.filter((replayState) => replayState.delegated) expect(delegated, `delegated replay reached`).toHaveLength(1) - for (const session of delegated) { + for (const replayState of delegated) { expect( - session.privateRows === null || session.privateRows === 0, + replayState.privateRows === null || replayState.privateRows === 0, `delegated replay does not copy replacement rows`, ).toBe(true) } @@ -59,7 +59,7 @@ const shape = (changes: Array>): Batch => changes.map((c) => [c.type, c.key, c.value.version]) /** Retention witness: private replacement rows held per subscription. */ -function replaySessions(collection: unknown) { +function replayStates(collection: unknown) { const internals = collection as { _changes: { changeSubscriptions: Iterable<{ @@ -272,7 +272,7 @@ describe(`Replay publication storage`, () => { // Reentrant acquisition while the replay is open joins the barrier. sub.requestSnapshot({ where: eq(idRef(), 3), optimizedOnly: false }) expect(batches).toEqual([]) - const retention = replaySessions(s.source) + const retention = replayStates(s.source) expect(retention).toContainEqual({ delegated: false, privateRows: 3 }) expect(retention.filter((r) => r.delegated)).toHaveLength(1) expectDelegatedStorageAbsent(retention) @@ -288,7 +288,7 @@ describe(`Replay publication storage`, () => { [`update`, 2, 2], ]) expect(peerLive.get(2)?.version).toBe(2) - expect(replaySessions(s.source)).toEqual([]) + expect(replayStates(s.source)).toEqual([]) // A later plain delta publishes normally. s.sync.begin() @@ -338,7 +338,7 @@ describe(`Replay publication storage`, () => { expect(sub.status).toBe(`ready`) expect(sub.hasPendingTruncateReplacement).toBe(false) expect(visible.get(1)).toBe(1) - expect(replaySessions(s.source)).toEqual([]) + expect(replayStates(s.source)).toEqual([]) // Late settlement of the released transport changes nothing. hold.resolve() diff --git a/packages/query-db-collection/tests/cursor-pagination/LOSS-AUDIT.md b/packages/query-db-collection/tests/cursor-pagination/LOSS-AUDIT.md index 169f6f7488..8cf25fe24b 100644 --- a/packages/query-db-collection/tests/cursor-pagination/LOSS-AUDIT.md +++ b/packages/query-db-collection/tests/cursor-pagination/LOSS-AUDIT.md @@ -116,7 +116,7 @@ The publication oracle keeps the same full-relation reference. It adds: - Immutable cursor sequences with held suffix delivery, a real active outer QueryObserver, and the documented cancel-then-invalidate procedure. Omitting - cancellation is a fault control rejected by the same row checker. + cancellation is a mutant rejected by the same row checker. - Growth and refresh with a malformed final continuation, generated page sizes, depths and backward token targets, shared waiters, and Query retry enabled or disabled. Assertions cover rejection, no successful cache publication, retained diff --git a/packages/query-db-collection/tests/cursor-pagination/README.md b/packages/query-db-collection/tests/cursor-pagination/README.md index c322f6cf38..2d81ed2592 100644 --- a/packages/query-db-collection/tests/cursor-pagination/README.md +++ b/packages/query-db-collection/tests/cursor-pagination/README.md @@ -136,7 +136,7 @@ The cache-publication review then exposed invalidation during held growth and protocol errors after cache publication. Its oracle was red in four cells before the fixes. The supported refresh procedure now cancels before invalidation; response validation runs before Query can publish or resolve shared waiters. -The new five-cell suite retains an invalidation-only fault control and covers +The new five-cell suite retains an invalidation-only mutant and covers shared readers, retries, malformed final tokens, recovery and bounded slice work. See LOSS-AUDIT.md for the distinct generator and observation gaps.