From 951fca2c994c5406e5daf7f4545b8184c3ce2251 Mon Sep 17 00:00:00 2001 From: youzini Date: Sat, 30 May 2026 14:14:19 +0000 Subject: [PATCH] refactor(persistence): define v3 graph store shell --- graph/graph-v3-namespace.js | 97 +++++++++++++++++++++++ package.json | 1 + sync/graph-store-contract.js | 140 +++++++++++++++++++++++++++++++++ tests/graph-store-contract.mjs | 132 +++++++++++++++++++++++++++++++ 4 files changed, 370 insertions(+) create mode 100644 graph/graph-v3-namespace.js create mode 100644 sync/graph-store-contract.js create mode 100644 tests/graph-store-contract.mjs diff --git a/graph/graph-v3-namespace.js b/graph/graph-v3-namespace.js new file mode 100644 index 0000000..ef5b862 --- /dev/null +++ b/graph/graph-v3-namespace.js @@ -0,0 +1,97 @@ +// ST-BME v3 hard-cut namespace constants. +// +// These constants intentionally do not alias legacy st_bme/st-bme/STBME keys. +// Phase 6 introduces the namespace contract only; live routes are ported later. + +export const GRAPH_V3_NAMESPACE_VERSION = 3; +export const GRAPH_V3_MODULE_NAME = "st_bme_v3"; + +export const GRAPH_V3_METADATA_KEY = `${GRAPH_V3_MODULE_NAME}_graph`; +export const GRAPH_V3_COMMIT_MARKER_KEY = `${GRAPH_V3_MODULE_NAME}_commit_marker`; +export const GRAPH_V3_CHAT_STATE_NAMESPACE = `${GRAPH_V3_MODULE_NAME}_graph_state`; +export const GRAPH_V3_LUKER_MANIFEST_NAMESPACE = `${GRAPH_V3_MODULE_NAME}_graph_manifest`; +export const GRAPH_V3_LUKER_JOURNAL_NAMESPACE = `${GRAPH_V3_MODULE_NAME}_graph_journal`; +export const GRAPH_V3_LUKER_CHECKPOINT_NAMESPACE = `${GRAPH_V3_MODULE_NAME}_graph_checkpoint`; +export const GRAPH_V3_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${GRAPH_V3_MODULE_NAME}:graph-shadow:`; +export const GRAPH_V3_IDENTITY_ALIAS_STORAGE_KEY = `${GRAPH_V3_MODULE_NAME}:chat-identity-aliases`; + +export const GRAPH_V3_INDEXEDDB_NAME_PREFIX = "ST_BME_V3"; +export const GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME = "stbme-v3"; +export const GRAPH_V3_AUTHORITY_TABLES = Object.freeze({ + meta: `${GRAPH_V3_MODULE_NAME}_graph_meta`, + nodes: `${GRAPH_V3_MODULE_NAME}_graph_nodes`, + edges: `${GRAPH_V3_MODULE_NAME}_graph_edges`, + tombstones: `${GRAPH_V3_MODULE_NAME}_graph_tombstones`, +}); + +export const GRAPH_LEGACY_NAMESPACE_VALUES = Object.freeze([ + "st_bme", + "st_bme_graph", + "st_bme_commit_marker", + "st_bme_graph_state", + "st_bme_graph_manifest", + "st_bme_graph_journal", + "st_bme_graph_checkpoint", + "st_bme:graph-shadow:", + "st_bme:chat-identity-aliases", + "STBME_", + "st-bme", + "st_bme_graph_meta", + "st_bme_graph_nodes", + "st_bme_graph_edges", + "st_bme_graph_tombstones", +]); + +function normalizeNamespaceSegment(value = "") { + return String(value ?? "") + .trim() + .replace(/[^a-zA-Z0-9_-]+/g, "_") + .replace(/^_+|_+$/g, "") || "default"; +} + +export function buildGraphV3IndexedDbName(chatId = "") { + return `${GRAPH_V3_INDEXEDDB_NAME_PREFIX}_${normalizeNamespaceSegment(chatId)}`; +} + +export function buildGraphV3OpfsChatPath(chatId = "") { + return `${GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME}/chats/${normalizeNamespaceSegment(chatId)}`; +} + +export function buildGraphV3AuthorityPartition(graphId = "") { + return `${GRAPH_V3_MODULE_NAME}:${normalizeNamespaceSegment(graphId)}`; +} + +export function listGraphV3NamespaceValues() { + return Object.freeze([ + GRAPH_V3_MODULE_NAME, + GRAPH_V3_METADATA_KEY, + GRAPH_V3_COMMIT_MARKER_KEY, + GRAPH_V3_CHAT_STATE_NAMESPACE, + GRAPH_V3_LUKER_MANIFEST_NAMESPACE, + GRAPH_V3_LUKER_JOURNAL_NAMESPACE, + GRAPH_V3_LUKER_CHECKPOINT_NAMESPACE, + GRAPH_V3_SHADOW_SNAPSHOT_STORAGE_PREFIX, + GRAPH_V3_IDENTITY_ALIAS_STORAGE_KEY, + GRAPH_V3_INDEXEDDB_NAME_PREFIX, + GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME, + ...Object.values(GRAPH_V3_AUTHORITY_TABLES), + ]); +} + +export function validateGraphV3NamespaceIsolation(legacyValues = GRAPH_LEGACY_NAMESPACE_VALUES) { + const legacy = new Set((Array.isArray(legacyValues) ? legacyValues : []).map((value) => String(value))); + const conflicts = listGraphV3NamespaceValues().filter((value) => legacy.has(String(value))); + const unsafePrefixConflicts = []; + if (GRAPH_V3_INDEXEDDB_NAME_PREFIX.startsWith("STBME_")) { + unsafePrefixConflicts.push({ surface: "indexeddb", legacyPrefix: "STBME_" }); + } + if (GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME.startsWith("st-bme")) { + unsafePrefixConflicts.push({ surface: "opfs", legacyPrefix: "st-bme" }); + } + return { + isolated: conflicts.length === 0 && unsafePrefixConflicts.length === 0, + conflicts, + unsafePrefixConflicts, + namespaceVersion: GRAPH_V3_NAMESPACE_VERSION, + }; +} diff --git a/package.json b/package.json index 0f5f63b..0a16564 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "test:identity-resolver": "node tests/identity-resolver.mjs", "test:persistence-reducer": "node tests/persistence-reducer.mjs", "test:graph-head": "node tests/graph-head.mjs", + "test:graph-store-contract": "node tests/graph-store-contract.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/graph-store-contract.js b/sync/graph-store-contract.js new file mode 100644 index 0000000..0c554f7 --- /dev/null +++ b/sync/graph-store-contract.js @@ -0,0 +1,140 @@ +// ST-BME v3 GraphStore contract and pure router shell. +// +// Phase 6 only defines/validates the contract and route plans. Live adapters are +// ported in Phase 7 so durable routing is not switched accidentally. + +export const GRAPH_STORE_CONTRACT_VERSION = 3; + +export const GRAPH_STORE_KINDS = Object.freeze({ + AUTHORITY: "authority", + OPFS: "opfs", + INDEXEDDB: "indexeddb", + LUKER_CHAT_STATE: "luker-chat-state", + NONE: "none", +}); + +export const GRAPH_STORE_REQUIRED_METHODS = Object.freeze([ + "open", + "close", + "getMeta", + "patchMeta", + "commitDelta", + "exportSnapshot", + "exportSnapshotProbe", + "importSnapshot", +]); + +export const GRAPH_STORE_OPTIONAL_METHODS = Object.freeze([ + "readHead", + "writeHead", + "readCommitMarker", + "writeCommitMarker", + "isEmpty", + "deleteAll", +]); + +function normalizeStoreKind(value = "") { + const kind = String(value || "").trim().toLowerCase(); + if (Object.values(GRAPH_STORE_KINDS).includes(kind)) return kind; + return GRAPH_STORE_KINDS.NONE; +} + +function methodExists(store = null, method = "") { + return store && typeof store[method] === "function"; +} + +export function inspectGraphStoreContract(store = null, options = {}) { + const requiredMethods = Array.isArray(options.requiredMethods) + ? options.requiredMethods + : GRAPH_STORE_REQUIRED_METHODS; + const optionalMethods = Array.isArray(options.optionalMethods) + ? options.optionalMethods + : GRAPH_STORE_OPTIONAL_METHODS; + const missingMethods = requiredMethods.filter((method) => !methodExists(store, method)); + const supportedOptionalMethods = optionalMethods.filter((method) => methodExists(store, method)); + return { + contractVersion: GRAPH_STORE_CONTRACT_VERSION, + valid: missingMethods.length === 0, + storeKind: normalizeStoreKind(store?.storeKind || store?.kind), + storeMode: String(store?.storeMode || store?.mode || ""), + missingMethods, + supportedOptionalMethods, + }; +} + +export function assertGraphStoreContract(store = null, options = {}) { + const inspection = inspectGraphStoreContract(store, options); + if (!inspection.valid) { + const error = new Error(`graph-store-contract-invalid:${inspection.missingMethods.join(",")}`); + error.code = "graph_store_contract_invalid"; + error.contract = inspection; + throw error; + } + return inspection; +} + +function normalizeBoolean(value) { + return value === true; +} + +function normalizePreference(value = "") { + const normalized = String(value || "").trim().toLowerCase(); + if (normalized === "authority-sql") return GRAPH_STORE_KINDS.AUTHORITY; + if (normalized === "opfs-primary" || normalized === "opfs-shadow") return GRAPH_STORE_KINDS.OPFS; + if (normalized === "indexeddb") return GRAPH_STORE_KINDS.INDEXEDDB; + if (normalized === "luker-chat-state") return GRAPH_STORE_KINDS.LUKER_CHAT_STATE; + return "auto"; +} + +function pushUniqueRoute(routes, kind, reason = "") { + const normalizedKind = normalizeStoreKind(kind); + if (!normalizedKind || normalizedKind === GRAPH_STORE_KINDS.NONE) return; + if (routes.some((route) => route.kind === normalizedKind)) return; + routes.push({ kind: normalizedKind, reason: String(reason || normalizedKind) }); +} + +export function planGraphStoreRoute(input = {}) { + const preference = normalizePreference(input.preference || input.primaryStorageTier || input.localStoreMode); + const capabilities = input.capabilities && typeof input.capabilities === "object" ? input.capabilities : {}; + const environment = input.environment && typeof input.environment === "object" ? input.environment : {}; + const hardCutNamespace = input.hardCutNamespace && typeof input.hardCutNamespace === "object" + ? input.hardCutNamespace + : null; + const routes = []; + + const authorityReady = normalizeBoolean(capabilities.authoritySqlReady || capabilities.storagePrimaryReady); + const opfsReady = normalizeBoolean(capabilities.opfsReady || capabilities.opfsAvailable); + const indexedDbReady = + normalizeBoolean(capabilities.indexedDbReady) || normalizeBoolean(capabilities.indexedDbAvailable); + const lukerReady = normalizeBoolean(environment.lukerChatStateReady || capabilities.lukerChatStateReady); + + if (preference === GRAPH_STORE_KINDS.AUTHORITY && authorityReady) { + pushUniqueRoute(routes, GRAPH_STORE_KINDS.AUTHORITY, "preferred-authority-sql"); + } + if (preference === GRAPH_STORE_KINDS.OPFS && opfsReady) { + pushUniqueRoute(routes, GRAPH_STORE_KINDS.OPFS, "preferred-opfs"); + } + if (preference === GRAPH_STORE_KINDS.INDEXEDDB && indexedDbReady) { + pushUniqueRoute(routes, GRAPH_STORE_KINDS.INDEXEDDB, "preferred-indexeddb"); + } + if (preference === GRAPH_STORE_KINDS.LUKER_CHAT_STATE && lukerReady) { + pushUniqueRoute(routes, GRAPH_STORE_KINDS.LUKER_CHAT_STATE, "preferred-luker-chat-state"); + } + + if (authorityReady) pushUniqueRoute(routes, GRAPH_STORE_KINDS.AUTHORITY, "authority-sql-ready"); + if (opfsReady) pushUniqueRoute(routes, GRAPH_STORE_KINDS.OPFS, "opfs-ready"); + if (indexedDbReady) pushUniqueRoute(routes, GRAPH_STORE_KINDS.INDEXEDDB, "indexeddb-ready"); + if (lukerReady) pushUniqueRoute(routes, GRAPH_STORE_KINDS.LUKER_CHAT_STATE, "luker-chat-state-ready"); + + return { + contractVersion: GRAPH_STORE_CONTRACT_VERSION, + hardCut: true, + hotPathReadsLegacy: false, + namespace: hardCutNamespace, + primary: routes[0]?.kind || GRAPH_STORE_KINDS.NONE, + fallback: routes.slice(1).map((route) => route.kind), + routes, + blocked: routes.length === 0, + reason: routes.length ? routes[0].reason : "no-graph-store-route-ready", + }; +} diff --git a/tests/graph-store-contract.mjs b/tests/graph-store-contract.mjs new file mode 100644 index 0000000..07b9482 --- /dev/null +++ b/tests/graph-store-contract.mjs @@ -0,0 +1,132 @@ +// ST-BME restrained rebirth — Phase 6 GraphStore contract/router shell tests. + +import assert from "node:assert/strict"; +import { + GRAPH_LEGACY_NAMESPACE_VALUES, + GRAPH_V3_AUTHORITY_TABLES, + GRAPH_V3_CHAT_STATE_NAMESPACE, + GRAPH_V3_COMMIT_MARKER_KEY, + GRAPH_V3_INDEXEDDB_NAME_PREFIX, + GRAPH_V3_LUKER_CHECKPOINT_NAMESPACE, + GRAPH_V3_LUKER_JOURNAL_NAMESPACE, + GRAPH_V3_LUKER_MANIFEST_NAMESPACE, + GRAPH_V3_METADATA_KEY, + GRAPH_V3_MODULE_NAME, + GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME, + buildGraphV3AuthorityPartition, + buildGraphV3IndexedDbName, + buildGraphV3OpfsChatPath, + listGraphV3NamespaceValues, + validateGraphV3NamespaceIsolation, +} from "../graph/graph-v3-namespace.js"; +import { + GRAPH_STORE_CONTRACT_VERSION, + GRAPH_STORE_KINDS, + assertGraphStoreContract, + inspectGraphStoreContract, + planGraphStoreRoute, +} from "../sync/graph-store-contract.js"; + +const v3Values = listGraphV3NamespaceValues(); +assert.ok(v3Values.includes(GRAPH_V3_MODULE_NAME)); +assert.ok(v3Values.includes(GRAPH_V3_METADATA_KEY)); +assert.ok(v3Values.includes(GRAPH_V3_COMMIT_MARKER_KEY)); +assert.ok(v3Values.includes(GRAPH_V3_CHAT_STATE_NAMESPACE)); +assert.ok(v3Values.includes(GRAPH_V3_LUKER_MANIFEST_NAMESPACE)); +assert.ok(v3Values.includes(GRAPH_V3_LUKER_JOURNAL_NAMESPACE)); +assert.ok(v3Values.includes(GRAPH_V3_LUKER_CHECKPOINT_NAMESPACE)); +assert.ok(v3Values.includes(GRAPH_V3_INDEXEDDB_NAME_PREFIX)); +assert.ok(v3Values.includes(GRAPH_V3_OPFS_ROOT_DIRECTORY_NAME)); +assert.ok(v3Values.includes(GRAPH_V3_AUTHORITY_TABLES.meta)); + +const isolation = validateGraphV3NamespaceIsolation(); +assert.equal(isolation.isolated, true); +assert.deepEqual(isolation.conflicts, []); +assert.deepEqual(isolation.unsafePrefixConflicts, []); + +for (const value of v3Values) { + assert.equal( + GRAPH_LEGACY_NAMESPACE_VALUES.includes(value), + false, + `v3 namespace must not reuse legacy value: ${value}`, + ); +} + +assert.equal(buildGraphV3IndexedDbName("chat/a b"), "ST_BME_V3_chat_a_b"); +assert.equal(buildGraphV3OpfsChatPath("chat/a b"), "stbme-v3/chats/chat_a_b"); +assert.equal(buildGraphV3IndexedDbName("chat").startsWith("STBME_"), false); +assert.equal(buildGraphV3OpfsChatPath("chat").startsWith("st-bme"), false); +assert.equal(buildGraphV3AuthorityPartition("graph/a b"), "st_bme_v3:graph_a_b"); + +console.log(" ✓ v3 hard-cut namespaces are isolated from legacy keys"); + +function createMockStore(extra = {}) { + return { + storeKind: "authority", + storeMode: "authority-sql-primary", + async open() {}, + async close() {}, + async getMeta() {}, + async patchMeta() {}, + async commitDelta() {}, + async exportSnapshot() {}, + async exportSnapshotProbe() {}, + async importSnapshot() {}, + ...extra, + }; +} + +const contract = inspectGraphStoreContract(createMockStore({ async readHead() {} })); +assert.equal(contract.contractVersion, GRAPH_STORE_CONTRACT_VERSION); +assert.equal(contract.valid, true); +assert.equal(contract.storeKind, GRAPH_STORE_KINDS.AUTHORITY); +assert.deepEqual(contract.missingMethods, []); +assert.ok(contract.supportedOptionalMethods.includes("readHead")); +assert.doesNotThrow(() => assertGraphStoreContract(createMockStore())); + +assert.throws( + () => assertGraphStoreContract(createMockStore({ commitDelta: undefined })), + /graph-store-contract-invalid:commitDelta/, +); + +console.log(" ✓ GraphStore contract validates existing adapter-shaped stores"); + +const authorityPlan = planGraphStoreRoute({ + preference: "authority-sql", + capabilities: { authoritySqlReady: true, opfsReady: true, indexedDbReady: true }, + environment: { lukerChatStateReady: true }, + hardCutNamespace: { moduleName: GRAPH_V3_MODULE_NAME }, +}); +assert.equal(authorityPlan.hardCut, true); +assert.equal(authorityPlan.hotPathReadsLegacy, false); +assert.equal(authorityPlan.primary, GRAPH_STORE_KINDS.AUTHORITY); +assert.deepEqual(authorityPlan.fallback, [ + GRAPH_STORE_KINDS.OPFS, + GRAPH_STORE_KINDS.INDEXEDDB, + GRAPH_STORE_KINDS.LUKER_CHAT_STATE, +]); +assert.equal(authorityPlan.namespace.moduleName, GRAPH_V3_MODULE_NAME); + +const lukerPlan = planGraphStoreRoute({ + primaryStorageTier: "luker-chat-state", + capabilities: { authoritySqlReady: false, opfsReady: false, indexedDbReady: false }, + environment: { lukerChatStateReady: true }, +}); +assert.equal(lukerPlan.primary, GRAPH_STORE_KINDS.LUKER_CHAT_STATE); + +const blockedPlan = planGraphStoreRoute({ + capabilities: { authoritySqlReady: false, opfsReady: false, indexedDbReady: false }, + environment: { lukerChatStateReady: false }, +}); +assert.equal(blockedPlan.blocked, true); +assert.equal(blockedPlan.reason, "no-graph-store-route-ready"); + +const emptyCapabilityPlan = planGraphStoreRoute({}); +assert.equal( + emptyCapabilityPlan.blocked, + true, + "Phase 6 shell must not assume IndexedDB readiness when callers omit capabilities", +); + +console.log(" ✓ v3 router shell plans routes without switching live persistence"); +console.log("graph-store-contract tests passed");