fix(crdt): unify add fold, fix counter time-travel reconstruction, harden apply path - #1615
Conversation
…rden apply path
Three fixes to CRDT handling found during a correctness review, plus two
hardenings surfaced by the cross-model review:
- Unify the numeric `add` fold behind a single `addValues()` shared by the
storage path (crdt.add via updateAndFreeze) and the read/serialize path
(Addition.update). Previously they diverged: a numeric string concatenated on
one and coerced on the other, and a bigint folded on one but threw `+bigint`
on the other.
- getRecordAtTime: reconstruct "unknown" fields (a newer plain set with no
inverse) by forward-replaying from the nearest base (reconstructForward)
instead of copying the nearest audit entry's raw field, which returned an
unresolved `{__op__}` object (or a single delta) for a counter later
overwritten by a plain set. Partially addresses the reverse-reconstruction
family in #1413 (a)/(b); pruned-history and out-of-order-branch residuals
remain there. Use Object.hasOwn so an unknown field named like a prototype
member isn't filled from the prototype chain. Pruned-history "keep live value"
behavior preserved.
- updateAndFreeze: an unrecognized CRDT op (corruption / newer-node op type) now
warns and skips on the write/replication apply path instead of throwing, so it
can't abort a commit and wedge a subscription. Resolve ops against the explicit
`operations` registry rather than the crdt.ts export namespace, so a crafted
`__op__` naming another export (addValues, getRecordAtTime, …) can't be invoked
with the wrong arguments. Read-path reconstruction still throws loudly by design.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This comment has been minimized.
This comment has been minimized.
…olding Address cross-model review comments on #1615: - operations registry is now a null-prototype object, so a crafted `__op__` naming an Object.prototype member (toString, valueOf, constructor) resolves to undefined rather than an inherited function in applyForward/applyReverse and updateAndFreeze. - addValues: explicitly establish from a null/undefined prior (avoids `0 + 3n` TypeError), and keep a number-typed field a number when a stray bigint delta arrives instead of throwing a number+bigint TypeError on the apply path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
AI-generated (Claude Opus 4.8). CI note for reviewers — the red checks are pre-existing flakes, not this change.
There is no causal path from this diff ( Marking Ready for Review. |
DavidCockerill
left a comment
There was a problem hiding this comment.
Approving — the three fixes are correct and well-covered (30 unit tests hitting exactly the changed paths, green across matrices). I hand-traced getRecordAtTime on the headline scenario and confirmed it no longer leaks raw {__op__} op-objects, and the unified addValues fold is commutative/associative for the normal numeric case. The warn+skip hardening is the right call — throwing there would wedge the subscription, which is strictly worse.
In plain terms: CRDTs are what let replicas merge edits and land on the same value without a central coordinator. This PR (a) unifies how "add" operations fold so the result doesn't depend on the order they're merged, (b) fixes reconstructing a counter's past value by replaying its op log, and (c) makes the apply path warn-and-skip a bad op instead of throwing (a throw would kill the replication subscription). The remaining notes below are all the same narrow situation — a single field that ends up mixing plain numbers and BigInts — which at worst degrades a time-travel query, not the stored data.
Three low-severity notes, none blocking:
- Cross-type concurrent establish can diverge: field starts null, replica A applies
add(3)thenadd(2n), replica B the reverse — the number/bigint result is order-dependent, so replicas can settle differently. add.reversewasn't unified withaddValuesand is asymmetric on bigint: the forward fold now tolerates a number field + bigint delta (stays a number), but the reverse walk does5 - 3nwith no coercion and throwsCannot mix BigInt. So the fold can now create values its own inverse can't undo on the read path — reachable only in that same mixed-type case, and it degrades a time-travel query rather than corrupting state. Worth routingadd.reversethrough a sharedsubtractValuesmirror, or documenting the asymmetry.getRecordAtTime's fill loop mutatesrecord, which may alias a raw cachedputvalue — pre-existing (the old loop did the same), flagging only because this PR touches the block and the{ ...currentEntry.value }copy elsewhere shows the aliasing hazard is already on your radar. Arecord = { ...record }before the fill would close it; verify against the cache/decoder ownership model first.
— DAIvid (Claude Opus 4.8)
Summary
Three CRDT fixes found during a correctness review of
resources/crdt.ts+ theTable.tswrite path, plus two hardenings surfaced by cross-model review (Codex + Gemini + domain).Unify the
addfold. A singleaddValues()now backs both the storage path (crdt.addviaupdateAndFreeze) and the read/serialize path (Addition.update). They previously diverged: a numeric-string prior value concatenated on the storage path ("5" + 3 = "53") but coerced on the read path, and a bigint folded on the storage path but threw+biginton the read path.Fix counter time-travel reconstruction in
getRecordAtTime. Fields left "unknown" by the reverse walk (a newer plain set has no inverse) are now resolved by forward-replaying from the nearest base (reconstructForward) instead of copying the nearest audit entry's raw field. The old code returned an unresolved{ __op__: 'add', value: N }object (or a single delta) for a counter later overwritten by a plain set. Also switched the fill toObject.hasOwnso an unknown field named like a prototype member isn't filled from the prototype chain.Harden the apply path against unrecognized ops.
updateAndFreezenow warns + skips an unknown__op__(corruption, or a newer-node op type) instead of throwing — a throw here aborts the commit and can wedge a replicated subscription. Ops are resolved against the explicitoperationsregistry rather than the module export namespace, so a crafted__op__naming another export (addValues,getRecordAtTime, …) can't be invoked with the wrong arguments. Read-path reconstruction (applyForward/applyReverse) still throws loudly by design.Where to look
updateAndFreezewarn+skip (tracked.ts) is a deliberate tradeoff, not a clean win. It trades a throw (which wedges replication) for transient divergence, healed by full-copy re-convergence (base-copy sends resolved current-state values, so a stale field is overwritten). This path is unreachable today (onlyaddexists) — it only fires on corruption or a future cross-version op type. Flagging it because it's the one non-obvious design call: if you'd rather fail loud here, say so.additionalAuditRefs), and absent-as-absent residuals are pre-existing (shared with the reverse walk and the read_audit_log crashes (and time-travel point-read fails) for a key that was deleted then re-inserted #1330 delete-crossing path) and stay with audit: getRecordAtTime crash family (3 triggers) + silent-wrong reverse-reconstruction (4 mechanisms) #1413 — this PR does not regress them (the old fill was equal-or-worse in each).Related: partially addresses #1413; a follow-up design issue for a state-based counter (which would retire the best-effort dedup this hardening lives alongside) is #1614.
Unit tests cover all three fixes plus the two hardenings (30 in
crdt.test.js+tracked.test.js).Generated by Claude (Opus 4.8).