diff --git a/package.json b/package.json index a8f7b73..2db4c22 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "test:graph-snapshot-schema": "node tests/graph-snapshot-schema.mjs", "test:graph-snapshot-upgrade": "node tests/graph-snapshot-upgrade.mjs", "test:snapshot-forward-compat": "node tests/snapshot-forward-compat.mjs", + "test:luker-snapshot-forward-compat": "node tests/luker-snapshot-forward-compat.mjs", "test:reroll-transaction-boundary": "node tests/reroll-transaction-boundary.mjs", "test:vector-gate": "node tests/vector-gate.mjs", "test:hide-engine": "node tests/hide-engine.mjs", diff --git a/tests/luker-snapshot-forward-compat.mjs b/tests/luker-snapshot-forward-compat.mjs new file mode 100644 index 0000000..72cc014 --- /dev/null +++ b/tests/luker-snapshot-forward-compat.mjs @@ -0,0 +1,99 @@ +// ST-BME restrained rebirth — Phase 3c Luker sidecar forward-compat tests. +// +// Luker stores the graph as a serialized graph blob inside the checkpoint, not as +// the durable snapshot shape. This test proves the IMPORTANT property: unknown +// future fields on graph nodes/edges survive a Luker checkpoint round-trip +// (build -> normalize -> deserialize). It also documents the intentional boundary +// that the sidecar ENVELOPE metadata (manifest stats, checkpoint meta) is +// normalized/whitelisted on purpose — those are rebuildable operational metrics, +// not graph data. + +import assert from "node:assert/strict"; + +import { + buildLukerGraphCheckpointV2, + normalizeLukerGraphCheckpointV2, +} from "../graph/graph-persistence.js"; +import { + addEdge, + addNode, + createEdge, + createNode, + createEmptyGraph, + deserializeGraph, +} from "../graph/graph.js"; + +// Build a graph and inject unknown FUTURE fields onto a node and an edge. +const graph = createEmptyGraph(); +const nodeA = addNode( + graph, + createNode({ type: "char", fields: { name: "恬恬" }, seq: 1 }), +); +const nodeB = addNode( + graph, + createNode({ type: "event", fields: { title: "相遇" }, seq: 2 }), +); +// Simulate a future writer adding new fields the current build does not know. +nodeA.futureNodeField = { trait: "playful", version: 99 }; +const edge = addEdge( + graph, + createEdge({ fromId: nodeA.id, toId: nodeB.id, relation: "participatesIn" }), +); +edge.futureEdgeField = "edge-keep-me"; + +// 1. Checkpoint round-trip preserves unknown graph record fields. +const checkpoint = buildLukerGraphCheckpointV2(graph, { + revision: 7, + chatId: "chat-luker-fc", + integrity: "integrity-luker", + reason: "test", +}); +assert.ok(checkpoint, "checkpoint built"); +assert.ok(checkpoint.serializedGraph, "checkpoint carries serialized graph"); + +const restored = deserializeGraph(checkpoint.serializedGraph); +const restoredNodeA = restored.nodes.find((n) => n.id === nodeA.id); +const restoredEdge = restored.edges.find((e) => e.fromId === nodeA.id); +assert.ok(restoredNodeA, "node survived Luker checkpoint round-trip"); +assert.deepEqual( + restoredNodeA.futureNodeField, + { trait: "playful", version: 99 }, + "unknown future node field preserved through Luker checkpoint", +); +assert.ok(restoredEdge, "edge survived Luker checkpoint round-trip"); +assert.equal( + restoredEdge.futureEdgeField, + "edge-keep-me", + "unknown future edge field preserved through Luker checkpoint", +); +console.log(" ✓ Luker checkpoint preserves unknown future graph record fields"); + +// 2. normalize re-parse of a checkpoint payload preserves the serialized graph +// verbatim (so unknown fields inside it are never touched by the envelope layer). +const reNormalized = normalizeLukerGraphCheckpointV2({ + ...checkpoint, + // A future writer may add an unknown ENVELOPE field. It is intentionally + // dropped (rebuildable operational metadata), but graph data must be intact. + futureEnvelopeField: "envelope-meta-can-be-dropped", +}); +assert.equal( + reNormalized.serializedGraph, + checkpoint.serializedGraph, + "serialized graph blob is preserved verbatim by the envelope normalizer", +); +const reRestoredNode = deserializeGraph(reNormalized.serializedGraph).nodes.find( + (n) => n.id === nodeA.id, +); +assert.deepEqual( + reRestoredNode.futureNodeField, + { trait: "playful", version: 99 }, + "graph record unknown fields still intact after envelope re-normalize", +); +assert.equal( + "futureEnvelopeField" in reNormalized, + false, + "envelope-level unknown metadata is intentionally normalized away (rebuildable)", +); +console.log(" ✓ Luker envelope normalize keeps graph data intact while normalizing metadata"); + +console.log("luker-snapshot-forward-compat tests passed");