mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: harden persistence tiers and opfs durability
This commit is contained in:
@@ -267,6 +267,30 @@ function buildSnapshotFilename(prefix, revision = 0, stampMs = Date.now()) {
|
||||
return `${String(prefix || "snapshot")}.${normalizeRevision(revision)}.${normalizeTimestamp(stampMs)}.json`;
|
||||
}
|
||||
|
||||
function escapeRegex(value = "") {
|
||||
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
function parseSnapshotFilenameCandidate(name = "", prefix = "") {
|
||||
const normalizedName = String(name || "").trim();
|
||||
const normalizedPrefix = String(prefix || "").trim();
|
||||
if (!normalizedName || !normalizedPrefix) {
|
||||
return null;
|
||||
}
|
||||
const matcher = new RegExp(
|
||||
`^${escapeRegex(normalizedPrefix)}\\.(\\d+)\\.(\\d+)\\.json$`,
|
||||
);
|
||||
const match = normalizedName.match(matcher);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
filename: normalizedName,
|
||||
revision: normalizeRevision(match[1]),
|
||||
stampMs: normalizeTimestamp(match[2], 0),
|
||||
};
|
||||
}
|
||||
|
||||
function isNotFoundError(error) {
|
||||
const name = String(error?.name || "");
|
||||
const message = String(error?.message || "");
|
||||
@@ -330,6 +354,37 @@ async function deleteFileIfExists(parentHandle, name) {
|
||||
}
|
||||
}
|
||||
|
||||
async function listDirectoryFileNames(parentHandle) {
|
||||
if (!parentHandle) return [];
|
||||
|
||||
if (parentHandle.files instanceof Map) {
|
||||
return Array.from(parentHandle.files.keys()).map((name) => String(name || ""));
|
||||
}
|
||||
|
||||
const names = [];
|
||||
if (typeof parentHandle.keys === "function") {
|
||||
for await (const key of parentHandle.keys()) {
|
||||
if (typeof key === "string" && key) {
|
||||
names.push(key);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
if (typeof parentHandle.entries === "function") {
|
||||
for await (const [name, handle] of parentHandle.entries()) {
|
||||
if (
|
||||
typeof name === "string" &&
|
||||
name &&
|
||||
(!handle || handle.kind === "file" || typeof handle.getFile === "function")
|
||||
) {
|
||||
names.push(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function normalizeSnapshotState(snapshot = {}) {
|
||||
const meta =
|
||||
snapshot?.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta)
|
||||
@@ -396,19 +451,30 @@ function buildSnapshotFromStoredParts(manifest, corePayload = {}, auxPayload = {
|
||||
const nodes = sanitizeSnapshotRecordArray(corePayload?.nodes);
|
||||
const edges = sanitizeSnapshotRecordArray(corePayload?.edges);
|
||||
const tombstones = sanitizeSnapshotRecordArray(auxPayload?.tombstones);
|
||||
const mergedMeta = {
|
||||
...baseMeta,
|
||||
...coreMeta,
|
||||
...auxMeta,
|
||||
};
|
||||
const state = normalizeSnapshotState({
|
||||
meta: {
|
||||
...baseMeta,
|
||||
...coreMeta,
|
||||
...auxMeta,
|
||||
meta: mergedMeta,
|
||||
state: {
|
||||
...(corePayload?.state &&
|
||||
typeof corePayload.state === "object" &&
|
||||
!Array.isArray(corePayload.state)
|
||||
? corePayload.state
|
||||
: {}),
|
||||
...(Number.isFinite(Number(baseMeta?.lastProcessedFloor))
|
||||
? { lastProcessedFloor: Number(baseMeta.lastProcessedFloor) }
|
||||
: {}),
|
||||
...(Number.isFinite(Number(baseMeta?.extractionCount))
|
||||
? { extractionCount: Number(baseMeta.extractionCount) }
|
||||
: {}),
|
||||
},
|
||||
state: corePayload?.state,
|
||||
});
|
||||
const meta = {
|
||||
...createDefaultMetaValues(baseMeta.chatId || manifest?.chatId || ""),
|
||||
...toPlainData(baseMeta, {}),
|
||||
...toPlainData(coreMeta, {}),
|
||||
...toPlainData(auxMeta, {}),
|
||||
...toPlainData(mergedMeta, {}),
|
||||
chatId: normalizeChatId(baseMeta.chatId || manifest?.chatId || ""),
|
||||
schemaVersion: BME_DB_SCHEMA_VERSION,
|
||||
nodeCount: nodes.length,
|
||||
@@ -524,6 +590,14 @@ export class OpfsGraphStore {
|
||||
: getDefaultOpfsRootDirectory;
|
||||
this._chatDirectoryPromise = null;
|
||||
this._manifestCache = null;
|
||||
this._writeChain = Promise.resolve();
|
||||
this._writeQueueDepth = 0;
|
||||
this._writeLockState = {
|
||||
active: false,
|
||||
queueDepth: 0,
|
||||
lastReason: "",
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
async open() {
|
||||
@@ -534,11 +608,79 @@ export class OpfsGraphStore {
|
||||
async close() {
|
||||
this._chatDirectoryPromise = null;
|
||||
this._manifestCache = null;
|
||||
this._writeChain = Promise.resolve();
|
||||
this._writeQueueDepth = 0;
|
||||
this._writeLockState = {
|
||||
active: false,
|
||||
queueDepth: 0,
|
||||
lastReason: "",
|
||||
updatedAt: 0,
|
||||
};
|
||||
}
|
||||
|
||||
getWriteLockSnapshot() {
|
||||
return toPlainData(this._writeLockState, this._writeLockState);
|
||||
}
|
||||
|
||||
async _awaitPendingWrites() {
|
||||
try {
|
||||
await this._writeChain;
|
||||
} catch {
|
||||
// swallow previous write failure for read barrier
|
||||
}
|
||||
}
|
||||
|
||||
_setWriteLockState(patch = {}) {
|
||||
this._writeLockState = {
|
||||
...this._writeLockState,
|
||||
...(patch || {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
return this._writeLockState;
|
||||
}
|
||||
|
||||
async _runSerializedWrite(reason = "opfs-write", task = null) {
|
||||
if (typeof task !== "function") {
|
||||
throw new Error("OpfsGraphStore serialized write task is required");
|
||||
}
|
||||
this._writeQueueDepth += 1;
|
||||
this._setWriteLockState({
|
||||
active: true,
|
||||
queueDepth: this._writeQueueDepth,
|
||||
lastReason: String(reason || "opfs-write"),
|
||||
});
|
||||
const runTask = async () => {
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
this._writeQueueDepth = Math.max(0, this._writeQueueDepth - 1);
|
||||
this._setWriteLockState({
|
||||
active: this._writeQueueDepth > 0,
|
||||
queueDepth: this._writeQueueDepth,
|
||||
lastReason: String(reason || "opfs-write"),
|
||||
});
|
||||
}
|
||||
};
|
||||
const nextWrite = this._writeChain.catch(() => null).then(runTask);
|
||||
this._writeChain = nextWrite.catch(() => null);
|
||||
return await nextWrite;
|
||||
}
|
||||
|
||||
async getMeta(key, fallbackValue = null) {
|
||||
const normalizedKey = normalizeRecordId(key);
|
||||
if (!normalizedKey) return fallbackValue;
|
||||
if (OPFS_MANIFEST_META_KEYS.has(normalizedKey)) {
|
||||
const manifest = await this._ensureManifest();
|
||||
const manifestMeta =
|
||||
manifest?.meta &&
|
||||
typeof manifest.meta === "object" &&
|
||||
!Array.isArray(manifest.meta)
|
||||
? manifest.meta
|
||||
: {};
|
||||
return Object.prototype.hasOwnProperty.call(manifestMeta, normalizedKey)
|
||||
? manifestMeta[normalizedKey]
|
||||
: fallbackValue;
|
||||
}
|
||||
const snapshot = await this._loadSnapshot();
|
||||
return Object.prototype.hasOwnProperty.call(snapshot.meta, normalizedKey)
|
||||
? snapshot.meta[normalizedKey]
|
||||
@@ -548,22 +690,12 @@ export class OpfsGraphStore {
|
||||
async setMeta(key, value) {
|
||||
const normalizedKey = normalizeRecordId(key);
|
||||
if (!normalizedKey) return null;
|
||||
const snapshot = await this._loadSnapshot();
|
||||
snapshot.meta[normalizedKey] = toPlainData(value, value);
|
||||
if (normalizedKey === "lastProcessedFloor") {
|
||||
snapshot.state.lastProcessedFloor = Number.isFinite(Number(value))
|
||||
? Number(value)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR;
|
||||
}
|
||||
if (normalizedKey === "extractionCount") {
|
||||
snapshot.state.extractionCount = Number.isFinite(Number(value))
|
||||
? Number(value)
|
||||
: META_DEFAULT_EXTRACTION_COUNT;
|
||||
}
|
||||
await this._writeResolvedSnapshot(snapshot);
|
||||
await this.patchMeta({
|
||||
[normalizedKey]: value,
|
||||
});
|
||||
return {
|
||||
key: normalizedKey,
|
||||
value: snapshot.meta[normalizedKey],
|
||||
value: await this.getMeta(normalizedKey, null),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
@@ -572,27 +704,68 @@ export class OpfsGraphStore {
|
||||
if (!record || typeof record !== "object" || Array.isArray(record)) {
|
||||
return {};
|
||||
}
|
||||
const snapshot = await this._loadSnapshot();
|
||||
const entries = [];
|
||||
for (const [rawKey, value] of Object.entries(record)) {
|
||||
const key = normalizeRecordId(rawKey);
|
||||
if (!key) continue;
|
||||
const normalizedValue = toPlainData(value, value);
|
||||
snapshot.meta[key] = normalizedValue;
|
||||
if (key === "lastProcessedFloor") {
|
||||
snapshot.state.lastProcessedFloor = Number.isFinite(Number(normalizedValue))
|
||||
? Number(normalizedValue)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR;
|
||||
}
|
||||
if (key === "extractionCount") {
|
||||
snapshot.state.extractionCount = Number.isFinite(Number(normalizedValue))
|
||||
? Number(normalizedValue)
|
||||
: META_DEFAULT_EXTRACTION_COUNT;
|
||||
}
|
||||
entries.push([key, normalizedValue]);
|
||||
const entries = Object.entries(record)
|
||||
.map(([rawKey, value]) => [normalizeRecordId(rawKey), toPlainData(value, value)])
|
||||
.filter(([key]) => Boolean(key));
|
||||
if (!entries.length) {
|
||||
return {};
|
||||
}
|
||||
await this._writeResolvedSnapshot(snapshot);
|
||||
return Object.fromEntries(entries);
|
||||
|
||||
const allManifestOnly = entries.every(([key]) =>
|
||||
OPFS_MANIFEST_META_KEYS.has(key),
|
||||
);
|
||||
if (allManifestOnly) {
|
||||
return await this._runSerializedWrite("patchMeta:manifest", async () => {
|
||||
const manifest = await this._ensureManifest({ awaitWrites: false });
|
||||
const nextMeta = {
|
||||
...createDefaultMetaValues(this.chatId),
|
||||
...(manifest?.meta && typeof manifest.meta === "object" && !Array.isArray(manifest.meta)
|
||||
? toPlainData(manifest.meta, {})
|
||||
: {}),
|
||||
chatId: this.chatId,
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
};
|
||||
for (const [key, normalizedValue] of entries) {
|
||||
nextMeta[key] = normalizedValue;
|
||||
}
|
||||
const nextManifest = {
|
||||
...(manifest || {}),
|
||||
version: OPFS_MANIFEST_VERSION,
|
||||
chatId: this.chatId,
|
||||
storeKind: OPFS_STORE_KIND,
|
||||
storeMode: this.storeMode,
|
||||
activeCoreFilename: String(manifest?.activeCoreFilename || ""),
|
||||
activeAuxFilename: String(manifest?.activeAuxFilename || ""),
|
||||
meta: nextMeta,
|
||||
};
|
||||
const chatDirectory = await this._getChatDirectory();
|
||||
await writeJsonFile(chatDirectory, OPFS_MANIFEST_FILENAME, nextManifest);
|
||||
this._manifestCache = nextManifest;
|
||||
return Object.fromEntries(entries);
|
||||
});
|
||||
}
|
||||
|
||||
return await this._runSerializedWrite("patchMeta:snapshot", async () => {
|
||||
const snapshot = await this._loadSnapshot({ awaitWrites: false });
|
||||
const appliedEntries = [];
|
||||
for (const [key, normalizedValue] of entries) {
|
||||
snapshot.meta[key] = normalizedValue;
|
||||
if (key === "lastProcessedFloor") {
|
||||
snapshot.state.lastProcessedFloor = Number.isFinite(Number(normalizedValue))
|
||||
? Number(normalizedValue)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR;
|
||||
}
|
||||
if (key === "extractionCount") {
|
||||
snapshot.state.extractionCount = Number.isFinite(Number(normalizedValue))
|
||||
? Number(normalizedValue)
|
||||
: META_DEFAULT_EXTRACTION_COUNT;
|
||||
}
|
||||
appliedEntries.push([key, normalizedValue]);
|
||||
}
|
||||
await this._writeResolvedSnapshot(snapshot);
|
||||
return Object.fromEntries(appliedEntries);
|
||||
});
|
||||
}
|
||||
|
||||
async getRevision() {
|
||||
@@ -608,13 +781,16 @@ export class OpfsGraphStore {
|
||||
}
|
||||
|
||||
async commitDelta(delta = {}, options = {}) {
|
||||
const nowMs = Date.now();
|
||||
const normalizedDelta =
|
||||
delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
|
||||
const currentSnapshot = await this._loadSnapshot();
|
||||
const nodeMap = new Map();
|
||||
const edgeMap = new Map();
|
||||
const tombstoneMap = new Map();
|
||||
return await this._runSerializedWrite(
|
||||
String(options?.reason || "commitDelta"),
|
||||
async () => {
|
||||
const nowMs = Date.now();
|
||||
const normalizedDelta =
|
||||
delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
|
||||
const currentSnapshot = await this._loadSnapshot({ awaitWrites: false });
|
||||
const nodeMap = new Map();
|
||||
const edgeMap = new Map();
|
||||
const tombstoneMap = new Map();
|
||||
|
||||
for (const node of sanitizeSnapshotRecordArray(currentSnapshot.nodes)) {
|
||||
const id = normalizeRecordId(node.id);
|
||||
@@ -721,31 +897,33 @@ export class OpfsGraphStore {
|
||||
? Number(runtimeMetaPatch.extractionCount)
|
||||
: currentSnapshot.state.extractionCount,
|
||||
};
|
||||
const nextSnapshot = {
|
||||
meta: nextMeta,
|
||||
state: nextState,
|
||||
nodes: Array.from(nodeMap.values()),
|
||||
edges: Array.from(edgeMap.values()),
|
||||
tombstones: Array.from(tombstoneMap.values()),
|
||||
};
|
||||
await this._writeResolvedSnapshot(nextSnapshot);
|
||||
const nextSnapshot = {
|
||||
meta: nextMeta,
|
||||
state: nextState,
|
||||
nodes: Array.from(nodeMap.values()),
|
||||
edges: Array.from(edgeMap.values()),
|
||||
tombstones: Array.from(tombstoneMap.values()),
|
||||
};
|
||||
await this._writeResolvedSnapshot(nextSnapshot);
|
||||
|
||||
return {
|
||||
revision: nextRevision,
|
||||
lastModified: nowMs,
|
||||
imported: {
|
||||
nodes: nextSnapshot.nodes.length,
|
||||
edges: nextSnapshot.edges.length,
|
||||
tombstones: nextSnapshot.tombstones.length,
|
||||
return {
|
||||
revision: nextRevision,
|
||||
lastModified: nowMs,
|
||||
imported: {
|
||||
nodes: nextSnapshot.nodes.length,
|
||||
edges: nextSnapshot.edges.length,
|
||||
tombstones: nextSnapshot.tombstones.length,
|
||||
},
|
||||
delta: {
|
||||
upsertNodes: upsertNodes.length,
|
||||
upsertEdges: upsertEdges.length,
|
||||
deleteNodeIds: deleteNodeIds.length,
|
||||
deleteEdgeIds: deleteEdgeIds.length,
|
||||
tombstones: tombstones.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
delta: {
|
||||
upsertNodes: upsertNodes.length,
|
||||
upsertEdges: upsertEdges.length,
|
||||
deleteNodeIds: deleteNodeIds.length,
|
||||
deleteEdgeIds: deleteEdgeIds.length,
|
||||
tombstones: tombstones.length,
|
||||
},
|
||||
};
|
||||
);
|
||||
}
|
||||
|
||||
async bulkUpsertNodes(nodes = []) {
|
||||
@@ -990,140 +1168,216 @@ export class OpfsGraphStore {
|
||||
}
|
||||
|
||||
async importSnapshot(snapshot, options = {}) {
|
||||
const normalizedSnapshot = sanitizeSnapshot(snapshot);
|
||||
const mode = normalizeMode(options.mode);
|
||||
const shouldMarkSyncDirty = options.markSyncDirty !== false;
|
||||
const nowMs = Date.now();
|
||||
const currentSnapshot = await this._loadSnapshot();
|
||||
const nextSnapshot =
|
||||
mode === "replace"
|
||||
? normalizedSnapshot
|
||||
: {
|
||||
meta: {
|
||||
...currentSnapshot.meta,
|
||||
...normalizedSnapshot.meta,
|
||||
},
|
||||
state: {
|
||||
...currentSnapshot.state,
|
||||
...normalizedSnapshot.state,
|
||||
},
|
||||
nodes: mergeSnapshotRecords(currentSnapshot.nodes, normalizedSnapshot.nodes),
|
||||
edges: mergeSnapshotRecords(currentSnapshot.edges, normalizedSnapshot.edges),
|
||||
tombstones: mergeSnapshotRecords(
|
||||
currentSnapshot.tombstones,
|
||||
normalizedSnapshot.tombstones,
|
||||
),
|
||||
};
|
||||
const currentRevision = normalizeRevision(currentSnapshot.meta?.revision);
|
||||
const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision);
|
||||
const explicitRevision = normalizeRevision(options.revision);
|
||||
const requestedRevision = Number.isFinite(Number(options.revision))
|
||||
? explicitRevision
|
||||
: options.preserveRevision
|
||||
? incomingRevision
|
||||
: currentRevision + 1;
|
||||
const nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||||
nextSnapshot.meta = {
|
||||
...nextSnapshot.meta,
|
||||
chatId: this.chatId,
|
||||
revision: nextRevision,
|
||||
lastModified: nowMs,
|
||||
lastMutationReason: "importSnapshot",
|
||||
syncDirty: shouldMarkSyncDirty,
|
||||
syncDirtyReason: "importSnapshot",
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
};
|
||||
nextSnapshot.state = {
|
||||
...nextSnapshot.state,
|
||||
lastProcessedFloor: Number.isFinite(Number(nextSnapshot?.state?.lastProcessedFloor))
|
||||
? Number(nextSnapshot.state.lastProcessedFloor)
|
||||
: Number.isFinite(Number(nextSnapshot?.meta?.lastProcessedFloor))
|
||||
? Number(nextSnapshot.meta.lastProcessedFloor)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
extractionCount: Number.isFinite(Number(nextSnapshot?.state?.extractionCount))
|
||||
? Number(nextSnapshot.state.extractionCount)
|
||||
: Number.isFinite(Number(nextSnapshot?.meta?.extractionCount))
|
||||
? Number(nextSnapshot.meta.extractionCount)
|
||||
: META_DEFAULT_EXTRACTION_COUNT,
|
||||
};
|
||||
await this._writeResolvedSnapshot(nextSnapshot);
|
||||
return await this._runSerializedWrite("importSnapshot", async () => {
|
||||
const normalizedSnapshot = sanitizeSnapshot(snapshot);
|
||||
const mode = normalizeMode(options.mode);
|
||||
const shouldMarkSyncDirty = options.markSyncDirty !== false;
|
||||
const nowMs = Date.now();
|
||||
const currentSnapshot = await this._loadSnapshot({ awaitWrites: false });
|
||||
const nextSnapshot =
|
||||
mode === "replace"
|
||||
? normalizedSnapshot
|
||||
: {
|
||||
meta: {
|
||||
...currentSnapshot.meta,
|
||||
...normalizedSnapshot.meta,
|
||||
},
|
||||
state: {
|
||||
...currentSnapshot.state,
|
||||
...normalizedSnapshot.state,
|
||||
},
|
||||
nodes: mergeSnapshotRecords(currentSnapshot.nodes, normalizedSnapshot.nodes),
|
||||
edges: mergeSnapshotRecords(currentSnapshot.edges, normalizedSnapshot.edges),
|
||||
tombstones: mergeSnapshotRecords(
|
||||
currentSnapshot.tombstones,
|
||||
normalizedSnapshot.tombstones,
|
||||
),
|
||||
};
|
||||
const currentRevision = normalizeRevision(currentSnapshot.meta?.revision);
|
||||
const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision);
|
||||
const explicitRevision = normalizeRevision(options.revision);
|
||||
const requestedRevision = Number.isFinite(Number(options.revision))
|
||||
? explicitRevision
|
||||
: options.preserveRevision
|
||||
? incomingRevision
|
||||
: currentRevision + 1;
|
||||
const nextRevision = Math.max(currentRevision + 1, requestedRevision);
|
||||
nextSnapshot.meta = {
|
||||
...nextSnapshot.meta,
|
||||
chatId: this.chatId,
|
||||
revision: nextRevision,
|
||||
lastModified: nowMs,
|
||||
lastMutationReason: "importSnapshot",
|
||||
syncDirty: shouldMarkSyncDirty,
|
||||
syncDirtyReason: "importSnapshot",
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
};
|
||||
nextSnapshot.state = {
|
||||
...nextSnapshot.state,
|
||||
lastProcessedFloor: Number.isFinite(Number(nextSnapshot?.state?.lastProcessedFloor))
|
||||
? Number(nextSnapshot.state.lastProcessedFloor)
|
||||
: Number.isFinite(Number(nextSnapshot?.meta?.lastProcessedFloor))
|
||||
? Number(nextSnapshot.meta.lastProcessedFloor)
|
||||
: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
extractionCount: Number.isFinite(Number(nextSnapshot?.state?.extractionCount))
|
||||
? Number(nextSnapshot.state.extractionCount)
|
||||
: Number.isFinite(Number(nextSnapshot?.meta?.extractionCount))
|
||||
? Number(nextSnapshot.meta.extractionCount)
|
||||
: META_DEFAULT_EXTRACTION_COUNT,
|
||||
};
|
||||
await this._writeResolvedSnapshot(nextSnapshot);
|
||||
|
||||
return {
|
||||
mode,
|
||||
revision: nextRevision,
|
||||
imported: {
|
||||
nodes: nextSnapshot.nodes.length,
|
||||
edges: nextSnapshot.edges.length,
|
||||
tombstones: nextSnapshot.tombstones.length,
|
||||
},
|
||||
};
|
||||
return {
|
||||
mode,
|
||||
revision: nextRevision,
|
||||
imported: {
|
||||
nodes: nextSnapshot.nodes.length,
|
||||
edges: nextSnapshot.edges.length,
|
||||
tombstones: nextSnapshot.tombstones.length,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async clearAll() {
|
||||
const currentRevision = await this.getRevision();
|
||||
const nextRevision = currentRevision + 1;
|
||||
await this._writeResolvedSnapshot({
|
||||
meta: {
|
||||
return await this._runSerializedWrite("clearAll", async () => {
|
||||
const currentRevision = normalizeRevision(
|
||||
(await this._readManifest({ awaitWrites: false }))?.meta?.revision,
|
||||
);
|
||||
const nextRevision = currentRevision + 1;
|
||||
await this._writeResolvedSnapshot({
|
||||
meta: {
|
||||
revision: nextRevision,
|
||||
lastModified: Date.now(),
|
||||
lastMutationReason: "clearAll",
|
||||
syncDirty: true,
|
||||
syncDirtyReason: "clearAll",
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
extractionCount: META_DEFAULT_EXTRACTION_COUNT,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
});
|
||||
return {
|
||||
cleared: true,
|
||||
revision: nextRevision,
|
||||
lastModified: Date.now(),
|
||||
lastMutationReason: "clearAll",
|
||||
syncDirty: true,
|
||||
syncDirtyReason: "clearAll",
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: META_DEFAULT_LAST_PROCESSED_FLOOR,
|
||||
extractionCount: META_DEFAULT_EXTRACTION_COUNT,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
};
|
||||
});
|
||||
return {
|
||||
cleared: true,
|
||||
revision: nextRevision,
|
||||
};
|
||||
}
|
||||
|
||||
async pruneExpiredTombstones(nowMs = Date.now()) {
|
||||
const normalizedNow = normalizeTimestamp(nowMs, Date.now());
|
||||
const cutoffMs = normalizedNow - BME_TOMBSTONE_RETENTION_MS;
|
||||
const snapshot = await this._loadSnapshot();
|
||||
const nextTombstones = snapshot.tombstones.filter(
|
||||
(item) => normalizeTimestamp(item?.deletedAt, 0) >= cutoffMs,
|
||||
return await this._runSerializedWrite(
|
||||
"pruneExpiredTombstones",
|
||||
async () => {
|
||||
const normalizedNow = normalizeTimestamp(nowMs, Date.now());
|
||||
const cutoffMs = normalizedNow - BME_TOMBSTONE_RETENTION_MS;
|
||||
const snapshot = await this._loadSnapshot({ awaitWrites: false });
|
||||
const nextTombstones = snapshot.tombstones.filter(
|
||||
(item) => normalizeTimestamp(item?.deletedAt, 0) >= cutoffMs,
|
||||
);
|
||||
const removedCount = snapshot.tombstones.length - nextTombstones.length;
|
||||
if (removedCount <= 0) {
|
||||
return {
|
||||
pruned: 0,
|
||||
revision: normalizeRevision(snapshot.meta?.revision),
|
||||
cutoffMs,
|
||||
};
|
||||
}
|
||||
const nextRevision = normalizeRevision(snapshot.meta?.revision) + 1;
|
||||
await this._writeResolvedSnapshot({
|
||||
meta: {
|
||||
...snapshot.meta,
|
||||
revision: nextRevision,
|
||||
lastModified: normalizedNow,
|
||||
lastMutationReason: "pruneExpiredTombstones",
|
||||
syncDirty: true,
|
||||
syncDirtyReason: "pruneExpiredTombstones",
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
},
|
||||
state: snapshot.state,
|
||||
nodes: snapshot.nodes,
|
||||
edges: snapshot.edges,
|
||||
tombstones: nextTombstones,
|
||||
});
|
||||
return {
|
||||
pruned: removedCount,
|
||||
revision: nextRevision,
|
||||
cutoffMs,
|
||||
};
|
||||
},
|
||||
);
|
||||
const removedCount = snapshot.tombstones.length - nextTombstones.length;
|
||||
if (removedCount <= 0) {
|
||||
return {
|
||||
pruned: 0,
|
||||
revision: normalizeRevision(snapshot.meta?.revision),
|
||||
cutoffMs,
|
||||
};
|
||||
}
|
||||
|
||||
async _recoverManifestFromDirectory(chatDirectory, manifest = null) {
|
||||
const fileNames = await listDirectoryFileNames(chatDirectory);
|
||||
const coreCandidates = fileNames
|
||||
.map((name) => parseSnapshotFilenameCandidate(name, OPFS_CORE_FILENAME_PREFIX))
|
||||
.filter(Boolean);
|
||||
const auxCandidates = fileNames
|
||||
.map((name) => parseSnapshotFilenameCandidate(name, OPFS_AUX_FILENAME_PREFIX))
|
||||
.filter(Boolean);
|
||||
if (!coreCandidates.length || !auxCandidates.length) {
|
||||
return null;
|
||||
}
|
||||
const nextRevision = normalizeRevision(snapshot.meta?.revision) + 1;
|
||||
await this._writeResolvedSnapshot({
|
||||
|
||||
const coreByRevision = new Map();
|
||||
const auxByRevision = new Map();
|
||||
for (const candidate of coreCandidates) {
|
||||
const current = coreByRevision.get(candidate.revision) || null;
|
||||
if (!current || candidate.stampMs > current.stampMs) {
|
||||
coreByRevision.set(candidate.revision, candidate);
|
||||
}
|
||||
}
|
||||
for (const candidate of auxCandidates) {
|
||||
const current = auxByRevision.get(candidate.revision) || null;
|
||||
if (!current || candidate.stampMs > current.stampMs) {
|
||||
auxByRevision.set(candidate.revision, candidate);
|
||||
}
|
||||
}
|
||||
|
||||
const candidateRevisions = Array.from(coreByRevision.keys())
|
||||
.filter((revision) => auxByRevision.has(revision))
|
||||
.sort((left, right) => right - left);
|
||||
if (!candidateRevisions.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const recoveredRevision = candidateRevisions[0];
|
||||
const recoveredCore = coreByRevision.get(recoveredRevision);
|
||||
const recoveredAux = auxByRevision.get(recoveredRevision);
|
||||
if (!recoveredCore || !recoveredAux) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextManifest = {
|
||||
...(manifest || {}),
|
||||
version: OPFS_MANIFEST_VERSION,
|
||||
chatId: this.chatId,
|
||||
storeKind: OPFS_STORE_KIND,
|
||||
storeMode: this.storeMode,
|
||||
activeCoreFilename: recoveredCore.filename,
|
||||
activeAuxFilename: recoveredAux.filename,
|
||||
meta: {
|
||||
...snapshot.meta,
|
||||
revision: nextRevision,
|
||||
lastModified: normalizedNow,
|
||||
lastMutationReason: "pruneExpiredTombstones",
|
||||
syncDirty: true,
|
||||
syncDirtyReason: "pruneExpiredTombstones",
|
||||
...createDefaultMetaValues(this.chatId),
|
||||
...(manifest?.meta && typeof manifest.meta === "object" && !Array.isArray(manifest.meta)
|
||||
? toPlainData(manifest.meta, {})
|
||||
: {}),
|
||||
revision: recoveredRevision,
|
||||
chatId: this.chatId,
|
||||
storagePrimary: OPFS_STORE_KIND,
|
||||
storageMode: this.storeMode,
|
||||
},
|
||||
state: snapshot.state,
|
||||
nodes: snapshot.nodes,
|
||||
edges: snapshot.edges,
|
||||
tombstones: nextTombstones,
|
||||
});
|
||||
return {
|
||||
pruned: removedCount,
|
||||
revision: nextRevision,
|
||||
cutoffMs,
|
||||
};
|
||||
await writeJsonFile(chatDirectory, OPFS_MANIFEST_FILENAME, nextManifest);
|
||||
this._manifestCache = nextManifest;
|
||||
return nextManifest;
|
||||
}
|
||||
|
||||
async _getChatDirectory() {
|
||||
@@ -1150,8 +1404,8 @@ export class OpfsGraphStore {
|
||||
return await this._chatDirectoryPromise;
|
||||
}
|
||||
|
||||
async _ensureManifest() {
|
||||
const existingManifest = await this._readManifest();
|
||||
async _ensureManifest(options = {}) {
|
||||
const existingManifest = await this._readManifest(options);
|
||||
if (existingManifest) {
|
||||
return existingManifest;
|
||||
}
|
||||
@@ -1172,7 +1426,10 @@ export class OpfsGraphStore {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async _readManifest() {
|
||||
async _readManifest({ awaitWrites = true } = {}) {
|
||||
if (awaitWrites) {
|
||||
await this._awaitPendingWrites();
|
||||
}
|
||||
if (this._manifestCache) {
|
||||
return this._manifestCache;
|
||||
}
|
||||
@@ -1209,21 +1466,71 @@ export class OpfsGraphStore {
|
||||
return manifest;
|
||||
}
|
||||
|
||||
async _loadSnapshot() {
|
||||
const manifest = await this._ensureManifest();
|
||||
async _loadSnapshot({ awaitWrites = true } = {}) {
|
||||
if (awaitWrites) {
|
||||
await this._awaitPendingWrites();
|
||||
}
|
||||
let manifest = await this._ensureManifest({
|
||||
awaitWrites: false,
|
||||
});
|
||||
const chatDirectory = await this._getChatDirectory();
|
||||
const corePayload = manifest.activeCoreFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeCoreFilename, {})
|
||||
: {};
|
||||
const auxPayload = manifest.activeAuxFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeAuxFilename, {})
|
||||
: {};
|
||||
const activeCoreRevision = parseSnapshotFilenameCandidate(
|
||||
manifest?.activeCoreFilename,
|
||||
OPFS_CORE_FILENAME_PREFIX,
|
||||
)?.revision;
|
||||
const activeAuxRevision = parseSnapshotFilenameCandidate(
|
||||
manifest?.activeAuxFilename,
|
||||
OPFS_AUX_FILENAME_PREFIX,
|
||||
)?.revision;
|
||||
let shouldRecoverManifest =
|
||||
Boolean(manifest?.activeCoreFilename) &&
|
||||
Boolean(manifest?.activeAuxFilename) &&
|
||||
Number.isFinite(activeCoreRevision) &&
|
||||
Number.isFinite(activeAuxRevision) &&
|
||||
activeCoreRevision !== activeAuxRevision;
|
||||
let corePayload = {};
|
||||
let auxPayload = {};
|
||||
try {
|
||||
corePayload = manifest.activeCoreFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeCoreFilename, null)
|
||||
: {};
|
||||
auxPayload = manifest.activeAuxFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeAuxFilename, null)
|
||||
: {};
|
||||
if (
|
||||
(manifest.activeCoreFilename && !corePayload) ||
|
||||
(manifest.activeAuxFilename && !auxPayload)
|
||||
) {
|
||||
shouldRecoverManifest = true;
|
||||
}
|
||||
} catch {
|
||||
shouldRecoverManifest = true;
|
||||
}
|
||||
|
||||
if (shouldRecoverManifest) {
|
||||
const recoveredManifest = await this._recoverManifestFromDirectory(
|
||||
chatDirectory,
|
||||
manifest,
|
||||
);
|
||||
if (!recoveredManifest) {
|
||||
throw new Error("opfs-manifest-snapshot-mismatch");
|
||||
}
|
||||
manifest = recoveredManifest;
|
||||
corePayload = manifest.activeCoreFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeCoreFilename, {})
|
||||
: {};
|
||||
auxPayload = manifest.activeAuxFilename
|
||||
? await readJsonFile(chatDirectory, manifest.activeAuxFilename, {})
|
||||
: {};
|
||||
}
|
||||
return buildSnapshotFromStoredParts(manifest, corePayload, auxPayload);
|
||||
}
|
||||
|
||||
async _writeResolvedSnapshot(snapshot) {
|
||||
const chatDirectory = await this._getChatDirectory();
|
||||
const previousManifest = await this._ensureManifest();
|
||||
const previousManifest = await this._ensureManifest({
|
||||
awaitWrites: false,
|
||||
});
|
||||
const normalizedSnapshot = sanitizeSnapshot(snapshot);
|
||||
const state = normalizeSnapshotState(normalizedSnapshot);
|
||||
const writeStamp = Date.now();
|
||||
|
||||
@@ -1098,6 +1098,7 @@ result = {
|
||||
applyGraphLoadState,
|
||||
maybeFlushQueuedGraphPersist,
|
||||
retryPendingGraphPersist,
|
||||
persistExtractionBatchResult,
|
||||
saveGraphToIndexedDb,
|
||||
cloneGraphForPersistence,
|
||||
assertRecoveryChatStillActive,
|
||||
@@ -3185,4 +3186,89 @@ result = {
|
||||
assert.equal(result?.commitMarker?.storageTier, "chat-state");
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-generic-primary-no-mirror",
|
||||
globalChatId: "chat-generic-primary-no-mirror",
|
||||
characterId: "char-generic",
|
||||
chatMetadata: {
|
||||
integrity: "meta-generic-primary-no-mirror",
|
||||
},
|
||||
});
|
||||
const graph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-generic-primary-no-mirror", "generic-primary"),
|
||||
{
|
||||
revision: 5,
|
||||
integrity: "meta-generic-primary-no-mirror",
|
||||
chatId: "chat-generic-primary-no-mirror",
|
||||
reason: "generic-primary-seed",
|
||||
},
|
||||
);
|
||||
harness.api.setCurrentGraph(graph);
|
||||
|
||||
const result = await harness.api.persistExtractionBatchResult({
|
||||
reason: "generic-primary-persist",
|
||||
lastProcessedAssistantFloor: 6,
|
||||
});
|
||||
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.storageTier, "indexeddb");
|
||||
assert.equal(
|
||||
harness.runtimeContext.__chatContext.__chatStateStore.size,
|
||||
0,
|
||||
"generic ST 主写成功后不应再常驻 mirror 到 chat-state",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-luker-primary",
|
||||
globalChatId: "chat-luker-primary",
|
||||
characterId: "char-luker",
|
||||
chatMetadata: {
|
||||
integrity: "meta-luker-primary",
|
||||
},
|
||||
});
|
||||
harness.runtimeContext.Luker = {
|
||||
getContext() {
|
||||
return harness.runtimeContext.__chatContext;
|
||||
},
|
||||
};
|
||||
const graph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-luker-primary", "luker-primary"),
|
||||
{
|
||||
revision: 8,
|
||||
integrity: "meta-luker-primary",
|
||||
chatId: "chat-luker-primary",
|
||||
reason: "luker-primary-seed",
|
||||
},
|
||||
);
|
||||
harness.api.setCurrentGraph(graph);
|
||||
|
||||
const result = await harness.api.persistExtractionBatchResult({
|
||||
reason: "luker-primary-persist",
|
||||
lastProcessedAssistantFloor: 6,
|
||||
});
|
||||
|
||||
assert.equal(result.accepted, true);
|
||||
assert.equal(result.storageTier, "luker-chat-state");
|
||||
assert.equal(result.acceptedBy, "luker-chat-state");
|
||||
|
||||
const stored = await harness.runtimeContext.__chatContext.getChatState(
|
||||
GRAPH_CHAT_STATE_NAMESPACE,
|
||||
);
|
||||
assert.equal(stored?.revision, result.revision);
|
||||
assert.equal(stored?.storageTier, "luker-chat-state");
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
assert.equal(
|
||||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision || 0) >= result.revision,
|
||||
true,
|
||||
"Luker 主存储成功后应异步补写本地缓存",
|
||||
);
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceState().acceptedStorageTier,
|
||||
"luker-chat-state",
|
||||
);
|
||||
}
|
||||
|
||||
console.log("graph-persistence tests passed");
|
||||
|
||||
107
tests/helpers/memory-opfs.mjs
Normal file
107
tests/helpers/memory-opfs.mjs
Normal file
@@ -0,0 +1,107 @@
|
||||
export function createNotFoundError(message) {
|
||||
const error = new Error(String(message || "Not found"));
|
||||
error.name = "NotFoundError";
|
||||
return error;
|
||||
}
|
||||
|
||||
export class MemoryOpfsFileHandle {
|
||||
constructor(parent, name) {
|
||||
this.parent = parent;
|
||||
this.name = String(name || "");
|
||||
}
|
||||
|
||||
async getFile() {
|
||||
const parent = this.parent;
|
||||
const name = this.name;
|
||||
return {
|
||||
async text() {
|
||||
return String(parent.files.get(name) ?? "");
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async createWritable() {
|
||||
const parent = this.parent;
|
||||
const name = this.name;
|
||||
let buffer = String(parent.files.get(name) ?? "");
|
||||
return {
|
||||
async write(chunk) {
|
||||
if (typeof chunk === "string") {
|
||||
buffer = chunk;
|
||||
return;
|
||||
}
|
||||
if (chunk == null) {
|
||||
buffer = "";
|
||||
return;
|
||||
}
|
||||
buffer = String(chunk);
|
||||
},
|
||||
async close() {
|
||||
if (Number(parent.writeDelayMs) > 0) {
|
||||
await new Promise((resolve) =>
|
||||
setTimeout(resolve, Number(parent.writeDelayMs)),
|
||||
);
|
||||
}
|
||||
parent.files.set(name, buffer);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export class MemoryOpfsDirectoryHandle {
|
||||
constructor(name = "", { writeDelayMs = 0 } = {}) {
|
||||
this.name = String(name || "");
|
||||
this.directories = new Map();
|
||||
this.files = new Map();
|
||||
this.writeDelayMs = Number(writeDelayMs) || 0;
|
||||
}
|
||||
|
||||
async getDirectoryHandle(name, options = {}) {
|
||||
const normalizedName = String(name || "");
|
||||
let directory = this.directories.get(normalizedName) || null;
|
||||
if (!directory) {
|
||||
if (!options.create) {
|
||||
throw createNotFoundError(`Directory not found: ${normalizedName}`);
|
||||
}
|
||||
directory = new MemoryOpfsDirectoryHandle(normalizedName, {
|
||||
writeDelayMs: this.writeDelayMs,
|
||||
});
|
||||
this.directories.set(normalizedName, directory);
|
||||
}
|
||||
return directory;
|
||||
}
|
||||
|
||||
async getFileHandle(name, options = {}) {
|
||||
const normalizedName = String(name || "");
|
||||
if (!this.files.has(normalizedName)) {
|
||||
if (!options.create) {
|
||||
throw createNotFoundError(`File not found: ${normalizedName}`);
|
||||
}
|
||||
this.files.set(normalizedName, "");
|
||||
}
|
||||
return new MemoryOpfsFileHandle(this, normalizedName);
|
||||
}
|
||||
|
||||
async removeEntry(name, options = {}) {
|
||||
const normalizedName = String(name || "");
|
||||
if (this.files.delete(normalizedName)) {
|
||||
return;
|
||||
}
|
||||
const directory = this.directories.get(normalizedName) || null;
|
||||
if (directory) {
|
||||
const canDelete =
|
||||
options.recursive === true ||
|
||||
(directory.files.size === 0 && directory.directories.size === 0);
|
||||
if (!canDelete) {
|
||||
throw new Error(`Directory not empty: ${normalizedName}`);
|
||||
}
|
||||
this.directories.delete(normalizedName);
|
||||
return;
|
||||
}
|
||||
throw createNotFoundError(`Entry not found: ${normalizedName}`);
|
||||
}
|
||||
}
|
||||
|
||||
export function createMemoryOpfsRoot(options = {}) {
|
||||
return new MemoryOpfsDirectoryHandle("root", options);
|
||||
}
|
||||
180
tests/index-esm-entry-smoke.mjs
Normal file
180
tests/index-esm-entry-smoke.mjs
Normal file
@@ -0,0 +1,180 @@
|
||||
import assert from "node:assert/strict";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexPath = path.resolve(moduleDir, "../index.js");
|
||||
const indexSource = await fs.readFile(indexPath, "utf8");
|
||||
|
||||
function extractSnippet(startMarker, endMarker) {
|
||||
const start = indexSource.indexOf(startMarker);
|
||||
const end = indexSource.indexOf(endMarker, start);
|
||||
if (start < 0 || end < 0 || end <= start) {
|
||||
throw new Error(`无法提取 index.js 片段: ${startMarker} -> ${endMarker}`);
|
||||
}
|
||||
return indexSource.slice(start, end).replace(/^export\s+/gm, "");
|
||||
}
|
||||
|
||||
const saveGraphSnippet = extractSnippet(
|
||||
"async function saveGraphToIndexedDb(",
|
||||
"function queueGraphPersistToIndexedDb(",
|
||||
);
|
||||
|
||||
const tempModulePath = path.resolve(
|
||||
moduleDir,
|
||||
"../.tmp-index-esm-entry-smoke.mjs",
|
||||
);
|
||||
|
||||
await fs.writeFile(
|
||||
tempModulePath,
|
||||
`
|
||||
const GRAPH_LOAD_STATES = { SHADOW_RESTORED: "shadow-restored", LOADED: "loaded" };
|
||||
let currentGraph = null;
|
||||
let graphPersistenceState = {
|
||||
metadataIntegrity: "",
|
||||
loadState: "loaded",
|
||||
revision: 0,
|
||||
lastPersistedRevision: 0,
|
||||
lastAcceptedRevision: 0,
|
||||
cacheMirrorState: "idle",
|
||||
persistDiagnosticTier: "none",
|
||||
hostProfile: "generic-st",
|
||||
primaryStorageTier: "indexeddb",
|
||||
cacheStorageTier: "none",
|
||||
shadowSnapshotRevision: 0,
|
||||
shadowSnapshotUpdatedAt: "",
|
||||
shadowSnapshotReason: "",
|
||||
};
|
||||
function normalizeChatIdCandidate(value = "") { return String(value ?? "").trim(); }
|
||||
function normalizeIndexedDbRevision(value, fallbackValue = 0) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.floor(parsed) : Math.max(0, Number(fallbackValue) || 0);
|
||||
}
|
||||
function getContext() { return { chatId: "chat-esm", chatMetadata: {}, characterId: "char-esm" }; }
|
||||
function getSettings() {
|
||||
return {
|
||||
persistNativeDeltaBridgeMode: "json",
|
||||
persistUseNativeDelta: false,
|
||||
graphNativeForceDisable: false,
|
||||
nativeEngineFailOpen: true,
|
||||
};
|
||||
}
|
||||
function ensureBmeChatManager() {
|
||||
return {
|
||||
async getCurrentDb() {
|
||||
return {
|
||||
async exportSnapshot() {
|
||||
return { meta: { revision: 0 }, nodes: [], edges: [], tombstones: [], state: { lastProcessedFloor: -1, extractionCount: 0 } };
|
||||
},
|
||||
async commitDelta(delta, options = {}) {
|
||||
if (globalThis.__testCommitShouldThrow) {
|
||||
throw new Error("commit-failed");
|
||||
}
|
||||
return {
|
||||
revision: Number(options.requestedRevision || 1),
|
||||
lastModified: Date.now(),
|
||||
delta,
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
function getPreferredGraphLocalStorePresentationSync() {
|
||||
return { storagePrimary: "indexeddb", storageMode: "indexeddb", statusLabel: "IndexedDB", reasonPrefix: "indexeddb" };
|
||||
}
|
||||
function resolveDbGraphStorePresentation(db) {
|
||||
return { storagePrimary: "indexeddb", storageMode: "indexeddb", statusLabel: "IndexedDB", reasonPrefix: "indexeddb" };
|
||||
}
|
||||
function buildPersistenceEnvironment() {
|
||||
return { hostProfile: "generic-st", primaryStorageTier: "indexeddb", cacheStorageTier: "none" };
|
||||
}
|
||||
function resolveCurrentChatIdentity() {
|
||||
return { integrity: "meta-esm", hostChatId: "host-esm" };
|
||||
}
|
||||
function readCachedIndexedDbSnapshot() { return null; }
|
||||
function resolvePersistRevisionFloor(revision = 0) { return Number(revision) || 1; }
|
||||
function buildSnapshotFromGraph(graph, options = {}) {
|
||||
return {
|
||||
meta: {
|
||||
revision: Number(options.revision || 1),
|
||||
storagePrimary: "indexeddb",
|
||||
storageMode: "indexeddb",
|
||||
integrity: "meta-esm",
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
};
|
||||
}
|
||||
function evaluatePersistNativeDeltaGate() {
|
||||
return {
|
||||
allowed: false,
|
||||
reasons: [],
|
||||
minSnapshotRecords: 0,
|
||||
minStructuralDelta: 0,
|
||||
minCombinedSerializedChars: 0,
|
||||
beforeRecordCount: 0,
|
||||
afterRecordCount: 0,
|
||||
maxSnapshotRecords: 0,
|
||||
structuralDelta: 0,
|
||||
};
|
||||
}
|
||||
function readPersistDeltaDiagnosticsNow() { return Date.now(); }
|
||||
function updatePersistDeltaDiagnostics() {}
|
||||
function buildPersistDelta() {
|
||||
return {
|
||||
upsertNodes: [],
|
||||
upsertEdges: [],
|
||||
deleteNodeIds: [],
|
||||
deleteEdgeIds: [],
|
||||
tombstones: [],
|
||||
runtimeMetaPatch: {},
|
||||
};
|
||||
}
|
||||
function cloneRuntimeDebugValue(value, fallback = null) { return value == null ? fallback : JSON.parse(JSON.stringify(value)); }
|
||||
function buildBmeSyncRuntimeOptions() { return {}; }
|
||||
function scheduleUpload() {}
|
||||
function cacheIndexedDbSnapshot() {}
|
||||
function stampGraphPersistenceMeta() {}
|
||||
function getChatMetadataIntegrity() { return "meta-esm"; }
|
||||
function clearPendingGraphPersistRetry() {}
|
||||
function areChatIdsEquivalentForResolvedIdentity() { return false; }
|
||||
function applyGraphLoadState() {}
|
||||
function rememberResolvedGraphIdentityAlias() {}
|
||||
function resolveLocalStoreTierFromPresentation() { return "indexeddb"; }
|
||||
function updateGraphPersistenceState(patch = {}) { graphPersistenceState = { ...graphPersistenceState, ...(patch || {}) }; return graphPersistenceState; }
|
||||
${saveGraphSnippet}
|
||||
export { saveGraphToIndexedDb };
|
||||
`,
|
||||
"utf8",
|
||||
);
|
||||
|
||||
try {
|
||||
const smokeModule = await import(
|
||||
`${pathToFileURL(tempModulePath).href}?t=${Date.now()}`
|
||||
);
|
||||
const success = await smokeModule.saveGraphToIndexedDb(
|
||||
"chat-esm",
|
||||
{ historyState: {} },
|
||||
{ revision: 2, reason: "esm-success" },
|
||||
);
|
||||
assert.equal(success.saved, true);
|
||||
assert.equal(success.accepted, true);
|
||||
|
||||
globalThis.__testCommitShouldThrow = true;
|
||||
const failed = await smokeModule.saveGraphToIndexedDb(
|
||||
"chat-esm",
|
||||
{ historyState: {} },
|
||||
{ revision: 3, reason: "esm-failure" },
|
||||
);
|
||||
assert.equal(failed.saved, false);
|
||||
assert.equal(failed.reason, "indexeddb-write-failed");
|
||||
} finally {
|
||||
delete globalThis.__testCommitShouldThrow;
|
||||
await fs.unlink(tempModulePath).catch(() => {});
|
||||
}
|
||||
|
||||
console.log("index-esm-entry-smoke tests passed");
|
||||
53
tests/native-persist-delta-failopen.mjs
Normal file
53
tests/native-persist-delta-failopen.mjs
Normal file
@@ -0,0 +1,53 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
function moduleUrl(tag) {
|
||||
return `../vendor/wasm/stbme_core.js?test=${Date.now()}-${tag}`;
|
||||
}
|
||||
|
||||
globalThis.__stBmeDisableWasmPackArtifacts = true;
|
||||
delete globalThis.__stBmeLoadRustWasmLayout;
|
||||
|
||||
const firstLoad = await import(moduleUrl("native-persist-first"));
|
||||
let firstError = "";
|
||||
try {
|
||||
await firstLoad.installNativePersistDeltaHook();
|
||||
} catch (error) {
|
||||
firstError = error?.message || String(error);
|
||||
}
|
||||
|
||||
assert.match(
|
||||
firstError,
|
||||
/native module unavailable|native persist delta builder unavailable|global-loader|Rust\/WASM artifact is not initialized/i,
|
||||
);
|
||||
|
||||
globalThis.__stBmeLoadRustWasmLayout = async () => ({
|
||||
solve_layout() {
|
||||
return {
|
||||
ok: true,
|
||||
positions: [],
|
||||
diagnostics: {
|
||||
solver: "mock-rust-wasm",
|
||||
},
|
||||
};
|
||||
},
|
||||
build_persist_delta() {
|
||||
return {
|
||||
upsertNodes: [],
|
||||
upsertEdges: [],
|
||||
deleteNodeIds: [],
|
||||
deleteEdgeIds: [],
|
||||
tombstones: [],
|
||||
runtimeMetaPatch: {},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const retryStatus = await firstLoad.installNativePersistDeltaHook();
|
||||
assert.equal(retryStatus.loaded, true);
|
||||
assert.equal(typeof globalThis.__stBmeNativeBuildPersistDelta, "function");
|
||||
|
||||
delete globalThis.__stBmeNativeBuildPersistDelta;
|
||||
delete globalThis.__stBmeLoadRustWasmLayout;
|
||||
delete globalThis.__stBmeDisableWasmPackArtifacts;
|
||||
|
||||
console.log("native-persist-delta-failopen tests passed");
|
||||
72
tests/opfs-meta-fast-path.mjs
Normal file
72
tests/opfs-meta-fast-path.mjs
Normal file
@@ -0,0 +1,72 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
|
||||
OpfsGraphStore,
|
||||
} from "../sync/bme-opfs-store.js";
|
||||
import { createMemoryOpfsRoot } from "./helpers/memory-opfs.mjs";
|
||||
|
||||
const rootDirectory = createMemoryOpfsRoot();
|
||||
const store = new OpfsGraphStore("chat-opfs-meta-fast-path", {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW,
|
||||
});
|
||||
|
||||
await store.open();
|
||||
await store.importSnapshot(
|
||||
{
|
||||
meta: {
|
||||
revision: 3,
|
||||
lastBackupFilename: "before.json",
|
||||
lastSyncUploadedAt: 10,
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: 2,
|
||||
extractionCount: 1,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "event",
|
||||
fields: { title: "A" },
|
||||
archived: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
},
|
||||
);
|
||||
|
||||
const originalLoadSnapshot = store._loadSnapshot.bind(store);
|
||||
let loadSnapshotCalls = 0;
|
||||
store._loadSnapshot = async (...args) => {
|
||||
loadSnapshotCalls += 1;
|
||||
return await originalLoadSnapshot(...args);
|
||||
};
|
||||
|
||||
assert.equal(await store.getMeta("lastBackupFilename", ""), "before.json");
|
||||
assert.equal(await store.getRevision(), 3);
|
||||
await store.patchMeta({
|
||||
lastBackupFilename: "after.json",
|
||||
lastProcessedFloor: 9,
|
||||
extractionCount: 4,
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
loadSnapshotCalls,
|
||||
0,
|
||||
"manifest-only meta fast path should not load full snapshot",
|
||||
);
|
||||
|
||||
const snapshot = await originalLoadSnapshot();
|
||||
assert.equal(snapshot.meta.lastBackupFilename, "after.json");
|
||||
assert.equal(snapshot.state.lastProcessedFloor, 9);
|
||||
assert.equal(snapshot.state.extractionCount, 4);
|
||||
assert.equal(snapshot.nodes.length, 1);
|
||||
|
||||
console.log("opfs-meta-fast-path tests passed");
|
||||
133
tests/opfs-write-serialization.mjs
Normal file
133
tests/opfs-write-serialization.mjs
Normal file
@@ -0,0 +1,133 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
OpfsGraphStore,
|
||||
} from "../sync/bme-opfs-store.js";
|
||||
import { createMemoryOpfsRoot } from "./helpers/memory-opfs.mjs";
|
||||
|
||||
async function testCommitDeltaAndPatchMetaSerialize() {
|
||||
const rootDirectory = createMemoryOpfsRoot({
|
||||
writeDelayMs: 5,
|
||||
});
|
||||
const store = new OpfsGraphStore("chat-opfs-serialize-meta", {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
|
||||
await store.importSnapshot(
|
||||
{
|
||||
meta: {
|
||||
revision: 1,
|
||||
lastBackupFilename: "",
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: 0,
|
||||
extractionCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
},
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
store.commitDelta(
|
||||
{
|
||||
upsertNodes: [
|
||||
{
|
||||
id: "node-1",
|
||||
type: "event",
|
||||
fields: {
|
||||
title: "serialized",
|
||||
},
|
||||
archived: false,
|
||||
updatedAt: 100,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
reason: "serialized-node",
|
||||
},
|
||||
),
|
||||
store.patchMeta({
|
||||
lastBackupFilename: "backup-a.json",
|
||||
lastProcessedFloor: 7,
|
||||
extractionCount: 3,
|
||||
}),
|
||||
]);
|
||||
|
||||
const snapshot = await store.exportSnapshot();
|
||||
assert.equal(snapshot.nodes.length, 1);
|
||||
assert.equal(snapshot.nodes[0]?.id, "node-1");
|
||||
assert.equal(snapshot.meta.lastBackupFilename, "backup-a.json");
|
||||
assert.equal(snapshot.state.lastProcessedFloor, 7);
|
||||
assert.equal(snapshot.state.extractionCount, 3);
|
||||
}
|
||||
|
||||
async function testImportSnapshotAndClearAllSerialize() {
|
||||
const rootDirectory = createMemoryOpfsRoot({
|
||||
writeDelayMs: 5,
|
||||
});
|
||||
const store = new OpfsGraphStore("chat-opfs-serialize-clear", {
|
||||
rootDirectoryFactory: async () => rootDirectory,
|
||||
storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
|
||||
});
|
||||
await store.open();
|
||||
|
||||
await store.importSnapshot(
|
||||
{
|
||||
meta: { revision: 2 },
|
||||
state: { lastProcessedFloor: 5, extractionCount: 2 },
|
||||
nodes: [
|
||||
{
|
||||
id: "seed-node",
|
||||
type: "event",
|
||||
fields: { title: "seed" },
|
||||
archived: false,
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{ mode: "replace", preserveRevision: true },
|
||||
);
|
||||
|
||||
await Promise.all([
|
||||
store.clearAll(),
|
||||
store.importSnapshot(
|
||||
{
|
||||
meta: { revision: 4 },
|
||||
state: { lastProcessedFloor: 9, extractionCount: 4 },
|
||||
nodes: [
|
||||
{
|
||||
id: "after-clear-node",
|
||||
type: "fact",
|
||||
fields: { title: "after-clear" },
|
||||
archived: false,
|
||||
updatedAt: 2,
|
||||
},
|
||||
],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{ mode: "replace", preserveRevision: true },
|
||||
),
|
||||
]);
|
||||
|
||||
const snapshot = await store.exportSnapshot();
|
||||
assert.equal(snapshot.nodes.length, 1);
|
||||
assert.equal(snapshot.nodes[0]?.id, "after-clear-node");
|
||||
assert.equal(snapshot.state.lastProcessedFloor, 9);
|
||||
assert.equal(snapshot.state.extractionCount, 4);
|
||||
}
|
||||
|
||||
await testCommitDeltaAndPatchMetaSerialize();
|
||||
await testImportSnapshotAndClearAllSerialize();
|
||||
console.log("opfs-write-serialization tests passed");
|
||||
66
ui/panel.js
66
ui/panel.js
@@ -2032,20 +2032,58 @@ function _refreshTaskPersistence() {
|
||||
const STORAGE_TIER_LABELS = {
|
||||
none: "无",
|
||||
metadata: "元数据",
|
||||
"metadata-full": "完整 metadata",
|
||||
indexeddb: "IndexedDB",
|
||||
chat: "聊天存档",
|
||||
opfs: "OPFS",
|
||||
"chat-state": "聊天侧车",
|
||||
"luker-chat-state": "Luker 侧车主存储",
|
||||
shadow: "影子快照",
|
||||
};
|
||||
const HOST_PROFILE_LABELS = {
|
||||
"generic-st": "通用 ST",
|
||||
luker: "Luker",
|
||||
};
|
||||
const CACHE_MIRROR_LABELS = {
|
||||
idle: "空闲",
|
||||
none: "无",
|
||||
queued: "排队中",
|
||||
saved: "已更新",
|
||||
error: "失败",
|
||||
};
|
||||
|
||||
const loadStateLabel = LOAD_STATE_LABELS[ps.loadState] || ps.loadState || "未知";
|
||||
const storageTierLabel = STORAGE_TIER_LABELS[ps.acceptedStorageTier || ps.storageTier] || ps.acceptedStorageTier || ps.storageTier || "—";
|
||||
const acceptedTierLabel =
|
||||
STORAGE_TIER_LABELS[ps.acceptedStorageTier || ps.storageTier] ||
|
||||
ps.acceptedStorageTier ||
|
||||
ps.storageTier ||
|
||||
"—";
|
||||
const primaryTierLabel =
|
||||
STORAGE_TIER_LABELS[ps.primaryStorageTier] || ps.primaryStorageTier || "—";
|
||||
const cacheTierLabel =
|
||||
STORAGE_TIER_LABELS[ps.cacheStorageTier] || ps.cacheStorageTier || "—";
|
||||
const hostProfileLabel =
|
||||
HOST_PROFILE_LABELS[ps.hostProfile] || ps.hostProfile || "未知";
|
||||
const opfsLock = ps.opfsWriteLockState || null;
|
||||
const opfsLockLabel = opfsLock
|
||||
? opfsLock.active
|
||||
? `活跃中 · queue ${Number(opfsLock.queueDepth || 0)}`
|
||||
: `空闲 · queue ${Number(opfsLock.queueDepth || 0)}`
|
||||
: "—";
|
||||
|
||||
const kvs = [
|
||||
["加载状态", loadStateLabel],
|
||||
["存储层级", storageTierLabel],
|
||||
["宿主档案", hostProfileLabel],
|
||||
["主 durable", primaryTierLabel],
|
||||
["当前 accepted", acceptedTierLabel],
|
||||
["accepted by", ps.acceptedBy || "—"],
|
||||
["本地缓存", cacheTierLabel],
|
||||
["缓存镜像", CACHE_MIRROR_LABELS[ps.cacheMirrorState] || ps.cacheMirrorState || "—"],
|
||||
["版本号", ps.revision ?? "—"],
|
||||
["提交标记", ps.commitMarker ? "存在" : "无"],
|
||||
["提交标记", ps.commitMarker ? "存在(诊断锚点)" : "无"],
|
||||
["诊断层", STORAGE_TIER_LABELS[ps.persistDiagnosticTier] || ps.persistDiagnosticTier || "无"],
|
||||
["阻塞原因", ps.blockedReason || ps.reason || "—"],
|
||||
["影子快照", ps.shadowSnapshotUsed ? "已使用" : "未使用"],
|
||||
["OPFS 写锁", opfsLockLabel],
|
||||
];
|
||||
|
||||
const kvHtml = kvs.map(([k, v]) => `<div class="bme-persist-kv__row"><span>${_escHtml(k)}</span><strong>${_escHtml(String(v))}</strong></div>`).join("");
|
||||
@@ -2061,11 +2099,18 @@ function _refreshTaskPersistence() {
|
||||
|
||||
const guidePairs = [
|
||||
["加载状态", "记忆图谱在当前聊天中的加载进度。\"已加载\" 表示正常运行。"],
|
||||
["存储层级", "当前持久化使用的最高存储介质。IndexedDB 最快,聊天存档最稳。"],
|
||||
["宿主档案", "当前运行环境。Luker 会把聊天侧车当主 durable 存储,其它宿主仍以本地存储为主。"],
|
||||
["主 durable", "当前宿主下真正负责 accepted 的主存储层。"],
|
||||
["当前 accepted", "最近一次已确认持久化最终落在哪一层。"],
|
||||
["accepted by", "本批最近一次 accepted 是由哪一层确认的。"],
|
||||
["本地缓存", "主存储之外的本地缓存层。Luker 下这里通常是 IndexedDB 或 OPFS。"],
|
||||
["缓存镜像", "本地缓存 mirror 的当前状态。失败不会自动等价为主持久化失败。"],
|
||||
["版本号", "图谱修订号,每次写入操作自增。用于检测并发冲突。"],
|
||||
["提交标记", "聊天元数据中的标记,指示是否有更高版本存在于本地 IndexedDB。"],
|
||||
["提交标记", "聊天元数据中的诊断锚点,只用于对账与修复建议,不再单独代表 accepted。"],
|
||||
["诊断层", "最近一次仅作诊断/恢复用途的层级,例如影子快照或完整 metadata。"],
|
||||
["阻塞原因", "如果加载被阻塞,这里显示具体原因。\"—\" 表示未阻塞。"],
|
||||
["影子快照", "是否在启动时使用了上次会话留下的影子快照来加速加载。"],
|
||||
["OPFS 写锁", "OPFS 本地存储的串行写状态。活跃表示当前有写任务排队或执行中。"],
|
||||
["图谱节点 / 边", "当前内存中图谱的节点和边数量。"],
|
||||
["批次日志", "尚未合并到主快照的增量操作日志条目数。"],
|
||||
["运行版本", "运行时图谱的内部版本号,和版本号联动。"],
|
||||
@@ -11227,10 +11272,17 @@ function _getGraphPersistenceSnapshot() {
|
||||
shadowSnapshotUsed: false,
|
||||
pendingPersist: false,
|
||||
lastAcceptedRevision: 0,
|
||||
hostProfile: "generic-st",
|
||||
primaryStorageTier: "indexeddb",
|
||||
cacheStorageTier: "none",
|
||||
cacheMirrorState: "idle",
|
||||
acceptedBy: "none",
|
||||
persistDiagnosticTier: "none",
|
||||
persistMismatchReason: "",
|
||||
commitMarker: null,
|
||||
chatId: "",
|
||||
storageMode: "indexeddb",
|
||||
opfsWriteLockState: null,
|
||||
dbReady: false,
|
||||
syncState: "idle",
|
||||
syncDirty: false,
|
||||
@@ -11408,7 +11460,7 @@ function _getGraphLoadLabel(loadState = "") {
|
||||
case "empty-confirmed":
|
||||
return "当前聊天还没有图谱";
|
||||
case "blocked":
|
||||
return "当前聊天图谱未能完成 IndexedDB 确认,请稍后重试";
|
||||
return "当前聊天图谱未能完成正式持久化确认,请稍后重试";
|
||||
case "loaded":
|
||||
return "聊天图谱已加载";
|
||||
case "no-chat":
|
||||
|
||||
@@ -51,6 +51,12 @@ export function createGraphPersistenceState() {
|
||||
pendingPersist: false,
|
||||
lastAcceptedRevision: 0,
|
||||
acceptedStorageTier: "none",
|
||||
hostProfile: "generic-st",
|
||||
primaryStorageTier: "indexeddb",
|
||||
cacheStorageTier: "none",
|
||||
cacheMirrorState: "idle",
|
||||
persistDiagnosticTier: "none",
|
||||
acceptedBy: "none",
|
||||
lastRecoverableStorageTier: "none",
|
||||
persistMismatchReason: "",
|
||||
commitMarker: null,
|
||||
@@ -63,6 +69,12 @@ export function createGraphPersistenceState() {
|
||||
},
|
||||
storagePrimary: "indexeddb",
|
||||
storageMode: "indexeddb",
|
||||
opfsWriteLockState: {
|
||||
active: false,
|
||||
queueDepth: 0,
|
||||
lastReason: "",
|
||||
updatedAt: 0,
|
||||
},
|
||||
dbReady: false,
|
||||
indexedDbRevision: 0,
|
||||
indexedDbLastError: "",
|
||||
|
||||
29
vendor/wasm/stbme_core.js
vendored
29
vendor/wasm/stbme_core.js
vendored
@@ -3,6 +3,21 @@ let triedLoad = false;
|
||||
let loadError = null;
|
||||
let moduleSource = "none";
|
||||
|
||||
function shouldRetryNativeLoad() {
|
||||
return (
|
||||
!cachedNativeModule &&
|
||||
triedLoad &&
|
||||
typeof globalThis.__stBmeLoadRustWasmLayout === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export function resetNativeModuleStatus() {
|
||||
cachedNativeModule = null;
|
||||
triedLoad = false;
|
||||
loadError = null;
|
||||
moduleSource = "none";
|
||||
}
|
||||
|
||||
async function resolveWasmModuleInput(wasmUrl) {
|
||||
if (
|
||||
wasmUrl &&
|
||||
@@ -69,12 +84,18 @@ async function loadFromWasmPackArtifacts() {
|
||||
};
|
||||
}
|
||||
|
||||
async function loadNativeModule() {
|
||||
async function loadNativeModule(options = {}) {
|
||||
if (cachedNativeModule) return cachedNativeModule;
|
||||
if (triedLoad) {
|
||||
if (triedLoad && !(options?.forceRetry === true || shouldRetryNativeLoad())) {
|
||||
throw loadError || new Error("stbme_core native module unavailable");
|
||||
}
|
||||
|
||||
if (triedLoad && (options?.forceRetry === true || shouldRetryNativeLoad())) {
|
||||
triedLoad = false;
|
||||
loadError = null;
|
||||
moduleSource = "none";
|
||||
}
|
||||
|
||||
triedLoad = true;
|
||||
|
||||
let wasmPackError = null;
|
||||
@@ -148,7 +169,9 @@ export async function solveLayout(payload) {
|
||||
}
|
||||
|
||||
export async function installNativePersistDeltaHook() {
|
||||
const module = await loadNativeModule();
|
||||
const module = await loadNativeModule({
|
||||
forceRetry: shouldRetryNativeLoad(),
|
||||
});
|
||||
if (
|
||||
!module ||
|
||||
(typeof module.build_persist_delta_compact_hash !== "function" &&
|
||||
|
||||
Reference in New Issue
Block a user