From 4fd4786983055e7cbad5966ec1984fec38ab73a9 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Tue, 21 Apr 2026 16:56:17 +0800
Subject: [PATCH 01/74] perf: reduce graph load memory and clone overhead
---
index.js | 87 ++++++++++-------------
maintenance/extraction-controller.js | 11 +--
sync/bme-db.js | 102 ++++++++++++++++++---------
sync/bme-opfs-store.js | 68 +++++++++++++-----
tests/indexeddb-persistence.mjs | 69 ++++++++++++++++++
tests/opfs-persistence.mjs | 6 ++
6 files changed, 238 insertions(+), 105 deletions(-)
diff --git a/index.js b/index.js
index 684b59c..c403dd0 100644
--- a/index.js
+++ b/index.js
@@ -5423,11 +5423,8 @@ function applyShadowSnapshotToRuntime(
let shadowGraph = null;
try {
- shadowGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- deserializeGraph(shadowSnapshot.serializedGraph),
- normalizedChatId,
- ),
+ shadowGraph = normalizeGraphRuntimeState(
+ deserializeGraph(shadowSnapshot.serializedGraph),
normalizedChatId,
);
} catch (error) {
@@ -5970,6 +5967,12 @@ function isIndexedDbSnapshotMeaningful(snapshot = null) {
if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0) return true;
if (Array.isArray(snapshot.edges) && snapshot.edges.length > 0) return true;
+ if (
+ snapshot.__stBmeTombstonesOmitted === true &&
+ Number(snapshot?.meta?.tombstoneCount || 0) > 0
+ ) {
+ return true;
+ }
if (Array.isArray(snapshot.tombstones) && snapshot.tombstones.length > 0)
return true;
@@ -6017,6 +6020,7 @@ function isIndexedDbSnapshotMeaningful(snapshot = null) {
function cacheIndexedDbSnapshot(chatId, snapshot = null) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return;
+ if (snapshot.__stBmeTombstonesOmitted === true) return;
const snapshotStore = resolveSnapshotGraphStorePresentation(snapshot);
bmeIndexedDbSnapshotCacheByChatId.set(normalizedChatId, {
chatId: normalizedChatId,
@@ -6311,10 +6315,7 @@ async function readLocalCacheSnapshotForChat(chatId, source = "luker-sidecar-loa
const manager = ensureBmeChatManager();
if (!manager) return null;
const db = await manager.getCurrentDb(normalizedChatId);
- const snapshot = await db.exportSnapshot();
- if (snapshot) {
- cacheIndexedDbSnapshot(normalizedChatId, snapshot);
- }
+ const snapshot = await db.exportSnapshot({ includeTombstones: false });
return snapshot;
} catch (error) {
console.warn("[ST-BME] 读取 Luker 本地缓存快照失败:", source, error);
@@ -6394,11 +6395,8 @@ function buildSnapshotFromLukerSidecarState(
let snapshot = null;
if (sidecar?.checkpoint?.serializedGraph) {
try {
- const checkpointGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- deserializeGraph(sidecar.checkpoint.serializedGraph),
- normalizedChatId,
- ),
+ const checkpointGraph = normalizeGraphRuntimeState(
+ deserializeGraph(sidecar.checkpoint.serializedGraph),
normalizedChatId,
);
snapshot = buildSnapshotFromGraph(checkpointGraph, {
@@ -6437,8 +6435,8 @@ function buildSnapshotFromLukerSidecarState(
headRevision: Number(normalizedManifest.headRevision || 0),
};
} else {
- const emptyGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(createEmptyGraph(), normalizedChatId),
+ const emptyGraph = normalizeGraphRuntimeState(
+ createEmptyGraph(),
normalizedChatId,
);
snapshot = buildSnapshotFromGraph(emptyGraph, {
@@ -7659,11 +7657,8 @@ async function loadGraphFromChatState(
let chatStateGraph = null;
try {
- chatStateGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- deserializeGraph(payload.serializedGraph),
- normalizedChatId,
- ),
+ chatStateGraph = normalizeGraphRuntimeState(
+ deserializeGraph(payload.serializedGraph),
normalizedChatId,
);
} catch (error) {
@@ -7953,13 +7948,9 @@ async function readPersistedGraphForChatStateTarget(
});
if (sidecarResult?.ok && sidecarResult?.snapshot) {
try {
- return cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- buildGraphFromSnapshot(sidecarResult.snapshot),
- targetChatId,
- ),
- targetChatId,
- );
+ return buildGraphFromSnapshot(sidecarResult.snapshot, {
+ chatId: targetChatId,
+ });
} catch (error) {
console.warn("[ST-BME] 读取 Luker branch source snapshot 失败:", error);
}
@@ -7971,11 +7962,8 @@ async function readPersistedGraphForChatStateTarget(
});
if (legacySnapshot?.serializedGraph) {
try {
- return cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- deserializeGraph(legacySnapshot.serializedGraph),
- targetChatId,
- ),
+ return normalizeGraphRuntimeState(
+ deserializeGraph(legacySnapshot.serializedGraph),
targetChatId,
);
} catch (error) {
@@ -8175,10 +8163,13 @@ function readLegacyGraphFromChatMetadata(chatId, context = getContext()) {
typeof legacyGraph === "string"
? deserializeGraph(legacyGraph)
: legacyGraph;
- return cloneGraphForPersistence(
- normalizeGraphRuntimeState(hydratedLegacyGraph, normalizedChatId),
+ const normalizedLegacyGraph = normalizeGraphRuntimeState(
+ hydratedLegacyGraph,
normalizedChatId,
);
+ return typeof legacyGraph === "string"
+ ? normalizedLegacyGraph
+ : cloneGraphForPersistence(normalizedLegacyGraph, normalizedChatId);
} catch (error) {
console.warn("[ST-BME] 读取 legacy chat_metadata 图谱失败:", error);
return null;
@@ -9218,10 +9209,7 @@ function applyIndexedDbSnapshotToRuntime(
attemptIndex,
};
}
- currentGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId),
- normalizedChatId,
- );
+ currentGraph = graphFromSnapshot;
stampGraphPersistenceMeta(currentGraph, {
revision,
reason: `${reasonPrefix}:${String(source || reasonPrefix)}`,
@@ -9485,7 +9473,7 @@ async function loadGraphFromIndexedDb(
identityRecoveryResult?.snapshot ||
localStoreMigrationResult?.snapshot ||
migrationResult?.snapshot ||
- (await db.exportSnapshot());
+ (await db.exportSnapshot({ includeTombstones: false }));
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(
resolveCurrentChatIdentity(getContext()),
);
@@ -10802,11 +10790,8 @@ function resolvePendingPersistGraphSource(chatId = "") {
shadowSnapshot.serializedGraph
) {
try {
- const shadowGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(
- deserializeGraph(shadowSnapshot.serializedGraph),
- normalizedChatId,
- ),
+ const shadowGraph = normalizeGraphRuntimeState(
+ deserializeGraph(shadowSnapshot.serializedGraph),
normalizedChatId,
);
return {
@@ -11376,7 +11361,9 @@ async function persistExtractionBatchResult({
const context = getContext();
const persistGraph =
graphSnapshot && typeof graphSnapshot === "object"
- ? cloneGraphSnapshot(graphSnapshot)
+ ? graphSnapshot === currentGraph
+ ? cloneGraphSnapshot(graphSnapshot)
+ : graphSnapshot
: currentGraph;
if (!context || !persistGraph) {
return buildGraphPersistResult({
@@ -12847,10 +12834,14 @@ function loadGraphFromChat(options = {}) {
: undefined;
if (savedData != null && savedData !== "") {
try {
- const officialGraph = cloneGraphForPersistence(
- normalizeGraphRuntimeState(deserializeGraph(savedData), chatId),
+ const hydratedOfficialGraph = normalizeGraphRuntimeState(
+ deserializeGraph(savedData),
chatId,
);
+ const officialGraph =
+ typeof savedData === "string"
+ ? hydratedOfficialGraph
+ : cloneGraphForPersistence(hydratedOfficialGraph, chatId);
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
officialGraph,
shadowSnapshot,
diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js
index 8320e71..38fa94e 100644
--- a/maintenance/extraction-controller.js
+++ b/maintenance/extraction-controller.js
@@ -297,7 +297,7 @@ function buildCommittedBatchPersistSnapshot(
: null,
rangeEnd,
];
- const afterSnapshot = runtime.cloneGraphSnapshot(graph);
+ const afterSnapshot = graph;
const effectiveArtifacts = Array.isArray(postProcessArtifacts)
? [...postProcessArtifacts]
: [];
@@ -357,17 +357,12 @@ function buildCommittedBatchPersistSnapshot(
persistGraphSnapshot: committedGraphSnapshot,
committedBatchJournalEntry,
afterSnapshot,
- committedAfterSnapshot: runtime.cloneGraphSnapshot(committedGraphSnapshot),
+ committedAfterSnapshot: committedGraphSnapshot,
postProcessArtifacts: effectiveArtifacts,
};
}
function isPersistenceRevisionAccepted(runtime, persistence = null) {
- if (!persistence || persistence.accepted === true) return true;
- const graphPersistenceState = runtime?.getGraphPersistenceState?.() || {};
- if (graphPersistenceState.pendingPersist === true) {
- return false;
- }
const persistenceRevision = Number(persistence?.revision || 0);
if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) {
return false;
@@ -645,7 +640,7 @@ export async function executeExtractionBatchController(
processedRange: [startIdx, endIdx],
postProcessArtifacts: runtime.computePostProcessArtifacts(
beforeSnapshot,
- runtime.cloneGraphSnapshot(runtime.getCurrentGraph()),
+ runtime.getCurrentGraph(),
effects?.postProcessArtifacts || [],
),
vectorHashesInserted: effects?.vectorHashesInserted || [],
diff --git a/sync/bme-db.js b/sync/bme-db.js
index 2f54e3a..b4a7337 100644
--- a/sync/bme-db.js
+++ b/sync/bme-db.js
@@ -2013,6 +2013,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
normalizeChatId(options.chatId) ||
normalizeChatId(snapshotMeta?.chatId) ||
normalizeChatId(snapshotState?.chatId);
+ const snapshotHistoryState = toPlainData(
+ snapshotMeta?.[BME_RUNTIME_HISTORY_META_KEY],
+ {},
+ );
+ const snapshotVectorState = toPlainData(
+ snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY],
+ {},
+ );
const runtimeGraph = createEmptyGraph();
runtimeGraph.version = Number.isFinite(
@@ -2020,21 +2028,17 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
)
? Number(snapshotMeta[BME_RUNTIME_GRAPH_VERSION_META_KEY])
: runtimeGraph.version;
- runtimeGraph.nodes = toArray(snapshotView.nodes).map((node) => ({
- ...(node || {}),
- }));
- runtimeGraph.edges = toArray(snapshotView.edges).map((edge) => ({
- ...(edge || {}),
- }));
+ runtimeGraph.nodes = toArray(toPlainData(snapshotView.nodes, []));
+ runtimeGraph.edges = toArray(toPlainData(snapshotView.edges, []));
runtimeGraph.batchJournal = toArray(
- snapshotMeta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY],
+ toPlainData(snapshotMeta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY], []),
);
runtimeGraph.lastRecallResult = toPlainData(
snapshotMeta?.[BME_RUNTIME_LAST_RECALL_META_KEY],
null,
);
runtimeGraph.maintenanceJournal = toArray(
- snapshotMeta?.[BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY],
+ toPlainData(snapshotMeta?.[BME_RUNTIME_MAINTENANCE_JOURNAL_META_KEY], []),
);
runtimeGraph.knowledgeState = toPlainData(
snapshotMeta?.[BME_RUNTIME_KNOWLEDGE_STATE_META_KEY],
@@ -2073,22 +2077,21 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
runtimeGraph.historyState = {
...(runtimeGraph.historyState || {}),
- ...(snapshotMeta?.[BME_RUNTIME_HISTORY_META_KEY] || {}),
+ ...snapshotHistoryState,
lastProcessedAssistantFloor: Number.isFinite(
Number(snapshotState?.lastProcessedFloor),
)
? Number(snapshotState.lastProcessedFloor)
: Number(
- snapshotMeta?.[BME_RUNTIME_HISTORY_META_KEY]
- ?.lastProcessedAssistantFloor ?? META_DEFAULT_LAST_PROCESSED_FLOOR,
+ snapshotHistoryState?.lastProcessedAssistantFloor ??
+ META_DEFAULT_LAST_PROCESSED_FLOOR,
),
extractionCount: Number.isFinite(
Number(snapshotState?.extractionCount),
)
? Number(snapshotState.extractionCount)
: Number(
- snapshotMeta?.[BME_RUNTIME_HISTORY_META_KEY]
- ?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT,
+ snapshotHistoryState?.extractionCount ?? META_DEFAULT_EXTRACTION_COUNT,
),
};
if (
@@ -2146,10 +2149,10 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
}
runtimeGraph.vectorIndexState = {
...(runtimeGraph.vectorIndexState || {}),
- ...(snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY] || {}),
+ ...snapshotVectorState,
collectionId: buildVectorCollectionId(
chatId ||
- snapshotMeta?.[BME_RUNTIME_HISTORY_META_KEY]?.chatId ||
+ snapshotHistoryState?.chatId ||
runtimeGraph.historyState?.chatId ||
"",
),
@@ -3032,23 +3035,47 @@ export class BmeDatabase {
};
}
- async exportSnapshot() {
+ async exportSnapshot(options = {}) {
const db = await this.open();
- const [nodes, edges, tombstones, metaRows] = await db.transaction(
- "r",
- db.table("nodes"),
- db.table("edges"),
- db.table("tombstones"),
- db.table("meta"),
- async () =>
- await Promise.all([
- db.table("nodes").toArray(),
- db.table("edges").toArray(),
- db.table("tombstones").toArray(),
- db.table("meta").toArray(),
- ]),
- );
+ const includeTombstones =
+ options && typeof options === "object"
+ ? options.includeTombstones !== false
+ : options !== false;
+ let nodes = [];
+ let edges = [];
+ let tombstones = [];
+ let metaRows = [];
+
+ if (includeTombstones) {
+ [nodes, edges, tombstones, metaRows] = await db.transaction(
+ "r",
+ db.table("nodes"),
+ db.table("edges"),
+ db.table("tombstones"),
+ db.table("meta"),
+ async () =>
+ await Promise.all([
+ db.table("nodes").toArray(),
+ db.table("edges").toArray(),
+ db.table("tombstones").toArray(),
+ db.table("meta").toArray(),
+ ]),
+ );
+ } else {
+ [nodes, edges, metaRows] = await db.transaction(
+ "r",
+ db.table("nodes"),
+ db.table("edges"),
+ db.table("meta"),
+ async () =>
+ await Promise.all([
+ db.table("nodes").toArray(),
+ db.table("edges").toArray(),
+ db.table("meta").toArray(),
+ ]),
+ );
+ }
const metaMap = toMetaMap(metaRows);
const meta = {
@@ -3058,7 +3085,10 @@ export class BmeDatabase {
revision: normalizeRevision(metaMap?.revision),
nodeCount: nodes.length,
edgeCount: edges.length,
- tombstoneCount: tombstones.length,
+ tombstoneCount: normalizeNonNegativeInteger(
+ metaMap?.tombstoneCount,
+ tombstones.length,
+ ),
};
const state = {
@@ -3070,13 +3100,19 @@ export class BmeDatabase {
: META_DEFAULT_EXTRACTION_COUNT,
};
- return {
+ const snapshot = {
meta,
nodes,
edges,
- tombstones,
+ tombstones: includeTombstones ? tombstones : [],
state,
};
+
+ if (!includeTombstones) {
+ snapshot.__stBmeTombstonesOmitted = true;
+ }
+
+ return snapshot;
}
async importSnapshot(snapshot, options = {}) {
diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js
index 1ce5ae0..68eac7c 100644
--- a/sync/bme-opfs-store.js
+++ b/sync/bme-opfs-store.js
@@ -1345,15 +1345,23 @@ class LegacyOpfsGraphStore {
};
}
- async exportSnapshot() {
- const snapshot = await this._loadSnapshot();
- return {
+ async exportSnapshot(options = {}) {
+ const includeTombstones =
+ options && typeof options === "object"
+ ? options.includeTombstones !== false
+ : options !== false;
+ const snapshot = await this._loadSnapshot({ includeTombstones });
+ const exported = {
meta: toPlainData(snapshot.meta, {}),
nodes: toPlainData(snapshot.nodes, []),
edges: toPlainData(snapshot.edges, []),
- tombstones: toPlainData(snapshot.tombstones, []),
+ tombstones: includeTombstones ? toPlainData(snapshot.tombstones, []) : [],
state: toPlainData(snapshot.state, {}),
};
+ if (!includeTombstones) {
+ exported.__stBmeTombstonesOmitted = true;
+ }
+ return exported;
}
async importSnapshot(snapshot, options = {}) {
@@ -2643,15 +2651,23 @@ export class OpfsGraphStore {
};
}
- async exportSnapshot() {
- const snapshot = await this._loadSnapshot();
- return {
+ async exportSnapshot(options = {}) {
+ const includeTombstones =
+ options && typeof options === "object"
+ ? options.includeTombstones !== false
+ : options !== false;
+ const snapshot = await this._loadSnapshot({ includeTombstones });
+ const exported = {
meta: toPlainData(snapshot.meta, {}),
nodes: toPlainData(snapshot.nodes, []),
edges: toPlainData(snapshot.edges, []),
- tombstones: toPlainData(snapshot.tombstones, []),
+ tombstones: includeTombstones ? toPlainData(snapshot.tombstones, []) : [],
state: toPlainData(snapshot.state, {}),
};
+ if (!includeTombstones) {
+ exported.__stBmeTombstonesOmitted = true;
+ }
+ return exported;
}
async importSnapshot(snapshot, options = {}) {
@@ -3263,7 +3279,7 @@ export class OpfsGraphStore {
return records;
}
- async _loadBaseSnapshotFromV2(manifest = null) {
+ async _loadBaseSnapshotFromV2(manifest = null, { includeTombstones = true } = {}) {
const normalizedManifest = manifest || (await this._ensureV2Ready());
const runtimeMeta = await this._readRuntimeMetaEntries();
const nodes = [];
@@ -3275,8 +3291,10 @@ export class OpfsGraphStore {
for (let index = 0; index < OPFS_V2_EDGE_BUCKET_COUNT; index += 1) {
edges.push(...(await this._readShardRecords("edges", index)));
}
- for (let index = 0; index < OPFS_V2_TOMBSTONE_BUCKET_COUNT; index += 1) {
- tombstones.push(...(await this._readShardRecords("tombstones", index)));
+ if (includeTombstones) {
+ for (let index = 0; index < OPFS_V2_TOMBSTONE_BUCKET_COUNT; index += 1) {
+ tombstones.push(...(await this._readShardRecords("tombstones", index)));
+ }
}
const meta = {
...createDefaultMetaValues(this.chatId),
@@ -3311,7 +3329,7 @@ export class OpfsGraphStore {
return snapshot;
}
- async _loadSnapshot({ awaitWrites = true } = {}) {
+ async _loadSnapshot({ awaitWrites = true, includeTombstones = true } = {}) {
if (awaitWrites) {
await this._awaitPendingWrites();
}
@@ -3319,10 +3337,24 @@ export class OpfsGraphStore {
const headRevision = normalizeRevision(
manifest?.headRevision || manifest?.meta?.revision,
);
- if (this._snapshotCache && normalizeRevision(this._snapshotCache.meta?.revision) === headRevision) {
- return this._snapshotCache;
+ if (
+ this._snapshotCache &&
+ normalizeRevision(this._snapshotCache.meta?.revision) === headRevision
+ ) {
+ if (includeTombstones) {
+ return this._snapshotCache;
+ }
+ return {
+ meta: this._snapshotCache.meta,
+ state: this._snapshotCache.state,
+ nodes: this._snapshotCache.nodes,
+ edges: this._snapshotCache.edges,
+ tombstones: [],
+ };
}
- const snapshot = await this._loadBaseSnapshotFromV2(manifest);
+ const snapshot = await this._loadBaseSnapshotFromV2(manifest, {
+ includeTombstones,
+ });
const walRecords = await this._readWalRecords(manifest);
for (const walRecord of walRecords) {
const nextSnapshot = applyOpfsV2DeltaToSnapshot(snapshot, walRecord.delta, walRecord.committedAt);
@@ -3355,7 +3387,11 @@ export class OpfsGraphStore {
snapshot.state = normalizeSnapshotState(snapshot);
snapshot.meta.lastProcessedFloor = snapshot.state.lastProcessedFloor;
snapshot.meta.extractionCount = snapshot.state.extractionCount;
- this._snapshotCache = snapshot;
+ if (includeTombstones) {
+ this._snapshotCache = snapshot;
+ return snapshot;
+ }
+ snapshot.tombstones = [];
return snapshot;
}
diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs
index 4945a4d..5d31269 100644
--- a/tests/indexeddb-persistence.mjs
+++ b/tests/indexeddb-persistence.mjs
@@ -2,6 +2,9 @@ import assert from "node:assert/strict";
import {
BME_DB_SCHEMA_VERSION,
+ BME_RUNTIME_BATCH_JOURNAL_META_KEY,
+ BME_RUNTIME_HISTORY_META_KEY,
+ BME_RUNTIME_VECTOR_META_KEY,
BME_TOMBSTONE_RETENTION_MS,
BmeDatabase,
buildBmeDbName,
@@ -20,6 +23,7 @@ const chatIdsForCleanup = new Set([
"chat-manager-a",
"chat-manager-b",
"chat-manager-selector",
+ "chat-export-without-tombstones",
"chat-replace-reset",
]);
@@ -196,6 +200,41 @@ async function testSnapshotExportImport() {
await db.close();
}
+async function testSnapshotExportWithoutTombstones() {
+ const db = new BmeDatabase("chat-export-without-tombstones", {
+ dexieClass: globalThis.Dexie,
+ });
+ await db.open();
+
+ await db.bulkUpsertNodes([
+ {
+ id: "node-light-snapshot",
+ type: "event",
+ sourceFloor: 3,
+ archived: false,
+ updatedAt: Date.now(),
+ },
+ ]);
+ await db.bulkUpsertTombstones([
+ {
+ id: "tomb-light-snapshot",
+ kind: "node",
+ targetId: "node-deleted-light-snapshot",
+ deletedAt: Date.now(),
+ sourceDeviceId: "device-light-snapshot",
+ },
+ ]);
+
+ const exported = await db.exportSnapshot({ includeTombstones: false });
+ assert.equal(exported.__stBmeTombstonesOmitted, true);
+ assert.ok(Array.isArray(exported.nodes));
+ assert.ok(Array.isArray(exported.edges));
+ assert.deepEqual(exported.tombstones, []);
+ assert.equal(exported.meta.tombstoneCount, 1);
+
+ await db.close();
+}
+
async function testReplaceImportResetsStaleMeta() {
const chatId = "chat-replace-reset";
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
@@ -532,6 +571,9 @@ async function testGraphSnapshotConverters() {
id: "node-converter",
type: "event",
sourceFloor: 9,
+ fields: {
+ title: "Converter Node",
+ },
updatedAt: Date.now(),
});
@@ -583,6 +625,32 @@ async function testGraphSnapshotConverters() {
assert.equal(rebuilt.regionState.activeRegion, "camp");
assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1");
assert.equal(rebuilt.summaryState.entries[0].id, "summary-1");
+
+ rebuilt.nodes[0].fields.title = "Mutated Converter Node";
+ rebuilt.historyState.processedMessageHashes[1] = "mutated-hash";
+ rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated";
+ rebuilt.batchJournal[0].processedRange[0] = 99;
+
+ assert.equal(
+ snapshot.nodes[0].fields.title,
+ "Converter Node",
+ "buildGraphFromSnapshot 不应复用 snapshot 节点的嵌套字段引用",
+ );
+ assert.equal(
+ snapshot.meta[BME_RUNTIME_HISTORY_META_KEY].processedMessageHashes[1],
+ "hash-1",
+ "buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用",
+ );
+ assert.equal(
+ snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"],
+ "node-converter",
+ "buildGraphFromSnapshot 不应复用 snapshot vectorState 的嵌套对象引用",
+ );
+ assert.equal(
+ snapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY][0].processedRange[0],
+ 8,
+ "buildGraphFromSnapshot 不应复用 snapshot batchJournal 的嵌套数组引用",
+ );
}
async function main() {
@@ -593,6 +661,7 @@ async function main() {
await testCrudAndMeta();
await testTransactionRollback();
await testSnapshotExportImport();
+ await testSnapshotExportWithoutTombstones();
await testReplaceImportResetsStaleMeta();
await testRevisionMonotonicity();
await testTombstonePrune();
diff --git a/tests/opfs-persistence.mjs b/tests/opfs-persistence.mjs
index d246b28..184bcf9 100644
--- a/tests/opfs-persistence.mjs
+++ b/tests/opfs-persistence.mjs
@@ -311,6 +311,12 @@ async function testImportExportPersistenceAndFileRotation() {
assert.equal(firstExportedSnapshot.state.extractionCount, 2);
assert.equal(firstExportedSnapshot.meta.storagePrimary, "opfs");
assert.equal(firstExportedSnapshot.meta.storageMode, "opfs-primary");
+ const lightweightSnapshot = await store.exportSnapshot({
+ includeTombstones: false,
+ });
+ assert.equal(lightweightSnapshot.__stBmeTombstonesOmitted, true);
+ assert.deepEqual(lightweightSnapshot.tombstones, []);
+ assert.equal(lightweightSnapshot.meta.tombstoneCount, 1);
assert.deepEqual(firstExportedSnapshot.meta[BME_RUNTIME_BATCH_JOURNAL_META_KEY], {
pending: ["job-1"],
});
From a0313a6399ee165236f5e8d4ed265da959c202c6 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 21 Apr 2026 08:58:05 +0000
Subject: [PATCH 02/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 333f09d..ce1eba8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.4.5",
+ "version": "5.4.6",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 6ddf3a73861f9f68c7a72f96922eb26d058a2184 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Tue, 21 Apr 2026 17:44:32 +0800
Subject: [PATCH 03/74] Optimize detached graph save path
---
index.js | 26 +++++++++++++++---
tests/graph-persistence.mjs | 53 +++++++++++++++++++++++++++++++++++++
2 files changed, 76 insertions(+), 3 deletions(-)
diff --git a/index.js b/index.js
index c403dd0..3189343 100644
--- a/index.js
+++ b/index.js
@@ -7425,6 +7425,7 @@ async function persistGraphToHostChatState(
mode = "primary",
persistDelta = null,
chatStateTarget = null,
+ graphDetached = false,
} = {},
) {
if (!context || !graph || !canUseHostGraphChatStatePersistence(context)) {
@@ -7482,7 +7483,10 @@ async function persistGraphToHostChatState(
getChatMetadataIntegrity(context) ||
normalizeChatIdCandidate(resolvedIdentity?.integrity) ||
graphPersistenceState.metadataIntegrity;
- const persistedGraph = cloneGraphForPersistence(graph, chatId);
+ const persistedGraph =
+ graphDetached === true
+ ? normalizeGraphRuntimeState(graph, chatId)
+ : cloneGraphForPersistence(graph, chatId);
stampGraphPersistenceMeta(persistedGraph, {
revision,
reason: `chat-state:${String(reason || "graph-chat-state")}`,
@@ -10534,6 +10538,7 @@ async function persistGraphToConfiguredDurableTier(
lastProcessedAssistantFloor = null,
persistDelta = null,
chatStateTarget = null,
+ graphDetached = false,
} = {},
) {
const preferredLocalStore = getPreferredGraphLocalStorePresentationSync();
@@ -10559,6 +10564,7 @@ async function persistGraphToConfiguredDurableTier(
mode: "primary",
persistDelta,
chatStateTarget,
+ graphDetached,
});
if (chatStateResult?.saved) {
const acceptedRevision = Number(chatStateResult.revision || revision);
@@ -10624,6 +10630,7 @@ async function persistGraphToConfiguredDurableTier(
persistRole: "cache-mirror",
scheduleCloudUpload: false,
persistDelta,
+ graphDetached,
});
}
return buildGraphPersistResult({
@@ -10691,6 +10698,7 @@ async function persistGraphToConfiguredDurableTier(
mode: "primary",
persistDelta,
chatStateTarget,
+ graphDetached,
});
if (chatStateResult?.saved) {
const acceptedRevision = Number(chatStateResult.revision || revision);
@@ -10734,6 +10742,7 @@ async function persistGraphToConfiguredDurableTier(
revision: acceptedRevision,
reason: `${reason}:chat-state-fallback:promote-indexeddb`,
persistDelta,
+ graphDetached,
});
return buildGraphPersistResult({
saved: true,
@@ -11359,6 +11368,10 @@ async function persistExtractionBatchResult({
} = {}) {
ensureCurrentGraphRuntimeState();
const context = getContext();
+ const persistGraphDetached =
+ Boolean(graphSnapshot) &&
+ typeof graphSnapshot === "object" &&
+ graphSnapshot !== currentGraph;
const persistGraph =
graphSnapshot && typeof graphSnapshot === "object"
? graphSnapshot === currentGraph
@@ -11400,6 +11413,7 @@ async function persistExtractionBatchResult({
reason,
lastProcessedAssistantFloor,
persistDelta,
+ graphDetached: persistGraphDetached,
},
);
if (acceptedPersistResult?.accepted) {
@@ -13824,6 +13838,7 @@ function queueGraphPersistToIndexedDb(
persistRole = "primary",
scheduleCloudUpload = undefined,
persistDelta = null,
+ graphDetached = false,
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
@@ -13872,7 +13887,9 @@ function queueGraphPersistToIndexedDb(
};
}
const graphSnapshot = graph
- ? cloneGraphForPersistence(graph, normalizedChatId)
+ ? graphDetached === true
+ ? normalizeGraphRuntimeState(graph, normalizedChatId)
+ : cloneGraphForPersistence(graph, normalizedChatId)
: null;
return await saveGraphToIndexedDb(normalizedChatId, graphSnapshot, {
revision: normalizedRevision,
@@ -13939,7 +13956,8 @@ function saveGraphToChat(options = {}) {
}
const shouldQueueIndexedDbPersist =
- markMutation || !isGraphEffectivelyEmpty(currentGraph);
+ persistenceEnvironment.hostProfile !== "luker" &&
+ (markMutation || !isGraphEffectivelyEmpty(currentGraph));
if (shouldQueueIndexedDbPersist) {
queueGraphPersistToIndexedDb(chatId, currentGraph, {
revision,
@@ -13985,6 +14003,7 @@ function saveGraphToChat(options = {}) {
reason,
lastProcessedAssistantFloor,
chatStateTarget,
+ graphDetached: true,
},
);
if (!persistResult?.accepted) {
@@ -18727,6 +18746,7 @@ async function onRebuildLocalCacheFromLukerSidecar() {
reason: "panel-manual-luker-cache-rebuild",
persistRole: "cache-mirror",
scheduleCloudUpload: false,
+ graphDetached: true,
});
refreshPanelLiveState();
toastr.success("已开始从 Luker 主 sidecar 重建本地缓存");
diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs
index 892a1d6..b8a4499 100644
--- a/tests/graph-persistence.mjs
+++ b/tests/graph-persistence.mjs
@@ -3782,6 +3782,59 @@ result = {
);
}
+{
+ const harness = await createGraphPersistenceHarness({
+ chatId: "chat-luker-queued-save-detached",
+ globalChatId: "chat-luker-queued-save-detached",
+ characterId: "char-luker-queued-save",
+ chatMetadata: {
+ integrity: "meta-luker-queued-save-detached",
+ },
+ });
+ harness.runtimeContext.Luker = {
+ getContext() {
+ return harness.runtimeContext.__chatContext;
+ },
+ };
+ harness.api.setCurrentGraph(
+ stampPersistedGraph(
+ createMeaningfulGraph("chat-luker-queued-save-detached", "luker-detached"),
+ {
+ revision: 6,
+ integrity: "meta-luker-queued-save-detached",
+ chatId: "chat-luker-queued-save-detached",
+ reason: "luker-detached-seed",
+ },
+ ),
+ );
+ harness.api.setGraphPersistenceState({
+ loadState: "loaded",
+ chatId: "chat-luker-queued-save-detached",
+ revision: 6,
+ lastPersistedRevision: 6,
+ writesBlocked: false,
+ });
+
+ const result = harness.api.saveGraphToChat({
+ reason: "luker-detached-save",
+ markMutation: false,
+ });
+
+ assert.equal(result.queued, true);
+ assert.equal(result.storageTier, "luker-chat-state");
+ assert.equal(result.saveMode, "luker-chat-state-queued");
+
+ harness.api.getCurrentGraph().nodes[0].fields.title = "runtime-mutated-after-queued-save";
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ await new Promise((resolve) => setTimeout(resolve, 0));
+
+ assert.equal(
+ harness.api.getIndexedDbSnapshot()?.nodes?.[0]?.fields?.title,
+ "事件-luker-detached",
+ "Luker queued save 的异步本地 mirror 不应被后续 live graph 修改污染",
+ );
+}
+
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-v2-load",
From bfb5c236b6f7d24de91778f3adaa6ad4f90ac8e2 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 21 Apr 2026 09:45:22 +0000
Subject: [PATCH 04/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index ce1eba8..835ddb2 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.4.6",
+ "version": "5.4.7",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 50ab967d7b87ddae18bb97a40d716ef5b9c4a459 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Tue, 21 Apr 2026 18:46:07 +0800
Subject: [PATCH 05/74] Optimize IndexedDB save snapshot reuse
---
index.js | 77 +++++++++++++++++++------------------
tests/graph-persistence.mjs | 50 ++++++++++++++++++++++++
2 files changed, 89 insertions(+), 38 deletions(-)
diff --git a/index.js b/index.js
index 3189343..1cbf5d4 100644
--- a/index.js
+++ b/index.js
@@ -5149,7 +5149,6 @@ function buildRecoveredSnapshotForChatIdentity(
const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId);
const normalizedIntegrity = normalizeChatIdCandidate(integrity);
const normalizedLegacyChatId = normalizeChatIdCandidate(legacyChatId);
- const normalizedGraph = cloneGraphForPersistence(graph, normalizedTargetChatId);
const effectiveRevision = Math.max(
1,
normalizeIndexedDbRevision(
@@ -5157,14 +5156,7 @@ function buildRecoveredSnapshotForChatIdentity(
),
);
- stampGraphPersistenceMeta(normalizedGraph, {
- revision: effectiveRevision,
- reason: source,
- chatId: normalizedTargetChatId,
- integrity: normalizedIntegrity,
- });
-
- return buildSnapshotFromGraph(normalizedGraph, {
+ return buildSnapshotFromGraph(graph, {
chatId: normalizedTargetChatId,
revision: effectiveRevision,
lastModified: Date.now(),
@@ -7845,7 +7837,6 @@ function deriveBranchGraphFromSourceGraph(
normalizeChatIdCandidate(targetChatId) ||
normalizeChatIdCandidate(sourceGraph?.historyState?.chatId);
const branchGraph = cloneGraphForPersistence(sourceGraph, nextChatId);
- normalizeGraphRuntimeState(branchGraph, nextChatId);
const safeCutoff =
Number.isFinite(Number(cutoffFloor)) && Number(cutoffFloor) >= 0
@@ -11269,6 +11260,10 @@ async function retryPendingGraphPersist({
queuedChatId,
);
const pendingPersistGraph = pendingPersistGraphSource?.graph || currentGraph;
+ const pendingPersistGraphDetached =
+ Boolean(pendingPersistGraph) &&
+ typeof pendingPersistGraph === "object" &&
+ pendingPersistGraph !== currentGraph;
const targetRevision = Math.max(
Number(graphPersistenceState.queuedPersistRevision || 0),
Number(graphPersistenceState.revision || 0),
@@ -11286,6 +11281,7 @@ async function retryPendingGraphPersist({
revision: targetRevision,
reason,
lastProcessedAssistantFloor,
+ graphDetached: pendingPersistGraphDetached,
},
);
if (acceptedPersistResult?.accepted) {
@@ -12864,20 +12860,18 @@ function loadGraphFromChat(options = {}) {
1,
getGraphPersistedRevision(officialGraph),
);
+ const officialSnapshot = buildSnapshotFromGraph(officialGraph, {
+ chatId,
+ revision: officialRevision,
+ });
const metadataCommitMismatch = detectIndexedDbSnapshotCommitMarkerMismatch(
- buildSnapshotFromGraph(officialGraph, {
- chatId,
- revision: officialRevision,
- }),
+ officialSnapshot,
commitMarker,
);
const officialRuntimeStaleDecision =
detectStaleIndexedDbSnapshotAgainstRuntime(
chatId,
- buildSnapshotFromGraph(officialGraph, {
- chatId,
- revision: officialRevision,
- }),
+ officialSnapshot,
{
identity: chatIdentity,
},
@@ -13411,28 +13405,35 @@ async function saveGraphToIndexedDb(
requestedRevision,
markSyncDirty: true,
});
+ const committedRevision = normalizeIndexedDbRevision(
+ commitResult?.revision,
+ requestedRevision,
+ );
+ const committedLastModified = Number(commitResult?.lastModified || Date.now());
let scheduleUploadWarning = "";
if (graph) {
- snapshot = buildSnapshotFromGraph(graph, {
- chatId: normalizedChatId,
- revision: normalizeIndexedDbRevision(commitResult?.revision, requestedRevision),
- baseSnapshot: baseSnapshot || undefined,
- lastModified: Number(commitResult?.lastModified || Date.now()),
- meta: {
- storagePrimary: localStore.storagePrimary,
- storageMode: localStore.storageMode,
- lastMutationReason: String(reason || "graph-save"),
- integrity:
- currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
- hostChatId: currentIdentity.hostChatId || "",
- },
- });
- snapshot.meta.revision = normalizeIndexedDbRevision(
- commitResult?.revision,
- requestedRevision,
- );
- snapshot.meta.lastModified = Number(commitResult?.lastModified || Date.now());
+ if (!snapshot) {
+ snapshot = buildSnapshotFromGraph(graph, {
+ chatId: normalizedChatId,
+ revision: committedRevision,
+ baseSnapshot: baseSnapshot || undefined,
+ lastModified: committedLastModified,
+ meta: {
+ storagePrimary: localStore.storagePrimary,
+ storageMode: localStore.storageMode,
+ lastMutationReason: String(reason || "graph-save"),
+ integrity:
+ currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
+ hostChatId: currentIdentity.hostChatId || "",
+ },
+ });
+ }
+ if (!snapshot.meta || typeof snapshot.meta !== "object" || Array.isArray(snapshot.meta)) {
+ snapshot.meta = {};
+ }
+ snapshot.meta.revision = committedRevision;
+ snapshot.meta.lastModified = committedLastModified;
snapshot.meta.lastMutationReason = String(reason || "graph-save");
snapshot.meta.storagePrimary = localStore.storagePrimary;
snapshot.meta.storageMode = localStore.storageMode;
@@ -13441,7 +13442,7 @@ async function saveGraphToIndexedDb(
if (graph === currentGraph) {
stampGraphPersistenceMeta(currentGraph, {
- revision: normalizeIndexedDbRevision(commitResult?.revision, requestedRevision),
+ revision: committedRevision,
reason: String(reason || "graph-save"),
chatId: normalizedChatId,
integrity:
diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs
index b8a4499..62d8f13 100644
--- a/tests/graph-persistence.mjs
+++ b/tests/graph-persistence.mjs
@@ -3189,6 +3189,56 @@ result = {
);
}
+{
+ const harness = await createGraphPersistenceHarness({
+ chatId: "chat-idb-single-snapshot-build",
+ globalChatId: "chat-idb-single-snapshot-build",
+ chatMetadata: {
+ integrity: "meta-idb-single-snapshot-build",
+ },
+ });
+ harness.api.setCurrentGraph(
+ createMeaningfulGraph("chat-idb-single-snapshot-build", "single-snapshot-build"),
+ );
+ harness.api.setGraphPersistenceState({
+ loadState: "loaded",
+ chatId: "chat-idb-single-snapshot-build",
+ revision: 8,
+ lastPersistedRevision: 0,
+ writesBlocked: false,
+ });
+
+ const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
+ let buildSnapshotCallCount = 0;
+ harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
+ buildSnapshotCallCount += 1;
+ return originalBuildSnapshotFromGraph(...args);
+ };
+
+ const result = await harness.api.saveGraphToIndexedDb(
+ "chat-idb-single-snapshot-build",
+ harness.api.getCurrentGraph(),
+ {
+ revision: 8,
+ reason: "single-snapshot-build-save",
+ scheduleCloudUpload: false,
+ },
+ );
+
+ assert.equal(result.saved, true);
+ assert.equal(
+ buildSnapshotCallCount,
+ 1,
+ "saveGraphToIndexedDb 热路径应复用首次构建的 snapshot,而不是提交后再重建一次",
+ );
+ assert.equal(result.snapshot?.meta?.revision, 8);
+ assert.equal(
+ harness.api.getIndexedDbSnapshot()?.meta?.revision,
+ 8,
+ "复用首次 snapshot 后仍应正确回填缓存 revision",
+ );
+}
+
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-pending-persist-retry",
From 5a8f563168b792e4012fb261a8c75e155490e1e9 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 21 Apr 2026 10:46:52 +0000
Subject: [PATCH 06/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 835ddb2..0a5a5ab 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.4.7",
+ "version": "5.4.8",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From d2c3d1f5ddecde9d4618371922fca471b21dd02d Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Tue, 21 Apr 2026 20:32:03 +0800
Subject: [PATCH 07/74] Add persistence and retrieval observability with native
delta gating
---
index.js | 175 ++++++++++++++++++--
maintenance/extraction-controller.js | 87 ++++++++--
retrieval/retriever.js | 20 +++
retrieval/shared-ranking.js | 17 ++
tests/extraction-persistence-gating.mjs | 101 ++++++++++++
ui/panel.js | 177 ++++++++++++++++++++-
ui/ui-status.js | 1 +
vector/vector-index.js | 202 ++++++++++++++++++++++--
8 files changed, 745 insertions(+), 35 deletions(-)
diff --git a/index.js b/index.js
index 1cbf5d4..7ab16e8 100644
--- a/index.js
+++ b/index.js
@@ -1585,6 +1585,10 @@ function getGraphPersistenceLiveState() {
null,
),
persistDelta: cloneRuntimeDebugValue(graphPersistenceState.persistDelta, null),
+ loadDiagnostics: cloneRuntimeDebugValue(
+ graphPersistenceState.loadDiagnostics,
+ null,
+ ),
};
return cloneRuntimeDebugValue(snapshot, snapshot);
@@ -1654,6 +1658,14 @@ function readPersistDeltaDiagnosticsNow() {
return Date.now();
}
+function readLoadDiagnosticsNow() {
+ return readPersistDeltaDiagnosticsNow();
+}
+
+function normalizeLoadDiagnosticsMs(value = 0) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
function updatePersistDeltaDiagnostics(snapshot = null) {
const nextSnapshot =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
@@ -1671,6 +1683,23 @@ function updatePersistDeltaDiagnostics(snapshot = null) {
return nextSnapshot;
}
+function updateLoadDiagnostics(snapshot = null) {
+ const nextSnapshot =
+ snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
+ ? {
+ ...(graphPersistenceState.loadDiagnostics &&
+ typeof graphPersistenceState.loadDiagnostics === "object" &&
+ !Array.isArray(graphPersistenceState.loadDiagnostics)
+ ? cloneRuntimeDebugValue(graphPersistenceState.loadDiagnostics, {})
+ : {}),
+ ...cloneRuntimeDebugValue(snapshot, {}),
+ updatedAt: new Date().toISOString(),
+ }
+ : null;
+ updateGraphPersistenceState({ loadDiagnostics: nextSnapshot });
+ return nextSnapshot;
+}
+
function bumpGraphRevision(reason = "graph-mutation") {
const nextRevision =
Math.max(
@@ -9093,14 +9122,37 @@ function applyIndexedDbSnapshotToRuntime(
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
syncCommitMarkerToPersistenceState(getContext());
+ const loadStartedAt = readLoadDiagnosticsNow();
+ const recordLoadDiagnostics = (patch = {}) =>
+ updateLoadDiagnostics({
+ stage: "apply-indexeddb-snapshot",
+ source: String(source || reasonPrefix),
+ reasonPrefix: String(reasonPrefix || "indexeddb"),
+ statusLabel: String(statusLabel || "IndexedDB"),
+ chatId: normalizedChatId || "",
+ attemptIndex: Number.isFinite(Number(attemptIndex))
+ ? Math.max(0, Math.floor(Number(attemptIndex)))
+ : 0,
+ storagePrimary: String(storagePrimary || "indexeddb"),
+ storageMode: String(storageMode || storagePrimary || "indexeddb"),
+ ...cloneRuntimeDebugValue(patch, {}),
+ totalMs: normalizeLoadDiagnosticsMs(readLoadDiagnosticsNow() - loadStartedAt),
+ });
+ let hydrateMs = 0;
if (!normalizedChatId || !isIndexedDbSnapshotMeaningful(snapshot)) {
- return {
+ const result = {
success: false,
loaded: false,
reason: `${reasonPrefix}-empty`,
chatId: normalizedChatId,
attemptIndex,
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ });
+ return result;
}
const revision = Math.max(
@@ -9145,7 +9197,7 @@ function applyIndexedDbSnapshotToRuntime(
revision,
staleDetail: staleDecision,
});
- return {
+ const result = {
success: false,
loaded: false,
reason: `${reasonPrefix}-stale-runtime`,
@@ -9154,12 +9206,22 @@ function applyIndexedDbSnapshotToRuntime(
revision,
staleDetail: cloneRuntimeDebugValue(staleDecision, null),
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ revision,
+ staleDetail: cloneRuntimeDebugValue(staleDecision, null),
+ });
+ return result;
}
let graphFromSnapshot = null;
try {
+ const hydrateStartedAt = readLoadDiagnosticsNow();
graphFromSnapshot = buildGraphFromSnapshot(snapshot, {
chatId: normalizedChatId,
});
+ hydrateMs = readLoadDiagnosticsNow() - hydrateStartedAt;
} catch (error) {
const failureReason =
error?.code === "BME_SNAPSHOT_INTEGRITY_ERROR"
@@ -9194,7 +9256,7 @@ function applyIndexedDbSnapshotToRuntime(
detail: error?.message || String(error),
integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [],
});
- return {
+ const result = {
success: false,
loaded: false,
reason: failureReason,
@@ -9203,7 +9265,18 @@ function applyIndexedDbSnapshotToRuntime(
chatId: normalizedChatId,
attemptIndex,
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: failureReason,
+ revision,
+ hydrateMs: normalizeLoadDiagnosticsMs(hydrateMs),
+ error: error?.message || String(error),
+ integrityReasons: Array.isArray(error?.reasons) ? [...error.reasons] : [],
+ });
+ return result;
}
+ const applyRuntimeStartedAt = readLoadDiagnosticsNow();
currentGraph = graphFromSnapshot;
stampGraphPersistenceMeta(currentGraph, {
revision,
@@ -9298,7 +9371,7 @@ function applyIndexedDbSnapshotToRuntime(
...getGraphStats(currentGraph),
});
- return {
+ const result = {
success: true,
loaded: true,
loadState: GRAPH_LOAD_STATES.LOADED,
@@ -9308,6 +9381,17 @@ function applyIndexedDbSnapshotToRuntime(
shadowSnapshotUsed: false,
revision,
};
+ recordLoadDiagnostics({
+ success: true,
+ loaded: true,
+ reason: result.reason,
+ revision,
+ hydrateMs: normalizeLoadDiagnosticsMs(hydrateMs),
+ applyRuntimeMs: normalizeLoadDiagnosticsMs(
+ readLoadDiagnosticsNow() - applyRuntimeStartedAt,
+ ),
+ });
+ return result;
}
async function loadGraphFromIndexedDb(
@@ -9321,27 +9405,55 @@ async function loadGraphFromIndexedDb(
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
const commitMarker = syncCommitMarkerToPersistenceState(getContext());
+ const loadStartedAt = readLoadDiagnosticsNow();
+ const recordLoadDiagnostics = (patch = {}) =>
+ updateLoadDiagnostics({
+ stage: "load-indexeddb",
+ source: String(source || "indexeddb-probe"),
+ chatId: normalizedChatId || "",
+ attemptIndex: Number.isFinite(Number(attemptIndex))
+ ? Math.max(0, Math.floor(Number(attemptIndex)))
+ : 0,
+ ...cloneRuntimeDebugValue(patch, {}),
+ totalMs: normalizeLoadDiagnosticsMs(readLoadDiagnosticsNow() - loadStartedAt),
+ });
+ let exportSnapshotMs = 0;
+ let exportSnapshotSource = "";
if (!normalizedChatId) {
- return {
+ const result = {
success: false,
loaded: false,
reason: "indexeddb-missing-chat-id",
chatId: "",
attemptIndex,
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ });
+ return result;
}
let localStore = getPreferredGraphLocalStorePresentationSync();
try {
const manager = ensureBmeChatManager();
if (!manager) {
- return {
+ const result = {
success: false,
loaded: false,
reason: "indexeddb-manager-unavailable",
chatId: normalizedChatId,
attemptIndex,
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ storagePrimary: localStore.storagePrimary,
+ storageMode: localStore.storageMode,
+ });
+ return result;
}
const db = await manager.getCurrentDb(normalizedChatId);
localStore = resolveDbGraphStorePresentation(db);
@@ -9464,11 +9576,22 @@ async function loadGraphFromIndexedDb(
},
});
}
- const snapshot =
- identityRecoveryResult?.snapshot ||
- localStoreMigrationResult?.snapshot ||
- migrationResult?.snapshot ||
- (await db.exportSnapshot({ includeTombstones: false }));
+ let snapshot = null;
+ if (identityRecoveryResult?.snapshot) {
+ snapshot = identityRecoveryResult.snapshot;
+ exportSnapshotSource = "identity-recovery";
+ } else if (localStoreMigrationResult?.snapshot) {
+ snapshot = localStoreMigrationResult.snapshot;
+ exportSnapshotSource = "local-store-migration";
+ } else if (migrationResult?.snapshot) {
+ snapshot = migrationResult.snapshot;
+ exportSnapshotSource = "legacy-migration";
+ } else {
+ const exportStartedAt = readLoadDiagnosticsNow();
+ snapshot = await db.exportSnapshot({ includeTombstones: false });
+ exportSnapshotMs = readLoadDiagnosticsNow() - exportStartedAt;
+ exportSnapshotSource = "indexeddb-export";
+ }
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(
resolveCurrentChatIdentity(getContext()),
);
@@ -9678,6 +9801,7 @@ async function loadGraphFromIndexedDb(
};
}
+ const applyInvokeStartedAt = readLoadDiagnosticsNow();
const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, {
source,
attemptIndex,
@@ -9686,11 +9810,26 @@ async function loadGraphFromIndexedDb(
statusLabel: snapshotStore.statusLabel,
reasonPrefix: snapshotStore.reasonPrefix,
});
+ const applyInvokeMs = readLoadDiagnosticsNow() - applyInvokeStartedAt;
if (commitMarkerDiagnostic?.reason && loadResult?.loaded) {
updateGraphPersistenceState({
persistMismatchReason: commitMarkerDiagnostic.reason,
});
}
+ recordLoadDiagnostics({
+ success: loadResult?.success === true,
+ loaded: loadResult?.loaded === true,
+ reason: String(loadResult?.reason || ""),
+ revision: Number.isFinite(Number(loadResult?.revision))
+ ? Number(loadResult.revision)
+ : snapshotRevision,
+ storagePrimary: snapshotStore.storagePrimary,
+ storageMode: snapshotStore.storageMode,
+ commitMarkerMismatched: commitMarkerMismatch.mismatched === true,
+ exportSnapshotSource: exportSnapshotSource || "snapshot-prepared",
+ exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
+ applyInvokeMs: normalizeLoadDiagnosticsMs(applyInvokeMs),
+ });
return loadResult;
} catch (error) {
console.warn(`[ST-BME] ${localStore.statusLabel} 读取失败,回退 metadata:`, error);
@@ -9706,7 +9845,7 @@ async function loadGraphFromIndexedDb(
at: Date.now(),
},
});
- return {
+ const result = {
success: false,
loaded: false,
reason: `${localStore.reasonPrefix}-read-failed`,
@@ -9714,6 +9853,17 @@ async function loadGraphFromIndexedDb(
attemptIndex,
error,
};
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ storagePrimary: localStore.storagePrimary,
+ storageMode: localStore.storageMode,
+ error: error?.message || String(error),
+ exportSnapshotSource: exportSnapshotSource || "unknown",
+ exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
+ });
+ return result;
}
}
@@ -16030,6 +16180,7 @@ async function executeExtractionBatch({
getEmbeddingConfig,
getExtractionCount: () => extractionCount,
getLastProcessedAssistantFloor,
+ getSettings,
getSchema,
handleExtractionSuccess,
persistExtractionBatchResult,
diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js
index 38fa94e..0f1d78f 100644
--- a/maintenance/extraction-controller.js
+++ b/maintenance/extraction-controller.js
@@ -7,6 +7,8 @@ import {
normalizeDialogueFloorRange,
} from "./chat-history.js";
+let nativePersistDeltaInstallPromise = null;
+
function toSafeFloor(value, fallback = null) {
if (value == null || value === "") return fallback;
const numeric = Number(value);
@@ -115,6 +117,31 @@ function cloneSerializable(value, fallback = null) {
}
}
+function readNow() {
+ if (typeof performance === "object" && typeof performance.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+}
+
+async function ensureNativePersistDeltaHookInstalled() {
+ if (typeof globalThis.__stBmeNativeBuildPersistDelta === "function") {
+ return {
+ loaded: true,
+ source: "global-hook",
+ };
+ }
+ if (!nativePersistDeltaInstallPromise) {
+ nativePersistDeltaInstallPromise = import("../vendor/wasm/stbme_core.js")
+ .then((module) => module?.installNativePersistDeltaHook?.())
+ .catch((error) => {
+ nativePersistDeltaInstallPromise = null;
+ throw error;
+ });
+ }
+ return await nativePersistDeltaInstallPromise;
+}
+
function setExtractionProgressStatus(
runtime,
text,
@@ -247,11 +274,12 @@ function buildRerunFallbackInfo(chat = [], targetDialogueRange = [-1, -1]) {
};
}
-function buildCommittedBatchPersistSnapshot(
+async function buildCommittedBatchPersistSnapshot(
runtime,
{
graph = null,
chat = [],
+ settings = null,
beforeSnapshot = null,
processedRange = [null, null],
postProcessArtifacts = [],
@@ -274,6 +302,10 @@ function buildCommittedBatchPersistSnapshot(
const range = Array.isArray(processedRange) ? processedRange : [null, null];
const rangeStart = Number.isFinite(Number(range[0])) ? Number(range[0]) : null;
const rangeEnd = Number.isFinite(Number(range[1])) ? Number(range[1]) : null;
+ const runtimeSettings =
+ settings && typeof settings === "object" && !Array.isArray(settings)
+ ? settings
+ : runtime?.getSettings?.() || {};
const dialogueMap = buildDialogueFloorMap(chat);
const processedDialogueRange = [
Number.isFinite(Number(rangeStart))
@@ -290,7 +322,7 @@ function buildCommittedBatchPersistSnapshot(
Number(rangeStart) -
Math.max(
0,
- Number(runtime?.getSettings?.()?.extractContextTurns) || 0,
+ Number(runtimeSettings?.extractContextTurns) || 0,
) *
2,
)
@@ -347,13 +379,45 @@ function buildCommittedBatchPersistSnapshot(
);
}
+ let persistDelta = null;
+ const shouldUseNativePersistDelta =
+ runtimeSettings?.persistUseNativeDelta === true &&
+ runtimeSettings?.graphNativeForceDisable !== true;
+ const nativeFailOpen = runtimeSettings?.nativeEngineFailOpen !== false;
+ if (typeof runtime.buildPersistDelta === "function") {
+ if (shouldUseNativePersistDelta) {
+ const preloadStartedAt = readNow();
+ try {
+ await ensureNativePersistDeltaHookInstalled();
+ } catch (error) {
+ if (!nativeFailOpen) {
+ throw error;
+ }
+ runtime?.console?.warn?.(
+ "[ST-BME] extraction native persist delta preload failed, fallback to JS delta:",
+ {
+ error: error?.message || String(error),
+ preloadMs: readNow() - preloadStartedAt,
+ },
+ );
+ }
+ }
+
+ persistDelta = runtime.buildPersistDelta(beforeSnapshot, committedGraphSnapshot, {
+ useNativeDelta: shouldUseNativePersistDelta,
+ nativeFailOpen,
+ persistNativeDeltaThresholdRecords:
+ runtimeSettings?.persistNativeDeltaThresholdRecords,
+ persistNativeDeltaThresholdStructuralDelta:
+ runtimeSettings?.persistNativeDeltaThresholdStructuralDelta,
+ persistNativeDeltaThresholdSerializedChars:
+ runtimeSettings?.persistNativeDeltaThresholdSerializedChars,
+ persistNativeDeltaBridgeMode: runtimeSettings?.persistNativeDeltaBridgeMode,
+ });
+ }
+
return {
- persistDelta:
- typeof runtime.buildPersistDelta === "function"
- ? runtime.buildPersistDelta(beforeSnapshot, committedGraphSnapshot, {
- useNativeDelta: false,
- })
- : null,
+ persistDelta,
persistGraphSnapshot: committedGraphSnapshot,
committedBatchJournalEntry,
afterSnapshot,
@@ -367,7 +431,9 @@ function isPersistenceRevisionAccepted(runtime, persistence = null) {
if (!Number.isFinite(persistenceRevision) || persistenceRevision <= 0) {
return false;
}
- const lastAcceptedRevision = Number(graphPersistenceState?.lastAcceptedRevision || 0);
+ const lastAcceptedRevision = Number(
+ runtime?.getGraphPersistenceState?.()?.lastAcceptedRevision || 0,
+ );
return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= persistenceRevision;
}
@@ -633,9 +699,10 @@ export async function executeExtractionBatchController(
batchStatus,
);
const batchStatusRef = effects?.batchStatus || batchStatus;
- const committedPersistState = buildCommittedBatchPersistSnapshot(runtime, {
+ const committedPersistState = await buildCommittedBatchPersistSnapshot(runtime, {
graph: runtime.getCurrentGraph(),
chat,
+ settings,
beforeSnapshot,
processedRange: [startIdx, endIdx],
postProcessArtifacts: runtime.computePostProcessArtifacts(
diff --git a/retrieval/retriever.js b/retrieval/retriever.js
index ea97f90..504189c 100644
--- a/retrieval/retriever.js
+++ b/retrieval/retriever.js
@@ -1379,6 +1379,24 @@ export async function retrieve({
)
? [...sharedRanking.diagnostics.lexicalTopHits]
: [];
+ retrievalMeta.timings.sharedQueryBlend = Number(
+ sharedRanking?.diagnostics?.timings?.queryBlend || 0,
+ );
+ retrievalMeta.timings.sharedLexical = Number(
+ sharedRanking?.diagnostics?.timings?.lexical || 0,
+ );
+ retrievalMeta.timings.sharedScoring = Number(
+ sharedRanking?.diagnostics?.timings?.scoring || 0,
+ );
+ retrievalMeta.timings.sharedTotal = Number(
+ sharedRanking?.diagnostics?.timings?.total || 0,
+ );
+ retrievalMeta.timings.sharedVector = Number(
+ sharedRanking?.diagnostics?.timings?.vector || 0,
+ );
+ retrievalMeta.timings.sharedDiffusion = Number(
+ sharedRanking?.diagnostics?.timings?.diffusion || 0,
+ );
retrievalMeta.timings.vector = Number(
sharedRanking?.diagnostics?.timings?.vector || 0,
);
@@ -1395,12 +1413,14 @@ export async function retrieve({
? [...sharedRanking.diffusionResults]
: [];
exactEntityAnchors.push(...(sharedRanking?.exactEntityAnchors || []));
+ const anchorCollectStartedAt = nowMs();
supplementalAnchorNodeIds = collectSupplementalAnchorNodeIds(
graph,
vectorResults,
exactEntityAnchors.map((item) => item.nodeId),
5,
);
+ retrievalMeta.timings.anchorCollect = roundMs(nowMs() - anchorCollectStartedAt);
let residualResult = {
triggered: false,
diff --git a/retrieval/shared-ranking.js b/retrieval/shared-ranking.js
index 6e1fba3..05b782a 100644
--- a/retrieval/shared-ranking.js
+++ b/retrieval/shared-ranking.js
@@ -536,6 +536,8 @@ export async function rankNodesForTaskContext({
? options.activeNodes.filter((node) => node && !node.archived)
: getActiveNodes(graph).filter((node) => node && !node.archived);
const vectorValidation = validateVectorConfig(embeddingConfig);
+ const rankingStartedAt = nowMs();
+ const queryBlendStartedAt = nowMs();
const contextQueryBlend = buildContextQueryBlend(userMessage, recentMessages, {
enabled: enableContextQueryBlend,
assistantWeight: contextAssistantWeight,
@@ -553,6 +555,7 @@ export async function rankNodesForTaskContext({
maxSegments: multiIntentMaxSegments,
},
);
+ const queryBlendMs = roundMs(nowMs() - queryBlendStartedAt);
const diagnostics = {
queryBlendActive: contextQueryBlend.active,
queryBlendParts: (contextQueryBlend.parts || []).map((part) => ({
@@ -577,12 +580,17 @@ export async function rankNodesForTaskContext({
lexicalTopHits: [],
skipReasons: [],
timings: {
+ queryBlend: queryBlendMs,
vector: 0,
diffusion: 0,
+ lexical: 0,
+ scoring: 0,
+ total: 0,
},
};
if (!graph || activeNodes.length === 0) {
+ diagnostics.timings.total = roundMs(nowMs() - rankingStartedAt);
return {
activeNodes,
contextQueryBlend,
@@ -688,13 +696,19 @@ export async function rankNodesForTaskContext({
}
}
+ const scoringStartedAt = nowMs();
+ let lexicalMs = 0;
const scoredNodes = [];
for (const [nodeId, scores] of scoreMap.entries()) {
const node = getNode(graph, nodeId);
if (!node || node.archived) continue;
+ const lexicalStartedAt = enableLexicalBoost ? nowMs() : 0;
const lexicalScore = enableLexicalBoost
? computeLexicalScore(node, lexicalQuery.sources)
: 0;
+ if (enableLexicalBoost) {
+ lexicalMs += nowMs() - lexicalStartedAt;
+ }
const finalScore = hybridScore(
{
graphScore: scores.graphScore,
@@ -719,6 +733,8 @@ export async function rankNodesForTaskContext({
weightedScore: finalScore,
});
}
+ diagnostics.timings.lexical = roundMs(lexicalMs);
+ diagnostics.timings.scoring = roundMs(nowMs() - scoringStartedAt);
scoredNodes.sort((left, right) => {
const weightedDelta =
@@ -737,6 +753,7 @@ export async function rankNodesForTaskContext({
(item) => (Number(item.lexicalScore) || 0) > 0,
).length;
diagnostics.lexicalTopHits = buildLexicalTopHits(scoredNodes);
+ diagnostics.timings.total = roundMs(nowMs() - rankingStartedAt);
return {
activeNodes,
diff --git a/tests/extraction-persistence-gating.mjs b/tests/extraction-persistence-gating.mjs
index 897b09d..3741646 100644
--- a/tests/extraction-persistence-gating.mjs
+++ b/tests/extraction-persistence-gating.mjs
@@ -16,6 +16,7 @@ function createRuntime(persistResult) {
};
let processedHistoryUpdates = 0;
let persistedGraphSnapshot = null;
+ let lastPersistDeltaOptions = null;
return {
graph,
@@ -35,6 +36,22 @@ function createRuntime(persistResult) {
cloneGraphSnapshot(value) {
return JSON.parse(JSON.stringify(value));
},
+ buildPersistDelta(_beforeSnapshot, _afterSnapshot, options = {}) {
+ lastPersistDeltaOptions = { ...(options || {}) };
+ return {
+ upsertNodes: [],
+ upsertEdges: [],
+ deleteNodeIds: [],
+ deleteEdgeIds: [],
+ tombstones: [],
+ countDelta: {
+ nodes: 0,
+ edges: 0,
+ tombstones: 0,
+ },
+ runtimeMetaPatch: {},
+ };
+ },
buildExtractionMessages() {
return [{ seq: 5, role: "assistant", content: "测试消息" }];
},
@@ -101,6 +118,9 @@ function createRuntime(persistResult) {
get persistedGraphSnapshot() {
return persistedGraphSnapshot;
},
+ get lastPersistDeltaOptions() {
+ return lastPersistDeltaOptions;
+ },
};
}
@@ -212,4 +232,85 @@ function createRuntime(persistResult) {
assert.equal(runtime.graph.historyState.lastBatchStatus.persistence, null);
}
+{
+ const originalNativeBuilder = globalThis.__stBmeNativeBuildPersistDelta;
+ globalThis.__stBmeNativeBuildPersistDelta = () => ({
+ upsertNodes: [],
+ upsertEdges: [],
+ deleteNodeIds: [],
+ deleteEdgeIds: [],
+ tombstones: [],
+ runtimeMetaPatch: {},
+ });
+ const runtime = createRuntime({
+ saved: true,
+ queued: false,
+ blocked: false,
+ accepted: true,
+ reason: "indexeddb",
+ revision: 9,
+ saveMode: "indexeddb",
+ storageTier: "indexeddb",
+ });
+ const result = await executeExtractionBatchController(runtime, {
+ chat: [{ is_user: false, mes: "测试" }],
+ startIdx: 5,
+ endIdx: 5,
+ settings: {
+ persistUseNativeDelta: true,
+ graphNativeForceDisable: false,
+ nativeEngineFailOpen: true,
+ persistNativeDeltaThresholdRecords: 123,
+ persistNativeDeltaThresholdStructuralDelta: 45,
+ persistNativeDeltaThresholdSerializedChars: 6789,
+ persistNativeDeltaBridgeMode: "hash",
+ },
+ });
+
+ assert.equal(result.success, true);
+ assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, true);
+ assert.equal(runtime.lastPersistDeltaOptions.nativeFailOpen, true);
+ assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdRecords, 123);
+ assert.equal(
+ runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdStructuralDelta,
+ 45,
+ );
+ assert.equal(
+ runtime.lastPersistDeltaOptions.persistNativeDeltaThresholdSerializedChars,
+ 6789,
+ );
+ assert.equal(runtime.lastPersistDeltaOptions.persistNativeDeltaBridgeMode, "hash");
+
+ if (typeof originalNativeBuilder === "function") {
+ globalThis.__stBmeNativeBuildPersistDelta = originalNativeBuilder;
+ } else {
+ delete globalThis.__stBmeNativeBuildPersistDelta;
+ }
+}
+
+{
+ const runtime = createRuntime({
+ saved: true,
+ queued: false,
+ blocked: false,
+ accepted: true,
+ reason: "indexeddb",
+ revision: 10,
+ saveMode: "indexeddb",
+ storageTier: "indexeddb",
+ });
+ const result = await executeExtractionBatchController(runtime, {
+ chat: [{ is_user: false, mes: "测试" }],
+ startIdx: 5,
+ endIdx: 5,
+ settings: {
+ persistUseNativeDelta: true,
+ graphNativeForceDisable: true,
+ },
+ });
+
+ assert.equal(result.success, true);
+ assert.equal(runtime.lastPersistDeltaOptions.useNativeDelta, false);
+}
+
console.log("extraction-persistence-gating tests passed");
diff --git a/ui/panel.js b/ui/panel.js
index cfbf1ae..67b4622 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -1461,6 +1461,159 @@ function _resolvePipelineStatus(statusObj) {
return { label: text || "IDLE", color, detail: meta };
}
+function _readPersistenceDiagnosticObject(snapshot = null) {
+ if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) {
+ return null;
+ }
+ return snapshot;
+}
+
+function _formatLoadDiagnosticsStageLabel(stage = "") {
+ const normalized = String(stage || "").trim();
+ if (!normalized) return "—";
+ const labels = {
+ "load-indexeddb": "IndexedDB 加载",
+ "apply-indexeddb-snapshot": "快照应用",
+ };
+ return labels[normalized] || normalized;
+}
+
+function _formatPipelineLoadDiagnosticsMeta(loadDiagnostics = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(loadDiagnostics);
+ if (!diagnostics) return "";
+ const totalText = _formatDurationMs(diagnostics.totalMs);
+ if (totalText !== "—") return `load ${totalText}`;
+ const stageLabel = _formatLoadDiagnosticsStageLabel(diagnostics.stage);
+ return stageLabel === "—" ? "" : stageLabel;
+}
+
+function _formatPipelinePersistDeltaMeta(persistDelta = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(persistDelta);
+ if (!diagnostics) return "";
+
+ const parts = [];
+ const totalText = _formatDurationMs(diagnostics.totalMs || diagnostics.buildMs);
+ if (totalText !== "—") {
+ parts.push(`delta ${totalText}`);
+ }
+
+ const gateText = String(_formatPersistDeltaGateText(diagnostics) || "").trim();
+ if (gateText) {
+ const compactGate = gateText.startsWith("已拦截") ? "已拦截" : gateText;
+ parts.push(`native ${compactGate}`);
+ }
+
+ return parts.join(" · ");
+}
+
+function _formatPersistenceLoadSummary(loadDiagnostics = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(loadDiagnostics);
+ if (!diagnostics) return "暂无";
+
+ const statusText =
+ diagnostics.success === true
+ ? "成功"
+ : diagnostics.success === false
+ ? "失败"
+ : "未知";
+ const totalText = _formatDurationMs(diagnostics.totalMs);
+ const stageLabel = _formatLoadDiagnosticsStageLabel(diagnostics.stage);
+ const reasonText = String(diagnostics.reason || "").trim();
+ const parts = [statusText];
+ if (stageLabel !== "—") parts.push(stageLabel);
+ if (totalText !== "—") parts.push(`total ${totalText}`);
+ if (reasonText) parts.push(reasonText);
+ return parts.join(" · ");
+}
+
+function _formatPersistencePersistDeltaSummary(persistDelta = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(persistDelta);
+ if (!diagnostics) return "暂无";
+
+ const pathText = String(diagnostics.path || "").trim() || "—";
+ const totalText = _formatDurationMs(diagnostics.totalMs || diagnostics.buildMs);
+ const gateText = String(_formatPersistDeltaGateText(diagnostics) || "").trim();
+ const parts = [pathText];
+ if (totalText !== "—") parts.push(totalText);
+ if (gateText) parts.push(`native ${gateText}`);
+ return parts.join(" · ");
+}
+
+function _buildLoadDiagnosticRows(loadDiagnostics = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(loadDiagnostics);
+ if (!diagnostics) {
+ return [["Load 诊断", "无"]];
+ }
+
+ const statusText =
+ diagnostics.success === true
+ ? "成功"
+ : diagnostics.success === false
+ ? "失败"
+ : "未知";
+ const updatedAtText = diagnostics.updatedAt
+ ? _formatTaskProfileTime(diagnostics.updatedAt)
+ : "—";
+
+ return [
+ ["Load 阶段", _formatLoadDiagnosticsStageLabel(diagnostics.stage)],
+ ["Load 来源", String(diagnostics.source || diagnostics.statusLabel || "—")],
+ ["Load 状态", statusText],
+ ["Load 原因", String(diagnostics.reason || "—")],
+ ["Load 总耗时", _formatDurationMs(diagnostics.totalMs)],
+ ["导出快照", _formatDurationMs(diagnostics.exportSnapshotMs)],
+ ["Hydrate", _formatDurationMs(diagnostics.hydrateMs)],
+ ["Apply 调用", _formatDurationMs(diagnostics.applyInvokeMs)],
+ ["Apply 运行", _formatDurationMs(diagnostics.applyRuntimeMs)],
+ ["Load 更新时间", updatedAtText],
+ ];
+}
+
+function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
+ const diagnostics = _readPersistenceDiagnosticObject(persistDelta);
+ if (!diagnostics) {
+ return [["Persist Delta 诊断", "无"]];
+ }
+
+ const errorText = String(
+ diagnostics.moduleError || diagnostics.preloadError || diagnostics.nativeError || "",
+ ).trim();
+ const bridgeText = `${String(diagnostics.requestedBridgeMode || "none")} → ${String(
+ diagnostics.preparedBridgeMode || "none",
+ )}`;
+ const deltaSizeText = `${Number(diagnostics.upsertNodeCount || 0)}N / ${Number(
+ diagnostics.upsertEdgeCount || 0,
+ )}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(
+ diagnostics.deleteEdgeCount || 0,
+ )}DE`;
+ const updatedAtText = diagnostics.updatedAt
+ ? _formatTaskProfileTime(diagnostics.updatedAt)
+ : "—";
+
+ return [
+ ["Persist 路径", String(diagnostics.path || "—")],
+ ["Native Gate", _formatPersistDeltaGateText(diagnostics)],
+ ["Bridge 模式", bridgeText],
+ ["Persist 总耗时", _formatDurationMs(diagnostics.totalMs || diagnostics.buildMs)],
+ ["构建耗时", _formatDurationMs(diagnostics.buildMs)],
+ [
+ "Prepare / Native",
+ `${_formatDurationMs(diagnostics.prepareMs)} / ${_formatDurationMs(diagnostics.nativeAttemptMs)}`,
+ ],
+ [
+ "Lookup / JS Diff",
+ `${_formatDurationMs(diagnostics.lookupMs)} / ${_formatDurationMs(diagnostics.jsDiffMs)}`,
+ ],
+ ["Hydrate", _formatDurationMs(diagnostics.hydrateMs)],
+ ["Preload", String(diagnostics.preloadStatus || "—")],
+ ["Native 来源", String(diagnostics.moduleSource || "—")],
+ ["Fallback 原因", String(diagnostics.fallbackReason || "—")],
+ ["Preload / Native 错误", errorText || "—"],
+ ["增量规模", deltaSizeText],
+ ["Persist 更新时间", updatedAtText],
+ ];
+}
+
function _refreshTaskPipelineOverview() {
const el = document.getElementById("bme-task-pipeline");
if (!el) return;
@@ -1473,9 +1626,22 @@ function _refreshTaskPipelineOverview() {
const vector = _resolvePipelineStatus(_getLastVectorStatus?.());
const recall = _resolvePipelineStatus(_getLastRecallStatus?.());
const persistLevel = loadInfo.loadState === "loaded" ? "info" : loadInfo.loadState === "loading" ? "info" : "warn";
+ const persistenceMetaParts = [`rev ${loadInfo.revision || 0}`];
+ const pipelineLoadMeta = _formatPipelineLoadDiagnosticsMeta(
+ loadInfo.loadDiagnostics,
+ );
+ if (pipelineLoadMeta) {
+ persistenceMetaParts.push(pipelineLoadMeta);
+ }
+ const pipelinePersistDeltaMeta = _formatPipelinePersistDeltaMeta(
+ loadInfo.persistDelta,
+ );
+ if (pipelinePersistDeltaMeta) {
+ persistenceMetaParts.push(pipelinePersistDeltaMeta);
+ }
const persistence = _resolvePipelineStatus({
text: loadInfo.loadState || "unknown",
- meta: `rev ${loadInfo.revision || 0}`,
+ meta: persistenceMetaParts.join(" · "),
level: persistLevel,
});
@@ -2166,6 +2332,8 @@ function _refreshTaskPersistence() {
const graph = _getGraph?.() || {};
const ps = _getGraphPersistenceSnapshot();
const rs = graph.runtimeState || {};
+ const loadDiagnostics = _readPersistenceDiagnosticObject(ps.loadDiagnostics);
+ const persistDeltaDiagnostics = _readPersistenceDiagnosticObject(ps.persistDelta);
const LOAD_STATE_LABELS = {
"no-chat": "无聊天",
@@ -2307,6 +2475,8 @@ function _refreshTaskPersistence() {
const primaryRows = [
["当前状态", acceptedSummaryLabel],
["健康状态", healthLabel],
+ ["Load 诊断", _formatPersistenceLoadSummary(loadDiagnostics)],
+ ["Persist Delta", _formatPersistencePersistDeltaSummary(persistDeltaDiagnostics)],
["Chat Target", compactTargetLabel],
["主 durable", primaryTierLabel],
ps.hostProfile === "luker"
@@ -2358,6 +2528,10 @@ function _refreshTaskPersistence() {
["缓存落后", cacheLagLabel],
);
}
+ diagnosticRows.push(
+ ..._buildLoadDiagnosticRows(loadDiagnostics),
+ ..._buildPersistDeltaDiagnosticRows(persistDeltaDiagnostics),
+ );
el.innerHTML = `
@@ -11849,6 +12023,7 @@ function _getGraphPersistenceSnapshot() {
lastBackupFilename: "",
lastSyncError: "",
persistDelta: null,
+ loadDiagnostics: null,
};
}
diff --git a/ui/ui-status.js b/ui/ui-status.js
index a492a01..debedd3 100644
--- a/ui/ui-status.js
+++ b/ui/ui-status.js
@@ -125,6 +125,7 @@ export function createGraphPersistenceState() {
lastSyncError: "",
dualWriteLastResult: null,
persistDelta: null,
+ loadDiagnostics: null,
updatedAt: new Date().toISOString(),
};
}
diff --git a/vector/vector-index.js b/vector/vector-index.js
index 7fd4e54..b7fde57 100644
--- a/vector/vector-index.js
+++ b/vector/vector-index.js
@@ -63,6 +63,17 @@ function throwIfAborted(signal) {
}
}
+function nowMs() {
+ if (typeof performance?.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+}
+
+function roundMs(value) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
export const BACKEND_DEFAULT_MODELS = {
openai: "text-embedding-3-small",
openrouter: "openai/text-embedding-3-small",
@@ -349,16 +360,38 @@ function getEligibleVectorNodes(graph, range = null) {
return nodes.filter((node) => buildNodeVectorText(node).length > 0);
}
-function buildDesiredVectorEntries(graph, config, range = null) {
- return getEligibleVectorNodes(graph, range).map((node) => {
- const hash = buildNodeVectorHash(node, config);
+function buildDesiredVectorEntries(graph, config, range = null, diagnostics = null) {
+ const modelScope = getVectorModelScope(config);
+ let textBuildMs = 0;
+ let hashBuildMs = 0;
+ const entries = getEligibleVectorNodes(graph, range).map((node) => {
+ const textStartedAt = diagnostics ? nowMs() : 0;
+ const text = buildNodeVectorText(node);
+ if (diagnostics) {
+ textBuildMs += nowMs() - textStartedAt;
+ }
+ const seqEnd = node?.seqRange?.[1] ?? node?.seq ?? 0;
+ const hashStartedAt = diagnostics ? nowMs() : 0;
+ const payload = [node?.id || "", text, String(seqEnd), modelScope].join("::");
+ const hash = stableHashString(payload);
+ if (diagnostics) {
+ hashBuildMs += nowMs() - hashStartedAt;
+ }
return {
nodeId: node.id,
hash,
- text: buildNodeVectorText(node),
- index: node?.seqRange?.[1] ?? node?.seq ?? 0,
+ text,
+ index: seqEnd,
};
});
+
+ if (diagnostics && typeof diagnostics === "object") {
+ diagnostics.textBuildMs = roundMs(textBuildMs);
+ diagnostics.hashBuildMs = roundMs(hashBuildMs);
+ diagnostics.entryCount = entries.length;
+ }
+
+ return entries;
}
function computeVectorStats(graph, desiredEntries) {
@@ -547,26 +580,54 @@ export async function syncGraphVectorIndex(
return {
insertedHashes: [],
stats: { total: 0, indexed: 0, stale: 0, pending: 0 },
+ timings: null,
};
}
throwIfAborted(signal);
+ const syncStartedAt = nowMs();
+ const syncMode = isBackendVectorConfig(config) ? "backend" : "direct";
+
const validation = validateVectorConfig(config);
if (!validation.valid) {
graph.vectorIndexState.lastWarning = validation.error;
graph.vectorIndexState.dirty = true;
- return { insertedHashes: [], stats: graph.vectorIndexState.lastStats };
+ graph.vectorIndexState.lastTimings = {
+ mode: syncMode,
+ validationError: validation.error,
+ totalMs: roundMs(nowMs() - syncStartedAt),
+ updatedAt: Date.now(),
+ };
+ return {
+ insertedHashes: [],
+ stats: graph.vectorIndexState.lastStats,
+ timings: graph.vectorIndexState.lastTimings,
+ };
}
const state = graph.vectorIndexState;
const collectionId = buildVectorCollectionId(
chatId || graph?.historyState?.chatId,
);
- const desiredEntries = buildDesiredVectorEntries(graph, config, range);
+ const desiredBuildDiagnostics = {};
+ const desiredBuildStartedAt = nowMs();
+ const desiredEntries = buildDesiredVectorEntries(
+ graph,
+ config,
+ range,
+ desiredBuildDiagnostics,
+ );
+ const desiredBuildMs = nowMs() - desiredBuildStartedAt;
const desiredByNodeId = new Map(
desiredEntries.map((entry) => [entry.nodeId, entry]),
);
const insertedHashes = [];
+ let backendPurgeMs = 0;
+ let backendDeleteMs = 0;
+ let backendInsertMs = 0;
+ let embedBatchMs = 0;
+ let deletedHashCount = 0;
+ let embeddingsRequested = 0;
const hasConcreteRange =
range && Number.isFinite(range.start) && Number.isFinite(range.end);
const rangedNodeIds = new Set(desiredEntries.map((entry) => entry.nodeId));
@@ -581,9 +642,13 @@ export async function syncGraphVectorIndex(
purge || state.dirty || scopeChanged || (force && !hasConcreteRange);
if (fullReset) {
+ const purgeStartedAt = nowMs();
await purgeVectorCollection(collectionId, signal);
+ backendPurgeMs += nowMs() - purgeStartedAt;
resetVectorMappings(graph, config, chatId);
+ const insertStartedAt = nowMs();
await insertVectorEntries(collectionId, config, desiredEntries, signal);
+ backendInsertMs += nowMs() - insertStartedAt;
for (const entry of desiredEntries) {
state.hashToNodeId[entry.hash] = entry.nodeId;
state.nodeToHash[entry.nodeId] = entry.hash;
@@ -623,8 +688,13 @@ export async function syncGraphVectorIndex(
entriesToInsert.push(entry);
}
+ deletedHashCount = hashesToDelete.length;
+ const deleteStartedAt = nowMs();
await deleteVectorHashes(collectionId, config, hashesToDelete, signal);
+ backendDeleteMs += nowMs() - deleteStartedAt;
+ const insertStartedAt = nowMs();
await insertVectorEntries(collectionId, config, entriesToInsert, signal);
+ backendInsertMs += nowMs() - insertStartedAt;
for (const entry of entriesToInsert) {
state.hashToNodeId[entry.hash] = entry.nodeId;
@@ -679,11 +749,14 @@ export async function syncGraphVectorIndex(
let directSyncHadFailures = false;
if (entriesToEmbed.length > 0) {
throwIfAborted(signal);
+ embeddingsRequested = entriesToEmbed.length;
+ const embedStartedAt = nowMs();
const embeddings = await embedBatch(
entriesToEmbed.map((entry) => entry.text),
config,
{ signal },
);
+ embedBatchMs += nowMs() - embedStartedAt;
for (let index = 0; index < entriesToEmbed.length; index++) {
const entry = entriesToEmbed[index];
@@ -718,14 +791,34 @@ export async function syncGraphVectorIndex(
state.lastWarning = "";
}
state.lastSyncAt = Date.now();
+ const statsBuildStartedAt = nowMs();
state.lastStats = computeVectorStats(
graph,
buildDesiredVectorEntries(graph, config),
);
+ const statsBuildMs = nowMs() - statsBuildStartedAt;
+ state.lastTimings = {
+ mode: syncMode,
+ desiredEntries: Number(desiredBuildDiagnostics.entryCount || desiredEntries.length),
+ desiredBuildMs: roundMs(desiredBuildMs),
+ textBuildMs: Number(desiredBuildDiagnostics.textBuildMs || 0),
+ hashBuildMs: Number(desiredBuildDiagnostics.hashBuildMs || 0),
+ backendPurgeMs: roundMs(backendPurgeMs),
+ backendDeleteMs: roundMs(backendDeleteMs),
+ backendInsertMs: roundMs(backendInsertMs),
+ embedBatchMs: roundMs(embedBatchMs),
+ statsBuildMs: roundMs(statsBuildMs),
+ deletedHashes: Math.max(0, Math.floor(deletedHashCount)),
+ insertedEntries: insertedHashes.length,
+ embeddingsRequested: Math.max(0, Math.floor(embeddingsRequested)),
+ totalMs: roundMs(nowMs() - syncStartedAt),
+ updatedAt: Date.now(),
+ };
return {
insertedHashes,
stats: state.lastStats,
+ timings: state.lastTimings,
};
}
@@ -743,14 +836,52 @@ export async function findSimilarNodesByText(
const candidateNodes = Array.isArray(candidates)
? candidates
: getEligibleVectorNodes(graph);
+ const searchStartedAt = nowMs();
+ const mode = isDirectVectorConfig(config) ? "direct" : "backend";
+ const recordSearchTimings = (patch = {}) => {
+ const state = graph?.vectorIndexState;
+ if (!state || typeof state !== "object" || Array.isArray(state)) return;
+ state.lastSearchTimings = {
+ ...(state.lastSearchTimings &&
+ typeof state.lastSearchTimings === "object" &&
+ !Array.isArray(state.lastSearchTimings)
+ ? state.lastSearchTimings
+ : {}),
+ mode,
+ queryLength: String(text || "").length,
+ candidateCount: candidateNodes.length,
+ topK: Math.max(1, Math.floor(Number(topK) || 1)),
+ ...patch,
+ totalMs: roundMs(nowMs() - searchStartedAt),
+ updatedAt: Date.now(),
+ };
+ };
- if (candidateNodes.length === 0) return [];
+ if (candidateNodes.length === 0) {
+ recordSearchTimings({
+ success: true,
+ reason: "no-candidates",
+ resultCount: 0,
+ });
+ return [];
+ }
if (isDirectVectorConfig(config)) {
+ const queryEmbedStartedAt = nowMs();
const queryVec = await embedText(text, config, { signal });
- if (!queryVec) return [];
+ const queryEmbedMs = nowMs() - queryEmbedStartedAt;
+ if (!queryVec) {
+ recordSearchTimings({
+ success: false,
+ reason: "direct-query-embed-empty",
+ queryEmbedMs: roundMs(queryEmbedMs),
+ resultCount: 0,
+ });
+ return [];
+ }
- return searchSimilar(
+ const localSearchStartedAt = nowMs();
+ const results = searchSimilar(
queryVec,
candidateNodes
.filter(
@@ -762,12 +893,29 @@ export async function findSimilarNodesByText(
})),
topK,
);
+ recordSearchTimings({
+ success: true,
+ reason: "ok",
+ queryEmbedMs: roundMs(queryEmbedMs),
+ searchMs: roundMs(nowMs() - localSearchStartedAt),
+ resultCount: results.length,
+ });
+ return results;
}
const validation = validateVectorConfig(config);
- if (!validation.valid) return [];
+ if (!validation.valid) {
+ recordSearchTimings({
+ success: false,
+ reason: "vector-config-invalid",
+ error: validation.error,
+ resultCount: 0,
+ });
+ return [];
+ }
try {
+ const requestStartedAt = nowMs();
const response = await fetchWithTimeout(
"/api/vector/query",
{
@@ -784,6 +932,7 @@ export async function findSimilarNodesByText(
},
getConfiguredTimeoutMs(config),
);
+ const requestMs = nowMs() - requestStartedAt;
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
@@ -795,23 +944,47 @@ export async function findSimilarNodesByText(
"backend-query-failed",
`后端向量查询失败(${message}),已标记待重建`,
);
+ recordSearchTimings({
+ success: false,
+ reason: "backend-query-http-failed",
+ statusCode: Number(response.status || 0),
+ requestMs: roundMs(requestMs),
+ error: message,
+ resultCount: 0,
+ });
return [];
}
+ const parseStartedAt = nowMs();
const data = await response.json().catch(() => ({ hashes: [] }));
+ const parseMs = nowMs() - parseStartedAt;
const hashes = Array.isArray(data?.hashes) ? data.hashes : [];
const nodeIdByHash = graph.vectorIndexState?.hashToNodeId || {};
const allowedIds = new Set(candidateNodes.map((node) => node.id));
- return hashes
+ const results = hashes
.map((hash, index) => ({
nodeId: nodeIdByHash[hash],
score: Math.max(0.01, 1 - index / Math.max(1, hashes.length)),
}))
.filter((entry) => entry.nodeId && allowedIds.has(entry.nodeId))
.slice(0, topK);
+ recordSearchTimings({
+ success: true,
+ reason: "ok",
+ requestMs: roundMs(requestMs),
+ parseMs: roundMs(parseMs),
+ resultCount: results.length,
+ hashCount: hashes.length,
+ });
+ return results;
} catch (error) {
if (isAbortError(error)) {
+ recordSearchTimings({
+ success: false,
+ reason: "aborted",
+ error: error?.message || String(error),
+ });
throw error;
}
const message = error?.message || String(error) || "后端向量查询失败";
@@ -821,6 +994,11 @@ export async function findSimilarNodesByText(
"backend-query-failed",
`后端向量查询失败(${message}),已标记待重建`,
);
+ recordSearchTimings({
+ success: false,
+ reason: "backend-query-exception",
+ error: message,
+ });
throw error;
}
}
From ad92f92afc22cad7ea3f4718880b7f6cfe97957b Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Tue, 21 Apr 2026 12:32:41 +0000
Subject: [PATCH 08/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 0a5a5ab..7398962 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.4.8",
+ "version": "5.4.9",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From b8491176469e75b867826ef711a5ea7744d7462f Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 17:11:59 +0800
Subject: [PATCH 09/74] Add persistence load and commit attribution diagnostics
---
index.js | 105 ++++++++++++++++++++++++++++++--
sync/bme-db.js | 36 +++++++++++
sync/bme-opfs-store.js | 70 +++++++++++++++++++++
tests/index-esm-entry-smoke.mjs | 1 +
ui/panel.js | 103 +++++++++++++++++++++++++++++++
5 files changed, 311 insertions(+), 4 deletions(-)
diff --git a/index.js b/index.js
index 7ab16e8..254685b 100644
--- a/index.js
+++ b/index.js
@@ -1666,6 +1666,10 @@ function normalizeLoadDiagnosticsMs(value = 0) {
return Math.round((Number(value) || 0) * 10) / 10;
}
+function normalizePersistDeltaDiagnosticsMs(value = 0) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
function updatePersistDeltaDiagnostics(snapshot = null) {
const nextSnapshot =
snapshot && typeof snapshot === "object" && !Array.isArray(snapshot)
@@ -9418,6 +9422,7 @@ async function loadGraphFromIndexedDb(
totalMs: normalizeLoadDiagnosticsMs(readLoadDiagnosticsNow() - loadStartedAt),
});
let exportSnapshotMs = 0;
+ let preApplyMs = 0;
let exportSnapshotSource = "";
if (!normalizedChatId) {
const result = {
@@ -9801,6 +9806,7 @@ async function loadGraphFromIndexedDb(
};
}
+ preApplyMs = readLoadDiagnosticsNow() - loadStartedAt;
const applyInvokeStartedAt = readLoadDiagnosticsNow();
const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, {
source,
@@ -9811,6 +9817,8 @@ async function loadGraphFromIndexedDb(
reasonPrefix: snapshotStore.reasonPrefix,
});
const applyInvokeMs = readLoadDiagnosticsNow() - applyInvokeStartedAt;
+ const totalLoadMs = readLoadDiagnosticsNow() - loadStartedAt;
+ const loadAccountedMs = preApplyMs + applyInvokeMs;
if (commitMarkerDiagnostic?.reason && loadResult?.loaded) {
updateGraphPersistenceState({
persistMismatchReason: commitMarkerDiagnostic.reason,
@@ -9828,7 +9836,14 @@ async function loadGraphFromIndexedDb(
commitMarkerMismatched: commitMarkerMismatch.mismatched === true,
exportSnapshotSource: exportSnapshotSource || "snapshot-prepared",
exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
+ preApplyMs: normalizeLoadDiagnosticsMs(preApplyMs),
+ preApplyOtherMs: normalizeLoadDiagnosticsMs(
+ Math.max(0, preApplyMs - exportSnapshotMs),
+ ),
applyInvokeMs: normalizeLoadDiagnosticsMs(applyInvokeMs),
+ untrackedMs: normalizeLoadDiagnosticsMs(
+ Math.max(0, totalLoadMs - loadAccountedMs),
+ ),
});
return loadResult;
} catch (error) {
@@ -9862,6 +9877,16 @@ async function loadGraphFromIndexedDb(
error: error?.message || String(error),
exportSnapshotSource: exportSnapshotSource || "unknown",
exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
+ preApplyMs: normalizeLoadDiagnosticsMs(
+ preApplyMs || (readLoadDiagnosticsNow() - loadStartedAt),
+ ),
+ preApplyOtherMs: normalizeLoadDiagnosticsMs(
+ Math.max(
+ 0,
+ (preApplyMs || (readLoadDiagnosticsNow() - loadStartedAt)) -
+ exportSnapshotMs,
+ ),
+ ),
});
return result;
}
@@ -13363,12 +13388,19 @@ async function saveGraphToIndexedDb(
let nativePersistPreloadStatus = "not-requested";
let nativePersistPreloadError = "";
let nativePersistPreloadMs = 0;
+ let baseSnapshotReadMs = 0;
+ let graphSnapshotBuildMs = 0;
const persistDeltaStartedAt = readPersistDeltaDiagnosticsNow();
if (!delta) {
- baseSnapshot =
- readCachedIndexedDbSnapshot(normalizedChatId, localStore) ||
- (await db.exportSnapshot());
+ const baseSnapshotReadStartedAt = readPersistDeltaDiagnosticsNow();
+ baseSnapshot = readCachedIndexedDbSnapshot(normalizedChatId, localStore);
+ if (!baseSnapshot) {
+ baseSnapshot = await db.exportSnapshot();
+ }
+ baseSnapshotReadMs =
+ readPersistDeltaDiagnosticsNow() - baseSnapshotReadStartedAt;
+ const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow();
snapshot = buildSnapshotFromGraph(graph, {
chatId: normalizedChatId,
revision: requestedRevision,
@@ -13383,6 +13415,8 @@ async function saveGraphToIndexedDb(
hostChatId: currentIdentity.hostChatId || "",
},
});
+ graphSnapshotBuildMs =
+ readPersistDeltaDiagnosticsNow() - graphSnapshotBuildStartedAt;
}
const nativePersistBridgeMode = String(
currentSettings.persistNativeDeltaBridgeMode || "json",
@@ -13555,6 +13589,12 @@ async function saveGraphToIndexedDb(
requestedRevision,
markSyncDirty: true,
});
+ const commitDiagnostics =
+ commitResult?.diagnostics &&
+ typeof commitResult.diagnostics === "object" &&
+ !Array.isArray(commitResult.diagnostics)
+ ? cloneRuntimeDebugValue(commitResult.diagnostics, {})
+ : null;
const committedRevision = normalizeIndexedDbRevision(
commitResult?.revision,
requestedRevision,
@@ -13564,6 +13604,7 @@ async function saveGraphToIndexedDb(
let scheduleUploadWarning = "";
if (graph) {
if (!snapshot) {
+ const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow();
snapshot = buildSnapshotFromGraph(graph, {
chatId: normalizedChatId,
revision: committedRevision,
@@ -13578,6 +13619,8 @@ async function saveGraphToIndexedDb(
hostChatId: currentIdentity.hostChatId || "",
},
});
+ graphSnapshotBuildMs +=
+ readPersistDeltaDiagnosticsNow() - graphSnapshotBuildStartedAt;
}
if (!snapshot.meta || typeof snapshot.meta !== "object" || Array.isArray(snapshot.meta)) {
snapshot.meta = {};
@@ -13620,6 +13663,14 @@ async function saveGraphToIndexedDb(
}
}
+ const persistTotalMs = readPersistDeltaDiagnosticsNow() - persistDeltaStartedAt;
+ const persistAccountedMs =
+ Number(nativePersistPreloadMs || 0) +
+ Number(baseSnapshotReadMs || 0) +
+ Number(graphSnapshotBuildMs || 0) +
+ Number(persistDeltaBuildDiagnostics?.buildMs || 0) +
+ Number(commitDiagnostics?.queueWaitMs || 0) +
+ Number(commitDiagnostics?.commitMs || 0);
const persistDeltaDiagnostics = {
...cloneRuntimeDebugValue(persistDeltaBuildDiagnostics, {}),
chatId: normalizedChatId,
@@ -13669,13 +13720,59 @@ async function saveGraphToIndexedDb(
moduleError: String(
nativePersistModuleStatus?.error || nativePersistPreloadError || "",
),
+ baseSnapshotReadMs: normalizePersistDeltaDiagnosticsMs(baseSnapshotReadMs),
+ snapshotBuildMs: normalizePersistDeltaDiagnosticsMs(graphSnapshotBuildMs),
+ commitStorageKind: String(
+ commitDiagnostics?.storageKind || localStore.storagePrimary || "",
+ ),
+ commitStoreMode: String(
+ commitDiagnostics?.storeMode || localStore.storageMode || "",
+ ),
+ commitQueueWaitMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.queueWaitMs,
+ ),
+ commitMs: normalizePersistDeltaDiagnosticsMs(commitDiagnostics?.commitMs),
+ commitTxMs: normalizePersistDeltaDiagnosticsMs(commitDiagnostics?.txMs),
+ commitSnapshotReadMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.snapshotReadMs,
+ ),
+ commitSnapshotWriteMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.snapshotWriteMs,
+ ),
+ commitManifestReadMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.manifestReadMs,
+ ),
+ commitWalWriteMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.walWriteMs,
+ ),
+ commitManifestWriteMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.manifestWriteMs,
+ ),
+ commitCacheApplyMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.cacheApplyMs,
+ ),
+ commitPayloadBytes: Math.max(
+ 0,
+ Math.floor(Number(commitDiagnostics?.payloadBytes || 0)),
+ ),
+ commitWalBytes: Math.max(
+ 0,
+ Math.floor(Number(commitDiagnostics?.walBytes || 0)),
+ ),
+ commitRuntimeMetaKeyCount: Math.max(
+ 0,
+ Math.floor(Number(commitDiagnostics?.runtimeMetaKeyCount || 0)),
+ ),
status: "committed",
commitRevision: normalizeIndexedDbRevision(
commitResult?.revision,
requestedRevision,
),
commitDelta: cloneRuntimeDebugValue(commitResult?.delta, null),
- totalMs: readPersistDeltaDiagnosticsNow() - persistDeltaStartedAt,
+ totalMs: normalizePersistDeltaDiagnosticsMs(persistTotalMs),
+ untrackedMs: normalizePersistDeltaDiagnosticsMs(
+ Math.max(0, persistTotalMs - persistAccountedMs),
+ ),
};
persistDeltaDiagnostics.fallbackReason =
persistDeltaDiagnostics.requestedNative && !persistDeltaDiagnostics.usedNative
diff --git a/sync/bme-db.js b/sync/bme-db.js
index b4a7337..8c75c83 100644
--- a/sync/bme-db.js
+++ b/sync/bme-db.js
@@ -109,6 +109,26 @@ function normalizeNonNegativeInteger(value, fallback = 0) {
return Math.max(0, Math.floor(parsed));
}
+function readPersistCommitNow() {
+ if (typeof performance === "object" && typeof performance.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+}
+
+function normalizePersistCommitMs(value = 0) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
+function estimatePersistPayloadBytes(value = null) {
+ if (value == null) return 0;
+ try {
+ return JSON.stringify(value).length;
+ } catch {
+ return 0;
+ }
+}
+
function toPlainData(value, fallbackValue = null) {
if (value == null) {
return fallbackValue;
@@ -2530,6 +2550,7 @@ export class BmeDatabase {
async commitDelta(delta = {}, options = {}) {
const db = await this.open();
+ const commitRequestedAt = readPersistCommitNow();
const nowMs = Date.now();
const normalizedDelta =
delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
@@ -2554,6 +2575,7 @@ export class BmeDatabase {
const reason = String(options.reason || "commitDelta");
const requestedRevision = normalizeRevision(options.requestedRevision);
const shouldMarkSyncDirty = options.markSyncDirty !== false;
+ const payloadBytes = estimatePersistPayloadBytes(normalizedDelta);
const normalizedCountDelta =
normalizedDelta.countDelta &&
typeof normalizedDelta.countDelta === "object" &&
@@ -2567,7 +2589,9 @@ export class BmeDatabase {
edges: 0,
tombstones: 0,
};
+ let transactionMs = 0;
+ const transactionStartedAt = readPersistCommitNow();
await db.transaction(
"rw",
db.table("nodes"),
@@ -2614,6 +2638,7 @@ export class BmeDatabase {
);
},
);
+ transactionMs = readPersistCommitNow() - transactionStartedAt;
return {
revision: nextRevision,
@@ -2630,6 +2655,17 @@ export class BmeDatabase {
deleteEdgeIds: deleteEdgeIds.length,
tombstones: tombstones.length,
},
+ diagnostics: {
+ storageKind: "indexeddb",
+ storeMode: "indexeddb",
+ queueWaitMs: 0,
+ commitMs: normalizePersistCommitMs(
+ readPersistCommitNow() - commitRequestedAt,
+ ),
+ txMs: normalizePersistCommitMs(transactionMs),
+ payloadBytes,
+ runtimeMetaKeyCount: Object.keys(runtimeMetaPatch).length,
+ },
};
}
diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js
index 68eac7c..9aad9d3 100644
--- a/sync/bme-opfs-store.js
+++ b/sync/bme-opfs-store.js
@@ -112,6 +112,26 @@ function normalizeNonNegativeInteger(value, fallback = 0) {
return Math.floor(parsed);
}
+function readPersistCommitNow() {
+ if (typeof performance === "object" && typeof performance.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+}
+
+function normalizePersistCommitMs(value = 0) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
+function estimatePersistPayloadBytes(value = null) {
+ if (value == null) return 0;
+ try {
+ return JSON.stringify(value).length;
+ } catch {
+ return 0;
+ }
+}
+
function deriveNodeSourceFloor(node = {}) {
const directSourceFloor = normalizeSourceFloor(node?.sourceFloor);
if (directSourceFloor != null) return directSourceFloor;
@@ -970,13 +990,19 @@ class LegacyOpfsGraphStore {
}
async commitDelta(delta = {}, options = {}) {
+ const commitRequestedAt = readPersistCommitNow();
return await this._runSerializedWrite(
String(options?.reason || "commitDelta"),
async () => {
+ const commitStartedAt = readPersistCommitNow();
+ const queueWaitMs = commitStartedAt - commitRequestedAt;
const nowMs = Date.now();
const normalizedDelta =
delta && typeof delta === "object" && !Array.isArray(delta) ? delta : {};
+ const payloadBytes = estimatePersistPayloadBytes(normalizedDelta);
+ const snapshotReadStartedAt = readPersistCommitNow();
const currentSnapshot = await this._loadSnapshot({ awaitWrites: false });
+ const snapshotReadMs = readPersistCommitNow() - snapshotReadStartedAt;
const nodeMap = new Map();
const edgeMap = new Map();
const tombstoneMap = new Map();
@@ -1093,7 +1119,9 @@ class LegacyOpfsGraphStore {
edges: Array.from(edgeMap.values()),
tombstones: Array.from(tombstoneMap.values()),
};
+ const snapshotWriteStartedAt = readPersistCommitNow();
await this._writeResolvedSnapshot(nextSnapshot);
+ const snapshotWriteMs = readPersistCommitNow() - snapshotWriteStartedAt;
return {
revision: nextRevision,
@@ -1110,6 +1138,18 @@ class LegacyOpfsGraphStore {
deleteEdgeIds: deleteEdgeIds.length,
tombstones: tombstones.length,
},
+ diagnostics: {
+ storageKind: OPFS_STORE_KIND,
+ storeMode: this.storeMode,
+ queueWaitMs: normalizePersistCommitMs(queueWaitMs),
+ commitMs: normalizePersistCommitMs(
+ readPersistCommitNow() - commitStartedAt,
+ ),
+ snapshotReadMs: normalizePersistCommitMs(snapshotReadMs),
+ snapshotWriteMs: normalizePersistCommitMs(snapshotWriteMs),
+ payloadBytes,
+ runtimeMetaKeyCount: Object.keys(runtimeMetaPatch).length,
+ },
};
},
);
@@ -2326,12 +2366,18 @@ export class OpfsGraphStore {
}
async commitDelta(delta = {}, options = {}) {
+ const commitRequestedAt = readPersistCommitNow();
return await this._runSerializedWrite(
String(options?.reason || "commitDelta"),
async () => {
+ const commitStartedAt = readPersistCommitNow();
+ const queueWaitMs = commitStartedAt - commitRequestedAt;
+ const manifestReadStartedAt = readPersistCommitNow();
const manifest = await this._ensureV2Ready({ awaitWrites: false });
+ const manifestReadMs = readPersistCommitNow() - manifestReadStartedAt;
const nowMs = Date.now();
const normalizedDelta = sanitizeOpfsV2Delta(delta, nowMs);
+ const payloadBytes = estimatePersistPayloadBytes(normalizedDelta);
const requestedRevision = normalizeRevision(options.requestedRevision);
const shouldMarkSyncDirty = options.markSyncDirty !== false;
const reason = String(options.reason || "commitDelta");
@@ -2370,10 +2416,12 @@ export class OpfsGraphStore {
runtimeMetaPatch: normalizedDelta.runtimeMetaPatch,
countDelta: nextCountDelta,
};
+ const walWriteStartedAt = readPersistCommitNow();
const walDirectory = await this._getWalDirectory();
const walFilename = buildOpfsV2WalFilename(nextRevision);
await writeJsonFile(walDirectory, walFilename, walRecord);
const walByteLength = JSON.stringify(walRecord).length;
+ const walWriteMs = readPersistCommitNow() - walWriteStartedAt;
const hadPendingWal =
normalizeRevision(manifest?.pendingLogFromRevision) <= currentHeadRevision;
@@ -2400,9 +2448,13 @@ export class OpfsGraphStore {
lastReason: reason,
},
};
+ const manifestWriteStartedAt = readPersistCommitNow();
await this._writeManifest(nextManifest);
+ const manifestWriteMs = readPersistCommitNow() - manifestWriteStartedAt;
+ let cacheApplyMs = 0;
if (this._snapshotCache) {
+ const cacheApplyStartedAt = readPersistCommitNow();
const nextSnapshot = applyOpfsV2DeltaToSnapshot(
this._snapshotCache,
normalizedDelta,
@@ -2414,6 +2466,7 @@ export class OpfsGraphStore {
};
nextSnapshot.state = normalizeSnapshotState(nextSnapshot);
this._snapshotCache = nextSnapshot;
+ cacheApplyMs = readPersistCommitNow() - cacheApplyStartedAt;
}
this._maybeScheduleCompaction(nextManifest, reason);
@@ -2433,6 +2486,23 @@ export class OpfsGraphStore {
deleteEdgeIds: normalizedDelta.deleteEdgeIds.length,
tombstones: normalizedDelta.tombstones.length,
},
+ diagnostics: {
+ storageKind: OPFS_STORE_KIND,
+ storeMode: this.storeMode,
+ queueWaitMs: normalizePersistCommitMs(queueWaitMs),
+ commitMs: normalizePersistCommitMs(
+ readPersistCommitNow() - commitStartedAt,
+ ),
+ manifestReadMs: normalizePersistCommitMs(manifestReadMs),
+ walWriteMs: normalizePersistCommitMs(walWriteMs),
+ manifestWriteMs: normalizePersistCommitMs(manifestWriteMs),
+ cacheApplyMs: normalizePersistCommitMs(cacheApplyMs),
+ payloadBytes,
+ walBytes: walByteLength,
+ runtimeMetaKeyCount: Object.keys(
+ normalizedDelta.runtimeMetaPatch || {},
+ ).length,
+ },
};
},
);
diff --git a/tests/index-esm-entry-smoke.mjs b/tests/index-esm-entry-smoke.mjs
index 739ec37..7799358 100644
--- a/tests/index-esm-entry-smoke.mjs
+++ b/tests/index-esm-entry-smoke.mjs
@@ -123,6 +123,7 @@ function evaluatePersistNativeDeltaGate() {
};
}
function readPersistDeltaDiagnosticsNow() { return Date.now(); }
+function normalizePersistDeltaDiagnosticsMs(value = 0) { return Math.round((Number(value) || 0) * 10) / 10; }
function updatePersistDeltaDiagnostics() {}
function buildPersistDelta() {
return {
diff --git a/ui/panel.js b/ui/panel.js
index 67b4622..c080d21 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -1532,13 +1532,64 @@ function _formatPersistencePersistDeltaSummary(persistDelta = null) {
const pathText = String(diagnostics.path || "").trim() || "—";
const totalText = _formatDurationMs(diagnostics.totalMs || diagnostics.buildMs);
+ const commitText = _formatDurationMs(diagnostics.commitMs);
const gateText = String(_formatPersistDeltaGateText(diagnostics) || "").trim();
const parts = [pathText];
if (totalText !== "—") parts.push(totalText);
+ if (commitText !== "—") parts.push(`commit ${commitText}`);
if (gateText) parts.push(`native ${gateText}`);
return parts.join(" · ");
}
+function _formatPersistCommitPhaseText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const queueText = _formatDurationMs(snapshot.commitQueueWaitMs);
+ const commitText = _formatDurationMs(snapshot.commitMs);
+ if (queueText === "—" && commitText === "—") return "—";
+ return `${queueText} / ${commitText}`;
+}
+
+function _formatPersistCommitBreakdownText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const parts = [
+ snapshot.commitTxMs ? `tx ${_formatDurationMs(snapshot.commitTxMs)}` : "",
+ snapshot.commitSnapshotReadMs
+ ? `snapshot-read ${_formatDurationMs(snapshot.commitSnapshotReadMs)}`
+ : "",
+ snapshot.commitSnapshotWriteMs
+ ? `snapshot-write ${_formatDurationMs(snapshot.commitSnapshotWriteMs)}`
+ : "",
+ snapshot.commitManifestReadMs
+ ? `manifest-read ${_formatDurationMs(snapshot.commitManifestReadMs)}`
+ : "",
+ snapshot.commitWalWriteMs
+ ? `wal ${_formatDurationMs(snapshot.commitWalWriteMs)}`
+ : "",
+ snapshot.commitManifestWriteMs
+ ? `manifest-write ${_formatDurationMs(snapshot.commitManifestWriteMs)}`
+ : "",
+ snapshot.commitCacheApplyMs
+ ? `cache ${_formatDurationMs(snapshot.commitCacheApplyMs)}`
+ : "",
+ ].filter(Boolean);
+ return parts.join(" · ") || "—";
+}
+
+function _formatPersistCommitBytesText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const parts = [];
+ const payloadText = _formatDataSizeBytes(snapshot.commitPayloadBytes);
+ const walText = _formatDataSizeBytes(snapshot.commitWalBytes);
+ const metaKeyCount = Number(snapshot.commitRuntimeMetaKeyCount || 0);
+ if (payloadText !== "—") parts.push(`payload ${payloadText}`);
+ if (walText !== "—") parts.push(`wal ${walText}`);
+ if (metaKeyCount > 0) parts.push(`meta ${metaKeyCount} keys`);
+ return parts.join(" · ") || "—";
+}
+
function _buildLoadDiagnosticRows(loadDiagnostics = null) {
const diagnostics = _readPersistenceDiagnosticObject(loadDiagnostics);
if (!diagnostics) {
@@ -1561,10 +1612,13 @@ function _buildLoadDiagnosticRows(loadDiagnostics = null) {
["Load 状态", statusText],
["Load 原因", String(diagnostics.reason || "—")],
["Load 总耗时", _formatDurationMs(diagnostics.totalMs)],
+ ["Load 前置", _formatDurationMs(diagnostics.preApplyMs)],
["导出快照", _formatDurationMs(diagnostics.exportSnapshotMs)],
+ ["前置(除导出)", _formatDurationMs(diagnostics.preApplyOtherMs)],
["Hydrate", _formatDurationMs(diagnostics.hydrateMs)],
["Apply 调用", _formatDurationMs(diagnostics.applyInvokeMs)],
["Apply 运行", _formatDurationMs(diagnostics.applyRuntimeMs)],
+ ["Load 未归因", _formatDurationMs(diagnostics.untrackedMs)],
["Load 更新时间", updatedAtText],
];
}
@@ -1586,6 +1640,12 @@ function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
)}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(
diagnostics.deleteEdgeCount || 0,
)}DE`;
+ const commitStoreText = `${String(diagnostics.commitStorageKind || "—")} / ${String(
+ diagnostics.commitStoreMode || "—",
+ )}`;
+ const commitPhaseText = _formatPersistCommitPhaseText(diagnostics);
+ const commitBreakdownText = _formatPersistCommitBreakdownText(diagnostics);
+ const commitBytesText = _formatPersistCommitBytesText(diagnostics);
const updatedAtText = diagnostics.updatedAt
? _formatTaskProfileTime(diagnostics.updatedAt)
: "—";
@@ -1594,8 +1654,11 @@ function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
["Persist 路径", String(diagnostics.path || "—")],
["Native Gate", _formatPersistDeltaGateText(diagnostics)],
["Bridge 模式", bridgeText],
+ ["Commit 存储", commitStoreText],
["Persist 总耗时", _formatDurationMs(diagnostics.totalMs || diagnostics.buildMs)],
["构建耗时", _formatDurationMs(diagnostics.buildMs)],
+ ["Base 快照读取", _formatDurationMs(diagnostics.baseSnapshotReadMs)],
+ ["图谱快照构建", _formatDurationMs(diagnostics.snapshotBuildMs)],
[
"Prepare / Native",
`${_formatDurationMs(diagnostics.prepareMs)} / ${_formatDurationMs(diagnostics.nativeAttemptMs)}`,
@@ -1605,11 +1668,15 @@ function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
`${_formatDurationMs(diagnostics.lookupMs)} / ${_formatDurationMs(diagnostics.jsDiffMs)}`,
],
["Hydrate", _formatDurationMs(diagnostics.hydrateMs)],
+ ["Commit 排队 / 提交", commitPhaseText],
+ ["Commit 细分", commitBreakdownText],
+ ["Commit Payload", commitBytesText],
["Preload", String(diagnostics.preloadStatus || "—")],
["Native 来源", String(diagnostics.moduleSource || "—")],
["Fallback 原因", String(diagnostics.fallbackReason || "—")],
["Preload / Native 错误", errorText || "—"],
["增量规模", deltaSizeText],
+ ["Persist 未归因", _formatDurationMs(diagnostics.untrackedMs)],
["Persist 更新时间", updatedAtText],
];
}
@@ -8293,6 +8360,16 @@ function _formatDurationMs(durationMs) {
return `${(normalized / 1000).toFixed(normalized >= 10000 ? 0 : 1)}s`;
}
+function _formatDataSizeBytes(byteCount) {
+ const normalized = Number(byteCount);
+ if (!Number.isFinite(normalized) || normalized <= 0) return "—";
+ if (normalized < 1024) return `${Math.round(normalized)} B`;
+ if (normalized < 1024 * 1024) {
+ return `${(normalized / 1024).toFixed(normalized >= 10 * 1024 ? 0 : 1)} KB`;
+ }
+ return `${(normalized / (1024 * 1024)).toFixed(normalized >= 10 * 1024 * 1024 ? 0 : 1)} MB`;
+}
+
function _getMonitorTaskTypeLabel(taskType = "") {
const normalized = String(taskType || "").trim().toLowerCase();
const labels = {
@@ -8767,6 +8844,12 @@ function _renderPersistDeltaTraceCard(state) {
const payloadCharsText = diagnostics.combinedSerializedChars
? `${Number(diagnostics.combinedSerializedChars || 0)} / ${Number(diagnostics.minCombinedSerializedChars || 0)}`
: "—";
+ const snapshotBuildText = `${_formatDurationMs(diagnostics.baseSnapshotReadMs)} / ${_formatDurationMs(
+ diagnostics.snapshotBuildMs,
+ )}`;
+ const commitPhaseText = _formatPersistCommitPhaseText(diagnostics);
+ const commitBreakdownText = _formatPersistCommitBreakdownText(diagnostics);
+ const commitBytesText = _formatPersistCommitBytesText(diagnostics);
const cacheText = `${Number(diagnostics.serializationCacheHits || 0)}H / ${Number(
diagnostics.serializationCacheMisses || 0,
)}M`;
@@ -8819,6 +8902,10 @@ function _renderPersistDeltaTraceCard(state) {
构建耗时
${_escHtml(_formatDurationMs(diagnostics.buildMs))}
+
+ Base / Snapshot
+ ${_escHtml(snapshotBuildText)}
+
Prepare / Native
${_escHtml(
@@ -8837,6 +8924,18 @@ function _renderPersistDeltaTraceCard(state) {
`${_formatDurationMs(diagnostics.hydrateMs)} / ${cacheText}`,
)}
+
+ Commit 排队 / 提交
+ ${_escHtml(commitPhaseText)}
+
+
+ Commit 细分
+ ${_escHtml(commitBreakdownText)}
+
+
+ Commit Payload
+ ${_escHtml(commitBytesText)}
+
PreparedSet Cache
${_escHtml(preparedSetCacheText)}
@@ -8855,6 +8954,10 @@ function _renderPersistDeltaTraceCard(state) {
`${Number(diagnostics.upsertNodeCount || 0)}N / ${Number(diagnostics.upsertEdgeCount || 0)}E / ${Number(diagnostics.deleteNodeCount || 0)}DN / ${Number(diagnostics.deleteEdgeCount || 0)}DE`,
)}
+
+ 未归因
+ ${_escHtml(_formatDurationMs(diagnostics.untrackedMs))}
+
${_renderMessageTraceTextBlock(
"Fallback reason",
From 9bad9d6ed2ccc84b5a05ea14d6500b8fafde4076 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 09:12:23 +0000
Subject: [PATCH 10/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 7398962..f9c811a 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.4.9",
+ "version": "5.5.0",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From b1937336bd7e1db9109e6ea58652312cb5ccb8db Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 17:16:31 +0800
Subject: [PATCH 11/74] Make persistence diagnostics detail two-column
---
style.css | 16 ++++++++++++++++
ui/panel.js | 25 ++++++++++++++++++-------
2 files changed, 34 insertions(+), 7 deletions(-)
diff --git a/style.css b/style.css
index 72f6042..32244f0 100644
--- a/style.css
+++ b/style.css
@@ -1689,6 +1689,16 @@
.bme-persist-kv__row span { color: var(--bme-on-surface-dim); }
.bme-persist-kv__row strong { color: var(--bme-on-surface); font-weight: 600; }
+.bme-persist-kv-columns {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
+ gap: 12px;
+}
+
+.bme-persist-kv-column {
+ min-width: 0;
+}
+
.bme-persist-guide {
margin-top: 4px;
padding: 14px 16px;
@@ -1728,6 +1738,12 @@
color: var(--bme-on-surface-dim);
}
+@media (max-width: 900px) {
+ .bme-persist-kv-columns {
+ grid-template-columns: minmax(0, 1fr);
+ }
+}
+
.bme-persist-actions {
display: flex;
gap: 8px;
diff --git a/ui/panel.js b/ui/panel.js
index c080d21..7aa4b29 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -2530,14 +2530,25 @@ function _refreshTaskPersistence() {
`主存储 · ${primaryTierLabel}`,
`确认 · ${acceptedSummaryLabel}`,
];
+ const collectVisibleRows = (rows = []) =>
+ rows.filter(([, value]) => value !== null && value !== undefined && value !== "");
+ const renderRow = ([key, value]) =>
+ `${_escHtml(String(key))}${_escHtml(String(value))}
`;
const renderRows = (rows = []) =>
- rows
- .filter(([, value]) => value !== null && value !== undefined && value !== "")
- .map(
- ([key, value]) =>
- `${_escHtml(String(key))}${_escHtml(String(value))}
`,
- )
+ collectVisibleRows(rows)
+ .map(renderRow)
.join("");
+ const renderRowsTwoColumn = (rows = []) => {
+ const visibleRows = collectVisibleRows(rows);
+ if (!visibleRows.length) return "";
+ const splitIndex = Math.ceil(visibleRows.length / 2);
+ return `
+
+
${visibleRows.slice(0, splitIndex).map(renderRow).join("")}
+
${visibleRows.slice(splitIndex).map(renderRow).join("")}
+
+ `;
+ };
const primaryRows = [
["当前状态", acceptedSummaryLabel],
@@ -2616,7 +2627,7 @@ function _refreshTaskPersistence() {
查看诊断细节
- ${renderRows(diagnosticRows)}
+ ${renderRowsTwoColumn(diagnosticRows)}
From 427521fd00940fb29b494da04ecb1eaed4dc82f0 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 09:16:55 +0000
Subject: [PATCH 12/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index f9c811a..99f3678 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.5.0",
+ "version": "5.5.1",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From cfc122244a11c88219153cf6f4452f31aee92aa3 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 18:34:56 +0800
Subject: [PATCH 13/74] perf: optimize persist/load P1 hot paths
---
index.js | 407 ++++++++++++++++++++++++---
maintenance/extraction-controller.js | 22 +-
sync/bme-db.js | 181 +++++++++++-
sync/bme-opfs-store.js | 114 +++++++-
sync/bme-sync.js | 327 +++++++++++++++++++--
tests/graph-persistence.mjs | 160 +++++++++++
tests/indexeddb-persistence.mjs | 73 +++++
tests/indexeddb-sync.mjs | 19 ++
tests/opfs-meta-fast-path.mjs | 6 +
tests/opfs-write-serialization.mjs | 55 ++++
tests/perf/persist-load-bench.mjs | 326 +++++++++++++++++++++
ui/panel.js | 85 +++++-
ui/ui-status.js | 10 +
13 files changed, 1707 insertions(+), 78 deletions(-)
create mode 100644 tests/perf/persist-load-bench.mjs
diff --git a/index.js b/index.js
index 254685b..569a94c 100644
--- a/index.js
+++ b/index.js
@@ -9220,10 +9220,19 @@ function applyIndexedDbSnapshotToRuntime(
return result;
}
let graphFromSnapshot = null;
+ let hydrateDiagnostics = null;
try {
const hydrateStartedAt = readLoadDiagnosticsNow();
graphFromSnapshot = buildGraphFromSnapshot(snapshot, {
chatId: normalizedChatId,
+ onDiagnostics(snapshotValue) {
+ hydrateDiagnostics =
+ snapshotValue &&
+ typeof snapshotValue === "object" &&
+ !Array.isArray(snapshotValue)
+ ? snapshotValue
+ : null;
+ },
});
hydrateMs = readLoadDiagnosticsNow() - hydrateStartedAt;
} catch (error) {
@@ -9275,6 +9284,18 @@ function applyIndexedDbSnapshotToRuntime(
reason: failureReason,
revision,
hydrateMs: normalizeLoadDiagnosticsMs(hydrateMs),
+ hydrateNodesMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.nodesMs),
+ hydrateEdgesMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.edgesMs),
+ hydrateRuntimeMetaMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.runtimeMetaMs,
+ ),
+ hydrateStateMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.stateMs),
+ hydrateNormalizeMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.normalizeMs,
+ ),
+ hydrateIntegrityMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.integrityMs,
+ ),
error: error?.message || String(error),
integrityReasons: Array.isArray(error?.reasons) ? [...error.reasons] : [],
});
@@ -9391,6 +9412,18 @@ function applyIndexedDbSnapshotToRuntime(
reason: result.reason,
revision,
hydrateMs: normalizeLoadDiagnosticsMs(hydrateMs),
+ hydrateNodesMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.nodesMs),
+ hydrateEdgesMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.edgesMs),
+ hydrateRuntimeMetaMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.runtimeMetaMs,
+ ),
+ hydrateStateMs: normalizeLoadDiagnosticsMs(hydrateDiagnostics?.stateMs),
+ hydrateNormalizeMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.normalizeMs,
+ ),
+ hydrateIntegrityMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.integrityMs,
+ ),
applyRuntimeMs: normalizeLoadDiagnosticsMs(
readLoadDiagnosticsNow() - applyRuntimeStartedAt,
),
@@ -9422,6 +9455,7 @@ async function loadGraphFromIndexedDb(
totalMs: normalizeLoadDiagnosticsMs(readLoadDiagnosticsNow() - loadStartedAt),
});
let exportSnapshotMs = 0;
+ let exportProbeMs = 0;
let preApplyMs = 0;
let exportSnapshotSource = "";
if (!normalizedChatId) {
@@ -9582,34 +9616,49 @@ async function loadGraphFromIndexedDb(
});
}
let snapshot = null;
+ let inspectionSnapshot = null;
if (identityRecoveryResult?.snapshot) {
snapshot = identityRecoveryResult.snapshot;
+ inspectionSnapshot = snapshot;
exportSnapshotSource = "identity-recovery";
} else if (localStoreMigrationResult?.snapshot) {
snapshot = localStoreMigrationResult.snapshot;
+ inspectionSnapshot = snapshot;
exportSnapshotSource = "local-store-migration";
} else if (migrationResult?.snapshot) {
snapshot = migrationResult.snapshot;
+ inspectionSnapshot = snapshot;
exportSnapshotSource = "legacy-migration";
} else {
- const exportStartedAt = readLoadDiagnosticsNow();
- snapshot = await db.exportSnapshot({ includeTombstones: false });
- exportSnapshotMs = readLoadDiagnosticsNow() - exportStartedAt;
- exportSnapshotSource = "indexeddb-export";
+ if (typeof db.exportSnapshotProbe === "function") {
+ const probeStartedAt = readLoadDiagnosticsNow();
+ inspectionSnapshot = await db.exportSnapshotProbe({ includeTombstones: false });
+ exportProbeMs = readLoadDiagnosticsNow() - probeStartedAt;
+ exportSnapshotSource = "indexeddb-probe";
+ }
+ if (!inspectionSnapshot) {
+ const exportStartedAt = readLoadDiagnosticsNow();
+ snapshot = await db.exportSnapshot({ includeTombstones: false });
+ exportSnapshotMs = readLoadDiagnosticsNow() - exportStartedAt;
+ inspectionSnapshot = snapshot;
+ exportSnapshotSource = "indexeddb-export";
+ }
}
const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(
resolveCurrentChatIdentity(getContext()),
);
- cacheIndexedDbSnapshot(normalizedChatId, snapshot);
- const snapshotStore = resolveSnapshotGraphStorePresentation(snapshot, localStore);
+ const snapshotStore = resolveSnapshotGraphStorePresentation(
+ inspectionSnapshot || snapshot,
+ localStore,
+ );
const commitMarkerMismatch = detectIndexedDbSnapshotCommitMarkerMismatch(
- snapshot,
+ inspectionSnapshot,
commitMarker,
);
let commitMarkerDiagnostic = null;
- if (!isIndexedDbSnapshotMeaningful(snapshot)) {
+ if (!isIndexedDbSnapshotMeaningful(inspectionSnapshot)) {
if (commitMarkerMismatch.mismatched) {
commitMarkerDiagnostic = recordPersistMismatchDiagnostic(
commitMarkerMismatch,
@@ -9709,9 +9758,9 @@ async function loadGraphFromIndexedDb(
}
const snapshotRevision = normalizeIndexedDbRevision(
- snapshot?.meta?.revision,
+ inspectionSnapshot?.meta?.revision,
);
- const snapshotIntegrity = String(snapshot?.meta?.integrity || "").trim();
+ const snapshotIntegrity = String(inspectionSnapshot?.meta?.integrity || "").trim();
const shadowDecision = shouldPreferShadowSnapshotOverOfficial(
createShadowComparisonGraph({
chatId: normalizedChatId,
@@ -9806,6 +9855,68 @@ async function loadGraphFromIndexedDb(
};
}
+ const staleDecision = detectStaleIndexedDbSnapshotAgainstRuntime(
+ normalizedChatId,
+ inspectionSnapshot,
+ );
+ if (staleDecision.stale) {
+ const result = {
+ success: false,
+ loaded: false,
+ reason: `${snapshotStore.reasonPrefix}-stale-runtime`,
+ chatId: normalizedChatId,
+ attemptIndex,
+ revision: snapshotRevision,
+ staleDetail: cloneRuntimeDebugValue(staleDecision, null),
+ };
+ updateGraphPersistenceState({
+ storagePrimary: snapshotStore.storagePrimary,
+ storageMode: snapshotStore.storageMode,
+ indexedDbLastError: "",
+ dualWriteLastResult: {
+ action: "load",
+ source: String(source || snapshotStore.reasonPrefix),
+ success: false,
+ rejected: true,
+ reason: result.reason,
+ revision: snapshotRevision,
+ staleDetail: cloneRuntimeDebugValue(staleDecision, null),
+ at: Date.now(),
+ },
+ });
+ recordLoadDiagnostics({
+ success: false,
+ loaded: false,
+ reason: result.reason,
+ revision: snapshotRevision,
+ storagePrimary: snapshotStore.storagePrimary,
+ storageMode: snapshotStore.storageMode,
+ exportSnapshotSource: exportSnapshotSource || "snapshot-probe",
+ exportProbeMs: normalizeLoadDiagnosticsMs(exportProbeMs),
+ exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
+ preApplyMs: normalizeLoadDiagnosticsMs(readLoadDiagnosticsNow() - loadStartedAt),
+ preApplyOtherMs: normalizeLoadDiagnosticsMs(
+ Math.max(
+ 0,
+ readLoadDiagnosticsNow() - loadStartedAt - exportSnapshotMs - exportProbeMs,
+ ),
+ ),
+ staleDetail: cloneRuntimeDebugValue(staleDecision, null),
+ });
+ return result;
+ }
+
+ if (!snapshot) {
+ const exportStartedAt = readLoadDiagnosticsNow();
+ snapshot = await db.exportSnapshot({ includeTombstones: false });
+ exportSnapshotMs += readLoadDiagnosticsNow() - exportStartedAt;
+ exportSnapshotSource =
+ exportSnapshotSource === "indexeddb-probe"
+ ? "indexeddb-probe+indexeddb-export"
+ : exportSnapshotSource || "indexeddb-export";
+ }
+ cacheIndexedDbSnapshot(normalizedChatId, snapshot);
+
preApplyMs = readLoadDiagnosticsNow() - loadStartedAt;
const applyInvokeStartedAt = readLoadDiagnosticsNow();
const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, {
@@ -9835,10 +9946,11 @@ async function loadGraphFromIndexedDb(
storageMode: snapshotStore.storageMode,
commitMarkerMismatched: commitMarkerMismatch.mismatched === true,
exportSnapshotSource: exportSnapshotSource || "snapshot-prepared",
+ exportProbeMs: normalizeLoadDiagnosticsMs(exportProbeMs),
exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
preApplyMs: normalizeLoadDiagnosticsMs(preApplyMs),
preApplyOtherMs: normalizeLoadDiagnosticsMs(
- Math.max(0, preApplyMs - exportSnapshotMs),
+ Math.max(0, preApplyMs - exportSnapshotMs - exportProbeMs),
),
applyInvokeMs: normalizeLoadDiagnosticsMs(applyInvokeMs),
untrackedMs: normalizeLoadDiagnosticsMs(
@@ -9876,6 +9988,7 @@ async function loadGraphFromIndexedDb(
storageMode: localStore.storageMode,
error: error?.message || String(error),
exportSnapshotSource: exportSnapshotSource || "unknown",
+ exportProbeMs: normalizeLoadDiagnosticsMs(exportProbeMs),
exportSnapshotMs: normalizeLoadDiagnosticsMs(exportSnapshotMs),
preApplyMs: normalizeLoadDiagnosticsMs(
preApplyMs || (readLoadDiagnosticsNow() - loadStartedAt),
@@ -9884,7 +9997,8 @@ async function loadGraphFromIndexedDb(
Math.max(
0,
(preApplyMs || (readLoadDiagnosticsNow() - loadStartedAt)) -
- exportSnapshotMs,
+ exportSnapshotMs -
+ exportProbeMs,
),
),
});
@@ -10703,6 +10817,8 @@ async function persistGraphToConfiguredDurableTier(
reason,
lastProcessedAssistantFloor = null,
persistDelta = null,
+ graphSnapshot = null,
+ persistSnapshot = null,
chatStateTarget = null,
graphDetached = false,
} = {},
@@ -10796,6 +10912,8 @@ async function persistGraphToConfiguredDurableTier(
persistRole: "cache-mirror",
scheduleCloudUpload: false,
persistDelta,
+ graphSnapshot,
+ persistSnapshot,
graphDetached,
});
}
@@ -10826,6 +10944,8 @@ async function persistGraphToConfiguredDurableTier(
revision,
reason,
persistDelta,
+ graphSnapshot,
+ persistSnapshot,
});
if (indexedDbResult?.saved) {
persistGraphCommitMarker(context, {
@@ -11535,6 +11655,7 @@ async function persistExtractionBatchResult({
reason = "extraction-batch-complete",
lastProcessedAssistantFloor = null,
graphSnapshot = null,
+ persistSnapshot = null,
persistDelta = null,
} = {}) {
ensureCurrentGraphRuntimeState();
@@ -11584,6 +11705,8 @@ async function persistExtractionBatchResult({
reason,
lastProcessedAssistantFloor,
persistDelta,
+ graphSnapshot,
+ persistSnapshot,
graphDetached: persistGraphDetached,
},
);
@@ -13317,6 +13440,8 @@ async function saveGraphToIndexedDb(
persistRole = "primary",
scheduleCloudUpload: scheduleCloudUploadOption = undefined,
persistDelta = null,
+ graphSnapshot = null,
+ persistSnapshot = null,
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
@@ -13380,8 +13505,21 @@ async function saveGraphToIndexedDb(
!Array.isArray(persistDelta)
? cloneRuntimeDebugValue(persistDelta, persistDelta)
: null;
+ const detachedGraphSnapshot =
+ graphSnapshot &&
+ typeof graphSnapshot === "object" &&
+ !Array.isArray(graphSnapshot)
+ ? graphSnapshot
+ : null;
+ const prebuiltPersistSnapshot =
+ persistSnapshot &&
+ typeof persistSnapshot === "object" &&
+ !Array.isArray(persistSnapshot)
+ ? persistSnapshot
+ : null;
+ const persistGraphInput = detachedGraphSnapshot || graph;
let baseSnapshot = null;
- let snapshot = null;
+ let snapshot = prebuiltPersistSnapshot;
let delta = directPersistDelta;
let persistDeltaBuildDiagnostics = null;
let nativePersistModuleStatus = null;
@@ -13390,6 +13528,7 @@ async function saveGraphToIndexedDb(
let nativePersistPreloadMs = 0;
let baseSnapshotReadMs = 0;
let graphSnapshotBuildMs = 0;
+ let snapshotBuildDiagnostics = null;
const persistDeltaStartedAt = readPersistDeltaDiagnosticsNow();
if (!delta) {
@@ -13400,23 +13539,33 @@ async function saveGraphToIndexedDb(
}
baseSnapshotReadMs =
readPersistDeltaDiagnosticsNow() - baseSnapshotReadStartedAt;
- const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow();
- snapshot = buildSnapshotFromGraph(graph, {
- chatId: normalizedChatId,
- revision: requestedRevision,
- baseSnapshot,
- lastModified: Date.now(),
- meta: {
- storagePrimary: localStore.storagePrimary,
- storageMode: localStore.storageMode,
- lastMutationReason: String(reason || "graph-save"),
- integrity:
- currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
- hostChatId: currentIdentity.hostChatId || "",
- },
- });
- graphSnapshotBuildMs =
- readPersistDeltaDiagnosticsNow() - graphSnapshotBuildStartedAt;
+ if (!snapshot) {
+ const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow();
+ snapshot = buildSnapshotFromGraph(persistGraphInput, {
+ chatId: normalizedChatId,
+ revision: requestedRevision,
+ baseSnapshot,
+ lastModified: Date.now(),
+ meta: {
+ storagePrimary: localStore.storagePrimary,
+ storageMode: localStore.storageMode,
+ lastMutationReason: String(reason || "graph-save"),
+ integrity:
+ currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
+ hostChatId: currentIdentity.hostChatId || "",
+ },
+ onDiagnostics(snapshotValue) {
+ snapshotBuildDiagnostics =
+ snapshotValue &&
+ typeof snapshotValue === "object" &&
+ !Array.isArray(snapshotValue)
+ ? snapshotValue
+ : null;
+ },
+ });
+ graphSnapshotBuildMs =
+ readPersistDeltaDiagnosticsNow() - graphSnapshotBuildStartedAt;
+ }
}
const nativePersistBridgeMode = String(
currentSettings.persistNativeDeltaBridgeMode || "json",
@@ -13588,6 +13737,7 @@ async function saveGraphToIndexedDb(
reason,
requestedRevision,
markSyncDirty: true,
+ committedSnapshot: snapshot,
});
const commitDiagnostics =
commitResult?.diagnostics &&
@@ -13602,10 +13752,10 @@ async function saveGraphToIndexedDb(
const committedLastModified = Number(commitResult?.lastModified || Date.now());
let scheduleUploadWarning = "";
- if (graph) {
+ if (persistGraphInput) {
if (!snapshot) {
const graphSnapshotBuildStartedAt = readPersistDeltaDiagnosticsNow();
- snapshot = buildSnapshotFromGraph(graph, {
+ snapshot = buildSnapshotFromGraph(persistGraphInput, {
chatId: normalizedChatId,
revision: committedRevision,
baseSnapshot: baseSnapshot || undefined,
@@ -13618,6 +13768,14 @@ async function saveGraphToIndexedDb(
currentIdentity.integrity || graphPersistenceState.metadataIntegrity,
hostChatId: currentIdentity.hostChatId || "",
},
+ onDiagnostics(snapshotValue) {
+ snapshotBuildDiagnostics =
+ snapshotValue &&
+ typeof snapshotValue === "object" &&
+ !Array.isArray(snapshotValue)
+ ? snapshotValue
+ : null;
+ },
});
graphSnapshotBuildMs +=
readPersistDeltaDiagnosticsNow() - graphSnapshotBuildStartedAt;
@@ -13722,6 +13880,33 @@ async function saveGraphToIndexedDb(
),
baseSnapshotReadMs: normalizePersistDeltaDiagnosticsMs(baseSnapshotReadMs),
snapshotBuildMs: normalizePersistDeltaDiagnosticsMs(graphSnapshotBuildMs),
+ snapshotNodesMs: normalizePersistDeltaDiagnosticsMs(
+ snapshotBuildDiagnostics?.nodesMs,
+ ),
+ snapshotEdgesMs: normalizePersistDeltaDiagnosticsMs(
+ snapshotBuildDiagnostics?.edgesMs,
+ ),
+ snapshotTombstonesMs: normalizePersistDeltaDiagnosticsMs(
+ snapshotBuildDiagnostics?.tombstonesMs,
+ ),
+ snapshotStateMs: normalizePersistDeltaDiagnosticsMs(
+ snapshotBuildDiagnostics?.stateMs,
+ ),
+ snapshotMetaMs: normalizePersistDeltaDiagnosticsMs(
+ snapshotBuildDiagnostics?.metaMs,
+ ),
+ snapshotNodeCount: Math.max(
+ 0,
+ Math.floor(Number(snapshotBuildDiagnostics?.nodeCount || 0)),
+ ),
+ snapshotEdgeCount: Math.max(
+ 0,
+ Math.floor(Number(snapshotBuildDiagnostics?.edgeCount || 0)),
+ ),
+ snapshotTombstoneCount: Math.max(
+ 0,
+ Math.floor(Number(snapshotBuildDiagnostics?.tombstoneCount || 0)),
+ ),
commitStorageKind: String(
commitDiagnostics?.storageKind || localStore.storagePrimary || "",
),
@@ -13742,9 +13927,21 @@ async function saveGraphToIndexedDb(
commitManifestReadMs: normalizePersistDeltaDiagnosticsMs(
commitDiagnostics?.manifestReadMs,
),
+ commitWalSerializeMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.walSerializeMs,
+ ),
+ commitWalFileWriteMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.walFileWriteMs,
+ ),
commitWalWriteMs: normalizePersistDeltaDiagnosticsMs(
commitDiagnostics?.walWriteMs,
),
+ commitManifestSerializeMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.manifestSerializeMs,
+ ),
+ commitManifestFileWriteMs: normalizePersistDeltaDiagnosticsMs(
+ commitDiagnostics?.manifestFileWriteMs,
+ ),
commitManifestWriteMs: normalizePersistDeltaDiagnosticsMs(
commitDiagnostics?.manifestWriteMs,
),
@@ -13784,6 +13981,34 @@ async function saveGraphToIndexedDb(
"js",
)
: "";
+ const persistObservability = buildPersistObservabilitySummary(
+ persistDeltaDiagnostics,
+ );
+ persistDeltaDiagnostics.pathKey = String(
+ persistObservability?.lastPathKey || "unknown",
+ );
+ persistDeltaDiagnostics.reasonKey = String(
+ persistObservability?.lastReasonKey || "graph-save",
+ );
+ persistDeltaDiagnostics.pathReasonKey = String(
+ persistObservability?.lastPathReasonKey || "unknown::graph-save",
+ );
+ persistDeltaDiagnostics.pathSampleCount = Math.max(
+ 0,
+ Math.floor(
+ Number(
+ persistObservability?.byPath?.[persistDeltaDiagnostics.pathKey]?.count || 0,
+ ),
+ ),
+ );
+ persistDeltaDiagnostics.reasonSampleCount = Math.max(
+ 0,
+ Math.floor(
+ Number(
+ persistObservability?.byReason?.[persistDeltaDiagnostics.reasonKey]?.count || 0,
+ ),
+ ),
+ );
const opfsWriteLockState =
typeof db?.getWriteLockSnapshot === "function"
@@ -13828,6 +14053,7 @@ async function saveGraphToIndexedDb(
opfsWalDepth: localStoreDiagnostics.opfsWalDepth,
opfsPendingBytes: localStoreDiagnostics.opfsPendingBytes,
opfsCompactionState: localStoreDiagnostics.opfsCompactionState,
+ persistObservability,
dualWriteLastResult: {
action: "cache-mirror",
target: localStore.storagePrimary,
@@ -13924,6 +14150,7 @@ async function saveGraphToIndexedDb(
opfsWalDepth: localStoreDiagnostics.opfsWalDepth,
opfsPendingBytes: localStoreDiagnostics.opfsPendingBytes,
opfsCompactionState: localStoreDiagnostics.opfsCompactionState,
+ persistObservability,
dualWriteLastResult: {
action: "save",
target: localStore.storagePrimary,
@@ -14077,6 +14304,104 @@ async function saveGraphToIndexedDb(
}
}
+function normalizePersistObservabilityKey(value = "", fallback = "unknown") {
+ const normalized = String(value || "")
+ .trim()
+ .toLowerCase()
+ .replace(/\s+/g, "-")
+ .replace(/[^a-z0-9:_-]+/g, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-+|-+$/g, "");
+ return normalized || String(fallback || "unknown");
+}
+
+function trimPersistObservabilityBuckets(buckets = {}, maxEntries = 16) {
+ const entries = Object.values(buckets || {}).filter(
+ (entry) => entry && typeof entry === "object" && !Array.isArray(entry),
+ );
+ entries.sort((left, right) => {
+ const countDelta = Number(right?.count || 0) - Number(left?.count || 0);
+ if (countDelta !== 0) return countDelta;
+ return String(right?.lastAt || "").localeCompare(String(left?.lastAt || ""));
+ });
+ return Object.fromEntries(
+ entries.slice(0, Math.max(1, Math.floor(Number(maxEntries) || 16))).map((entry) => [
+ String(entry.key || "unknown"),
+ entry,
+ ]),
+ );
+}
+
+function buildPersistObservabilitySummary(diagnostics = null) {
+ const source =
+ diagnostics && typeof diagnostics === "object" && !Array.isArray(diagnostics)
+ ? diagnostics
+ : {};
+ const previous =
+ graphPersistenceState.persistObservability &&
+ typeof graphPersistenceState.persistObservability === "object" &&
+ !Array.isArray(graphPersistenceState.persistObservability)
+ ? cloneRuntimeDebugValue(graphPersistenceState.persistObservability, {})
+ : {};
+ const totalMs = normalizePersistDeltaDiagnosticsMs(
+ source.totalMs || source.buildMs || 0,
+ );
+ const pathKey = normalizePersistObservabilityKey(
+ source.path || source.requestedBridgeMode || "unknown",
+ "unknown",
+ );
+ const reasonKey = normalizePersistObservabilityKey(
+ source.saveReason || "graph-save",
+ "graph-save",
+ );
+ const pathReasonKey = `${pathKey}::${reasonKey}`;
+ const recordedAt = new Date().toISOString();
+ const recordBucket = (buckets = {}, key = "unknown") => {
+ const current =
+ buckets[key] && typeof buckets[key] === "object" && !Array.isArray(buckets[key])
+ ? buckets[key]
+ : null;
+ const count = Math.max(0, Math.floor(Number(current?.count || 0))) + 1;
+ const totalBucketMs = normalizePersistDeltaDiagnosticsMs(
+ Number(current?.totalMs || 0) + totalMs,
+ );
+ buckets[key] = {
+ key,
+ count,
+ totalMs: totalBucketMs,
+ avgMs: normalizePersistDeltaDiagnosticsMs(totalBucketMs / count),
+ maxMs: normalizePersistDeltaDiagnosticsMs(
+ Math.max(Number(current?.maxMs || 0), totalMs),
+ ),
+ lastMs: totalMs,
+ lastAt: recordedAt,
+ };
+ return buckets;
+ };
+ const nextByPath = recordBucket(
+ cloneRuntimeDebugValue(previous.byPath || {}, {}),
+ pathKey,
+ );
+ const nextByReason = recordBucket(
+ cloneRuntimeDebugValue(previous.byReason || {}, {}),
+ reasonKey,
+ );
+ const nextByPathReason = recordBucket(
+ cloneRuntimeDebugValue(previous.byPathReason || {}, {}),
+ pathReasonKey,
+ );
+ return {
+ totalSamples: Math.max(0, Math.floor(Number(previous.totalSamples || 0))) + 1,
+ byPath: trimPersistObservabilityBuckets(nextByPath, 12),
+ byReason: trimPersistObservabilityBuckets(nextByReason, 16),
+ byPathReason: trimPersistObservabilityBuckets(nextByPathReason, 24),
+ lastPathKey: pathKey,
+ lastReasonKey: reasonKey,
+ lastPathReasonKey: pathReasonKey,
+ lastRecordedAt: recordedAt,
+ };
+}
+
function queueGraphPersistToIndexedDb(
chatId,
graph,
@@ -14086,6 +14411,8 @@ function queueGraphPersistToIndexedDb(
persistRole = "primary",
scheduleCloudUpload = undefined,
persistDelta = null,
+ graphSnapshot = null,
+ persistSnapshot = null,
graphDetached = false,
} = {},
) {
@@ -14134,17 +14461,21 @@ function queueGraphPersistToIndexedDb(
revision: normalizedRevision,
};
}
- const graphSnapshot = graph
- ? graphDetached === true
- ? normalizeGraphRuntimeState(graph, normalizedChatId)
- : cloneGraphForPersistence(graph, normalizedChatId)
- : null;
- return await saveGraphToIndexedDb(normalizedChatId, graphSnapshot, {
+ const persistGraphSnapshot = graphSnapshot
+ ? graphSnapshot
+ : graph
+ ? graphDetached === true
+ ? normalizeGraphRuntimeState(graph, normalizedChatId)
+ : cloneGraphForPersistence(graph, normalizedChatId)
+ : null;
+ return await saveGraphToIndexedDb(normalizedChatId, persistGraphSnapshot, {
revision: normalizedRevision,
reason,
persistRole,
scheduleCloudUpload,
persistDelta,
+ graphSnapshot: persistGraphSnapshot,
+ persistSnapshot,
});
})
.finally(() => {
diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js
index 0f1d78f..f9c6cdc 100644
--- a/maintenance/extraction-controller.js
+++ b/maintenance/extraction-controller.js
@@ -380,10 +380,22 @@ async function buildCommittedBatchPersistSnapshot(
}
let persistDelta = null;
+ let persistSnapshot = null;
const shouldUseNativePersistDelta =
runtimeSettings?.persistUseNativeDelta === true &&
runtimeSettings?.graphNativeForceDisable !== true;
const nativeFailOpen = runtimeSettings?.nativeEngineFailOpen !== false;
+ if (typeof runtime.buildSnapshotFromGraph === "function") {
+ persistSnapshot = runtime.buildSnapshotFromGraph(committedGraphSnapshot, {
+ chatId:
+ committedGraphSnapshot?.historyState?.chatId ||
+ beforeSnapshot?.meta?.chatId ||
+ "",
+ revision: Number(beforeSnapshot?.meta?.revision || 0) + 1,
+ baseSnapshot: beforeSnapshot || undefined,
+ lastModified: Date.now(),
+ });
+ }
if (typeof runtime.buildPersistDelta === "function") {
if (shouldUseNativePersistDelta) {
const preloadStartedAt = readNow();
@@ -403,7 +415,10 @@ async function buildCommittedBatchPersistSnapshot(
}
}
- persistDelta = runtime.buildPersistDelta(beforeSnapshot, committedGraphSnapshot, {
+ persistDelta = runtime.buildPersistDelta(
+ beforeSnapshot,
+ persistSnapshot || committedGraphSnapshot,
+ {
useNativeDelta: shouldUseNativePersistDelta,
nativeFailOpen,
persistNativeDeltaThresholdRecords:
@@ -413,11 +428,13 @@ async function buildCommittedBatchPersistSnapshot(
persistNativeDeltaThresholdSerializedChars:
runtimeSettings?.persistNativeDeltaThresholdSerializedChars,
persistNativeDeltaBridgeMode: runtimeSettings?.persistNativeDeltaBridgeMode,
- });
+ },
+ );
}
return {
persistDelta,
+ persistSnapshot,
persistGraphSnapshot: committedGraphSnapshot,
committedBatchJournalEntry,
afterSnapshot,
@@ -717,6 +734,7 @@ export async function executeExtractionBatchController(
reason: "extraction-batch-complete",
lastProcessedAssistantFloor: endIdx,
graphSnapshot: committedPersistState.persistGraphSnapshot,
+ persistSnapshot: committedPersistState.persistSnapshot,
persistDelta: committedPersistState.persistDelta,
});
const persistence = normalizePersistenceStateRecord(persistResult);
diff --git a/sync/bme-db.js b/sync/bme-db.js
index 8c75c83..1bf1dc8 100644
--- a/sync/bme-db.js
+++ b/sync/bme-db.js
@@ -646,6 +646,26 @@ export function buildSnapshotFromGraph(graph, options = {}) {
!Array.isArray(options.baseSnapshot)
? options.baseSnapshot
: {};
+ const shouldCollectDiagnostics = typeof options?.onDiagnostics === "function";
+ const snapshotStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
+ const snapshotDiagnostics = shouldCollectDiagnostics
+ ? {
+ nodeCount: 0,
+ edgeCount: 0,
+ tombstoneCount: 0,
+ reusedNodeCount: 0,
+ reusedEdgeCount: 0,
+ reusedTombstoneCount: 0,
+ clonedNodeCount: 0,
+ clonedEdgeCount: 0,
+ clonedTombstoneCount: 0,
+ nodesMs: 0,
+ edgesMs: 0,
+ tombstonesMs: 0,
+ stateMs: 0,
+ metaMs: 0,
+ }
+ : null;
const baseSnapshot = sanitizeSnapshot(baseSnapshotInput);
const baseSnapshotView = normalizePersistSnapshotView(baseSnapshotInput);
const nowMs = normalizeTimestamp(options.nowMs, Date.now());
@@ -674,6 +694,7 @@ export function buildSnapshotFromGraph(graph, options = {}) {
baseSnapshotView.tombstones,
);
+ const nodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const nodes = toArray(runtimeGraph?.nodes)
.map((node) => {
if (!node || typeof node !== "object" || Array.isArray(node)) {
@@ -689,18 +710,29 @@ export function buildSnapshotFromGraph(graph, options = {}) {
updatedAt: normalizedUpdatedAt,
})
) {
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.reusedNodeCount += 1;
+ }
return baseNode;
}
const plainNode = clonePersistSnapshotRecord(node);
if (!plainNode || typeof plainNode !== "object" || Array.isArray(plainNode)) {
return null;
}
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.clonedNodeCount += 1;
+ }
plainNode.id = id;
plainNode.updatedAt = normalizedUpdatedAt;
return plainNode;
})
.filter(Boolean);
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.nodeCount = nodes.length;
+ snapshotDiagnostics.nodesMs = readPersistDeltaNow() - nodesStartedAt;
+ }
+ const edgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const edges = toArray(runtimeGraph?.edges)
.map((edge) => {
if (!edge || typeof edge !== "object" || Array.isArray(edge)) {
@@ -719,12 +751,18 @@ export function buildSnapshotFromGraph(graph, options = {}) {
updatedAt: normalizedUpdatedAt,
})
) {
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.reusedEdgeCount += 1;
+ }
return baseEdge;
}
const plainEdge = clonePersistSnapshotRecord(edge);
if (!plainEdge || typeof plainEdge !== "object" || Array.isArray(plainEdge)) {
return null;
}
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.clonedEdgeCount += 1;
+ }
plainEdge.id = id;
plainEdge.fromId = normalizedFromId;
plainEdge.toId = normalizedToId;
@@ -732,7 +770,12 @@ export function buildSnapshotFromGraph(graph, options = {}) {
return plainEdge;
})
.filter(Boolean);
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.edgeCount = edges.length;
+ snapshotDiagnostics.edgesMs = readPersistDeltaNow() - edgesStartedAt;
+ }
+ const tombstonesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const tombstones = toArray(options.tombstones ?? baseSnapshotView.tombstones)
.map((record) => {
if (!record || typeof record !== "object" || Array.isArray(record))
@@ -752,12 +795,18 @@ export function buildSnapshotFromGraph(graph, options = {}) {
deletedAt: normalizedDeletedAt,
})
) {
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.reusedTombstoneCount += 1;
+ }
return baseTombstone;
}
const plainRecord = clonePersistSnapshotRecord(record);
if (!plainRecord || typeof plainRecord !== "object" || Array.isArray(plainRecord)) {
return null;
}
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.clonedTombstoneCount += 1;
+ }
plainRecord.id = id;
plainRecord.kind = normalizedKind;
plainRecord.targetId = normalizedTargetId;
@@ -766,7 +815,13 @@ export function buildSnapshotFromGraph(graph, options = {}) {
return plainRecord;
})
.filter(Boolean);
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.tombstoneCount = tombstones.length;
+ snapshotDiagnostics.tombstonesMs =
+ readPersistDeltaNow() - tombstonesStartedAt;
+ }
+ const stateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const state = {
...normalizeStateSnapshot(baseSnapshot),
...(options.state || {}),
@@ -783,7 +838,11 @@ export function buildSnapshotFromGraph(graph, options = {}) {
? Number(runtimeGraph.historyState.extractionCount)
: META_DEFAULT_EXTRACTION_COUNT,
};
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.stateMs = readPersistDeltaNow() - stateStartedAt;
+ }
+ const metaStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const mergedMeta = {
...baseSnapshot.meta,
...(options.meta || {}),
@@ -869,14 +928,26 @@ export function buildSnapshotFromGraph(graph, options = {}) {
? Number(runtimeGraph.version)
: Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0),
};
+ if (snapshotDiagnostics) {
+ snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt;
+ }
- return {
+ const snapshotResult = {
meta: mergedMeta,
nodes,
edges,
tombstones,
state,
};
+ if (snapshotDiagnostics) {
+ emitOptionalDiagnostics(options, {
+ ...snapshotDiagnostics,
+ runtimeMetaKeyCount: Object.keys(mergedMeta).length,
+ totalMs: readPersistDeltaNow() - snapshotStartedAt,
+ });
+ }
+
+ return snapshotResult;
}
function normalizeSnapshotMetaState(snapshot = {}) {
@@ -1630,7 +1701,7 @@ function readPersistDeltaNow() {
return Date.now();
}
-function emitPersistDeltaDiagnostics(options = {}, snapshot = null) {
+function emitOptionalDiagnostics(options = {}, snapshot = null) {
if (typeof options?.onDiagnostics !== "function") return;
try {
options.onDiagnostics(snapshot ? toPlainData(snapshot, snapshot) : null);
@@ -1639,6 +1710,10 @@ function emitPersistDeltaDiagnostics(options = {}, snapshot = null) {
}
}
+function emitPersistDeltaDiagnostics(options = {}, snapshot = null) {
+ emitOptionalDiagnostics(options, snapshot);
+}
+
function tryBuildNativePersistDelta(
beforeSnapshot,
afterSnapshot,
@@ -2016,6 +2091,23 @@ export function buildPersistDelta(beforeSnapshot, afterSnapshot, options = {}) {
}
export function buildGraphFromSnapshot(snapshot, options = {}) {
+ const shouldCollectDiagnostics = typeof options?.onDiagnostics === "function";
+ const hydrateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
+ const hydrateDiagnostics = shouldCollectDiagnostics
+ ? {
+ success: false,
+ nodeCount: 0,
+ edgeCount: 0,
+ tombstoneCount: 0,
+ nodesMs: 0,
+ edgesMs: 0,
+ runtimeMetaMs: 0,
+ stateMs: 0,
+ normalizeMs: 0,
+ integrityMs: 0,
+ integrityReasonCount: 0,
+ }
+ : null;
const snapshotView = normalizePersistSnapshotView(snapshot);
const snapshotMeta =
snapshotView.meta &&
@@ -2048,8 +2140,24 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
)
? Number(snapshotMeta[BME_RUNTIME_GRAPH_VERSION_META_KEY])
: runtimeGraph.version;
+
+ const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
runtimeGraph.nodes = toArray(toPlainData(snapshotView.nodes, []));
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length;
+ hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt;
+ }
+
+ const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
runtimeGraph.edges = toArray(toPlainData(snapshotView.edges, []));
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.edgeCount = runtimeGraph.edges.length;
+ hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt;
+ }
+
+ const hydrateRuntimeMetaStartedAt = shouldCollectDiagnostics
+ ? readPersistDeltaNow()
+ : 0;
runtimeGraph.batchJournal = toArray(
toPlainData(snapshotMeta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY], []),
);
@@ -2076,6 +2184,10 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
snapshotMeta?.[BME_RUNTIME_SUMMARY_STATE_META_KEY],
runtimeGraph.summaryState || {},
);
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.runtimeMetaMs =
+ readPersistDeltaNow() - hydrateRuntimeMetaStartedAt;
+ }
const rawKnowledgeState =
runtimeGraph.knowledgeState &&
typeof runtimeGraph.knowledgeState === "object" &&
@@ -2095,6 +2207,7 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
? runtimeGraph.timelineState
: {};
+ const hydrateStateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
runtimeGraph.historyState = {
...(runtimeGraph.historyState || {}),
...snapshotHistoryState,
@@ -2183,8 +2296,16 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
)
? Number(snapshotMeta[BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY])
: Number(runtimeGraph.historyState.lastProcessedAssistantFloor);
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.tombstoneCount = toArray(snapshotView.tombstones).length;
+ hydrateDiagnostics.stateMs = readPersistDeltaNow() - hydrateStateStartedAt;
+ }
+ const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId);
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt;
+ }
if (
normalizedGraph.knowledgeState &&
typeof normalizedGraph.knowledgeState === "object" &&
@@ -2238,6 +2359,7 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
);
const inconsistentReasons = [];
+ const integrityStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
if (
Number.isFinite(resolvedLastProcessedFloor) &&
Number.isFinite(resolvedLastProcessedSeq) &&
@@ -2255,8 +2377,20 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
if (collectionId && collectionId !== expectedCollectionId) {
inconsistentReasons.push("vector-collection-mismatch");
}
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.integrityMs = readPersistDeltaNow() - integrityStartedAt;
+ hydrateDiagnostics.integrityReasonCount = inconsistentReasons.length;
+ }
if (inconsistentReasons.length > 0) {
+ if (hydrateDiagnostics) {
+ emitOptionalDiagnostics(options, {
+ ...hydrateDiagnostics,
+ success: false,
+ integrityReasons: [...inconsistentReasons],
+ totalMs: readPersistDeltaNow() - hydrateStartedAt,
+ });
+ }
const error = new Error(
`图谱快照完整性校验失败: ${inconsistentReasons.join(", ")}`,
);
@@ -2266,6 +2400,15 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
throw error;
}
+ if (hydrateDiagnostics) {
+ emitOptionalDiagnostics(options, {
+ ...hydrateDiagnostics,
+ success: true,
+ integrityReasons: [],
+ totalMs: readPersistDeltaNow() - hydrateStartedAt,
+ });
+ }
+
return normalizedGraph;
}
@@ -3151,6 +3294,40 @@ export class BmeDatabase {
return snapshot;
}
+ async exportSnapshotProbe() {
+ const db = await this.open();
+ const metaRows = await db.transaction("r", db.table("meta"), async () =>
+ await db.table("meta").toArray(),
+ );
+ const metaMap = toMetaMap(metaRows);
+ const meta = {
+ ...metaMap,
+ schemaVersion: BME_DB_SCHEMA_VERSION,
+ chatId: this.chatId,
+ revision: normalizeRevision(metaMap?.revision),
+ nodeCount: normalizeNonNegativeInteger(metaMap?.nodeCount, 0),
+ edgeCount: normalizeNonNegativeInteger(metaMap?.edgeCount, 0),
+ tombstoneCount: normalizeNonNegativeInteger(metaMap?.tombstoneCount, 0),
+ };
+ const state = {
+ lastProcessedFloor: Number.isFinite(Number(meta.lastProcessedFloor))
+ ? Number(meta.lastProcessedFloor)
+ : META_DEFAULT_LAST_PROCESSED_FLOOR,
+ extractionCount: Number.isFinite(Number(meta.extractionCount))
+ ? Number(meta.extractionCount)
+ : META_DEFAULT_EXTRACTION_COUNT,
+ };
+ return {
+ meta,
+ state,
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ __stBmeProbeOnly: true,
+ __stBmeTombstonesOmitted: true,
+ };
+ }
+
async importSnapshot(snapshot, options = {}) {
const db = await this.open();
const normalizedSnapshot = sanitizeSnapshot(snapshot);
diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js
index 9aad9d3..c1fb5ab 100644
--- a/sync/bme-opfs-store.js
+++ b/sync/bme-opfs-store.js
@@ -426,12 +426,16 @@ async function readJsonFile(parentHandle, name, fallbackValue = null) {
return JSON.parse(text);
}
-async function writeJsonFile(parentHandle, name, value) {
+async function writeJsonFile(parentHandle, name, value, options = {}) {
+ const serializedText =
+ typeof options?.serializedText === "string"
+ ? options.serializedText
+ : JSON.stringify(value);
const fileHandle = await parentHandle.getFileHandle(String(name || ""), {
create: true,
});
const writable = await fileHandle.createWritable();
- await writable.write(JSON.stringify(value));
+ await writable.write(serializedText);
await writable.close();
return fileHandle;
}
@@ -2416,12 +2420,18 @@ export class OpfsGraphStore {
runtimeMetaPatch: normalizedDelta.runtimeMetaPatch,
countDelta: nextCountDelta,
};
+ const walSerializeStartedAt = readPersistCommitNow();
+ const walSerializedText = JSON.stringify(walRecord);
+ const walSerializeMs = readPersistCommitNow() - walSerializeStartedAt;
const walWriteStartedAt = readPersistCommitNow();
const walDirectory = await this._getWalDirectory();
const walFilename = buildOpfsV2WalFilename(nextRevision);
- await writeJsonFile(walDirectory, walFilename, walRecord);
- const walByteLength = JSON.stringify(walRecord).length;
- const walWriteMs = readPersistCommitNow() - walWriteStartedAt;
+ await writeJsonFile(walDirectory, walFilename, walRecord, {
+ serializedText: walSerializedText,
+ });
+ const walByteLength = walSerializedText.length;
+ const walFileWriteMs = readPersistCommitNow() - walWriteStartedAt;
+ const walWriteMs = walSerializeMs + walFileWriteMs;
const hadPendingWal =
normalizeRevision(manifest?.pendingLogFromRevision) <= currentHeadRevision;
@@ -2448,12 +2458,40 @@ export class OpfsGraphStore {
lastReason: reason,
},
};
- const manifestWriteStartedAt = readPersistCommitNow();
- await this._writeManifest(nextManifest);
- const manifestWriteMs = readPersistCommitNow() - manifestWriteStartedAt;
+ const manifestWriteDiagnostics = {};
+ await this._writeManifest(nextManifest, {
+ diagnostics: manifestWriteDiagnostics,
+ });
+ const manifestSerializeMs = Number(
+ manifestWriteDiagnostics.serializeMs || 0,
+ );
+ const manifestFileWriteMs = Number(
+ manifestWriteDiagnostics.writeMs || 0,
+ );
+ const manifestWriteMs = manifestSerializeMs + manifestFileWriteMs;
+ const committedSnapshot =
+ options?.committedSnapshot &&
+ typeof options.committedSnapshot === "object" &&
+ !Array.isArray(options.committedSnapshot)
+ ? sanitizeSnapshot(options.committedSnapshot)
+ : null;
let cacheApplyMs = 0;
- if (this._snapshotCache) {
+ if (committedSnapshot) {
+ const cacheApplyStartedAt = readPersistCommitNow();
+ committedSnapshot.meta = {
+ ...committedSnapshot.meta,
+ ...nextMeta,
+ };
+ committedSnapshot.state = normalizeSnapshotState(committedSnapshot);
+ committedSnapshot.meta.lastProcessedFloor = committedSnapshot.state.lastProcessedFloor;
+ committedSnapshot.meta.extractionCount = committedSnapshot.state.extractionCount;
+ committedSnapshot.meta.nodeCount = committedSnapshot.nodes.length;
+ committedSnapshot.meta.edgeCount = committedSnapshot.edges.length;
+ committedSnapshot.meta.tombstoneCount = committedSnapshot.tombstones.length;
+ this._snapshotCache = committedSnapshot;
+ cacheApplyMs = readPersistCommitNow() - cacheApplyStartedAt;
+ } else if (this._snapshotCache) {
const cacheApplyStartedAt = readPersistCommitNow();
const nextSnapshot = applyOpfsV2DeltaToSnapshot(
this._snapshotCache,
@@ -2494,7 +2532,11 @@ export class OpfsGraphStore {
readPersistCommitNow() - commitStartedAt,
),
manifestReadMs: normalizePersistCommitMs(manifestReadMs),
+ walSerializeMs: normalizePersistCommitMs(walSerializeMs),
+ walFileWriteMs: normalizePersistCommitMs(walFileWriteMs),
walWriteMs: normalizePersistCommitMs(walWriteMs),
+ manifestSerializeMs: normalizePersistCommitMs(manifestSerializeMs),
+ manifestFileWriteMs: normalizePersistCommitMs(manifestFileWriteMs),
manifestWriteMs: normalizePersistCommitMs(manifestWriteMs),
cacheApplyMs: normalizePersistCommitMs(cacheApplyMs),
payloadBytes,
@@ -2740,6 +2782,39 @@ export class OpfsGraphStore {
return exported;
}
+ async exportSnapshotProbe() {
+ const manifest = await this._ensureV2Ready();
+ const meta = {
+ ...createDefaultMetaValues(this.chatId),
+ ...(manifest?.meta || {}),
+ chatId: this.chatId,
+ revision: normalizeRevision(manifest?.headRevision || manifest?.meta?.revision),
+ nodeCount: normalizeNonNegativeInteger(manifest?.meta?.nodeCount, 0),
+ edgeCount: normalizeNonNegativeInteger(manifest?.meta?.edgeCount, 0),
+ tombstoneCount: normalizeNonNegativeInteger(manifest?.meta?.tombstoneCount, 0),
+ storagePrimary: OPFS_STORE_KIND,
+ storageMode: this.storeMode,
+ schemaVersion: BME_DB_SCHEMA_VERSION,
+ };
+ const state = {
+ lastProcessedFloor: Number.isFinite(Number(meta.lastProcessedFloor))
+ ? Number(meta.lastProcessedFloor)
+ : META_DEFAULT_LAST_PROCESSED_FLOOR,
+ extractionCount: Number.isFinite(Number(meta.extractionCount))
+ ? Number(meta.extractionCount)
+ : META_DEFAULT_EXTRACTION_COUNT,
+ };
+ return {
+ meta,
+ state,
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ __stBmeProbeOnly: true,
+ __stBmeTombstonesOmitted: true,
+ };
+ }
+
async importSnapshot(snapshot, options = {}) {
return await this._runSerializedWrite("importSnapshot", async () => {
await this._ensureV2Ready({ awaitWrites: false });
@@ -2993,7 +3068,7 @@ export class OpfsGraphStore {
return manifest;
}
- async _writeManifest(manifest = {}) {
+ async _writeManifest(manifest = {}, options = {}) {
const chatDirectory = await this._getChatDirectory();
const nextManifest = {
...manifest,
@@ -3017,7 +3092,24 @@ export class OpfsGraphStore {
storageMode: this.storeMode,
},
};
- await writeJsonFile(chatDirectory, OPFS_MANIFEST_FILENAME, nextManifest);
+ let serializedText = "";
+ let serializeMs = 0;
+ if (options?.diagnostics && typeof options.diagnostics === "object") {
+ const serializeStartedAt = readPersistCommitNow();
+ serializedText = JSON.stringify(nextManifest);
+ serializeMs = readPersistCommitNow() - serializeStartedAt;
+ }
+ const writeStartedAt =
+ options?.diagnostics && typeof options.diagnostics === "object"
+ ? readPersistCommitNow()
+ : 0;
+ await writeJsonFile(chatDirectory, OPFS_MANIFEST_FILENAME, nextManifest, {
+ serializedText,
+ });
+ if (options?.diagnostics && typeof options.diagnostics === "object") {
+ options.diagnostics.serializeMs = serializeMs;
+ options.diagnostics.writeMs = readPersistCommitNow() - writeStartedAt;
+ }
this._manifestCache = nextManifest;
return nextManifest;
}
diff --git a/sync/bme-sync.js b/sync/bme-sync.js
index a8078b9..80b66e0 100644
--- a/sync/bme-sync.js
+++ b/sync/bme-sync.js
@@ -47,6 +47,30 @@ export function buildRestoreSafetyChatId(chatId) {
return `__restore_safety__${normalizeChatId(chatId)}`;
}
+function readSyncTimingNow() {
+ if (typeof performance === "object" && typeof performance.now === "function") {
+ return performance.now();
+ }
+ return Date.now();
+}
+
+function normalizeSyncTimingMs(value = 0) {
+ return Math.round((Number(value) || 0) * 10) / 10;
+}
+
+function finalizeSyncTimings(record = {}, startedAt = 0) {
+ const result = {};
+ for (const [key, value] of Object.entries(record || {})) {
+ if (typeof value === "number" && Number.isFinite(value)) {
+ result[key] = normalizeSyncTimingMs(value);
+ }
+ }
+ if (startedAt > 0) {
+ result.totalMs = normalizeSyncTimingMs(readSyncTimingNow() - startedAt);
+ }
+ return result;
+}
+
function resolveCloudStorageMode(options = {}) {
const mode =
typeof options.getCloudStorageMode === "function"
@@ -494,14 +518,20 @@ async function resolveBackupLookupContext(chatId, options = {}) {
}
async function readBackupEnvelope(chatId, options = {}) {
+ const readStartedAt = readSyncTimingNow();
const normalizedChatId = normalizeChatId(chatId);
+ const lookupStartedAt = readSyncTimingNow();
const lookup = await resolveBackupLookupContext(normalizedChatId, options);
+ const lookupMs = readSyncTimingNow() - lookupStartedAt;
const fetchImpl = getFetch(options);
const fallbackFilename = buildBackupFilename(normalizedChatId);
let lastMissingFilename = lookup.candidates[0]?.filename || fallbackFilename;
+ let networkMs = 0;
+ let parseMs = 0;
for (const candidate of lookup.candidates) {
try {
+ const networkStartedAt = readSyncTimingNow();
const response = await fetchImpl(
`${candidate.serverPath || `/user/files/${encodeURIComponent(candidate.filename)}`}?t=${Date.now()}`,
{
@@ -509,6 +539,7 @@ async function readBackupEnvelope(chatId, options = {}) {
cache: "no-store",
},
);
+ networkMs += readSyncTimingNow() - networkStartedAt;
if (response.status === 404) {
lastMissingFilename = candidate.filename;
continue;
@@ -521,10 +552,13 @@ async function readBackupEnvelope(chatId, options = {}) {
envelope: null,
reason: "backup-read-error",
error: new Error(errorText || `HTTP ${response.status}`),
+ timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
};
}
+ const parseStartedAt = readSyncTimingNow();
const payload = await response.json();
+ parseMs += readSyncTimingNow() - parseStartedAt;
const envelope = normalizeBackupEnvelope(payload, normalizedChatId);
if (!envelope) {
return {
@@ -532,6 +566,7 @@ async function readBackupEnvelope(chatId, options = {}) {
filename: candidate.filename,
envelope: null,
reason: "invalid-backup",
+ timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
};
}
return {
@@ -539,6 +574,7 @@ async function readBackupEnvelope(chatId, options = {}) {
filename: candidate.filename,
envelope,
reason: "ok",
+ timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
};
} catch (error) {
return {
@@ -547,6 +583,7 @@ async function readBackupEnvelope(chatId, options = {}) {
envelope: null,
reason: "backup-read-error",
error,
+ timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
};
}
}
@@ -557,6 +594,7 @@ async function readBackupEnvelope(chatId, options = {}) {
envelope: null,
reason: "not-found",
manifestError: lookup.manifestError,
+ timings: finalizeSyncTimings({ lookupMs, networkMs, parseMs }, readStartedAt),
};
}
@@ -581,10 +619,14 @@ async function syncDeletedBackupMeta(chatId, remainingEntry, options = {}) {
}
async function writeBackupEnvelope(envelope, chatId, options = {}) {
+ const writeStartedAt = readSyncTimingNow();
const normalizedChatId = normalizeChatId(chatId);
const filename = buildBackupFilename(normalizedChatId);
const fetchImpl = getFetch(options);
+ const serializeStartedAt = readSyncTimingNow();
const payload = JSON.stringify(envelope);
+ const serializeMs = readSyncTimingNow() - serializeStartedAt;
+ const uploadStartedAt = readSyncTimingNow();
const response = await fetchImpl("/api/files/upload", {
method: "POST",
headers: {
@@ -596,16 +638,27 @@ async function writeBackupEnvelope(envelope, chatId, options = {}) {
data: encodeBase64Utf8(payload),
}),
});
+ const uploadMs = readSyncTimingNow() - uploadStartedAt;
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(errorText || `HTTP ${response.status}`);
}
+ const responseParseStartedAt = readSyncTimingNow();
const uploadResult = await response.json().catch(() => ({}));
+ const responseParseMs = readSyncTimingNow() - responseParseStartedAt;
return {
filename,
path: String(uploadResult?.path || `/user/files/${filename}`),
+ timings: finalizeSyncTimings(
+ {
+ serializeMs,
+ uploadMs,
+ responseParseMs,
+ },
+ writeStartedAt,
+ ),
};
}
@@ -1825,6 +1878,7 @@ async function resolveSyncFilenameCandidates(chatId, options = {}) {
}
async function readRemoteSnapshot(chatId, options = {}) {
+ const readStartedAt = readSyncTimingNow();
const normalizedChatId = normalizeChatId(chatId);
if (!normalizedChatId) {
return {
@@ -1832,15 +1886,22 @@ async function readRemoteSnapshot(chatId, options = {}) {
status: "missing-chat-id",
filename: "",
snapshot: null,
+ timings: finalizeSyncTimings({}, readStartedAt),
};
}
const fetchImpl = getFetch(options);
+ const resolveStartedAt = readSyncTimingNow();
const candidateFilenames = await resolveSyncFilenameCandidates(
normalizedChatId,
options,
);
+ const resolveCandidatesMs = readSyncTimingNow() - resolveStartedAt;
let lastNotFoundFilename = candidateFilenames[0] || "";
+ let networkMs = 0;
+ let parseMs = 0;
+ let chunkReadMs = 0;
+ let normalizeMs = 0;
for (const filename of candidateFilenames) {
const cacheBust = `t=${Date.now()}`;
@@ -1848,10 +1909,12 @@ async function readRemoteSnapshot(chatId, options = {}) {
let response;
try {
+ const networkStartedAt = readSyncTimingNow();
response = await fetchImpl(url, {
method: "GET",
cache: "no-store",
});
+ networkMs += readSyncTimingNow() - networkStartedAt;
} catch (error) {
console.warn("[ST-BME] 读取远端同步文件失败:", error);
return {
@@ -1860,6 +1923,10 @@ async function readRemoteSnapshot(chatId, options = {}) {
filename,
snapshot: null,
error,
+ timings: finalizeSyncTimings(
+ { resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
+ readStartedAt,
+ ),
};
}
@@ -1879,14 +1946,20 @@ async function readRemoteSnapshot(chatId, options = {}) {
snapshot: null,
error,
statusCode: response.status,
+ timings: finalizeSyncTimings(
+ { resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
+ readStartedAt,
+ ),
};
}
try {
+ const parseStartedAt = readSyncTimingNow();
const remotePayload = await response.json();
+ parseMs += readSyncTimingNow() - parseStartedAt;
let snapshot = null;
if (Number(remotePayload?.formatVersion || 0) === BME_REMOTE_SYNC_FORMAT_VERSION_V2) {
- snapshot = await readRemoteSnapshotV2Manifest(
+ const manifestResult = await readRemoteSnapshotV2Manifest(
remotePayload,
normalizedChatId,
{
@@ -1894,8 +1967,13 @@ async function readRemoteSnapshot(chatId, options = {}) {
filename,
},
);
+ snapshot = manifestResult.snapshot;
+ chunkReadMs += Number(manifestResult?.timings?.chunkReadMs || 0);
+ normalizeMs += Number(manifestResult?.timings?.normalizeMs || 0);
} else {
+ const normalizeStartedAt = readSyncTimingNow();
snapshot = normalizeSyncSnapshot(remotePayload, normalizedChatId);
+ normalizeMs += readSyncTimingNow() - normalizeStartedAt;
}
rememberResolvedSyncFilename(normalizedChatId, filename);
return {
@@ -1903,6 +1981,10 @@ async function readRemoteSnapshot(chatId, options = {}) {
status: "ok",
filename,
snapshot,
+ timings: finalizeSyncTimings(
+ { resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
+ readStartedAt,
+ ),
};
} catch (error) {
console.warn("[ST-BME] 解析远端同步文件失败:", error);
@@ -1912,6 +1994,10 @@ async function readRemoteSnapshot(chatId, options = {}) {
filename,
snapshot: null,
error,
+ timings: finalizeSyncTimings(
+ { resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
+ readStartedAt,
+ ),
};
}
}
@@ -1921,6 +2007,10 @@ async function readRemoteSnapshot(chatId, options = {}) {
status: "not-found",
filename: lastNotFoundFilename,
snapshot: null,
+ timings: finalizeSyncTimings(
+ { resolveCandidatesMs, networkMs, parseMs, chunkReadMs, normalizeMs },
+ readStartedAt,
+ ),
};
}
@@ -1944,17 +2034,21 @@ async function readRemoteJsonFile(filename, options = {}) {
}
async function readRemoteSnapshotV2Manifest(manifest = {}, chatId = "", options = {}) {
+ const readStartedAt = readSyncTimingNow();
const normalizedChatId = normalizeChatId(chatId);
const chunks = Array.isArray(manifest?.chunks) ? manifest.chunks : [];
const nodes = [];
const edges = [];
const tombstones = [];
let runtimeMeta = {};
+ let chunkReadMs = 0;
for (const chunk of chunks) {
const filename = String(chunk?.filename || "").trim();
if (!filename) continue;
+ const chunkStartedAt = readSyncTimingNow();
const payload = await readRemoteJsonFile(filename, options);
+ chunkReadMs += readSyncTimingNow() - chunkStartedAt;
const records = Array.isArray(payload?.records) ? payload.records : [];
switch (String(chunk.kind || "").trim()) {
case "nodes":
@@ -1977,7 +2071,8 @@ async function readRemoteSnapshotV2Manifest(manifest = {}, chatId = "", options
}
}
- return normalizeSyncSnapshot(
+ const normalizeStartedAt = readSyncTimingNow();
+ const snapshot = normalizeSyncSnapshot(
{
meta: {
...runtimeMeta,
@@ -1994,55 +2089,94 @@ async function readRemoteSnapshotV2Manifest(manifest = {}, chatId = "", options
},
normalizedChatId,
);
+ const normalizeMs = readSyncTimingNow() - normalizeStartedAt;
+ return {
+ snapshot,
+ timings: finalizeSyncTimings(
+ {
+ chunkReadMs,
+ normalizeMs,
+ },
+ readStartedAt,
+ ),
+ };
}
async function writeSnapshotToRemote(snapshot, chatId, options = {}) {
+ const writeStartedAt = readSyncTimingNow();
const normalizedChatId = normalizeChatId(chatId);
const normalizedSnapshot = normalizeSyncSnapshot(snapshot, normalizedChatId);
const filename = await resolveSyncFilename(normalizedChatId, options);
const fetchImpl = getFetch(options);
+ const envelopeBuildStartedAt = readSyncTimingNow();
const syncEnvelope = buildRemoteSyncEnvelopeV2(
normalizedSnapshot,
normalizedChatId,
filename,
);
+ const envelopeBuildMs = readSyncTimingNow() - envelopeBuildStartedAt;
const requestHeaders = {
...getRequestHeadersSafe(options),
"Content-Type": "application/json",
};
+ let chunkSerializeMs = 0;
+ let chunkUploadMs = 0;
for (const chunk of syncEnvelope.chunks) {
+ const serializeStartedAt = readSyncTimingNow();
+ const chunkPayload = JSON.stringify(chunk.payload, null, 2);
+ chunkSerializeMs += readSyncTimingNow() - serializeStartedAt;
+ const uploadStartedAt = readSyncTimingNow();
const chunkResponse = await fetchImpl("/api/files/upload", {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
name: chunk.filename,
- data: encodeBase64Utf8(JSON.stringify(chunk.payload, null, 2)),
+ data: encodeBase64Utf8(chunkPayload),
}),
});
+ chunkUploadMs += readSyncTimingNow() - uploadStartedAt;
if (!chunkResponse.ok) {
const errorText = await chunkResponse.text().catch(() => chunkResponse.statusText);
throw new Error(errorText || `HTTP ${chunkResponse.status}`);
}
}
+ const manifestSerializeStartedAt = readSyncTimingNow();
+ const manifestPayload = JSON.stringify(syncEnvelope.manifest, null, 2);
+ const manifestSerializeMs = readSyncTimingNow() - manifestSerializeStartedAt;
+ const manifestUploadStartedAt = readSyncTimingNow();
const response = await fetchImpl("/api/files/upload", {
method: "POST",
headers: requestHeaders,
body: JSON.stringify({
name: filename,
- data: encodeBase64Utf8(JSON.stringify(syncEnvelope.manifest, null, 2)),
+ data: encodeBase64Utf8(manifestPayload),
}),
});
+ const manifestUploadMs = readSyncTimingNow() - manifestUploadStartedAt;
if (!response.ok) {
const errorText = await response.text().catch(() => response.statusText);
throw new Error(errorText || `HTTP ${response.status}`);
}
+ const responseParseStartedAt = readSyncTimingNow();
const uploadResult = await response.json().catch(() => ({}));
+ const responseParseMs = readSyncTimingNow() - responseParseStartedAt;
return {
filename,
path: String(uploadResult?.path || ""),
payload: syncEnvelope.manifest,
+ timings: finalizeSyncTimings(
+ {
+ envelopeBuildMs,
+ chunkSerializeMs,
+ chunkUploadMs,
+ manifestSerializeMs,
+ manifestUploadMs,
+ responseParseMs,
+ },
+ writeStartedAt,
+ ),
};
}
@@ -2160,15 +2294,19 @@ export async function backupToServer(chatId, options = {}) {
backedUp: false,
chatId: "",
reason: "missing-chat-id",
+ timings: finalizeSyncTimings({}, readSyncTimingNow()),
};
}
+ const backupStartedAt = readSyncTimingNow();
try {
const db = await getDb(normalizedChatId, options);
+ const exportStartedAt = readSyncTimingNow();
const snapshot = normalizeSyncSnapshot(
await db.exportSnapshot(),
normalizedChatId,
);
+ const exportMs = readSyncTimingNow() - exportStartedAt;
const nowMs = Date.now();
const deviceId = getOrCreateDeviceId();
@@ -2179,6 +2317,7 @@ export async function backupToServer(chatId, options = {}) {
nowMs,
);
+ const envelopeBuildStartedAt = readSyncTimingNow();
const backupSnapshot = buildManualBackupSnapshot(snapshot, normalizedChatId);
const envelope = {
kind: "st-bme-backup",
@@ -2188,15 +2327,18 @@ export async function backupToServer(chatId, options = {}) {
sourceDeviceId: deviceId,
snapshot: backupSnapshot,
};
+ const envelopeBuildMs = readSyncTimingNow() - envelopeBuildStartedAt;
const uploadResult = await writeBackupEnvelope(
envelope,
normalizedChatId,
options,
);
+ const uploadTimings = uploadResult?.timings || {};
const serializedEnvelope = JSON.stringify(envelope);
try {
+ const manifestWriteStartedAt = readSyncTimingNow();
await upsertBackupManifestEntry(
{
filename: uploadResult.filename,
@@ -2210,6 +2352,37 @@ export async function backupToServer(chatId, options = {}) {
},
options,
);
+ const manifestWriteMs = readSyncTimingNow() - manifestWriteStartedAt;
+ const metaPatchStartedAt = readSyncTimingNow();
+ await patchDbMeta(db, {
+ deviceId,
+ syncDirty: false,
+ syncDirtyReason: "",
+ lastBackupUploadedAt: nowMs,
+ lastBackupFilename: uploadResult.filename,
+ });
+ const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
+
+ return {
+ backedUp: true,
+ chatId: normalizedChatId,
+ filename: uploadResult.filename,
+ remotePath: uploadResult.path,
+ revision: normalizeRevision(snapshot.meta.revision),
+ backupTime: nowMs,
+ timings: finalizeSyncTimings(
+ {
+ exportMs,
+ envelopeBuildMs,
+ uploadMs: Number(uploadTimings.totalMs || 0),
+ envelopeSerializeMs: Number(uploadTimings.serializeMs || 0),
+ envelopeResponseParseMs: Number(uploadTimings.responseParseMs || 0),
+ manifestWriteMs,
+ metaPatchMs,
+ },
+ backupStartedAt,
+ ),
+ };
} catch (manifestError) {
return {
backedUp: false,
@@ -2219,25 +2392,18 @@ export async function backupToServer(chatId, options = {}) {
reason: "backup-manifest-error",
backupUploaded: true,
error: manifestError,
+ timings: finalizeSyncTimings(
+ {
+ exportMs,
+ envelopeBuildMs,
+ uploadMs: Number(uploadTimings.totalMs || 0),
+ envelopeSerializeMs: Number(uploadTimings.serializeMs || 0),
+ envelopeResponseParseMs: Number(uploadTimings.responseParseMs || 0),
+ },
+ backupStartedAt,
+ ),
};
}
-
- await patchDbMeta(db, {
- deviceId,
- syncDirty: false,
- syncDirtyReason: "",
- lastBackupUploadedAt: nowMs,
- lastBackupFilename: uploadResult.filename,
- });
-
- return {
- backedUp: true,
- chatId: normalizedChatId,
- filename: uploadResult.filename,
- remotePath: uploadResult.path,
- revision: normalizeRevision(snapshot.meta.revision),
- backupTime: nowMs,
- };
} catch (error) {
console.warn("[ST-BME] 手动备份到云端失败:", error);
return {
@@ -2245,6 +2411,7 @@ export async function backupToServer(chatId, options = {}) {
chatId: normalizedChatId,
reason: "backup-error",
error,
+ timings: finalizeSyncTimings({}, backupStartedAt),
};
}
}
@@ -2256,18 +2423,30 @@ export async function restoreFromServer(chatId, options = {}) {
restored: false,
chatId: "",
reason: "missing-chat-id",
+ timings: finalizeSyncTimings({}, readSyncTimingNow()),
};
}
+ const restoreStartedAt = readSyncTimingNow();
try {
const db = await getDb(normalizedChatId, options);
const remoteResult = await readBackupEnvelope(normalizedChatId, options);
+ const downloadTimings = remoteResult?.timings || {};
if (!remoteResult.exists || !remoteResult.envelope) {
return {
restored: false,
chatId: normalizedChatId,
filename: remoteResult.filename || "",
reason: remoteResult.reason || "backup-missing",
+ timings: finalizeSyncTimings(
+ {
+ downloadMs: Number(downloadTimings.totalMs || 0),
+ lookupMs: Number(downloadTimings.lookupMs || 0),
+ networkMs: Number(downloadTimings.networkMs || 0),
+ envelopeParseMs: Number(downloadTimings.parseMs || 0),
+ },
+ restoreStartedAt,
+ ),
};
}
@@ -2278,6 +2457,15 @@ export async function restoreFromServer(chatId, options = {}) {
chatId: normalizedChatId,
filename: remoteResult.filename,
reason: "backup-version-mismatch",
+ timings: finalizeSyncTimings(
+ {
+ downloadMs: Number(downloadTimings.totalMs || 0),
+ lookupMs: Number(downloadTimings.lookupMs || 0),
+ networkMs: Number(downloadTimings.networkMs || 0),
+ envelopeParseMs: Number(downloadTimings.parseMs || 0),
+ },
+ restoreStartedAt,
+ ),
};
}
@@ -2287,6 +2475,15 @@ export async function restoreFromServer(chatId, options = {}) {
chatId: normalizedChatId,
filename: remoteResult.filename,
reason: "backup-chat-id-mismatch",
+ timings: finalizeSyncTimings(
+ {
+ downloadMs: Number(downloadTimings.totalMs || 0),
+ lookupMs: Number(downloadTimings.lookupMs || 0),
+ networkMs: Number(downloadTimings.networkMs || 0),
+ envelopeParseMs: Number(downloadTimings.parseMs || 0),
+ },
+ restoreStartedAt,
+ ),
};
}
@@ -2304,26 +2501,42 @@ export async function restoreFromServer(chatId, options = {}) {
chatId: normalizedChatId,
filename: remoteResult.filename,
reason: "snapshot-chat-id-mismatch",
+ timings: finalizeSyncTimings(
+ {
+ downloadMs: Number(downloadTimings.totalMs || 0),
+ lookupMs: Number(downloadTimings.lookupMs || 0),
+ networkMs: Number(downloadTimings.networkMs || 0),
+ envelopeParseMs: Number(downloadTimings.parseMs || 0),
+ },
+ restoreStartedAt,
+ ),
};
}
+ const localExportStartedAt = readSyncTimingNow();
const localSnapshot = normalizeSyncSnapshot(
await db.exportSnapshot(),
normalizedChatId,
);
+ const localExportMs = readSyncTimingNow() - localExportStartedAt;
+ const safetySnapshotStartedAt = readSyncTimingNow();
await createRestoreSafetySnapshot(
normalizedChatId,
localSnapshot,
options,
);
+ const safetySnapshotMs = readSyncTimingNow() - safetySnapshotStartedAt;
+ const importStartedAt = readSyncTimingNow();
await db.importSnapshot(snapshot, {
mode: "replace",
preserveRevision: true,
revision: normalizeRevision(snapshot.meta.revision),
markSyncDirty: false,
});
+ const importMs = readSyncTimingNow() - importStartedAt;
+ const metaPatchStartedAt = readSyncTimingNow();
await patchDbMeta(db, {
deviceId: getOrCreateDeviceId(),
syncDirty: false,
@@ -2332,12 +2545,15 @@ export async function restoreFromServer(chatId, options = {}) {
lastBackupFilename:
remoteResult.filename || buildBackupFilename(normalizedChatId),
});
+ const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
+ const hookStartedAt = readSyncTimingNow();
await invokeSyncAppliedHook(options, {
chatId: normalizedChatId,
action: "restore-backup",
revision: normalizeRevision(snapshot.meta.revision),
});
+ const hookMs = readSyncTimingNow() - hookStartedAt;
return {
restored: true,
@@ -2345,6 +2561,20 @@ export async function restoreFromServer(chatId, options = {}) {
filename: remoteResult.filename,
revision: normalizeRevision(snapshot.meta.revision),
backupTime: normalizeTimestamp(envelope.createdAt, 0),
+ timings: finalizeSyncTimings(
+ {
+ downloadMs: Number(downloadTimings.totalMs || 0),
+ lookupMs: Number(downloadTimings.lookupMs || 0),
+ networkMs: Number(downloadTimings.networkMs || 0),
+ envelopeParseMs: Number(downloadTimings.parseMs || 0),
+ localExportMs,
+ safetySnapshotMs,
+ importMs,
+ metaPatchMs,
+ hookMs,
+ },
+ restoreStartedAt,
+ ),
};
} catch (error) {
console.warn("[ST-BME] 从云端恢复备份失败:", error);
@@ -2353,6 +2583,7 @@ export async function restoreFromServer(chatId, options = {}) {
chatId: normalizedChatId,
reason: "restore-error",
error,
+ timings: finalizeSyncTimings({}, restoreStartedAt),
};
}
}
@@ -2455,12 +2686,16 @@ export async function upload(chatId, options = {}) {
uploaded: false,
chatId: "",
reason: "missing-chat-id",
+ timings: finalizeSyncTimings({}, readSyncTimingNow()),
};
}
+ const uploadStartedAt = readSyncTimingNow();
try {
const db = await getDb(normalizedChatId, options);
+ const exportStartedAt = readSyncTimingNow();
const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId);
+ const exportMs = readSyncTimingNow() - exportStartedAt;
const nowMs = Date.now();
const deviceId = getOrCreateDeviceId();
@@ -2469,7 +2704,9 @@ export async function upload(chatId, options = {}) {
localSnapshot.meta.lastModified = normalizeTimestamp(localSnapshot.meta.lastModified, nowMs);
const uploadResult = await writeSnapshotToRemote(localSnapshot, normalizedChatId, options);
+ const uploadTimings = uploadResult?.timings || {};
+ const metaPatchStartedAt = readSyncTimingNow();
await patchDbMeta(db, {
deviceId,
lastSyncUploadedAt: nowMs,
@@ -2479,6 +2716,7 @@ export async function upload(chatId, options = {}) {
lastModified: localSnapshot.meta.lastModified,
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
});
+ const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
return {
uploaded: true,
@@ -2486,6 +2724,19 @@ export async function upload(chatId, options = {}) {
filename: uploadResult.filename,
remotePath: uploadResult.path,
revision: normalizeRevision(localSnapshot.meta.revision),
+ timings: finalizeSyncTimings(
+ {
+ exportMs,
+ envelopeBuildMs: Number(uploadTimings.envelopeBuildMs || 0),
+ chunkSerializeMs: Number(uploadTimings.chunkSerializeMs || 0),
+ chunkUploadMs: Number(uploadTimings.chunkUploadMs || 0),
+ manifestSerializeMs: Number(uploadTimings.manifestSerializeMs || 0),
+ manifestUploadMs: Number(uploadTimings.manifestUploadMs || 0),
+ responseParseMs: Number(uploadTimings.responseParseMs || 0),
+ metaPatchMs,
+ },
+ uploadStartedAt,
+ ),
};
} catch (error) {
console.warn("[ST-BME] 上传同步文件失败:", error);
@@ -2494,6 +2745,7 @@ export async function upload(chatId, options = {}) {
chatId: normalizedChatId,
reason: "upload-error",
error,
+ timings: finalizeSyncTimings({}, uploadStartedAt),
};
}
}
@@ -2506,12 +2758,15 @@ export async function download(chatId, options = {}) {
exists: false,
chatId: "",
reason: "missing-chat-id",
+ timings: finalizeSyncTimings({}, readSyncTimingNow()),
};
}
+ const downloadStartedAt = readSyncTimingNow();
try {
const db = await getDb(normalizedChatId, options);
const remoteResult = await readRemoteSnapshot(normalizedChatId, options);
+ const remoteTimings = remoteResult?.timings || {};
if (!remoteResult.exists || !remoteResult.snapshot) {
return {
@@ -2520,6 +2775,16 @@ export async function download(chatId, options = {}) {
chatId: normalizedChatId,
filename: remoteResult.filename || "",
reason: remoteResult.status || "remote-missing",
+ timings: finalizeSyncTimings(
+ {
+ resolveCandidatesMs: Number(remoteTimings.resolveCandidatesMs || 0),
+ networkMs: Number(remoteTimings.networkMs || 0),
+ parseMs: Number(remoteTimings.parseMs || 0),
+ chunkReadMs: Number(remoteTimings.chunkReadMs || 0),
+ normalizeMs: Number(remoteTimings.normalizeMs || 0),
+ },
+ downloadStartedAt,
+ ),
};
}
@@ -2530,13 +2795,16 @@ export async function download(chatId, options = {}) {
);
const remoteRevision = normalizeRevision(remoteSnapshot.meta.revision);
+ const importStartedAt = readSyncTimingNow();
await db.importSnapshot(remoteSnapshot, {
mode: "replace",
preserveRevision: true,
revision: remoteRevision,
markSyncDirty: false,
});
+ const importMs = readSyncTimingNow() - importStartedAt;
+ const metaPatchStartedAt = readSyncTimingNow();
await patchDbMeta(db, {
deviceId: getOrCreateDeviceId(),
lastSyncDownloadedAt: Date.now(),
@@ -2545,12 +2813,15 @@ export async function download(chatId, options = {}) {
syncDirtyReason: "",
remoteSyncFormatVersion: BME_REMOTE_SYNC_FORMAT_VERSION_V2,
});
+ const metaPatchMs = readSyncTimingNow() - metaPatchStartedAt;
+ const hookStartedAt = readSyncTimingNow();
await invokeSyncAppliedHook(options, {
chatId: normalizedChatId,
action: "download",
revision: remoteRevision,
});
+ const hookMs = readSyncTimingNow() - hookStartedAt;
return {
downloaded: true,
@@ -2558,6 +2829,19 @@ export async function download(chatId, options = {}) {
chatId: normalizedChatId,
filename: remoteResult.filename,
revision: remoteRevision,
+ timings: finalizeSyncTimings(
+ {
+ resolveCandidatesMs: Number(remoteTimings.resolveCandidatesMs || 0),
+ networkMs: Number(remoteTimings.networkMs || 0),
+ parseMs: Number(remoteTimings.parseMs || 0),
+ chunkReadMs: Number(remoteTimings.chunkReadMs || 0),
+ normalizeMs: Number(remoteTimings.normalizeMs || 0),
+ importMs,
+ metaPatchMs,
+ hookMs,
+ },
+ downloadStartedAt,
+ ),
};
} catch (error) {
console.warn("[ST-BME] 下载同步文件失败:", error);
@@ -2567,6 +2851,7 @@ export async function download(chatId, options = {}) {
chatId: normalizedChatId,
reason: "download-error",
error,
+ timings: finalizeSyncTimings({}, downloadStartedAt),
};
}
}
diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs
index 62d8f13..438dd36 100644
--- a/tests/graph-persistence.mjs
+++ b/tests/graph-persistence.mjs
@@ -3239,6 +3239,166 @@ result = {
);
}
+{
+ const chatId = "chat-idb-direct-delta-prebuilt-persist-snapshot";
+ const baseGraph = createMeaningfulGraph(chatId, "direct-delta-base");
+ const runtimeGraph = createMeaningfulGraph(chatId, "direct-delta-after");
+ const baseSnapshot = buildSnapshotFromGraph(baseGraph, {
+ chatId,
+ revision: 7,
+ });
+ const persistSnapshot = buildSnapshotFromGraph(runtimeGraph, {
+ chatId,
+ revision: 8,
+ baseSnapshot,
+ });
+ const directDelta = buildPersistDelta(baseSnapshot, persistSnapshot, {
+ useNativeDelta: false,
+ });
+ const harness = await createGraphPersistenceHarness({
+ chatId,
+ globalChatId: chatId,
+ chatMetadata: {
+ integrity: "meta-idb-direct-delta-prebuilt-persist-snapshot",
+ },
+ indexedDbSnapshot: baseSnapshot,
+ });
+ harness.api.setCurrentGraph(runtimeGraph);
+ harness.api.setGraphPersistenceState({
+ loadState: "loaded",
+ chatId,
+ revision: 8,
+ lastPersistedRevision: 0,
+ writesBlocked: false,
+ });
+
+ const originalBuildSnapshotFromGraph = harness.runtimeContext.buildSnapshotFromGraph;
+ let buildSnapshotCallCount = 0;
+ harness.runtimeContext.buildSnapshotFromGraph = (...args) => {
+ buildSnapshotCallCount += 1;
+ return originalBuildSnapshotFromGraph(...args);
+ };
+
+ const result = await harness.api.saveGraphToIndexedDb(chatId, runtimeGraph, {
+ revision: 8,
+ reason: "direct-delta-prebuilt-persist-snapshot-save",
+ scheduleCloudUpload: false,
+ persistDelta: directDelta,
+ persistSnapshot,
+ });
+
+ assert.equal(result.saved, true);
+ assert.equal(
+ buildSnapshotCallCount,
+ 0,
+ "direct-delta 且已提供 persistSnapshot 时不应再次构建 snapshot",
+ );
+ assert.equal(result.snapshot?.meta?.revision, 8);
+ assert.equal(harness.api.getIndexedDbSnapshot()?.meta?.revision, 8);
+}
+
+{
+ const chatId = "chat-indexeddb-probe-empty-early-return";
+ const persistedSnapshot = {
+ meta: { revision: 0, chatId },
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ state: {
+ lastProcessedFloor: -1,
+ extractionCount: 0,
+ },
+ };
+ const harness = await createGraphPersistenceHarness({
+ chatId,
+ globalChatId: chatId,
+ chatMetadata: {
+ integrity: "meta-indexeddb-probe-empty-early-return",
+ },
+ indexedDbSnapshot: persistedSnapshot,
+ });
+ harness.runtimeContext.__globalChatId = chatId;
+ harness.runtimeContext.__chatContext.chatId = chatId;
+ harness.api.setChatContext({
+ ...harness.api.getChatContext(),
+ chatId,
+ chatMetadata: {
+ integrity: "meta-indexeddb-probe-empty-early-return",
+ },
+ });
+ harness.api.setCurrentGraph(
+ createMeaningfulGraph(chatId, "probe-empty-runtime-current"),
+ );
+ harness.api.setGraphPersistenceState({
+ loadState: "loaded",
+ chatId,
+ revision: 1,
+ lastPersistedRevision: 1,
+ storagePrimary: "indexeddb",
+ storageMode: "indexeddb",
+ writesBlocked: false,
+ });
+
+ const originalCreateDb = harness.runtimeContext.BmeChatManager.prototype._createDb;
+ let exportSnapshotCalls = 0;
+ let exportProbeCalls = 0;
+ harness.runtimeContext.BmeChatManager.prototype._createDb = function(dbChatId = "") {
+ const baseDb = originalCreateDb.call(this, dbChatId);
+ return {
+ ...baseDb,
+ async exportSnapshot() {
+ exportSnapshotCalls += 1;
+ return await baseDb.exportSnapshot();
+ },
+ async exportSnapshotProbe() {
+ exportProbeCalls += 1;
+ const snapshot = harness.api.getIndexedDbSnapshotForChat(dbChatId) || {
+ meta: { revision: 0, chatId: String(dbChatId || "") },
+ state: { lastProcessedFloor: -1, extractionCount: 0 },
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ };
+ return {
+ meta: {
+ ...(snapshot.meta || {}),
+ chatId: String(dbChatId || ""),
+ revision: Number(snapshot?.meta?.revision || 0),
+ nodeCount: Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0,
+ edgeCount: Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0,
+ tombstoneCount: Array.isArray(snapshot?.tombstones)
+ ? snapshot.tombstones.length
+ : 0,
+ },
+ state: {
+ lastProcessedFloor: Number(snapshot?.state?.lastProcessedFloor ?? -1),
+ extractionCount: Number(snapshot?.state?.extractionCount ?? 0),
+ },
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ __stBmeProbeOnly: true,
+ __stBmeTombstonesOmitted: true,
+ };
+ },
+ };
+ };
+
+ const result = await harness.api.loadGraphFromIndexedDb(chatId, {
+ source: "probe-empty-early-return",
+ attemptIndex: 0,
+ });
+
+ assert.equal(result.loaded, false);
+ assert.equal(exportProbeCalls, 1);
+ assert.equal(
+ exportSnapshotCalls,
+ 0,
+ "empty/probe 早退应在 probe 阶段终止,而不是继续全量导出 snapshot",
+ );
+ harness.runtimeContext.BmeChatManager.prototype._createDb = originalCreateDb;
+}
+
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-pending-persist-retry",
diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs
index 5d31269..f9f0855 100644
--- a/tests/indexeddb-persistence.mjs
+++ b/tests/indexeddb-persistence.mjs
@@ -235,6 +235,49 @@ async function testSnapshotExportWithoutTombstones() {
await db.close();
}
+async function testSnapshotProbeExport() {
+ const db = new BmeDatabase("chat-export-probe", {
+ dexieClass: globalThis.Dexie,
+ });
+ await db.open();
+
+ await db.bulkUpsertNodes([
+ {
+ id: "node-probe",
+ type: "event",
+ sourceFloor: 4,
+ archived: false,
+ updatedAt: Date.now(),
+ },
+ ]);
+ await db.patchMeta({
+ lastProcessedFloor: 6,
+ extractionCount: 3,
+ runtimeHistoryState: {
+ chatId: "chat-export-probe",
+ lastProcessedAssistantFloor: 6,
+ extractionCount: 3,
+ },
+ });
+
+ const probe = await db.exportSnapshotProbe();
+ assert.equal(probe.__stBmeProbeOnly, true);
+ assert.equal(probe.__stBmeTombstonesOmitted, true);
+ assert.deepEqual(probe.nodes, []);
+ assert.deepEqual(probe.edges, []);
+ assert.deepEqual(probe.tombstones, []);
+ assert.equal(probe.meta.chatId, "chat-export-probe");
+ assert.equal(probe.meta.nodeCount, 1);
+ assert.equal(probe.state.lastProcessedFloor, 6);
+ assert.equal(probe.state.extractionCount, 3);
+ assert.equal(
+ probe.meta.runtimeHistoryState.lastProcessedAssistantFloor,
+ 6,
+ );
+
+ await db.close();
+}
+
async function testReplaceImportResetsStaleMeta() {
const chatId = "chat-replace-reset";
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
@@ -577,29 +620,58 @@ async function testGraphSnapshotConverters() {
updatedAt: Date.now(),
});
+ let snapshotDiagnostics = null;
const snapshot = buildSnapshotFromGraph(graph, {
chatId: "chat-a",
revision: 17,
+ onDiagnostics(snapshotValue) {
+ snapshotDiagnostics = snapshotValue;
+ },
});
assert.equal(snapshot.meta.chatId, "chat-a");
assert.equal(snapshot.meta.revision, 17);
assert.equal(snapshot.state.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4);
assert.equal(snapshot.nodes.length, 1);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.nodesMs), true);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.edgesMs), true);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.tombstonesMs), true);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.stateMs), true);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.metaMs), true);
+ assert.equal(Number.isFinite(snapshotDiagnostics?.totalMs), true);
+ assert.equal(snapshotDiagnostics?.nodeCount, 1);
+ let hydrateDiagnostics = null;
const nextGraph = buildGraphFromSnapshot(snapshot, {
chatId: "chat-a",
+ onDiagnostics(snapshotValue) {
+ hydrateDiagnostics = snapshotValue;
+ },
});
+ assert.equal(hydrateDiagnostics?.success, true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.nodesMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.edgesMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.runtimeMetaMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.stateMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.normalizeMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.integrityMs), true);
+ assert.equal(Number.isFinite(hydrateDiagnostics?.totalMs), true);
+
+ let reusedSnapshotDiagnostics = null;
const reusedSnapshot = buildSnapshotFromGraph(nextGraph, {
chatId: "chat-a",
revision: 18,
baseSnapshot: snapshot,
+ onDiagnostics(snapshotValue) {
+ reusedSnapshotDiagnostics = snapshotValue;
+ },
});
assert.equal(
reusedSnapshot.nodes[0],
snapshot.nodes[0],
"未变化节点应直接复用 baseSnapshot 记录对象",
);
+ assert.equal(reusedSnapshotDiagnostics?.reusedNodeCount, 1);
nextGraph.nodes[0].updatedAt = Number(nextGraph.nodes[0].updatedAt || 0) + 1;
const changedSnapshot = buildSnapshotFromGraph(nextGraph, {
chatId: "chat-a",
@@ -662,6 +734,7 @@ async function main() {
await testTransactionRollback();
await testSnapshotExportImport();
await testSnapshotExportWithoutTombstones();
+ await testSnapshotProbeExport();
await testReplaceImportResetsStaleMeta();
await testRevisionMonotonicity();
await testTombstonePrune();
diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs
index 6c649ac..b373fc8 100644
--- a/tests/indexeddb-sync.mjs
+++ b/tests/indexeddb-sync.mjs
@@ -298,6 +298,10 @@ async function testUploadPayloadMetaFirstAndDebounce() {
assert.equal(uploadResult.uploaded, true);
assert.equal(logs.uploadCalls, 1);
assert.equal(logs.uploadChunkCalls > 0, true);
+ assert.equal(Number.isFinite(uploadResult.timings?.exportMs), true);
+ assert.equal(Number.isFinite(uploadResult.timings?.chunkUploadMs), true);
+ assert.equal(Number.isFinite(uploadResult.timings?.manifestUploadMs), true);
+ assert.equal(Number.isFinite(uploadResult.timings?.metaPatchMs), true);
const uploadedPayload = logs.uploadedPayloads[0].payload;
assert.equal(uploadedPayload.formatVersion, 2);
@@ -375,6 +379,10 @@ async function testDownloadImport() {
const result = await download("chat-download", runtime);
assert.equal(result.downloaded, true);
+ assert.equal(Number.isFinite(result.timings?.networkMs), true);
+ assert.equal(Number.isFinite(result.timings?.importMs), true);
+ assert.equal(Number.isFinite(result.timings?.metaPatchMs), true);
+ assert.equal(Number.isFinite(result.timings?.hookMs), true);
assert.equal(db.lastImportPayload.meta.revision, 12);
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node");
assert.equal(db.lastImportPayload.meta.runtimeVectorIndexState.dirty, true);
@@ -731,6 +739,10 @@ async function testManualBackupAndRestoreFlow() {
const backupResult = await backupToServer("chat-backup-flow", runtime);
assert.equal(backupResult.backedUp, true);
+ assert.equal(Number.isFinite(backupResult.timings?.exportMs), true);
+ assert.equal(Number.isFinite(backupResult.timings?.uploadMs), true);
+ assert.equal(Number.isFinite(backupResult.timings?.manifestWriteMs), true);
+ assert.equal(Number.isFinite(backupResult.timings?.metaPatchMs), true);
assert.equal(db.meta.get("syncDirty"), false);
assert.ok(Number(db.meta.get("lastBackupUploadedAt")) > 0);
assert.ok(String(db.meta.get("lastBackupFilename") || "").startsWith("ST-BME_backup_"));
@@ -801,6 +813,12 @@ async function testManualBackupAndRestoreFlow() {
const restoreResult = await restoreFromServer("chat-backup-flow", runtime);
assert.equal(restoreResult.restored, true);
+ assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
+ assert.equal(Number.isFinite(restoreResult.timings?.localExportMs), true);
+ assert.equal(Number.isFinite(restoreResult.timings?.safetySnapshotMs), true);
+ assert.equal(Number.isFinite(restoreResult.timings?.importMs), true);
+ assert.equal(Number.isFinite(restoreResult.timings?.metaPatchMs), true);
+ assert.equal(Number.isFinite(restoreResult.timings?.hookMs), true);
assert.equal(db.snapshot.nodes[0].id, "local-node");
assert.equal(db.snapshot.meta.runtimeBatchJournal.length, 4);
assert.equal(db.snapshot.meta.maintenanceJournal.length, 0);
@@ -963,6 +981,7 @@ async function testRestoreValidationDoesNotCreateSafetySnapshot() {
const restoreResult = await restoreFromServer("chat-no-backup", runtime);
assert.equal(restoreResult.restored, false);
assert.equal(restoreResult.reason, "not-found");
+ assert.equal(Number.isFinite(restoreResult.timings?.downloadMs), true);
const safetyStatus = await getRestoreSafetySnapshotStatus(
"chat-no-backup",
diff --git a/tests/opfs-meta-fast-path.mjs b/tests/opfs-meta-fast-path.mjs
index e1c74fc..f18e6ca 100644
--- a/tests/opfs-meta-fast-path.mjs
+++ b/tests/opfs-meta-fast-path.mjs
@@ -56,12 +56,18 @@ await store.patchMeta({
lastProcessedFloor: 9,
extractionCount: 4,
});
+const probe = await store.exportSnapshotProbe();
assert.equal(
loadSnapshotCalls,
0,
"manifest-only meta fast path should not load full snapshot",
);
+assert.equal(probe.__stBmeProbeOnly, true);
+assert.equal(probe.meta.lastBackupFilename, "after.json");
+assert.equal(probe.meta.nodeCount, 1);
+assert.equal(probe.state.lastProcessedFloor, 9);
+assert.equal(probe.state.extractionCount, 4);
const snapshot = await originalLoadSnapshot();
assert.equal(snapshot.meta.lastBackupFilename, "after.json");
diff --git a/tests/opfs-write-serialization.mjs b/tests/opfs-write-serialization.mjs
index 9cf6dff..c6bbd44 100644
--- a/tests/opfs-write-serialization.mjs
+++ b/tests/opfs-write-serialization.mjs
@@ -261,7 +261,62 @@ async function testGraphLikeDeltaPreservesHistoryFrontier() {
);
}
+async function testCommitDeltaDiagnosticsSplitWalAndManifestStages() {
+ const rootDirectory = createMemoryOpfsRoot();
+ const store = new OpfsGraphStore("chat-opfs-diagnostics-split", {
+ rootDirectoryFactory: async () => rootDirectory,
+ storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
+ });
+ await store.open();
+
+ await store.importSnapshot(
+ {
+ meta: { revision: 1 },
+ state: { lastProcessedFloor: 0, extractionCount: 0 },
+ nodes: [],
+ edges: [],
+ tombstones: [],
+ },
+ { mode: "replace", preserveRevision: true },
+ );
+
+ const result = await store.commitDelta(
+ {
+ upsertNodes: [
+ {
+ id: "diag-node-1",
+ type: "event",
+ fields: { title: "diag" },
+ archived: false,
+ updatedAt: 10,
+ },
+ ],
+ },
+ {
+ reason: "diagnostics-split",
+ requestedRevision: 2,
+ markSyncDirty: true,
+ },
+ );
+
+ assert.equal(Number.isFinite(result.diagnostics?.walSerializeMs), true);
+ assert.equal(Number.isFinite(result.diagnostics?.walFileWriteMs), true);
+ assert.equal(Number.isFinite(result.diagnostics?.walWriteMs), true);
+ assert.equal(Number.isFinite(result.diagnostics?.manifestSerializeMs), true);
+ assert.equal(Number.isFinite(result.diagnostics?.manifestFileWriteMs), true);
+ assert.equal(Number.isFinite(result.diagnostics?.manifestWriteMs), true);
+ assert.equal(
+ result.diagnostics.walWriteMs >= result.diagnostics.walSerializeMs,
+ true,
+ );
+ assert.equal(
+ result.diagnostics.manifestWriteMs >= result.diagnostics.manifestSerializeMs,
+ true,
+ );
+}
+
await testCommitDeltaAndPatchMetaSerialize();
await testImportSnapshotAndClearAllSerialize();
await testGraphLikeDeltaPreservesHistoryFrontier();
+await testCommitDeltaDiagnosticsSplitWalAndManifestStages();
console.log("opfs-write-serialization tests passed");
diff --git a/tests/perf/persist-load-bench.mjs b/tests/perf/persist-load-bench.mjs
new file mode 100644
index 0000000..a33f5c0
--- /dev/null
+++ b/tests/perf/persist-load-bench.mjs
@@ -0,0 +1,326 @@
+import { performance } from "node:perf_hooks";
+
+import {
+ buildGraphFromSnapshot,
+ buildPersistDelta,
+ buildSnapshotFromGraph,
+} from "../../sync/bme-db.js";
+import {
+ BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
+ OpfsGraphStore,
+} from "../../sync/bme-opfs-store.js";
+import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
+
+const RUNS = 4;
+const SIZE_PRESETS = [
+ { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
+ { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
+ { label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600, churn: 0.12 },
+];
+
+function summarize(values = []) {
+ if (!values.length) {
+ return { avg: 0, p95: 0, min: 0, max: 0 };
+ }
+ const sorted = [...values].sort((a, b) => a - b);
+ const sum = sorted.reduce((acc, value) => acc + value, 0);
+ const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
+ return {
+ avg: sum / sorted.length,
+ p95: sorted[p95Index],
+ min: sorted[0],
+ max: sorted[sorted.length - 1],
+ };
+}
+
+function formatSummary(label, values = []) {
+ const summary = summarize(values);
+ return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
+}
+
+function createRandom(seed = 1) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0xffffffff;
+ };
+}
+
+function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
+ const rand = createRandom(seed);
+ const nodes = [];
+ const edges = [];
+ for (let index = 0; index < nodeCount; index += 1) {
+ nodes.push({
+ id: `node-${index}`,
+ type: "event",
+ updatedAt: 1000 + index,
+ archived: false,
+ sourceFloor: index,
+ fields: {
+ title: `Node ${index}`,
+ text: `node-${index}-${Math.floor(rand() * 100000)}`,
+ },
+ });
+ }
+ for (let index = 0; index < edgeCount; index += 1) {
+ const fromIndex = Math.floor(rand() * nodeCount);
+ let toIndex = Math.floor(rand() * nodeCount);
+ if (toIndex === fromIndex) {
+ toIndex = (toIndex + 1) % nodeCount;
+ }
+ edges.push({
+ id: `edge-${index}`,
+ fromId: `node-${fromIndex}`,
+ toId: `node-${toIndex}`,
+ relation: "related",
+ strength: rand(),
+ updatedAt: 2000 + index,
+ });
+ }
+ return {
+ version: 1,
+ nodes,
+ edges,
+ historyState: {
+ chatId,
+ lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
+ extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
+ processedMessageHashes: {},
+ processedMessageHashVersion: 1,
+ processedMessageHashesNeedRefresh: false,
+ recentRecallOwnerKeys: [],
+ activeRecallOwnerKey: "",
+ activeRegion: "",
+ activeRegionSource: "",
+ activeStorySegmentId: "",
+ activeStoryTimeLabel: "",
+ activeStoryTimeSource: "",
+ lastBatchStatus: null,
+ lastMutationSource: "bench",
+ lastExtractedRegion: "",
+ lastExtractedStorySegmentId: "",
+ activeCharacterPovOwner: "",
+ activeUserPovOwner: "",
+ },
+ vectorIndexState: {
+ chatId,
+ collectionId: "",
+ hashToNodeId: {},
+ nodeToHash: {},
+ replayRequiredNodeIds: [],
+ dirty: false,
+ dirtyReason: "",
+ pendingRepairFromFloor: null,
+ lastIntegrityIssue: null,
+ lastStats: {
+ nodesIndexed: 0,
+ updatedAt: 0,
+ },
+ },
+ knowledgeState: {
+ owners: {},
+ activeOwnerKey: "",
+ },
+ regionState: {
+ activeRegion: "",
+ knownRegions: {},
+ manualActiveRegion: "",
+ },
+ timelineState: {
+ activeSegmentId: "",
+ manualActiveSegmentId: "",
+ segments: [],
+ },
+ summaryState: {
+ updatedAt: 0,
+ entries: [],
+ },
+ batchJournal: [],
+ maintenanceJournal: [],
+ lastRecallResult: null,
+ lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
+ };
+}
+
+function mutateRuntimeGraph(baseGraph, seed = 1, churn = 0.1) {
+ const rand = createRandom(seed);
+ const nextGraph = structuredClone(baseGraph);
+ const mutateNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn));
+ const mutateEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.5));
+ for (let index = 0; index < mutateNodeCount; index += 1) {
+ const nodeIndex = Math.floor(rand() * nextGraph.nodes.length);
+ const node = nextGraph.nodes[nodeIndex];
+ node.updatedAt += 100 + index;
+ node.fields.text = `${node.fields.text}-mut-${index}`;
+ }
+ for (let index = 0; index < mutateEdgeCount; index += 1) {
+ const edgeIndex = Math.floor(rand() * nextGraph.edges.length);
+ const edge = nextGraph.edges[edgeIndex];
+ edge.updatedAt += 80 + index;
+ edge.strength = rand();
+ }
+ const addNodeCount = Math.max(1, Math.floor(nextGraph.nodes.length * churn * 0.12));
+ const baseNodeId = nextGraph.nodes.length;
+ for (let index = 0; index < addNodeCount; index += 1) {
+ nextGraph.nodes.push({
+ id: `node-new-${baseNodeId + index}`,
+ type: "event",
+ updatedAt: 5000 + index,
+ archived: false,
+ sourceFloor: baseNodeId + index,
+ fields: {
+ title: `Node new ${index}`,
+ text: `new-node-${index}`,
+ },
+ });
+ }
+ const deleteEdgeCount = Math.max(1, Math.floor(nextGraph.edges.length * churn * 0.08));
+ nextGraph.edges.splice(0, deleteEdgeCount);
+ nextGraph.historyState.lastProcessedAssistantFloor += 1;
+ nextGraph.historyState.extractionCount += 1;
+ nextGraph.lastProcessedSeq = nextGraph.historyState.lastProcessedAssistantFloor;
+ nextGraph.summaryState.updatedAt += 1;
+ return nextGraph;
+}
+
+function buildBenchPair({ label, seed, nodeCount, edgeCount, churn }) {
+ const chatId = `bench-${label.toLowerCase()}`;
+ const beforeGraph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
+ const afterGraph = mutateRuntimeGraph(beforeGraph, seed + 101, churn);
+ return {
+ label,
+ chatId,
+ beforeGraph,
+ afterGraph,
+ };
+}
+
+function measureSnapshotBuild(graph, options) {
+ let diagnostics = null;
+ const startedAt = performance.now();
+ const snapshot = buildSnapshotFromGraph(graph, {
+ ...options,
+ onDiagnostics(snapshotValue) {
+ diagnostics = snapshotValue;
+ },
+ });
+ return {
+ elapsedMs: performance.now() - startedAt,
+ snapshot,
+ diagnostics,
+ };
+}
+
+function measureHydrate(snapshot, chatId) {
+ let diagnostics = null;
+ const startedAt = performance.now();
+ buildGraphFromSnapshot(snapshot, {
+ chatId,
+ onDiagnostics(snapshotValue) {
+ diagnostics = snapshotValue;
+ },
+ });
+ return {
+ elapsedMs: performance.now() - startedAt,
+ diagnostics,
+ };
+}
+
+async function measureOpfsCommit(baseSnapshot, afterSnapshot, delta, chatId) {
+ const rootDirectory = createMemoryOpfsRoot();
+ const store = new OpfsGraphStore(chatId, {
+ rootDirectoryFactory: async () => rootDirectory,
+ storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
+ });
+ await store.open();
+ await store.importSnapshot(baseSnapshot, {
+ mode: "replace",
+ preserveRevision: true,
+ markSyncDirty: false,
+ });
+ const startedAt = performance.now();
+ const result = await store.commitDelta(delta, {
+ reason: "bench-commit",
+ requestedRevision: Number(afterSnapshot?.meta?.revision || 0),
+ markSyncDirty: true,
+ committedSnapshot: afterSnapshot,
+ });
+ const elapsedMs = performance.now() - startedAt;
+ await store.close();
+ return {
+ elapsedMs,
+ diagnostics: result?.diagnostics || {},
+ };
+}
+
+async function runPreset(preset) {
+ const snapshotBuildSamples = [];
+ const hydrateSamples = [];
+ const opfsCommitSamples = [];
+ const snapshotNodesSamples = [];
+ const hydrateRuntimeMetaSamples = [];
+ const walFileWriteSamples = [];
+ const manifestFileWriteSamples = [];
+
+ for (let run = 0; run < RUNS; run += 1) {
+ const pair = buildBenchPair({
+ ...preset,
+ seed: preset.seed + run * 17,
+ });
+ const beforeSnapshotResult = measureSnapshotBuild(pair.beforeGraph, {
+ chatId: pair.chatId,
+ revision: 1,
+ });
+ const afterSnapshotResult = measureSnapshotBuild(pair.afterGraph, {
+ chatId: pair.chatId,
+ revision: 2,
+ baseSnapshot: beforeSnapshotResult.snapshot,
+ });
+ const delta = buildPersistDelta(
+ beforeSnapshotResult.snapshot,
+ afterSnapshotResult.snapshot,
+ { useNativeDelta: false },
+ );
+ const hydrateResult = measureHydrate(afterSnapshotResult.snapshot, pair.chatId);
+ const opfsCommitResult = await measureOpfsCommit(
+ beforeSnapshotResult.snapshot,
+ afterSnapshotResult.snapshot,
+ delta,
+ pair.chatId,
+ );
+
+ snapshotBuildSamples.push(afterSnapshotResult.elapsedMs);
+ hydrateSamples.push(hydrateResult.elapsedMs);
+ opfsCommitSamples.push(opfsCommitResult.elapsedMs);
+ snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
+ hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0));
+ walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
+ manifestFileWriteSamples.push(
+ Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
+ );
+ }
+
+ console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
+ console.log(
+ formatSummary("snapshot-build", snapshotBuildSamples),
+ `nodesPhaseP95=${summarize(snapshotNodesSamples).p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("hydrate", hydrateSamples),
+ `runtimeMetaP95=${summarize(hydrateRuntimeMetaSamples).p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("opfs-commit", opfsCommitSamples),
+ `walFileP95=${summarize(walFileWriteSamples).p95.toFixed(2)}ms`,
+ `manifestFileP95=${summarize(manifestFileWriteSamples).p95.toFixed(2)}ms`,
+ );
+}
+
+async function main() {
+ for (const preset of SIZE_PRESETS) {
+ await runPreset(preset);
+ }
+}
+
+await main();
diff --git a/ui/panel.js b/ui/panel.js
index 7aa4b29..0bd708b 100644
--- a/ui/panel.js
+++ b/ui/panel.js
@@ -1564,12 +1564,22 @@ function _formatPersistCommitBreakdownText(diagnostics = null) {
snapshot.commitManifestReadMs
? `manifest-read ${_formatDurationMs(snapshot.commitManifestReadMs)}`
: "",
- snapshot.commitWalWriteMs
- ? `wal ${_formatDurationMs(snapshot.commitWalWriteMs)}`
+ snapshot.commitWalSerializeMs
+ ? `wal-serialize ${_formatDurationMs(snapshot.commitWalSerializeMs)}`
: "",
- snapshot.commitManifestWriteMs
- ? `manifest-write ${_formatDurationMs(snapshot.commitManifestWriteMs)}`
+ snapshot.commitWalFileWriteMs
+ ? `wal-file ${_formatDurationMs(snapshot.commitWalFileWriteMs)}`
+ : snapshot.commitWalWriteMs
+ ? `wal ${_formatDurationMs(snapshot.commitWalWriteMs)}`
+ : "",
+ snapshot.commitManifestSerializeMs
+ ? `manifest-serialize ${_formatDurationMs(snapshot.commitManifestSerializeMs)}`
: "",
+ snapshot.commitManifestFileWriteMs
+ ? `manifest-file ${_formatDurationMs(snapshot.commitManifestFileWriteMs)}`
+ : snapshot.commitManifestWriteMs
+ ? `manifest-write ${_formatDurationMs(snapshot.commitManifestWriteMs)}`
+ : "",
snapshot.commitCacheApplyMs
? `cache ${_formatDurationMs(snapshot.commitCacheApplyMs)}`
: "",
@@ -1577,6 +1587,70 @@ function _formatPersistCommitBreakdownText(diagnostics = null) {
return parts.join(" · ") || "—";
}
+function _formatPersistSnapshotBuildBreakdownText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const parts = [
+ snapshot.snapshotNodesMs
+ ? `nodes ${_formatDurationMs(snapshot.snapshotNodesMs)}`
+ : "",
+ snapshot.snapshotEdgesMs
+ ? `edges ${_formatDurationMs(snapshot.snapshotEdgesMs)}`
+ : "",
+ snapshot.snapshotTombstonesMs
+ ? `tombstones ${_formatDurationMs(snapshot.snapshotTombstonesMs)}`
+ : "",
+ snapshot.snapshotStateMs
+ ? `state ${_formatDurationMs(snapshot.snapshotStateMs)}`
+ : "",
+ snapshot.snapshotMetaMs
+ ? `meta ${_formatDurationMs(snapshot.snapshotMetaMs)}`
+ : "",
+ ].filter(Boolean);
+ return parts.join(" · ") || "—";
+}
+
+function _formatLoadHydrateBreakdownText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const parts = [
+ snapshot.hydrateNodesMs
+ ? `nodes ${_formatDurationMs(snapshot.hydrateNodesMs)}`
+ : "",
+ snapshot.hydrateEdgesMs
+ ? `edges ${_formatDurationMs(snapshot.hydrateEdgesMs)}`
+ : "",
+ snapshot.hydrateRuntimeMetaMs
+ ? `meta ${_formatDurationMs(snapshot.hydrateRuntimeMetaMs)}`
+ : "",
+ snapshot.hydrateStateMs
+ ? `state ${_formatDurationMs(snapshot.hydrateStateMs)}`
+ : "",
+ snapshot.hydrateNormalizeMs
+ ? `normalize ${_formatDurationMs(snapshot.hydrateNormalizeMs)}`
+ : "",
+ snapshot.hydrateIntegrityMs
+ ? `integrity ${_formatDurationMs(snapshot.hydrateIntegrityMs)}`
+ : "",
+ ].filter(Boolean);
+ return parts.join(" · ") || "—";
+}
+
+function _formatPersistObservabilityText(diagnostics = null) {
+ const snapshot = _readPersistenceDiagnosticObject(diagnostics);
+ if (!snapshot) return "—";
+ const parts = [];
+ const pathKey = String(snapshot.pathKey || snapshot.path || "").trim();
+ const reasonKey = String(snapshot.reasonKey || snapshot.saveReason || "").trim();
+ const pathCount = Number(snapshot.pathSampleCount || 0);
+ const reasonCount = Number(snapshot.reasonSampleCount || 0);
+ if (pathKey) parts.push(`path ${pathKey}`);
+ if (pathCount > 0) parts.push(`${pathCount} samples`);
+ if (reasonKey) parts.push(`reason ${reasonKey}`);
+ if (reasonCount > 0) parts.push(`${reasonCount} reason-hits`);
+ return parts.join(" · ") || "—";
+}
+
function _formatPersistCommitBytesText(diagnostics = null) {
const snapshot = _readPersistenceDiagnosticObject(diagnostics);
if (!snapshot) return "—";
@@ -1616,6 +1690,7 @@ function _buildLoadDiagnosticRows(loadDiagnostics = null) {
["导出快照", _formatDurationMs(diagnostics.exportSnapshotMs)],
["前置(除导出)", _formatDurationMs(diagnostics.preApplyOtherMs)],
["Hydrate", _formatDurationMs(diagnostics.hydrateMs)],
+ ["Hydrate 细分", _formatLoadHydrateBreakdownText(diagnostics)],
["Apply 调用", _formatDurationMs(diagnostics.applyInvokeMs)],
["Apply 运行", _formatDurationMs(diagnostics.applyRuntimeMs)],
["Load 未归因", _formatDurationMs(diagnostics.untrackedMs)],
@@ -1659,6 +1734,7 @@ function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
["构建耗时", _formatDurationMs(diagnostics.buildMs)],
["Base 快照读取", _formatDurationMs(diagnostics.baseSnapshotReadMs)],
["图谱快照构建", _formatDurationMs(diagnostics.snapshotBuildMs)],
+ ["快照构建细分", _formatPersistSnapshotBuildBreakdownText(diagnostics)],
[
"Prepare / Native",
`${_formatDurationMs(diagnostics.prepareMs)} / ${_formatDurationMs(diagnostics.nativeAttemptMs)}`,
@@ -1671,6 +1747,7 @@ function _buildPersistDeltaDiagnosticRows(persistDelta = null) {
["Commit 排队 / 提交", commitPhaseText],
["Commit 细分", commitBreakdownText],
["Commit Payload", commitBytesText],
+ ["样本聚合", _formatPersistObservabilityText(diagnostics)],
["Preload", String(diagnostics.preloadStatus || "—")],
["Native 来源", String(diagnostics.moduleSource || "—")],
["Fallback 原因", String(diagnostics.fallbackReason || "—")],
diff --git a/ui/ui-status.js b/ui/ui-status.js
index debedd3..ccf295a 100644
--- a/ui/ui-status.js
+++ b/ui/ui-status.js
@@ -125,6 +125,16 @@ export function createGraphPersistenceState() {
lastSyncError: "",
dualWriteLastResult: null,
persistDelta: null,
+ persistObservability: {
+ totalSamples: 0,
+ byPath: {},
+ byReason: {},
+ byPathReason: {},
+ lastPathKey: "",
+ lastReasonKey: "",
+ lastPathReasonKey: "",
+ lastRecordedAt: "",
+ },
loadDiagnostics: null,
updatedAt: new Date().toISOString(),
};
From 913b71e2197915cc58d3cbb75976c038bd5a99af Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 10:35:32 +0000
Subject: [PATCH 14/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index f9c811a..99f3678 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.5.0",
+ "version": "5.5.1",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 37c6266a81e1306ab9098ab38ce5dbab9025cc56 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 10:36:35 +0000
Subject: [PATCH 15/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 99f3678..58213c8 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.5.1",
+ "version": "5.5.2",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From e880fe0b39743f27a7384c8de0509fe45992df89 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 19:31:44 +0800
Subject: [PATCH 16/74] perf: complete persist-load P2 hydration pass
---
graph/memory-scope.js | 45 ++++
graph/story-timeline.js | 46 ++++
package.json | 3 +
runtime/runtime-state.js | 19 +-
scripts/compare-p1-bench.mjs | 196 ++++++++++++++
sync/bme-db.js | 176 ++++++++++++-
tests/indexeddb-persistence.mjs | 60 +++++
tests/perf/load-preapply-bench.mjs | 397 +++++++++++++++++++++++++++++
tests/perf/persist-load-bench.mjs | 69 +++--
tests/scoped-memory.mjs | 49 ++++
10 files changed, 1037 insertions(+), 23 deletions(-)
create mode 100644 scripts/compare-p1-bench.mjs
create mode 100644 tests/perf/load-preapply-bench.mjs
diff --git a/graph/memory-scope.js b/graph/memory-scope.js
index c2a7f94..7731b67 100644
--- a/graph/memory-scope.js
+++ b/graph/memory-scope.js
@@ -58,6 +58,48 @@ function normalizeStringArray(values = []) {
return result;
}
+function isAlreadyNormalizedStringArray(values = []) {
+ if (!Array.isArray(values)) return false;
+ const seen = new Set();
+ for (const value of values) {
+ if (typeof value !== "string") return false;
+ const normalized = normalizeString(value);
+ const key = normalizeKey(normalized);
+ if (!normalized || normalized !== value || seen.has(key)) {
+ return false;
+ }
+ seen.add(key);
+ }
+ return true;
+}
+
+function canReuseNormalizedMemoryScope(scope = {}, defaults = {}) {
+ if (
+ !scope ||
+ typeof scope !== "object" ||
+ Array.isArray(scope) ||
+ (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0)
+ ) {
+ return false;
+ }
+ const layer = normalizeLayer(scope.layer);
+ const ownerType = normalizeOwnerType(layer, normalizeString(scope.ownerType));
+ const ownerId = ownerType
+ ? normalizeString(scope.ownerId || scope.ownerName)
+ : "";
+ const ownerName = ownerType ? normalizeString(scope.ownerName) : "";
+ const regionPrimary = normalizeString(scope.regionPrimary);
+ return (
+ scope.layer === layer &&
+ normalizeString(scope.ownerType) === ownerType &&
+ normalizeString(scope.ownerId || "") === ownerId &&
+ normalizeString(scope.ownerName || "") === ownerName &&
+ normalizeString(scope.regionPrimary || "") === regionPrimary &&
+ isAlreadyNormalizedStringArray(scope.regionPath) &&
+ isAlreadyNormalizedStringArray(scope.regionSecondary)
+ );
+}
+
function normalizeOwnerValueSet(values = []) {
return new Set(
normalizeStringArray(values).map((value) => normalizeKey(value)),
@@ -88,6 +130,9 @@ export function createDefaultMemoryScope(overrides = {}) {
}
export function normalizeMemoryScope(scope = {}, defaults = {}) {
+ if (canReuseNormalizedMemoryScope(scope, defaults)) {
+ return scope;
+ }
const merged = {
...DEFAULT_MEMORY_SCOPE,
...(defaults || {}),
diff --git a/graph/story-timeline.js b/graph/story-timeline.js
index fd187db..cfc0934 100644
--- a/graph/story-timeline.js
+++ b/graph/story-timeline.js
@@ -147,7 +147,50 @@ export function createDefaultTimelineState(overrides = {}) {
};
}
+function canReuseNormalizedStoryTime(value = {}, defaults = {}) {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ Array.isArray(value) ||
+ (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0)
+ ) {
+ return false;
+ }
+ return (
+ normalizeString(value.segmentId || "") === String(value.segmentId || "") &&
+ normalizeString(value.label || "") === String(value.label || "") &&
+ normalizeEnum(value.tense, STORY_TENSE_VALUES, "unknown") === value.tense &&
+ normalizeEnum(value.relation, STORY_RELATION_VALUES, "unknown") === value.relation &&
+ normalizeString(value.anchorLabel || "") === String(value.anchorLabel || "") &&
+ normalizeEnum(value.confidence, STORY_CONFIDENCE_VALUES, "medium") ===
+ value.confidence &&
+ normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source
+ );
+}
+
+function canReuseNormalizedStoryTimeSpan(value = {}, defaults = {}) {
+ if (
+ !value ||
+ typeof value !== "object" ||
+ Array.isArray(value) ||
+ (defaults && typeof defaults === "object" && Object.keys(defaults).length > 0)
+ ) {
+ return false;
+ }
+ return (
+ normalizeString(value.startSegmentId || "") === String(value.startSegmentId || "") &&
+ normalizeString(value.endSegmentId || "") === String(value.endSegmentId || "") &&
+ normalizeString(value.startLabel || "") === String(value.startLabel || "") &&
+ normalizeString(value.endLabel || "") === String(value.endLabel || "") &&
+ (value.mixed === true || value.mixed === false) &&
+ normalizeEnum(value.source, STORY_SOURCE_VALUES, "derived") === value.source
+ );
+}
+
export function normalizeStoryTime(value = {}, defaults = {}) {
+ if (canReuseNormalizedStoryTime(value, defaults)) {
+ return value;
+ }
return createDefaultStoryTime({
...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
@@ -155,6 +198,9 @@ export function normalizeStoryTime(value = {}, defaults = {}) {
}
export function normalizeStoryTimeSpan(value = {}, defaults = {}) {
+ if (canReuseNormalizedStoryTimeSpan(value, defaults)) {
+ return value;
+ }
return createDefaultStoryTimeSpan({
...defaults,
...(value && typeof value === "object" && !Array.isArray(value) ? value : {}),
diff --git a/package.json b/package.json
index ed1ed1c..ff538bb 100644
--- a/package.json
+++ b/package.json
@@ -14,6 +14,9 @@
"test:trivial-input": "node tests/trivial-user-input.mjs",
"bench:graph-layout": "node tests/perf/graph-layout-bench.mjs",
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
+ "bench:persist-load": "node tests/perf/persist-load-bench.mjs",
+ "bench:load-preapply": "node tests/perf/load-preapply-bench.mjs",
+ "bench:p1-compare": "node scripts/compare-p1-bench.mjs",
"bench:native": "npm run bench:graph-layout && npm run bench:persist-delta",
"test:indexeddb": "npm run test:indexeddb-persistence && npm run test:indexeddb-sync && npm run test:indexeddb-migration",
"test:persistence-matrix": "npm run test:p0 && npm run test:runtime-history && npm run test:graph-persistence && npm run test:indexeddb",
diff --git a/runtime/runtime-state.js b/runtime/runtime-state.js
index f6d3096..5ca427a 100644
--- a/runtime/runtime-state.js
+++ b/runtime/runtime-state.js
@@ -10,6 +10,7 @@ import {
} from "../graph/knowledge-state.js";
import {
createDefaultTimelineState,
+ normalizeTimelineState,
normalizeGraphStoryTimeline,
} from "../graph/story-timeline.js";
import {
@@ -224,10 +225,12 @@ function getRequiredJournalCoverageStartFloor(graph, journals = []) {
return null;
}
-export function normalizeGraphRuntimeState(graph, chatId = "") {
+export function normalizeGraphRuntimeState(graph, chatId = "", options = {}) {
if (!graph || typeof graph !== "object") {
return graph;
}
+ const skipRecordFieldNormalization =
+ options?.skipRecordFieldNormalization === true;
const hadSummaryState =
graph.summaryState &&
typeof graph.summaryState === "object" &&
@@ -475,10 +478,10 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
graph.historyState = historyState;
graph.vectorIndexState = vectorIndexState;
- if (Array.isArray(graph.nodes)) {
+ if (!skipRecordFieldNormalization && Array.isArray(graph.nodes)) {
graph.nodes.forEach((node) => normalizeNodeMemoryScope(node));
}
- if (Array.isArray(graph.edges)) {
+ if (!skipRecordFieldNormalization && Array.isArray(graph.edges)) {
graph.edges.forEach((edge) => normalizeEdgeMemoryScope(edge));
}
graph.batchJournal = Array.isArray(graph.batchJournal)
@@ -496,10 +499,16 @@ export function normalizeGraphRuntimeState(graph, chatId = "") {
: createDefaultMaintenanceJournal();
graph.knowledgeState = createDefaultKnowledgeState(graph.knowledgeState);
graph.regionState = createDefaultRegionState(graph.regionState);
- graph.timelineState = createDefaultTimelineState(graph.timelineState);
+ graph.timelineState = skipRecordFieldNormalization
+ ? normalizeTimelineState(graph.timelineState)
+ : createDefaultTimelineState(graph.timelineState);
graph.summaryState = createDefaultSummaryState(graph.summaryState);
normalizeGraphCognitiveState(graph);
- normalizeGraphStoryTimeline(graph);
+ if (skipRecordFieldNormalization) {
+ graph.timelineState = normalizeTimelineState(graph.timelineState);
+ } else {
+ normalizeGraphStoryTimeline(graph);
+ }
normalizeGraphSummaryState(graph);
if (!hadSummaryState) {
importLegacySynopsisToSummaryState(graph);
diff --git a/scripts/compare-p1-bench.mjs b/scripts/compare-p1-bench.mjs
new file mode 100644
index 0000000..cd75ba3
--- /dev/null
+++ b/scripts/compare-p1-bench.mjs
@@ -0,0 +1,196 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { execFile } from "node:child_process";
+import { promisify } from "node:util";
+
+const execFileAsync = promisify(execFile);
+const scriptDir = path.dirname(fileURLToPath(import.meta.url));
+const projectRoot = path.resolve(scriptDir, "..");
+const args = new Map(
+ process.argv.slice(2).map((entry) => {
+ const [key, ...rest] = String(entry || "").split("=");
+ return [key, rest.join("=") || true];
+ }),
+);
+const baselineRef = String(args.get("--baseline") || "origin/main");
+const currentRef = String(args.get("--current") || "HEAD");
+const outputJson = args.has("--json");
+
+async function runCommand(command, commandArgs, cwd) {
+ const { stdout, stderr } = await execFileAsync(command, commandArgs, {
+ cwd,
+ windowsHide: true,
+ maxBuffer: 1024 * 1024 * 20,
+ env: {
+ ...process.env,
+ ST_BME_NODE_MODULES_ROOT: projectRoot,
+ },
+ });
+ return {
+ stdout: String(stdout || "").trim(),
+ stderr: String(stderr || "").trim(),
+ };
+}
+
+async function resolveRef(ref) {
+ const result = await runCommand("git", ["rev-parse", ref], projectRoot);
+ return result.stdout;
+}
+
+async function ensureFileFromCurrentRepo(relativePath, targetRoot) {
+ const sourcePath = path.join(projectRoot, relativePath);
+ const targetPath = path.join(targetRoot, relativePath);
+ await fs.mkdir(path.dirname(targetPath), { recursive: true });
+ await fs.copyFile(sourcePath, targetPath);
+}
+
+function readJsonLine(stdout = "") {
+ const trimmed = String(stdout || "").trim();
+ const lines = trimmed.split(/\r?\n/).filter(Boolean);
+ return JSON.parse(lines[lines.length - 1]);
+}
+
+function formatDelta(current = 0, baseline = 0) {
+ const delta = current - baseline;
+ const ratio = baseline !== 0 ? (delta / baseline) * 100 : 0;
+ const sign = delta > 0 ? "+" : "";
+ return `${sign}${delta.toFixed(2)}ms (${sign}${ratio.toFixed(1)}%)`;
+}
+
+function collectMetricRows(compare, metricPath, label) {
+ return Object.entries(compare).map(([preset, metrics]) => ({
+ preset,
+ label,
+ baseline: Number(metricPath(metrics.baseline) || 0),
+ current: Number(metricPath(metrics.current) || 0),
+ }));
+}
+
+function printRows(rows = [], title = "") {
+ console.log(`\n[ST-BME][P1-compare] ${title}`);
+ for (const row of rows) {
+ console.log(
+ `${row.preset} baseline=${row.baseline.toFixed(2)}ms current=${row.current.toFixed(2)}ms delta=${formatDelta(row.current, row.baseline)}`,
+ );
+ }
+}
+
+async function runBenchSuite(cwd) {
+ const persistLoad = await runCommand(
+ process.execPath,
+ ["tests/perf/persist-load-bench.mjs", "--json"],
+ cwd,
+ );
+ const loadPreapply = await runCommand(
+ process.execPath,
+ ["tests/perf/load-preapply-bench.mjs", "--json"],
+ cwd,
+ );
+ return {
+ persistLoad: readJsonLine(persistLoad.stdout),
+ loadPreapply: readJsonLine(loadPreapply.stdout),
+ };
+}
+
+function compareBenchResults(baseline, current) {
+ const presets = {};
+ const presetNames = new Set([
+ ...Object.keys(baseline.persistLoad?.presets || {}),
+ ...Object.keys(current.persistLoad?.presets || {}),
+ ...Object.keys(baseline.loadPreapply?.presets || {}),
+ ...Object.keys(current.loadPreapply?.presets || {}),
+ ]);
+ for (const preset of presetNames) {
+ presets[preset] = {
+ baseline: {
+ ...(baseline.persistLoad?.presets?.[preset] || {}),
+ ...(baseline.loadPreapply?.presets?.[preset] || {}),
+ },
+ current: {
+ ...(current.persistLoad?.presets?.[preset] || {}),
+ ...(current.loadPreapply?.presets?.[preset] || {}),
+ },
+ };
+ }
+ return presets;
+}
+
+async function createWorktree(ref, tempRoot, name) {
+ const worktreePath = path.join(tempRoot, name);
+ await runCommand("git", ["worktree", "add", "--detach", worktreePath, ref], projectRoot);
+ await ensureFileFromCurrentRepo("tests/perf/persist-load-bench.mjs", worktreePath);
+ await ensureFileFromCurrentRepo("tests/perf/load-preapply-bench.mjs", worktreePath);
+ await ensureFileFromCurrentRepo("tests/helpers/memory-opfs.mjs", worktreePath);
+ return worktreePath;
+}
+
+async function removeWorktree(worktreePath) {
+ await runCommand("git", ["worktree", "remove", "--force", worktreePath], projectRoot);
+}
+
+async function main() {
+ const baselineSha = await resolveRef(baselineRef);
+ const currentSha = await resolveRef(currentRef);
+ const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), "st-bme-p1-compare-"));
+ let baselinePath = "";
+ let currentPath = "";
+
+ try {
+ baselinePath = await createWorktree(baselineSha, tempRoot, "baseline");
+ currentPath =
+ currentRef === "HEAD" ? projectRoot : await createWorktree(currentSha, tempRoot, "current");
+
+ const baselineResults = await runBenchSuite(baselinePath);
+ const currentResults = await runBenchSuite(currentPath);
+ const compare = compareBenchResults(baselineResults, currentResults);
+
+ if (outputJson) {
+ console.log(
+ JSON.stringify({
+ baselineRef,
+ baselineSha,
+ currentRef,
+ currentSha,
+ compare,
+ }),
+ );
+ return;
+ }
+
+ console.log(`[ST-BME][P1-compare] baseline=${baselineRef} (${baselineSha.slice(0, 7)})`);
+ console.log(`[ST-BME][P1-compare] current=${currentRef} (${currentSha.slice(0, 7)})`);
+
+ printRows(
+ collectMetricRows(compare, (entry) => entry.opfsCommitMs?.p95, "opfsCommitMs.p95"),
+ "opfs commit p95",
+ );
+ printRows(
+ collectMetricRows(compare, (entry) => entry.indexedDbProbeRejectMs?.p95, "indexedDbProbeRejectMs.p95"),
+ "indexeddb probe-reject preApply p95",
+ );
+ printRows(
+ collectMetricRows(compare, (entry) => entry.opfsProbeRejectMs?.p95, "opfsProbeRejectMs.p95"),
+ "opfs probe-reject preApply p95",
+ );
+ printRows(
+ collectMetricRows(compare, (entry) => entry.indexedDbPreApplySuccessMs?.p95, "indexedDbPreApplySuccessMs.p95"),
+ "indexeddb success preApply p95",
+ );
+ printRows(
+ collectMetricRows(compare, (entry) => entry.hydrateMs?.p95, "hydrateMs.p95"),
+ "hydrate p95",
+ );
+ } finally {
+ if (baselinePath) {
+ await removeWorktree(baselinePath);
+ }
+ if (currentPath && currentPath !== projectRoot) {
+ await removeWorktree(currentPath);
+ }
+ await fs.rm(tempRoot, { recursive: true, force: true });
+ }
+}
+
+await main();
diff --git a/sync/bme-db.js b/sync/bme-db.js
index 1bf1dc8..74e2a10 100644
--- a/sync/bme-db.js
+++ b/sync/bme-db.js
@@ -40,6 +40,8 @@ export const BME_RUNTIME_TIMELINE_STATE_META_KEY = "timelineState";
export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY =
"runtimeLastProcessedSeq";
export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion";
+export const BME_RUNTIME_RECORDS_NORMALIZED_META_KEY =
+ "runtimeRecordsNormalized";
export const BME_DB_TABLE_SCHEMAS = Object.freeze({
nodes:
@@ -153,6 +155,169 @@ function toArray(value) {
return Array.isArray(value) ? value : [];
}
+function cloneHydrateSnapshotNestedValue(value, fallbackValue = null) {
+ if (value == null || typeof value !== "object") {
+ return value == null ? fallbackValue : value;
+ }
+ if (Array.isArray(value)) {
+ const output = new Array(value.length);
+ for (let index = 0; index < value.length; index += 1) {
+ const entry = value[index];
+ output[index] =
+ entry != null && typeof entry === "object"
+ ? cloneHydrateSnapshotNestedValue(entry, entry)
+ : entry;
+ }
+ return output;
+ }
+ const prototype = Object.getPrototypeOf(value);
+ if (prototype !== Object.prototype && prototype !== null) {
+ return toPlainData(value, fallbackValue ?? value);
+ }
+ const output = {};
+ for (const key in value) {
+ if (!Object.prototype.hasOwnProperty.call(value, key)) continue;
+ const entry = value[key];
+ output[key] =
+ entry != null && typeof entry === "object"
+ ? cloneHydrateSnapshotNestedValue(entry, entry)
+ : entry;
+ }
+ return output;
+}
+
+function cloneHydrateSnapshotMemoryScope(scope = null) {
+ if (!scope || typeof scope !== "object" || Array.isArray(scope)) {
+ return cloneHydrateSnapshotNestedValue(scope, scope);
+ }
+ return {
+ ...scope,
+ regionPath: Array.isArray(scope.regionPath) ? [...scope.regionPath] : [],
+ regionSecondary: Array.isArray(scope.regionSecondary)
+ ? [...scope.regionSecondary]
+ : [],
+ };
+}
+
+function cloneHydrateSnapshotStoryTime(storyTime = null) {
+ if (!storyTime || typeof storyTime !== "object" || Array.isArray(storyTime)) {
+ return cloneHydrateSnapshotNestedValue(storyTime, storyTime);
+ }
+ return {
+ ...storyTime,
+ };
+}
+
+function cloneHydrateSnapshotStoryTimeSpan(storyTimeSpan = null) {
+ if (
+ !storyTimeSpan ||
+ typeof storyTimeSpan !== "object" ||
+ Array.isArray(storyTimeSpan)
+ ) {
+ return cloneHydrateSnapshotNestedValue(storyTimeSpan, storyTimeSpan);
+ }
+ return {
+ ...storyTimeSpan,
+ };
+}
+
+function cloneHydrateSnapshotNodeRecord(record = null) {
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
+ return null;
+ }
+ const cloned = {};
+ for (const key in record) {
+ if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
+ const value = record[key];
+ switch (key) {
+ case "fields":
+ cloned.fields = cloneHydrateSnapshotNestedValue(value, {});
+ break;
+ case "seqRange":
+ cloned.seqRange = Array.isArray(value)
+ ? value.slice()
+ : cloneHydrateSnapshotNestedValue(value, value);
+ break;
+ case "childIds":
+ cloned.childIds = Array.isArray(value)
+ ? value.slice()
+ : cloneHydrateSnapshotNestedValue(value, value);
+ break;
+ case "clusters":
+ cloned.clusters = Array.isArray(value)
+ ? value.slice()
+ : cloneHydrateSnapshotNestedValue(value, value);
+ break;
+ case "scope":
+ cloned.scope = cloneHydrateSnapshotMemoryScope(value);
+ break;
+ case "storyTime":
+ cloned.storyTime = cloneHydrateSnapshotStoryTime(value);
+ break;
+ case "storyTimeSpan":
+ cloned.storyTimeSpan = cloneHydrateSnapshotStoryTimeSpan(value);
+ break;
+ default:
+ cloned[key] =
+ value != null && typeof value === "object"
+ ? cloneHydrateSnapshotNestedValue(value, value)
+ : value;
+ break;
+ }
+ }
+ return cloned;
+}
+
+function cloneHydrateSnapshotEdgeRecord(record = null) {
+ if (!record || typeof record !== "object" || Array.isArray(record)) {
+ return null;
+ }
+ const cloned = {};
+ for (const key in record) {
+ if (!Object.prototype.hasOwnProperty.call(record, key)) continue;
+ const value = record[key];
+ if (key === "scope") {
+ cloned.scope = cloneHydrateSnapshotMemoryScope(value);
+ continue;
+ }
+ cloned[key] =
+ value != null && typeof value === "object"
+ ? cloneHydrateSnapshotNestedValue(value, value)
+ : value;
+ }
+ return cloned;
+}
+
+function cloneHydrateSnapshotNodeRecords(records = []) {
+ const sourceRecords = toArray(records);
+ if (sourceRecords.length === 0) return [];
+ const output = new Array(sourceRecords.length);
+ let writeIndex = 0;
+ for (let index = 0; index < sourceRecords.length; index += 1) {
+ const cloned = cloneHydrateSnapshotNodeRecord(sourceRecords[index]);
+ if (!cloned) continue;
+ output[writeIndex] = cloned;
+ writeIndex += 1;
+ }
+ output.length = writeIndex;
+ return output;
+}
+
+function cloneHydrateSnapshotEdgeRecords(records = []) {
+ const sourceRecords = toArray(records);
+ if (sourceRecords.length === 0) return [];
+ const output = new Array(sourceRecords.length);
+ let writeIndex = 0;
+ for (let index = 0; index < sourceRecords.length; index += 1) {
+ const cloned = cloneHydrateSnapshotEdgeRecord(sourceRecords[index]);
+ if (!cloned) continue;
+ output[writeIndex] = cloned;
+ writeIndex += 1;
+ }
+ output.length = writeIndex;
+ return output;
+}
+
function toMetaMap(rows = []) {
const output = {};
for (const row of rows) {
@@ -927,6 +1092,7 @@ export function buildSnapshotFromGraph(graph, options = {}) {
)
? Number(runtimeGraph.version)
: Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0),
+ [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
};
if (snapshotDiagnostics) {
snapshotDiagnostics.metaMs = readPersistDeltaNow() - metaStartedAt;
@@ -2133,6 +2299,8 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
snapshotMeta?.[BME_RUNTIME_VECTOR_META_KEY],
{},
);
+ const snapshotRecordsNormalized =
+ snapshotMeta?.[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY] === true;
const runtimeGraph = createEmptyGraph();
runtimeGraph.version = Number.isFinite(
@@ -2142,14 +2310,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
: runtimeGraph.version;
const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
- runtimeGraph.nodes = toArray(toPlainData(snapshotView.nodes, []));
+ runtimeGraph.nodes = cloneHydrateSnapshotNodeRecords(snapshotView.nodes);
if (hydrateDiagnostics) {
hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length;
hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt;
}
const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
- runtimeGraph.edges = toArray(toPlainData(snapshotView.edges, []));
+ runtimeGraph.edges = cloneHydrateSnapshotEdgeRecords(snapshotView.edges);
if (hydrateDiagnostics) {
hydrateDiagnostics.edgeCount = runtimeGraph.edges.length;
hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt;
@@ -2302,7 +2470,9 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
}
const normalizeStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
- const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId);
+ const normalizedGraph = normalizeGraphRuntimeState(runtimeGraph, chatId, {
+ skipRecordFieldNormalization: snapshotRecordsNormalized,
+ });
if (hydrateDiagnostics) {
hydrateDiagnostics.normalizeMs = readPersistDeltaNow() - normalizeStartedAt;
}
diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs
index f9f0855..a5876fd 100644
--- a/tests/indexeddb-persistence.mjs
+++ b/tests/indexeddb-persistence.mjs
@@ -4,6 +4,7 @@ import {
BME_DB_SCHEMA_VERSION,
BME_RUNTIME_BATCH_JOURNAL_META_KEY,
BME_RUNTIME_HISTORY_META_KEY,
+ BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
BME_RUNTIME_VECTOR_META_KEY,
BME_TOMBSTONE_RETENTION_MS,
BmeDatabase,
@@ -618,6 +619,33 @@ async function testGraphSnapshotConverters() {
title: "Converter Node",
},
updatedAt: Date.now(),
+ embedding: [0.25, 0.5, 0.75],
+ scope: {
+ layer: "pov",
+ ownerType: "character",
+ ownerId: "hero",
+ ownerName: "Hero",
+ regionPrimary: "camp",
+ regionPath: ["camp", "tent"],
+ regionSecondary: ["forest"],
+ },
+ storyTime: {
+ segmentId: "segment-1",
+ label: "Dawn",
+ tense: "ongoing",
+ relation: "same",
+ anchorLabel: "Night",
+ confidence: "high",
+ source: "derived",
+ },
+ storyTimeSpan: {
+ startSegmentId: "segment-0",
+ endSegmentId: "segment-1",
+ startLabel: "Night",
+ endLabel: "Dawn",
+ mixed: false,
+ source: "derived",
+ },
});
let snapshotDiagnostics = null;
@@ -630,6 +658,7 @@ async function testGraphSnapshotConverters() {
});
assert.equal(snapshot.meta.chatId, "chat-a");
assert.equal(snapshot.meta.revision, 17);
+ assert.equal(snapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY], true);
assert.equal(snapshot.state.lastProcessedFloor, 9);
assert.equal(snapshot.state.extractionCount, 4);
assert.equal(snapshot.nodes.length, 1);
@@ -687,18 +716,44 @@ async function testGraphSnapshotConverters() {
const rebuilt = buildGraphFromSnapshot(snapshot, {
chatId: "chat-a",
});
+ const legacyCompatibleSnapshot = {
+ ...snapshot,
+ meta: {
+ ...snapshot.meta,
+ },
+ };
+ delete legacyCompatibleSnapshot.meta[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY];
+ legacyCompatibleSnapshot.nodes = [
+ {
+ ...legacyCompatibleSnapshot.nodes[0],
+ scope: undefined,
+ storyTime: undefined,
+ storyTimeSpan: undefined,
+ },
+ ];
+ const rebuiltLegacyCompatible = buildGraphFromSnapshot(legacyCompatibleSnapshot, {
+ chatId: "chat-a",
+ });
assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 9);
assert.equal(rebuilt.historyState.extractionCount, 4);
assert.equal(rebuilt.nodes.length, 1);
assert.equal(rebuilt.nodes[0].id, "node-converter");
+ assert.equal(rebuilt.nodes[0].scope?.ownerType, "character");
+ assert.equal(rebuilt.nodes[0].scope?.regionPrimary, "camp");
+ assert.equal(rebuilt.nodes[0].storyTime?.label, "Dawn");
+ assert.equal(rebuilt.nodes[0].storyTimeSpan?.endLabel, "Dawn");
assert.equal(rebuilt.vectorIndexState.hashToNodeId["vec-hash"], "node-converter");
assert.equal(rebuilt.maintenanceJournal[0].id, "maintenance-1");
assert.equal(rebuilt.knowledgeState.activeOwnerKey, "owner:hero");
assert.equal(rebuilt.regionState.activeRegion, "camp");
assert.equal(rebuilt.timelineState.activeSegmentId, "segment-1");
assert.equal(rebuilt.summaryState.entries[0].id, "summary-1");
+ assert.equal(rebuiltLegacyCompatible.nodes[0].scope?.layer, "objective");
+ assert.equal(rebuiltLegacyCompatible.nodes[0].storyTime?.tense, "unknown");
+ assert.equal(rebuiltLegacyCompatible.nodes[0].storyTimeSpan?.mixed, false);
rebuilt.nodes[0].fields.title = "Mutated Converter Node";
+ rebuilt.nodes[0].embedding[0] = 99;
rebuilt.historyState.processedMessageHashes[1] = "mutated-hash";
rebuilt.vectorIndexState.hashToNodeId["vec-hash"] = "node-mutated";
rebuilt.batchJournal[0].processedRange[0] = 99;
@@ -713,6 +768,11 @@ async function testGraphSnapshotConverters() {
"hash-1",
"buildGraphFromSnapshot 不应复用 snapshot historyState 的嵌套对象引用",
);
+ assert.equal(
+ snapshot.nodes[0].embedding[0],
+ 0.25,
+ "buildGraphFromSnapshot 不应复用 snapshot 节点的数组字段引用",
+ );
assert.equal(
snapshot.meta[BME_RUNTIME_VECTOR_META_KEY].hashToNodeId["vec-hash"],
"node-converter",
diff --git a/tests/perf/load-preapply-bench.mjs b/tests/perf/load-preapply-bench.mjs
new file mode 100644
index 0000000..31b275f
--- /dev/null
+++ b/tests/perf/load-preapply-bench.mjs
@@ -0,0 +1,397 @@
+import { performance } from "node:perf_hooks";
+import path from "node:path";
+import { createRequire } from "node:module";
+import { pathToFileURL } from "node:url";
+
+import {
+ BmeDatabase,
+ buildBmeDbName,
+ buildGraphFromSnapshot,
+ buildSnapshotFromGraph,
+ ensureDexieLoaded,
+} from "../../sync/bme-db.js";
+import {
+ BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
+ OpfsGraphStore,
+} from "../../sync/bme-opfs-store.js";
+import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
+
+const RUNS = 4;
+const outputJson = process.argv.includes("--json");
+const projectRootHint = String(process.env.ST_BME_NODE_MODULES_ROOT || "").trim();
+const requireFromProjectRoot = projectRootHint
+ ? createRequire(path.join(projectRootHint, "package.json"))
+ : null;
+const SIZE_PRESETS = [
+ { label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600 },
+ { label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800 },
+ { label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600 },
+];
+
+async function importWithProjectRootFallback(specifier) {
+ try {
+ return await import(specifier);
+ } catch (error) {
+ if (!requireFromProjectRoot) {
+ throw error;
+ }
+ const resolved = requireFromProjectRoot.resolve(specifier);
+ return await import(pathToFileURL(resolved).href);
+ }
+}
+
+function summarize(values = []) {
+ if (!values.length) {
+ return { avg: 0, p95: 0, min: 0, max: 0 };
+ }
+ const sorted = [...values].sort((a, b) => a - b);
+ const sum = sorted.reduce((acc, value) => acc + value, 0);
+ const p95Index = Math.min(sorted.length - 1, Math.floor(sorted.length * 0.95));
+ return {
+ avg: sum / sorted.length,
+ p95: sorted[p95Index],
+ min: sorted[0],
+ max: sorted[sorted.length - 1],
+ };
+}
+
+function formatSummary(label, values = []) {
+ const summary = summarize(values);
+ return `${label} avg=${summary.avg.toFixed(2)}ms p95=${summary.p95.toFixed(2)}ms min=${summary.min.toFixed(2)}ms max=${summary.max.toFixed(2)}ms`;
+}
+
+function createRandom(seed = 1) {
+ let state = seed >>> 0;
+ return () => {
+ state = (state * 1664525 + 1013904223) >>> 0;
+ return state / 0xffffffff;
+ };
+}
+
+function buildRuntimeGraph(seed = 1, nodeCount = 100, edgeCount = 200, chatId = "bench-chat") {
+ const rand = createRandom(seed);
+ const nodes = [];
+ const edges = [];
+ for (let index = 0; index < nodeCount; index += 1) {
+ nodes.push({
+ id: `node-${index}`,
+ type: "event",
+ updatedAt: 1000 + index,
+ archived: false,
+ sourceFloor: index,
+ fields: {
+ title: `Node ${index}`,
+ text: `node-${index}-${Math.floor(rand() * 100000)}`,
+ },
+ });
+ }
+ for (let index = 0; index < edgeCount; index += 1) {
+ const fromIndex = Math.floor(rand() * nodeCount);
+ let toIndex = Math.floor(rand() * nodeCount);
+ if (toIndex === fromIndex) {
+ toIndex = (toIndex + 1) % nodeCount;
+ }
+ edges.push({
+ id: `edge-${index}`,
+ fromId: `node-${fromIndex}`,
+ toId: `node-${toIndex}`,
+ relation: "related",
+ strength: rand(),
+ updatedAt: 2000 + index,
+ });
+ }
+ return {
+ version: 1,
+ nodes,
+ edges,
+ historyState: {
+ chatId,
+ lastProcessedAssistantFloor: Math.max(0, Math.floor(nodeCount / 12)),
+ extractionCount: Math.max(1, Math.floor(nodeCount / 40)),
+ processedMessageHashes: {},
+ processedMessageHashVersion: 1,
+ processedMessageHashesNeedRefresh: false,
+ recentRecallOwnerKeys: [],
+ activeRecallOwnerKey: "",
+ activeRegion: "",
+ activeRegionSource: "",
+ activeStorySegmentId: "",
+ activeStoryTimeLabel: "",
+ activeStoryTimeSource: "",
+ lastBatchStatus: null,
+ lastMutationSource: "bench",
+ lastExtractedRegion: "",
+ lastExtractedStorySegmentId: "",
+ activeCharacterPovOwner: "",
+ activeUserPovOwner: "",
+ },
+ vectorIndexState: {
+ chatId,
+ collectionId: "",
+ hashToNodeId: {},
+ nodeToHash: {},
+ replayRequiredNodeIds: [],
+ dirty: false,
+ dirtyReason: "",
+ pendingRepairFromFloor: null,
+ lastIntegrityIssue: null,
+ lastStats: {
+ nodesIndexed: 0,
+ updatedAt: 0,
+ },
+ },
+ knowledgeState: {
+ owners: {},
+ activeOwnerKey: "",
+ },
+ regionState: {
+ activeRegion: "",
+ knownRegions: {},
+ manualActiveRegion: "",
+ },
+ timelineState: {
+ activeSegmentId: "",
+ manualActiveSegmentId: "",
+ segments: [],
+ },
+ summaryState: {
+ updatedAt: 0,
+ entries: [],
+ },
+ batchJournal: [],
+ maintenanceJournal: [],
+ lastRecallResult: null,
+ lastProcessedSeq: Math.max(0, Math.floor(nodeCount / 12)),
+ };
+}
+
+function buildBenchSnapshot({ label, seed, nodeCount, edgeCount }) {
+ const chatId = `load-bench-${label.toLowerCase()}-${seed}`;
+ const graph = buildRuntimeGraph(seed, nodeCount, edgeCount, chatId);
+ return {
+ chatId,
+ snapshot: buildSnapshotFromGraph(graph, {
+ chatId,
+ revision: 1,
+ }),
+ };
+}
+
+async function setupIndexedDbTestEnv() {
+ try {
+ await importWithProjectRootFallback("fake-indexeddb/auto");
+ } catch {
+ // no-op
+ }
+
+ if (!globalThis.Dexie) {
+ try {
+ const imported = await importWithProjectRootFallback("dexie");
+ globalThis.Dexie = imported?.default || imported?.Dexie || imported;
+ } catch {
+ await import("../../lib/dexie.min.js");
+ }
+ }
+
+ await ensureDexieLoaded();
+}
+
+async function cleanupDatabase(chatId = "") {
+ if (!chatId || typeof globalThis.Dexie?.delete !== "function") return;
+ try {
+ await globalThis.Dexie.delete(buildBmeDbName(chatId));
+ } catch {
+ // no-op
+ }
+}
+
+async function prepareIndexedDb(chatId, snapshot) {
+ await cleanupDatabase(chatId);
+ const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
+ await db.open();
+ await db.importSnapshot(snapshot, {
+ mode: "replace",
+ preserveRevision: true,
+ markSyncDirty: false,
+ });
+ return db;
+}
+
+async function prepareOpfsStore(chatId, snapshot) {
+ const rootDirectory = createMemoryOpfsRoot();
+ const store = new OpfsGraphStore(chatId, {
+ rootDirectoryFactory: async () => rootDirectory,
+ storeMode: BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY,
+ });
+ await store.open();
+ await store.importSnapshot(snapshot, {
+ mode: "replace",
+ preserveRevision: true,
+ markSyncDirty: false,
+ });
+ return store;
+}
+
+async function readProbeOrFallback(store) {
+ let inspectionSnapshot = null;
+ let exportProbeMs = 0;
+ let exportSnapshotMs = 0;
+ let exportSource = "";
+
+ if (typeof store.exportSnapshotProbe === "function") {
+ const probeStartedAt = performance.now();
+ inspectionSnapshot = await store.exportSnapshotProbe({ includeTombstones: false });
+ exportProbeMs = performance.now() - probeStartedAt;
+ exportSource = "probe";
+ }
+
+ if (!inspectionSnapshot) {
+ const exportStartedAt = performance.now();
+ inspectionSnapshot = await store.exportSnapshot({ includeTombstones: false });
+ exportSnapshotMs = performance.now() - exportStartedAt;
+ exportSource = "full-export";
+ }
+
+ return {
+ inspectionSnapshot,
+ exportProbeMs,
+ exportSnapshotMs,
+ exportSource,
+ };
+}
+
+async function measureSuccessPreApply(store, chatId) {
+ const startedAt = performance.now();
+ const probeResult = await readProbeOrFallback(store);
+ let snapshot = probeResult.inspectionSnapshot;
+ let exportSnapshotMs = probeResult.exportSnapshotMs;
+ let exportSource = probeResult.exportSource;
+
+ if (snapshot?.__stBmeProbeOnly === true) {
+ const exportStartedAt = performance.now();
+ snapshot = await store.exportSnapshot({ includeTombstones: false });
+ exportSnapshotMs += performance.now() - exportStartedAt;
+ exportSource =
+ probeResult.exportSource === "probe" ? "probe+full-export" : "full-export";
+ }
+
+ const preApplyMs = performance.now() - startedAt;
+ const hydrateStartedAt = performance.now();
+ buildGraphFromSnapshot(snapshot, { chatId });
+ const hydrateMs = performance.now() - hydrateStartedAt;
+
+ return {
+ preApplyMs,
+ exportProbeMs: probeResult.exportProbeMs,
+ exportSnapshotMs,
+ hydrateMs,
+ exportSource,
+ };
+}
+
+async function measureProbeRejectPreApply(store) {
+ const startedAt = performance.now();
+ const probeResult = await readProbeOrFallback(store);
+ return {
+ preApplyMs: performance.now() - startedAt,
+ exportProbeMs: probeResult.exportProbeMs,
+ exportSnapshotMs: probeResult.exportSnapshotMs,
+ exportSource: probeResult.exportSource,
+ };
+}
+
+async function runPreset(preset) {
+ const indexedDbSuccessSamples = [];
+ const indexedDbProbeRejectSamples = [];
+ const indexedDbProbeSamples = [];
+ const indexedDbExportSamples = [];
+ const indexedDbHydrateSamples = [];
+ const opfsSuccessSamples = [];
+ const opfsProbeRejectSamples = [];
+ const opfsProbeSamples = [];
+ const opfsExportSamples = [];
+ const opfsHydrateSamples = [];
+
+ for (let run = 0; run < RUNS; run += 1) {
+ const { chatId, snapshot } = buildBenchSnapshot({
+ ...preset,
+ seed: preset.seed + run * 17,
+ });
+
+ const indexedDbChatId = `${chatId}-indexeddb`;
+ const db = await prepareIndexedDb(indexedDbChatId, snapshot);
+ const indexedDbSuccess = await measureSuccessPreApply(db, indexedDbChatId);
+ const indexedDbProbeReject = await measureProbeRejectPreApply(db);
+ indexedDbSuccessSamples.push(indexedDbSuccess.preApplyMs);
+ indexedDbProbeRejectSamples.push(indexedDbProbeReject.preApplyMs);
+ indexedDbProbeSamples.push(indexedDbSuccess.exportProbeMs);
+ indexedDbExportSamples.push(indexedDbSuccess.exportSnapshotMs);
+ indexedDbHydrateSamples.push(indexedDbSuccess.hydrateMs);
+ await db.close();
+ await cleanupDatabase(indexedDbChatId);
+
+ const opfsChatId = `${chatId}-opfs`;
+ const opfsStore = await prepareOpfsStore(opfsChatId, snapshot);
+ const opfsSuccess = await measureSuccessPreApply(opfsStore, opfsChatId);
+ const opfsProbeReject = await measureProbeRejectPreApply(opfsStore);
+ opfsSuccessSamples.push(opfsSuccess.preApplyMs);
+ opfsProbeRejectSamples.push(opfsProbeReject.preApplyMs);
+ opfsProbeSamples.push(opfsSuccess.exportProbeMs);
+ opfsExportSamples.push(opfsSuccess.exportSnapshotMs);
+ opfsHydrateSamples.push(opfsSuccess.hydrateMs);
+ await opfsStore.close();
+ }
+
+ const result = {
+ indexedDbPreApplySuccessMs: summarize(indexedDbSuccessSamples),
+ indexedDbProbeRejectMs: summarize(indexedDbProbeRejectSamples),
+ indexedDbExportProbeMs: summarize(indexedDbProbeSamples),
+ indexedDbExportSnapshotMs: summarize(indexedDbExportSamples),
+ indexedDbHydrateMs: summarize(indexedDbHydrateSamples),
+ opfsPreApplySuccessMs: summarize(opfsSuccessSamples),
+ opfsProbeRejectMs: summarize(opfsProbeRejectSamples),
+ opfsExportProbeMs: summarize(opfsProbeSamples),
+ opfsExportSnapshotMs: summarize(opfsExportSamples),
+ opfsHydrateMs: summarize(opfsHydrateSamples),
+ };
+
+ if (!outputJson) {
+ console.log(`\n[ST-BME][load-preapply-bench] ${preset.label}`);
+ console.log(
+ formatSummary("indexeddb-preapply-success", indexedDbSuccessSamples),
+ `probeRejectP95=${result.indexedDbProbeRejectMs.p95.toFixed(2)}ms`,
+ `probeP95=${result.indexedDbExportProbeMs.p95.toFixed(2)}ms`,
+ `exportP95=${result.indexedDbExportSnapshotMs.p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("opfs-preapply-success", opfsSuccessSamples),
+ `probeRejectP95=${result.opfsProbeRejectMs.p95.toFixed(2)}ms`,
+ `probeP95=${result.opfsExportProbeMs.p95.toFixed(2)}ms`,
+ `exportP95=${result.opfsExportSnapshotMs.p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("indexeddb-hydrate", indexedDbHydrateSamples),
+ formatSummary("opfs-hydrate", opfsHydrateSamples),
+ );
+ }
+
+ return result;
+}
+
+async function main() {
+ await setupIndexedDbTestEnv();
+ const results = {};
+ for (const preset of SIZE_PRESETS) {
+ results[preset.label] = await runPreset(preset);
+ }
+ if (outputJson) {
+ console.log(
+ JSON.stringify({
+ runs: RUNS,
+ presets: results,
+ }),
+ );
+ }
+}
+
+await main();
diff --git a/tests/perf/persist-load-bench.mjs b/tests/perf/persist-load-bench.mjs
index a33f5c0..f075edc 100644
--- a/tests/perf/persist-load-bench.mjs
+++ b/tests/perf/persist-load-bench.mjs
@@ -12,6 +12,7 @@ import {
import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
const RUNS = 4;
+const outputJson = process.argv.includes("--json");
const SIZE_PRESETS = [
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
@@ -260,6 +261,11 @@ async function runPreset(preset) {
const opfsCommitSamples = [];
const snapshotNodesSamples = [];
const hydrateRuntimeMetaSamples = [];
+ const hydrateNodesSamples = [];
+ const hydrateEdgesSamples = [];
+ const hydrateStateSamples = [];
+ const hydrateNormalizeSamples = [];
+ const hydrateIntegritySamples = [];
const walFileWriteSamples = [];
const manifestFileWriteSamples = [];
@@ -295,31 +301,64 @@ async function runPreset(preset) {
opfsCommitSamples.push(opfsCommitResult.elapsedMs);
snapshotNodesSamples.push(Number(afterSnapshotResult.diagnostics?.nodesMs || 0));
hydrateRuntimeMetaSamples.push(Number(hydrateResult.diagnostics?.runtimeMetaMs || 0));
+ hydrateNodesSamples.push(Number(hydrateResult.diagnostics?.nodesMs || 0));
+ hydrateEdgesSamples.push(Number(hydrateResult.diagnostics?.edgesMs || 0));
+ hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0));
+ hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0));
+ hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0));
walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
manifestFileWriteSamples.push(
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
);
}
- console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
- console.log(
- formatSummary("snapshot-build", snapshotBuildSamples),
- `nodesPhaseP95=${summarize(snapshotNodesSamples).p95.toFixed(2)}ms`,
- );
- console.log(
- formatSummary("hydrate", hydrateSamples),
- `runtimeMetaP95=${summarize(hydrateRuntimeMetaSamples).p95.toFixed(2)}ms`,
- );
- console.log(
- formatSummary("opfs-commit", opfsCommitSamples),
- `walFileP95=${summarize(walFileWriteSamples).p95.toFixed(2)}ms`,
- `manifestFileP95=${summarize(manifestFileWriteSamples).p95.toFixed(2)}ms`,
- );
+ const result = {
+ snapshotBuildMs: summarize(snapshotBuildSamples),
+ snapshotNodesMs: summarize(snapshotNodesSamples),
+ hydrateMs: summarize(hydrateSamples),
+ hydrateNodesMs: summarize(hydrateNodesSamples),
+ hydrateEdgesMs: summarize(hydrateEdgesSamples),
+ hydrateStateMs: summarize(hydrateStateSamples),
+ hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
+ hydrateIntegrityMs: summarize(hydrateIntegritySamples),
+ hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
+ opfsCommitMs: summarize(opfsCommitSamples),
+ opfsWalFileWriteMs: summarize(walFileWriteSamples),
+ opfsManifestFileWriteMs: summarize(manifestFileWriteSamples),
+ };
+ if (!outputJson) {
+ console.log(`\n[ST-BME][persist-load-bench] ${preset.label}`);
+ console.log(
+ formatSummary("snapshot-build", snapshotBuildSamples),
+ `nodesPhaseP95=${result.snapshotNodesMs.p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("hydrate", hydrateSamples),
+ `nodesP95=${result.hydrateNodesMs.p95.toFixed(2)}ms`,
+ `edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`,
+ `normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`,
+ `integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`,
+ `runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`,
+ );
+ console.log(
+ formatSummary("opfs-commit", opfsCommitSamples),
+ `walFileP95=${result.opfsWalFileWriteMs.p95.toFixed(2)}ms`,
+ `manifestFileP95=${result.opfsManifestFileWriteMs.p95.toFixed(2)}ms`,
+ );
+ }
+ return result;
}
async function main() {
+ const results = {};
for (const preset of SIZE_PRESETS) {
- await runPreset(preset);
+ results[preset.label] = await runPreset(preset);
+ }
+ if (outputJson) {
+ console.log(JSON.stringify({
+ runs: RUNS,
+ presets: results,
+ }));
}
}
diff --git a/tests/scoped-memory.mjs b/tests/scoped-memory.mjs
index 519dd50..04fcc0f 100644
--- a/tests/scoped-memory.mjs
+++ b/tests/scoped-memory.mjs
@@ -8,6 +8,11 @@ import {
findLatestNode,
serializeGraph,
} from "../graph/graph.js";
+import { normalizeMemoryScope } from "../graph/memory-scope.js";
+import {
+ normalizeStoryTime,
+ normalizeStoryTimeSpan,
+} from "../graph/story-timeline.js";
const graph = createEmptyGraph();
const objectiveNode = createNode({
@@ -53,6 +58,50 @@ const latestPov = findLatestNode(
assert.equal(latestObjective?.id, objectiveNode.id);
assert.equal(latestPov?.id, povNode.id);
+const normalizedScope = {
+ layer: "pov",
+ ownerType: "character",
+ ownerId: "艾琳",
+ ownerName: "艾琳",
+ regionPrimary: "钟楼",
+ regionPath: ["钟楼", "塔顶"],
+ regionSecondary: ["旧城区"],
+};
+assert.equal(
+ normalizeMemoryScope(normalizedScope),
+ normalizedScope,
+ "已规范的 scope 对象应直接复用",
+);
+
+const normalizedStoryTime = {
+ segmentId: "tl-1",
+ label: "第二天清晨",
+ tense: "ongoing",
+ relation: "same",
+ anchorLabel: "昨夜",
+ confidence: "high",
+ source: "derived",
+};
+assert.equal(
+ normalizeStoryTime(normalizedStoryTime),
+ normalizedStoryTime,
+ "已规范的 storyTime 对象应直接复用",
+);
+
+const normalizedStoryTimeSpan = {
+ startSegmentId: "tl-0",
+ endSegmentId: "tl-1",
+ startLabel: "昨夜",
+ endLabel: "第二天清晨",
+ mixed: false,
+ source: "derived",
+};
+assert.equal(
+ normalizeStoryTimeSpan(normalizedStoryTimeSpan),
+ normalizedStoryTimeSpan,
+ "已规范的 storyTimeSpan 对象应直接复用",
+);
+
const legacyGraph = deserializeGraph({
version: 6,
lastProcessedSeq: 0,
From 5c20417ce4d6471ee88a9e29aa089a213ef7a931 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 11:32:54 +0000
Subject: [PATCH 17/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 58213c8..702d495 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.5.2",
+ "version": "5.5.3",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From 4ab2e0c3c9d4c5f15b6b4092f27f97479d0cc0a0 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 20:08:03 +0800
Subject: [PATCH 18/74] perf: add native hydrate wasm path
---
index.js | 192 ++++++++++++++++++++++++++
native/stbme-core/src/lib.rs | 65 +++++++++
package.json | 1 +
runtime/settings-defaults.js | 2 +
scripts/compare-p1-bench.mjs | 25 +++-
sync/bme-db.js | 216 +++++++++++++++++++++++++++++-
tests/default-settings.mjs | 2 +
tests/graph-persistence.mjs | 2 +
tests/native-hydrate-failopen.mjs | 57 ++++++++
tests/native-hydrate-hook.mjs | 208 ++++++++++++++++++++++++++++
tests/native-layout-wrapper.mjs | 40 ++++++
tests/perf/persist-load-bench.mjs | 68 +++++++++-
vendor/wasm/stbme_core.js | 24 ++++
13 files changed, 892 insertions(+), 10 deletions(-)
create mode 100644 tests/native-hydrate-failopen.mjs
create mode 100644 tests/native-hydrate-hook.mjs
diff --git a/index.js b/index.js
index 569a94c..7aed63c 100644
--- a/index.js
+++ b/index.js
@@ -23,6 +23,7 @@ import {
buildPersistDelta,
buildGraphFromSnapshot,
buildSnapshotFromGraph,
+ evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
ensureDexieLoaded,
} from "./sync/bme-db.js";
@@ -1207,6 +1208,7 @@ let isRecalling = false;
let activeRecallPromise = null;
let recallRunSequence = 0;
let nativePersistDeltaInstallPromise = null;
+let nativeHydrateInstallPromise = null;
let lastInjectionContent = "";
let lastExtractedItems = []; // 最近提取的节点(面板展示用)
let lastRecalledItems = []; // 最近召回的节点(面板展示用)
@@ -9122,6 +9124,14 @@ function applyIndexedDbSnapshotToRuntime(
storageMode = storagePrimary,
statusLabel = "IndexedDB",
reasonPrefix = "indexeddb",
+ currentSettings = null,
+ nativeHydrateRequested = null,
+ nativeHydrateForceDisabled = null,
+ nativeHydrateGate = null,
+ nativeHydratePreloadStatus = "",
+ nativeHydratePreloadMs = 0,
+ nativeHydratePreloadError = "",
+ nativeHydrateModuleStatus = null,
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
@@ -9221,10 +9231,35 @@ function applyIndexedDbSnapshotToRuntime(
}
let graphFromSnapshot = null;
let hydrateDiagnostics = null;
+ const effectiveSettings = currentSettings || getSettings();
+ const resolvedNativeHydrateRequested =
+ nativeHydrateRequested == null
+ ? effectiveSettings.loadUseNativeHydrate === true
+ : nativeHydrateRequested === true;
+ const resolvedNativeHydrateForceDisabled =
+ nativeHydrateForceDisabled == null
+ ? effectiveSettings.graphNativeForceDisable === true
+ : nativeHydrateForceDisabled === true;
+ const resolvedNativeHydrateGate =
+ nativeHydrateGate && typeof nativeHydrateGate === "object"
+ ? nativeHydrateGate
+ : evaluateNativeHydrateGate(snapshot, effectiveSettings);
+ const shouldUseNativeHydrate =
+ resolvedNativeHydrateRequested &&
+ resolvedNativeHydrateForceDisabled !== true &&
+ resolvedNativeHydrateGate.allowed;
+ const resolvedNativeHydratePreloadStatus = String(
+ nativeHydratePreloadStatus ||
+ (resolvedNativeHydrateRequested ? "not-preloaded" : "not-requested"),
+ );
try {
const hydrateStartedAt = readLoadDiagnosticsNow();
graphFromSnapshot = buildGraphFromSnapshot(snapshot, {
chatId: normalizedChatId,
+ useNativeHydrate: shouldUseNativeHydrate,
+ nativeFailOpen: effectiveSettings.nativeEngineFailOpen !== false,
+ loadNativeHydrateThresholdRecords:
+ effectiveSettings.loadNativeHydrateThresholdRecords,
onDiagnostics(snapshotValue) {
hydrateDiagnostics =
snapshotValue &&
@@ -9277,6 +9312,17 @@ function applyIndexedDbSnapshotToRuntime(
integrityReasons: Array.isArray(error?.reasons) ? error.reasons : [],
chatId: normalizedChatId,
attemptIndex,
+ hydrateDiagnostics: cloneRuntimeDebugValue(hydrateDiagnostics, null),
+ nativeHydrateRequested: resolvedNativeHydrateRequested,
+ nativeHydrateForceDisabled: resolvedNativeHydrateForceDisabled,
+ nativeHydrateGate: cloneRuntimeDebugValue(resolvedNativeHydrateGate, null),
+ nativeHydratePreloadStatus: resolvedNativeHydratePreloadStatus,
+ nativeHydratePreloadMs: nativeHydratePreloadMs,
+ nativeHydratePreloadError: nativeHydratePreloadError,
+ nativeHydrateModuleStatus: cloneRuntimeDebugValue(
+ nativeHydrateModuleStatus,
+ null,
+ ),
};
recordLoadDiagnostics({
success: false,
@@ -9296,6 +9342,27 @@ function applyIndexedDbSnapshotToRuntime(
hydrateIntegrityMs: normalizeLoadDiagnosticsMs(
hydrateDiagnostics?.integrityMs,
),
+ hydrateNativeRequested: resolvedNativeHydrateRequested,
+ hydrateNativeForceDisabled: resolvedNativeHydrateForceDisabled,
+ hydrateNativeGateAllowed: resolvedNativeHydrateGate.allowed === true,
+ hydrateNativeGateReasons: cloneRuntimeDebugValue(
+ resolvedNativeHydrateGate.reasons,
+ [],
+ ),
+ hydrateNativePreloadStatus: resolvedNativeHydratePreloadStatus,
+ hydrateNativePreloadMs: normalizeLoadDiagnosticsMs(nativeHydratePreloadMs),
+ hydrateNativePreloadError: String(nativeHydratePreloadError || ""),
+ hydrateNativeModuleLoaded: Boolean(nativeHydrateModuleStatus?.loaded),
+ hydrateNativeModuleSource: String(nativeHydrateModuleStatus?.source || ""),
+ hydrateNativeModuleError: String(
+ nativeHydrateModuleStatus?.error || nativeHydratePreloadError || "",
+ ),
+ hydrateNativeUsed: hydrateDiagnostics?.nativeUsed === true,
+ hydrateNativeStatus: String(hydrateDiagnostics?.nativeStatus || ""),
+ hydrateNativeError: String(hydrateDiagnostics?.nativeError || ""),
+ hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.nativeRecordsMs,
+ ),
error: error?.message || String(error),
integrityReasons: Array.isArray(error?.reasons) ? [...error.reasons] : [],
});
@@ -9405,6 +9472,17 @@ function applyIndexedDbSnapshotToRuntime(
attemptIndex,
shadowSnapshotUsed: false,
revision,
+ hydrateDiagnostics: cloneRuntimeDebugValue(hydrateDiagnostics, null),
+ nativeHydrateRequested: resolvedNativeHydrateRequested,
+ nativeHydrateForceDisabled: resolvedNativeHydrateForceDisabled,
+ nativeHydrateGate: cloneRuntimeDebugValue(resolvedNativeHydrateGate, null),
+ nativeHydratePreloadStatus: resolvedNativeHydratePreloadStatus,
+ nativeHydratePreloadMs: nativeHydratePreloadMs,
+ nativeHydratePreloadError: nativeHydratePreloadError,
+ nativeHydrateModuleStatus: cloneRuntimeDebugValue(
+ nativeHydrateModuleStatus,
+ null,
+ ),
};
recordLoadDiagnostics({
success: true,
@@ -9424,6 +9502,27 @@ function applyIndexedDbSnapshotToRuntime(
hydrateIntegrityMs: normalizeLoadDiagnosticsMs(
hydrateDiagnostics?.integrityMs,
),
+ hydrateNativeRequested: resolvedNativeHydrateRequested,
+ hydrateNativeForceDisabled: resolvedNativeHydrateForceDisabled,
+ hydrateNativeGateAllowed: resolvedNativeHydrateGate.allowed === true,
+ hydrateNativeGateReasons: cloneRuntimeDebugValue(
+ resolvedNativeHydrateGate.reasons,
+ [],
+ ),
+ hydrateNativePreloadStatus: resolvedNativeHydratePreloadStatus,
+ hydrateNativePreloadMs: normalizeLoadDiagnosticsMs(nativeHydratePreloadMs),
+ hydrateNativePreloadError: String(nativeHydratePreloadError || ""),
+ hydrateNativeModuleLoaded: Boolean(nativeHydrateModuleStatus?.loaded),
+ hydrateNativeModuleSource: String(nativeHydrateModuleStatus?.source || ""),
+ hydrateNativeModuleError: String(
+ nativeHydrateModuleStatus?.error || nativeHydratePreloadError || "",
+ ),
+ hydrateNativeUsed: hydrateDiagnostics?.nativeUsed === true,
+ hydrateNativeStatus: String(hydrateDiagnostics?.nativeStatus || ""),
+ hydrateNativeError: String(hydrateDiagnostics?.nativeError || ""),
+ hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs(
+ hydrateDiagnostics?.nativeRecordsMs,
+ ),
applyRuntimeMs: normalizeLoadDiagnosticsMs(
readLoadDiagnosticsNow() - applyRuntimeStartedAt,
),
@@ -9458,6 +9557,7 @@ async function loadGraphFromIndexedDb(
let exportProbeMs = 0;
let preApplyMs = 0;
let exportSnapshotSource = "";
+ const currentSettings = getSettings();
if (!normalizedChatId) {
const result = {
success: false,
@@ -9917,6 +10017,57 @@ async function loadGraphFromIndexedDb(
}
cacheIndexedDbSnapshot(normalizedChatId, snapshot);
+ const nativeHydrateRequested = currentSettings.loadUseNativeHydrate === true;
+ const nativeHydrateForceDisabled =
+ currentSettings.graphNativeForceDisable === true;
+ const nativeHydrateGate = evaluateNativeHydrateGate(snapshot, currentSettings);
+ const shouldUseNativeHydrate =
+ nativeHydrateRequested &&
+ nativeHydrateForceDisabled !== true &&
+ nativeHydrateGate.allowed;
+ let nativeHydrateModuleStatus = null;
+ let nativeHydratePreloadStatus = nativeHydrateRequested
+ ? nativeHydrateForceDisabled
+ ? "force-disabled"
+ : nativeHydrateGate.allowed
+ ? "pending"
+ : "gated-out"
+ : "not-requested";
+ let nativeHydratePreloadError = "";
+ let nativeHydratePreloadMs = 0;
+ if (shouldUseNativeHydrate) {
+ const preloadStartedAt = readLoadDiagnosticsNow();
+ try {
+ if (!nativeHydrateInstallPromise) {
+ nativeHydrateInstallPromise = import("./vendor/wasm/stbme_core.js")
+ .then((module) => module?.installNativeHydrateHook?.())
+ .catch((error) => {
+ nativeHydrateInstallPromise = null;
+ throw error;
+ });
+ }
+ nativeHydrateModuleStatus = await nativeHydrateInstallPromise;
+ nativeHydratePreloadStatus = nativeHydrateModuleStatus?.loaded
+ ? "loaded"
+ : "not-loaded";
+ nativeHydratePreloadMs =
+ readLoadDiagnosticsNow() - preloadStartedAt;
+ } catch (error) {
+ nativeHydratePreloadStatus = "failed";
+ nativeHydratePreloadMs =
+ readLoadDiagnosticsNow() - preloadStartedAt;
+ nativeHydratePreloadError = error?.message || String(error);
+ if (currentSettings.nativeEngineFailOpen !== false) {
+ console.warn(
+ "[ST-BME] native hydrate preload failed, fallback to JS hydrate:",
+ error,
+ );
+ } else {
+ throw error;
+ }
+ }
+ }
+
preApplyMs = readLoadDiagnosticsNow() - loadStartedAt;
const applyInvokeStartedAt = readLoadDiagnosticsNow();
const loadResult = applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, {
@@ -9926,6 +10077,14 @@ async function loadGraphFromIndexedDb(
storageMode: snapshotStore.storageMode,
statusLabel: snapshotStore.statusLabel,
reasonPrefix: snapshotStore.reasonPrefix,
+ currentSettings,
+ nativeHydrateRequested,
+ nativeHydrateForceDisabled,
+ nativeHydrateGate,
+ nativeHydratePreloadStatus,
+ nativeHydratePreloadMs,
+ nativeHydratePreloadError,
+ nativeHydrateModuleStatus,
});
const applyInvokeMs = readLoadDiagnosticsNow() - applyInvokeStartedAt;
const totalLoadMs = readLoadDiagnosticsNow() - loadStartedAt;
@@ -9952,6 +10111,39 @@ async function loadGraphFromIndexedDb(
preApplyOtherMs: normalizeLoadDiagnosticsMs(
Math.max(0, preApplyMs - exportSnapshotMs - exportProbeMs),
),
+ hydrateNativeRequested: loadResult?.nativeHydrateRequested === true,
+ hydrateNativeForceDisabled: loadResult?.nativeHydrateForceDisabled === true,
+ hydrateNativeGateAllowed: loadResult?.nativeHydrateGate?.allowed === true,
+ hydrateNativeGateReasons: cloneRuntimeDebugValue(
+ loadResult?.nativeHydrateGate?.reasons,
+ [],
+ ),
+ hydrateNativePreloadStatus: String(
+ loadResult?.nativeHydratePreloadStatus || nativeHydratePreloadStatus || "",
+ ),
+ hydrateNativePreloadMs: normalizeLoadDiagnosticsMs(
+ loadResult?.nativeHydratePreloadMs,
+ ),
+ hydrateNativePreloadError: String(
+ loadResult?.nativeHydratePreloadError || "",
+ ),
+ hydrateNativeModuleLoaded: Boolean(
+ loadResult?.nativeHydrateModuleStatus?.loaded,
+ ),
+ hydrateNativeModuleSource: String(
+ loadResult?.nativeHydrateModuleStatus?.source || "",
+ ),
+ hydrateNativeModuleError: String(
+ loadResult?.nativeHydrateModuleStatus?.error || "",
+ ),
+ hydrateNativeUsed: loadResult?.hydrateDiagnostics?.nativeUsed === true,
+ hydrateNativeStatus: String(
+ loadResult?.hydrateDiagnostics?.nativeStatus || "",
+ ),
+ hydrateNativeError: String(loadResult?.hydrateDiagnostics?.nativeError || ""),
+ hydrateNativeRecordsMs: normalizeLoadDiagnosticsMs(
+ loadResult?.hydrateDiagnostics?.nativeRecordsMs,
+ ),
applyInvokeMs: normalizeLoadDiagnosticsMs(applyInvokeMs),
untrackedMs: normalizeLoadDiagnosticsMs(
Math.max(0, totalLoadMs - loadAccountedMs),
diff --git a/native/stbme-core/src/lib.rs b/native/stbme-core/src/lib.rs
index 74db058..7a15f7f 100644
--- a/native/stbme-core/src/lib.rs
+++ b/native/stbme-core/src/lib.rs
@@ -22,6 +22,25 @@ struct LayoutNode {
region_rect: RegionRect,
}
+fn solve_hydrate_records_in_rust(payload: HydrateRecordsPayload) -> HydrateRecordsResult {
+ let nodes = clone_hydrate_records(payload.nodes);
+ let edges = clone_hydrate_records(payload.edges);
+ let node_count = nodes.len();
+ let edge_count = edges.len();
+ HydrateRecordsResult {
+ ok: true,
+ used_native: true,
+ nodes,
+ edges,
+ diagnostics: HydrateRecordsDiagnostics {
+ solver: "rust-wasm".to_string(),
+ node_count,
+ edge_count,
+ records_normalized: payload.records_normalized,
+ },
+ }
+}
+
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct LayoutEdge {
@@ -224,6 +243,36 @@ struct PersistDeltaIdResult {
upsert_tombstone_ids: Vec,
}
+#[derive(Debug, Clone, Deserialize, Default)]
+#[serde(rename_all = "camelCase")]
+struct HydrateRecordsPayload {
+ #[serde(default)]
+ nodes: Vec,
+ #[serde(default)]
+ edges: Vec,
+ #[serde(default)]
+ records_normalized: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct HydrateRecordsDiagnostics {
+ solver: String,
+ node_count: usize,
+ edge_count: usize,
+ records_normalized: bool,
+}
+
+#[derive(Debug, Clone, Serialize)]
+#[serde(rename_all = "camelCase")]
+struct HydrateRecordsResult {
+ ok: bool,
+ used_native: bool,
+ nodes: Vec,
+ edges: Vec,
+ diagnostics: HydrateRecordsDiagnostics,
+}
+
fn default_iterations() -> u32 {
80
}
@@ -299,6 +348,13 @@ fn sanitize_json_records(records: Vec) -> Vec {
.collect()
}
+fn clone_hydrate_records(records: Vec) -> Vec {
+ records
+ .into_iter()
+ .filter(|record| record.is_object())
+ .collect()
+}
+
fn sanitize_persist_snapshot(snapshot: PersistSnapshot) -> PersistSnapshot {
PersistSnapshot {
meta: snapshot.meta,
@@ -1018,3 +1074,12 @@ pub fn build_persist_delta_compact_hash(payload: JsValue) -> Result Result {
+ let parsed: HydrateRecordsPayload = serde_wasm_bindgen::from_value(payload)
+ .map_err(|error| JsValue::from_str(&format!("invalid hydrate payload: {error}")))?;
+ let solved = solve_hydrate_records_in_rust(parsed);
+ serde_wasm_bindgen::to_value(&solved)
+ .map_err(|error| JsValue::from_str(&format!("serialize hydrate result failed: {error}")))
+}
diff --git a/package.json b/package.json
index ff538bb..350eda5 100644
--- a/package.json
+++ b/package.json
@@ -15,6 +15,7 @@
"bench:graph-layout": "node tests/perf/graph-layout-bench.mjs",
"bench:persist-delta": "node tests/perf/persist-delta-bench.mjs",
"bench:persist-load": "node tests/perf/persist-load-bench.mjs",
+ "bench:persist-load:native-hydrate": "node tests/perf/persist-load-bench.mjs --native-hydrate",
"bench:load-preapply": "node tests/perf/load-preapply-bench.mjs",
"bench:p1-compare": "node scripts/compare-p1-bench.mjs",
"bench:native": "npm run bench:graph-layout && npm run bench:persist-delta",
diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js
index 71e6cf2..222d8a4 100644
--- a/runtime/settings-defaults.js
+++ b/runtime/settings-defaults.js
@@ -122,6 +122,8 @@ export const defaultSettings = {
persistNativeDeltaThresholdStructuralDelta: 600,
persistNativeDeltaThresholdSerializedChars: 4000000,
persistNativeDeltaBridgeMode: "json",
+ loadUseNativeHydrate: false,
+ loadNativeHydrateThresholdRecords: 12000,
nativeEngineFailOpen: true,
graphNativeForceDisable: false,
diff --git a/scripts/compare-p1-bench.mjs b/scripts/compare-p1-bench.mjs
index cd75ba3..c935b7e 100644
--- a/scripts/compare-p1-bench.mjs
+++ b/scripts/compare-p1-bench.mjs
@@ -17,6 +17,8 @@ const args = new Map(
const baselineRef = String(args.get("--baseline") || "origin/main");
const currentRef = String(args.get("--current") || "HEAD");
const outputJson = args.has("--json");
+const useNativeHydrate = args.has("--native-hydrate");
+const nativeHydrateThreshold = args.get("--native-hydrate-threshold");
async function runCommand(command, commandArgs, cwd) {
const { stdout, stderr } = await execFileAsync(command, commandArgs, {
@@ -78,9 +80,16 @@ function printRows(rows = [], title = "") {
}
async function runBenchSuite(cwd) {
+ const persistLoadArgs = ["tests/perf/persist-load-bench.mjs", "--json"];
+ if (useNativeHydrate) {
+ persistLoadArgs.push("--native-hydrate");
+ }
+ if (nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true) {
+ persistLoadArgs.push(`--native-hydrate-threshold=${nativeHydrateThreshold}`);
+ }
const persistLoad = await runCommand(
process.execPath,
- ["tests/perf/persist-load-bench.mjs", "--json"],
+ persistLoadArgs,
cwd,
);
const loadPreapply = await runCommand(
@@ -153,6 +162,11 @@ async function main() {
baselineSha,
currentRef,
currentSha,
+ nativeHydrateRequested: useNativeHydrate,
+ nativeHydrateThreshold:
+ nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true
+ ? String(nativeHydrateThreshold)
+ : null,
compare,
}),
);
@@ -161,6 +175,15 @@ async function main() {
console.log(`[ST-BME][P1-compare] baseline=${baselineRef} (${baselineSha.slice(0, 7)})`);
console.log(`[ST-BME][P1-compare] current=${currentRef} (${currentSha.slice(0, 7)})`);
+ if (useNativeHydrate) {
+ console.log(
+ `[ST-BME][P1-compare] nativeHydrate=on threshold=${
+ nativeHydrateThreshold !== undefined && nativeHydrateThreshold !== true
+ ? nativeHydrateThreshold
+ : "default"
+ }`,
+ );
+ }
printRows(
collectMetricRows(compare, (entry) => entry.opfsCommitMs?.p95, "opfsCommitMs.p95"),
diff --git a/sync/bme-db.js b/sync/bme-db.js
index 74e2a10..0d51e4e 100644
--- a/sync/bme-db.js
+++ b/sync/bme-db.js
@@ -19,6 +19,7 @@ const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_RECORDS = 20000;
const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_STRUCTURAL_DELTA = 600;
const DEFAULT_PERSIST_NATIVE_DELTA_THRESHOLD_SERIALIZED_CHARS = 4000000;
const DEFAULT_PERSIST_NATIVE_DELTA_BRIDGE_MODE = "json";
+const DEFAULT_NATIVE_HYDRATE_THRESHOLD_RECORDS = 12000;
const SUPPORTED_PERSIST_NATIVE_DELTA_BRIDGE_MODES = new Set(["json", "hash"]);
const PERSIST_RECORD_SERIALIZATION_CACHE_LIMIT = 50000;
@@ -131,6 +132,52 @@ function estimatePersistPayloadBytes(value = null) {
}
}
+function tryBuildNativeHydrateRecords(snapshotView, options = {}) {
+ if (options?.useNativeHydrate !== true) {
+ return {
+ rawResult: null,
+ status: "not-requested",
+ error: "",
+ };
+ }
+ const nativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords;
+ if (typeof nativeBuilder !== "function") {
+ if (options?.nativeFailOpen === false) {
+ throw new Error("native-hydrate-builder-unavailable");
+ }
+ return {
+ rawResult: null,
+ status: "builder-unavailable",
+ error: "native-hydrate-builder-unavailable",
+ };
+ }
+
+ try {
+ return {
+ rawResult: nativeBuilder(
+ {
+ nodes: toArray(snapshotView?.nodes),
+ edges: toArray(snapshotView?.edges),
+ },
+ {
+ recordsNormalized: options?.recordsNormalized === true,
+ },
+ ),
+ status: "ok",
+ error: "",
+ };
+ } catch (error) {
+ if (options?.nativeFailOpen === false) {
+ throw error;
+ }
+ return {
+ rawResult: null,
+ status: "builder-error",
+ error: error?.message || String(error),
+ };
+ }
+}
+
function toPlainData(value, fallbackValue = null) {
if (value == null) {
return fallbackValue;
@@ -303,6 +350,72 @@ function cloneHydrateSnapshotNodeRecords(records = []) {
return output;
}
+function hasSharedHydrateRecordReferences(records = [], sourceRecords = []) {
+ const normalizedSourceRecords = toArray(sourceRecords);
+ if (!normalizedSourceRecords.length || !Array.isArray(records) || !records.length) {
+ return false;
+ }
+ const sourceRecordSet = new WeakSet();
+ for (let index = 0; index < normalizedSourceRecords.length; index += 1) {
+ const record = normalizedSourceRecords[index];
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
+ sourceRecordSet.add(record);
+ }
+ for (let index = 0; index < records.length; index += 1) {
+ const record = records[index];
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
+ if (sourceRecordSet.has(record)) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function normalizeNativeHydrateRecordArray(records = []) {
+ const sourceRecords = toArray(records);
+ if (sourceRecords.length === 0) return [];
+ const output = new Array(sourceRecords.length);
+ let writeIndex = 0;
+ for (let index = 0; index < sourceRecords.length; index += 1) {
+ const record = sourceRecords[index];
+ if (!record || typeof record !== "object" || Array.isArray(record)) continue;
+ output[writeIndex] = record;
+ writeIndex += 1;
+ }
+ output.length = writeIndex;
+ return output;
+}
+
+function normalizeNativeHydrateResult(rawResult = null, snapshotView = {}) {
+ if (!rawResult || typeof rawResult !== "object" || Array.isArray(rawResult)) {
+ return null;
+ }
+ if (
+ rawResult.nodes === snapshotView?.nodes ||
+ rawResult.edges === snapshotView?.edges
+ ) {
+ return null;
+ }
+ const nodes = normalizeNativeHydrateRecordArray(rawResult.nodes);
+ const edges = normalizeNativeHydrateRecordArray(rawResult.edges);
+ if (
+ hasSharedHydrateRecordReferences(nodes, snapshotView?.nodes) ||
+ hasSharedHydrateRecordReferences(edges, snapshotView?.edges)
+ ) {
+ return null;
+ }
+ return {
+ nodes,
+ edges,
+ diagnostics:
+ rawResult.diagnostics &&
+ typeof rawResult.diagnostics === "object" &&
+ !Array.isArray(rawResult.diagnostics)
+ ? rawResult.diagnostics
+ : null,
+ };
+}
+
function cloneHydrateSnapshotEdgeRecords(records = []) {
const sourceRecords = toArray(records);
if (sourceRecords.length === 0) return [];
@@ -452,6 +565,40 @@ function countPersistSnapshotRecords(snapshot = {}) {
);
}
+function countHydrateSnapshotRecords(snapshot = {}) {
+ return toArray(snapshot?.nodes).length + toArray(snapshot?.edges).length;
+}
+
+export function resolveNativeHydrateGateOptions(options = {}) {
+ return {
+ minSnapshotRecords: normalizePersistNativeDeltaThreshold(
+ options?.loadNativeHydrateThresholdRecords ??
+ options?.hydrateNativeThresholdRecords ??
+ options?.minSnapshotRecords,
+ DEFAULT_NATIVE_HYDRATE_THRESHOLD_RECORDS,
+ ),
+ };
+}
+
+export function evaluateNativeHydrateGate(snapshot, options = {}) {
+ const normalizedSnapshot = normalizePersistSnapshotView(snapshot);
+ const gateOptions = resolveNativeHydrateGateOptions(options);
+ const recordCount = countHydrateSnapshotRecords(normalizedSnapshot);
+ const reasons = [];
+ if (
+ gateOptions.minSnapshotRecords > 0 &&
+ recordCount < gateOptions.minSnapshotRecords
+ ) {
+ reasons.push("below-min-snapshot-records");
+ }
+ return {
+ allowed: reasons.length === 0,
+ reasons,
+ minSnapshotRecords: gateOptions.minSnapshotRecords,
+ recordCount,
+ };
+}
+
function countPersistSnapshotStructuralDelta(beforeSnapshot = {}, afterSnapshot = {}) {
return (
Math.abs(toArray(afterSnapshot?.nodes).length - toArray(beforeSnapshot?.nodes).length) +
@@ -2272,6 +2419,14 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
normalizeMs: 0,
integrityMs: 0,
integrityReasonCount: 0,
+ nativeRequested: false,
+ nativeUsed: false,
+ nativeStatus: "not-requested",
+ nativeError: "",
+ nativeRecordsMs: 0,
+ nativeGateAllowed: false,
+ nativeGateReasons: [],
+ nativeModuleDiagnostics: null,
}
: null;
const snapshotView = normalizePersistSnapshotView(snapshot);
@@ -2301,6 +2456,58 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
);
const snapshotRecordsNormalized =
snapshotMeta?.[BME_RUNTIME_RECORDS_NORMALIZED_META_KEY] === true;
+ const nativeHydrateGate =
+ options?.useNativeHydrate === true
+ ? evaluateNativeHydrateGate(snapshotView, options)
+ : null;
+ const nativeHydrateStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
+ let nativeHydrateAttempt =
+ options?.useNativeHydrate !== true
+ ? {
+ rawResult: null,
+ status: "not-requested",
+ error: "",
+ }
+ : nativeHydrateGate?.allowed === false
+ ? {
+ rawResult: null,
+ status: "gated-out",
+ error: "",
+ }
+ : tryBuildNativeHydrateRecords(
+ snapshotView,
+ {
+ ...options,
+ recordsNormalized: snapshotRecordsNormalized,
+ },
+ );
+ let nativeHydrateResult = normalizeNativeHydrateResult(
+ nativeHydrateAttempt.rawResult,
+ snapshotView,
+ );
+ if (nativeHydrateAttempt.rawResult && !nativeHydrateResult) {
+ if (options?.nativeFailOpen === false) {
+ throw new Error("native-hydrate-invalid-result");
+ }
+ nativeHydrateAttempt = {
+ rawResult: null,
+ status: "invalid-result",
+ error: "native-hydrate-invalid-result",
+ };
+ }
+ if (hydrateDiagnostics) {
+ hydrateDiagnostics.nativeRequested = options?.useNativeHydrate === true;
+ hydrateDiagnostics.nativeStatus = nativeHydrateAttempt.status;
+ hydrateDiagnostics.nativeError = nativeHydrateAttempt.error;
+ hydrateDiagnostics.nativeGateAllowed = nativeHydrateGate?.allowed ?? false;
+ hydrateDiagnostics.nativeGateReasons = nativeHydrateGate?.reasons || [];
+ hydrateDiagnostics.nativeModuleDiagnostics =
+ nativeHydrateResult?.diagnostics || null;
+ if (nativeHydrateAttempt.rawResult) {
+ hydrateDiagnostics.nativeRecordsMs =
+ readPersistDeltaNow() - nativeHydrateStartedAt;
+ }
+ }
const runtimeGraph = createEmptyGraph();
runtimeGraph.version = Number.isFinite(
@@ -2310,17 +2517,22 @@ export function buildGraphFromSnapshot(snapshot, options = {}) {
: runtimeGraph.version;
const hydrateNodesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
- runtimeGraph.nodes = cloneHydrateSnapshotNodeRecords(snapshotView.nodes);
+ runtimeGraph.nodes = nativeHydrateResult
+ ? nativeHydrateResult.nodes
+ : cloneHydrateSnapshotNodeRecords(snapshotView.nodes);
if (hydrateDiagnostics) {
hydrateDiagnostics.nodeCount = runtimeGraph.nodes.length;
hydrateDiagnostics.nodesMs = readPersistDeltaNow() - hydrateNodesStartedAt;
}
const hydrateEdgesStartedAt = shouldCollectDiagnostics ? readPersistDeltaNow() : 0;
- runtimeGraph.edges = cloneHydrateSnapshotEdgeRecords(snapshotView.edges);
+ runtimeGraph.edges = nativeHydrateResult
+ ? nativeHydrateResult.edges
+ : cloneHydrateSnapshotEdgeRecords(snapshotView.edges);
if (hydrateDiagnostics) {
hydrateDiagnostics.edgeCount = runtimeGraph.edges.length;
hydrateDiagnostics.edgesMs = readPersistDeltaNow() - hydrateEdgesStartedAt;
+ hydrateDiagnostics.nativeUsed = Boolean(nativeHydrateResult);
}
const hydrateRuntimeMetaStartedAt = shouldCollectDiagnostics
diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs
index b8e9339..00127a9 100644
--- a/tests/default-settings.mjs
+++ b/tests/default-settings.mjs
@@ -76,6 +76,8 @@ assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000);
assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600);
assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000);
assert.equal(defaultSettings.persistNativeDeltaBridgeMode, "json");
+assert.equal(defaultSettings.loadUseNativeHydrate, false);
+assert.equal(defaultSettings.loadNativeHydrateThresholdRecords, 12000);
assert.equal(defaultSettings.nativeEngineFailOpen, true);
assert.equal(defaultSettings.graphNativeForceDisable, false);
assert.equal(defaultSettings.taskProfilesVersion, 3);
diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs
index 438dd36..26301e6 100644
--- a/tests/graph-persistence.mjs
+++ b/tests/graph-persistence.mjs
@@ -9,6 +9,7 @@ import {
buildGraphFromSnapshot,
buildPersistDelta,
buildSnapshotFromGraph,
+ evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
} from "../sync/bme-db.js";
import { onMessageReceivedController } from "../host/event-binding.js";
@@ -1032,6 +1033,7 @@ async function createGraphPersistenceHarness({
buildGraphFromSnapshot,
buildPersistDelta,
buildSnapshotFromGraph,
+ evaluateNativeHydrateGate,
evaluatePersistNativeDeltaGate,
buildBmeDbName,
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
diff --git a/tests/native-hydrate-failopen.mjs b/tests/native-hydrate-failopen.mjs
new file mode 100644
index 0000000..18e4a85
--- /dev/null
+++ b/tests/native-hydrate-failopen.mjs
@@ -0,0 +1,57 @@
+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-hydrate-first"));
+let firstError = "";
+try {
+ await firstLoad.installNativeHydrateHook();
+} catch (error) {
+ firstError = error?.message || String(error);
+}
+
+assert.match(
+ firstError,
+ /native module unavailable|native hydrate 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_hydrate_records() {
+ return {
+ ok: true,
+ usedNative: true,
+ nodes: [],
+ edges: [],
+ diagnostics: {
+ solver: "mock-rust-wasm",
+ nodeCount: 0,
+ edgeCount: 0,
+ recordsNormalized: false,
+ },
+ };
+ },
+});
+
+const retryStatus = await firstLoad.installNativeHydrateHook();
+assert.equal(retryStatus.loaded, true);
+assert.equal(typeof globalThis.__stBmeNativeHydrateSnapshotRecords, "function");
+
+delete globalThis.__stBmeNativeHydrateSnapshotRecords;
+delete globalThis.__stBmeLoadRustWasmLayout;
+delete globalThis.__stBmeDisableWasmPackArtifacts;
+
+console.log("native-hydrate-failopen tests passed");
diff --git a/tests/native-hydrate-hook.mjs b/tests/native-hydrate-hook.mjs
new file mode 100644
index 0000000..e3d57b6
--- /dev/null
+++ b/tests/native-hydrate-hook.mjs
@@ -0,0 +1,208 @@
+import assert from "node:assert/strict";
+
+import {
+ BME_RUNTIME_HISTORY_META_KEY,
+ BME_RUNTIME_RECORDS_NORMALIZED_META_KEY,
+ BME_RUNTIME_VECTOR_META_KEY,
+ buildGraphFromSnapshot,
+ evaluateNativeHydrateGate,
+ resolveNativeHydrateGateOptions,
+} from "../sync/bme-db.js";
+
+function cloneValue(value) {
+ if (typeof globalThis.structuredClone === "function") {
+ return globalThis.structuredClone(value);
+ }
+ return JSON.parse(JSON.stringify(value));
+}
+
+const snapshot = {
+ meta: {
+ chatId: "chat-native-hydrate",
+ revision: 3,
+ [BME_RUNTIME_RECORDS_NORMALIZED_META_KEY]: true,
+ [BME_RUNTIME_HISTORY_META_KEY]: {
+ chatId: "chat-native-hydrate",
+ lastProcessedAssistantFloor: 7,
+ extractionCount: 2,
+ processedMessageHashes: {},
+ processedMessageHashVersion: 1,
+ processedMessageHashesNeedRefresh: false,
+ recentRecallOwnerKeys: [],
+ activeRecallOwnerKey: "",
+ activeRegion: "",
+ activeRegionSource: "",
+ activeStorySegmentId: "",
+ activeStoryTimeLabel: "",
+ activeStoryTimeSource: "",
+ lastBatchStatus: null,
+ lastMutationSource: "test",
+ lastExtractedRegion: "",
+ lastExtractedStorySegmentId: "",
+ activeCharacterPovOwner: "",
+ activeUserPovOwner: "",
+ },
+ [BME_RUNTIME_VECTOR_META_KEY]: {
+ chatId: "chat-native-hydrate",
+ collectionId: "",
+ hashToNodeId: {},
+ nodeToHash: {},
+ replayRequiredNodeIds: [],
+ dirty: false,
+ dirtyReason: "",
+ pendingRepairFromFloor: null,
+ lastIntegrityIssue: null,
+ lastStats: {
+ nodesIndexed: 0,
+ updatedAt: 0,
+ },
+ },
+ },
+ state: {
+ lastProcessedFloor: 7,
+ extractionCount: 2,
+ },
+ nodes: [
+ {
+ id: "native-node-1",
+ type: "event",
+ updatedAt: 10,
+ fields: {
+ title: "Native Node",
+ },
+ embedding: [1, 2, 3],
+ scope: {
+ ownerType: "character",
+ ownerId: "owner-1",
+ layer: "objective",
+ regionPrimary: "camp",
+ regionPath: ["camp"],
+ regionSecondary: [],
+ },
+ storyTime: {
+ label: "Dawn",
+ tense: "unknown",
+ },
+ storyTimeSpan: {
+ startLabel: "Dawn",
+ endLabel: "Dawn",
+ mixed: false,
+ },
+ },
+ ],
+ edges: [
+ {
+ id: "native-edge-1",
+ fromId: "native-node-1",
+ toId: "native-node-2",
+ relation: "related",
+ scope: {
+ ownerType: "character",
+ ownerId: "owner-1",
+ layer: "objective",
+ regionPrimary: "camp",
+ regionPath: ["camp"],
+ regionSecondary: [],
+ },
+ },
+ ],
+ tombstones: [],
+};
+
+const defaultGate = resolveNativeHydrateGateOptions({});
+assert.equal(defaultGate.minSnapshotRecords, 12000);
+const gatedSmall = evaluateNativeHydrateGate(snapshot, {});
+assert.equal(gatedSmall.allowed, false);
+assert.deepEqual(gatedSmall.reasons, ["below-min-snapshot-records"]);
+const gatedLarge = evaluateNativeHydrateGate(
+ {
+ nodes: new Array(6000).fill({ id: "node-x" }),
+ edges: new Array(6000).fill({ id: "edge-x" }),
+ },
+ {},
+);
+assert.equal(gatedLarge.allowed, true);
+assert.deepEqual(gatedLarge.reasons, []);
+
+const originalNativeBuilder = globalThis.__stBmeNativeHydrateSnapshotRecords;
+
+globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
+ assert.equal(options.recordsNormalized, true);
+ return {
+ ok: true,
+ usedNative: true,
+ nodes: cloneValue(snapshotView.nodes).map((node) => ({
+ ...node,
+ nativeHydrated: true,
+ })),
+ edges: cloneValue(snapshotView.edges).map((edge) => ({
+ ...edge,
+ nativeHydrated: true,
+ })),
+ diagnostics: {
+ solver: "test-native-hydrate",
+ nodeCount: Array.isArray(snapshotView.nodes) ? snapshotView.nodes.length : 0,
+ edgeCount: Array.isArray(snapshotView.edges) ? snapshotView.edges.length : 0,
+ recordsNormalized: options.recordsNormalized === true,
+ },
+ };
+};
+
+let nativeDiagnostics = null;
+const rebuilt = buildGraphFromSnapshot(snapshot, {
+ chatId: "chat-native-hydrate",
+ useNativeHydrate: true,
+ minSnapshotRecords: 0,
+ onDiagnostics(snapshotValue) {
+ nativeDiagnostics = snapshotValue;
+ },
+});
+assert.equal(rebuilt.nodes[0].nativeHydrated, true);
+assert.equal(rebuilt.edges[0].nativeHydrated, true);
+assert.equal(rebuilt.historyState.lastProcessedAssistantFloor, 7);
+assert.equal(nativeDiagnostics.nativeRequested, true);
+assert.equal(nativeDiagnostics.nativeUsed, true);
+assert.equal(nativeDiagnostics.nativeStatus, "ok");
+assert.equal(nativeDiagnostics.nativeGateAllowed, true);
+assert.equal(nativeDiagnostics.nativeModuleDiagnostics?.solver, "test-native-hydrate");
+assert.equal(Number.isFinite(nativeDiagnostics.nativeRecordsMs), true);
+rebuilt.nodes[0].fields.title = "Mutated Native Node";
+rebuilt.nodes[0].embedding[0] = 99;
+assert.equal(snapshot.nodes[0].fields.title, "Native Node");
+assert.equal(snapshot.nodes[0].embedding[0], 1);
+
+delete globalThis.__stBmeNativeHydrateSnapshotRecords;
+
+let fallbackDiagnostics = null;
+const fallbackGraph = buildGraphFromSnapshot(snapshot, {
+ chatId: "chat-native-hydrate",
+ useNativeHydrate: true,
+ minSnapshotRecords: 0,
+ onDiagnostics(snapshotValue) {
+ fallbackDiagnostics = snapshotValue;
+ },
+});
+assert.equal(fallbackGraph.nodes.length, 1);
+assert.equal(fallbackDiagnostics.nativeRequested, true);
+assert.equal(fallbackDiagnostics.nativeUsed, false);
+assert.equal(fallbackDiagnostics.nativeStatus, "builder-unavailable");
+
+let threwUnavailable = false;
+try {
+ buildGraphFromSnapshot(snapshot, {
+ chatId: "chat-native-hydrate",
+ useNativeHydrate: true,
+ minSnapshotRecords: 0,
+ nativeFailOpen: false,
+ });
+} catch (error) {
+ threwUnavailable =
+ String(error?.message || "") === "native-hydrate-builder-unavailable";
+}
+assert.equal(threwUnavailable, true);
+
+if (typeof originalNativeBuilder === "function") {
+ globalThis.__stBmeNativeHydrateSnapshotRecords = originalNativeBuilder;
+}
+
+console.log("native-hydrate-hook tests passed");
diff --git a/tests/native-layout-wrapper.mjs b/tests/native-layout-wrapper.mjs
index 6e543ce..df3f4d9 100644
--- a/tests/native-layout-wrapper.mjs
+++ b/tests/native-layout-wrapper.mjs
@@ -22,6 +22,24 @@ try {
},
};
},
+ build_hydrate_records(payload = {}) {
+ return {
+ ok: true,
+ usedNative: true,
+ nodes: Array.isArray(payload?.nodes)
+ ? payload.nodes.map((node) => ({ ...node, nativeHydrated: true }))
+ : [],
+ edges: Array.isArray(payload?.edges)
+ ? payload.edges.map((edge) => ({ ...edge, nativeHydrated: true }))
+ : [],
+ diagnostics: {
+ solver: "mock-loader",
+ nodeCount: Array.isArray(payload?.nodes) ? payload.nodes.length : 0,
+ edgeCount: Array.isArray(payload?.edges) ? payload.edges.length : 0,
+ recordsNormalized: payload?.recordsNormalized === true,
+ },
+ };
+ },
build_persist_delta_compact(payload = {}) {
return {
upsertNodeIds: Array.isArray(payload?.afterNodes?.ids)
@@ -105,8 +123,29 @@ try {
assert.deepEqual(deltaResult.upsertNodes, [{ id: "persist-native-node", marker: "after-chat" }]);
assert.equal(deltaResult.runtimeMetaPatch.native, true);
+ const hydrateInstallStatus = await wrapper.installNativeHydrateHook();
+ assert.equal(hydrateInstallStatus.loaded, true);
+ assert.equal(
+ typeof globalThis.__stBmeNativeHydrateSnapshotRecords,
+ "function",
+ );
+ const hydrateResult = globalThis.__stBmeNativeHydrateSnapshotRecords(
+ {
+ nodes: [{ id: "hydrate-node", type: "event" }],
+ edges: [{ id: "hydrate-edge", fromId: "hydrate-node", toId: "hydrate-node-2" }],
+ },
+ {
+ recordsNormalized: true,
+ },
+ );
+ assert.equal(hydrateResult.ok, true);
+ assert.equal(hydrateResult.nodes[0].nativeHydrated, true);
+ assert.equal(hydrateResult.edges[0].nativeHydrated, true);
+ assert.equal(hydrateResult.diagnostics.recordsNormalized, true);
+
delete globalThis.__stBmeLoadRustWasmLayout;
delete globalThis.__stBmeNativeBuildPersistDelta;
+ delete globalThis.__stBmeNativeHydrateSnapshotRecords;
delete globalThis.__stBmeDisableWasmPackArtifacts;
const wrapperNoLoader = await importFreshWrapper("no-loader");
@@ -136,6 +175,7 @@ try {
}
delete globalThis.__stBmeDisableWasmPackArtifacts;
delete globalThis.__stBmeNativeBuildPersistDelta;
+ delete globalThis.__stBmeNativeHydrateSnapshotRecords;
}
console.log("native-layout-wrapper tests passed");
diff --git a/tests/perf/persist-load-bench.mjs b/tests/perf/persist-load-bench.mjs
index f075edc..1a8451a 100644
--- a/tests/perf/persist-load-bench.mjs
+++ b/tests/perf/persist-load-bench.mjs
@@ -13,12 +13,27 @@ import { createMemoryOpfsRoot } from "../helpers/memory-opfs.mjs";
const RUNS = 4;
const outputJson = process.argv.includes("--json");
+const useNativeHydrate = process.argv.includes("--native-hydrate");
+const nativeHydrateThresholdArg = process.argv.find((entry) =>
+ String(entry || "").startsWith("--native-hydrate-threshold="),
+);
+const nativeHydrateThresholdRecords = nativeHydrateThresholdArg
+ ? Math.max(
+ 0,
+ Math.floor(
+ Number(String(nativeHydrateThresholdArg).split("=").slice(1).join("=") || 0) || 0,
+ ),
+ )
+ : undefined;
const SIZE_PRESETS = [
{ label: "M", seed: 17, nodeCount: 1200, edgeCount: 3600, churn: 0.08 },
{ label: "L", seed: 29, nodeCount: 3600, edgeCount: 10800, churn: 0.1 },
{ label: "XL", seed: 43, nodeCount: 7200, edgeCount: 21600, churn: 0.12 },
];
+let nativeHydratePreloadStatus = useNativeHydrate ? "pending" : "not-requested";
+let nativeHydratePreloadError = "";
+
function summarize(values = []) {
if (!values.length) {
return { avg: 0, p95: 0, min: 0, max: 0 };
@@ -218,6 +233,9 @@ function measureHydrate(snapshot, chatId) {
const startedAt = performance.now();
buildGraphFromSnapshot(snapshot, {
chatId,
+ useNativeHydrate,
+ loadNativeHydrateThresholdRecords: nativeHydrateThresholdRecords,
+ nativeFailOpen: true,
onDiagnostics(snapshotValue) {
diagnostics = snapshotValue;
},
@@ -266,8 +284,10 @@ async function runPreset(preset) {
const hydrateStateSamples = [];
const hydrateNormalizeSamples = [];
const hydrateIntegritySamples = [];
+ const hydrateNativeRecordsSamples = [];
const walFileWriteSamples = [];
const manifestFileWriteSamples = [];
+ let hydrateNativeUsedRuns = 0;
for (let run = 0; run < RUNS; run += 1) {
const pair = buildBenchPair({
@@ -306,6 +326,12 @@ async function runPreset(preset) {
hydrateStateSamples.push(Number(hydrateResult.diagnostics?.stateMs || 0));
hydrateNormalizeSamples.push(Number(hydrateResult.diagnostics?.normalizeMs || 0));
hydrateIntegritySamples.push(Number(hydrateResult.diagnostics?.integrityMs || 0));
+ hydrateNativeRecordsSamples.push(
+ Number(hydrateResult.diagnostics?.nativeRecordsMs || 0),
+ );
+ if (hydrateResult.diagnostics?.nativeUsed === true) {
+ hydrateNativeUsedRuns += 1;
+ }
walFileWriteSamples.push(Number(opfsCommitResult.diagnostics?.walFileWriteMs || 0));
manifestFileWriteSamples.push(
Number(opfsCommitResult.diagnostics?.manifestFileWriteMs || 0),
@@ -321,6 +347,11 @@ async function runPreset(preset) {
hydrateStateMs: summarize(hydrateStateSamples),
hydrateNormalizeMs: summarize(hydrateNormalizeSamples),
hydrateIntegrityMs: summarize(hydrateIntegritySamples),
+ hydrateNativeRecordsMs: summarize(hydrateNativeRecordsSamples),
+ hydrateNativeUsedRuns,
+ nativeHydrateRequested: useNativeHydrate,
+ nativeHydrateThresholdRecords:
+ nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
hydrateRuntimeMetaMs: summarize(hydrateRuntimeMetaSamples),
opfsCommitMs: summarize(opfsCommitSamples),
opfsWalFileWriteMs: summarize(walFileWriteSamples),
@@ -338,6 +369,8 @@ async function runPreset(preset) {
`edgesP95=${result.hydrateEdgesMs.p95.toFixed(2)}ms`,
`normalizeP95=${result.hydrateNormalizeMs.p95.toFixed(2)}ms`,
`integrityP95=${result.hydrateIntegrityMs.p95.toFixed(2)}ms`,
+ `nativeRecordsP95=${result.hydrateNativeRecordsMs.p95.toFixed(2)}ms`,
+ `nativeUsed=${result.hydrateNativeUsedRuns}/${RUNS}`,
`runtimeMetaP95=${result.hydrateRuntimeMetaMs.p95.toFixed(2)}ms`,
);
console.log(
@@ -350,15 +383,36 @@ async function runPreset(preset) {
}
async function main() {
- const results = {};
- for (const preset of SIZE_PRESETS) {
- results[preset.label] = await runPreset(preset);
+ if (useNativeHydrate) {
+ try {
+ const nativeModule = await import("../../vendor/wasm/stbme_core.js");
+ const nativeStatus = await nativeModule?.installNativeHydrateHook?.();
+ nativeHydratePreloadStatus = nativeStatus?.loaded ? "loaded" : "not-loaded";
+ nativeHydratePreloadError = String(nativeStatus?.error || "");
+ } catch (error) {
+ nativeHydratePreloadStatus = "failed";
+ nativeHydratePreloadError = error?.message || String(error);
+ console.warn(
+ "[ST-BME][persist-load-bench] native hydrate preload failed, fallback to JS hydrate:",
+ error,
+ );
+ }
}
+ const presets = {};
+ for (const preset of SIZE_PRESETS) {
+ presets[preset.label] = await runPreset(preset);
+ }
+ const payload = {
+ runs: RUNS,
+ nativeHydrateRequested: useNativeHydrate,
+ nativeHydratePreloadStatus,
+ nativeHydratePreloadError,
+ nativeHydrateThresholdRecords:
+ nativeHydrateThresholdRecords == null ? null : nativeHydrateThresholdRecords,
+ presets,
+ };
if (outputJson) {
- console.log(JSON.stringify({
- runs: RUNS,
- presets: results,
- }));
+ console.log(JSON.stringify(payload));
}
}
diff --git a/vendor/wasm/stbme_core.js b/vendor/wasm/stbme_core.js
index a1cf6b3..ce5b5b6 100644
--- a/vendor/wasm/stbme_core.js
+++ b/vendor/wasm/stbme_core.js
@@ -69,6 +69,10 @@ async function loadFromWasmPackArtifacts() {
return {
solve_layout: module.solve_layout,
+ build_hydrate_records:
+ typeof module.build_hydrate_records === "function"
+ ? module.build_hydrate_records
+ : null,
build_persist_delta_compact_hash:
typeof module.build_persist_delta_compact_hash === "function"
? module.build_persist_delta_compact_hash
@@ -222,6 +226,26 @@ export async function installNativePersistDeltaHook() {
return getNativeModuleStatus();
}
+export async function installNativeHydrateHook() {
+ const module = await loadNativeModule({
+ forceRetry: shouldRetryNativeLoad(),
+ });
+ if (!module || typeof module.build_hydrate_records !== "function") {
+ throw new Error("native hydrate builder unavailable");
+ }
+
+ globalThis.__stBmeNativeHydrateSnapshotRecords = (snapshotView = {}, options = {}) => {
+ const raw = module.build_hydrate_records({
+ nodes: Array.isArray(snapshotView?.nodes) ? snapshotView.nodes : [],
+ edges: Array.isArray(snapshotView?.edges) ? snapshotView.edges : [],
+ recordsNormalized: options?.recordsNormalized === true,
+ });
+ return raw && typeof raw === "object" ? raw : null;
+ };
+
+ return getNativeModuleStatus();
+}
+
export function getNativeModuleStatus() {
return {
loaded: Boolean(cachedNativeModule),
From b28a297b92b7e9e316f171146568838752aab6d8 Mon Sep 17 00:00:00 2001
From: "github-actions[bot]"
<41898282+github-actions[bot]@users.noreply.github.com>
Date: Wed, 22 Apr 2026 12:10:04 +0000
Subject: [PATCH 19/74] chore: bump manifest version [skip ci]
---
manifest.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/manifest.json b/manifest.json
index 702d495..027402c 100644
--- a/manifest.json
+++ b/manifest.json
@@ -6,6 +6,6 @@
"js": "index.js",
"css": "style.css",
"author": "Youzini",
- "version": "5.5.3",
+ "version": "5.5.4",
"homePage": "https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology"
}
From ba33054124bd360361197befab2d9b3e1bf2d712 Mon Sep 17 00:00:00 2001
From: Youzini-afk <13153778771cx@gmail.com>
Date: Wed, 22 Apr 2026 20:22:06 +0800
Subject: [PATCH 20/74] perf: ship prebuilt wasm artifacts by default
---
runtime/settings-defaults.js | 6 +-
scripts/build-native-wasm.mjs | 14 +-
tests/default-settings.mjs | 6 +-
tests/graph-persistence.mjs | 3 +
ui/graph-native-bridge.js | 2 +-
vendor/wasm/pkg/.gitignore | 7 +
vendor/wasm/pkg/package.json | 15 +
vendor/wasm/pkg/stbme_core_pkg.d.ts | 52 ++
vendor/wasm/pkg/stbme_core_pkg.js | 576 ++++++++++++++++++++
vendor/wasm/pkg/stbme_core_pkg_bg.wasm | Bin 0 -> 277466 bytes
vendor/wasm/pkg/stbme_core_pkg_bg.wasm.d.ts | 15 +
11 files changed, 688 insertions(+), 8 deletions(-)
create mode 100644 vendor/wasm/pkg/.gitignore
create mode 100644 vendor/wasm/pkg/package.json
create mode 100644 vendor/wasm/pkg/stbme_core_pkg.d.ts
create mode 100644 vendor/wasm/pkg/stbme_core_pkg.js
create mode 100644 vendor/wasm/pkg/stbme_core_pkg_bg.wasm
create mode 100644 vendor/wasm/pkg/stbme_core_pkg_bg.wasm.d.ts
diff --git a/runtime/settings-defaults.js b/runtime/settings-defaults.js
index 222d8a4..497f95c 100644
--- a/runtime/settings-defaults.js
+++ b/runtime/settings-defaults.js
@@ -113,16 +113,16 @@ export const defaultSettings = {
embeddingAutoSuffix: true,
// Native 性能加速(灰度)
- graphUseNativeLayout: false,
+ graphUseNativeLayout: true,
graphNativeLayoutThresholdNodes: 280,
graphNativeLayoutThresholdEdges: 1600,
graphNativeLayoutWorkerTimeoutMs: 260,
- persistUseNativeDelta: false,
+ persistUseNativeDelta: true,
persistNativeDeltaThresholdRecords: 20000,
persistNativeDeltaThresholdStructuralDelta: 600,
persistNativeDeltaThresholdSerializedChars: 4000000,
persistNativeDeltaBridgeMode: "json",
- loadUseNativeHydrate: false,
+ loadUseNativeHydrate: true,
loadNativeHydrateThresholdRecords: 12000,
nativeEngineFailOpen: true,
graphNativeForceDisable: false,
diff --git a/scripts/build-native-wasm.mjs b/scripts/build-native-wasm.mjs
index 4eb2258..31be2ec 100644
--- a/scripts/build-native-wasm.mjs
+++ b/scripts/build-native-wasm.mjs
@@ -1,10 +1,21 @@
-import { mkdir } from "node:fs/promises";
+import { mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import { spawn } from "node:child_process";
const ROOT = process.cwd();
const CRATE_DIR = path.resolve(ROOT, "native", "stbme-core");
const OUT_DIR = path.resolve(ROOT, "vendor", "wasm", "pkg");
+const OUT_GITIGNORE = path.resolve(OUT_DIR, ".gitignore");
+const OUT_GITIGNORE_CONTENT = [
+ "*",
+ "!.gitignore",
+ "!package.json",
+ "!stbme_core_pkg.js",
+ "!stbme_core_pkg.d.ts",
+ "!stbme_core_pkg_bg.wasm",
+ "!stbme_core_pkg_bg.wasm.d.ts",
+ "",
+].join("\n");
function runCommand(command, args, cwd) {
return new Promise((resolve, reject) => {
@@ -47,6 +58,7 @@ async function main() {
],
CRATE_DIR,
);
+ await writeFile(OUT_GITIGNORE, OUT_GITIGNORE_CONTENT, "utf8");
console.log("[ST-BME][native] wasm artifact build completed");
}
diff --git a/tests/default-settings.mjs b/tests/default-settings.mjs
index 00127a9..bde079c 100644
--- a/tests/default-settings.mjs
+++ b/tests/default-settings.mjs
@@ -67,16 +67,16 @@ assert.equal(defaultSettings.worldInfoFilterMode, "default");
assert.equal(defaultSettings.worldInfoFilterCustomKeywords, "");
assert.equal("maintenanceAutoMinNewNodes" in defaultSettings, false);
assert.equal(defaultSettings.embeddingTransportMode, "direct");
-assert.equal(defaultSettings.graphUseNativeLayout, false);
+assert.equal(defaultSettings.graphUseNativeLayout, true);
assert.equal(defaultSettings.graphNativeLayoutThresholdNodes, 280);
assert.equal(defaultSettings.graphNativeLayoutThresholdEdges, 1600);
assert.equal(defaultSettings.graphNativeLayoutWorkerTimeoutMs, 260);
-assert.equal(defaultSettings.persistUseNativeDelta, false);
+assert.equal(defaultSettings.persistUseNativeDelta, true);
assert.equal(defaultSettings.persistNativeDeltaThresholdRecords, 20000);
assert.equal(defaultSettings.persistNativeDeltaThresholdStructuralDelta, 600);
assert.equal(defaultSettings.persistNativeDeltaThresholdSerializedChars, 4000000);
assert.equal(defaultSettings.persistNativeDeltaBridgeMode, "json");
-assert.equal(defaultSettings.loadUseNativeHydrate, false);
+assert.equal(defaultSettings.loadUseNativeHydrate, true);
assert.equal(defaultSettings.loadNativeHydrateThresholdRecords, 12000);
assert.equal(defaultSettings.nativeEngineFailOpen, true);
assert.equal(defaultSettings.graphNativeForceDisable, false);
diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs
index 26301e6..489ef8b 100644
--- a/tests/graph-persistence.mjs
+++ b/tests/graph-persistence.mjs
@@ -3156,6 +3156,9 @@ result = {
lastPersistedRevision: 0,
writesBlocked: false,
});
+ harness.runtimeContext.extension_settings[MODULE_NAME] = {
+ persistUseNativeDelta: false,
+ };
harness.runtimeContext.__scheduleUploadShouldThrow = true;
const result = await harness.api.saveGraphToIndexedDb(
diff --git a/ui/graph-native-bridge.js b/ui/graph-native-bridge.js
index ccb4bdd..73a5de2 100644
--- a/ui/graph-native-bridge.js
+++ b/ui/graph-native-bridge.js
@@ -1,5 +1,5 @@
const DEFAULT_NATIVE_RUNTIME_OPTIONS = Object.freeze({
- graphUseNativeLayout: false,
+ graphUseNativeLayout: true,
graphNativeLayoutThresholdNodes: 280,
graphNativeLayoutThresholdEdges: 1600,
graphNativeLayoutWorkerTimeoutMs: 260,
diff --git a/vendor/wasm/pkg/.gitignore b/vendor/wasm/pkg/.gitignore
new file mode 100644
index 0000000..592237b
--- /dev/null
+++ b/vendor/wasm/pkg/.gitignore
@@ -0,0 +1,7 @@
+*
+!.gitignore
+!package.json
+!stbme_core_pkg.js
+!stbme_core_pkg.d.ts
+!stbme_core_pkg_bg.wasm
+!stbme_core_pkg_bg.wasm.d.ts
diff --git a/vendor/wasm/pkg/package.json b/vendor/wasm/pkg/package.json
new file mode 100644
index 0000000..88c9b25
--- /dev/null
+++ b/vendor/wasm/pkg/package.json
@@ -0,0 +1,15 @@
+{
+ "name": "stbme-core",
+ "type": "module",
+ "version": "0.1.0",
+ "files": [
+ "stbme_core_pkg_bg.wasm",
+ "stbme_core_pkg.js",
+ "stbme_core_pkg.d.ts"
+ ],
+ "main": "stbme_core_pkg.js",
+ "types": "stbme_core_pkg.d.ts",
+ "sideEffects": [
+ "./snippets/*"
+ ]
+}
\ No newline at end of file
diff --git a/vendor/wasm/pkg/stbme_core_pkg.d.ts b/vendor/wasm/pkg/stbme_core_pkg.d.ts
new file mode 100644
index 0000000..0458b2b
--- /dev/null
+++ b/vendor/wasm/pkg/stbme_core_pkg.d.ts
@@ -0,0 +1,52 @@
+/* tslint:disable */
+/* eslint-disable */
+
+export function build_hydrate_records(payload: any): any;
+
+export function build_persist_delta(payload: any): any;
+
+export function build_persist_delta_compact(payload: any): any;
+
+export function build_persist_delta_compact_hash(payload: any): any;
+
+export function solve_layout(payload: any): any;
+
+export type InitInput = RequestInfo | URL | Response | BufferSource | WebAssembly.Module;
+
+export interface InitOutput {
+ readonly memory: WebAssembly.Memory;
+ readonly build_hydrate_records: (a: any) => [number, number, number];
+ readonly build_persist_delta: (a: any) => [number, number, number];
+ readonly build_persist_delta_compact: (a: any) => [number, number, number];
+ readonly build_persist_delta_compact_hash: (a: any) => [number, number, number];
+ readonly solve_layout: (a: any) => [number, number, number];
+ readonly __wbindgen_malloc: (a: number, b: number) => number;
+ readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number;
+ readonly __wbindgen_exn_store: (a: number) => void;
+ readonly __externref_table_alloc: () => number;
+ readonly __wbindgen_externrefs: WebAssembly.Table;
+ readonly __externref_table_dealloc: (a: number) => void;
+ readonly __wbindgen_start: () => void;
+}
+
+export type SyncInitInput = BufferSource | WebAssembly.Module;
+
+/**
+ * Instantiates the given `module`, which can either be bytes or
+ * a precompiled `WebAssembly.Module`.
+ *
+ * @param {{ module: SyncInitInput }} module - Passing `SyncInitInput` directly is deprecated.
+ *
+ * @returns {InitOutput}
+ */
+export function initSync(module: { module: SyncInitInput } | SyncInitInput): InitOutput;
+
+/**
+ * If `module_or_path` is {RequestInfo} or {URL}, makes a request and
+ * for everything else, calls `WebAssembly.instantiate` directly.
+ *
+ * @param {{ module_or_path: InitInput | Promise }} module_or_path - Passing `InitInput` directly is deprecated.
+ *
+ * @returns {Promise}
+ */
+export default function __wbg_init (module_or_path?: { module_or_path: InitInput | Promise } | InitInput | Promise): Promise;
diff --git a/vendor/wasm/pkg/stbme_core_pkg.js b/vendor/wasm/pkg/stbme_core_pkg.js
new file mode 100644
index 0000000..7104033
--- /dev/null
+++ b/vendor/wasm/pkg/stbme_core_pkg.js
@@ -0,0 +1,576 @@
+/* @ts-self-types="./stbme_core_pkg.d.ts" */
+
+/**
+ * @param {any} payload
+ * @returns {any}
+ */
+export function build_hydrate_records(payload) {
+ const ret = wasm.build_hydrate_records(payload);
+ if (ret[2]) {
+ throw takeFromExternrefTable0(ret[1]);
+ }
+ return takeFromExternrefTable0(ret[0]);
+}
+
+/**
+ * @param {any} payload
+ * @returns {any}
+ */
+export function build_persist_delta(payload) {
+ const ret = wasm.build_persist_delta(payload);
+ if (ret[2]) {
+ throw takeFromExternrefTable0(ret[1]);
+ }
+ return takeFromExternrefTable0(ret[0]);
+}
+
+/**
+ * @param {any} payload
+ * @returns {any}
+ */
+export function build_persist_delta_compact(payload) {
+ const ret = wasm.build_persist_delta_compact(payload);
+ if (ret[2]) {
+ throw takeFromExternrefTable0(ret[1]);
+ }
+ return takeFromExternrefTable0(ret[0]);
+}
+
+/**
+ * @param {any} payload
+ * @returns {any}
+ */
+export function build_persist_delta_compact_hash(payload) {
+ const ret = wasm.build_persist_delta_compact_hash(payload);
+ if (ret[2]) {
+ throw takeFromExternrefTable0(ret[1]);
+ }
+ return takeFromExternrefTable0(ret[0]);
+}
+
+/**
+ * @param {any} payload
+ * @returns {any}
+ */
+export function solve_layout(payload) {
+ const ret = wasm.solve_layout(payload);
+ if (ret[2]) {
+ throw takeFromExternrefTable0(ret[1]);
+ }
+ return takeFromExternrefTable0(ret[0]);
+}
+function __wbg_get_imports() {
+ const import0 = {
+ __proto__: null,
+ __wbg_Error_960c155d3d49e4c2: function(arg0, arg1) {
+ const ret = Error(getStringFromWasm0(arg0, arg1));
+ return ret;
+ },
+ __wbg_Number_32bf70a599af1d4b: function(arg0) {
+ const ret = Number(arg0);
+ return ret;
+ },
+ __wbg_String_8564e559799eccda: function(arg0, arg1) {
+ const ret = String(arg1);
+ const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len1 = WASM_VECTOR_LEN;
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
+ },
+ __wbg___wbindgen_bigint_get_as_i64_3d3aba5d616c6a51: function(arg0, arg1) {
+ const v = arg1;
+ const ret = typeof(v) === 'bigint' ? v : undefined;
+ getDataViewMemory0().setBigInt64(arg0 + 8 * 1, isLikeNone(ret) ? BigInt(0) : ret, true);
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
+ },
+ __wbg___wbindgen_boolean_get_6ea149f0a8dcc5ff: function(arg0) {
+ const v = arg0;
+ const ret = typeof(v) === 'boolean' ? v : undefined;
+ return isLikeNone(ret) ? 0xFFFFFF : ret ? 1 : 0;
+ },
+ __wbg___wbindgen_debug_string_ab4b34d23d6778bd: function(arg0, arg1) {
+ const ret = debugString(arg1);
+ const ptr1 = passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ const len1 = WASM_VECTOR_LEN;
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
+ },
+ __wbg___wbindgen_in_a5d8b22e52b24dd1: function(arg0, arg1) {
+ const ret = arg0 in arg1;
+ return ret;
+ },
+ __wbg___wbindgen_is_bigint_ec25c7f91b4d9e93: function(arg0) {
+ const ret = typeof(arg0) === 'bigint';
+ return ret;
+ },
+ __wbg___wbindgen_is_function_3baa9db1a987f47d: function(arg0) {
+ const ret = typeof(arg0) === 'function';
+ return ret;
+ },
+ __wbg___wbindgen_is_object_63322ec0cd6ea4ef: function(arg0) {
+ const val = arg0;
+ const ret = typeof(val) === 'object' && val !== null;
+ return ret;
+ },
+ __wbg___wbindgen_is_string_6df3bf7ef1164ed3: function(arg0) {
+ const ret = typeof(arg0) === 'string';
+ return ret;
+ },
+ __wbg___wbindgen_is_undefined_29a43b4d42920abd: function(arg0) {
+ const ret = arg0 === undefined;
+ return ret;
+ },
+ __wbg___wbindgen_jsval_eq_d3465d8a07697228: function(arg0, arg1) {
+ const ret = arg0 === arg1;
+ return ret;
+ },
+ __wbg___wbindgen_jsval_loose_eq_cac3565e89b4134c: function(arg0, arg1) {
+ const ret = arg0 == arg1;
+ return ret;
+ },
+ __wbg___wbindgen_number_get_c7f42aed0525c451: function(arg0, arg1) {
+ const obj = arg1;
+ const ret = typeof(obj) === 'number' ? obj : undefined;
+ getDataViewMemory0().setFloat64(arg0 + 8 * 1, isLikeNone(ret) ? 0 : ret, true);
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, !isLikeNone(ret), true);
+ },
+ __wbg___wbindgen_string_get_7ed5322991caaec5: function(arg0, arg1) {
+ const obj = arg1;
+ const ret = typeof(obj) === 'string' ? obj : undefined;
+ var ptr1 = isLikeNone(ret) ? 0 : passStringToWasm0(ret, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
+ var len1 = WASM_VECTOR_LEN;
+ getDataViewMemory0().setInt32(arg0 + 4 * 1, len1, true);
+ getDataViewMemory0().setInt32(arg0 + 4 * 0, ptr1, true);
+ },
+ __wbg___wbindgen_throw_6b64449b9b9ed33c: function(arg0, arg1) {
+ throw new Error(getStringFromWasm0(arg0, arg1));
+ },
+ __wbg_call_14b169f759b26747: function() { return handleError(function (arg0, arg1) {
+ const ret = arg0.call(arg1);
+ return ret;
+ }, arguments); },
+ __wbg_done_9158f7cc8751ba32: function(arg0) {
+ const ret = arg0.done;
+ return ret;
+ },
+ __wbg_entries_e0b73aa8571ddb56: function(arg0) {
+ const ret = Object.entries(arg0);
+ return ret;
+ },
+ __wbg_get_1affdbdd5573b16a: function() { return handleError(function (arg0, arg1) {
+ const ret = Reflect.get(arg0, arg1);
+ return ret;
+ }, arguments); },
+ __wbg_get_8360291721e2339f: function(arg0, arg1) {
+ const ret = arg0[arg1 >>> 0];
+ return ret;
+ },
+ __wbg_get_unchecked_17f53dad852b9588: function(arg0, arg1) {
+ const ret = arg0[arg1 >>> 0];
+ return ret;
+ },
+ __wbg_get_with_ref_key_6412cf3094599694: function(arg0, arg1) {
+ const ret = arg0[arg1];
+ return ret;
+ },
+ __wbg_instanceof_ArrayBuffer_7c8433c6ed14ffe3: function(arg0) {
+ let result;
+ try {
+ result = arg0 instanceof ArrayBuffer;
+ } catch (_) {
+ result = false;
+ }
+ const ret = result;
+ return ret;
+ },
+ __wbg_instanceof_Map_1b76fd4635be43eb: function(arg0) {
+ let result;
+ try {
+ result = arg0 instanceof Map;
+ } catch (_) {
+ result = false;
+ }
+ const ret = result;
+ return ret;
+ },
+ __wbg_instanceof_Uint8Array_152ba1f289edcf3f: function(arg0) {
+ let result;
+ try {
+ result = arg0 instanceof Uint8Array;
+ } catch (_) {
+ result = false;
+ }
+ const ret = result;
+ return ret;
+ },
+ __wbg_isArray_c3109d14ffc06469: function(arg0) {
+ const ret = Array.isArray(arg0);
+ return ret;
+ },
+ __wbg_isSafeInteger_4fc213d1989d6d2a: function(arg0) {
+ const ret = Number.isSafeInteger(arg0);
+ return ret;
+ },
+ __wbg_iterator_013bc09ec998c2a7: function() {
+ const ret = Symbol.iterator;
+ return ret;
+ },
+ __wbg_length_3d4ecd04bd8d22f1: function(arg0) {
+ const ret = arg0.length;
+ return ret;
+ },
+ __wbg_length_9f1775224cf1d815: function(arg0) {
+ const ret = arg0.length;
+ return ret;
+ },
+ __wbg_new_0c7403db6e782f19: function(arg0) {
+ const ret = new Uint8Array(arg0);
+ return ret;
+ },
+ __wbg_new_34d45cc8e36aaead: function() {
+ const ret = new Map();
+ return ret;
+ },
+ __wbg_new_682678e2f47e32bc: function() {
+ const ret = new Array();
+ return ret;
+ },
+ __wbg_new_aa8d0fa9762c29bd: function() {
+ const ret = new Object();
+ return ret;
+ },
+ __wbg_next_0340c4ae324393c3: function() { return handleError(function (arg0) {
+ const ret = arg0.next();
+ return ret;
+ }, arguments); },
+ __wbg_next_7646edaa39458ef7: function(arg0) {
+ const ret = arg0.next;
+ return ret;
+ },
+ __wbg_prototypesetcall_a6b02eb00b0f4ce2: function(arg0, arg1, arg2) {
+ Uint8Array.prototype.set.call(getArrayU8FromWasm0(arg0, arg1), arg2);
+ },
+ __wbg_set_3bf1de9fab0cd644: function(arg0, arg1, arg2) {
+ arg0[arg1 >>> 0] = arg2;
+ },
+ __wbg_set_6be42768c690e380: function(arg0, arg1, arg2) {
+ arg0[arg1] = arg2;
+ },
+ __wbg_set_fde2cec06c23692b: function(arg0, arg1, arg2) {
+ const ret = arg0.set(arg1, arg2);
+ return ret;
+ },
+ __wbg_value_ee3a06f4579184fa: function(arg0) {
+ const ret = arg0.value;
+ return ret;
+ },
+ __wbindgen_cast_0000000000000001: function(arg0) {
+ // Cast intrinsic for `F64 -> Externref`.
+ const ret = arg0;
+ return ret;
+ },
+ __wbindgen_cast_0000000000000002: function(arg0) {
+ // Cast intrinsic for `I64 -> Externref`.
+ const ret = arg0;
+ return ret;
+ },
+ __wbindgen_cast_0000000000000003: function(arg0, arg1) {
+ // Cast intrinsic for `Ref(String) -> Externref`.
+ const ret = getStringFromWasm0(arg0, arg1);
+ return ret;
+ },
+ __wbindgen_cast_0000000000000004: function(arg0) {
+ // Cast intrinsic for `U64 -> Externref`.
+ const ret = BigInt.asUintN(64, arg0);
+ return ret;
+ },
+ __wbindgen_init_externref_table: function() {
+ const table = wasm.__wbindgen_externrefs;
+ const offset = table.grow(4);
+ table.set(0, undefined);
+ table.set(offset + 0, undefined);
+ table.set(offset + 1, null);
+ table.set(offset + 2, true);
+ table.set(offset + 3, false);
+ },
+ };
+ return {
+ __proto__: null,
+ "./stbme_core_pkg_bg.js": import0,
+ };
+}
+
+function addToExternrefTable0(obj) {
+ const idx = wasm.__externref_table_alloc();
+ wasm.__wbindgen_externrefs.set(idx, obj);
+ return idx;
+}
+
+function debugString(val) {
+ // primitive types
+ const type = typeof val;
+ if (type == 'number' || type == 'boolean' || val == null) {
+ return `${val}`;
+ }
+ if (type == 'string') {
+ return `"${val}"`;
+ }
+ if (type == 'symbol') {
+ const description = val.description;
+ if (description == null) {
+ return 'Symbol';
+ } else {
+ return `Symbol(${description})`;
+ }
+ }
+ if (type == 'function') {
+ const name = val.name;
+ if (typeof name == 'string' && name.length > 0) {
+ return `Function(${name})`;
+ } else {
+ return 'Function';
+ }
+ }
+ // objects
+ if (Array.isArray(val)) {
+ const length = val.length;
+ let debug = '[';
+ if (length > 0) {
+ debug += debugString(val[0]);
+ }
+ for(let i = 1; i < length; i++) {
+ debug += ', ' + debugString(val[i]);
+ }
+ debug += ']';
+ return debug;
+ }
+ // Test for built-in
+ const builtInMatches = /\[object ([^\]]+)\]/.exec(toString.call(val));
+ let className;
+ if (builtInMatches && builtInMatches.length > 1) {
+ className = builtInMatches[1];
+ } else {
+ // Failed to match the standard '[object ClassName]'
+ return toString.call(val);
+ }
+ if (className == 'Object') {
+ // we're a user defined class or Object
+ // JSON.stringify avoids problems with cycles, and is generally much
+ // easier than looping through ownProperties of `val`.
+ try {
+ return 'Object(' + JSON.stringify(val) + ')';
+ } catch (_) {
+ return 'Object';
+ }
+ }
+ // errors
+ if (val instanceof Error) {
+ return `${val.name}: ${val.message}\n${val.stack}`;
+ }
+ // TODO we could test for more things here, like `Set`s and `Map`s.
+ return className;
+}
+
+function getArrayU8FromWasm0(ptr, len) {
+ ptr = ptr >>> 0;
+ return getUint8ArrayMemory0().subarray(ptr / 1, ptr / 1 + len);
+}
+
+let cachedDataViewMemory0 = null;
+function getDataViewMemory0() {
+ if (cachedDataViewMemory0 === null || cachedDataViewMemory0.buffer.detached === true || (cachedDataViewMemory0.buffer.detached === undefined && cachedDataViewMemory0.buffer !== wasm.memory.buffer)) {
+ cachedDataViewMemory0 = new DataView(wasm.memory.buffer);
+ }
+ return cachedDataViewMemory0;
+}
+
+function getStringFromWasm0(ptr, len) {
+ ptr = ptr >>> 0;
+ return decodeText(ptr, len);
+}
+
+let cachedUint8ArrayMemory0 = null;
+function getUint8ArrayMemory0() {
+ if (cachedUint8ArrayMemory0 === null || cachedUint8ArrayMemory0.byteLength === 0) {
+ cachedUint8ArrayMemory0 = new Uint8Array(wasm.memory.buffer);
+ }
+ return cachedUint8ArrayMemory0;
+}
+
+function handleError(f, args) {
+ try {
+ return f.apply(this, args);
+ } catch (e) {
+ const idx = addToExternrefTable0(e);
+ wasm.__wbindgen_exn_store(idx);
+ }
+}
+
+function isLikeNone(x) {
+ return x === undefined || x === null;
+}
+
+function passStringToWasm0(arg, malloc, realloc) {
+ if (realloc === undefined) {
+ const buf = cachedTextEncoder.encode(arg);
+ const ptr = malloc(buf.length, 1) >>> 0;
+ getUint8ArrayMemory0().subarray(ptr, ptr + buf.length).set(buf);
+ WASM_VECTOR_LEN = buf.length;
+ return ptr;
+ }
+
+ let len = arg.length;
+ let ptr = malloc(len, 1) >>> 0;
+
+ const mem = getUint8ArrayMemory0();
+
+ let offset = 0;
+
+ for (; offset < len; offset++) {
+ const code = arg.charCodeAt(offset);
+ if (code > 0x7F) break;
+ mem[ptr + offset] = code;
+ }
+ if (offset !== len) {
+ if (offset !== 0) {
+ arg = arg.slice(offset);
+ }
+ ptr = realloc(ptr, len, len = offset + arg.length * 3, 1) >>> 0;
+ const view = getUint8ArrayMemory0().subarray(ptr + offset, ptr + len);
+ const ret = cachedTextEncoder.encodeInto(arg, view);
+
+ offset += ret.written;
+ ptr = realloc(ptr, len, offset, 1) >>> 0;
+ }
+
+ WASM_VECTOR_LEN = offset;
+ return ptr;
+}
+
+function takeFromExternrefTable0(idx) {
+ const value = wasm.__wbindgen_externrefs.get(idx);
+ wasm.__externref_table_dealloc(idx);
+ return value;
+}
+
+let cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
+cachedTextDecoder.decode();
+const MAX_SAFARI_DECODE_BYTES = 2146435072;
+let numBytesDecoded = 0;
+function decodeText(ptr, len) {
+ numBytesDecoded += len;
+ if (numBytesDecoded >= MAX_SAFARI_DECODE_BYTES) {
+ cachedTextDecoder = new TextDecoder('utf-8', { ignoreBOM: true, fatal: true });
+ cachedTextDecoder.decode();
+ numBytesDecoded = len;
+ }
+ return cachedTextDecoder.decode(getUint8ArrayMemory0().subarray(ptr, ptr + len));
+}
+
+const cachedTextEncoder = new TextEncoder();
+
+if (!('encodeInto' in cachedTextEncoder)) {
+ cachedTextEncoder.encodeInto = function (arg, view) {
+ const buf = cachedTextEncoder.encode(arg);
+ view.set(buf);
+ return {
+ read: arg.length,
+ written: buf.length
+ };
+ };
+}
+
+let WASM_VECTOR_LEN = 0;
+
+let wasmModule, wasm;
+function __wbg_finalize_init(instance, module) {
+ wasm = instance.exports;
+ wasmModule = module;
+ cachedDataViewMemory0 = null;
+ cachedUint8ArrayMemory0 = null;
+ wasm.__wbindgen_start();
+ return wasm;
+}
+
+async function __wbg_load(module, imports) {
+ if (typeof Response === 'function' && module instanceof Response) {
+ if (typeof WebAssembly.instantiateStreaming === 'function') {
+ try {
+ return await WebAssembly.instantiateStreaming(module, imports);
+ } catch (e) {
+ const validResponse = module.ok && expectedResponseType(module.type);
+
+ if (validResponse && module.headers.get('Content-Type') !== 'application/wasm') {
+ console.warn("`WebAssembly.instantiateStreaming` failed because your server does not serve Wasm with `application/wasm` MIME type. Falling back to `WebAssembly.instantiate` which is slower. Original error:\n", e);
+
+ } else { throw e; }
+ }
+ }
+
+ const bytes = await module.arrayBuffer();
+ return await WebAssembly.instantiate(bytes, imports);
+ } else {
+ const instance = await WebAssembly.instantiate(module, imports);
+
+ if (instance instanceof WebAssembly.Instance) {
+ return { instance, module };
+ } else {
+ return instance;
+ }
+ }
+
+ function expectedResponseType(type) {
+ switch (type) {
+ case 'basic': case 'cors': case 'default': return true;
+ }
+ return false;
+ }
+}
+
+function initSync(module) {
+ if (wasm !== undefined) return wasm;
+
+
+ if (module !== undefined) {
+ if (Object.getPrototypeOf(module) === Object.prototype) {
+ ({module} = module)
+ } else {
+ console.warn('using deprecated parameters for `initSync()`; pass a single object instead')
+ }
+ }
+
+ const imports = __wbg_get_imports();
+ if (!(module instanceof WebAssembly.Module)) {
+ module = new WebAssembly.Module(module);
+ }
+ const instance = new WebAssembly.Instance(module, imports);
+ return __wbg_finalize_init(instance, module);
+}
+
+async function __wbg_init(module_or_path) {
+ if (wasm !== undefined) return wasm;
+
+
+ if (module_or_path !== undefined) {
+ if (Object.getPrototypeOf(module_or_path) === Object.prototype) {
+ ({module_or_path} = module_or_path)
+ } else {
+ console.warn('using deprecated parameters for the initialization function; pass a single object instead')
+ }
+ }
+
+ if (module_or_path === undefined) {
+ module_or_path = new URL('stbme_core_pkg_bg.wasm', import.meta.url);
+ }
+ const imports = __wbg_get_imports();
+
+ if (typeof module_or_path === 'string' || (typeof Request === 'function' && module_or_path instanceof Request) || (typeof URL === 'function' && module_or_path instanceof URL)) {
+ module_or_path = fetch(module_or_path);
+ }
+
+ const { instance, module } = await __wbg_load(await module_or_path, imports);
+
+ return __wbg_finalize_init(instance, module);
+}
+
+export { initSync, __wbg_init as default };
diff --git a/vendor/wasm/pkg/stbme_core_pkg_bg.wasm b/vendor/wasm/pkg/stbme_core_pkg_bg.wasm
new file mode 100644
index 0000000000000000000000000000000000000000..20d67c84dd66e501b7be1f694d70e6ce4ca2b972
GIT binary patch
literal 277466
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