TanStack

Content temporarily unavailable

Optimistic state drops at settlement

Pre-repair executable revision: origin/main at 95c3f9ec9. Repair branch: fix-settled-snapshot-ack. Production code b5e498ddb through 9b82ac3dc. Evidence head 3c85ac0de.

Law

An optimistic transaction overlays its optimistic state only while its mutation function runs. When the mutation function settles, fulfilled or rejected, the state drops. Success does not wait for a sync confirmation: a handler that returns before its server row arrives shows the previous synced row until that row applies.

A sync transaction committed while an optimistic transaction persists is accepted and held. It becomes visible in the publication that drops the optimistic state, and isPersisted settles after that publication.

Each sync transaction therefore has two moments:

  • Accepted: commit() returns, or a persistence wrapper finishes its durable step. Handler-facing writes wait for this moment: Query Collection direct writes, persisted mutation confirmation and startup, and the PowerSync mutation path.
  • Visible: the rows apply and publish. Commit receipts, subset loads, and Collection readiness wait for this moment.

An accepted transaction always applies, in commit order, and a later abort cannot withdraw its rows. A load whose caller aborted still rejects with AbortError. A source discards a stale page by checking the signal before commit().

A sync write is $origin: 'local' only if it was committed while the transaction persisted. A write committed after settlement is 'remote'.

Authority: user decisions recorded in the PR discussion for this branch. These replace the accepted-snapshot retention law (#1213, #1807, #1822, #1870) and begin({ immediate }) (#1130).

Gap

Since #1213, recomputeOptimisticState retained a completed direct transaction's row until a later sync transaction wrote its key. Query Collection direct writes used begin({ immediate: true }) to apply during persistence. After such a write, no later sync transaction retired the retained row. Passing runs of awaits persisted server responses in update handlers depended on an unrelated persisted sync transaction arriving later.

The optimistic-history oracle encoded the retention law as its reference model, so the oracle agreed with the drift.

In #1990, a refetch canceled an earlier result's commit that was waiting for storage. The cancellation invalidated the dependent result, and the collection entered error.

Repair and witnesses

  • Core: removes begin({ immediate }), post-commit cancellation, retention and its dependency machinery, the truncate optimistic snapshot, and the direct-transaction marker. The only hold left keeps a completed row while a queued sync transaction touches its key.
  • Receipts: a commit receipt resolves at visibility. It carries its acceptance moment (withAcceptedReceipt / whenSyncAccepted). Readiness waits for accepted rows to publish.
  • Query Collection: direct writes read and validate accepted rows, and update the Query cache after acceptance. That cache read was the root cause of #1130. A refetch commits without a signal and no longer rolls back accepted ownership. An eager fetch that started before a direct write is discarded.
  • Persistence: stores a held source transaction at acceptance. Internal applies inside the apply mutex wait only for acceptance. Electric reserves its commit turn through persistence.reserveCommitTurn().
  • Electric and TrailBase: reject an aborted load after its accepted rows apply.
  • Optimistic-history oracle: the prose, model, grammar, and driver are rewritten. The handler writes at every cut, with and without awaiting acceptance. It observes the rows visible when isPersisted settles, allows a single cut at settlement, keeps receipts pending while queued, and checks $origin for held and later confirmations.
RunResult
#1990 refetch, first durable write held, on origin/main3 of 3 isolated runs fail: expected 'error' to be 'ready'
#1990 handler writeUpsert, result write held, on origin/main4 of 5 full-file runs fail: expected +0 to be 3 (the retained optimistic row)
Real-SQLite handler test ported from #2002, on origin/main (await source.commit())times out after 5000 ms
Same three on the branchpass; Query Collection file passes 5 of 5 full-file runs
awaits persisted server responses in update handlers, branch20 of 20 full-file runs pass
Eager refetch older than a handler writeUpsert, before its fixexpected 2 to be 3

Mutants

Each mutant is a temporary production edit that is restored after its run.

MutantOwnerResult
Retention kept: hold a completed row without a queued sync transactionstate-retention oraclekilled
Held sync applies before the drop (immediate bypass)state-retention oraclekilled, 11 failures
isPersisted resolves before publicationstate-retention oraclekilled, 40 failures
Handler-facing receipt waits for visibilitystate-retention oraclekilled, 6 failures
Load settles at acceptanceload-subset oracle and state retentionkilled, 10 and 4 failures
$origin marker persists until the next writestate-retention oraclekilled, 6 failures
Held confirmation marked 'remote'state-retention oraclekilled, 7 failures
Core abort cancels an accepted transactionload-subset and refinement oracleskilled
Stale eager fetch appliesQuery Collectionkilled
Query cache reads applied rowsQuery Collectionkilled
TrailBase aborted load resolvesTrailBasekilled
Electric aborted load resolvesElectrickilled
Query Collection passes its signal to commit() againQuery Collectionsurvived; equivalent
Query Collection rolls back accepted ownership on supersessionownership oraclesurvived at first, then killed

Survivors:

  • Signal passed to commit(): this mutant is equivalent. Core now ignores a post-commit abort, so the signal cannot cancel an accepted transaction. The core-level mutant above covers this law.
  • Ownership rollback: every supersession test held durable storage. In that case the result's core transaction had already applied, so restoring the old ownership did nothing. The ownership oracle now also holds the result behind a persisting mutation and checks visible and synced rows after each refetch, including a later empty refetch. The mutant fails there.

After the merge with the state-stack refactor

Every mutant above, plus five more, ran again on the merged code. The merge took #2004's smaller state stack and kept this branch's laws.

MutantOwnerResult
Retention keptstate-retention oraclekilled, 5 failures
Held sync applies before the dropstate-retention oraclekilled, 12 failures
isPersisted resolves before publicationstate-retention oraclekilled, 44 failures
Handler-facing receipt waits for visibilitystate-retention oraclekilled, 5 failures
Load settles at acceptanceload-subset oracle and state retentionkilled, 11 and 4 failures
$origin marker persists until the next writestate-retention oraclekilled, 11 failures
Held confirmation marked 'remote'state-retention oraclekilled, 9 failures
An open sync transaction counts for holdsstate-retention oraclekilled, 5 failures
Older held row wins over the newest completed rowstate-retention oraclekilled, 1 failure
Pre-sync capture counts an open transactionstate-retention oraclekilled, 1 failure
Replay invalidates a committed transaction instead of throwingstate-retention oraclekilled, 1 failure
DbClient chunk queues after open transactionsDbClient hydration oraclekilled, 2 failures
Core abort listener drops an accepted transactionload-subset and refinement oracleskilled, 1 and 1 failures
Core abort listener calls the cancel pathload-subset and refinement oraclessurvived; see below
Stale eager fetch appliesQuery Collectionkilled
Query cache reads applied rowsQuery Collectionkilled
Query Collection passes its signal to commit() againQuery Collectionkilled, 1 failure
Query Collection rolls back accepted ownership on supersessionownership oraclekilled
TrailBase aborted load resolvesTrailBasekilled, 5 failures
Electric aborted load resolvesElectrickilled, 1 failure
Reject a source delete for a never-held keystate-retention oraclekilled, 11 failures
Drop a source delete for a never-held keystate-retention oraclesurvived; equivalent

Corrections and survivors:

  • Signal passed to commit(): the earlier run selected no test, because its name filter no longer matched the supersession test. With the correct filter, the mutant fails. It is not equivalent, and the earlier note about it is withdrawn.
  • Dropped never-held delete: the key is absent from the applied synced rows, and the optimistic state drops at settlement either way. The absent-key review records the rewritten pinned histories.
  • Core abort calls the cancel path: cancelPendingSyncedTransaction now throws SyncQueueInvariantError for a committed transaction. The guard stops the cancellation before it changes state, so the accepted rows still apply. The unguarded variant, which removes the accepted transaction directly, fails in both oracles.

Limits

  • On-demand self-load: a handler that awaits an on-demand load of its own Collection waits for itself. This history is documented and outside the generated grammar.
  • Truncate: a truncate still applies while a transaction persists, and the still-persisting transactions overlay it.
  • duplicate subset loads correctly: 0 of 10 full-file runs fail on origin/main. On the branch it failed every time because its handler wrote no confirmation, until the fixture was changed.
  • Eager refetch race: the guard covers eager mode. On-demand mode keeps its post-write authority path.

Verification

At 3c85ac0de with built dist:

  • db: 7920 tests, and 8224 with type checking; no type errors.
  • Query Collection: 945 tests with type checking; no type errors.
  • Other packages: Electric 629, persistence core 374, node SQLite 110, PowerSync 176, offline 242, TrailBase 67, RxDB 12. React, Vue, Solid, Svelte, and Angular pass.
  • Checks: test:oracles, check:mangle, and test-minified-db pass.