mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
137 lines
5.3 KiB
JavaScript
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),
|
|
};
|
|
}
|