diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 6f24434eec..92b78a24a4 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -274,7 +274,7 @@ comment and the current API/architecture contract before extending its model. | Reused sync row objects | [reused row witness](https://github.com/TanStack/db/blob/main/packages/db/tests/sync-reused-row.test.ts) | Focused witnesses for a sync source that changes a stored row object in place: in development, writing it again as an update without `previousValue` throws, while the same write with `previousValue`, or with a new object, moves the row out of an `eq` live query. Production skips the check. The check compares a shallow snapshot taken when the object was written, so it misses changes to nested fields; a write that names `previousValue` and a row whose fields throw on read are not checked. | | Index suggestions | [collection-size suggestion oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/index-suggestion-oracle.test.ts) | A public filtered live-query Collection checks manual/default, eager, threshold, matching-index, and unrelated-index cases against the documented collection-size suggestion policy. The original manual-silence/eager-noise implementation failed two expected observations. The owner does not judge repeated-query warning volume, slow-query timing, query work, production-build suppression, or every query shape. It runs in `@tanstack/db`'s `test:oracles` campaign. | | Minified DB public API | [consumer bundle check](https://github.com/TanStack/db/blob/main/scripts/test-minified-db.mjs) | CI builds `@tanstack/db`, then bundles its built `dist` entry with db-ivm inlined through esbuild `minify: true`. The build renames TypeScript-private members from `packages/db/mangle-cache.json`, so this lane runs against the renamed output that consumers install. It rejects a `dist` that is older than `src`, the cache, or the package's build configuration. `pnpm --filter @tanstack/db test:dist` also runs five db test files against the built `dist`, chosen for coverage of the modules with the most renamed members. `pnpm check:mangle` fails when a cached name is used other than as a private member in db src, is read by another package, appears as a string, or has a short name that collides with a source identifier ([private-member mangling review](oracle-reviews/code-weight-private-member-mangling.md)). It checks every exported error class's current public `name`, the built-in `BasicIndex` resolver name in index metadata and its event, complete query rows (after removing the four documented virtual fields) against an independent array filter/sort/projection, and one live update. The `new.target.name`, public-member mangle, and extra-output-field calibration modes fail at the intended observations. This fixed slice does not replace the unminified generated oracles, exercise framework adapters, or establish stable names for custom index resolvers. | -| Boundary refinements | [cleanup/restart](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-cleanup-restart-oracle.test.ts), [idle cleanup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-idle-cleanup.test.ts), [issue #1891 async-cleanup review](oracle-reviews/issue-1891-async-cleanup.md), [issue #1891 cleanup-start follow-up](oracle-reviews/issue-1891-cleanup-start-followup.md), [metadata publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [row metadata composition](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts), [nested-begin metadata rebuild review](oracle-reviews/2026-10-06-nested-begin-metadata-rebuild.md), [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. | +| Boundary refinements | [cleanup/restart](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-cleanup-restart-oracle.test.ts), [idle cleanup](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-idle-cleanup.test.ts), [issue #1891 async-cleanup review](oracle-reviews/issue-1891-async-cleanup.md), [issue #1891 cleanup-start follow-up](oracle-reviews/issue-1891-cleanup-start-followup.md), [metadata publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-metadata-publication-oracle.property.test.ts), [row metadata composition](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-row-metadata-composition-oracle.test.ts), [nested-begin metadata rebuild review](oracle-reviews/2026-10-06-nested-begin-metadata-rebuild.md), [state mutation round 4](oracle-reviews/2026-10-06-state-mutation-round-4.md), [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-oracle.test.ts), [cleanup queue](https://github.com/TanStack/db/blob/main/packages/db/tests/cleanup-queue-oracle.property.test.ts), [live-query GC clock](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-gc-clock.test.ts), [guarded replay](https://github.com/TanStack/db/blob/main/packages/db/tests/oracle-replay.test.ts) | Map/full-sort and elapsed appointment-list models with executed target/seed/path checks. SortedMap also checks deferred bulk writes, array-movement work, scan reuse, live iterators, runtime `undefined` keys, and public single-transaction and queued-transaction Collection sync paths. It does not settle the contract for comparator-observed values mutated after publication or every legal iterator/mutation interleaving. Wall-clock steps do not move due times, and a focused public witness checks source subscription release. Callback-reentrant scheduling, OS suspend/resume, environments without `performance.now()`, and fake/real Performance clock replacement while GC is pending remain outside this coverage. Tests must drain or reset pending GC before switching timer providers. | The [React source ID reuse oracle](https://github.com/TanStack/db/blob/main/packages/react-db/tests/source-id-reuse-oracle.test.tsx) ([review](oracle-reviews/issue-1991-react-source-id.md)) diff --git a/docs/contributing/oracle-reviews/2026-10-06-state-mutation-round-4.md b/docs/contributing/oracle-reviews/2026-10-06-state-mutation-round-4.md new file mode 100644 index 0000000000..4171a92d80 --- /dev/null +++ b/docs/contributing/oracle-reviews/2026-10-06-state-mutation-round-4.md @@ -0,0 +1,193 @@ +# State stack mutation round 4 + +Revisions: + +- Mutation campaign: the 53 mutants ran on `de8d0f1bf` (`main` after #2040). +- Witnesses: the first versions ran on `2376eb581` (`main` after #2048). The + first review's fixes ran on `d57be3d6f`, and its merge of `main` on + `5a24b9025`. +- Second review: its fixes are in `7bc58cfab`, on `5a24b9025`. +- Third review: its fixes are in the commit that adds this line, on + `7bc58cfab`. The X7 results ran on that commit. + +This branch changes no production code. + +## Method + +Round 4 applied 53 plausible maintenance mistakes to unchanged `main`: 23 in +`state.ts`, 15 in `sync.ts`, 6 in `mutations.ts`, 4 in `lifecycle.ts`, 3 in +`sync-receipt.ts`, and one each in the lazy `state` getters of +`live-query-observer.ts` and `live-query-window-controller.ts`. Each mutant +ran against the full `@tanstack/db` suite with `--bail=1`. Of the 53, 44 +failed the suite and 9 survived. Each survivor received a probe that runs on +`main`, a code argument, or full-suite instrumentation. + +Triage also found a bug on `main`. A sync transaction begun inside an open +one could commit first and make the rebuild lose last-write-wins row +metadata. #2048 fixes it, and its record corrects the round 3 claim that the +history was unreachable. + +## Survivors + +| Mutant | Change | Verdict | +| --- | --- | --- | +| SY8 | `write` stages writes into a transaction that a replay invalidated | Gap. Closed here. | +| SY13 | A stale sync run's `commit` returns `false` | Gap. Closed here. | +| X3 | A receipt with no acceptance moment counts as accepted at once | Gap. Closed here. | +| X4 | The observer snapshot builds a new `state` map on every read | Gap. Closed here. | +| X5 | The window snapshot copies `state` on every read | Gap. Closed here. | +| ST27 | Dropping a held row keeps its pending local origin | Equivalent. The branch is unreachable. See below. | +| ST16 | Recompute copies `rowOrigins` instead of keeping a reference | Equivalent by argument. Recompute writes no origin before it reads the reference. | +| ST17 | The origin snapshot stores `undefined` for keys without an origin | Equivalent. Every reader treats a missing origin as `remote`. | +| SY18 | A missing `loadSubset` returns `false` | Equivalent. The caller resolves a non-promise result the same way. | + +## Gaps closed here + +**Writes after invalidation (SY8).** After a replay invalidates the open +transaction, `write` returns early. Without that guard, a late write enters +the pending projection, and a newer transaction classifies its own inserts +against it. A probe on `main` showed the effect. A late insert of key 9 made +a newer, valid insert of key 9 throw `DuplicateKeySyncError`, and row 9 was +lost. Two witnesses in `collection-state-retention-oracle.property.test.ts` +write a late insert and a late delete. The newer transaction must see only +the accepted row. Both fail under SY8. + +**A stale run's commit (SY13).** The retention oracle's lifecycle driver +committed from an old run only while that run was still current. Now the old +run also writes and commits a row after cleanup ends it, at two points: after +a plain `restart`, and while a reentrant restart's new transaction is still +open. The commit must return `true`, and the row must not appear. The fixed +and random retention campaigns fail under SY13. A mutant that routes a stale +run's write into the new run's open transaction fails both pinned +`afterOldReturn` histories, because the row appears. After a plain restart no +transaction is open, so there a stale write fails only by throwing. + +**Acceptance of an abandoned receipt (X3).** A commit that core never +accepts, because its signal was aborted or a replay invalidated it, returns a +bare receipt with no acceptance moment. `whenSyncAccepted` must then wait for +that receipt, which rejects. The optimistic-history oracle's aborted batches +and the retention oracle's invalidation replay now check that the acceptance +moment rejects too. Under X3, the invalidation replay, the open-at-settlement +histories, and the fixed and random optimistic-history campaigns fail. X3 also +reaches every adapter that calls `whenSyncAccepted` on a core receipt: +SQLite persistence, Query DB, Electric, and PowerSync. Under X3 those callers +would treat an abandoned write as accepted. This review did not run their +suites under X3. The core witnesses above own the fallback rule. + +**State identity (X4, X5).** Before #2043, `state` was a field of the +snapshot. The lazy getter must keep that identity: one snapshot has one +`state` map. Consumers that compare by identity, such as a React dependency +list, rely on it. The snapshot oracle now checks that a second read of `data` +and `state` returns the first read's objects. Five of its six tests fail +under X4. The sixth is the calibration test, which replaces the `state` +getter. `data` is a plain field today, so its identity check has no +demonstrated kill. It guards a future lazy `data`. A witness in +`live-query-window-controller.test.ts` checks the window snapshot and fails +under both X4 and X5. + +The second review found X6, which shares one map per observer and refills it +on every read. Each read then looks right on its own, so X6 passed. Now the +oracle saves every map it reads and checks each one again after all reads. +Snapshots with different rows must also have different maps. The window +witness fetches a page and checks that the newer revision has its own map and +the older map is unchanged. Under X6, five of the six snapshot tests and the +window witness fail. + +A third review found X7, a single cache entry on the observer. It builds a +new map whenever a different snapshot is read, so a later read of an earlier +snapshot returns a new map with the same rows. X7 passed every check above. +Now the oracle reads each snapshot again after all reads and requires the map +that its first read returned. The window witness reads the older snapshot +again after the newer one. Under X7, the same six tests fail. + +## ST27: an unreachable branch + +`recomputeOptimisticState` keeps a completed optimistic row only while a +committed, queued sync transaction touches its key. It drops a held row whose +key no longer has such a transaction, and ST27 skips the origin delete in that +branch. A committed transaction leaves the queue only by applying, and the +commit path then clears the held row and the pending local origin for each of +its keys. A cleanup reset clears both maps. So no reached history takes the +branch. Full-suite instrumentation agrees. A log write in the branch fired 0 +times across 249 files and 8,578 tests. A log write at the loop entry fired 55 +times in two of those files. The branch is a code-weight candidate. Its +removal belongs with the cuts, with this argument as its evidence. + +## Other finding + +The window snapshot's `state` is the observer's `state`, so it includes the +peek-ahead row that `data` omits. `useLiveInfiniteQuery` in React returns that +map. This predates #2043. This review does not decide whether `state` should +match `data`. + +## Review + +A medium code review found eight items. Each is fixed here. + +1. A commit hook's ESLint run removed a type assertion from the snapshot + oracle, and `tsc` then failed. The setup now passes type arguments. +2. The record said X3 cannot reach the persistence packages. It can reach + every adapter that calls `whenSyncAccepted`. Corrected above. +3. The window witness did not clean up in a `finally` block. Fixed. +4. The stale-run row check could not fail on its own after a plain restart. + The driver now also writes from the ended run while a new transaction is + open, and a routing mutant fails there. +5. The `data` identity check has no demonstrated kill. Stated above. +6. Reentrant restarts never reached the stale commit. Fixed with item 4. +7. The snapshot oracle has six tests, not five. Corrected. +8. The SY8 witnesses did not check their premise. They now check that the + open transaction is invalid before the late write. + +## Second review + +A second code review, of `5a24b9025`, found two items. + +1. The record named the tested revision as "this branch on `2376eb581`", + but later commits changed the witnesses. Fixed: the revisions above name + each run. +2. X6, a map shared across snapshots, passed the identity checks. Fixed: the + saved-map and cross-revision checks under "State identity" above. + +## Third review + +A third review, of `7bc58cfab`, found one item. The saved-map checks did not +read a snapshot again after another snapshot was read, so X7 passed. Fixed: +the identity checks under "State identity" above. + +CodeRabbit then asked for the same recheck of `data`. The oracle now saves +each first read's `data` too and requires it on the later read. `data` is a +plain field today, so this check has no demonstrated kill. + +## ORC outcomes + +- **ORC-001: met.** SY8 follows the comment on the guard: a commit receipt + owns the invalidation, and later writes cannot revive it. SY13 follows + the stale-run rule in the retention oracle's opening prose, which now + states the commit result. X3 follows the `whenSyncAccepted` contract. X4 + and X5 follow the snapshot oracle's point-in-time contract, which now + states identity. +- **ORC-002: met.** The witnesses expect fixed outcomes or use the existing + independent models. +- **ORC-003: met.** Each change has prose beside its code. +- **ORC-004: met.** The `restart` and `reentrantRestart` actions are in the + existing generated grammar. Every plain restart and every `afterOldReturn` + reentrant restart reaches the stale commit. An `insideListener` reentrant + restart commits the new run inside the listener, so no open transaction + remains for a stale write to reach. The other changes add observations, + not grammar. +- **ORC-005: met.** Each witness drives a real Collection, observer, or + window controller and observes public results. +- **ORC-006: met.** Each closed mutant fails the tests named above. +- **ORC-007: met.** The retention and optimistic-history oracles run fixed + and random campaigns with seed and path replay. +- **ORC-008: not applicable.** No model gained state. +- **ORC-009: met.** "Acceptance moment" is the value `whenSyncAccepted` + returns. +- **ORC-010: met.** Each witness cleans up its Collection, live query, or + controller in a `finally` block. +- **ORC-011: not applicable.** +- **ORC-012: met by this record.** +- **ORC-013: met.** The SY8 witnesses cover both directions of the leak: one + makes a valid insert throw, and one lets a duplicate insert pass. Each + first checks that the replay already invalidated the open transaction. +- **ORC-014: not applicable.** 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 8b158d0d0d..c5b9034ee2 100644 --- a/packages/db/tests/collection-state-retention-oracle.property.test.ts +++ b/packages/db/tests/collection-state-retention-oracle.property.test.ts @@ -9,6 +9,7 @@ import { import { BTreeIndex } from '../src/indexes/btree-index.js' import { createDeferred } from '../src/deferred.js' import { createTransaction } from '../src/transactions.js' +import { whenSyncAccepted } from '../src/sync-receipt.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' import { runOptimisticHistory } from './optimistic-history-oracle.js' import type { OptimisticStep } from './optimistic-history-oracle.js' @@ -28,7 +29,10 @@ import type { SyncConfig, TransactionState } from '../src/types.js' * * 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. + * that vanish or reappear only after unrelated work observable. After a plain + * restart, and while a reentrant restart's new transaction is still open, the + * old run writes and commits a row. Cleanup ended that run, so the row never + * appears and the commit returns `true`. */ type RetainedRow = { @@ -181,6 +185,16 @@ function applyAction( expect(sync.commit()).toBe(true) } +/** + * Write and commit from a sync run that cleanup ended. Key 9 is outside the + * generated keys, so a leaked write shows as an extra row. + */ +function writeFromEndedRun(sync: SyncActions): void { + sync.begin() + sync.write({ type: `insert`, value: { id: 9, value: 9 } }) + expect(sync.commit(), `stale sync run commit`).toBe(true) +} + function expectRetainedState( collection: Collection, model: ReadonlyMap, @@ -215,9 +229,13 @@ async function runRetentionHistory( expectRetainedState(collection, model) for (const action of actions) { if (action.type === `restart`) { + const oldSync = harness.sync await collection.cleanup() collection.startSyncImmediate() model.clear() + // Cleanup ended the old run, so its retained actions are inert. Its + // commit returns `true`: the caller has nothing to wait for. + writeFromEndedRun(oldSync) } else if (action.type === `reentrantRestart`) { const oldSync = harness.sync const triggerType = model.has(action.row.id) ? `update` : `insert` @@ -295,6 +313,9 @@ async function runRetentionHistory( expect(restartedReceipt).toBeDefined() if (restartedReceipt !== true) await restartedReceipt } else { + // The new run's transaction is open here, so a stale write that + // reached it would apply with the new run's commit. + writeFromEndedRun(oldSync) expect(restartedSync.commit()).toBe(true) } const triggerRows = new Map(model) @@ -894,6 +915,10 @@ it(`rejects an open transaction that a later nested commit invalidates`, async ( const rejected = sync.commit() expect(rejected).toBeInstanceOf(Promise) await expect(rejected).rejects.toBeInstanceOf(DuplicateKeySyncError) + // Core never accepted the transaction, so its acceptance moment rejects too. + await expect( + Promise.resolve(whenSyncAccepted(rejected)), + ).rejects.toBeInstanceOf(DuplicateKeySyncError) expect(collection.get(1)?.value).toBe(2) expect(collection._state.pendingSyncedTransactions).toHaveLength(0) } finally { @@ -901,6 +926,71 @@ it(`rejects an open transaction that a later nested commit invalidates`, async ( } }) +// After a replay invalidates the open transaction, its later writes must not +// change what a newer transaction sees. Each write below would leak into the +// projection: an insert of key 9 would make the newer insert of key 9 a +// duplicate, and a delete of key 1 would let the newer insert of key 1 pass. +// The newer transaction sees only the accepted row 1, as if the invalidated +// transaction wrote nothing after its invalidation. +it.each([ + { + late: { type: `insert`, value: { id: 9, value: 1 } }, + newer: { id: 9, value: 3 }, + duplicate: false, + }, + { + late: { type: `delete`, key: 1 }, + newer: { id: 1, value: 3 }, + duplicate: true, + }, +] as const)( + `ignores writes to an invalidated transaction: late $late.type`, + async ({ late, newer, duplicate }) => { + let sync!: Parameters[`sync`]>[0] + const collection = createCollection({ + getKey: (row) => row.id, + startSync: true, + sync: { + sync: (actions) => { + sync = actions + actions.markReady() + }, + }, + }) + try { + await collection.stateWhenReady() + sync.begin() + sync.write({ type: `insert`, value: { id: 1, value: 1 } }) + sync.begin() + sync.write({ type: `update`, value: { id: 1, value: 2 } }) + expect(sync.commit()).toBe(true) + // The premise: the replay has already invalidated the open transaction. + expect( + collection._state.pendingSyncedTransactions.at(-1)?.invalidationError, + ).toBeInstanceOf(DuplicateKeySyncError) + sync.write(late) + sync.begin() + const write = () => sync.write({ type: `insert`, value: newer }) + if (duplicate) expect(write).toThrow(DuplicateKeySyncError) + else write() + expect(sync.commit()).toBe(true) + await expect(sync.commit()).rejects.toBeInstanceOf(DuplicateKeySyncError) + expect( + [...collection.state.values()].map(({ id, value }) => [id, value]), + ).toEqual( + duplicate + ? [[1, 2]] + : [ + [1, 2], + [newer.id, newer.value], + ], + ) + } finally { + await collection.cleanup() + } + }, +) + // No public path can make a committed queued transaction invalid on replay: // only the open last transaction can be canceled. A replay that finds one is // an invariant failure, not a recoverable rejection. diff --git a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts index e900f6006a..9e52a2051d 100644 --- a/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts +++ b/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts @@ -14,7 +14,9 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' * Collection changed and after newer snapshots were built. The contract: the * first read, in either order, shows the rows that were visible when the * snapshot was built. `data` lists the same rows as `state.values()`, in the - * same order. A single-result query keeps only the first such row: `state` + * same order. Every later read returns the same `data` and `state` objects as + * the first, and reading another snapshot never changes a map that an earlier + * read returned. A single-result query keeps only the first such row: `state` * holds that row, and `data` is that row or `undefined`. * * The model is a sorted map from key to version. Each history step changes the @@ -29,6 +31,7 @@ import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' | --- | --- | --- | --- | --- | | point-in-time first read | insert, update, delete, capture; keys a..d, versions 0..9 | observer getSnapshot -> rebuild | retained snapshot `data`/`state`, first read after all later writes | this generated oracle | | data/state agreement | the same histories | the same | `data` order equals `state.values()` order at the end | this generated oracle | +| stable identity | the same histories | the same | a second read of `data` and `state` returns the first read's objects | this generated oracle | | single result | the same histories over `findOne()` | isSingleResultCollection branch | `data` equals the first recorded row or `undefined` | this generated oracle | | attached and detached reads | a subscriber is present or absent | publication cache vs refreshDetachedState | the same observations | this generated oracle; listener delivery belongs to `live-query-observer-history-oracle.property.test.ts` | @@ -149,7 +152,7 @@ function checkRead( entry: Captured, dataFirst: boolean, label: string, -): void { +): Pick { let read: Pick if (dataFirst) { const data = entry.snapshot.data @@ -178,6 +181,10 @@ function checkRead( ...(read.state?.values() ?? []), ]) } + // A snapshot is one value: later reads return the same objects. + expect(entry.snapshot.state, `${label}: state identity`).toBe(read.state) + expect(entry.snapshot.data, `${label}: data identity`).toBe(read.data) + return read } async function checkHistory( @@ -192,12 +199,38 @@ async function checkHistory( reads.length > 0 ? reads : captured.map((_, i) => ({ snapshot: i, dataFirst: true })) - const seen = new Set() + const seen = new Map>() for (const { snapshot, dataFirst } of order) { const index = snapshot % captured.length if (seen.has(index)) continue - seen.add(index) - checkRead(shape, captured[index]!, dataFirst, `snapshot ${index}`) + seen.set( + index, + checkRead(shape, captured[index]!, dataFirst, `snapshot ${index}`), + ) + } + // Reading a later snapshot must not change a map an earlier read + // returned, and snapshots with different rows have different maps. + const keep = shape === `single` ? 1 : Infinity + for (const [index, { data, state }] of seen) { + // Every later read of a snapshot returns the objects its first read did. + expect( + captured[index]!.snapshot.state, + `snapshot ${index}: state identity after other reads`, + ).toBe(state) + expect( + captured[index]!.snapshot.data, + `snapshot ${index}: data identity after other reads`, + ).toBe(data) + const rows = captured[index]!.rows.slice(0, keep) + expect( + [...(state?.values() ?? [])].map(plain), + `snapshot ${index}: saved state rows`, + ).toEqual(rows) + for (const [other, { state: otherState }] of seen) { + const otherRows = captured[other]!.rows.slice(0, keep) + if (JSON.stringify(otherRows) !== JSON.stringify(rows)) + expect(otherState, `snapshots ${index} and ${other}`).not.toBe(state) + } } } finally { await cleanup() diff --git a/packages/db/tests/live-query-window-controller.test.ts b/packages/db/tests/live-query-window-controller.test.ts index cfa1a4a02c..2619c7817c 100644 --- a/packages/db/tests/live-query-window-controller.test.ts +++ b/packages/db/tests/live-query-window-controller.test.ts @@ -213,6 +213,41 @@ describe(`createLiveQueryWindowController`, () => { controller.dispose() }) + // A snapshot is one value. Code that compares `state` by identity, such as + // a React dependency list, must see one Map for one snapshot. + it(`returns the same state map on every read of one snapshot`, async () => { + const source = makeSource() + const lq = makeOrderedLiveQuery(source, 2) + const controller = createLiveQueryWindowController(lq as any, { + pageSize: 2, + }) + try { + controller.subscribe(() => {}) + await lq.preload() + await flush() + + const snap = controller.getSnapshot() + const state = snap.state + expect(state?.size).toBeGreaterThan(0) + expect(snap.state).toBe(state) + expect(controller.getSnapshot().state).toBe(state) + + // A newer revision has its own map and leaves the older one unchanged. + const keys = [...(state?.keys() ?? [])] + await controller.fetchNextPage() + await flush() + const next = controller.getSnapshot().state + expect([...(next?.keys() ?? [])]).not.toEqual(keys) + expect(next).not.toBe(state) + expect(snap.state).toBe(state) + expect([...(state?.keys() ?? [])]).toEqual(keys) + } finally { + controller.dispose() + await lq.cleanup() + await source.cleanup() + } + }) + it(`loads further pages via fetchNextPage until the source is exhausted`, async () => { const lq = makeOrderedLiveQuery(makeSource(), 2) const controller = createLiveQueryWindowController(lq as any, { diff --git a/packages/db/tests/optimistic-history-oracle.ts b/packages/db/tests/optimistic-history-oracle.ts index 371de093e2..69c253c732 100644 --- a/packages/db/tests/optimistic-history-oracle.ts +++ b/packages/db/tests/optimistic-history-oracle.ts @@ -50,7 +50,8 @@ import type { CollectionConfig, SyncConfig } from '../src/types.js' * A sync transaction still open when a transaction settles is not accepted: * it holds no completed row and attributes nothing. If it later commits, its * writes are `'remote'` unless a persisting transaction touches their key; if - * it aborts, its writes never apply. + * it aborts, its writes never apply, and both its receipt and its acceptance + * moment (`whenSyncAccepted`) reject. * * A mutation handler can write a sync transaction before it returns, and can * await that write's acceptance. It is the same event as a sync transaction @@ -1004,6 +1005,11 @@ export async function runOptimisticHistory( Promise, ) await expect(receipt).rejects.toMatchObject({ name: `AbortError` }) + // Core never accepted the batch, so its acceptance moment rejects too. + await expect( + Promise.resolve(whenSyncAccepted(receipt)), + `aborted open batch acceptance`, + ).rejects.toMatchObject({ name: `AbortError` }) counts.abortedBatches++ } } else {