Repository navigation
Description
Summary
Add a shared conformance oracle suite that tests the contract between core and a collection adapter. The adapters in scope are Electric, query-db, TrailBase, PowerSync, RxDB, and any third-party sync implementation. Each adapter supplies a small harness. The suite then runs the same laws, histories, and checks against every adapter. It uses controlled transports in CI and can also run against real backends.
Why
Core decides when a live query is ready, when an Effect stops suppressing initial rows, and when a window may publish. It decides from signals the adapter gives it: loadSubset settlement, markReady(), applied receipts, aborts, truncates, and unloads. If an adapter sends a wrong signal, core shows a wrong result, even though core and the adapter's own tests both pass.
#2115 showed this directly. Its readiness fix made settled subset demand the readiness evidence for on-demand sources. Several adapter paths then turned out to break the settlement contract (ARCHITECTURE.md law 7: a successful load settles only after its rows are visible):
- Electric progressive: during a must-refetch before the first ready,
loadSubsetresolved at once without loading rows. A query became ready and empty. Only a code review found this. fix(db): ready a live query once its on-demand subset settles #2115 fixes it. - TrailBase on-demand:
loadcan finish before the change subscription starts, so a change between the fetch and the subscription can be lost. Only reading the code found this. - Electric resume: a resumed progressive stream settles loads at once because its local rows are complete by contract. A plausible "fix" broke that contract, and only Electric's own oracles caught it.
Every adapter now re-derives these rules in its own tests, in its own words. Nothing shows that each adapter keeps them.
Proposed design
Adapter harness
Each adapter provides a harness that:
- creates a Collection over a controlled transport, for example a scripted Electric
ShapeStream, a fake TanStack Query fetcher, or a TrailBase record API stub; - runs backend actions: insert, update, and delete rows; send a must-refetch or reset; resume from an offset; fail a request; delay a request;
- declares its capabilities: sync modes, cancellation support, resume, progressive buffering, ordered loading, txid confirmation, and persistence composition.
The suite uses the declared capabilities to choose which laws apply. A missing capability is an explicit, reviewed exclusion, not a silent skip. This also makes the concept from #2115 explicit: an adapter declares whether a source serves subset demand in each phase, instead of core inferring it from syncMode.
Laws (first set)
Each law needs an independent model, a legal history grammar, and public observations at named checkpoints, following docs/contributing/oracle-tests.md.
- Applied settlement. When
loadSubsetfulfills, every row that the request establishes is visible, or the source is ready and owns the authoritative absence. Histories: the buffered initial sync, a reset before and during a fetch, resume, retry after failure, and concurrent loads with overlapping predicates. - Cancellation. An aborted load rejects with
AbortError. A source that can cancel installs no request-scoped rows after it sees the abort. A source that cannot cancel declares that and settles its work. - Readiness meaning. For each sync mode, state what
markReady()promises. Examples: an eager source has applied its whole dataset, and a progressive source has completed its full sync. Check the promise at thereadytransition. - Freshness. After a load settles, the source delivers later changes to its rows. Any declared buffering window is a capability with a pinned observation, for example progressive buffering until the atomic swap.
- Reset and truncate. A must-refetch or truncate replaces state atomically behind the replay barrier. A failed reset keeps the last complete public state.
- Unload ownership.
unloadSubsetdoes not remove rows that another active demand still owns. Each physical acquisition gets one release. - Cleanup and restart. Cleanup aborts in-flight loads and runs no first-ready callbacks. A restart acquires its demand again through a fresh barrier.
- Mutation confirmation. For adapters with write handlers, confirmation helpers (
awaitTxId,awaitMatch, and others) resolve only after the confirming sync transaction applies.
Observation through core
Run every history through a real live query and a real skipInitial Effect, not only through the adapter's API. The law that matters to users is the composed one: when core reports ready, the rows are right. Use the Model D shapes from #2115 as the consumer side: direct, single result, ordered window, lazy join, and include.
Calibration
For each law, keep at least one hostile adapter fixture that breaks it. Each fixture must fail the suite at the intended checkpoint, not at setup or by timing out. The #2115 failures make good first fixtures:
- a load that resolves without rows during a reset;
- a load that resolves before its change subscription starts;
- a resume path that waits for the network although its local rows are complete.
Real-provider runs
The same laws should register in packages/db-collection-e2e for real backends where possible. That gives each controlled premise a receiving witness (ORC-014). It also gives the existing progressive registrations the phase and capability evidence that the coverage map says they lack.
Existing pieces to build on
packages/db-collection-e2e(shared real-backend suites)- The persisted conformance manifest (
packages/db-collection-e2e/src/fixtures/persisted-conformance-manifest.ts) - The framework conformance contract (
packages/db/tests/conformance/) - Model D (
packages/db/tests/query/includes-temporal-oracle.test.ts) and the Electric phase matrix (packages/electric-db-collection/tests/electric-live-query.test.ts)
Out of scope for the first version
- Performance and work budgets.
- Multi-process persistence (the SQLite owners cover it).
- Framework render timing (the framework conformance suite covers it).
First milestone
Implement law 1 (applied settlement) and law 2 (cancellation) with harnesses for Electric and query-db. Use the three #2115 hostile fixtures as calibration. Then add TrailBase and PowerSync, and record the TrailBase subscription gap as RED or as a declared capability.
Activity
KyleAMathews commentedon Oct 28, 2026
Folding #2269 into this issue, because they plan the same suite. Three things from #2269 and #2168 extend the plan above.
Requirements added by the maintainer
1. Usable by external adapter authors
Many people write their own sync adapters, so the suite must ship as a public test kit, not only as internal tests.
@tanstack/db-adapter-conformance, or as a@tanstack/db/testingentry point. A third-party adapter author installs it and runs it in their own test runner, with Vitest support at minimum.collectionOptionsover a test backend;2. Random data types in the backend, observed on both sides
null;undefinedor a missing field;Date;bigint; nested objects; and arrays.Dateas an ISO string,bigintas a string,undefineddropped. An undeclared difference is a failure.Adapter obligations to enforce (decided in #2168)
During #2168 the core dropped machinery that tolerated some adapter behaviors, to keep the bundle small. These behaviors are now adapter obligations. The core relies on them and no longer guards them, so the conformance suite must check each one per adapter, with a hostile-backend mutant that breaks it.
An abort ends the load. When the
loadSubsetsignal aborts, the load is over for the core. The core stops listening, so a later success or rejection decides nothing, and no replay waits for it. The adapter must stop its work on abort (the standardAbortSignalcontract). Rows it still writes apply as ordinary writes, so it should not write rows for an aborted load.Check: a backend that settles after an abort, or keeps writing after it, must not change the request error or the replay's outcome. Its late rows must not reappear after a later replay that excludes them.
A throw is a rejection. A synchronous throw from
loadSubsetis the same as a rejected promise, a failed load.Check: the same history with a throw and with a rejection gives the same observations.
A reload's kept rows are updated, not inserted. During a reload without a snapshot, the Collection keeps the retired run's rows. A new run that sends a row the Collection already holds must write
update, notinsert. Aninsertof a held key throwsDuplicateKeySyncError.Check: a reload of a ready Collection whose new run re-sends every row, with and without changes. No duplicate-key errors, and the rows end equal to the backend's.
Remote deletes during a reload are the adapter's job. The core does not sweep rows after a reload. An adapter that can see deletes must send them during or after the reload: a full snapshot (
begin({ snapshot: true })ortruncate()), a stream delete, or ownership, as query-db does. An adapter that cannot see them must document the limit.Check: delete a row at the backend while no stream listens, then reload, both eager and on-demand. Either the row is gone at reload completion, or the driver declares the limit and the suite reports the cell as skipped.
Full-state loads are snapshots. A run that sends the full state for the Collection's scope marks that transaction
begin({ snapshot: true })(or usestruncate()). Partial loads,loadSubsetresults, and metadata-only commits are never marked.Check: a mutant driver that marks a partial or resumed commit as a snapshot loses rows. A full-state adapter that does not mark its snapshot keeps rows the backend deleted.
Report the right verb.
reportError()while the source still tries, andreportHealthy()orreportHealthy(error)when it recovers.fail()when it stops.fail()other thanmarkReady()throw, andmarkReady()after the run is ready or failed is a no-op.Check: failure injection per kind gives the documented status and health, with no unhandled rejections.
Stop after cleanup. A retired run's late calls are fenced by the core, but the adapter must release its resources in its cleanup, and its cleanup must not reject for a remote failure. A rejected cleanup of a retired run crashes the Collection.
Check: a slow or failing remote release during a reload leaves the new run healthy.
Laws #2168 added that the suite should include
begin({ snapshot: true })ortruncate()) or itsmarkReady(); the rows then equal the backend's. In feat(db): split Collection status from sync errors; add reload() and cleanup admission #2168 several adapters failed this law before they were ported, and they were only ever tested fromerror.docs/contributing/oracle-coverage.mdunder the adapter receivers for reload, the abort receivers, and the Electric, persisted, PowerSync, RxDB, TrailBase, and query-db lanes.Shared eager live-source conformance oracle for sync adapters, and th…