Files
ST-Bionic-Memory-Ecology/sync/graph-snapshot-schema.js

137 lines
5.3 KiB
JavaScript

// 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),
};
}