mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
refactor(persistence): define v3 graph store shell
This commit is contained in:
97
graph/graph-v3-namespace.js
Normal file
97
graph/graph-v3-namespace.js
Normal file
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
140
sync/graph-store-contract.js
Normal file
140
sync/graph-store-contract.js
Normal file
@@ -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",
|
||||
};
|
||||
}
|
||||
132
tests/graph-store-contract.mjs
Normal file
132
tests/graph-store-contract.mjs
Normal file
@@ -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");
|
||||
Reference in New Issue
Block a user