Evidence by revision, on fix-failed-transaction-retention, based on origin/main 2ab7f3e55:
A Collection tracks its optimistic transactions in state.transactions. Every per-mutation pass walks that map: recomputeOptimisticState, overlayActiveTransactions, hasPersistingTransaction, commitLocalOnlyDirect, and the Query Collection's isMutationPublicationBlocked. A settled transaction must leave the map, whether it succeeded or failed. Otherwise per-mutation cost grows with history, not with live work.
The law has three parts:
Authority: no reader needs a settled transaction. Every loop over the map skips failed entries. A completed entry is read once, by the recompute that records its held rows. Readers outside packages/db are the offline-transactions restore path and an Electric test that reads a completed transaction.
Removal now happens in recomputeOptimisticState. The pass that records a completed transaction's held rows collects every settled entry and deletes those entries after the loop. Deleting during the loop would skip the next entry, because SortedMap.delete splices the key array that the loop iterates.
Every way into the map settles through a recompute: a commit or rollback calls touchCollection(), and offline restoration calls rollback() or recomputes itself. So the sweep covers offline restoration without a separate cleanup path. scheduleTransactionCleanup and its per-mutate() handler registrations are gone.
The sweep deletes by map entry, so a same-id successor that replaced its predecessor's entry stays tracked.
The removed isPersisted handlers also kept a rejected isPersisted promise from reaching unhandledrejection, as main does. A Collection now marks the promise handled once, when it first owns the transaction. Without that, the db and offline-transactions suites reported 48 and 154 unhandled rejections while every test passed.
touchCollection() now recomputes every Collection of a settled transaction before it rethrows the first error. Before, a throwing subscriber in one Collection left the next Collection showing the settled transaction's optimistic row. This was a pre-existing visibility bug.
| Check | Before | After |
|---|---|---|
| Optimistic oracles, 6 files | origin/main: 37 of 323 fail at a failed settle | 556 pass with the exact law |
| Same-id successor | 89de5ea0a: expected undefined to be Transaction; 2ab7f3e55: pass | Pass |
| Offline restoration rollback | 89de5ea0a: expected 1 to be +0 | Pass |
| Two-Collection rollback with a throwing subscriber | 89de5ea0a and 2ab7f3e55: the second Collection keeps the rolled-back row | Pass |
| Tracked after 200 rollbacks | origin/main: 200; 89de5ea0a: up to 49 between flushes | 0 |
The first campaign measured 4,000 mutate and rollback cycles on 10k rows: the median per cycle for the last 200 cycles was 0.67–0.94 ms on origin/main and 0.008 ms on the branch. scripts/bench/incremental-update.ts gave a write geomean of 0.88× and 0.84× against origin/main, with same-code noise at 1.03×. The bench rolls back after each optimistic iteration, so its numbers on main include this leak.
All mutants ran on the follow-up revision.
| Mutant | Outcome (ORC-006) |
|---|---|
| Remove by id when isPersisted settles (the first campaign's design) | Assertion failure: the same-id witness |
| Failed transactions never removed | Assertion failure: 51 tests, the offline witness, and the work witness (443 steps against 34) |
| Keep a failed transaction while a held sync transaction exists | Assertion failure: 24 tests |
| Remove a persisting transaction early | Assertion failure: 91 tests |
| Delete settled entries during the recompute loop | Assertion failure: the adjacent-settled witness only. The generated grammar never settles two transactions before one recompute. |
| Requirement | Outcome |
|---|---|
| ORC-001 | Applicable. The law and authority are above. The claim covers a Collection's tracked transactions; it does not bound transaction-scope state in transactions.ts. |
| ORC-002 | Applicable. The expected count comes from the model's transaction states, not from production. |
| ORC-003 | Applicable. The opening prose of the history oracle states the law beside the model, and the driver states the observation. |
| ORC-004 | Applicable, with gaps. The history grammar generates failed settles, cascading rollbacks, held sync transactions and truncates over one Collection. The ownership grammar adds two Collections, merged-away pairs, truncate-listener rollbacks and throwing subscribers. Same-id instances, offline restoration and two settlements before one recompute have focused witnesses. |
| ORC-005 | Applicable. The driver reads _state.transactions, the semi-public observation used by collection.test.ts and the local-only direct-write oracle. The work witness checks the public cost law without that field. |
| ORC-006 | Applicable. See the mutant table. |
| ORC-007 | Applicable. The history campaigns keep their fixed and random seeds. The ownership oracle runs a fixed seed (2080) and a random or replayed seed through oraclePropertyOptions, with the same property and budget. |
| ORC-008 | Applicable. The ownership model keeps each transaction's owning Collections separately from its writes, because a merged-away pair leaves an owner with no write; the pinned history distinguishes them. |
| ORC-009 | Applicable. "Unsettled" maps to the glossary's persisting state; the model has no separate pending state. |
| ORC-010 | Applicable, with a gap. The ownership driver cleans up in finally, so a cleanup error after an assertion failure replaces it. No run showed a cleanup failure. |
| ORC-011 | Applicable. The reviewer named a shared fault: an id-keyed removal. The same-id witness is the independent check. |
| ORC-013 | Inapplicable. No threshold law. |
| ORC-014 | Inapplicable. No controlled provider. |
The review of 609e97162 found that removal reached only the Collections that the transaction's current mutations touch, while the removed isPersisted cleanup had reached every Collection that ever tracked it.
| ID | Finding | RED | Fix |
|---|---|---|---|
| M1 | A conflicting transaction's rollback throws, so the primary never settles | tx1Settled:false, bValue:1, bTracksTx1:true on 609e97162 and main | rollback() runs every step, then rethrows a subscriber error |
| M2 | Mutations that merge away leave the Collection tracking the transaction | Tracked after commit on 609e97162; released on main | The transaction records each Collection that tracked it; settlement, including an empty commit, recomputes all of them |
| M3 | Two Collection instances with one id | The first instance keeps the transaction on 609e97162; both keep it on main | touchCollection() reaches instances, not ids |
| M4 | A rollback in a truncate listener during a sync commit | Tracked on 609e97162 and main | Removal moved into overlayActiveTransactions, which the sync commit calls after it skips recomputes |
| M5 | Only the first settlement error survives | — | Accepted after the simplification below: these failures are rare, and every step still runs |
| M6 | The history oracle compared a count | A wrong-identity removal could keep the count | It compares transaction ids |
| M7 | The offline witness yielded a microtask before asserting | — | The yield is gone; the row and the entry leave in one recompute |
| M8 | The work witness patched Map and Set globally and asserted equality | — | It counts walks of the Collection's own transactions, warms up first, and asserts a bound |
| M9 | as any in the work witness | — | Reflect.get and Reflect.set |
| M10 | Each removal splices sortedKeys | — | Kept. A recompute normally removes one entry. A local measurement deleted k entries from a SortedMap of n: at n = 1,000 and k = 1, splicing took 1.3 ms against 64 ms for a deferred rebuild over the same 2,000 runs; splicing stays cheaper until k nears n / 10. |
The transaction-ownership oracle (packages/db/tests/transaction-ownership-oracle.property.test.ts, registered as transaction-ownership.settlement-release) owns the law across two Collections. It runs a fixed campaign (seed 2080) and a random or replayed campaign, 200 runs each, plus a pinned history. Its grammar covers writes, a pair that merges away, commits, successful and failed settlements, rollbacks, truncate-listener rollbacks, and a throwing subscriber during a settling step. After each synchronous settling step it compares the tracked ids in the same call stack, then compares tracked ids, rows and isPersisted settlement after promises flush. The model's first draft treated an update that keeps the visible value as a write; production creates no mutation for it, which the contract permits, so the model now skips it.
| Mutant | Outcome |
|---|---|
| A persisting transaction counts as settled | Assertion failure, 15 tests |
| Only the current mutations' Collections recompute | Assertion failure: all three ownership campaigns and the same-id witness |
| touchCollection() deduplicates by Collection id | Assertion failure: the same-id witness only |
| No removal during a sync commit | Assertion failure: all three ownership campaigns |
| A conflicting rollback's throw skips the primary's settlement | Assertion failure: the pinned ownership history only |
| Removal one microtask late | Assertion failure: the same-call checks and two focused witnesses |
| Never remove (main) | Assertion failure, 28 tests |
A low review of 26d34291d found that the ownership driver removed its throwing subscriber in a synchronous finally, before a commit's settlement resumed in a later microtask. A subscriber therefore never threw during an asynchronous success or failure settlement. Measuring the reach showed a wider gap: steps chose a transaction index from 0 to 3 at random, so most pointed at a missing transaction or one in the wrong state, and in one run the settle step ran 0 times in 400 histories.
The driver now keeps the subscriber installed until promises flush, and it asserts that the subscriber ran whenever the model predicts that the step changes that Collection's rows. A step's tx number now chooses among the transactions whose state allows the step, and settle and commit steps are weighted higher. A pinned history throws during an asynchronous success and an asynchronous failure settlement.
| Check, on cb9f8a47b production code | Result |
|---|---|
| New driver | All 4 cases pass; no production defect |
| Old subscriber ordering with the new invocation assertion | The random campaign fails: the throwing subscriber ran: expected 0 to be greater than 0, at an asynchronous failed settlement |
| Mutant: success settlement skips isPersisted when a subscriber throws | New driver: assertion failure in the random campaign and the asynchronous pinned history. Old driver: survives |
| Mutant: touchCollection() stops at the first throwing Collection | New driver: assertion failure in the asynchronous pinned history. Old driver: its random campaign can also reach it through the synchronous rollback path, so this mutant does not distinguish the drivers |
The fixed seed alone does not catch the first mutant; the pinned history is the reliable witness for asynchronous settlement with a throwing subscriber.
Probes on 7fd5de08b and on main fe284ccbd, from a scratch probe file:
| Finding | 7fd5de08b | main | Disposition |
|---|---|---|---|
| A second live transaction with the same id evicts the first | First evicted; its row shows 0, not 5 | Same | Fixed: trackTransaction throws DuplicateTransactionIdError (code 233) before applying. Ids are documented as unique (TransactionConfig.id) |
| TransactionScope removes by id | Settling t2 removed live t1; a later conflicting rollback left t1 pending | Same | Fixed: removal by identity |
| commit() loses the mutation error when a rollback subscriber throws | Rejects with the subscriber error | Same | Fixed: commit() rejects with the mutation error |
| Nested AggregateError from conflicting rollbacks | Pinned history: flat aggregate: expected true to be false | Only the first error escapes | Removed with the aggregate; see the simplification below |
| A settled transaction keeps its Collections | collections.size is 1 after settlement | No such field | Fixed: settlement clears the set |
| Offline restoration bypasses ownership and removes by id | By source | Same | Fixed: restoration uses trackTransaction, and its cleanup settles through touchCollection() |
| Release depends on a recompute | Could not make the capture step throw during settlement; by source, the next recompute or sync-commit end sweeps a skipped release | Main never releases | Refuted as a permanent skip; a transient residue until the next recompute is accepted |
The ownership oracle gained:
A focused witness covers a Collection cleaned up before its transaction settles.
All before the fix, on the oracle above: 8 of 8 cases failed on 7fd5de08b. After the fix: 8 of 8 pass.
| Mutant | Result |
|---|---|
| Swallow settlement errors | Assertion failure, 5 tests |
| Always wrap errors | Assertion failure, 4 tests |
| Throw only the first error | Assertion failure, 2 tests |
| Scope removes by id | Assertion failure, 1 test (the pinned scope-identity history) |
| Overwrite a live same-id transaction | Assertion failure, 3 tests |
| Keep collections after settlement | Assertion failure, 7 tests |
| commit() rethrows settlement errors instead of keeping the mutation error | Assertion failure, 2 tests |
The optimistic-history oracle's tracked-transaction check counted only persisting transactions as unsettled. main's pending manual edits (#2078) showed that the model was wrong, not production: a pending transaction is tracked. The check now counts pending and persisting transactions.
The collections CI group then failed to build PowerSync. A private settlement helper made a handler's TransactionWithMutations unassignable to Transaction<any>, because that type omits a key from Transaction, and Omit drops private members. The db typecheck missed it because nothing in db made that assignment. The helper was then @internal; the simplification below removed it. mutation-handler-compatibility.test-d.ts asserts the assignability. The test fails with the private method and passes without it.
Review decision: precise reports for these rare settlement failures do not earn their code. Correct handling does. The AggregateError, its code 234 and the internal error lists are removed:
The ownership oracle's error-shape law now requires one of the step's subscriber errors, as is, and the mutation error for a failed commit. It does not choose which subscriber error, because the contract does not.
| Mutant | Result |
|---|---|
| touchCollection() stops at the first throw | 4 tests fail |
| Conflicting rollbacks stop at the first throw | 1 test fails |
| A throwing conflicting rollback skips the primary's settlement | 2 tests fail |
| commit() rejects with the settlement error | 3 tests fail |
Probes on 9e7ebb0a8 and on main 2c98b4992:
| Finding | 9e7ebb0a8 | main | Disposition |
|---|---|---|---|
| A direct write (commitDirect) whose subscriber throws leaves a pending transaction tracked | Handler never runs; the row stays pending | Same | Open: the settlement law for a direct write that throws is undecided (rollback or commit) |
| OfflineTransactionAPI.mutate() creates a new transaction with the same id on each call | Second call throws DuplicateTransactionIdError | The second transaction replaced the first and hid its rows | Fixed: repeated calls add to one transaction |
| A completed restoration never settles isPersisted | Pending | Same | Fixed |
| A restoration that one Collection cannot track stays tracked by the others | The first Collection keeps the restored row | Not reachable: no duplicate-id error | Fixed: a secondary rollback, which leaves live transactions on the same keys alone |
| rollback({ error }) on a settled transaction replaces its error | Replaced | Kept | Fixed: the error is recorded after the state checks |
| "A settled transaction holds no reference to its Collections" | Its mutations still name their Collection | — | Claim corrected: its set of tracking Collections is empty |
| The optimistic-history oracle compares tracked transactions by id | Equivalent within its grammar, where ids are unique | — | Now compares identity |
| commit() and isPersisted report settlement errors in different shapes | By source | — | Open: needs a decision |
| A recompute that throws delays release until the next recompute | Same as round 2 | — | Accepted residue, as in round 2 |
The focused witnesses in packages/offline-transactions/tests/restoration-ownership.test.ts and packages/db/tests/transactions.test.ts failed on 9e7ebb0a8 and pass now.
| Mutant | Result |
|---|---|
| mutate() replaces the offline transaction | Assertion failure, 1 test |
| A completed restoration skips isPersisted | Assertion failure, 1 test |
| No rollback after a restoration fails to track | Assertion failure, 1 test |
| A plain rollback, which cascades to the live same-key transaction | Assertion failure, 1 test |
| rollback() records the error before checking the state | Assertion failure, 1 test |
The review had nine findings from reading the code. Each behavioral claim was probed on this head, and on main's core where the claim compared them.
| Finding | Verdict | Change |
|---|---|---|
| 1. With autoCommit, a second offline mutate() throws | Confirmed behavior change | Accepted: the first call commits, and mutate() on a committed transaction throws, as for any Transaction. On main the second call replaced the first transaction and hid its rows. The changeset says so. Witness: the second call throws TransactionNotPendingMutateError and the first row stays. |
| 2. Corrupted outbox mutations without a Collection throw | Refuted | TransactionSerializer.deserializeMutation throws when a Collection is missing, so no restored mutation lacks one. The remaining dead guard in restoration is removed. |
| 3. commit() drops rollback errors | Accepted | Precise errors for rare settlement failures are not required (maintainer decision, 2026-10-09). The mutation error is the reported cause. |
| 4. rollback()'s finally can mask the first error | Accepted | Same decision. The oracle law requires one subscriber error, not a particular one. |
| 5. Release inside overlayActiveTransactions drops held rows when a subscriber commits sync into B | Refuted on the stated path | Probe: a transaction spans A and B, B's confirmation is queued, and A's subscriber commits a sync row into B during settlement. B keeps row 1 as local with no delete event, on this branch and on main's core. |
| 6. A Collection tracks before applyMutations, which might throw | Refuted | applyMutations uses only Map and array operations; nothing in it can throw. Tracking first lets the duplicate-id check change nothing. |
| 7. Restoration warns on a live transaction with the same id | Confirmed, low reach | Restoration loads only transactions the scheduler does not hold, so it needs a cleared scheduler with the transaction still live. A live transaction with this id already shows its rows, so restoration now skips it without a warning. Witness: the existing untrackable-restoration test asserts no warning; a mutant that warns again fails it. |
| 8. A test name includes the PR number | Confirmed | Renamed generated histories with a fixed seed. |
| 9. A test name promises one flat aggregate | Confirmed | Renamed to state the current law: every transaction settles, and one subscriber error is rethrown. |
Noted, outside this change. OfflineTransaction.mutate() with autoCommit rethrows a failed auto-commit inside a .catch, so a failed auto-commit is an unhandled rejection. This is on main as well.