From 844327fa72d99c5c903eff3d68c2b1a755b3c945 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 9 Oct 2026 11:43:08 -0600 Subject: [PATCH 01/14] fix(db): roll back a direct write whose subscriber throws A subscriber that threw while a direct write was admitted left its transaction pending: the handler never ran and the Collection kept the transaction. The write now rolls back its own transaction and rethrows. The transaction-ownership oracle gains a direct-write step; on main both campaigns fail with an orphaned tracked transaction. Co-authored-by: Isaac --- .../fix-direct-write-subscriber-throw.md | 5 +++ docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 13 +++++- ...nsaction-ownership-oracle.property.test.ts | 45 ++++++++++++++++++- 4 files changed, 61 insertions(+), 4 deletions(-) create mode 100644 .changeset/fix-direct-write-subscriber-throw.md diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md new file mode 100644 index 0000000000..3a4fedd9b3 --- /dev/null +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db': patch +--- + +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index f7128fb41e..952af73c61 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Open: two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection; and direct writes (`commitDirect`) with a throwing subscriber leave an orphaned pending transaction on `main` and on this branch, a settlement law still under review ([targeted round](oracle-reviews/failed-transaction-retention.md#targeted-review-head-9e7ebb0a8)). | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Open: two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. Its grammar also has direct writes (`collection.update` outside a transaction), optionally with a throwing subscriber: a write whose admission throws rolls back only its own transaction and rethrows, and no Collection keeps it. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index c011068f57..7d7b2d3b00 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -271,7 +271,18 @@ export class CollectionMutationsManager< >, }), }) - this.applyOwnedMutations(transaction, mutations) + try { + this.applyOwnedMutations(transaction, mutations) + } catch (error) { + // A subscriber threw before the handler could run. Roll back only this + // write, so no Collection keeps a transaction that nothing will settle. + try { + transaction.rollback({ isSecondaryRollback: true }) + } catch { + // The admission error is the reported cause. + } + throw error + } // Errors still reject `isPersisted.promise`. This catch only prevents an // unhandled rejection from the fire-and-forget commit. transaction.commit().catch(() => undefined) diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index 8289d93e3c..cc30e60d5e 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -45,6 +45,13 @@ import type { Transaction } from '../src/transactions.js' * transaction. * - A failed `mutate()` callback undoes its writes, but the Collection that * took one still tracked the transaction. + * - A direct write (`collection.update` outside a transaction) runs in its own + * transaction, which settles within its step: its handler resolves at once + * and writes no sync row, so afterwards it shows nothing and no Collection + * tracks it. A subscriber that throws while the write is admitted makes the + * write fail: the call rethrows that error, the write's transaction rolls + * back without rolling back other transactions, and no Collection keeps + * it. So a direct write never changes the model. * - Error shape: a settling call that ran no throwing subscriber reports no * subscriber error. Otherwise it throws one of the subscriber errors, as * is. The contract does not say which one, because these failures are rare @@ -57,7 +64,9 @@ import type { Transaction } from '../src/transactions.js' * state allows that step, so generated steps rarely skip. Steps write a value, write and remove key 9 in one call * (a pair that merges away), commit, settle a commit as success or failure, * roll back, roll back from a `truncate` listener during a sync commit, and - * run a `mutate()` callback that writes and then throws. A settling step may + * run a `mutate()` callback that writes and then throws. A direct write + * updates a key outside any transaction, optionally with a throwing + * subscriber on its Collection. A settling step may * install a throwing subscriber on one Collection or on both. It stays * installed until promises flush, because a commit settles in a later * microtask, and it must have run whenever the model predicts that the step @@ -107,6 +116,13 @@ type Step = key: Key value: number } + | { + type: `direct` + on: CollectionName + key: Key + value: number + throws: boolean + } type ModelState = `pending` | `persisting` | `completed` | `failed` type ModelTx = { @@ -246,6 +262,16 @@ const step: fc.Arbitrary = fc.oneof( on: name, }), }, + { + weight: 2, + arbitrary: fc.record({ + type: fc.constant(`direct` as const), + on: name, + key: fc.constantFrom(1, 2), + value: fc.integer({ min: 1, max: 9 }), + throws: fc.boolean(), + }), + }, { weight: 1, arbitrary: fc.record({ @@ -306,7 +332,9 @@ async function makeCollection(id: string) { id: number v: number }>({ id, getKey: (row) => row.id, startSync: true }) - const collection = createCollection(options) + // A direct write's handler resolves at once, so its transaction settles + // within the step instead of waiting for a sync commit. + const collection = createCollection({ ...options, onUpdate: async () => {} }) const writeBase = () => { options.utils.write({ type: `insert`, value: { id: 1, v: 0 } }) options.utils.write({ type: `insert`, value: { id: 2, v: 0 } }) @@ -453,6 +481,19 @@ async function runHistory(steps: ReadonlyArray): Promise { model.open( reuse === undefined ? undefined : model.transactions[reuse]!.id, ) + } else if (current.type === `direct`) { + // An update that leaves the visible row unchanged writes nothing. + if (model.row(current.on, current.key) === current.value) continue + const { result, raised } = await withThrowingSubscribers( + label, + current.throws ? current.on : undefined, + () => + collections[current.on].collection.update(current.key, (draft) => { + draft.v = current.value + }), + () => {}, + ) + expectErrorShape(`${label} direct write`, result, raised) } else { const states: ReadonlyArray = current.type === `edit` || From 74630f6b3dcf446c3a85650acc481d6ca1fa7794 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 9 Oct 2026 14:14:25 -0600 Subject: [PATCH 02/14] fix(db): settle a local-only direct write whose subscriber throws The local-only direct path stores its rows when it publishes them, so a throwing subscriber cannot undo the write, but the transaction stayed pending. It now completes and the error is rethrown. The non-local rollback passes the admission error as its cause. The ownership oracle's direct step now covers insert, update and delete, captures the direct write's own transaction, requires it to settle, and requires an insert's or update's throwing subscriber to reach the admission. A hostile mutant that removes the transaction from the Collection without settling it now fails. Co-authored-by: Isaac --- .../fix-direct-write-subscriber-throw.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 16 ++- .../local-only-direct-write-oracle.test.ts | 37 +++++++ ...nsaction-ownership-oracle.property.test.ts | 104 +++++++++++++++--- 5 files changed, 138 insertions(+), 23 deletions(-) diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md index 3a4fedd9b3..281427d4aa 100644 --- a/.changeset/fix-direct-write-subscriber-throw.md +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 952af73c61..2dfeb1eabd 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Open: two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. Its grammar also has direct writes (`collection.update` outside a transaction), optionally with a throwing subscriber: a write whose admission throws rolls back only its own transaction and rethrows, and no Collection keeps it. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber; it captures each direct write's own transaction and requires it to settle. A write whose admission throws rolls back only its own transaction and rethrows. The [local-only direct-write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) check that a local-only direct write whose subscriber throws still completes. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 7d7b2d3b00..7e5fbf3c25 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -219,9 +219,14 @@ export class CollectionMutationsManager< mutationFn: () => Promise.resolve(), }) transaction.applyMutations(mutations) - direct.write(mutations) - transaction.setState(`completed`) - transaction.isPersisted.resolve(transaction) + // The rows are stored when `write` publishes, so a throwing subscriber + // cannot undo them; the transaction still settles. + try { + direct.write(mutations) + } finally { + transaction.setState(`completed`) + transaction.isPersisted.resolve(transaction) + } return transaction } @@ -277,7 +282,10 @@ export class CollectionMutationsManager< // A subscriber threw before the handler could run. Roll back only this // write, so no Collection keeps a transaction that nothing will settle. try { - transaction.rollback({ isSecondaryRollback: true }) + transaction.rollback({ + isSecondaryRollback: true, + error: error instanceof Error ? error : undefined, + }) } catch { // The admission error is the reported cause. } diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index bea6f239f6..354d56a9f8 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -138,6 +138,43 @@ describe(`local-only direct writes`, () => { expect(orders.get(1)?.value).toBe(`a`) }) + // The direct write stores its rows when it publishes them, so a throwing + // subscriber cannot undo the write. The call rethrows, and the transaction + // still completes instead of staying pending. + it(`completes a direct write whose subscriber throws`, async () => { + const orders = createOrders() + const created: Array<{ + state: string + isPersisted: { promise: Promise } + }> = [] + const manager = ( + orders as unknown as { + _mutations: { createTransaction: (...args: Array) => any } + } + )._mutations + const createTransactionFor = manager.createTransaction + manager.createTransaction = (...args) => { + const transaction = createTransactionFor.apply(manager, args) + created.push(transaction) + return transaction + } + const failure = new Error(`subscriber failed`) + const subscription = orders.subscribeChanges(() => { + throw failure + }) + expect(() => orders.insert({ id: 2, value: `b` })).toThrow(failure) + subscription.unsubscribe() + manager.createTransaction = createTransactionFor + + expect(orders.get(2)?.value).toBe(`b`) + expect(created.map((transaction) => transaction.state)).toEqual([ + `completed`, + ]) + await expect(created[0]!.isPersisted.promise).resolves.toBe(created[0]) + // The next direct write still takes the direct path. + expect(orders.insert({ id: 3, value: `c` }).state).toBe(`completed`) + }) + it(`runs the user's handler for that operation type`, async () => { let calls = 0 const orders = createOrders({ diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index cc30e60d5e..e4164711f4 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -45,13 +45,14 @@ import type { Transaction } from '../src/transactions.js' * transaction. * - A failed `mutate()` callback undoes its writes, but the Collection that * took one still tracked the transaction. - * - A direct write (`collection.update` outside a transaction) runs in its own - * transaction, which settles within its step: its handler resolves at once - * and writes no sync row, so afterwards it shows nothing and no Collection - * tracks it. A subscriber that throws while the write is admitted makes the - * write fail: the call rethrows that error, the write's transaction rolls - * back without rolling back other transactions, and no Collection keeps - * it. So a direct write never changes the model. + * - A direct write (`insert`, `update` or `delete` outside a transaction) runs + * in its own transaction, which settles within its step: its handler + * resolves at once and writes no sync row, so afterwards it shows nothing + * and no Collection tracks it. A subscriber that throws while the write is + * admitted makes the write fail: the call rethrows that error, the write's + * transaction rolls back without rolling back other transactions, and no + * Collection keeps it. Either way the write's own transaction has settled, + * with its `isPersisted`. So a direct write never changes the model. * - Error shape: a settling call that ran no throwing subscriber reports no * subscriber error. Otherwise it throws one of the subscriber errors, as * is. The contract does not say which one, because these failures are rare @@ -65,8 +66,9 @@ import type { Transaction } from '../src/transactions.js' * (a pair that merges away), commit, settle a commit as success or failure, * roll back, roll back from a `truncate` listener during a sync commit, and * run a `mutate()` callback that writes and then throws. A direct write - * updates a key outside any transaction, optionally with a throwing - * subscriber on its Collection. A settling step may + * inserts key 3, or updates or deletes key 1 or 2, outside any transaction, + * optionally with a throwing subscriber on its Collection. The driver + * captures the transaction each direct write creates. A settling step may * install a throwing subscriber on one Collection or on both. It stays * installed until promises flush, because a commit settles in a later * microtask, and it must have run whenever the model predicts that the step @@ -118,6 +120,7 @@ type Step = } | { type: `direct` + op: `insert` | `update` | `delete` on: CollectionName key: Key value: number @@ -266,6 +269,11 @@ const step: fc.Arbitrary = fc.oneof( weight: 2, arbitrary: fc.record({ type: fc.constant(`direct` as const), + op: fc.constantFrom( + `insert` as const, + `update` as const, + `delete` as const, + ), on: name, key: fc.constantFrom(1, 2), value: fc.integer({ min: 1, max: 9 }), @@ -334,7 +342,12 @@ async function makeCollection(id: string) { }>({ id, getKey: (row) => row.id, startSync: true }) // A direct write's handler resolves at once, so its transaction settles // within the step instead of waiting for a sync commit. - const collection = createCollection({ ...options, onUpdate: async () => {} }) + const collection = createCollection({ + ...options, + onInsert: async () => {}, + onUpdate: async () => {}, + onDelete: async () => {}, + }) const writeBase = () => { options.utils.write({ type: `insert`, value: { id: 1, v: 0 } }) options.utils.write({ type: `insert`, value: { id: 2, v: 0 } }) @@ -439,6 +452,7 @@ async function runHistory(steps: ReadonlyArray): Promise { ) } expect(collection.has(9), `${label}: ${on} merged-away row`).toBe(false) + expect(collection.has(3), `${label}: ${on} direct insert`).toBe(false) } for (const [index, entry] of driven.entries()) { const settled = !model.unsettled(model.transactions[index]!) @@ -482,18 +496,74 @@ async function runHistory(steps: ReadonlyArray): Promise { reuse === undefined ? undefined : model.transactions[reuse]!.id, ) } else if (current.type === `direct`) { - // An update that leaves the visible row unchanged writes nothing. - if (model.row(current.on, current.key) === current.value) continue + // A direct update that leaves the visible row unchanged writes nothing. + if ( + current.op === `update` && + model.row(current.on, current.key) === current.value + ) + continue + const { collection } = collections[current.on] + const created: Array> = [] + const manager = ( + collection as unknown as { + _mutations: { createTransaction: (...args: Array) => any } + } + )._mutations + const createTransactionFor = manager.createTransaction + manager.createTransaction = (...args) => { + const tx = createTransactionFor.apply(manager, args) + created.push(tx) + return tx + } + const settled = new Set>() const { result, raised } = await withThrowingSubscribers( label, current.throws ? current.on : undefined, - () => - collections[current.on].collection.update(current.key, (draft) => { - draft.v = current.value - }), + () => { + try { + if (current.op === `insert`) + collection.insert({ id: 3, v: current.value }) + else if (current.op === `delete`) collection.delete(current.key) + else + collection.update(current.key, (draft) => { + draft.v = current.value + }) + } finally { + manager.createTransaction = createTransactionFor + for (const tx of created) + tx.isPersisted.promise.then( + () => settled.add(tx), + () => settled.add(tx), + ) + } + }, () => {}, ) - expectErrorShape(`${label} direct write`, result, raised) + // The throwing subscriber must reach the admission of an insert or an + // update, so the write fails rather than passing unnoticed. A + // subscriber is not told of a delete of a row it was never sent, so + // a delete's throwing subscriber runs only when the settled delete + // shows the row again, and the write itself succeeds. + if (current.throws && current.op !== `delete`) { + expect( + raised.length, + `${label}: throwing subscriber ran`, + ).toBeGreaterThan(0) + expect(result.ok, `${label}: direct write rejected`).toBe(false) + } + if (current.throws && current.op === `delete`) + expect(result.ok, `${label}: direct delete succeeds`).toBe(true) + else expectErrorShape(`${label} direct write`, result, raised) + expect(created.length, `${label}: one direct transaction`).toBe(1) + for (const tx of created) { + expect( + tx.state === `completed` || tx.state === `failed`, + `${label}: direct transaction settled`, + ).toBe(true) + expect(settled.has(tx), `${label}: direct isPersisted settled`).toBe( + true, + ) + } } else { const states: ReadonlyArray = current.type === `edit` || From 7d36ae5879075ed656a5a87808a975ee194cbc22 Mon Sep 17 00:00:00 2001 From: Isaac Date: Fri, 9 Oct 2026 15:08:15 -0600 Subject: [PATCH 03/14] test(db): generate throwing subscribers in the change-event history oracle Generated local-only histories now let a subscriber, registered after the mirror, throw while one direct write publishes. The call must rethrow with its transaction already completed, and the rows and mirror must follow the model. On main the property fails at the first such write: the transaction stays pending. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/change-event-history-oracle.test.ts | 74 +++++++++++++++++-- 2 files changed, 70 insertions(+), 6 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 2dfeb1eabd..51156e7949 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber; it captures each direct write's own transaction and requires it to settle. A write whose admission throws rolls back only its own transaction and rethrows. The [local-only direct-write witnesses](https://github.com/TanStack/db/blob/main/packages/db/tests/local-only-direct-write-oracle.test.ts) check that a local-only direct write whose subscriber throws still completes. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber; it captures each direct write's own transaction and requires it to settle. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index b1711075aa..c1a4415e77 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -47,6 +47,13 @@ import type { ChangeMessage, SyncConfig } from '../src/types' * repeated changes and complete row values, while leaving cross-key * interleaving unconstrained. * + * About one generated history in three meets a second subscriber, registered + * after the mirror, that throws while one operation publishes. A local-only + * direct write stores its rows as it publishes, so the call rethrows, its + * transaction is already completed, and the rows and mirror still follow the + * model. Whether a throwing subscriber may stop a later subscriber of the same + * publication is not decided here; the throwing subscriber is always last. + * * This partial oracle does not cover failed persistence, sync-transaction * cancellation, general callback batch shape, callback-time index agreement, or * publications without change messages. @@ -84,6 +91,8 @@ type GeneratedHistory = { initialKeys: ReadonlyArray sequence: ReadonlyArray mode: `batched` | `sequential` + /** The operation whose publication meets a throwing subscriber. */ + throwOn?: number } type GeneratedStep = { key: Key; deleteWhenPresent: boolean } @@ -359,6 +368,7 @@ async function runHistory( mode: `batched` | `sequential`, id: string, indexType?: IndexType, + throwOn?: number, ): Promise { const initialRows = [...expectedRowsAfter(initialKeys, [])].map( ([, row]) => row, @@ -436,9 +446,56 @@ async function runHistory( ? collection.subscribeChanges(onChanges, { includeInitialState: false }) : collection.subscribeChanges(onChanges) + // A second subscriber, after the mirror, throws while one operation + // publishes. A local-only direct write stores its rows as it publishes + // them, so the throw cannot undo the write: the call rethrows, and its + // transaction still settles. The rows and the mirror follow the model. + const injected = new Error(`subscriber failed`) + let armed = false + const throwing = + throwOn === undefined + ? undefined + : collection.subscribeChanges( + () => { + if (armed) throw injected + }, + { includeInitialState: true }, + ) + const created: Array<{ isPersisted: { promise: Promise } }> = [] + const manager = ( + collection as unknown as { + _mutations: { createTransaction: (...args: Array) => any } + } + )._mutations + const createTransactionFor = manager.createTransaction + manager.createTransaction = (...args) => { + const transaction = createTransactionFor.apply(manager, args) + created.push(transaction) + return transaction + } + const applyAt = (op: Op, operationIndex: number) => { + if (operationIndex !== throwOn) return applyOp(collection, op) + armed = true + const before = created.length + try { + applyOp(collection, op) + } catch (error) { + expect(error, `operation ${operationIndex} rethrows`).toBe(injected) + expect(created.length, `one transaction`).toBe(before + 1) + expect( + (created[before] as unknown as { state: string }).state, + `operation ${operationIndex} settles`, + ).toBe(`completed`) + return created[before]! + } finally { + armed = false + } + throw new Error(`operation ${operationIndex} did not meet the throw`) + } + if (mode === `batched`) { const transactions = sequence.map((op, operationIndex) => { - const transaction = applyOp(collection, op) + const transaction = applyAt(op, operationIndex) if (index) { const expected = expectedRowsAfter( initialKeys, @@ -455,8 +512,8 @@ async function runHistory( if (index) assertIndexAgreement(index, expected) } else { const settled: Array = [] - for (const op of sequence) { - await settle(applyOp(collection, op)) + for (const [operationIndex, op] of sequence.entries()) { + await settle(applyAt(op, operationIndex)) settled.push(op) const expected = expectedRowsAfter(initialKeys, settled) assertFinalAgreement(collection, mirror, expected) @@ -473,6 +530,8 @@ async function runHistory( for (const publication of publications) { expect(publication.mirrorRows).toEqual(publication.publicRows) } + throwing?.unsubscribe() + manager.createTransaction = createTransactionFor expect(protocolViolations, `change-message protocol`).toEqual([]) } catch (error) { primaryFailure = error @@ -509,12 +568,15 @@ async function verifyGeneratedHistory({ initialKeys, sequence, mode, + throwOn, }: GeneratedHistory): Promise { await runHistory( sequence, initialKeys, mode, `history-oracle-generated-${initialKeys.join(``)}-${mode}-${describeSequence(sequence)}`, + undefined, + throwOn === undefined ? undefined : throwOn % sequence.length, ) } @@ -660,10 +722,12 @@ describe(`change-event history oracle`, () => { { minLength: 1, maxLength: 20 }, ), mode: fc.constantFrom(`batched` as const, `sequential` as const), + // About one history in three meets a throwing subscriber. + throwOn: fc.option(fc.nat(19), { nil: undefined, freq: 2 }), }) - .map(({ initialKeys, steps, mode }) => { + .map(({ initialKeys, steps, mode, throwOn }) => { const sequence = opsFromPresence(initialKeys, steps, 1) - return { initialKeys, sequence, mode } + return { initialKeys, sequence, mode, throwOn } }) // The fixed prefix forces reused keys through an already-built index. From e56531941879add825b296f47d48fc69ad2b57ee Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 10 Oct 2026 09:36:12 -0600 Subject: [PATCH 04/14] test(db): observe direct-write handlers, mirrors and delete rollbacks The review's mutants that commit a rejected direct write, or roll it back without publishing the revert, both passed the ownership oracle: it never watched handlers or a subscriber. It now counts handler calls (none for a rejected write, one otherwise), requires a rejected write's transaction to fail, and keeps a mirror subscriber on each Collection that must match the rows after every step. A throwing subscriber may now have been sent the rows first, which generates the rollback of a direct delete. Both mutants fail. The transaction-capture hook used by three oracles is one typed helper. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/change-event-history-oracle.test.ts | 17 +-- .../local-only-direct-write-oracle.test.ts | 20 +--- ...nsaction-ownership-oracle.property.test.ts | 101 ++++++++++++------ packages/db/tests/utils.ts | 32 ++++++ 5 files changed, 110 insertions(+), 62 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 51156e7949..6ea9076b15 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber; it captures each direct write's own transaction and requires it to settle. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write made from inside another Collection's change subscriber has its subscriber error deferred to the outer publication, so it commits while the outer write rolls back (a law question for the maintainer); a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index c1a4415e77..b948257149 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -14,6 +14,7 @@ import type { LocalOnlyCollectionUtils } from '../src/local-only' import type { Collection } from '../src/index' import type { BaseIndex } from '../src/indexes/base-index' import type { ChangeMessage, SyncConfig } from '../src/types' +import { captureCreatedTransactions } from './utils' /** * # Do settled change messages reconstruct the Collection's public rows? @@ -461,18 +462,8 @@ async function runHistory( }, { includeInitialState: true }, ) - const created: Array<{ isPersisted: { promise: Promise } }> = [] - const manager = ( - collection as unknown as { - _mutations: { createTransaction: (...args: Array) => any } - } - )._mutations - const createTransactionFor = manager.createTransaction - manager.createTransaction = (...args) => { - const transaction = createTransactionFor.apply(manager, args) - created.push(transaction) - return transaction - } + const { created, restore: restoreCreate } = + captureCreatedTransactions(collection) const applyAt = (op: Op, operationIndex: number) => { if (operationIndex !== throwOn) return applyOp(collection, op) armed = true @@ -531,7 +522,7 @@ async function runHistory( expect(publication.mirrorRows).toEqual(publication.publicRows) } throwing?.unsubscribe() - manager.createTransaction = createTransactionFor + restoreCreate() expect(protocolViolations, `change-message protocol`).toEqual([]) } catch (error) { primaryFailure = error diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index 354d56a9f8..443da4e893 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -4,6 +4,7 @@ import { createCollection } from '../src/index' import { localOnlyCollectionOptions } from '../src/local-only' import { createTransaction } from '../src/transactions' import { createDeferred } from '../src/deferred' +import { captureCreatedTransactions } from './utils' /** * # When does a local-only direct write skip the optimistic stage? @@ -143,28 +144,15 @@ describe(`local-only direct writes`, () => { // still completes instead of staying pending. it(`completes a direct write whose subscriber throws`, async () => { const orders = createOrders() - const created: Array<{ - state: string - isPersisted: { promise: Promise } - }> = [] - const manager = ( - orders as unknown as { - _mutations: { createTransaction: (...args: Array) => any } - } - )._mutations - const createTransactionFor = manager.createTransaction - manager.createTransaction = (...args) => { - const transaction = createTransactionFor.apply(manager, args) - created.push(transaction) - return transaction - } + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) const failure = new Error(`subscriber failed`) const subscription = orders.subscribeChanges(() => { throw failure }) expect(() => orders.insert({ id: 2, value: `b` })).toThrow(failure) subscription.unsubscribe() - manager.createTransaction = createTransactionFor + restoreCreate() expect(orders.get(2)?.value).toBe(`b`) expect(created.map((transaction) => transaction.state)).toEqual([ diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index e4164711f4..7c23a54102 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -4,7 +4,10 @@ import { createCollection } from '../src/collection/index.js' import { createTransaction } from '../src/transactions.js' import { DuplicateTransactionIdError } from '../src/errors.js' import { oraclePropertyOptions, oracleRuns } from './oracle-config.js' -import { mockSyncCollectionOptionsNoInitialState } from './utils.js' +import { + captureCreatedTransactions, + mockSyncCollectionOptionsNoInitialState, +} from './utils.js' import type { Transaction } from '../src/transactions.js' /** @@ -125,6 +128,8 @@ type Step = key: Key value: number throws: boolean + /** The throwing subscriber was sent the rows before the write. */ + seen: boolean } type ModelState = `pending` | `persisting` | `completed` | `failed` @@ -278,6 +283,7 @@ const step: fc.Arbitrary = fc.oneof( key: fc.constantFrom(1, 2), value: fc.integer({ min: 1, max: 9 }), throws: fc.boolean(), + seen: fc.boolean(), }), }, { @@ -342,11 +348,17 @@ async function makeCollection(id: string) { }>({ id, getKey: (row) => row.id, startSync: true }) // A direct write's handler resolves at once, so its transaction settles // within the step instead of waiting for a sync commit. + // Each handler call counts, so a rolled-back direct write can be told from + // one that reached its handler. + const handled = { calls: 0 } + const handler = async () => { + handled.calls++ + } const collection = createCollection({ ...options, - onInsert: async () => {}, - onUpdate: async () => {}, - onDelete: async () => {}, + onInsert: handler, + onUpdate: handler, + onDelete: handler, }) const writeBase = () => { options.utils.write({ type: `insert`, value: { id: 1, v: 0 } }) @@ -357,7 +369,19 @@ async function makeCollection(id: string) { options.utils.commit() options.utils.markReady() await collection.stateWhenReady() - return { collection, utils: options.utils, writeBase } + // A mirror subscriber applies every change message, so a rollback that + // restores rows without publishing the revert leaves it behind. + const mirror = new Map() + collection.subscribeChanges( + (changes) => { + for (const change of changes) { + if (change.type === `delete`) mirror.delete(change.key as number) + else mirror.set(change.key as number, change.value.v) + } + }, + { includeInitialState: true }, + ) + return { collection, utils: options.utils, writeBase, handled, mirror } } /** Runs one history against the model and two Collections. */ @@ -387,20 +411,29 @@ async function runHistory(steps: ReadonlyArray): Promise { throwOn: ThrowOn, run: () => void, applyModel: () => void, + seen = false, ) => { const targets: Array = throwOn === `both` ? [`A`, `B`] : throwOn ? [throwOn] : [] const before = targets.map((on) => JSON.stringify(rows(on))) const raised: Array = [] const calls = new Map() + // A subscriber that was sent the rows first is told of every later + // change, including a delete; it throws only once armed. + let armed = false const subscriptions = targets.map((on) => - collections[on].collection.subscribeChanges(() => { - calls.set(on, (calls.get(on) ?? 0) + 1) - const error = new Error(`subscriber on ${on} failed`) - raised.push(error) - throw error - }), + collections[on].collection.subscribeChanges( + () => { + if (!armed) return + calls.set(on, (calls.get(on) ?? 0) + 1) + const error = new Error(`subscriber on ${on} failed`) + raised.push(error) + throw error + }, + seen ? { includeInitialState: true } : undefined, + ), ) + armed = true let result: { ok: true } | { ok: false; error: unknown } = { ok: true } try { run() @@ -453,6 +486,11 @@ async function runHistory(steps: ReadonlyArray): Promise { } expect(collection.has(9), `${label}: ${on} merged-away row`).toBe(false) expect(collection.has(3), `${label}: ${on} direct insert`).toBe(false) + const { mirror } = collections[on] + for (const key of [1, 2, 3, 9] as const) + expect(mirror.get(key), `${label}: ${on} mirror of row ${key}`).toBe( + collection.get(key)?.v, + ) } for (const [index, entry] of driven.entries()) { const settled = !model.unsettled(model.transactions[index]!) @@ -503,19 +541,10 @@ async function runHistory(steps: ReadonlyArray): Promise { ) continue const { collection } = collections[current.on] - const created: Array> = [] - const manager = ( - collection as unknown as { - _mutations: { createTransaction: (...args: Array) => any } - } - )._mutations - const createTransactionFor = manager.createTransaction - manager.createTransaction = (...args) => { - const tx = createTransactionFor.apply(manager, args) - created.push(tx) - return tx - } + const { created, restore: restoreCreate } = + captureCreatedTransactions(collection) const settled = new Set>() + const handledBefore = collections[current.on].handled.calls const { result, raised } = await withThrowingSubscribers( label, current.throws ? current.on : undefined, @@ -529,7 +558,7 @@ async function runHistory(steps: ReadonlyArray): Promise { draft.v = current.value }) } finally { - manager.createTransaction = createTransactionFor + restoreCreate() for (const tx of created) tx.isPersisted.promise.then( () => settled.add(tx), @@ -538,28 +567,36 @@ async function runHistory(steps: ReadonlyArray): Promise { } }, () => {}, + current.seen, ) // The throwing subscriber must reach the admission of an insert or an // update, so the write fails rather than passing unnoticed. A // subscriber is not told of a delete of a row it was never sent, so - // a delete's throwing subscriber runs only when the settled delete - // shows the row again, and the write itself succeeds. - if (current.throws && current.op !== `delete`) { + // such a delete's throwing subscriber runs only when the settled + // delete shows the row again, and the write itself succeeds. + const rejected = + current.throws && (current.op !== `delete` || current.seen) + if (rejected) { expect( raised.length, `${label}: throwing subscriber ran`, ).toBeGreaterThan(0) expect(result.ok, `${label}: direct write rejected`).toBe(false) } - if (current.throws && current.op === `delete`) + if (current.throws && !rejected) expect(result.ok, `${label}: direct delete succeeds`).toBe(true) else expectErrorShape(`${label} direct write`, result, raised) + // A rejected write rolls back before its handler; an accepted one + // reaches its handler exactly once. + expect( + collections[current.on].handled.calls - handledBefore, + `${label}: handler calls`, + ).toBe(rejected ? 0 : 1) expect(created.length, `${label}: one direct transaction`).toBe(1) for (const tx of created) { - expect( - tx.state === `completed` || tx.state === `failed`, - `${label}: direct transaction settled`, - ).toBe(true) + expect(tx.state, `${label}: direct transaction settled`).toBe( + rejected ? `failed` : `completed`, + ) expect(settled.has(tx), `${label}: direct isPersisted settled`).toBe( true, ) diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 15883824cb..5c4589de20 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -12,6 +12,7 @@ import type { } from '../src/index.js' import type { IndexConstructor } from '../src/indexes/base-index' import type { WithVirtualProps } from '../src/virtual-props.js' +import type { Transaction } from '../src/transactions.js' export type OutputWithVirtual< T extends object, @@ -678,3 +679,34 @@ export function resetCleanupQueue(): void { clearTimeout(holder.instance.timeoutId) holder.instance = null } + +/** + * Record each transaction a Collection's mutations manager creates, such as + * the one a direct write makes for itself, which the call may not return when + * it throws. Call `restore` when done. + */ +export function captureCreatedTransactions(collection: object): { + created: Array> + restore: () => void +} { + const manager = ( + collection as { + _mutations: { + createTransaction: (...args: Array) => Transaction + } + } + )._mutations + const createTransaction = manager.createTransaction + const created: Array> = [] + manager.createTransaction = (...args) => { + const transaction = createTransaction.apply(manager, args) + created.push(transaction) + return transaction + } + return { + created, + restore: () => { + manager.createTransaction = createTransaction + }, + } +} From 9f5a064c363f618050e30fdd854f45f63c7848e0 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 10 Oct 2026 09:46:06 -0600 Subject: [PATCH 05/14] test(db): order the utils import in the change-event oracle Co-authored-by: Isaac --- packages/db/tests/change-event-history-oracle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index b948257149..081d6f2cad 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -10,11 +10,11 @@ import { import { localOnlyCollectionOptions } from '../src/local-only' import { PropRef } from '../src/query/ir' import { oraclePropertyOptions, oracleRuns } from './oracle-config' +import { captureCreatedTransactions } from './utils' import type { LocalOnlyCollectionUtils } from '../src/local-only' import type { Collection } from '../src/index' import type { BaseIndex } from '../src/indexes/base-index' import type { ChangeMessage, SyncConfig } from '../src/types' -import { captureCreatedTransactions } from './utils' /** * # Do settled change messages reconstruct the Collection's public rows? From 735a5412c0298dc3e71c1702d8aa155554233f34 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 10 Oct 2026 12:24:15 -0600 Subject: [PATCH 06/14] fix(db): a nested direct write fails on its own subscriber's error A direct write made inside another Collection's change subscriber had its subscriber failure deferred to the outer publication: the write committed and its handler ran, while the outer write was rolled back. The write now takes the listener failures recorded during its admission and its rollback, rejects, and throws to its caller. The ownership oracle generates such nested writes; without the fix the outer write is charged with the error, and a mutant that lets the rollback's failure reach the outer publication fails too. Co-authored-by: Isaac --- .../fix-direct-write-subscriber-throw.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 28 +++++++---- packages/db/src/scheduler.ts | 22 +++++++++ ...nsaction-ownership-oracle.property.test.ts | 47 ++++++++++++++++++- 5 files changed, 88 insertions(+), 13 deletions(-) diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md index 281427d4aa..d46b96a163 100644 --- a/.changeset/fix-direct-write-subscriber-throw.md +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. A direct write made inside another Collection's change subscriber now fails on its own subscriber's error, instead of committing while the outer write was rolled back. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 6ea9076b15..77b556755b 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write made from inside another Collection's change subscriber has its subscriber error deferred to the outer publication, so it commits while the outer write rolls back (a law question for the maintainer); a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, succeeds. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 7e5fbf3c25..788fe340fc 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -5,6 +5,7 @@ import { } from '../proxy' import { safeRandomUUID } from '../utils/uuid' import { createTransaction, getActiveTransaction } from '../transactions' +import { takePublicationFailure } from '../scheduler' import { DeleteKeyNotFoundError, DuplicateKeyError, @@ -277,18 +278,27 @@ export class CollectionMutationsManager< }), }) try { - this.applyOwnedMutations(transaction, mutations) + // Inside another Collection's publication, a subscriber failure is + // deferred to that publication. It is this write's failure, so take it. + const failure = takePublicationFailure(() => + this.applyOwnedMutations(transaction, mutations), + ) + if (failure) throw failure.error } catch (error) { // A subscriber threw before the handler could run. Roll back only this // write, so no Collection keeps a transaction that nothing will settle. - try { - transaction.rollback({ - isSecondaryRollback: true, - error: error instanceof Error ? error : undefined, - }) - } catch { - // The admission error is the reported cause. - } + // The admission error is the reported cause, also for the rollback's + // own subscriber failures, deferred or not. + takePublicationFailure(() => { + try { + transaction.rollback({ + isSecondaryRollback: true, + error: error instanceof Error ? error : undefined, + }) + } catch { + // Settlement still ran every step. + } + }) throw error } // Errors still reject `isPersisted.promise`. This catch only prevents an diff --git a/packages/db/src/scheduler.ts b/packages/db/src/scheduler.ts index 9fb1a08493..52f1e94041 100644 --- a/packages/db/src/scheduler.ts +++ b/packages/db/src/scheduler.ts @@ -180,6 +180,28 @@ export function recordPublicationError(error: unknown): void { activePublicationFailure ??= { error } } +/** + * Runs `run` and returns the first listener failure it recorded inside an + * outer publication, which then does not report it. Outside a publication a + * listener failure is thrown, so there is nothing to take. + */ +export function takePublicationFailure( + run: () => void, +): { error: unknown } | undefined { + if (activePublicationContext === undefined) { + run() + return undefined + } + const outer = activePublicationFailure + activePublicationFailure = undefined + try { + run() + return activePublicationFailure + } finally { + activePublicationFailure = outer + } +} + /** * Runs one synchronous Collection publication inside a scheduler context. * Nested publications share the outer context, so downstream live queries run diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index 7c23a54102..c9dfb97f72 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -130,6 +130,11 @@ type Step = throws: boolean /** The throwing subscriber was sent the rows before the write. */ seen: boolean + /** + * The write runs inside a change subscriber of the other Collection, + * during that Collection's own direct insert of key 4. + */ + nested: boolean } type ModelState = `pending` | `persisting` | `completed` | `failed` @@ -284,6 +289,7 @@ const step: fc.Arbitrary = fc.oneof( value: fc.integer({ min: 1, max: 9 }), throws: fc.boolean(), seen: fc.boolean(), + nested: fc.boolean(), }), }, { @@ -486,8 +492,11 @@ async function runHistory(steps: ReadonlyArray): Promise { } expect(collection.has(9), `${label}: ${on} merged-away row`).toBe(false) expect(collection.has(3), `${label}: ${on} direct insert`).toBe(false) + expect(collection.has(4), `${label}: ${on} outer direct insert`).toBe( + false, + ) const { mirror } = collections[on] - for (const key of [1, 2, 3, 9] as const) + for (const key of [1, 2, 3, 4, 9] as const) expect(mirror.get(key), `${label}: ${on} mirror of row ${key}`).toBe( collection.get(key)?.v, ) @@ -549,7 +558,7 @@ async function runHistory(steps: ReadonlyArray): Promise { label, current.throws ? current.on : undefined, () => { - try { + const write = () => { if (current.op === `insert`) collection.insert({ id: 3, v: current.value }) else if (current.op === `delete`) collection.delete(current.key) @@ -557,6 +566,40 @@ async function runHistory(steps: ReadonlyArray): Promise { collection.update(current.key, (draft) => { draft.v = current.value }) + } + try { + if (!current.nested) write() + else { + // The write's outcome belongs to the write, even inside the + // other Collection's publication: its caller sees its error, + // and the outer write, whose subscriber catches it, succeeds. + const outer = + collections[current.on === `A` ? `B` : `A`].collection + let inner: { error: unknown } | undefined + let ran = false + const subscription = outer.subscribeChanges(() => { + if (ran) return + ran = true + try { + write() + } catch (error) { + inner = { error } + } + }) + let outerError: unknown + try { + outer.insert({ id: 4, v: 1 }) + } catch (error) { + outerError = error + } finally { + subscription.unsubscribe() + } + expect(ran, `${label}: nested write ran`).toBe(true) + expect(outerError, `${label}: outer write unaffected`).toBe( + undefined, + ) + if (inner) throw inner.error + } } finally { restoreCreate() for (const tx of created) From 8b6fce447b26b875685274eab636f947969b7568 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 10 Oct 2026 14:14:45 -0600 Subject: [PATCH 07/14] fix(db): a nested local-only direct write fails on its own subscriber's error The change-event history oracle now nests the throwing local-only write in another Collection's subscriber and requires the outer write to succeed; it failed with the inner error before. The transaction ownership oracle adds an outer throwing subscriber and checks the outer write's transaction and handler calls. Co-authored-by: Isaac --- .../fix-direct-write-subscriber-throw.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 6 ++- .../tests/change-event-history-oracle.test.ts | 51 +++++++++++++++++-- ...nsaction-ownership-oracle.property.test.ts | 40 +++++++++++++-- packages/db/tests/utils.ts | 7 ++- 6 files changed, 94 insertions(+), 14 deletions(-) diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md index d46b96a163..5f90ac030a 100644 --- a/.changeset/fix-direct-write-subscriber-throw.md +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. A direct write made inside another Collection's change subscriber now fails on its own subscriber's error, instead of committing while the outer write was rolled back. +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. A direct write made inside another Collection's change subscriber, including a local-only one, now fails on its own subscriber's error. Before, the error failed the outer write instead. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 77b556755b..cc99d3c2ee 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, succeeds. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model; whether a throwing subscriber may stop a later subscriber is not decided. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write; the outer write must then fail with that error and roll back, and otherwise complete with one handler call. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed; whether a throwing subscriber may stop a later subscriber is not decided. Open: no generated publication records a deferred failure before a nested direct write takes its own, so a take that drops the earlier failure survives; a local-only write that throws before it stores its rows still completes its transaction (contract breach only); a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 788fe340fc..0a1efc3c98 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -221,9 +221,11 @@ export class CollectionMutationsManager< }) transaction.applyMutations(mutations) // The rows are stored when `write` publishes, so a throwing subscriber - // cannot undo them; the transaction still settles. + // cannot undo them; the transaction still settles. Inside another + // publication, the subscriber failure is still this write's. try { - direct.write(mutations) + const failure = takePublicationFailure(() => direct.write(mutations)) + if (failure) throw failure.error } finally { transaction.setState(`completed`) transaction.isPersisted.resolve(transaction) diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 081d6f2cad..5ec625b37c 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -94,6 +94,11 @@ type GeneratedHistory = { mode: `batched` | `sequential` /** The operation whose publication meets a throwing subscriber. */ throwOn?: number + /** + * The throwing operation runs inside a subscriber of another Collection, + * during that Collection's own write. + */ + nested?: boolean } type GeneratedStep = { key: Key; deleteWhenPresent: boolean } @@ -370,6 +375,7 @@ async function runHistory( id: string, indexType?: IndexType, throwOn?: number, + nested = false, ): Promise { const initialRows = [...expectedRowsAfter(initialKeys, [])].map( ([, row]) => row, @@ -451,6 +457,8 @@ async function runHistory( // publishes. A local-only direct write stores its rows as it publishes // them, so the throw cannot undo the write: the call rethrows, and its // transaction still settles. The rows and the mirror follow the model. + // When the operation runs inside another Collection's subscriber, the + // failure is still this call's, and the outer write succeeds. const injected = new Error(`subscriber failed`) let armed = false const throwing = @@ -464,12 +472,46 @@ async function runHistory( ) const { created, restore: restoreCreate } = captureCreatedTransactions(collection) + // A nested write's failure belongs to that write, even inside another + // Collection's publication: the outer write, whose subscriber catches + // the inner error, succeeds. + const outer = createCollection( + localOnlyCollectionOptions({ + id: `${id}-outer`, + getKey: (item: TestItem) => item.id, + }), + ) + const applyNested = (op: Op) => { + let inner: { error: unknown } | undefined + let ran = false + const subscription = outer.subscribeChanges(() => { + if (ran) return + ran = true + try { + applyOp(collection, op) + } catch (error) { + inner = { error } + } + }) + let outerError: unknown + try { + outer.insert({ id: outer.size + 1, name: `outer`, fileId: `f1` }) + } catch (error) { + outerError = error + } finally { + subscription.unsubscribe() + } + expect(ran, `nested write ran`).toBe(true) + expect(outerError, `outer write unaffected`).toBe(undefined) + if (inner) throw inner.error + } const applyAt = (op: Op, operationIndex: number) => { if (operationIndex !== throwOn) return applyOp(collection, op) armed = true const before = created.length try { - applyOp(collection, op) + if (nested) applyNested(op) + else applyOp(collection, op) } catch (error) { expect(error, `operation ${operationIndex} rethrows`).toBe(injected) expect(created.length, `one transaction`).toBe(before + 1) @@ -560,6 +602,7 @@ async function verifyGeneratedHistory({ sequence, mode, throwOn, + nested, }: GeneratedHistory): Promise { await runHistory( sequence, @@ -568,6 +611,7 @@ async function verifyGeneratedHistory({ `history-oracle-generated-${initialKeys.join(``)}-${mode}-${describeSequence(sequence)}`, undefined, throwOn === undefined ? undefined : throwOn % sequence.length, + nested, ) } @@ -715,10 +759,11 @@ describe(`change-event history oracle`, () => { mode: fc.constantFrom(`batched` as const, `sequential` as const), // About one history in three meets a throwing subscriber. throwOn: fc.option(fc.nat(19), { nil: undefined, freq: 2 }), + nested: fc.boolean(), }) - .map(({ initialKeys, steps, mode, throwOn }) => { + .map(({ initialKeys, steps, mode, throwOn, nested }) => { const sequence = opsFromPresence(initialKeys, steps, 1) - return { initialKeys, sequence, mode, throwOn } + return { initialKeys, sequence, mode, throwOn, nested } }) // The fixed prefix forces reused keys through an already-built index. diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index c9dfb97f72..50c762fa4d 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -135,6 +135,8 @@ type Step = * during that Collection's own direct insert of key 4. */ nested: boolean + /** A nested write's outer Collection also has a throwing subscriber. */ + outerThrows: boolean } type ModelState = `pending` | `persisting` | `completed` | `failed` @@ -290,6 +292,7 @@ const step: fc.Arbitrary = fc.oneof( throws: fc.boolean(), seen: fc.boolean(), nested: fc.boolean(), + outerThrows: fc.boolean(), }), }, { @@ -554,6 +557,7 @@ async function runHistory(steps: ReadonlyArray): Promise { captureCreatedTransactions(collection) const settled = new Set>() const handledBefore = collections[current.on].handled.calls + let checkOuter: (() => void) | undefined const { result, raised } = await withThrowingSubscribers( label, current.throws ? current.on : undefined, @@ -572,11 +576,23 @@ async function runHistory(steps: ReadonlyArray): Promise { else { // The write's outcome belongs to the write, even inside the // other Collection's publication: its caller sees its error, - // and the outer write, whose subscriber catches it, succeeds. - const outer = - collections[current.on === `A` ? `B` : `A`].collection + // and the outer write, whose subscriber catches it, has only + // its own outcome. It fails exactly when its own subscriber + // throws, and otherwise reaches its handler once. + const outerName = current.on === `A` ? `B` : `A` + const outer = collections[outerName].collection + const outerHandledBefore = collections[outerName].handled.calls + const outerFailure = new Error(`outer subscriber failed`) + const outerCreated = captureCreatedTransactions(outer) let inner: { error: unknown } | undefined let ran = false + // The outer failure is recorded before the nested write runs, + // so taking the nested failure must keep it. + const outerThrowing = current.outerThrows + ? outer.subscribeChanges(() => { + throw outerFailure + }) + : undefined const subscription = outer.subscribeChanges(() => { if (ran) return ran = true @@ -593,11 +609,24 @@ async function runHistory(steps: ReadonlyArray): Promise { outerError = error } finally { subscription.unsubscribe() + outerThrowing?.unsubscribe() + outerCreated.restore() } expect(ran, `${label}: nested write ran`).toBe(true) - expect(outerError, `${label}: outer write unaffected`).toBe( - undefined, + expect(outerError, `${label}: outer write outcome`).toBe( + current.outerThrows ? outerFailure : undefined, ) + // The outer handler settles after promises flush. + checkOuter = () => { + expect( + outerCreated.created.map((tx) => tx.state), + `${label}: outer transaction`, + ).toEqual([current.outerThrows ? `failed` : `completed`]) + expect( + collections[outerName].handled.calls - outerHandledBefore, + `${label}: outer handler calls`, + ).toBe(current.outerThrows ? 0 : 1) + } if (inner) throw inner.error } } finally { @@ -612,6 +641,7 @@ async function runHistory(steps: ReadonlyArray): Promise { () => {}, current.seen, ) + checkOuter?.() // The throwing subscriber must reach the admission of an insert or an // update, so the write fails rather than passing unnoticed. A // subscriber is not told of a delete of a row it was never sent, so diff --git a/packages/db/tests/utils.ts b/packages/db/tests/utils.ts index 5c4589de20..c952b7674e 100644 --- a/packages/db/tests/utils.ts +++ b/packages/db/tests/utils.ts @@ -13,6 +13,7 @@ import type { import type { IndexConstructor } from '../src/indexes/base-index' import type { WithVirtualProps } from '../src/virtual-props.js' import type { Transaction } from '../src/transactions.js' +import type { Collection } from '../src/collection/index.js' export type OutputWithVirtual< T extends object, @@ -685,12 +686,14 @@ export function resetCleanupQueue(): void { * the one a direct write makes for itself, which the call may not return when * it throws. Call `restore` when done. */ -export function captureCreatedTransactions(collection: object): { +export function captureCreatedTransactions( + collection: Collection, +): { created: Array> restore: () => void } { const manager = ( - collection as { + collection as unknown as { _mutations: { createTransaction: (...args: Array) => Transaction } From f5beb74e5759405e48c3fb07917580c64ef915c3 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sat, 10 Oct 2026 15:51:47 -0600 Subject: [PATCH 08/14] fix(db): fail a local-only direct write whose rows were not stored A local-only direct write now completes only when its rows are stored, so a contract breach that throws before storage reports failed instead of completed. The transaction ownership oracle adds a nested history in which the outer publication defers a failure before the nested direct write takes its own; a take that drops the earlier failure now fails it. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 26 +++++-- .../local-only-direct-write-oracle.test.ts | 43 ++++++++++++ ...nsaction-ownership-oracle.property.test.ts | 67 +++++++++++++++++-- 4 files changed, 127 insertions(+), 11 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index cc99d3c2ee..830825894d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -245,7 +245,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write; the outer write must then fail with that error and roll back, and otherwise complete with one handler call. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed; whether a throwing subscriber may stop a later subscriber is not decided. Open: no generated publication records a deferred failure before a nested direct write takes its own, so a take that drops the earlier failure survives; a local-only write that throws before it stores its rows still completes its transaction (contract breach only); a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| 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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write, and its publication sometimes defers a failure first: a sync commit to a third Collection meets a throwing subscriber. The outer write must then fail with one of those errors and roll back, and otherwise complete with one handler call. A nested write's take of its own failure must keep the deferred one. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed; whether a throwing subscriber may stop a later subscriber is not decided. A local-only write that throws before it stores its rows fails its transaction; only a contract breach reaches that, so focused witnesses cover insert, update and delete. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 0a1efc3c98..3e2c89407d 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -3,6 +3,7 @@ import { withChangeTracking, withFlatChangeTracking, } from '../proxy' +import { deepEquals } from '../utils' import { safeRandomUUID } from '../utils/uuid' import { createTransaction, getActiveTransaction } from '../transactions' import { takePublicationFailure } from '../scheduler' @@ -221,15 +222,30 @@ export class CollectionMutationsManager< }) transaction.applyMutations(mutations) // The rows are stored when `write` publishes, so a throwing subscriber - // cannot undo them; the transaction still settles. Inside another - // publication, the subscriber failure is still this write's. + // cannot undo them; the transaction still completes. Inside another + // publication, the subscriber failure is still this write's. Any other + // failure, which only a contract breach causes, leaves the rows unstored. + let failure: { error: unknown } | undefined try { - const failure = takePublicationFailure(() => direct.write(mutations)) - if (failure) throw failure.error - } finally { + failure = takePublicationFailure(() => direct.write(mutations)) + } catch (error) { + failure = { error } + } + const stored = mutations.every((mutation) => { + const row = this.state.getAcceptedSyncedRow(mutation.key) + return mutation.type === `delete` + ? row === undefined + : deepEquals(row, mutation.modified) + }) + if (stored) { transaction.setState(`completed`) transaction.isPersisted.resolve(transaction) + } else { + transaction.setState(`failed`) + transaction.isPersisted.promise.catch(() => undefined) + transaction.isPersisted.reject(failure!.error) } + if (failure) throw failure.error return transaction } diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index 443da4e893..0b239b5d4e 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -163,6 +163,49 @@ describe(`local-only direct writes`, () => { expect(orders.insert({ id: 3, value: `c` }).state).toBe(`completed`) }) + // Invariant witness, not a generated history: only a contract breach can + // make the local-only write throw before it stores its rows, so no legal + // history reaches it. The transaction must then report the failure instead + // of completing. + for (const kind of [`insert`, `update`, `delete`] as const) { + it(`fails a direct ${kind} whose rows were not stored`, async () => { + const orders = createOrders() + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) + const direct = ( + orders._state as unknown as { + localOnlyDirectWrite: { write: (mutations: unknown) => void } + } + ).localOnlyDirectWrite + const write = direct.write + const breach = new Error(`storage failed`) + direct.write = () => { + throw breach + } + try { + expect(() => { + if (kind === `insert`) orders.insert({ id: 2, value: `b` }) + else if (kind === `update`) + orders.update(1, (draft) => { + draft.value = `changed` + }) + else orders.delete(1) + }).toThrow(breach) + } finally { + direct.write = write + restoreCreate() + } + + // The rows the write would have stored are not there. + if (kind === `insert`) expect(orders.has(2)).toBe(false) + else expect(orders.get(1)?.value).toBe(`a`) + expect(created.map((transaction) => transaction.state)).toEqual([ + `failed`, + ]) + await expect(created[0]!.isPersisted.promise).rejects.toBe(breach) + }) + } + it(`runs the user's handler for that operation type`, async () => { let calls = 0 const orders = createOrders({ diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index 50c762fa4d..a3f337c012 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -56,6 +56,13 @@ import type { Transaction } from '../src/transactions.js' * transaction rolls back without rolling back other transactions, and no * Collection keeps it. Either way the write's own transaction has settled, * with its `isPersisted`. So a direct write never changes the model. + * - A direct write can run inside a change subscriber of the other + * Collection, during that Collection's own direct insert of key 4. Its + * subscriber failure belongs to it: it rejects and rolls back, and the + * outer write is not told. The outer write fails exactly when its own + * publication recorded a failure: an outer subscriber threw, or a sync + * commit to a third Collection, made earlier in that publication, met a + * throwing subscriber. The nested write must keep that earlier failure. * - Error shape: a settling call that ran no throwing subscriber reports no * subscriber error. Otherwise it throws one of the subscriber errors, as * is. The contract does not say which one, because these failures are rare @@ -137,6 +144,11 @@ type Step = nested: boolean /** A nested write's outer Collection also has a throwing subscriber. */ outerThrows: boolean + /** + * Before the nested write, the outer publication defers a failure: a + * sync commit to a third Collection meets a throwing subscriber. + */ + priorDeferred: boolean } type ModelState = `pending` | `persisting` | `completed` | `failed` @@ -293,6 +305,7 @@ const step: fc.Arbitrary = fc.oneof( seen: fc.boolean(), nested: fc.boolean(), outerThrows: fc.boolean(), + priorDeferred: fc.boolean(), }), }, { @@ -400,6 +413,15 @@ async function runHistory(steps: ReadonlyArray): Promise { A: await makeCollection(`ownership-a-${suffix}`), B: await makeCollection(`ownership-b-${suffix}`), } + // A third Collection outside the model. A sync commit to it inside another + // publication meets a throwing subscriber, which defers its failure. + const deferred = await makeCollection(`ownership-deferred-${suffix}`) + const deferredFailure = new Error(`deferred subscriber failed`) + let deferredArmed = false + deferred.collection.subscribeChanges(() => { + if (deferredArmed) throw deferredFailure + }) + let nextDeferredKey = 100 const model = new OwnershipModel() const driven: Array = [] const settle = (entry: Driven) => { @@ -593,6 +615,24 @@ async function runHistory(steps: ReadonlyArray): Promise { throw outerFailure }) : undefined + let deferredRan = false + const deferring = current.priorDeferred + ? outer.subscribeChanges(() => { + if (deferredRan) return + deferredRan = true + deferredArmed = true + try { + deferred.utils.begin() + deferred.utils.write({ + type: `insert`, + value: { id: nextDeferredKey++, v: 0 }, + }) + deferred.utils.commit() + } finally { + deferredArmed = false + } + }) + : undefined const subscription = outer.subscribeChanges(() => { if (ran) return ran = true @@ -609,23 +649,39 @@ async function runHistory(steps: ReadonlyArray): Promise { outerError = error } finally { subscription.unsubscribe() + deferring?.unsubscribe() outerThrowing?.unsubscribe() outerCreated.restore() } expect(ran, `${label}: nested write ran`).toBe(true) - expect(outerError, `${label}: outer write outcome`).toBe( - current.outerThrows ? outerFailure : undefined, - ) + // The outer write fails with a failure that its publication + // recorded; a nested write's own failure is not among them. + // When both apply, either error may be reported: precise + // errors are not required (maintainer decision). + const outerFails = current.outerThrows || current.priorDeferred + const outerFailures = [ + ...(current.outerThrows ? [outerFailure] : []), + ...(current.priorDeferred ? [deferredFailure] : []), + ] + if (outerFails) + expect( + outerFailures, + `${label}: outer write outcome`, + ).toContain(outerError) + else + expect(outerError, `${label}: outer write outcome`).toBe( + undefined, + ) // The outer handler settles after promises flush. checkOuter = () => { expect( outerCreated.created.map((tx) => tx.state), `${label}: outer transaction`, - ).toEqual([current.outerThrows ? `failed` : `completed`]) + ).toEqual([outerFails ? `failed` : `completed`]) expect( collections[outerName].handled.calls - outerHandledBefore, `${label}: outer handler calls`, - ).toBe(current.outerThrows ? 0 : 1) + ).toBe(outerFails ? 0 : 1) } if (inner) throw inner.error } @@ -809,6 +865,7 @@ async function runHistory(steps: ReadonlyArray): Promise { await flush() await collections.A.collection.cleanup() await collections.B.collection.cleanup() + await deferred.collection.cleanup() } } From aca21b46abbf88b12332f6b51882d6950811b011 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 11 Oct 2026 08:26:14 -0600 Subject: [PATCH 09/14] fix(db): mark a local-only direct write stored when its write returns Deciding storage by re-reading rows misfired when a subscriber wrote the same key, so the insert threw a TypeError. The write now runs inside a publication context: a subscriber failure comes back as a value and only a throw from the write means the rows were not stored. A write during the Collection's own sync drain takes the transaction path, so a nested write into the same Collection owns its subscriber failure. Rollback and failed settlement record an Error. The change-event oracle nests the throwing write in its own Collection's publication, and witnesses cover a subscriber that rewrites its key and a non-Error throw; each failed on the previous head. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 42 +++++++------ packages/db/src/collection/state.ts | 2 +- packages/db/tests/_mine_probe/lo.test.ts | 29 +++++++++ .../tests/change-event-history-oracle.test.ts | 55 +++++++++++------ .../local-only-direct-write-oracle.test.ts | 60 +++++++++++++++++++ 6 files changed, 153 insertions(+), 37 deletions(-) create mode 100644 packages/db/tests/_mine_probe/lo.test.ts diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 102c9845dd..9895c846a1 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -248,7 +248,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write, and its publication sometimes defers a failure first: a sync commit to a third Collection meets a throwing subscriber. The outer write must then fail with one of those errors and roll back, and otherwise complete with one handler call. A nested write's take of its own failure must keep the deferred one. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed; whether a throwing subscriber may stop a later subscriber is not decided. A local-only write that throws before it stores its rows fails its transaction; only a contract breach reaches that, so focused witnesses cover insert, update and delete. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write, and its publication sometimes defers a failure first: a sync commit to a third Collection meets a throwing subscriber. The outer write must then fail with one of those errors and roll back, and otherwise complete with one handler call. A nested write's take of its own failure must keep the deferred one. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed. When it runs inside a subscriber of its own Collection's publication, it takes the transaction path, so its subscriber failure rolls it back and the outer write still succeeds. Focused witnesses cover a subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. A local-only write that throws before it stores its rows fails its transaction; only a contract breach reaches that, so focused witnesses cover insert, update and delete. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 3e2c89407d..8cab07af36 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -3,10 +3,10 @@ import { withChangeTracking, withFlatChangeTracking, } from '../proxy' -import { deepEquals } from '../utils' +import { normalizeError } from '../utils/error.js' import { safeRandomUUID } from '../utils/uuid' import { createTransaction, getActiveTransaction } from '../transactions' -import { takePublicationFailure } from '../scheduler' +import { takePublicationFailure, withPublicationContext } from '../scheduler' import { DeleteKeyNotFoundError, DuplicateKeyError, @@ -205,7 +205,11 @@ export class CollectionMutationsManager< type: OperationType, ): TransactionType | undefined { const direct = this.state.localOnlyDirectWrite - if (!direct?.types.has(type)) return undefined + // A write during this Collection's own sync drain is queued behind it, so + // its subscribers would run after this call. The transaction path + // publishes it now. + if (!direct?.types.has(type) || this.state.isDrainingSyncTransactions) + return undefined for (const transaction of this.state.transactions.values()) { // A persisting transaction holds sync commits, and a pending one // overlays them. @@ -221,29 +225,31 @@ export class CollectionMutationsManager< mutationFn: () => Promise.resolve(), }) transaction.applyMutations(mutations) - // The rows are stored when `write` publishes, so a throwing subscriber - // cannot undo them; the transaction still completes. Inside another - // publication, the subscriber failure is still this write's. Any other - // failure, which only a contract breach causes, leaves the rows unstored. - let failure: { error: unknown } | undefined + // `write` stores the rows before it publishes them. Inside a publication + // context a subscriber failure comes back as a value, so it cannot undo + // the write and the transaction completes. Only a contract breach makes + // `write` itself throw, and then the rows are not stored. + const outcome: { stored: boolean; failure?: { error: unknown } } = { + stored: false, + } try { - failure = takePublicationFailure(() => direct.write(mutations)) + withPublicationContext(() => { + outcome.failure = takePublicationFailure(() => direct.write(mutations)) + outcome.stored = true + }) } catch (error) { - failure = { error } + outcome.failure ??= { error } } - const stored = mutations.every((mutation) => { - const row = this.state.getAcceptedSyncedRow(mutation.key) - return mutation.type === `delete` - ? row === undefined - : deepEquals(row, mutation.modified) - }) + const { stored, failure } = outcome if (stored) { transaction.setState(`completed`) transaction.isPersisted.resolve(transaction) } else { + const error = normalizeError(failure!.error) + transaction.error = { message: error.message, error } transaction.setState(`failed`) transaction.isPersisted.promise.catch(() => undefined) - transaction.isPersisted.reject(failure!.error) + transaction.isPersisted.reject(error) } if (failure) throw failure.error return transaction @@ -311,7 +317,7 @@ export class CollectionMutationsManager< try { transaction.rollback({ isSecondaryRollback: true, - error: error instanceof Error ? error : undefined, + error: normalizeError(error), }) } catch { // Settlement still ran every step. diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 5723483aaf..54cdd50774 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -189,7 +189,7 @@ export class CollectionStateManager< public recentlySyncedKeys = new Set() public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false - private isDrainingSyncTransactions = false + public isDrainingSyncTransactions = false private syncRunGeneration = 0 public isLocalOnly = false /** diff --git a/packages/db/tests/_mine_probe/lo.test.ts b/packages/db/tests/_mine_probe/lo.test.ts new file mode 100644 index 0000000000..3b2e7058fb --- /dev/null +++ b/packages/db/tests/_mine_probe/lo.test.ts @@ -0,0 +1,29 @@ +import { it } from 'vitest' +import { createCollection } from '../../src/index' +import { localOnlyCollectionOptions } from '../../src/local-only' + +const make = (id: string, handlers = {}) => createCollection(localOnlyCollectionOptions<{ id: number; v: number }, number>({ id, getKey: (r) => r.id, ...handlers })) +const run = (label: string, f: () => unknown) => { try { f(); return `${label}: ok` } catch (e) { return `${label}: threw ${String(e)}` } } + +it(`same-key subscriber writes and same-collection nesting`, () => { + for (const kind of [`update`, `delete`] as const) { + const c = make(`lo-${kind}`) + let done = false + c.subscribeChanges((changes) => { + if (done || !changes.some((ch) => ch.key === 1 && ch.type === `insert`)) return + done = true + if (kind === `update`) c.update(1, (d) => { d.v = 2 }); else c.delete(1) + }) + console.log(run(`subscriber ${kind}s same key`, () => c.insert({ id: 1, v: 1 })), `rows:`, JSON.stringify(c.toArray.map((r) => [r.id, r.v]))) + } + for (const handler of [false, true]) { + const c = make(`lo-nest-${handler}`, handler ? { onInsert: async () => {} } : {}) + let inner = `not run` + c.subscribeChanges((changes) => { + if (!changes.some((ch) => ch.key === 1)) return + try { c.insert({ id: 2, v: 0 }); inner = `ok` } catch (e) { inner = `threw ${String(e)}` } + }) + c.subscribeChanges((changes) => { if (changes.some((ch) => ch.key === 2)) throw new Error(`boom2`) }) + console.log(`handler=${handler}`, run(`outer`, () => c.insert({ id: 1, v: 0 })), `inner:`, inner) + } +}) diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index d522bdcc8f..0be8bfbb68 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -95,10 +95,11 @@ type GeneratedHistory = { /** The operation whose publication meets a throwing subscriber. */ throwOn?: number /** - * The throwing operation runs inside a subscriber of another Collection, - * during that Collection's own write. + * The throwing operation runs inside a change subscriber during another + * write: of another Collection, or of this Collection on a key outside the + * model, which is removed again afterwards. */ - nested?: boolean + nested?: `other` | `same` } type GeneratedStep = { key: Key; deleteWhenPresent: boolean } @@ -375,7 +376,7 @@ async function runHistory( id: string, indexType?: IndexType, throwOn?: number, - nested = false, + nested?: `other` | `same`, ): Promise { const initialRows = [...expectedRowsAfter(initialKeys, [])].map( ([, row]) => row, @@ -457,16 +458,20 @@ async function runHistory( // publishes. A local-only direct write stores its rows as it publishes // them, so the throw cannot undo the write: the call rethrows, and its // transaction still settles. The rows and the mirror follow the model. - // When the operation runs inside another Collection's subscriber, the - // failure is still this call's, and the outer write succeeds. + // When the operation runs inside another write's subscriber, the failure + // is still this call's, and the outer write succeeds. Inside this + // Collection's own publication the operation takes the transaction path, + // so its failure rolls it back instead. The subscriber + // throws only for the operation's key. const injected = new Error(`subscriber failed`) - let armed = false + let armedKey: Key | undefined const throwing = throwOn === undefined ? undefined : collection.subscribeChanges( - () => { - if (armed) throw injected + (changes) => { + if (changes.some((change) => change.key === armedKey)) + throw injected }, { includeInitialState: true }, ) @@ -481,11 +486,12 @@ async function runHistory( getKey: (item: TestItem) => item.id, }), ) - const applyNested = (op: Op) => { + const OUTER_KEY = 99 + const applyNested = (op: Op, target: typeof collection) => { let inner: { error: unknown } | undefined let ran = false - const subscription = outer.subscribeChanges(() => { - if (ran) return + const subscription = target.subscribeChanges((changes) => { + if (ran || changes.every((change) => change.key !== OUTER_KEY)) return ran = true try { applyOp(collection, op) @@ -495,7 +501,7 @@ async function runHistory( }) let outerError: unknown try { - outer.insert({ id: outer.size + 1, name: `outer`, fileId: `f1` }) + target.insert({ id: OUTER_KEY, name: `outer`, fileId: `f1` }) } catch (error) { outerError = error } finally { @@ -503,17 +509,32 @@ async function runHistory( } expect(ran, `nested write ran`).toBe(true) expect(outerError, `outer write unaffected`).toBe(undefined) + // The outer row is outside the model; remove it again. + target.delete(OUTER_KEY) if (inner) throw inner.error } const applyAt = (op: Op, operationIndex: number) => { if (operationIndex !== throwOn) return applyOp(collection, op) - armed = true + armedKey = op.key const before = created.length try { - if (nested) applyNested(op) + if (nested) applyNested(op, nested === `same` ? collection : outer) else applyOp(collection, op) } catch (error) { expect(error, `operation ${operationIndex} rethrows`).toBe(injected) + if (nested === `same`) { + // During this Collection's own publication the operation takes the + // transaction path, so its subscriber failure rolls it back. The + // outer insert and its removal also create transactions. Apply the + // operation again, unobserved, so the history continues as planned. + expect(created.length, `transactions created`).toBe(before + 3) + expect( + (created[before + 1] as unknown as { state: string }).state, + `operation ${operationIndex} rolls back`, + ).toBe(`failed`) + armedKey = undefined + return applyOp(collection, op) + } expect(created.length, `one transaction`).toBe(before + 1) expect( (created[before] as unknown as { state: string }).state, @@ -521,7 +542,7 @@ async function runHistory( ).toBe(`completed`) return created[before]! } finally { - armed = false + armedKey = undefined } throw new Error(`operation ${operationIndex} did not meet the throw`) } @@ -759,7 +780,7 @@ describe(`change-event history oracle`, () => { mode: fc.constantFrom(`batched` as const, `sequential` as const), // About one history in three meets a throwing subscriber. throwOn: fc.option(fc.nat(19), { nil: undefined, freq: 2 }), - nested: fc.boolean(), + nested: fc.constantFrom(undefined, `other` as const, `same` as const), }) .map(({ initialKeys, steps, mode, throwOn, nested }) => { const sequence = opsFromPresence(initialKeys, steps, 1) diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index 0b239b5d4e..f1b975d7e9 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -167,6 +167,65 @@ describe(`local-only direct writes`, () => { // make the local-only write throw before it stores its rows, so no legal // history reaches it. The transaction must then report the failure instead // of completing. + // A subscriber may write the key it was told about, as a normalizing or + // rejecting subscriber does. The first write stored its rows before that, + // so it completes and does not throw. The second write runs during this + // Collection's own publication, so it takes the transaction path. + for (const kind of [`update`, `delete`] as const) { + it(`completes a direct insert whose subscriber ${kind}s the same key`, async () => { + const orders = createOrders() + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) + let reacted = false + const subscription = orders.subscribeChanges((changes) => { + if (reacted || changes.every((change) => change.key !== 2)) return + reacted = true + if (kind === `update`) + orders.update(2, (draft) => { + draft.value = `normalized` + }) + else orders.delete(2) + }) + try { + expect(() => orders.insert({ id: 2, value: `b` })).not.toThrow() + } finally { + subscription.unsubscribe() + restoreCreate() + } + expect(orders.get(2)?.value).toBe( + kind === `update` ? `normalized` : undefined, + ) + await Promise.all( + created.map((transaction) => transaction.isPersisted.promise), + ) + expect(created.map((transaction) => transaction.state)).toEqual([ + `completed`, + `completed`, + ]) + }) + } + + // A subscriber may throw a value that is not an Error. The rolled-back + // transaction still records an Error, so its receipt names the failure. + it(`records an Error when a direct write's subscriber throws a non-Error`, async () => { + const orders = createOrders({ onInsert: async () => {} }) + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) + const subscription = orders.subscribeChanges(() => { + throw `bad` + }) + try { + expect(() => orders.insert({ id: 2, value: `b` })).toThrow(`bad`) + } finally { + subscription.unsubscribe() + restoreCreate() + } + expect(created.map((transaction) => transaction.state)).toEqual([`failed`]) + expect(created[0]!.error?.error).toBeInstanceOf(Error) + expect(created[0]!.error?.message).toBe(`bad`) + await expect(created[0]!.isPersisted.promise).rejects.toBeInstanceOf(Error) + }) + for (const kind of [`insert`, `update`, `delete`] as const) { it(`fails a direct ${kind} whose rows were not stored`, async () => { const orders = createOrders() @@ -202,6 +261,7 @@ describe(`local-only direct writes`, () => { expect(created.map((transaction) => transaction.state)).toEqual([ `failed`, ]) + expect(created[0]!.error?.error, `transaction error`).toBe(breach) await expect(created[0]!.isPersisted.promise).rejects.toBe(breach) }) } From 050c6ed5700e15d5b23c97bdd8a8f93696a65ee4 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 11 Oct 2026 14:29:41 +0000 Subject: [PATCH 10/14] ci: apply automated fixes --- packages/db/tests/_mine_probe/lo.test.ts | 56 ++++++++++++++++++++---- 1 file changed, 47 insertions(+), 9 deletions(-) diff --git a/packages/db/tests/_mine_probe/lo.test.ts b/packages/db/tests/_mine_probe/lo.test.ts index 3b2e7058fb..fc14ea9a98 100644 --- a/packages/db/tests/_mine_probe/lo.test.ts +++ b/packages/db/tests/_mine_probe/lo.test.ts @@ -2,28 +2,66 @@ import { it } from 'vitest' import { createCollection } from '../../src/index' import { localOnlyCollectionOptions } from '../../src/local-only' -const make = (id: string, handlers = {}) => createCollection(localOnlyCollectionOptions<{ id: number; v: number }, number>({ id, getKey: (r) => r.id, ...handlers })) -const run = (label: string, f: () => unknown) => { try { f(); return `${label}: ok` } catch (e) { return `${label}: threw ${String(e)}` } } +const make = (id: string, handlers = {}) => + createCollection( + localOnlyCollectionOptions<{ id: number; v: number }, number>({ + id, + getKey: (r) => r.id, + ...handlers, + }), + ) +const run = (label: string, f: () => unknown) => { + try { + f() + return `${label}: ok` + } catch (e) { + return `${label}: threw ${String(e)}` + } +} it(`same-key subscriber writes and same-collection nesting`, () => { for (const kind of [`update`, `delete`] as const) { const c = make(`lo-${kind}`) let done = false c.subscribeChanges((changes) => { - if (done || !changes.some((ch) => ch.key === 1 && ch.type === `insert`)) return + if (done || !changes.some((ch) => ch.key === 1 && ch.type === `insert`)) + return done = true - if (kind === `update`) c.update(1, (d) => { d.v = 2 }); else c.delete(1) + if (kind === `update`) + c.update(1, (d) => { + d.v = 2 + }) + else c.delete(1) }) - console.log(run(`subscriber ${kind}s same key`, () => c.insert({ id: 1, v: 1 })), `rows:`, JSON.stringify(c.toArray.map((r) => [r.id, r.v]))) + console.log( + run(`subscriber ${kind}s same key`, () => c.insert({ id: 1, v: 1 })), + `rows:`, + JSON.stringify(c.toArray.map((r) => [r.id, r.v])), + ) } for (const handler of [false, true]) { - const c = make(`lo-nest-${handler}`, handler ? { onInsert: async () => {} } : {}) + const c = make( + `lo-nest-${handler}`, + handler ? { onInsert: async () => {} } : {}, + ) let inner = `not run` c.subscribeChanges((changes) => { if (!changes.some((ch) => ch.key === 1)) return - try { c.insert({ id: 2, v: 0 }); inner = `ok` } catch (e) { inner = `threw ${String(e)}` } + try { + c.insert({ id: 2, v: 0 }) + inner = `ok` + } catch (e) { + inner = `threw ${String(e)}` + } + }) + c.subscribeChanges((changes) => { + if (changes.some((ch) => ch.key === 2)) throw new Error(`boom2`) }) - c.subscribeChanges((changes) => { if (changes.some((ch) => ch.key === 2)) throw new Error(`boom2`) }) - console.log(`handler=${handler}`, run(`outer`, () => c.insert({ id: 1, v: 0 })), `inner:`, inner) + console.log( + `handler=${handler}`, + run(`outer`, () => c.insert({ id: 1, v: 0 })), + `inner:`, + inner, + ) } }) From 369aef13706036cbb9e5d4db0e04349ed5660a96 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 11 Oct 2026 09:21:48 -0600 Subject: [PATCH 11/14] refactor(db): scope the direct-write fix to its core law Per the maintainer, nested-write failure attribution and the local-only contract-breach settlement are out of scope. This removes takePublicationFailure, the same-Collection drain fallback and the local-only storage check, and the oracle dimensions that only those paths needed. A direct write whose subscriber throws still rolls back only its own transaction and rethrows, and a local-only direct write still completes and rethrows; mutants without either fail the oracles. Co-authored-by: Isaac --- .../fix-direct-write-subscriber-throw.md | 2 +- docs/contributing/oracle-coverage.md | 2 +- packages/db/src/collection/mutations.ts | 62 ++------ packages/db/src/collection/state.ts | 2 +- packages/db/src/scheduler.ts | 22 --- .../tests/change-event-history-oracle.test.ts | 82 ++--------- .../local-only-direct-write-oracle.test.ts | 103 -------------- ...nsaction-ownership-oracle.property.test.ts | 134 +----------------- 8 files changed, 28 insertions(+), 381 deletions(-) diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md index 5f90ac030a..281427d4aa 100644 --- a/.changeset/fix-direct-write-subscriber-throw.md +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. A direct write made inside another Collection's change subscriber, including a local-only one, now fails on its own subscriber's error. Before, the error failed the outer write instead. +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 9895c846a1..3b8870de3e 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -248,7 +248,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A direct write may also run inside a change subscriber of the other Collection: its subscriber failure is its own, so it rejects and rolls back, and the outer write, whose subscriber catches the error, has only its own outcome. The outer Collection sometimes has its own throwing subscriber, registered before the nested write, and its publication sometimes defers a failure first: a sync commit to a third Collection meets a throwing subscriber. The outer write must then fail with one of those errors and roll back, and otherwise complete with one handler call. A nested write's take of its own failure must keep the deferred one. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model, also when the write runs inside another local-only Collection's subscriber, whose write must succeed. When it runs inside a subscriber of its own Collection's publication, it takes the transaction path, so its subscriber failure rolls it back and the outer write still succeeds. Focused witnesses cover a subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. A local-only write that throws before it stores its rows fails its transaction; only a contract breach reaches that, so focused witnesses cover insert, update and delete. Open: a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limit): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/src/collection/mutations.ts b/packages/db/src/collection/mutations.ts index 8cab07af36..2310757360 100644 --- a/packages/db/src/collection/mutations.ts +++ b/packages/db/src/collection/mutations.ts @@ -6,7 +6,6 @@ import { import { normalizeError } from '../utils/error.js' import { safeRandomUUID } from '../utils/uuid' import { createTransaction, getActiveTransaction } from '../transactions' -import { takePublicationFailure, withPublicationContext } from '../scheduler' import { DeleteKeyNotFoundError, DuplicateKeyError, @@ -205,11 +204,7 @@ export class CollectionMutationsManager< type: OperationType, ): TransactionType | undefined { const direct = this.state.localOnlyDirectWrite - // A write during this Collection's own sync drain is queued behind it, so - // its subscribers would run after this call. The transaction path - // publishes it now. - if (!direct?.types.has(type) || this.state.isDrainingSyncTransactions) - return undefined + if (!direct?.types.has(type)) return undefined for (const transaction of this.state.transactions.values()) { // A persisting transaction holds sync commits, and a pending one // overlays them. @@ -225,33 +220,14 @@ export class CollectionMutationsManager< mutationFn: () => Promise.resolve(), }) transaction.applyMutations(mutations) - // `write` stores the rows before it publishes them. Inside a publication - // context a subscriber failure comes back as a value, so it cannot undo - // the write and the transaction completes. Only a contract breach makes - // `write` itself throw, and then the rows are not stored. - const outcome: { stored: boolean; failure?: { error: unknown } } = { - stored: false, - } + // The rows are stored when `write` publishes them, so a throwing + // subscriber cannot undo the write; the transaction still completes. try { - withPublicationContext(() => { - outcome.failure = takePublicationFailure(() => direct.write(mutations)) - outcome.stored = true - }) - } catch (error) { - outcome.failure ??= { error } - } - const { stored, failure } = outcome - if (stored) { + direct.write(mutations) + } finally { transaction.setState(`completed`) transaction.isPersisted.resolve(transaction) - } else { - const error = normalizeError(failure!.error) - transaction.error = { message: error.message, error } - transaction.setState(`failed`) - transaction.isPersisted.promise.catch(() => undefined) - transaction.isPersisted.reject(error) } - if (failure) throw failure.error return transaction } @@ -302,27 +278,19 @@ export class CollectionMutationsManager< }), }) try { - // Inside another Collection's publication, a subscriber failure is - // deferred to that publication. It is this write's failure, so take it. - const failure = takePublicationFailure(() => - this.applyOwnedMutations(transaction, mutations), - ) - if (failure) throw failure.error + this.applyOwnedMutations(transaction, mutations) } catch (error) { // A subscriber threw before the handler could run. Roll back only this // write, so no Collection keeps a transaction that nothing will settle. - // The admission error is the reported cause, also for the rollback's - // own subscriber failures, deferred or not. - takePublicationFailure(() => { - try { - transaction.rollback({ - isSecondaryRollback: true, - error: normalizeError(error), - }) - } catch { - // Settlement still ran every step. - } - }) + // The admission error is the reported cause. + try { + transaction.rollback({ + isSecondaryRollback: true, + error: normalizeError(error), + }) + } catch { + // Settlement still ran every step. + } throw error } // Errors still reject `isPersisted.promise`. This catch only prevents an diff --git a/packages/db/src/collection/state.ts b/packages/db/src/collection/state.ts index 54cdd50774..5723483aaf 100644 --- a/packages/db/src/collection/state.ts +++ b/packages/db/src/collection/state.ts @@ -189,7 +189,7 @@ export class CollectionStateManager< public recentlySyncedKeys = new Set() public hasReceivedFirstCommit = false public isCommittingSyncTransactions = false - public isDrainingSyncTransactions = false + private isDrainingSyncTransactions = false private syncRunGeneration = 0 public isLocalOnly = false /** diff --git a/packages/db/src/scheduler.ts b/packages/db/src/scheduler.ts index 52f1e94041..9fb1a08493 100644 --- a/packages/db/src/scheduler.ts +++ b/packages/db/src/scheduler.ts @@ -180,28 +180,6 @@ export function recordPublicationError(error: unknown): void { activePublicationFailure ??= { error } } -/** - * Runs `run` and returns the first listener failure it recorded inside an - * outer publication, which then does not report it. Outside a publication a - * listener failure is thrown, so there is nothing to take. - */ -export function takePublicationFailure( - run: () => void, -): { error: unknown } | undefined { - if (activePublicationContext === undefined) { - run() - return undefined - } - const outer = activePublicationFailure - activePublicationFailure = undefined - try { - run() - return activePublicationFailure - } finally { - activePublicationFailure = outer - } -} - /** * Runs one synchronous Collection publication inside a scheduler context. * Nested publications share the outer context, so downstream live queries run diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 0be8bfbb68..9dfb97b44f 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -94,12 +94,6 @@ type GeneratedHistory = { mode: `batched` | `sequential` /** The operation whose publication meets a throwing subscriber. */ throwOn?: number - /** - * The throwing operation runs inside a change subscriber during another - * write: of another Collection, or of this Collection on a key outside the - * model, which is removed again afterwards. - */ - nested?: `other` | `same` } type GeneratedStep = { key: Key; deleteWhenPresent: boolean } @@ -376,7 +370,6 @@ async function runHistory( id: string, indexType?: IndexType, throwOn?: number, - nested?: `other` | `same`, ): Promise { const initialRows = [...expectedRowsAfter(initialKeys, [])].map( ([, row]) => row, @@ -458,83 +451,27 @@ async function runHistory( // publishes. A local-only direct write stores its rows as it publishes // them, so the throw cannot undo the write: the call rethrows, and its // transaction still settles. The rows and the mirror follow the model. - // When the operation runs inside another write's subscriber, the failure - // is still this call's, and the outer write succeeds. Inside this - // Collection's own publication the operation takes the transaction path, - // so its failure rolls it back instead. The subscriber - // throws only for the operation's key. const injected = new Error(`subscriber failed`) - let armedKey: Key | undefined + let armed = false const throwing = throwOn === undefined ? undefined : collection.subscribeChanges( - (changes) => { - if (changes.some((change) => change.key === armedKey)) - throw injected + () => { + if (armed) throw injected }, { includeInitialState: true }, ) const { created, restore: restoreCreate } = captureCreatedTransactions(collection) - // A nested write's failure belongs to that write, even inside another - // Collection's publication: the outer write, whose subscriber catches - // the inner error, succeeds. - const outer = createCollection( - localOnlyCollectionOptions({ - id: `${id}-outer`, - getKey: (item: TestItem) => item.id, - }), - ) - const OUTER_KEY = 99 - const applyNested = (op: Op, target: typeof collection) => { - let inner: { error: unknown } | undefined - let ran = false - const subscription = target.subscribeChanges((changes) => { - if (ran || changes.every((change) => change.key !== OUTER_KEY)) return - ran = true - try { - applyOp(collection, op) - } catch (error) { - inner = { error } - } - }) - let outerError: unknown - try { - target.insert({ id: OUTER_KEY, name: `outer`, fileId: `f1` }) - } catch (error) { - outerError = error - } finally { - subscription.unsubscribe() - } - expect(ran, `nested write ran`).toBe(true) - expect(outerError, `outer write unaffected`).toBe(undefined) - // The outer row is outside the model; remove it again. - target.delete(OUTER_KEY) - if (inner) throw inner.error - } const applyAt = (op: Op, operationIndex: number) => { if (operationIndex !== throwOn) return applyOp(collection, op) - armedKey = op.key + armed = true const before = created.length try { - if (nested) applyNested(op, nested === `same` ? collection : outer) - else applyOp(collection, op) + applyOp(collection, op) } catch (error) { expect(error, `operation ${operationIndex} rethrows`).toBe(injected) - if (nested === `same`) { - // During this Collection's own publication the operation takes the - // transaction path, so its subscriber failure rolls it back. The - // outer insert and its removal also create transactions. Apply the - // operation again, unobserved, so the history continues as planned. - expect(created.length, `transactions created`).toBe(before + 3) - expect( - (created[before + 1] as unknown as { state: string }).state, - `operation ${operationIndex} rolls back`, - ).toBe(`failed`) - armedKey = undefined - return applyOp(collection, op) - } expect(created.length, `one transaction`).toBe(before + 1) expect( (created[before] as unknown as { state: string }).state, @@ -542,7 +479,7 @@ async function runHistory( ).toBe(`completed`) return created[before]! } finally { - armedKey = undefined + armed = false } throw new Error(`operation ${operationIndex} did not meet the throw`) } @@ -623,7 +560,6 @@ async function verifyGeneratedHistory({ sequence, mode, throwOn, - nested, }: GeneratedHistory): Promise { await runHistory( sequence, @@ -632,7 +568,6 @@ async function verifyGeneratedHistory({ `history-oracle-generated-${initialKeys.join(``)}-${mode}-${describeSequence(sequence)}`, undefined, throwOn === undefined ? undefined : throwOn % sequence.length, - nested, ) } @@ -780,11 +715,10 @@ describe(`change-event history oracle`, () => { mode: fc.constantFrom(`batched` as const, `sequential` as const), // About one history in three meets a throwing subscriber. throwOn: fc.option(fc.nat(19), { nil: undefined, freq: 2 }), - nested: fc.constantFrom(undefined, `other` as const, `same` as const), }) - .map(({ initialKeys, steps, mode, throwOn, nested }) => { + .map(({ initialKeys, steps, mode, throwOn }) => { const sequence = opsFromPresence(initialKeys, steps, 1) - return { initialKeys, sequence, mode, throwOn, nested } + return { initialKeys, sequence, mode, throwOn } }) // The fixed prefix forces reused keys through an already-built index. diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index f1b975d7e9..443da4e893 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -163,109 +163,6 @@ describe(`local-only direct writes`, () => { expect(orders.insert({ id: 3, value: `c` }).state).toBe(`completed`) }) - // Invariant witness, not a generated history: only a contract breach can - // make the local-only write throw before it stores its rows, so no legal - // history reaches it. The transaction must then report the failure instead - // of completing. - // A subscriber may write the key it was told about, as a normalizing or - // rejecting subscriber does. The first write stored its rows before that, - // so it completes and does not throw. The second write runs during this - // Collection's own publication, so it takes the transaction path. - for (const kind of [`update`, `delete`] as const) { - it(`completes a direct insert whose subscriber ${kind}s the same key`, async () => { - const orders = createOrders() - const { created, restore: restoreCreate } = - captureCreatedTransactions(orders) - let reacted = false - const subscription = orders.subscribeChanges((changes) => { - if (reacted || changes.every((change) => change.key !== 2)) return - reacted = true - if (kind === `update`) - orders.update(2, (draft) => { - draft.value = `normalized` - }) - else orders.delete(2) - }) - try { - expect(() => orders.insert({ id: 2, value: `b` })).not.toThrow() - } finally { - subscription.unsubscribe() - restoreCreate() - } - expect(orders.get(2)?.value).toBe( - kind === `update` ? `normalized` : undefined, - ) - await Promise.all( - created.map((transaction) => transaction.isPersisted.promise), - ) - expect(created.map((transaction) => transaction.state)).toEqual([ - `completed`, - `completed`, - ]) - }) - } - - // A subscriber may throw a value that is not an Error. The rolled-back - // transaction still records an Error, so its receipt names the failure. - it(`records an Error when a direct write's subscriber throws a non-Error`, async () => { - const orders = createOrders({ onInsert: async () => {} }) - const { created, restore: restoreCreate } = - captureCreatedTransactions(orders) - const subscription = orders.subscribeChanges(() => { - throw `bad` - }) - try { - expect(() => orders.insert({ id: 2, value: `b` })).toThrow(`bad`) - } finally { - subscription.unsubscribe() - restoreCreate() - } - expect(created.map((transaction) => transaction.state)).toEqual([`failed`]) - expect(created[0]!.error?.error).toBeInstanceOf(Error) - expect(created[0]!.error?.message).toBe(`bad`) - await expect(created[0]!.isPersisted.promise).rejects.toBeInstanceOf(Error) - }) - - for (const kind of [`insert`, `update`, `delete`] as const) { - it(`fails a direct ${kind} whose rows were not stored`, async () => { - const orders = createOrders() - const { created, restore: restoreCreate } = - captureCreatedTransactions(orders) - const direct = ( - orders._state as unknown as { - localOnlyDirectWrite: { write: (mutations: unknown) => void } - } - ).localOnlyDirectWrite - const write = direct.write - const breach = new Error(`storage failed`) - direct.write = () => { - throw breach - } - try { - expect(() => { - if (kind === `insert`) orders.insert({ id: 2, value: `b` }) - else if (kind === `update`) - orders.update(1, (draft) => { - draft.value = `changed` - }) - else orders.delete(1) - }).toThrow(breach) - } finally { - direct.write = write - restoreCreate() - } - - // The rows the write would have stored are not there. - if (kind === `insert`) expect(orders.has(2)).toBe(false) - else expect(orders.get(1)?.value).toBe(`a`) - expect(created.map((transaction) => transaction.state)).toEqual([ - `failed`, - ]) - expect(created[0]!.error?.error, `transaction error`).toBe(breach) - await expect(created[0]!.isPersisted.promise).rejects.toBe(breach) - }) - } - it(`runs the user's handler for that operation type`, async () => { let calls = 0 const orders = createOrders({ diff --git a/packages/db/tests/transaction-ownership-oracle.property.test.ts b/packages/db/tests/transaction-ownership-oracle.property.test.ts index a3f337c012..7c23a54102 100644 --- a/packages/db/tests/transaction-ownership-oracle.property.test.ts +++ b/packages/db/tests/transaction-ownership-oracle.property.test.ts @@ -56,13 +56,6 @@ import type { Transaction } from '../src/transactions.js' * transaction rolls back without rolling back other transactions, and no * Collection keeps it. Either way the write's own transaction has settled, * with its `isPersisted`. So a direct write never changes the model. - * - A direct write can run inside a change subscriber of the other - * Collection, during that Collection's own direct insert of key 4. Its - * subscriber failure belongs to it: it rejects and rolls back, and the - * outer write is not told. The outer write fails exactly when its own - * publication recorded a failure: an outer subscriber threw, or a sync - * commit to a third Collection, made earlier in that publication, met a - * throwing subscriber. The nested write must keep that earlier failure. * - Error shape: a settling call that ran no throwing subscriber reports no * subscriber error. Otherwise it throws one of the subscriber errors, as * is. The contract does not say which one, because these failures are rare @@ -137,18 +130,6 @@ type Step = throws: boolean /** The throwing subscriber was sent the rows before the write. */ seen: boolean - /** - * The write runs inside a change subscriber of the other Collection, - * during that Collection's own direct insert of key 4. - */ - nested: boolean - /** A nested write's outer Collection also has a throwing subscriber. */ - outerThrows: boolean - /** - * Before the nested write, the outer publication defers a failure: a - * sync commit to a third Collection meets a throwing subscriber. - */ - priorDeferred: boolean } type ModelState = `pending` | `persisting` | `completed` | `failed` @@ -303,9 +284,6 @@ const step: fc.Arbitrary = fc.oneof( value: fc.integer({ min: 1, max: 9 }), throws: fc.boolean(), seen: fc.boolean(), - nested: fc.boolean(), - outerThrows: fc.boolean(), - priorDeferred: fc.boolean(), }), }, { @@ -413,15 +391,6 @@ async function runHistory(steps: ReadonlyArray): Promise { A: await makeCollection(`ownership-a-${suffix}`), B: await makeCollection(`ownership-b-${suffix}`), } - // A third Collection outside the model. A sync commit to it inside another - // publication meets a throwing subscriber, which defers its failure. - const deferred = await makeCollection(`ownership-deferred-${suffix}`) - const deferredFailure = new Error(`deferred subscriber failed`) - let deferredArmed = false - deferred.collection.subscribeChanges(() => { - if (deferredArmed) throw deferredFailure - }) - let nextDeferredKey = 100 const model = new OwnershipModel() const driven: Array = [] const settle = (entry: Driven) => { @@ -517,11 +486,8 @@ async function runHistory(steps: ReadonlyArray): Promise { } expect(collection.has(9), `${label}: ${on} merged-away row`).toBe(false) expect(collection.has(3), `${label}: ${on} direct insert`).toBe(false) - expect(collection.has(4), `${label}: ${on} outer direct insert`).toBe( - false, - ) const { mirror } = collections[on] - for (const key of [1, 2, 3, 4, 9] as const) + for (const key of [1, 2, 3, 9] as const) expect(mirror.get(key), `${label}: ${on} mirror of row ${key}`).toBe( collection.get(key)?.v, ) @@ -579,12 +545,11 @@ async function runHistory(steps: ReadonlyArray): Promise { captureCreatedTransactions(collection) const settled = new Set>() const handledBefore = collections[current.on].handled.calls - let checkOuter: (() => void) | undefined const { result, raised } = await withThrowingSubscribers( label, current.throws ? current.on : undefined, () => { - const write = () => { + try { if (current.op === `insert`) collection.insert({ id: 3, v: current.value }) else if (current.op === `delete`) collection.delete(current.key) @@ -592,99 +557,6 @@ async function runHistory(steps: ReadonlyArray): Promise { collection.update(current.key, (draft) => { draft.v = current.value }) - } - try { - if (!current.nested) write() - else { - // The write's outcome belongs to the write, even inside the - // other Collection's publication: its caller sees its error, - // and the outer write, whose subscriber catches it, has only - // its own outcome. It fails exactly when its own subscriber - // throws, and otherwise reaches its handler once. - const outerName = current.on === `A` ? `B` : `A` - const outer = collections[outerName].collection - const outerHandledBefore = collections[outerName].handled.calls - const outerFailure = new Error(`outer subscriber failed`) - const outerCreated = captureCreatedTransactions(outer) - let inner: { error: unknown } | undefined - let ran = false - // The outer failure is recorded before the nested write runs, - // so taking the nested failure must keep it. - const outerThrowing = current.outerThrows - ? outer.subscribeChanges(() => { - throw outerFailure - }) - : undefined - let deferredRan = false - const deferring = current.priorDeferred - ? outer.subscribeChanges(() => { - if (deferredRan) return - deferredRan = true - deferredArmed = true - try { - deferred.utils.begin() - deferred.utils.write({ - type: `insert`, - value: { id: nextDeferredKey++, v: 0 }, - }) - deferred.utils.commit() - } finally { - deferredArmed = false - } - }) - : undefined - const subscription = outer.subscribeChanges(() => { - if (ran) return - ran = true - try { - write() - } catch (error) { - inner = { error } - } - }) - let outerError: unknown - try { - outer.insert({ id: 4, v: 1 }) - } catch (error) { - outerError = error - } finally { - subscription.unsubscribe() - deferring?.unsubscribe() - outerThrowing?.unsubscribe() - outerCreated.restore() - } - expect(ran, `${label}: nested write ran`).toBe(true) - // The outer write fails with a failure that its publication - // recorded; a nested write's own failure is not among them. - // When both apply, either error may be reported: precise - // errors are not required (maintainer decision). - const outerFails = current.outerThrows || current.priorDeferred - const outerFailures = [ - ...(current.outerThrows ? [outerFailure] : []), - ...(current.priorDeferred ? [deferredFailure] : []), - ] - if (outerFails) - expect( - outerFailures, - `${label}: outer write outcome`, - ).toContain(outerError) - else - expect(outerError, `${label}: outer write outcome`).toBe( - undefined, - ) - // The outer handler settles after promises flush. - checkOuter = () => { - expect( - outerCreated.created.map((tx) => tx.state), - `${label}: outer transaction`, - ).toEqual([outerFails ? `failed` : `completed`]) - expect( - collections[outerName].handled.calls - outerHandledBefore, - `${label}: outer handler calls`, - ).toBe(outerFails ? 0 : 1) - } - if (inner) throw inner.error - } } finally { restoreCreate() for (const tx of created) @@ -697,7 +569,6 @@ async function runHistory(steps: ReadonlyArray): Promise { () => {}, current.seen, ) - checkOuter?.() // The throwing subscriber must reach the admission of an insert or an // update, so the write fails rather than passing unnoticed. A // subscriber is not told of a delete of a row it was never sent, so @@ -865,7 +736,6 @@ async function runHistory(steps: ReadonlyArray): Promise { await flush() await collections.A.collection.cleanup() await collections.B.collection.cleanup() - await deferred.collection.cleanup() } } From 44ffcaedd8f11b225283f9d3e713554c6bffa386 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 11 Oct 2026 09:22:22 -0600 Subject: [PATCH 12/14] test(db): remove a scratch probe committed by mistake Co-authored-by: Isaac --- packages/db/tests/_mine_probe/lo.test.ts | 67 ------------------------ 1 file changed, 67 deletions(-) delete mode 100644 packages/db/tests/_mine_probe/lo.test.ts diff --git a/packages/db/tests/_mine_probe/lo.test.ts b/packages/db/tests/_mine_probe/lo.test.ts deleted file mode 100644 index fc14ea9a98..0000000000 --- a/packages/db/tests/_mine_probe/lo.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { it } from 'vitest' -import { createCollection } from '../../src/index' -import { localOnlyCollectionOptions } from '../../src/local-only' - -const make = (id: string, handlers = {}) => - createCollection( - localOnlyCollectionOptions<{ id: number; v: number }, number>({ - id, - getKey: (r) => r.id, - ...handlers, - }), - ) -const run = (label: string, f: () => unknown) => { - try { - f() - return `${label}: ok` - } catch (e) { - return `${label}: threw ${String(e)}` - } -} - -it(`same-key subscriber writes and same-collection nesting`, () => { - for (const kind of [`update`, `delete`] as const) { - const c = make(`lo-${kind}`) - let done = false - c.subscribeChanges((changes) => { - if (done || !changes.some((ch) => ch.key === 1 && ch.type === `insert`)) - return - done = true - if (kind === `update`) - c.update(1, (d) => { - d.v = 2 - }) - else c.delete(1) - }) - console.log( - run(`subscriber ${kind}s same key`, () => c.insert({ id: 1, v: 1 })), - `rows:`, - JSON.stringify(c.toArray.map((r) => [r.id, r.v])), - ) - } - for (const handler of [false, true]) { - const c = make( - `lo-nest-${handler}`, - handler ? { onInsert: async () => {} } : {}, - ) - let inner = `not run` - c.subscribeChanges((changes) => { - if (!changes.some((ch) => ch.key === 1)) return - try { - c.insert({ id: 2, v: 0 }) - inner = `ok` - } catch (e) { - inner = `threw ${String(e)}` - } - }) - c.subscribeChanges((changes) => { - if (changes.some((ch) => ch.key === 2)) throw new Error(`boom2`) - }) - console.log( - `handler=${handler}`, - run(`outer`, () => c.insert({ id: 1, v: 0 })), - `inner:`, - inner, - ) - } -}) From 99032de5f8171fa7971e10cc43b8b7b38e8a17eb Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 11 Oct 2026 10:40:05 -0600 Subject: [PATCH 13/14] test(db): restore the local-only same-key and non-Error witnesses The scope trim removed them by mistake while the coverage map still listed them. The coverage map now also states the approved local-only divergence between the fast path and the transaction path. Co-authored-by: Isaac --- docs/contributing/oracle-coverage.md | 2 +- .../tests/change-event-history-oracle.test.ts | 2 +- .../local-only-direct-write-oracle.test.ts | 58 +++++++++++++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) diff --git a/docs/contributing/oracle-coverage.md b/docs/contributing/oracle-coverage.md index 3b8870de3e..d7ef0d543d 100644 --- a/docs/contributing/oracle-coverage.md +++ b/docs/contributing/oracle-coverage.md @@ -248,7 +248,7 @@ comment and the current API/architecture contract before extending its model. | 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. Fragmented lazy-demand consolidation covers delta growth and successful, rejected/retried, and obsolete replacements at the Collection request boundary; it observes exact key unions and abort checkpoints. A compiled-includes fixture makes adapter unload evict owned child rows and proves a rejected union cannot remove established public child rows. Retry after that terminal live-query failure remains covered at the acquisition boundary; no automatic in-place recovery is claimed. 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), [change-event history](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts), [sync publication reentrancy](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-sync-reentrancy-oracle.test.ts), [history](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-history-oracle.property.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/collection-subscription-lifecycle-publication-oracle.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), [PR #1902 review](oracle-reviews/pr-1902-change-event-history.md), [batch-order decision review](oracle-reviews/change-event-batch-order.md), [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md) | Core Collection `insert`/`update`/`delete` admission while `startSync:false` is idle; complete public-row/change-message agreement across bounded one- and two-key histories plus replayable four-key campaigns, where every message must be valid for a strict consumer (insert only for an absent key, update or delete only for a present one; [absent-key insert review](oracle-reviews/2026-10-02-insert-for-absent-key.md)); a two-key deferred sync history checks each key's causal insert/update/delete trace while allowing any cross-key interleaving. Reversing all deferred messages fails that check; reversing each sync transaction's distinct-key messages passes. Queued duplicate admission and cancellation, ownership and phase histories, exact caller/error/publication evidence, and late completion and restart have separate witnesses. Generated deferred histories with cancellation or reentrant callbacks need a witness in the change-event history owner before claiming general batch-shape coverage. Query write utilities and effect self-dependent disposal remain separate contracts. During a truncate, a ready callback can write to any key after a prior optimistic request. Each subscriber, with or without initial state or a filter, must receive valid messages after the Collection is ready and end at the Collection's rows. A throwing ready callback during sync entry surfaces once the sync function returns, whether it calls markReady or commits a truncate ([round 3 follow-ups](oracle-reviews/2026-10-05-round-3-followups.md)). Callback rollbacks, non-optimistic callback writes, and several writing callbacks remain open ([ready-callback truncate review](oracle-reviews/2026-10-05-truncate-ready-callback-delete.md)). | | Paced mutations | [virtual-clock oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/paced-mutations-oracle.test.ts) | `createPacedMutations` with queue front/back options, bounded waiting capacity at zero and one, cleanup draining in FIFO/LIFO order after a held write, cleanup timing at the configured wait for immediate writes, admitted writes after separate Collection cleanup, debounce and throttle schedules with explicit and omitted options, and first-leading throttle execution at epoch zero. Finite histories compare optimistic rows, returned transaction identity and receipt outcome, execution order and virtual-clock time. Held writes verify queue serialization. Leading-only throttle and debounce witnesses reject skipped calls immediately with their named dropped-call errors, including omitted edges, both edges disabled, and same-row rollback while a prior write is held. Capacity witnesses cover overflow rejection, distinct-key optimistic rollback, same-key admitted-write survival, and in-flight versus waiting admission. Cleanup witnesses check repeated queue cleanup, eventual admitted receipt settlement, direct queue strategy rejection of new callbacks, public post-cleanup rollback with `QueueDisposedError`, pending debounce transactions that wait until the last call's quiet edge after separate Collection cleanup, and a trailing throttle timer that runs at its regular edge after separate Collection cleanup. Deferred custom queue and batch callbacks check compatibility with `void` and `false` execute results, respectively. Frozen options verify factory non-mutation; a mutable option witness checks that debounce uses its construction-time trailing setting. Throwing `onMutate` histories check leading-edge reuse before any row change, failed-row restoration when a pending transaction already has admitted work, eligible leading writes behind a held handler, and throttle pending merges at the throw, start, receipt, and later-timer cuts. New debounce/throttle admission after cleanup, manual commit of a strategy-owned transaction, longer throwing/reentrant bursts, failed persistence, broader capacities and schedules remain outside this owner. | -| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limit): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | +| Optimistic state | [same-key composition](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-composition-oracle.test.ts), [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-oracle.test.ts), [publication](https://github.com/TanStack/db/blob/main/packages/db/tests/optimistic-history-publication-oracle.test.ts) | Optimistic state drops at settlement and held sync transactions publish with it ([settlement-drop review](oracle-reviews/2026-10-03-settlement-drop.md)); whole-row active overlays, metadata and prior-value events. A raw subscriber without initial state checks that each truncate batch inserts a key once ([2026-10-02 review](oracle-reviews/2026-10-02-truncate-raw-subscriber.md)). Never rebase a pending snapshot merely to simplify the model. Generated histories also write source batches inside mutation handlers, before they return; the Collection must own the request by then ([handler source batch review](oracle-reviews/2026-10-01-handler-source-batch.md)). The same-key transaction lane also authors a delete followed by a reinsert, which must be visible optimistically and net to the truth-table request ([state mutation round 2](oracle-reviews/2026-10-02-state-mutation-round-2.md)). Source rows are written as `insert` for a key the source lacks, and batches may delete keys the source holds ([state-stack mutation gaps](oracle-reviews/2026-10-02-state-stack-mutant-gaps.md)). Batches may also delete a key the source never held. That delete leaves an active request in place ([absent-key source delete review](oracle-reviews/2026-10-02-absent-key-source-delete.md)). A separate partial campaign keeps the default partial row update mode, where source updates may omit a field and merge into the source row, not into an optimistic row ([round 3 review](oracle-reviews/2026-10-05-state-mutation-round-3.md)). An aborted open batch restores the driver's own source rows as well as its keys, so a later partial update still omits the field ([aborted-batch review](oracle-reviews/2026-10-08-partial-update-aborted-batch.md)). A failed synchronous manual `mutate` callback is now checked after same-key update, distinct insert, and same-key delete, with and without earlier intent, plus two two-Collection cases, one with a restoration listener failure. The throw cut compares public rows and pending payload; the later commit checks only surviving intent. A restoration listener error retains the callback error as the aggregate cause and does not stop other Collection restoration. These witnesses allow transient notifications during the callback and do not cover nested throwing callbacks or async work after the synchronous scope. After every step the driver also checks, by transaction identity, that the Collection tracks exactly its unsettled transactions, so a failed or rolled-back transaction leaves the map ([failed transaction retention review](oracle-reviews/failed-transaction-retention.md)). The [transaction ownership oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/transaction-ownership-oracle.property.test.ts) owns release across two Collections: merged-away pairs, conflicting rollbacks with a throwing subscriber, asynchronous success and failure settlements with a throwing subscriber, rollbacks from a `truncate` listener during a sync commit, a reused transaction id, and a `mutate()` callback that throws, checked in the same call stack and after promises flush. It also checks the settlement error shape and that a settled transaction's set of tracking Collections is empty; its mutations still name their Collection, so neither owner claims the Collection is collectable. Focused [restoration ownership](https://github.com/TanStack/db/blob/main/packages/offline-transactions/tests/restoration-ownership.test.ts) witnesses check that repeated offline `mutate()` calls add to one transaction, that a completed restoration settles `isPersisted`, and that a restoration one Collection cannot track leaves no Collection tracking it. Its grammar also has direct writes (`insert`, `update` and `delete` outside a transaction), optionally with a throwing subscriber that may have been sent the rows first; it captures each direct write's own transaction and requires it to fail when the admission throws and to complete otherwise, counts handler calls (none for a rejected write), and keeps a mirror subscriber on each Collection that must match the rows after every step. A write whose admission throws rolls back only its own transaction and rethrows. The [change-event history oracle](https://github.com/TanStack/db/blob/main/packages/db/tests/change-event-history-oracle.test.ts) generates local-only direct writes that meet a throwing subscriber, registered after the mirror, and requires the call to rethrow with its transaction completed and the rows following the model. Focused witnesses cover a local-only subscriber that writes the key it was told about, and a subscriber that throws a value that is not an Error; whether a throwing subscriber may stop a later subscriber is not decided. Open (approved limits): a direct write made inside another write's change subscriber reports its subscriber failure to the outer write; on a local-only Collection a direct write whose subscriber throws completes on the fast path but rolls back on the transaction path, and the change-event oracle generates only the fast path; a direct write admitted from inside a sync-commit listener, where the rollback's recompute is deferred, is not generated; two Collection instances with one id, a Collection cleaned up before its transaction settles, and offline restoration have focused witnesses only; no generated history combines a held completed row with a second Collection. | | Drafts and native values | [proxy](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-oracle.test.ts), [detachment](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-detachment-contract-oracle.test.ts), [iteration](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-iteration-contract-oracle.test.ts), [revert](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-revert-oracle.property.test.ts), [native methods](https://github.com/TanStack/db/blob/main/packages/db/tests/proxy-native-methods-oracle.property.test.ts) | Native-operation controls, exact patches and actual stored rows; alias/cycle/adversarial-key histories. Set and Map reorder histories check draft patches and stored iteration order after replacement and clear-and-readd; RegExp replacement/reversion histories check `lastIndex`. General native-mutator and symbol-write support is not established by a plain-object oracle. The revert owner generates assignment, revert, delete, nested, symbol-key, and `for...of` histories and checks `getChanges()` and the draft against an independent draft-equality model: ordered Map and Set contents (including Sets inside Map values), RegExp `lastIndex`, array holes, and symbol keys inside nested values. Map and Set mutators and top-level symbol writes are outside its grammar; array mutators appear only in the mixed block below. Its round-trip grammar also replaces a field with a new object and restores the original value through a nested write. The law requires every changed key, and permits a written key whose final value equals its original to be reported with that value: a false positive repeats the stored value, and a false negative loses a write ([review](oracle-reviews/proxy-revert-replaced-object.md)). A further revert block mixes array `push`, `pop`, and `reverse` with assignments, a nested object replacement, and a retained handle, and checks that a mutator through a replaced handle changes nothing; pinned witnesses require a native write through a Map or Set value, a typed-array view, or a frozen draft to be reported, and a nested revert inside a Map or Set to keep the container's other write; Map and Set mutators are not mixed with assignments, and an equal-valued assignment keeps a retained handle attached (declared). The proxy owner's stored-function law checks, against a native row, that a function stored as data reads back as stored by every read path and that a stored method sees the draft as `this`, so its writes are tracked. The detachment owner pins which key classes a draft copies: enumerable string keys and every symbol key. The revert owner also generates typed arrays (including a subclass that does not forward its constructor argument), URLs, and a class with only private state as written values, with a property that writes two of them over one field. The native-methods owner generates rows (holes, `undefined`, duplicates, nested arrays, empty rows, typed `-0` and `NaN`) and calls every method of `Array.prototype` and `TypedArray.prototype`, in fixed and random campaigns with replay; it compares the result, whether the method returned the array itself, the identity of each original object, and the published row with native values, and a new built-in method fails until it has arguments. The proxy owner's `defineProperty`, stored-function, and frozen-draft laws are bounded tables against native rows. Its `defineProperty` law compares result, value, descriptor, and changes, for values and accessors. The iteration owner checks that array iterators see a push, removal, index write, or length cut made while they are open. Open: a draft reads a class instance of the original row as a plain object, so private-field getters read `undefined`, and a built-in method called through `this` on a nested Map or Set draft rejects the Proxy receiver; a mutator that changes nothing publishes an equal value; writing back a keyless class instance of the original row is a change, and `DataView` setters are untracked ([draft proxy review](oracle-reviews/code-weight-draft-proxy.md)). | | Query DB and observer | [ownership](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/ownership-lifecycle.oracle.test.ts), [persisted direct-write window](https://github.com/TanStack/db/blob/main/packages/query-db-collection/tests/persisted-direct-write-window.oracle.test.ts) ([review](oracle-reviews/issue-2029-direct-write-lock-window.md)), [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-oracle.property.test.ts), [retained snapshots](https://github.com/TanStack/db/blob/main/packages/db/tests/live-query-observer-snapshot-oracle.property.test.ts) ([review](oracle-reviews/pr-2043-retained-observer-snapshots.md)) | Real QueryClient boundary, applied-settlement barriers for existing and cached demand, mutation-handler Collection identity, terminal fetch-record lifecycle, and a per-listener eligibility ledger, not a duplicate dispatch queue. The bounded handler grammar crosses insert, update, delete, parameter and mutation-alias access, refetch, and clearError. A held clearError retry retains the prior public error through initial, background, and Collection application failures; success clears it, failure advances the count, including a repeated error at clock time zero. An intermediate Query retry attempt does not recount the previous background error; a held final attempt can succeed or fail. A held application after successful Query fetch keeps the error visible until the applied result. A mocked persisted-baseline scan holds an older success while a newer terminal Query failure arrives; the newer public error and count survive application, including when the Error object and clock tick repeat. Native SQLite persistence, concurrent retries across multiple tracked Queries, mutation-handler fetch-boundary settlement, and deferred result application remain outside this witness. The mutation overlap grammar crosses both start orders. Check reentry, peer survival, FIFO and disposal independently of final rows. A retained observer snapshot shows its capture-time rows on a later first read of `data` or `state`. | | Query DB SSR dehydration | `packages/query-db-collection/tests/ssr-dehydration-oracle.test.ts`, [review record](oracle-reviews/issue-1950-ssr-dehydration.md) | A plain Query cache control, a live on-demand compound predicate, a signal-only request, and an ordered cursor with a custom comparator all retain their row through QueryClient dehydration, reconstruction of Seroval's emitted stream payload, and Query Core hydration from that payload. The on-demand query functions still receive the original IR, comparator, and signal; serialized metadata retains enumerable user metadata but excludes request options. The original implementation fails the compound and signal cases at the stream checkpoint. A serializable stream-only request-options leak passes the former chunk-count check but fails the current payload assertion. This owner covers successful Query cache entries at the installed Query Core 5.90.20 and Seroval 1.5.0 versions. A TanStack Start integration owner still needs the reporter's Router 1.171.33 and Seroval 1.6.8 path, browser Collection resume/refetch, and request cancellation across SSR. Query subset and pagination oracles remain owners of predicate/order/cursor meaning and supported value types. The issue's `shouldDehydrateQuery` workaround replaces Query Core's success-only default and admits pending/error queries; any documented workaround must preserve that default filter. | diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index 9dfb97b44f..a4bdec0625 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -474,7 +474,7 @@ async function runHistory( expect(error, `operation ${operationIndex} rethrows`).toBe(injected) expect(created.length, `one transaction`).toBe(before + 1) expect( - (created[before] as unknown as { state: string }).state, + created[before]!.state, `operation ${operationIndex} settles`, ).toBe(`completed`) return created[before]! diff --git a/packages/db/tests/local-only-direct-write-oracle.test.ts b/packages/db/tests/local-only-direct-write-oracle.test.ts index 443da4e893..dc2b58559e 100644 --- a/packages/db/tests/local-only-direct-write-oracle.test.ts +++ b/packages/db/tests/local-only-direct-write-oracle.test.ts @@ -163,6 +163,64 @@ describe(`local-only direct writes`, () => { expect(orders.insert({ id: 3, value: `c` }).state).toBe(`completed`) }) + // A subscriber may write the key it was told about, as a normalizing or + // rejecting subscriber does. The first write stored its rows before that, + // so it completes and does not throw. + for (const kind of [`update`, `delete`] as const) { + it(`completes a direct insert whose subscriber ${kind}s the same key`, async () => { + const orders = createOrders() + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) + let reacted = false + const subscription = orders.subscribeChanges((changes) => { + if (reacted || changes.every((change) => change.key !== 2)) return + reacted = true + if (kind === `update`) + orders.update(2, (draft) => { + draft.value = `normalized` + }) + else orders.delete(2) + }) + try { + expect(() => orders.insert({ id: 2, value: `b` })).not.toThrow() + } finally { + subscription.unsubscribe() + restoreCreate() + } + expect(orders.get(2)?.value).toBe( + kind === `update` ? `normalized` : undefined, + ) + await Promise.all( + created.map((transaction) => transaction.isPersisted.promise), + ) + expect(created.map((transaction) => transaction.state)).toEqual([ + `completed`, + `completed`, + ]) + }) + } + + // A subscriber may throw a value that is not an Error. The rolled-back + // transaction still records an Error, so its receipt names the failure. + it(`records an Error when a direct write's subscriber throws a non-Error`, async () => { + const orders = createOrders({ onInsert: async () => {} }) + const { created, restore: restoreCreate } = + captureCreatedTransactions(orders) + const subscription = orders.subscribeChanges(() => { + throw `bad` + }) + try { + expect(() => orders.insert({ id: 2, value: `b` })).toThrow(`bad`) + } finally { + subscription.unsubscribe() + restoreCreate() + } + expect(created.map((transaction) => transaction.state)).toEqual([`failed`]) + expect(created[0]!.error?.error).toBeInstanceOf(Error) + expect(created[0]!.error?.message).toBe(`bad`) + await expect(created[0]!.isPersisted.promise).rejects.toBeInstanceOf(Error) + }) + it(`runs the user's handler for that operation type`, async () => { let calls = 0 const orders = createOrders({ From 4eb657a0812925560c437c373a23d446b3a5b136 Mon Sep 17 00:00:00 2001 From: Isaac Date: Sun, 11 Oct 2026 18:23:06 -0600 Subject: [PATCH 14/14] docs(db): limit the local-only completion claim to the fast path Co-authored-by: Isaac --- .changeset/fix-direct-write-subscriber-throw.md | 2 +- packages/db/tests/change-event-history-oracle.test.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/.changeset/fix-direct-write-subscriber-throw.md b/.changeset/fix-direct-write-subscriber-throw.md index 281427d4aa..fc95df0668 100644 --- a/.changeset/fix-direct-write-subscriber-throw.md +++ b/.changeset/fix-direct-write-subscriber-throw.md @@ -2,4 +2,4 @@ '@tanstack/db': patch --- -A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write, which stores its rows when it publishes them, completes its transaction and rethrows. +A direct write (`insert`, `update` or `delete` outside a transaction) whose change subscriber throws no longer leaves a pending transaction behind. Before, the write's transaction stayed pending with its optimistic row visible, its handler never ran, and the Collection kept tracking it. The write now rolls back its own transaction, without rolling back other transactions, and rethrows the subscriber's error. A local-only direct write that takes the fast path (the Collection has no handler for that operation and no other pending or persisting transaction) stores its rows when it publishes them, so it completes its transaction and rethrows. On the transaction path it rolls back and rethrows, as any other direct write does. diff --git a/packages/db/tests/change-event-history-oracle.test.ts b/packages/db/tests/change-event-history-oracle.test.ts index a4bdec0625..005b450e22 100644 --- a/packages/db/tests/change-event-history-oracle.test.ts +++ b/packages/db/tests/change-event-history-oracle.test.ts @@ -48,7 +48,7 @@ import type { ChangeMessage, SyncConfig } from '../src/types' * repeated changes and complete row values, while leaving cross-key * interleaving unconstrained. * - * About one generated history in three meets a second subscriber, registered + * About one generated history in four meets a second subscriber, registered * after the mirror, that throws while one operation publishes. A local-only * direct write stores its rows as it publishes, so the call rethrows, its * transaction is already completed, and the rows and mirror still follow the @@ -713,7 +713,8 @@ describe(`change-event history oracle`, () => { { minLength: 1, maxLength: 20 }, ), mode: fc.constantFrom(`batched` as const, `sequential` as const), - // About one history in three meets a throwing subscriber. + // About two histories in three arm a throwing subscriber; about one in + // four reaches the operation it throws on. throwOn: fc.option(fc.nat(19), { nil: undefined, freq: 2 }), }) .map(({ initialKeys, steps, mode, throwOn }) => {