mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
fix: deep repair p0-p1 persistence runtime merge and integrity
This commit is contained in:
@@ -455,6 +455,7 @@ result = {
|
||||
loadGraphFromChat,
|
||||
saveGraphToChat,
|
||||
syncGraphLoadFromLiveContext,
|
||||
buildBmeSyncRuntimeOptions,
|
||||
onMessageReceived,
|
||||
applyGraphLoadState,
|
||||
maybeFlushQueuedGraphPersist,
|
||||
@@ -541,11 +542,17 @@ result = {
|
||||
source: "global-chat-id",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(result.reason, "global-chat-id:metadata-compat-provisional");
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().historyState.chatId,
|
||||
"chat-global",
|
||||
);
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, false);
|
||||
assert.equal(
|
||||
harness.api.getGraphPersistenceLiveState().writesBlocked,
|
||||
true,
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -839,6 +846,42 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-sync-refresh",
|
||||
chatMetadata: {
|
||||
integrity: "chat-sync-refresh-ready",
|
||||
},
|
||||
});
|
||||
harness.api.setCurrentGraph(
|
||||
normalizeGraphRuntimeState(createMeaningfulGraph("chat-sync-refresh", "stale-runtime"), "chat-sync-refresh"),
|
||||
);
|
||||
harness.api.setGraphPersistenceState({
|
||||
loadState: "loaded",
|
||||
chatId: "chat-sync-refresh",
|
||||
reason: "runtime-stale",
|
||||
revision: 2,
|
||||
lastPersistedRevision: 2,
|
||||
dbReady: true,
|
||||
writesBlocked: false,
|
||||
});
|
||||
harness.api.setIndexedDbSnapshot(
|
||||
buildSnapshotFromGraph(createMeaningfulGraph("chat-sync-refresh", "fresh-indexeddb"), {
|
||||
chatId: "chat-sync-refresh",
|
||||
revision: 7,
|
||||
}),
|
||||
);
|
||||
|
||||
const runtimeOptions = harness.api.buildBmeSyncRuntimeOptions();
|
||||
await runtimeOptions.onSyncApplied({ chatId: "chat-sync-refresh", action: "download" });
|
||||
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-fresh-indexeddb",
|
||||
"download/merge 后应刷新当前运行时图谱",
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const sharedSession = new Map();
|
||||
const writer = await createGraphPersistenceHarness({
|
||||
@@ -901,7 +944,7 @@ result = {
|
||||
source: "official-load",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(
|
||||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-official",
|
||||
@@ -943,8 +986,8 @@ result = {
|
||||
source: "official-older-than-shadow",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.reason, "official-older-than-shadow:metadata-compat");
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(result.reason, "official-older-than-shadow:metadata-compat-provisional");
|
||||
assert.equal(
|
||||
reader.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-official-older",
|
||||
@@ -1166,7 +1209,7 @@ result = {
|
||||
source: "load-official-decoupled",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.loadState, "loading");
|
||||
const runtimeGraph = harness.api.getCurrentGraph();
|
||||
const persistedGraph =
|
||||
harness.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||||
@@ -1233,7 +1276,7 @@ result = {
|
||||
source: "load-shadow-decoupled",
|
||||
});
|
||||
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.loadState, "loading");
|
||||
const runtimeGraph = reader.api.getCurrentGraph();
|
||||
const persistedGraph =
|
||||
reader.runtimeContext.__chatContext.chatMetadata.st_bme_graph;
|
||||
|
||||
@@ -19,6 +19,7 @@ const chatIdsForCleanup = new Set([
|
||||
"chat-b",
|
||||
"chat-manager-a",
|
||||
"chat-manager-b",
|
||||
"chat-replace-reset",
|
||||
]);
|
||||
|
||||
async function setupIndexedDbTestEnv() {
|
||||
@@ -194,6 +195,81 @@ async function testSnapshotExportImport() {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testReplaceImportResetsStaleMeta() {
|
||||
const chatId = "chat-replace-reset";
|
||||
const db = new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
await db.patchMeta({
|
||||
runtimeHistoryState: {
|
||||
chatId,
|
||||
lastProcessedAssistantFloor: 99,
|
||||
processedMessageHashes: {
|
||||
99: "stale-hash",
|
||||
},
|
||||
},
|
||||
runtimeVectorIndexState: {
|
||||
hashToNodeId: {
|
||||
"stale-hash": "node-stale",
|
||||
},
|
||||
nodeToHash: {
|
||||
"node-stale": "stale-hash",
|
||||
},
|
||||
dirty: true,
|
||||
pendingRepairFromFloor: 88,
|
||||
},
|
||||
runtimeBatchJournal: [{ id: "stale-journal", processedRange: [90, 99] }],
|
||||
runtimeLastRecallResult: { updatedAt: 123456, nodes: ["node-stale"] },
|
||||
runtimeLastProcessedSeq: 999,
|
||||
runtimeGraphVersion: 999,
|
||||
migrationCompletedAt: 987654321,
|
||||
legacyRetentionUntil: 987654321,
|
||||
customLeakField: "stale-value",
|
||||
});
|
||||
|
||||
const revisionBefore = await db.getRevision();
|
||||
|
||||
const importResult = await db.importSnapshot(
|
||||
{
|
||||
meta: {
|
||||
chatId,
|
||||
revision: 1,
|
||||
deviceId: "device-replace-new",
|
||||
},
|
||||
state: {
|
||||
lastProcessedFloor: 3,
|
||||
extractionCount: 2,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
},
|
||||
{
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
markSyncDirty: false,
|
||||
},
|
||||
);
|
||||
|
||||
assert.ok(importResult.revision > revisionBefore, "replace 导入后 revision 必须单调递增");
|
||||
assert.equal(await db.getMeta("chatId", ""), chatId);
|
||||
assert.equal(await db.getMeta("lastProcessedFloor", -1), 3);
|
||||
assert.equal(await db.getMeta("extractionCount", 0), 2);
|
||||
assert.equal(await db.getMeta("deviceId", ""), "device-replace-new");
|
||||
assert.equal(await db.getMeta("migrationCompletedAt", -1), 0);
|
||||
assert.equal(await db.getMeta("legacyRetentionUntil", -1), 0);
|
||||
assert.equal(await db.getMeta("runtimeHistoryState", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("runtimeVectorIndexState", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("runtimeBatchJournal", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("runtimeLastRecallResult", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("runtimeLastProcessedSeq", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("runtimeGraphVersion", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("customLeakField", "__missing__"), "__missing__");
|
||||
assert.equal(await db.getMeta("syncDirty", true), false);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testRevisionMonotonicity() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
@@ -395,6 +471,7 @@ async function main() {
|
||||
await testCrudAndMeta();
|
||||
await testTransactionRollback();
|
||||
await testSnapshotExportImport();
|
||||
await testReplaceImportResetsStaleMeta();
|
||||
await testRevisionMonotonicity();
|
||||
await testTombstonePrune();
|
||||
await testChatIsolationAndManager();
|
||||
|
||||
@@ -371,6 +371,128 @@ async function testMergeRules() {
|
||||
assert.equal(merged.state.extractionCount, 3);
|
||||
}
|
||||
|
||||
async function testMergeRuntimeMetaPolicies() {
|
||||
const local = {
|
||||
meta: {
|
||||
chatId: "chat-merge-meta",
|
||||
revision: 7,
|
||||
lastModified: 200,
|
||||
deviceId: "local-device",
|
||||
schemaVersion: 1,
|
||||
runtimeHistoryState: {
|
||||
chatId: "chat-merge-meta",
|
||||
lastProcessedAssistantFloor: 6,
|
||||
extractionCount: 6,
|
||||
processedMessageHashes: {
|
||||
1: "h1",
|
||||
2: "h2",
|
||||
3: "h3",
|
||||
4: "local-h4",
|
||||
6: "h6",
|
||||
},
|
||||
},
|
||||
runtimeVectorIndexState: {
|
||||
hashToNodeId: {
|
||||
"hash-local-a": "node-a",
|
||||
"hash-shared-b": "node-b",
|
||||
},
|
||||
nodeToHash: {
|
||||
"node-a": "hash-local-a",
|
||||
"node-b": "hash-shared-b",
|
||||
},
|
||||
},
|
||||
runtimeBatchJournal: [
|
||||
{ id: "journal-shared", processedRange: [0, 2], createdAt: 100 },
|
||||
{ id: "journal-drop-local", processedRange: [4, 5], createdAt: 110 },
|
||||
],
|
||||
runtimeLastRecallResult: { nodes: ["local-only"] },
|
||||
runtimeLastProcessedSeq: 2,
|
||||
runtimeGraphVersion: 10,
|
||||
},
|
||||
nodes: [
|
||||
{ id: "node-a", updatedAt: 100 },
|
||||
{ id: "node-b", updatedAt: 100 },
|
||||
],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 6,
|
||||
extractionCount: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const remote = {
|
||||
meta: {
|
||||
chatId: "chat-merge-meta",
|
||||
revision: 10,
|
||||
lastModified: 200,
|
||||
deviceId: "remote-device",
|
||||
schemaVersion: 1,
|
||||
runtimeHistoryState: {
|
||||
chatId: "chat-merge-meta",
|
||||
lastProcessedAssistantFloor: 5,
|
||||
extractionCount: 7,
|
||||
processedMessageHashes: {
|
||||
1: "h1",
|
||||
2: "h2",
|
||||
3: "h3",
|
||||
4: "remote-h4",
|
||||
5: "h5",
|
||||
},
|
||||
},
|
||||
runtimeVectorIndexState: {
|
||||
hashToNodeId: {
|
||||
"hash-remote-a": "node-a",
|
||||
"hash-shared-b": "node-b",
|
||||
},
|
||||
nodeToHash: {
|
||||
"node-a": "hash-remote-a",
|
||||
"node-b": "hash-shared-b",
|
||||
},
|
||||
},
|
||||
runtimeBatchJournal: [
|
||||
{ id: "journal-shared", processedRange: [0, 3], createdAt: 210 },
|
||||
{ id: "journal-drop-remote", processedRange: [3, 4], createdAt: 220 },
|
||||
],
|
||||
runtimeLastRecallResult: { nodes: ["remote-only"] },
|
||||
runtimeLastProcessedSeq: 9,
|
||||
runtimeGraphVersion: 7,
|
||||
},
|
||||
nodes: [
|
||||
{ id: "node-a", updatedAt: 200 },
|
||||
{ id: "node-b", updatedAt: 200 },
|
||||
],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 5,
|
||||
extractionCount: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeSnapshots(local, remote, { chatId: "chat-merge-meta" });
|
||||
|
||||
assert.equal(merged.state.lastProcessedFloor, 3, "冲突哈希楼层应触发保守回退");
|
||||
assert.equal(merged.state.extractionCount, 7);
|
||||
assert.deepEqual(Object.keys(merged.meta.runtimeHistoryState.processedMessageHashes), ["1", "2", "3"]);
|
||||
assert.equal(merged.meta.runtimeHistoryState.historyDirtyFrom, 4);
|
||||
assert.ok(String(merged.meta.runtimeHistoryState.lastMutationReason).includes("processed-hash-conflict@4"));
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.nodeToHash["node-a"], undefined);
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.nodeToHash["node-b"], "hash-shared-b");
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.hashToNodeId["hash-local-a"], undefined);
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.hashToNodeId["hash-remote-a"], undefined);
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.hashToNodeId["hash-shared-b"], "node-b");
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.dirty, true);
|
||||
assert.ok(merged.meta.runtimeVectorIndexState.replayRequiredNodeIds.includes("node-a"));
|
||||
assert.equal(merged.meta.runtimeVectorIndexState.pendingRepairFromFloor, 3);
|
||||
assert.equal(merged.meta.runtimeBatchJournal.length, 1);
|
||||
assert.equal(merged.meta.runtimeBatchJournal[0].id, "journal-shared");
|
||||
assert.deepEqual(merged.meta.runtimeBatchJournal[0].processedRange, [0, 3]);
|
||||
assert.equal(merged.meta.runtimeLastRecallResult, null);
|
||||
assert.equal(merged.meta.runtimeLastProcessedSeq, 9);
|
||||
assert.equal(merged.meta.runtimeGraphVersion, 11);
|
||||
}
|
||||
|
||||
async function testSyncNowLockAndAutoSync() {
|
||||
const { fetch, remoteFiles, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
@@ -518,6 +640,81 @@ async function testSyncNowRemoteReadErrorPath() {
|
||||
assert.equal(result.reason, "http-error");
|
||||
}
|
||||
|
||||
async function testSyncAppliedHook() {
|
||||
const { fetch, remoteFiles } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
const hookCalls = [];
|
||||
|
||||
dbByChatId.set(
|
||||
"chat-hook-download",
|
||||
new FakeDb("chat-hook-download", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-hook-download",
|
||||
revision: 1,
|
||||
lastModified: 10,
|
||||
deviceId: "",
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
dbByChatId.set(
|
||||
"chat-hook-merge",
|
||||
new FakeDb("chat-hook-merge", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-hook-merge",
|
||||
revision: 4,
|
||||
lastModified: 20,
|
||||
deviceId: "",
|
||||
nodeCount: 1,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [{ id: "local-merge", updatedAt: 20 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: 1, extractionCount: 1 },
|
||||
}),
|
||||
);
|
||||
|
||||
remoteFiles.set("ST-BME_sync_chat-hook-download.json", {
|
||||
meta: { schemaVersion: 1, chatId: "chat-hook-download", revision: 3, lastModified: 30, deviceId: "remote", nodeCount: 1, edgeCount: 0, tombstoneCount: 0 },
|
||||
nodes: [{ id: "remote-download", updatedAt: 30 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: 2, extractionCount: 1 },
|
||||
});
|
||||
remoteFiles.set("ST-BME_sync_chat-hook-merge.json", {
|
||||
meta: { schemaVersion: 1, chatId: "chat-hook-merge", revision: 4, lastModified: 25, deviceId: "remote", nodeCount: 1, edgeCount: 0, tombstoneCount: 0 },
|
||||
nodes: [{ id: "remote-merge", updatedAt: 25 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: 3, extractionCount: 2 },
|
||||
});
|
||||
|
||||
const runtime = {
|
||||
...buildRuntimeOptions({ dbByChatId, fetch }),
|
||||
onSyncApplied: async (payload) => hookCalls.push({ ...payload }),
|
||||
};
|
||||
|
||||
const downloadResult = await syncNow("chat-hook-download", runtime);
|
||||
assert.equal(downloadResult.action, "download");
|
||||
|
||||
dbByChatId.get("chat-hook-merge").meta.set("syncDirty", true);
|
||||
const mergeResult = await syncNow("chat-hook-merge", runtime);
|
||||
assert.equal(mergeResult.action, "merge");
|
||||
|
||||
assert.deepEqual(hookCalls.map((item) => item.action), ["download", "merge"]);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${PREFIX} debounce=${BME_SYNC_UPLOAD_DEBOUNCE_MS}`);
|
||||
await testDeviceId();
|
||||
@@ -525,10 +722,12 @@ async function main() {
|
||||
await testUploadPayloadMetaFirstAndDebounce();
|
||||
await testDownloadImport();
|
||||
await testMergeRules();
|
||||
await testMergeRuntimeMetaPolicies();
|
||||
await testSyncNowLockAndAutoSync();
|
||||
await testDeleteRemoteSyncFile();
|
||||
await testAutoSyncOnVisibility();
|
||||
await testSyncNowRemoteReadErrorPath();
|
||||
await testSyncAppliedHook();
|
||||
console.log("indexeddb-sync tests passed");
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,24 @@ const cleanDetection = detectHistoryMutation(chat, {
|
||||
});
|
||||
assert.equal(cleanDetection.dirty, false);
|
||||
|
||||
const missingHashesDetection = detectHistoryMutation(chat, {
|
||||
lastProcessedAssistantFloor: 3,
|
||||
processedMessageHashes: {},
|
||||
});
|
||||
assert.equal(missingHashesDetection.dirty, true);
|
||||
assert.equal(missingHashesDetection.earliestAffectedFloor, 0);
|
||||
|
||||
const sparseHashesDetection = detectHistoryMutation(chat, {
|
||||
lastProcessedAssistantFloor: 3,
|
||||
processedMessageHashes: {
|
||||
0: hashes[0],
|
||||
2: hashes[2],
|
||||
3: hashes[3],
|
||||
},
|
||||
});
|
||||
assert.equal(sparseHashesDetection.dirty, true);
|
||||
assert.equal(sparseHashesDetection.earliestAffectedFloor, 1);
|
||||
|
||||
const editedChat = structuredClone(chat);
|
||||
editedChat[1].mes = "我改过内容了。";
|
||||
const editedDetection = detectHistoryMutation(editedChat, {
|
||||
|
||||
Reference in New Issue
Block a user