fix(crdt): unify add fold, fix counter time-travel reconstruction, harden apply path#1615
fix(crdt): unify add fold, fix counter time-travel reconstruction, harden apply path#1615kriszyp wants to merge 3 commits into
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. |
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).