fix(migration): build the v4→v5 canonical structure seed for decoded records#1520
Conversation
|
Reviewed; no blockers found. |
There was a problem hiding this comment.
Code Review
This pull request updates the shapeForStructure function in bin/copyDb.ts to correctly recurse into decoded records (non-plain objects) during database migrations, while continuing to stub leaf object types like Blobs, Dates, and ArrayBuffers. It also introduces a comprehensive regression test suite in unitTests/bin/shapeForStructure.test.js. The review feedback recommends explicitly handling SharedArrayBuffer as a leaf object type (and adding it to the test suite) and optimizing the property iteration by using Object.keys() instead of a for...in loop with hasOwnProperty.
…records
copyDb's structure observer seeds the canonical v5 shared-structures dictionary
by encoding shapeForStructure(record) for each migrated record. The migration
reads source records as RecordObject instances (the encoder's structPrototype),
not plain Object, so shapeForStructure's `value.constructor === Object` gate
stubbed every record to the scalar 1: the observer minted no structure,
canonicalStructures stayed undefined, the persist guard skipped, and the seed
was never written. v5 workers then mint the dictionary from an empty durable in
their own encounter order and fork it — records decode against a divergent
in-memory shape ("end of buffer not reached" / silent wrong values) under
v4→v5 upgrade load (#1508; the #1453 fork family).
Broaden shapeForStructure to recurse any non-leaf object (records and plain
objects alike) while still stubbing leaf object types — Blob (must not be
walked: it would pull the file-backed payload this skeleton exists to avoid),
Date, Buffer/typed arrays, Map, Set. RecordObject `for...in` yields exactly the
data fields (methods are non-enumerable, metadata rides a WeakMap), so the
seeded structure matches what msgpackr encodes for the real record.
Export shapeForStructure and add a unit test (fails without the fix). The
end-to-end behavior was validated with a live 4.7.34→5.1.14 migration repro.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
075991a to
9954b79
Compare
| !ArrayBuffer.isView(value) && | ||
| !(value instanceof ArrayBuffer) && | ||
| !(value instanceof SharedArrayBuffer) && | ||
| !(value instanceof Map) && |
There was a problem hiding this comment.
Map/Set probably should not be leaves here. msgpackr still records structures for object keys/values inside them; for example { m: new Map([["k", { a: 1, b: 2 }]]) } produces both ["m"] and ["a", "b"]. Since Any fields can carry these through migration, the canonical seed can still be incomplete and workers can mint the missing nested structures in different orders.
Suggested fix: recurse Map entries and Set values through shapeForStructure while preserving the container type, and add a regression case for object values inside Map/Set.
🤖 Posted by Codex (gpt-5.5) on Nathan's behalf
ldt1996
left a comment
There was a problem hiding this comment.
manual tests consistently showed this is the right fix, great work!
Summary
copyDb's structure observer seeds the canonical v5 shared-structures dictionary by encodingshapeForStructure(record)for each migrated record. The v4→v5 migration reads source records asRecordObjectinstances (the encoder'sstructPrototype), not plainObject, so the old gatevalue.constructor === Objectstubbed every record to the scalar1. The observer then minted no structure,canonicalStructuresstayedundefined, the persist guard skipped, and the canonical seed was never written. v5 workers then mint the dictionary from an empty durable in their own encounter order and fork it — records decode against a divergent in-memory shape (Data read, but end of buffer not reached/ silent wrong values) under v4→v5 upgrade load.Fix: broaden
shapeForStructureto recurse any non-leaf object (decoded records and plain objects) while still stubbing leaf object types (Blob,Date,Buffer/typed arrays,ArrayBuffer,Map,Set). ABlobmust not be walked — that would pull the file-backed payload the skeleton exists to avoid.RecordObject's own-enumerable keys are exactly its data fields (methods are non-enumerable, metadata rides a WeakMap), so the seeded structure matches what msgpackr encodes for the real record.Why this is the fix (#1508)
This is the prevention side of the v4→v5 structure-id fork family (#1453 / #1163; #1506 is the recovery side). Root cause confirmed live: a single-node
4.7.34 → 5.1.14migration of 300 seeded nested records loggedcanonicalStructures=undefined,WILL_PERSIST=false, andshapeForStructure(record) → 1for a realRecordObject. With the fix the observer mints the structures and the seed persists.Where to look
bin/copyDb.tsshapeForStructure— the broadened recursion gate. The risk to weigh is shape parity: the skeleton must encode to the same struct (key set + order) msgpackr produces for the real record, or it would itself fork the dictionary. It uses own-enumerable keys (matching struct fields) and stubs leaf object types so a binary/blob/date field stays a scalar leaf.Tests
unitTests/bin/shapeForStructure.test.js— guards the recursion (record vs plain vs leaf), own-enumerable-only iteration, and the leaf stubs. Fails onmain(record →1), passes with the fix.integrationTests/upgrade/4.x-upgrade.test.ts(realharperdb@4source, runs in CI) passes with the fix — migration round-trips and records decode after cold restart (no regression). Verified locally; with the fix the migrated default CF contains the[Symbol.for('structures'), 'widgets/']seed.4.7.34 → 5.1.14migration: with the fix the observer mints structures and the seed persists —canonicalStructuresis a 19-entry array,WILL_PERSIST=true(vsundefined/skipped onmain).table()) decodes records to plainObject, not the coldRecordObjects a real v4 source yields; and at the integration layercopyStructuresalready writes a (verbatim v4) seed onmain, so a simple "seed exists" check can't cleanly separate the fix frommain. The multi-worker dictionary fork the seed prevents is validated by the cluster repro. TheshapeForStructureunit test is the clean fail-without-fix guard.Cross-model review
Codex + Gemini (
agy) + a Harper-domain pass. No open blockers. Two findings were addressed in this diff: rawArrayBuffer/SharedArrayBufferwould be walked (now excluded), andfor...inprototype-chain leakage (now own-enumerable only). Adjudicated as not-blocking for migration data (acyclic msgpack values;Blobresolves to the global base; a literal__proto__field encodes identically on both sides): cyclic-reference recursion (pre-existing; the old code recursed objects too) and the__proto__own-key trap.🤖 Generated by KrAIs (Claude Opus 4.8). LLM-authored; please review with that in mind.