From 63912595552368871ab5ec04be824c342c52c98d Mon Sep 17 00:00:00 2001 From: youzini Date: Sat, 30 May 2026 18:02:51 +0000 Subject: [PATCH] feat(persistence): add durable snapshot schema contract --- package.json | 1 + sync/bme-db.js | 1 + sync/graph-snapshot-schema.js | 136 ++++++++++++++++++++++++++++++++ tests/graph-snapshot-schema.mjs | 79 +++++++++++++++++++ 4 files changed, 217 insertions(+) create mode 100644 sync/graph-snapshot-schema.js create mode 100644 tests/graph-snapshot-schema.mjs diff --git a/package.json b/package.json index d054265..e54f8fa 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,7 @@ "test:graph-head": "node tests/graph-head.mjs", "test:graph-store-contract": "node tests/graph-store-contract.mjs", "test:graph-store-v3-adapter": "node tests/graph-store-v3-adapter.mjs", + "test:graph-snapshot-schema": "node tests/graph-snapshot-schema.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/sync/bme-db.js b/sync/bme-db.js index bd154c7..fe3d279 100644 --- a/sync/bme-db.js +++ b/sync/bme-db.js @@ -1756,6 +1756,7 @@ export function buildSnapshotFromGraph(graph, options = {}) { } const snapshotResult = { + schemaVersion: BME_DB_SCHEMA_VERSION, meta: mergedMeta, nodes, edges, diff --git a/sync/graph-snapshot-schema.js b/sync/graph-snapshot-schema.js new file mode 100644 index 0000000..08306ce --- /dev/null +++ b/sync/graph-snapshot-schema.js @@ -0,0 +1,136 @@ +// ST-BME durable graph snapshot schema contract. +// +// This module is the single source of truth for the forward-compatible durable +// snapshot shape used across IndexedDB / OPFS / Authority SQL / Luker chat-state. +// +// The forward-compatibility mechanism is intentionally minimal — NOT an envelope: +// 1. A frozen top-level key set + an explicit schemaVersion. +// 2. Tolerant parsing: unknown fields inside meta / nodes / edges / tombstones / +// state are preserved on round-trip, never dropped, never cause a throw. +// 3. Upgrade-on-read (see graph-snapshot-upgrade.js): old schemaVersion is +// upgraded in place; the namespace never changes. +// +// Rule for all future evolution: add fields additively inside meta or record +// objects. NEVER add new top-level snapshot keys, NEVER remove an existing +// top-level key, NEVER change the meaning of an existing field. Following this +// rule means a future v4 full-namespace migration is never required. + +// Current durable snapshot layout version. Bump ONLY when an in-place +// upgrade-on-read step is added to graph-snapshot-upgrade.js. Bumping this must +// never require a new storage namespace. +export const GRAPH_SNAPSHOT_SCHEMA_VERSION = 1; + +// Frozen forever. The durable snapshot has exactly these top-level keys. +// `schemaVersion` is the layout authority; everything that evolves lives inside +// `meta`, `state`, or the record objects (all of which preserve unknown fields). +export const GRAPH_SNAPSHOT_TOP_LEVEL_KEYS = Object.freeze([ + "schemaVersion", + "meta", + "nodes", + "edges", + "tombstones", + "state", +]); + +function isPlainObject(value) { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function toArray(value) { + return Array.isArray(value) ? value : []; +} + +// Preserve every own field of a record (node/edge/tombstone/meta entry). +// This is what keeps unknown future fields alive across a round-trip. +function preserveRecord(record) { + if (!isPlainObject(record)) return null; + return { ...record }; +} + +function preserveRecordArray(records) { + return toArray(records) + .map((record) => preserveRecord(record)) + .filter(Boolean); +} + +// Read the layout version from either the explicit top-level field (new) or the +// historical meta.schemaVersion (already stamped by buildSnapshotFromGraph). +export function readGraphSnapshotSchemaVersion(snapshot) { + if (!isPlainObject(snapshot)) return 0; + const topLevel = Number(snapshot.schemaVersion); + if (Number.isFinite(topLevel) && topLevel > 0) return topLevel; + const metaVersion = isPlainObject(snapshot.meta) + ? Number(snapshot.meta.schemaVersion) + : NaN; + if (Number.isFinite(metaVersion) && metaVersion > 0) return metaVersion; + return 0; +} + +// Normalize any snapshot-shaped input into the frozen top-level shape WITHOUT +// dropping unknown nested fields. This is the tolerant-parse contract: +// - keeps unknown fields inside meta / state / records, +// - drops only unknown TOP-LEVEL keys (which are contractually disallowed), +// - never throws on malformed input (returns an empty valid snapshot instead), +// - stamps schemaVersion at top level and mirrors it into meta for back-compat. +export function normalizeGraphSnapshotShape(snapshot, options = {}) { + const fallbackVersion = Number.isFinite(Number(options.schemaVersion)) + ? Number(options.schemaVersion) + : GRAPH_SNAPSHOT_SCHEMA_VERSION; + + if (!isPlainObject(snapshot)) { + return { + schemaVersion: fallbackVersion, + meta: { schemaVersion: fallbackVersion }, + nodes: [], + edges: [], + tombstones: [], + state: {}, + }; + } + + const detectedVersion = readGraphSnapshotSchemaVersion(snapshot) || fallbackVersion; + const meta = isPlainObject(snapshot.meta) ? { ...snapshot.meta } : {}; + const state = isPlainObject(snapshot.state) ? { ...snapshot.state } : {}; + + // Keep top-level schemaVersion and meta.schemaVersion in agreement. We do not + // downgrade here — upgrade-on-read owns version transitions. + meta.schemaVersion = Number.isFinite(Number(meta.schemaVersion)) + ? Number(meta.schemaVersion) + : detectedVersion; + + return { + schemaVersion: detectedVersion, + meta, + nodes: preserveRecordArray(snapshot.nodes), + edges: preserveRecordArray(snapshot.edges), + tombstones: preserveRecordArray(snapshot.tombstones), + state, + }; +} + +// List top-level keys that violate the frozen contract. Used by tests and by the +// upgrade layer to assert nothing is silently smuggled outside meta/records. +export function findUnknownTopLevelSnapshotKeys(snapshot) { + if (!isPlainObject(snapshot)) return []; + return Object.keys(snapshot).filter( + (key) => !GRAPH_SNAPSHOT_TOP_LEVEL_KEYS.includes(key), + ); +} + +// Structural inspection — never throws. Reports whether the snapshot matches the +// frozen contract and what its layout version is. +export function inspectGraphSnapshotContract(snapshot) { + const valid = + isPlainObject(snapshot) && + isPlainObject(snapshot.meta) && + Array.isArray(snapshot.nodes) && + Array.isArray(snapshot.edges) && + Array.isArray(snapshot.tombstones) && + isPlainObject(snapshot.state); + return { + schemaContractVersion: GRAPH_SNAPSHOT_SCHEMA_VERSION, + valid, + schemaVersion: readGraphSnapshotSchemaVersion(snapshot), + unknownTopLevelKeys: findUnknownTopLevelSnapshotKeys(snapshot), + }; +} diff --git a/tests/graph-snapshot-schema.mjs b/tests/graph-snapshot-schema.mjs new file mode 100644 index 0000000..4905dfc --- /dev/null +++ b/tests/graph-snapshot-schema.mjs @@ -0,0 +1,79 @@ +// ST-BME restrained rebirth — durable graph snapshot schema contract tests. +// +// Locks the forward-compatibility discipline: frozen top-level keys, explicit +// schemaVersion, tolerant parsing (unknown nested fields preserved), and that +// the real durable buildSnapshotFromGraph path stamps the top-level version. + +import assert from "node:assert/strict"; + +import { + GRAPH_SNAPSHOT_SCHEMA_VERSION, + GRAPH_SNAPSHOT_TOP_LEVEL_KEYS, + findUnknownTopLevelSnapshotKeys, + inspectGraphSnapshotContract, + normalizeGraphSnapshotShape, + readGraphSnapshotSchemaVersion, +} from "../sync/graph-snapshot-schema.js"; +import { + BME_DB_SCHEMA_VERSION, + buildSnapshotFromGraph, +} from "../sync/bme-db.js"; +import { createEmptyGraph } from "../graph/graph.js"; + +// 1. Top-level key set is frozen and matches the durable contract. +assert.deepEqual( + [...GRAPH_SNAPSHOT_TOP_LEVEL_KEYS].sort(), + ["edges", "meta", "nodes", "schemaVersion", "state", "tombstones"], +); +assert.throws(() => { + "use strict"; + GRAPH_SNAPSHOT_TOP_LEVEL_KEYS.push("rogue"); +}); +console.log(" ✓ durable snapshot top-level key set is frozen"); + +// 2. Tolerant parse preserves unknown nested fields, drops unknown top-level. +const dirty = { + schemaVersion: 1, + meta: { schemaVersion: 1, chatId: "chat-a", futureMetaField: { a: 1 } }, + nodes: [{ id: "n1", type: "char", futureNodeField: "keep-me" }], + edges: [{ id: "e1", fromId: "n1", toId: "n2", futureEdgeField: 7 }], + tombstones: [{ id: "t1", kind: "node", futureTombField: true }], + state: { lastProcessedFloor: 3, futureStateField: "keep" }, + rogueTopLevel: "should-be-dropped", +}; +const normalized = normalizeGraphSnapshotShape(dirty); +assert.deepEqual(findUnknownTopLevelSnapshotKeys(dirty), ["rogueTopLevel"]); +assert.equal("rogueTopLevel" in normalized, false, "unknown top-level dropped"); +assert.equal(normalized.nodes[0].futureNodeField, "keep-me", "unknown node field preserved"); +assert.equal(normalized.edges[0].futureEdgeField, 7, "unknown edge field preserved"); +assert.equal(normalized.tombstones[0].futureTombField, true, "unknown tombstone field preserved"); +assert.equal(normalized.meta.futureMetaField.a, 1, "unknown meta field preserved"); +assert.equal(normalized.state.futureStateField, "keep", "unknown state field preserved"); +console.log(" ✓ tolerant parse preserves unknown nested fields, drops unknown top-level"); + +// 3. Malformed input never throws; returns an empty valid snapshot. +for (const bad of [null, undefined, 42, "x", [], true]) { + const safe = normalizeGraphSnapshotShape(bad); + const inspection = inspectGraphSnapshotContract(safe); + assert.equal(inspection.valid, true, "normalized malformed input is contract-valid"); + assert.equal(safe.schemaVersion, GRAPH_SNAPSHOT_SCHEMA_VERSION); +} +console.log(" ✓ malformed input normalizes to an empty valid snapshot without throwing"); + +// 4. schemaVersion is read from top-level first, then meta fallback. +assert.equal(readGraphSnapshotSchemaVersion({ schemaVersion: 3, meta: { schemaVersion: 1 } }), 3); +assert.equal(readGraphSnapshotSchemaVersion({ meta: { schemaVersion: 2 } }), 2); +assert.equal(readGraphSnapshotSchemaVersion({}), 0); +console.log(" ✓ schemaVersion resolves from top-level then meta fallback"); + +// 5. The REAL durable path stamps an explicit top-level schemaVersion. +const graph = createEmptyGraph(); +const durable = buildSnapshotFromGraph(graph, { chatId: "chat-real" }); +const durableInspection = inspectGraphSnapshotContract(durable); +assert.equal(durableInspection.valid, true, "durable snapshot matches frozen contract"); +assert.equal(durable.schemaVersion, BME_DB_SCHEMA_VERSION, "durable snapshot has top-level schemaVersion"); +assert.equal(durable.meta.schemaVersion, BME_DB_SCHEMA_VERSION, "meta schemaVersion mirrors top-level"); +assert.deepEqual(findUnknownTopLevelSnapshotKeys(durable), [], "durable snapshot has no rogue top-level keys"); +console.log(" ✓ real buildSnapshotFromGraph stamps explicit top-level schemaVersion"); + +console.log("graph-snapshot-schema tests passed");