diff --git a/graph/graph-v3-namespace.js b/graph/graph-v3-namespace.js index ef5b862..3ec52d8 100644 --- a/graph/graph-v3-namespace.js +++ b/graph/graph-v3-namespace.js @@ -7,6 +7,7 @@ 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_HEAD_KEY = `${GRAPH_V3_MODULE_NAME}_graph_head`; 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`; @@ -65,6 +66,7 @@ export function listGraphV3NamespaceValues() { return Object.freeze([ GRAPH_V3_MODULE_NAME, GRAPH_V3_METADATA_KEY, + GRAPH_V3_HEAD_KEY, GRAPH_V3_COMMIT_MARKER_KEY, GRAPH_V3_CHAT_STATE_NAMESPACE, GRAPH_V3_LUKER_MANIFEST_NAMESPACE, diff --git a/package.json b/package.json index 0a16564..d054265 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "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:graph-store-v3-adapter": "node tests/graph-store-v3-adapter.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-v3-adapter.js b/sync/graph-store-v3-adapter.js new file mode 100644 index 0000000..6a0da1c --- /dev/null +++ b/sync/graph-store-v3-adapter.js @@ -0,0 +1,172 @@ +// ST-BME v3 GraphStore adapter wrappers. +// +// These wrappers add v3 head/marker sidecar methods to existing stores without +// changing their legacy load/save behavior. Physical namespace cutover is handled +// by dedicated constructors/routes later. + +import { + GRAPH_V3_COMMIT_MARKER_KEY, + GRAPH_V3_HEAD_KEY, +} from "../graph/graph-v3-namespace.js"; +import { + normalizeCommitMarkerV3, + normalizeGraphHead, +} from "../graph/graph-head.js"; +import { + readGraphChatStateNamespaces, + writeGraphChatStatePayload, +} from "../graph/graph-persistence.js"; +import { assertGraphStoreContract } from "./graph-store-contract.js"; + +const GRAPH_STORE_V3_WRAPPED = Symbol.for("st-bme.graph-store-v3-wrapped"); + +function bindStoreMethod(store = null, method = "") { + const value = store?.[method]; + return typeof value === "function" ? value.bind(store) : value; +} + +export function isGraphStoreV3Wrapped(store = null) { + return Boolean(store?.[GRAPH_STORE_V3_WRAPPED]); +} + +export function wrapDbLikeGraphStoreV3(store = null) { + assertGraphStoreContract(store); + if (isGraphStoreV3Wrapped(store)) return store; + + const wrapper = Object.create(store); + Object.defineProperty(wrapper, GRAPH_STORE_V3_WRAPPED, { + value: true, + enumerable: false, + }); + + for (const key of Reflect.ownKeys(store)) { + if (key === GRAPH_STORE_V3_WRAPPED) continue; + const descriptor = Object.getOwnPropertyDescriptor(store, key); + if (descriptor) Object.defineProperty(wrapper, key, descriptor); + } + + for (const method of [ + "open", + "close", + "getMeta", + "setMeta", + "patchMeta", + "commitDelta", + "exportSnapshot", + "exportSnapshotProbe", + "importSnapshot", + "isEmpty", + "clearAll", + ]) { + if (typeof store[method] === "function") { + wrapper[method] = bindStoreMethod(store, method); + } + } + + wrapper.readHead = async ({ fallback = null } = {}) => { + const raw = await store.getMeta(GRAPH_V3_HEAD_KEY, null); + return raw == null ? fallback : normalizeGraphHead(raw, fallback || {}); + }; + + wrapper.writeHead = async (head = null, { fallback = null } = {}) => { + const normalized = normalizeGraphHead(head, fallback || {}); + await store.patchMeta({ [GRAPH_V3_HEAD_KEY]: normalized }); + return normalized; + }; + + wrapper.readCommitMarker = async ({ fallback = null } = {}) => { + const raw = await store.getMeta(GRAPH_V3_COMMIT_MARKER_KEY, null); + return normalizeCommitMarkerV3(raw) || fallback; + }; + + wrapper.writeCommitMarker = async (marker = null) => { + const normalized = normalizeCommitMarkerV3(marker); + if (!normalized) { + const error = new Error("graph-store-v3-commit-marker-invalid"); + error.code = "graph_store_v3_commit_marker_invalid"; + throw error; + } + await store.patchMeta({ [GRAPH_V3_COMMIT_MARKER_KEY]: normalized }); + return normalized; + }; + + wrapper.deleteAll = async (...args) => { + if (typeof store.clearAll !== "function") { + const error = new Error("graph-store-v3-delete-all-unavailable"); + error.code = "graph_store_v3_delete_all_unavailable"; + throw error; + } + return store.clearAll(...args); + }; + + return wrapper; +} + +export function createLukerChatStateGraphStoreV3({ + context = null, + chatStateTarget = null, + storeKind = "luker-chat-state", + storeMode = "luker-chat-state-v3", +} = {}) { + async function readNamespace(namespace = "", fallback = null) { + const payloads = await readGraphChatStateNamespaces(context, [namespace], { + target: chatStateTarget, + }); + return payloads.get(namespace) ?? fallback; + } + + async function writeNamespace(namespace = "", payload = null) { + const result = await writeGraphChatStatePayload(context, namespace, payload, { + target: chatStateTarget, + }); + if (result?.ok !== true) { + const error = new Error(result?.reason || "luker-graph-store-v3-write-failed"); + error.code = "luker_graph_store_v3_write_failed"; + error.result = result; + throw error; + } + return payload; + } + + return { + storeKind, + storeMode, + async open() { + return this; + }, + async close() {}, + async getMeta(key = "", fallbackValue = null) { + return readNamespace(String(key || ""), fallbackValue); + }, + async patchMeta(record = {}) { + const entries = Object.entries(record && typeof record === "object" ? record : {}); + for (const [key, value] of entries) { + await writeNamespace(key, value); + } + return record; + }, + async readHead({ fallback = null } = {}) { + const raw = await readNamespace(GRAPH_V3_HEAD_KEY, null); + return raw == null ? fallback : normalizeGraphHead(raw, fallback || {}); + }, + async writeHead(head = null, { fallback = null } = {}) { + const normalized = normalizeGraphHead(head, fallback || {}); + await writeNamespace(GRAPH_V3_HEAD_KEY, normalized); + return normalized; + }, + async readCommitMarker({ fallback = null } = {}) { + const raw = await readNamespace(GRAPH_V3_COMMIT_MARKER_KEY, null); + return normalizeCommitMarkerV3(raw) || fallback; + }, + async writeCommitMarker(marker = null) { + const normalized = normalizeCommitMarkerV3(marker); + if (!normalized) { + const error = new Error("luker-graph-store-v3-commit-marker-invalid"); + error.code = "luker_graph_store_v3_commit_marker_invalid"; + throw error; + } + await writeNamespace(GRAPH_V3_COMMIT_MARKER_KEY, normalized); + return normalized; + }, + }; +} diff --git a/tests/graph-store-v3-adapter.mjs b/tests/graph-store-v3-adapter.mjs new file mode 100644 index 0000000..b3fb402 --- /dev/null +++ b/tests/graph-store-v3-adapter.mjs @@ -0,0 +1,190 @@ +// ST-BME restrained rebirth — Phase 7 v3 GraphStore adapter tests. + +import assert from "node:assert/strict"; +import { + GRAPH_V3_COMMIT_MARKER_KEY, + GRAPH_V3_HEAD_KEY, + GRAPH_V3_METADATA_KEY, +} from "../graph/graph-v3-namespace.js"; +import { + buildCommitMarkerV3, + normalizeGraphHead, + normalizeReplicaPointer, +} from "../graph/graph-head.js"; +import { GRAPH_STORE_REQUIRED_METHODS, inspectGraphStoreContract } from "../sync/graph-store-contract.js"; +import { + createLukerChatStateGraphStoreV3, + isGraphStoreV3Wrapped, + wrapDbLikeGraphStoreV3, +} from "../sync/graph-store-v3-adapter.js"; + +function createMockDbLikeStore() { + const meta = new Map(); + const calls = []; + return { + storeKind: "indexeddb", + storeMode: "indexeddb-v3-test", + meta, + calls, + async open() { + calls.push(["open"]); + return this; + }, + async close() { + calls.push(["close"]); + }, + async getMeta(key, fallbackValue = null) { + calls.push(["getMeta", key]); + return meta.has(key) ? meta.get(key) : fallbackValue; + }, + async patchMeta(record = {}) { + calls.push(["patchMeta", Object.keys(record).sort()]); + for (const [key, value] of Object.entries(record)) { + meta.set(key, value); + } + return record; + }, + async commitDelta() {}, + async exportSnapshot() {}, + async exportSnapshotProbe() {}, + async importSnapshot() {}, + async clearAll() { + calls.push(["clearAll"]); + meta.clear(); + return { ok: true }; + }, + }; +} + +const rawStore = createMockDbLikeStore(); +const wrapped = wrapDbLikeGraphStoreV3(rawStore); +assert.equal(isGraphStoreV3Wrapped(wrapped), true); +assert.equal(wrapDbLikeGraphStoreV3(wrapped), wrapped); + +const wrappedContract = inspectGraphStoreContract(wrapped); +assert.equal(wrappedContract.valid, true); +assert.ok(wrappedContract.supportedOptionalMethods.includes("readHead")); +assert.ok(wrappedContract.supportedOptionalMethods.includes("writeCommitMarker")); +assert.ok(wrappedContract.supportedOptionalMethods.includes("deleteAll")); + +const head = normalizeGraphHead({ + graphId: "graph-a", + chatId: "chat-a", + integrity: "integrity-a", + revision: 9, + counts: { nodeCount: 2, edgeCount: 1 }, +}); +const writtenHead = await wrapped.writeHead(head); +assert.equal(writtenHead.graphId, "graph-a"); +assert.deepEqual(await wrapped.readHead(), writtenHead); +assert.deepEqual(rawStore.meta.get(GRAPH_V3_HEAD_KEY), writtenHead); +assert.equal(rawStore.meta.has(GRAPH_V3_METADATA_KEY), false, "head must use dedicated v3 head key"); + +const marker = buildCommitMarkerV3({ + head, + replica: normalizeReplicaPointer({ + graphId: head.graphId, + revision: head.revision, + storageTier: "indexeddb", + accepted: true, + }), +}); +const writtenMarker = await wrapped.writeCommitMarker(marker); +assert.equal(writtenMarker.accepted, true); +assert.deepEqual(await wrapped.readCommitMarker(), writtenMarker); +assert.deepEqual(rawStore.meta.get(GRAPH_V3_COMMIT_MARKER_KEY), writtenMarker); +assert.equal(rawStore.meta.has("st_bme_commit_marker"), false, "legacy marker key must stay untouched"); + +await wrapped.deleteAll(); +assert.equal(rawStore.meta.size, 0); + +console.log(" ✓ DB-like v3 wrapper adds head/marker methods without legacy key writes"); + +class ClassBackedStore { + constructor() { + this.storeKind = "opfs"; + this.storeMode = "class-backed-test"; + this.meta = new Map(); + this.clearCount = 0; + } + + async open() { + return this; + } + + async close() {} + + async getMeta(key, fallbackValue = null) { + assert.equal(this instanceof ClassBackedStore, true, "wrapped methods must keep class instance this"); + return this.meta.has(key) ? this.meta.get(key) : fallbackValue; + } + + async patchMeta(record = {}) { + assert.equal(this instanceof ClassBackedStore, true, "patchMeta must run on the original class instance"); + for (const [key, value] of Object.entries(record)) { + this.meta.set(key, value); + } + return record; + } + + async commitDelta() {} + async exportSnapshot() {} + async exportSnapshotProbe() {} + async importSnapshot() {} + + async clearAll() { + assert.equal(this instanceof ClassBackedStore, true, "deleteAll must delegate to class-backed clearAll"); + this.clearCount += 1; + } +} + +const classBackedRaw = new ClassBackedStore(); +const classBackedWrapped = wrapDbLikeGraphStoreV3(classBackedRaw); +await classBackedWrapped.writeHead(head); +assert.equal((await classBackedWrapped.readHead()).graphId, "graph-a"); +await classBackedWrapped.deleteAll(); +assert.equal(classBackedRaw.clearCount, 1); + +console.log(" ✓ DB-like wrapper preserves class-instance method binding"); + +const chatState = new Map(); +const updatedNamespaces = []; +const lukerContext = { + getChatState(namespace) { + return chatState.get(namespace) || null; + }, + getChatStateBatch(namespaces) { + return new Map(namespaces.map((namespace) => [namespace, chatState.get(namespace) || null])); + }, + updateChatState(namespace, updater) { + updatedNamespaces.push(namespace); + const next = updater(chatState.get(namespace) || null); + chatState.set(namespace, next); + return { ok: true, updated: true }; + }, +}; + +const lukerStore = createLukerChatStateGraphStoreV3({ context: lukerContext }); +const lukerContract = inspectGraphStoreContract(lukerStore, { + requiredMethods: ["open", "close", "getMeta", "patchMeta", "readHead", "writeHead", "readCommitMarker", "writeCommitMarker"], +}); +assert.equal(lukerContract.valid, true); +for (const requiredMethod of GRAPH_STORE_REQUIRED_METHODS) { + if (["commitDelta", "exportSnapshot", "exportSnapshotProbe", "importSnapshot"].includes(requiredMethod)) { + assert.equal( + typeof lukerStore[requiredMethod], + "undefined", + `Luker thin wrapper must not claim unsupported DB-like method ${requiredMethod}`, + ); + } +} + +await lukerStore.writeHead(head); +await lukerStore.writeCommitMarker(marker); +assert.deepEqual(await lukerStore.readHead(), head); +assert.deepEqual(await lukerStore.readCommitMarker(), marker); +assert.deepEqual(updatedNamespaces, [GRAPH_V3_HEAD_KEY, GRAPH_V3_COMMIT_MARKER_KEY]); +assert.equal(chatState.has("st_bme_graph_state"), false, "Luker wrapper must not write legacy chat-state namespace"); + +console.log(" ✓ Luker v3 wrapper writes only v3 head/marker namespaces"); +console.log("graph-store-v3-adapter tests passed");