mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-05-15 22:30:38 +08:00
feat: switch ST-BME runtime to indexeddb-primary with sync hardening
This commit is contained in:
89
bme-chat-manager.js
Normal file
89
bme-chat-manager.js
Normal file
@@ -0,0 +1,89 @@
|
||||
import { BmeDatabase } from "./bme-db.js";
|
||||
|
||||
function normalizeChatId(chatId) {
|
||||
return String(chatId ?? "").trim();
|
||||
}
|
||||
|
||||
export class BmeChatManager {
|
||||
constructor(options = {}) {
|
||||
this.options = options;
|
||||
this._currentChatId = "";
|
||||
this._dbByChatId = new Map();
|
||||
|
||||
this._databaseFactory =
|
||||
typeof options.databaseFactory === "function"
|
||||
? options.databaseFactory
|
||||
: (chatId) => new BmeDatabase(chatId, options.databaseOptions || {});
|
||||
}
|
||||
|
||||
async switchChat(chatId) {
|
||||
const normalizedChatId = normalizeChatId(chatId);
|
||||
|
||||
if (!normalizedChatId) {
|
||||
await this.closeCurrent();
|
||||
this._currentChatId = "";
|
||||
return null;
|
||||
}
|
||||
|
||||
this._currentChatId = normalizedChatId;
|
||||
return await this.getCurrentDb(normalizedChatId);
|
||||
}
|
||||
|
||||
async getCurrentDb(chatId = this._currentChatId) {
|
||||
const normalizedChatId = normalizeChatId(chatId);
|
||||
if (!normalizedChatId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (this._currentChatId !== normalizedChatId) {
|
||||
this._currentChatId = normalizedChatId;
|
||||
}
|
||||
|
||||
let db = this._dbByChatId.get(normalizedChatId);
|
||||
if (!db) {
|
||||
db = this._databaseFactory(normalizedChatId);
|
||||
if (!db || typeof db.open !== "function") {
|
||||
throw new Error("BmeChatManager: databaseFactory 必须返回可 open() 的实例");
|
||||
}
|
||||
this._dbByChatId.set(normalizedChatId, db);
|
||||
}
|
||||
|
||||
await db.open();
|
||||
return db;
|
||||
}
|
||||
|
||||
getCurrentChatId() {
|
||||
return this._currentChatId;
|
||||
}
|
||||
|
||||
async closeCurrent() {
|
||||
const chatId = this._currentChatId;
|
||||
if (!chatId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const db = this._dbByChatId.get(chatId);
|
||||
if (db && typeof db.close === "function") {
|
||||
await db.close();
|
||||
}
|
||||
|
||||
this._dbByChatId.delete(chatId);
|
||||
this._currentChatId = "";
|
||||
}
|
||||
|
||||
async closeAll() {
|
||||
const dbInstances = Array.from(this._dbByChatId.values());
|
||||
|
||||
for (const db of dbInstances) {
|
||||
if (!db || typeof db.close !== "function") continue;
|
||||
try {
|
||||
await db.close();
|
||||
} catch (error) {
|
||||
console.warn("[ST-BME] 关闭 BME chat 数据库失败:", error);
|
||||
}
|
||||
}
|
||||
|
||||
this._dbByChatId.clear();
|
||||
this._currentChatId = "";
|
||||
}
|
||||
}
|
||||
1017
bme-sync.js
Normal file
1017
bme-sync.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -263,8 +263,12 @@ export async function onBeforeCombinePromptsController(runtime) {
|
||||
}
|
||||
|
||||
export function onMessageReceivedController(runtime) {
|
||||
const loadState = runtime.getGraphPersistenceState?.()?.loadState || "";
|
||||
const persistenceState = runtime.getGraphPersistenceState?.() || {};
|
||||
const loadState = persistenceState.loadState || "";
|
||||
const dbReady =
|
||||
persistenceState.dbReady ?? (loadState === "loaded" || loadState === "empty-confirmed");
|
||||
if (
|
||||
!dbReady ||
|
||||
loadState === "loading" ||
|
||||
loadState === "shadow-restored" ||
|
||||
loadState === "blocked"
|
||||
@@ -281,7 +285,6 @@ export function onMessageReceivedController(runtime) {
|
||||
) {
|
||||
runtime.maybeFlushQueuedGraphPersist("message-received-pending-flush");
|
||||
}
|
||||
runtime.maybeCaptureGraphShadowSnapshot("message-received-passive-sync");
|
||||
}
|
||||
|
||||
const pendingRecallSendIntent = runtime.getPendingRecallSendIntent();
|
||||
|
||||
4
lib/dexie.min.js
vendored
Normal file
4
lib/dexie.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
39
package-lock.json
generated
Normal file
39
package-lock.json
generated
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"name": "ST-BME",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"triviumdb": "^0.4.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dexie": "4.0.8",
|
||||
"fake-indexeddb": "^6.2.5"
|
||||
}
|
||||
},
|
||||
"node_modules/dexie": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/dexie/-/dexie-4.0.8.tgz",
|
||||
"integrity": "sha512-1G6cJevS17KMDK847V3OHvK2zei899GwpDiqfEXHP1ASvme6eWJmAp9AU4s1son2TeGkWmC0g3y8ezOBPnalgQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/fake-indexeddb": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.5.tgz",
|
||||
"integrity": "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/triviumdb": {
|
||||
"version": "0.4.41",
|
||||
"resolved": "https://registry.npmjs.org/triviumdb/-/triviumdb-0.4.41.tgz",
|
||||
"integrity": "sha512-2onLIrmVxB+Vfjbk5c939dovkP6SKXJnrdQ8ICz2DHdFA9iYW9XBQUvxcz+vRSk/iRdLUDr1ypYb9dV82xi8PQ==",
|
||||
"license": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
package.json
Normal file
9
package.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"dependencies": {
|
||||
"triviumdb": "^0.4.41"
|
||||
},
|
||||
"devDependencies": {
|
||||
"dexie": "4.0.8",
|
||||
"fake-indexeddb": "^6.2.5"
|
||||
}
|
||||
}
|
||||
13
panel.js
13
panel.js
@@ -4201,6 +4201,13 @@ function _getGraphPersistenceSnapshot() {
|
||||
shadowSnapshotUsed: false,
|
||||
pendingPersist: false,
|
||||
chatId: "",
|
||||
storageMode: "indexeddb",
|
||||
dbReady: false,
|
||||
syncState: "idle",
|
||||
lastSyncUploadedAt: 0,
|
||||
lastSyncDownloadedAt: 0,
|
||||
lastSyncedRevision: 0,
|
||||
lastSyncError: "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4224,6 +4231,7 @@ function _getGraphLoadLabel(loadState = "") {
|
||||
|
||||
function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) {
|
||||
return (
|
||||
loadInfo.dbReady === true ||
|
||||
loadInfo.loadState === "loaded" ||
|
||||
loadInfo.loadState === "empty-confirmed" ||
|
||||
loadInfo.shadowSnapshotUsed === true
|
||||
@@ -4231,6 +4239,9 @@ function _canRenderGraphData(loadInfo = _getGraphPersistenceSnapshot()) {
|
||||
}
|
||||
|
||||
function _isGraphWriteBlocked(loadInfo = _getGraphPersistenceSnapshot()) {
|
||||
if (typeof loadInfo.dbReady === "boolean" && !loadInfo.dbReady) {
|
||||
return true;
|
||||
}
|
||||
return Boolean(loadInfo.writesBlocked);
|
||||
}
|
||||
|
||||
@@ -4271,6 +4282,8 @@ function _refreshGraphAvailabilityState() {
|
||||
}
|
||||
|
||||
const shouldShowOverlay =
|
||||
blocked ||
|
||||
loadInfo.syncState === "syncing" ||
|
||||
loadInfo.loadState === "loading" ||
|
||||
loadInfo.loadState === "shadow-restored" ||
|
||||
loadInfo.loadState === "blocked";
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
writeGraphShadowSnapshot,
|
||||
} from "../graph-persistence.js";
|
||||
import { onMessageReceivedController } from "../event-binding.js";
|
||||
import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js";
|
||||
|
||||
const moduleDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const indexPath = path.resolve(moduleDir, "../index.js");
|
||||
@@ -151,6 +152,7 @@ async function createGraphPersistenceHarness({
|
||||
globalChatId = "",
|
||||
characterId = "",
|
||||
groupId = null,
|
||||
indexedDbSnapshot = null,
|
||||
chat = [],
|
||||
} = {}) {
|
||||
const timers = new Map();
|
||||
@@ -169,6 +171,7 @@ async function createGraphPersistenceHarness({
|
||||
Boolean,
|
||||
structuredClone,
|
||||
result: null,
|
||||
__indexedDbSnapshot: indexedDbSnapshot,
|
||||
sessionStorage: storage,
|
||||
setTimeout(fn, delay) {
|
||||
const id = nextTimerId++;
|
||||
@@ -313,6 +316,20 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
notifyExtractionIssue() {},
|
||||
async runExtraction() {},
|
||||
getRequestHeaders() {
|
||||
return {};
|
||||
},
|
||||
__syncNowCalls: [],
|
||||
async syncNow(chatId, options = {}) {
|
||||
runtimeContext.__syncNowCalls.push({
|
||||
chatId,
|
||||
options: {
|
||||
reason: String(options?.reason || ""),
|
||||
trigger: String(options?.trigger || ""),
|
||||
},
|
||||
});
|
||||
return { synced: true, chatId, reason: String(options?.reason || "") };
|
||||
},
|
||||
__chatContext: {
|
||||
chatId,
|
||||
chatMetadata,
|
||||
@@ -340,6 +357,82 @@ async function createGraphPersistenceHarness({
|
||||
},
|
||||
__contextSaveCalls: 0,
|
||||
__contextImmediateSaveCalls: 0,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
scheduleUpload() {},
|
||||
BmeChatManager: class {
|
||||
constructor() {
|
||||
this._db = {
|
||||
async exportSnapshot() {
|
||||
if (runtimeContext.__indexedDbSnapshot) {
|
||||
return structuredClone(runtimeContext.__indexedDbSnapshot);
|
||||
}
|
||||
return {
|
||||
meta: { revision: 0, chatId: "" },
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
};
|
||||
},
|
||||
async importSnapshot(snapshot) {
|
||||
runtimeContext.__indexedDbSnapshot = structuredClone(snapshot);
|
||||
return {
|
||||
revision:
|
||||
Number(snapshot?.meta?.revision) ||
|
||||
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) ||
|
||||
0,
|
||||
};
|
||||
},
|
||||
async getMeta(key, fallbackValue = 0) {
|
||||
const snapshot = runtimeContext.__indexedDbSnapshot || {};
|
||||
if (!snapshot?.meta || !(key in snapshot.meta)) {
|
||||
return fallbackValue;
|
||||
}
|
||||
return snapshot.meta[key];
|
||||
},
|
||||
async getRevision() {
|
||||
return (
|
||||
Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) || 0
|
||||
);
|
||||
},
|
||||
async isEmpty() {
|
||||
const snapshot = runtimeContext.__indexedDbSnapshot || {};
|
||||
const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0;
|
||||
const edges = Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0;
|
||||
const tombstones = Array.isArray(snapshot?.tombstones)
|
||||
? snapshot.tombstones.length
|
||||
: 0;
|
||||
return {
|
||||
empty: nodes === 0 && edges === 0,
|
||||
nodes,
|
||||
edges,
|
||||
tombstones,
|
||||
};
|
||||
},
|
||||
async importLegacyGraph(graph, options = {}) {
|
||||
const revision = Number(options?.revision) || 1;
|
||||
runtimeContext.__indexedDbSnapshot = buildSnapshotFromGraph(graph, {
|
||||
chatId: runtimeContext.__chatContext?.chatId || "",
|
||||
revision,
|
||||
meta: {
|
||||
migrationCompletedAt: Date.now(),
|
||||
migrationSource: "chat_metadata",
|
||||
},
|
||||
});
|
||||
return { migrated: true, revision, imported: { nodes: runtimeContext.__indexedDbSnapshot.nodes.length, edges: runtimeContext.__indexedDbSnapshot.edges.length, tombstones: runtimeContext.__indexedDbSnapshot.tombstones.length } };
|
||||
},
|
||||
async markSyncDirty() {},
|
||||
};
|
||||
}
|
||||
async getCurrentDb() {
|
||||
return this._db;
|
||||
}
|
||||
async switchChat() {
|
||||
return this._db;
|
||||
}
|
||||
async closeCurrent() {}
|
||||
},
|
||||
};
|
||||
|
||||
runtimeContext.globalThis = runtimeContext;
|
||||
@@ -395,6 +488,12 @@ result = {
|
||||
getChatContext() {
|
||||
return globalThis.__chatContext;
|
||||
},
|
||||
setIndexedDbSnapshot(snapshot) {
|
||||
globalThis.__indexedDbSnapshot = snapshot;
|
||||
},
|
||||
getIndexedDbSnapshot() {
|
||||
return globalThis.__indexedDbSnapshot;
|
||||
},
|
||||
};
|
||||
`,
|
||||
].join("\n"),
|
||||
@@ -480,13 +579,20 @@ result = {
|
||||
saveMetadataDebounced() {},
|
||||
});
|
||||
|
||||
harness.api.setIndexedDbSnapshot(
|
||||
buildSnapshotFromGraph(lateGraph, { chatId: "chat-late", revision: 5 }),
|
||||
);
|
||||
|
||||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||||
source: "late-context-sync",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
assert.equal(result.loadState, "loaded");
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(harness.api.getCurrentGraph().historyState.chatId, "chat-late");
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||||
assert.equal(harness.api.getGraphPersistenceState().storagePrimary, "indexeddb");
|
||||
}
|
||||
|
||||
{
|
||||
@@ -521,9 +627,12 @@ result = {
|
||||
const result = harness.api.syncGraphLoadFromLiveContext({
|
||||
source: "late-empty-sync",
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(result.synced, true);
|
||||
assert.equal(result.loadState, "empty-confirmed");
|
||||
assert.equal(result.loadState, "loading");
|
||||
assert.equal(harness.api.getGraphPersistenceState().loadState, "empty-confirmed");
|
||||
assert.equal(harness.api.getGraphPersistenceState().dbReady, true);
|
||||
}
|
||||
|
||||
{
|
||||
@@ -611,20 +720,20 @@ result = {
|
||||
reason: "blocked-save",
|
||||
markMutation: false,
|
||||
});
|
||||
assert.equal(result.saved, false);
|
||||
assert.equal(result.queued, true);
|
||||
assert.equal(result.blocked, true);
|
||||
assert.equal(result.saved, true);
|
||||
assert.equal(result.queued, false);
|
||||
assert.equal(result.blocked, false);
|
||||
assert.equal(result.saveMode, "indexeddb-queued");
|
||||
assert.equal(harness.runtimeContext.__chatContext.chatMetadata, undefined);
|
||||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||||
assert.equal(harness.runtimeContext.__globalSaveCalls, 0);
|
||||
|
||||
const shadow = harness.api.readGraphShadowSnapshot("chat-blocked");
|
||||
assert.ok(shadow, "loading 状态下应写入会话影子快照");
|
||||
assert.equal(shadow.revision, 4);
|
||||
assert.equal(shadow, null, "IndexedDB 主路径不再依赖会话影子快照");
|
||||
assert.equal(
|
||||
harness.api.readRuntimeDebugSnapshot().graphPersistence
|
||||
?.queuedPersistRevision,
|
||||
4,
|
||||
0,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -680,9 +789,10 @@ result = {
|
||||
"onMessageReceived 不应在 loading 期间写回 chat metadata",
|
||||
);
|
||||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||||
assert.ok(
|
||||
assert.equal(
|
||||
harness.api.readGraphShadowSnapshot("chat-message"),
|
||||
"onMessageReceived 应只做会话快照兜底",
|
||||
null,
|
||||
"onMessageReceived 不再依赖 shadow snapshot 兜底",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -709,19 +819,23 @@ result = {
|
||||
st_bme_graph: createMeaningfulGraph("chat-late-reconcile", "late-official"),
|
||||
},
|
||||
});
|
||||
harness.api.setIndexedDbSnapshot(
|
||||
buildSnapshotFromGraph(createMeaningfulGraph("chat-late-reconcile", "late-indexeddb"), {
|
||||
chatId: "chat-late-reconcile",
|
||||
revision: 7,
|
||||
}),
|
||||
);
|
||||
|
||||
harness.api.onMessageReceived();
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
const live = harness.api.getGraphPersistenceLiveState();
|
||||
assert.equal(
|
||||
live.loadState,
|
||||
"loaded",
|
||||
"BLOCKED 后 onMessageReceived 应触发元数据重探测并自动恢复",
|
||||
);
|
||||
assert.equal(live.loadState, "loaded");
|
||||
assert.equal(live.writesBlocked, false);
|
||||
assert.equal(live.storagePrimary, "indexeddb");
|
||||
assert.equal(
|
||||
harness.api.getCurrentGraph().nodes[0]?.fields?.title,
|
||||
"事件-late-official",
|
||||
"事件-late-indexeddb",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -927,8 +1041,8 @@ result = {
|
||||
});
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
assert.equal(result.saveMode, "immediate");
|
||||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 1);
|
||||
assert.equal(result.saveMode, "indexeddb-queued");
|
||||
assert.equal(harness.runtimeContext.__contextImmediateSaveCalls, 0);
|
||||
assert.equal(harness.runtimeContext.__contextSaveCalls, 0);
|
||||
assert.equal(
|
||||
harness.runtimeContext.__chatContext.chatMetadata?.integrity ===
|
||||
@@ -937,8 +1051,13 @@ result = {
|
||||
"插件保存图谱时不能改写宿主 metadata.integrity",
|
||||
);
|
||||
assert.equal(
|
||||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph
|
||||
?.__stBmePersistence?.revision > 0,
|
||||
harness.runtimeContext.__chatContext.chatMetadata?.st_bme_graph,
|
||||
undefined,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(
|
||||
Number(harness.api.getIndexedDbSnapshot()?.meta?.revision) > 0,
|
||||
true,
|
||||
);
|
||||
}
|
||||
@@ -1006,6 +1125,7 @@ result = {
|
||||
const result = harness.api.saveGraphToChat({
|
||||
reason: "decouple-metadata-runtime",
|
||||
markMutation: false,
|
||||
persistMetadata: true,
|
||||
});
|
||||
|
||||
assert.equal(result.saved, true);
|
||||
@@ -1159,6 +1279,7 @@ result = {
|
||||
const firstSave = harness.api.saveGraphToChat({
|
||||
reason: "first-save",
|
||||
markMutation: false,
|
||||
persistMetadata: true,
|
||||
});
|
||||
assert.equal(firstSave.saved, true);
|
||||
const firstPersistedGraph =
|
||||
@@ -1175,6 +1296,7 @@ result = {
|
||||
const secondSave = harness.api.saveGraphToChat({
|
||||
reason: "second-save",
|
||||
markMutation: false,
|
||||
persistMetadata: true,
|
||||
});
|
||||
assert.equal(secondSave.saved, true);
|
||||
const secondPersistedGraph =
|
||||
@@ -1329,4 +1451,88 @@ result = {
|
||||
);
|
||||
}
|
||||
|
||||
{
|
||||
const metadataGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-indexeddb-priority", "metadata"),
|
||||
{
|
||||
revision: 3,
|
||||
integrity: "meta-indexeddb-priority",
|
||||
chatId: "chat-indexeddb-priority",
|
||||
reason: "metadata-seed",
|
||||
},
|
||||
);
|
||||
const indexedDbGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-indexeddb-priority", "indexeddb"),
|
||||
{
|
||||
revision: 9,
|
||||
integrity: "idxdb-indexeddb-priority",
|
||||
chatId: "chat-indexeddb-priority",
|
||||
reason: "indexeddb-seed",
|
||||
},
|
||||
);
|
||||
const indexedDbSnapshot = buildSnapshotFromGraph(indexedDbGraph, {
|
||||
chatId: "chat-indexeddb-priority",
|
||||
revision: 9,
|
||||
});
|
||||
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-indexeddb-priority",
|
||||
globalChatId: "chat-indexeddb-priority",
|
||||
chatMetadata: {
|
||||
integrity: "meta-indexeddb-priority",
|
||||
[GRAPH_METADATA_KEY]: metadataGraph,
|
||||
},
|
||||
indexedDbSnapshot,
|
||||
});
|
||||
|
||||
harness.api.loadGraphFromChat({ source: "indexeddb-priority" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-indexeddb");
|
||||
assert.equal(harness.api.getGraphPersistenceState().storagePrimary, "indexeddb");
|
||||
}
|
||||
|
||||
{
|
||||
const legacyGraph = stampPersistedGraph(
|
||||
createMeaningfulGraph("chat-legacy-migration", "legacy"),
|
||||
{
|
||||
revision: 6,
|
||||
integrity: "meta-legacy-migration",
|
||||
chatId: "chat-legacy-migration",
|
||||
reason: "legacy-seed",
|
||||
},
|
||||
);
|
||||
|
||||
const harness = await createGraphPersistenceHarness({
|
||||
chatId: "chat-legacy-migration",
|
||||
globalChatId: "chat-legacy-migration",
|
||||
chatMetadata: {
|
||||
integrity: "meta-legacy-migration",
|
||||
[GRAPH_METADATA_KEY]: legacyGraph,
|
||||
},
|
||||
indexedDbSnapshot: {
|
||||
meta: {
|
||||
chatId: "chat-legacy-migration",
|
||||
revision: 0,
|
||||
migrationCompletedAt: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: -1,
|
||||
extractionCount: 0,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
harness.api.loadGraphFromChat({ source: "legacy-migration-check" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
assert.ok(harness.runtimeContext.__syncNowCalls.length >= 1);
|
||||
assert.equal(harness.runtimeContext.__syncNowCalls[0].options.reason, "post-migration");
|
||||
assert.equal(harness.api.getCurrentGraph().nodes[0].id, "node-legacy");
|
||||
assert.equal(harness.api.getIndexedDbSnapshot().meta.migrationSource, "chat_metadata");
|
||||
}
|
||||
|
||||
console.log("graph-persistence tests passed");
|
||||
|
||||
224
tests/indexeddb-migration.mjs
Normal file
224
tests/indexeddb-migration.mjs
Normal file
@@ -0,0 +1,224 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_LEGACY_RETENTION_MS,
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
ensureDexieLoaded,
|
||||
} from "../bme-db.js";
|
||||
import { createEmptyGraph } from "../graph.js";
|
||||
|
||||
const PREFIX = "[ST-BME][indexeddb-migration]";
|
||||
|
||||
const chatIdsForCleanup = new Set([
|
||||
"chat-migration-a",
|
||||
"chat-migration-b",
|
||||
"chat-migration-c",
|
||||
]);
|
||||
|
||||
async function setupIndexedDbTestEnv() {
|
||||
try {
|
||||
await import("fake-indexeddb/auto");
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`${PREFIX} fake-indexeddb 未安装,回退到当前运行时 indexedDB:`,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.Dexie) {
|
||||
try {
|
||||
const imported = await import("dexie");
|
||||
globalThis.Dexie = imported?.default || imported?.Dexie || imported;
|
||||
} catch {
|
||||
await import("../lib/dexie.min.js");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDexieLoaded();
|
||||
assert.equal(typeof globalThis.Dexie, "function", "Dexie 构造函数必须可用");
|
||||
}
|
||||
|
||||
async function cleanupDatabases() {
|
||||
if (typeof globalThis.Dexie?.delete !== "function") return;
|
||||
|
||||
for (const chatId of chatIdsForCleanup) {
|
||||
try {
|
||||
await globalThis.Dexie.delete(buildBmeDbName(chatId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacyGraph(chatId, suffix = "legacy") {
|
||||
const graph = createEmptyGraph();
|
||||
graph.historyState.chatId = chatId;
|
||||
graph.historyState.lastProcessedAssistantFloor = 8;
|
||||
graph.historyState.extractionCount = 3;
|
||||
graph.lastProcessedSeq = 8;
|
||||
graph.nodes.push(
|
||||
{
|
||||
id: `node-${suffix}-a`,
|
||||
type: "event",
|
||||
seq: 5,
|
||||
seqRange: [4, 5],
|
||||
archived: false,
|
||||
fields: {
|
||||
title: "第一条",
|
||||
},
|
||||
},
|
||||
{
|
||||
id: `node-${suffix}-b`,
|
||||
type: "event",
|
||||
seq: 8,
|
||||
archived: false,
|
||||
fields: {
|
||||
title: "第二条",
|
||||
},
|
||||
},
|
||||
);
|
||||
graph.edges.push({
|
||||
id: `edge-${suffix}-ab`,
|
||||
fromId: `node-${suffix}-a`,
|
||||
toId: `node-${suffix}-b`,
|
||||
relation: "related",
|
||||
seqRange: [5, 8],
|
||||
});
|
||||
graph.__stBmePersistence = {
|
||||
revision: 6,
|
||||
reason: "legacy-seed",
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return graph;
|
||||
}
|
||||
|
||||
async function testMigrationSuccessAndMeta() {
|
||||
const db = new BmeDatabase("chat-migration-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const before = await db.isEmpty();
|
||||
assert.equal(before.empty, true);
|
||||
|
||||
const nowMs = 1735689600000;
|
||||
const result = await db.importLegacyGraph(createLegacyGraph("chat-migration-a"), {
|
||||
nowMs,
|
||||
source: "chat_metadata",
|
||||
revision: 6,
|
||||
});
|
||||
|
||||
assert.equal(result.migrated, true);
|
||||
assert.ok(result.revision >= 1);
|
||||
|
||||
const snapshot = await db.exportSnapshot();
|
||||
assert.equal(snapshot.nodes.length, 2);
|
||||
assert.equal(snapshot.edges.length, 1);
|
||||
|
||||
const migratedNodeA = snapshot.nodes.find((item) => item.id === "node-legacy-a");
|
||||
const migratedNodeB = snapshot.nodes.find((item) => item.id === "node-legacy-b");
|
||||
const migratedEdge = snapshot.edges.find((item) => item.id === "edge-legacy-ab");
|
||||
|
||||
assert.ok(migratedNodeA);
|
||||
assert.ok(migratedNodeB);
|
||||
assert.ok(migratedEdge);
|
||||
|
||||
assert.equal(migratedNodeA.sourceFloor, 5, "node sourceFloor 应优先取 seqRange[1]");
|
||||
assert.equal(migratedNodeB.sourceFloor, 8, "node sourceFloor 应回退到 seq");
|
||||
assert.equal(migratedEdge.sourceFloor, 8, "edge sourceFloor 应优先取 seqRange[1]");
|
||||
|
||||
assert.equal(snapshot.meta.migrationSource, "chat_metadata");
|
||||
assert.equal(snapshot.meta.migrationCompletedAt, nowMs);
|
||||
assert.equal(snapshot.meta.legacyRetentionUntil, nowMs + BME_LEGACY_RETENTION_MS);
|
||||
assert.equal(snapshot.state.lastProcessedFloor, 8);
|
||||
assert.equal(snapshot.state.extractionCount, 3);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testMigrationIdempotent() {
|
||||
const db = new BmeDatabase("chat-migration-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const beforeSnapshot = await db.exportSnapshot();
|
||||
const result = await db.importLegacyGraph(createLegacyGraph("chat-migration-a"), {
|
||||
nowMs: beforeSnapshot.meta.migrationCompletedAt + 1000,
|
||||
source: "chat_metadata",
|
||||
revision: 12,
|
||||
});
|
||||
|
||||
assert.equal(result.migrated, false);
|
||||
assert.equal(result.reason, "migration-already-completed");
|
||||
|
||||
const afterSnapshot = await db.exportSnapshot();
|
||||
assert.equal(afterSnapshot.meta.revision, beforeSnapshot.meta.revision);
|
||||
assert.equal(afterSnapshot.nodes.length, beforeSnapshot.nodes.length);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testMigrationSkippedWhenNotEmpty() {
|
||||
const db = new BmeDatabase("chat-migration-b", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "existing-node",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = await db.importLegacyGraph(createLegacyGraph("chat-migration-b"), {
|
||||
nowMs: Date.now(),
|
||||
source: "chat_metadata",
|
||||
revision: 5,
|
||||
});
|
||||
|
||||
assert.equal(result.migrated, false);
|
||||
assert.equal(result.reason, "indexeddb-not-empty");
|
||||
|
||||
const migrationCompletedAt = await db.getMeta("migrationCompletedAt", 0);
|
||||
assert.equal(migrationCompletedAt, 0);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testIsEmptyWithTombstonesOption() {
|
||||
const db = new BmeDatabase("chat-migration-c", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
await db.bulkUpsertTombstones([
|
||||
{
|
||||
id: "tomb-only",
|
||||
kind: "node",
|
||||
targetId: "legacy-node",
|
||||
deletedAt: Date.now(),
|
||||
sourceDeviceId: "device-a",
|
||||
},
|
||||
]);
|
||||
|
||||
const defaultEmpty = await db.isEmpty();
|
||||
const strictEmpty = await db.isEmpty({ includeTombstones: true });
|
||||
|
||||
assert.equal(defaultEmpty.empty, true);
|
||||
assert.equal(strictEmpty.empty, false);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await setupIndexedDbTestEnv();
|
||||
await cleanupDatabases();
|
||||
|
||||
await testMigrationSuccessAndMeta();
|
||||
await testMigrationIdempotent();
|
||||
await testMigrationSkippedWhenNotEmpty();
|
||||
await testIsEmptyWithTombstonesOption();
|
||||
|
||||
await cleanupDatabases();
|
||||
|
||||
console.log("indexeddb-migration tests passed");
|
||||
}
|
||||
|
||||
await main();
|
||||
408
tests/indexeddb-persistence.mjs
Normal file
408
tests/indexeddb-persistence.mjs
Normal file
@@ -0,0 +1,408 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_DB_SCHEMA_VERSION,
|
||||
BME_TOMBSTONE_RETENTION_MS,
|
||||
BmeDatabase,
|
||||
buildBmeDbName,
|
||||
buildGraphFromSnapshot,
|
||||
buildSnapshotFromGraph,
|
||||
ensureDexieLoaded,
|
||||
} from "../bme-db.js";
|
||||
import { BmeChatManager } from "../bme-chat-manager.js";
|
||||
import { createEmptyGraph } from "../graph.js";
|
||||
|
||||
const PREFIX = "[ST-BME][indexeddb-persistence]";
|
||||
|
||||
const chatIdsForCleanup = new Set([
|
||||
"chat-a",
|
||||
"chat-b",
|
||||
"chat-manager-a",
|
||||
"chat-manager-b",
|
||||
]);
|
||||
|
||||
async function setupIndexedDbTestEnv() {
|
||||
let fakeIndexedDbLoaded = false;
|
||||
|
||||
try {
|
||||
await import("fake-indexeddb/auto");
|
||||
fakeIndexedDbLoaded = true;
|
||||
} catch (error) {
|
||||
console.warn(
|
||||
`${PREFIX} fake-indexeddb 未安装,回退到当前运行时 indexedDB:`,
|
||||
error?.message || error,
|
||||
);
|
||||
}
|
||||
|
||||
if (!globalThis.Dexie) {
|
||||
try {
|
||||
const imported = await import("dexie");
|
||||
globalThis.Dexie = imported?.default || imported?.Dexie || imported;
|
||||
} catch {
|
||||
await import("../lib/dexie.min.js");
|
||||
}
|
||||
}
|
||||
|
||||
await ensureDexieLoaded();
|
||||
|
||||
assert.equal(typeof globalThis.Dexie, "function", "Dexie 构造函数必须可用");
|
||||
assert.ok(globalThis.indexedDB, "indexedDB 必须可用");
|
||||
assert.ok(globalThis.IDBKeyRange, "IDBKeyRange 必须可用");
|
||||
|
||||
return { fakeIndexedDbLoaded };
|
||||
}
|
||||
|
||||
async function cleanupDatabases() {
|
||||
if (typeof globalThis.Dexie?.delete !== "function") return;
|
||||
|
||||
for (const chatId of chatIdsForCleanup) {
|
||||
try {
|
||||
await globalThis.Dexie.delete(buildBmeDbName(chatId));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function testBuildAndOpen() {
|
||||
assert.equal(buildBmeDbName("chat-a"), "STBME_chat-a");
|
||||
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const tableNames = db.db.tables.map((table) => table.name).sort();
|
||||
assert.deepEqual(tableNames, ["edges", "meta", "nodes", "tombstones"]);
|
||||
|
||||
const schemaVersion = await db.getMeta("schemaVersion", 0);
|
||||
assert.equal(schemaVersion, BME_DB_SCHEMA_VERSION);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testCrudAndMeta() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const nodeResult = await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-1",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
fields: {
|
||||
title: "第一次相遇",
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(nodeResult.upserted, 1);
|
||||
|
||||
const edgeResult = await db.bulkUpsertEdges([
|
||||
{
|
||||
id: "edge-1",
|
||||
fromId: "node-1",
|
||||
toId: "node-1",
|
||||
relation: "self",
|
||||
sourceFloor: 1,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
assert.equal(edgeResult.upserted, 1);
|
||||
|
||||
await db.setMeta("lastProcessedFloor", 7);
|
||||
assert.equal(await db.getMeta("lastProcessedFloor", -1), 7);
|
||||
|
||||
await db.patchMeta({
|
||||
extractionCount: 3,
|
||||
deviceId: "device-test",
|
||||
});
|
||||
assert.equal(await db.getMeta("extractionCount", 0), 3);
|
||||
assert.equal(await db.getMeta("deviceId", ""), "device-test");
|
||||
|
||||
const nodes = await db.listNodes({ includeDeleted: false, reverse: false });
|
||||
const edges = await db.listEdges({ includeDeleted: false, reverse: false });
|
||||
|
||||
assert.equal(nodes.length, 1);
|
||||
assert.equal(edges.length, 1);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testTransactionRollback() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await db.db.transaction("rw", db.db.table("nodes"), async () => {
|
||||
await db.db.table("nodes").put({
|
||||
id: "node-rollback",
|
||||
type: "event",
|
||||
sourceFloor: 9,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
throw new Error("simulate rollback");
|
||||
});
|
||||
});
|
||||
|
||||
const rollbackNode = await db.db.table("nodes").get("node-rollback");
|
||||
assert.equal(rollbackNode, undefined);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testSnapshotExportImport() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-snapshot",
|
||||
type: "event",
|
||||
sourceFloor: 2,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
await db.bulkUpsertEdges([
|
||||
{
|
||||
id: "edge-snapshot",
|
||||
fromId: "node-snapshot",
|
||||
toId: "node-1",
|
||||
relation: "related",
|
||||
sourceFloor: 2,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const exported = await db.exportSnapshot();
|
||||
assert.ok(exported.meta);
|
||||
assert.ok(Array.isArray(exported.nodes));
|
||||
assert.ok(Array.isArray(exported.edges));
|
||||
|
||||
await db.clearAll();
|
||||
assert.equal((await db.listNodes()).length, 0);
|
||||
|
||||
const importResult = await db.importSnapshot(exported, {
|
||||
mode: "replace",
|
||||
preserveRevision: true,
|
||||
});
|
||||
|
||||
assert.equal(importResult.mode, "replace");
|
||||
assert.ok(importResult.imported.nodes >= 1);
|
||||
assert.ok((await db.listNodes()).some((item) => item.id === "node-snapshot"));
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testRevisionMonotonicity() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const revisionBefore = await db.getRevision();
|
||||
|
||||
const afterNode = await db.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-rev-1",
|
||||
type: "event",
|
||||
sourceFloor: 3,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const afterEdge = await db.bulkUpsertEdges([
|
||||
{
|
||||
id: "edge-rev-1",
|
||||
fromId: "node-rev-1",
|
||||
toId: "node-snapshot",
|
||||
relation: "next",
|
||||
sourceFloor: 3,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
assert.ok(afterNode.revision > revisionBefore);
|
||||
assert.ok(afterEdge.revision > afterNode.revision);
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testTombstonePrune() {
|
||||
const db = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
await db.open();
|
||||
|
||||
const nowMs = Date.now();
|
||||
const oldDeletedAt = nowMs - BME_TOMBSTONE_RETENTION_MS - 1000;
|
||||
const freshDeletedAt = nowMs - 1000;
|
||||
|
||||
await db.bulkUpsertTombstones([
|
||||
{
|
||||
id: "tomb-old",
|
||||
kind: "node",
|
||||
targetId: "node-old",
|
||||
deletedAt: oldDeletedAt,
|
||||
sourceDeviceId: "device-a",
|
||||
},
|
||||
{
|
||||
id: "tomb-fresh",
|
||||
kind: "node",
|
||||
targetId: "node-fresh",
|
||||
deletedAt: freshDeletedAt,
|
||||
sourceDeviceId: "device-a",
|
||||
},
|
||||
]);
|
||||
|
||||
const pruneResult = await db.pruneExpiredTombstones(nowMs);
|
||||
assert.equal(pruneResult.pruned, 1);
|
||||
|
||||
const tombstones = await db.listTombstones({ reverse: false });
|
||||
assert.equal(tombstones.length, 1);
|
||||
assert.equal(tombstones[0].id, "tomb-fresh");
|
||||
|
||||
await db.close();
|
||||
}
|
||||
|
||||
async function testChatIsolationAndManager() {
|
||||
const dbA = new BmeDatabase("chat-a", { dexieClass: globalThis.Dexie });
|
||||
const dbB = new BmeDatabase("chat-b", { dexieClass: globalThis.Dexie });
|
||||
|
||||
await dbA.open();
|
||||
await dbB.open();
|
||||
|
||||
await dbA.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-chat-a",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
await dbB.bulkUpsertNodes([
|
||||
{
|
||||
id: "node-chat-b",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
archived: false,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const nodesA = await dbA.listNodes({ reverse: false });
|
||||
const nodesB = await dbB.listNodes({ reverse: false });
|
||||
|
||||
assert.ok(nodesA.some((item) => item.id === "node-chat-a"));
|
||||
assert.ok(!nodesA.some((item) => item.id === "node-chat-b"));
|
||||
assert.ok(nodesB.some((item) => item.id === "node-chat-b"));
|
||||
|
||||
await dbA.close();
|
||||
await dbB.close();
|
||||
|
||||
const manager = new BmeChatManager({
|
||||
databaseFactory: (chatId) => {
|
||||
chatIdsForCleanup.add(chatId);
|
||||
return new BmeDatabase(chatId, { dexieClass: globalThis.Dexie });
|
||||
},
|
||||
});
|
||||
|
||||
const managerDbA = await manager.switchChat("chat-manager-a");
|
||||
assert.equal(manager.getCurrentChatId(), "chat-manager-a");
|
||||
assert.ok(managerDbA);
|
||||
|
||||
await managerDbA.bulkUpsertNodes([
|
||||
{
|
||||
id: "manager-node-a",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const managerDbB = await manager.switchChat("chat-manager-b");
|
||||
assert.equal(manager.getCurrentChatId(), "chat-manager-b");
|
||||
await managerDbB.bulkUpsertNodes([
|
||||
{
|
||||
id: "manager-node-b",
|
||||
type: "event",
|
||||
sourceFloor: 1,
|
||||
updatedAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const managerDbBNodes = await managerDbB.listNodes({ reverse: false });
|
||||
assert.ok(managerDbBNodes.some((item) => item.id === "manager-node-b"));
|
||||
|
||||
const reopenedA = await manager.getCurrentDb("chat-manager-a");
|
||||
const reopenedANodes = await reopenedA.listNodes({ reverse: false });
|
||||
assert.ok(reopenedANodes.some((item) => item.id === "manager-node-a"));
|
||||
assert.ok(!reopenedANodes.some((item) => item.id === "manager-node-b"));
|
||||
|
||||
await manager.closeAll();
|
||||
assert.equal(manager.getCurrentChatId(), "");
|
||||
}
|
||||
|
||||
async function testGraphSnapshotConverters() {
|
||||
const graph = createEmptyGraph();
|
||||
graph.historyState.chatId = "chat-a";
|
||||
graph.historyState.lastProcessedAssistantFloor = 9;
|
||||
graph.historyState.extractionCount = 4;
|
||||
graph.historyState.processedMessageHashes = {
|
||||
1: "hash-1",
|
||||
};
|
||||
graph.vectorIndexState.hashToNodeId = {
|
||||
"vec-hash": "node-converter",
|
||||
};
|
||||
graph.lastRecallResult = ["node-converter"];
|
||||
graph.batchJournal = [
|
||||
{
|
||||
id: "journal-1",
|
||||
processedRange: [8, 9],
|
||||
},
|
||||
];
|
||||
graph.nodes.push({
|
||||
id: "node-converter",
|
||||
type: "event",
|
||||
sourceFloor: 9,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
const snapshot = buildSnapshotFromGraph(graph, {
|
||||
chatId: "chat-a",
|
||||
revision: 17,
|
||||
});
|
||||
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);
|
||||
|
||||
const rebuilt = buildGraphFromSnapshot(snapshot, {
|
||||
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.vectorIndexState.hashToNodeId["vec-hash"], "node-converter");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await setupIndexedDbTestEnv();
|
||||
await cleanupDatabases();
|
||||
|
||||
await testBuildAndOpen();
|
||||
await testCrudAndMeta();
|
||||
await testTransactionRollback();
|
||||
await testSnapshotExportImport();
|
||||
await testRevisionMonotonicity();
|
||||
await testTombstonePrune();
|
||||
await testChatIsolationAndManager();
|
||||
await testGraphSnapshotConverters();
|
||||
|
||||
await cleanupDatabases();
|
||||
|
||||
console.log("indexeddb-persistence tests passed");
|
||||
}
|
||||
|
||||
await main();
|
||||
535
tests/indexeddb-sync.mjs
Normal file
535
tests/indexeddb-sync.mjs
Normal file
@@ -0,0 +1,535 @@
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
BME_SYNC_DEVICE_ID_KEY,
|
||||
BME_SYNC_UPLOAD_DEBOUNCE_MS,
|
||||
__testOnlyDecodeBase64Utf8,
|
||||
autoSyncOnChatChange,
|
||||
autoSyncOnVisibility,
|
||||
deleteRemoteSyncFile,
|
||||
getOrCreateDeviceId,
|
||||
getRemoteStatus,
|
||||
download,
|
||||
mergeSnapshots,
|
||||
scheduleUpload,
|
||||
syncNow,
|
||||
upload,
|
||||
} from "../bme-sync.js";
|
||||
|
||||
const PREFIX = "[ST-BME][indexeddb-sync]";
|
||||
|
||||
class MemoryStorage {
|
||||
constructor() {
|
||||
this.map = new Map();
|
||||
}
|
||||
|
||||
getItem(key) {
|
||||
return this.map.has(key) ? this.map.get(key) : null;
|
||||
}
|
||||
|
||||
setItem(key, value) {
|
||||
this.map.set(String(key), String(value));
|
||||
}
|
||||
|
||||
removeItem(key) {
|
||||
this.map.delete(String(key));
|
||||
}
|
||||
}
|
||||
|
||||
class FakeDb {
|
||||
constructor(chatId, snapshot = null) {
|
||||
this.chatId = chatId;
|
||||
this.snapshot = snapshot || {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId,
|
||||
deviceId: "",
|
||||
revision: 0,
|
||||
lastModified: Date.now(),
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: -1,
|
||||
extractionCount: 0,
|
||||
},
|
||||
};
|
||||
this.meta = new Map([
|
||||
["syncDirty", false],
|
||||
["syncDirtyReason", ""],
|
||||
["lastSyncedRevision", 0],
|
||||
["deviceId", ""],
|
||||
]);
|
||||
this.lastImportPayload = null;
|
||||
this.lastImportOptions = null;
|
||||
}
|
||||
|
||||
async exportSnapshot() {
|
||||
return JSON.parse(JSON.stringify(this.snapshot));
|
||||
}
|
||||
|
||||
async importSnapshot(snapshot, options = {}) {
|
||||
this.lastImportPayload = JSON.parse(JSON.stringify(snapshot));
|
||||
this.lastImportOptions = { ...options };
|
||||
this.snapshot = JSON.parse(JSON.stringify(snapshot));
|
||||
return {
|
||||
mode: options.mode || "replace",
|
||||
revision: snapshot?.meta?.revision || 0,
|
||||
imported: {
|
||||
nodes: Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0,
|
||||
edges: Array.isArray(snapshot?.edges) ? snapshot.edges.length : 0,
|
||||
tombstones: Array.isArray(snapshot?.tombstones) ? snapshot.tombstones.length : 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getMeta(key, fallback = null) {
|
||||
return this.meta.has(key) ? this.meta.get(key) : fallback;
|
||||
}
|
||||
|
||||
async patchMeta(record = {}) {
|
||||
for (const [key, value] of Object.entries(record)) {
|
||||
this.meta.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
async setMeta(key, value) {
|
||||
this.meta.set(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
function createJsonResponse(status, body) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
statusText: String(status),
|
||||
async json() {
|
||||
return JSON.parse(JSON.stringify(body));
|
||||
},
|
||||
async text() {
|
||||
return typeof body === "string" ? body : JSON.stringify(body);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createMockFetchEnvironment() {
|
||||
const remoteFiles = new Map();
|
||||
const logs = {
|
||||
sanitizeCalls: 0,
|
||||
getCalls: 0,
|
||||
uploadCalls: 0,
|
||||
deleteCalls: 0,
|
||||
uploadedPayloads: [],
|
||||
};
|
||||
|
||||
const fetch = async (url, options = {}) => {
|
||||
const method = String(options?.method || "GET").toUpperCase();
|
||||
|
||||
if (url === "/api/files/sanitize-filename" && method === "POST") {
|
||||
logs.sanitizeCalls += 1;
|
||||
const body = JSON.parse(String(options.body || "{}"));
|
||||
const sanitized = String(body.fileName || "")
|
||||
.replace(/[<>:"/\\|?*\x00-\x1F]/g, "_")
|
||||
.replace(/\s+/g, "_");
|
||||
return createJsonResponse(200, { fileName: sanitized });
|
||||
}
|
||||
|
||||
if (url === "/api/files/upload" && method === "POST") {
|
||||
logs.uploadCalls += 1;
|
||||
const body = JSON.parse(String(options.body || "{}"));
|
||||
const decoded = __testOnlyDecodeBase64Utf8(body.data);
|
||||
const payload = JSON.parse(decoded);
|
||||
remoteFiles.set(body.name, payload);
|
||||
logs.uploadedPayloads.push({
|
||||
name: body.name,
|
||||
payload,
|
||||
});
|
||||
return createJsonResponse(200, { path: `/user/files/${body.name}` });
|
||||
}
|
||||
|
||||
if (url === "/api/files/delete" && method === "POST") {
|
||||
logs.deleteCalls += 1;
|
||||
const body = JSON.parse(String(options.body || "{}"));
|
||||
const name = String(body.path || "").replace("/user/files/", "");
|
||||
if (!remoteFiles.has(name)) return createJsonResponse(404, "not found");
|
||||
remoteFiles.delete(name);
|
||||
return createJsonResponse(200, {});
|
||||
}
|
||||
|
||||
if (String(url).startsWith("/user/files/") && method === "GET") {
|
||||
logs.getCalls += 1;
|
||||
const withoutQuery = String(url).split("?")[0];
|
||||
const fileName = decodeURIComponent(withoutQuery.slice("/user/files/".length));
|
||||
if (!remoteFiles.has(fileName)) {
|
||||
return createJsonResponse(404, "not found");
|
||||
}
|
||||
return createJsonResponse(200, remoteFiles.get(fileName));
|
||||
}
|
||||
|
||||
return createJsonResponse(404, "unsupported route");
|
||||
};
|
||||
|
||||
return {
|
||||
fetch,
|
||||
remoteFiles,
|
||||
logs,
|
||||
};
|
||||
}
|
||||
|
||||
function buildRuntimeOptions({ dbByChatId, fetch }) {
|
||||
return {
|
||||
fetch,
|
||||
getDb: async (chatId) => {
|
||||
const db = dbByChatId.get(chatId);
|
||||
if (!db) throw new Error(`missing db: ${chatId}`);
|
||||
return db;
|
||||
},
|
||||
getRequestHeaders: () => ({
|
||||
"X-Test": "1",
|
||||
}),
|
||||
disableRemoteSanitize: false,
|
||||
};
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
function createVisibilityMockDocument(initialVisibilityState = "visible") {
|
||||
const listeners = new Map();
|
||||
const document = {
|
||||
visibilityState: initialVisibilityState,
|
||||
addEventListener(eventName, handler) {
|
||||
listeners.set(String(eventName), handler);
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
document,
|
||||
emitVisibilityChange(nextVisibilityState) {
|
||||
document.visibilityState = nextVisibilityState;
|
||||
const handler = listeners.get("visibilitychange");
|
||||
if (typeof handler === "function") {
|
||||
handler();
|
||||
}
|
||||
},
|
||||
getListener(eventName) {
|
||||
return listeners.get(String(eventName));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function testDeviceId() {
|
||||
const storage = new MemoryStorage();
|
||||
globalThis.localStorage = storage;
|
||||
|
||||
const first = getOrCreateDeviceId();
|
||||
const second = getOrCreateDeviceId();
|
||||
|
||||
assert.ok(first);
|
||||
assert.equal(first, second);
|
||||
assert.equal(storage.getItem(BME_SYNC_DEVICE_ID_KEY), first);
|
||||
}
|
||||
|
||||
async function testRemoteStatusMissing() {
|
||||
const { fetch } = createMockFetchEnvironment();
|
||||
const status = await getRemoteStatus("chat-a", {
|
||||
fetch,
|
||||
getRequestHeaders: () => ({}),
|
||||
});
|
||||
|
||||
assert.equal(status.exists, false);
|
||||
assert.equal(status.status, "not-found");
|
||||
}
|
||||
|
||||
async function testUploadPayloadMetaFirstAndDebounce() {
|
||||
const { fetch, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
dbByChatId.set(
|
||||
"chat-upload",
|
||||
new FakeDb("chat-upload", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-upload",
|
||||
deviceId: "",
|
||||
revision: 9,
|
||||
lastModified: Date.now(),
|
||||
nodeCount: 1,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [{ id: "n1", updatedAt: 100 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: 7, extractionCount: 4 },
|
||||
}),
|
||||
);
|
||||
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
const uploadResult = await upload("chat-upload", runtime);
|
||||
assert.equal(uploadResult.uploaded, true);
|
||||
assert.equal(logs.uploadCalls, 1);
|
||||
|
||||
const uploadedPayload = logs.uploadedPayloads[0].payload;
|
||||
assert.equal(Object.keys(uploadedPayload)[0], "meta");
|
||||
assert.equal(uploadedPayload.meta.revision, 9);
|
||||
|
||||
scheduleUpload("chat-upload", {
|
||||
...runtime,
|
||||
debounceMs: 20,
|
||||
});
|
||||
await sleep(50);
|
||||
assert.equal(logs.uploadCalls, 2);
|
||||
}
|
||||
|
||||
async function testDownloadImport() {
|
||||
const { fetch, remoteFiles } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
const db = new FakeDb("chat-download");
|
||||
dbByChatId.set("chat-download", db);
|
||||
|
||||
remoteFiles.set("ST-BME_sync_chat-download.json", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-download",
|
||||
revision: 12,
|
||||
deviceId: "remote-device",
|
||||
lastModified: 500,
|
||||
nodeCount: 1,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [{ id: "remote-node", updatedAt: 400 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 10,
|
||||
extractionCount: 2,
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
const result = await download("chat-download", runtime);
|
||||
|
||||
assert.equal(result.downloaded, true);
|
||||
assert.equal(db.lastImportPayload.meta.revision, 12);
|
||||
assert.equal(db.lastImportPayload.nodes[0].id, "remote-node");
|
||||
}
|
||||
|
||||
async function testMergeRules() {
|
||||
const local = {
|
||||
meta: {
|
||||
chatId: "chat-merge",
|
||||
revision: 7,
|
||||
lastModified: 100,
|
||||
deviceId: "local-device",
|
||||
schemaVersion: 1,
|
||||
},
|
||||
nodes: [{ id: "node-a", updatedAt: 100, value: "old" }],
|
||||
edges: [{ id: "edge-a", updatedAt: 100, fromId: "a", toId: "b" }],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 5,
|
||||
extractionCount: 3,
|
||||
},
|
||||
};
|
||||
|
||||
const remote = {
|
||||
meta: {
|
||||
chatId: "chat-merge",
|
||||
revision: 10,
|
||||
lastModified: 200,
|
||||
deviceId: "remote-device",
|
||||
schemaVersion: 1,
|
||||
},
|
||||
nodes: [{ id: "node-a", updatedAt: 200, value: "new" }],
|
||||
edges: [{ id: "edge-a", updatedAt: 200, fromId: "a", toId: "b" }],
|
||||
tombstones: [
|
||||
{
|
||||
id: "node:node-a",
|
||||
kind: "node",
|
||||
targetId: "node-a",
|
||||
deletedAt: 250,
|
||||
sourceDeviceId: "remote-device",
|
||||
},
|
||||
],
|
||||
state: {
|
||||
lastProcessedFloor: 8,
|
||||
extractionCount: 2,
|
||||
},
|
||||
};
|
||||
|
||||
const merged = mergeSnapshots(local, remote, { chatId: "chat-merge" });
|
||||
|
||||
assert.equal(merged.meta.revision, 11);
|
||||
assert.equal(merged.nodes.length, 0, "tombstone 必须覆盖复活");
|
||||
assert.equal(merged.state.lastProcessedFloor, 8);
|
||||
assert.equal(merged.state.extractionCount, 3);
|
||||
}
|
||||
|
||||
async function testSyncNowLockAndAutoSync() {
|
||||
const { fetch, remoteFiles, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
const db = new FakeDb("chat-lock", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-lock",
|
||||
revision: 1,
|
||||
lastModified: 10,
|
||||
deviceId: "",
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: -1,
|
||||
extractionCount: 0,
|
||||
},
|
||||
});
|
||||
dbByChatId.set("chat-lock", db);
|
||||
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
|
||||
const [r1, r2] = await Promise.all([
|
||||
syncNow("chat-lock", runtime),
|
||||
syncNow("chat-lock", runtime),
|
||||
]);
|
||||
|
||||
assert.equal(r1.action, "upload");
|
||||
assert.equal(r2.action, "upload");
|
||||
assert.equal(logs.uploadCalls, 1, "同 chatId 并发 sync 应串行去重");
|
||||
|
||||
remoteFiles.set("ST-BME_sync_chat-lock.json", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-lock",
|
||||
revision: 3,
|
||||
lastModified: 99,
|
||||
deviceId: "remote-device",
|
||||
nodeCount: 1,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [{ id: "remote-new", updatedAt: 99 }],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: {
|
||||
lastProcessedFloor: 2,
|
||||
extractionCount: 1,
|
||||
},
|
||||
});
|
||||
|
||||
db.meta.set("syncDirty", false);
|
||||
const autoResult = await autoSyncOnChatChange("chat-lock", runtime);
|
||||
assert.equal(autoResult.action, "download");
|
||||
assert.equal(db.lastImportPayload.nodes[0].id, "remote-new");
|
||||
}
|
||||
|
||||
async function testDeleteRemoteSyncFile() {
|
||||
const { fetch, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
dbByChatId.set("chat-delete", new FakeDb("chat-delete"));
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
|
||||
await upload("chat-delete", runtime);
|
||||
assert.equal(logs.uploadCalls, 1);
|
||||
|
||||
const deleteResult = await deleteRemoteSyncFile("chat-delete", runtime);
|
||||
assert.equal(deleteResult.deleted, true);
|
||||
assert.equal(deleteResult.chatId, "chat-delete");
|
||||
assert.equal(logs.deleteCalls, 1);
|
||||
|
||||
const deleteMissingResult = await deleteRemoteSyncFile("chat-delete", runtime);
|
||||
assert.equal(deleteMissingResult.deleted, false);
|
||||
assert.equal(deleteMissingResult.reason, "not-found");
|
||||
assert.equal(logs.deleteCalls, 2);
|
||||
}
|
||||
|
||||
async function testAutoSyncOnVisibility() {
|
||||
const { fetch, logs } = createMockFetchEnvironment();
|
||||
const dbByChatId = new Map();
|
||||
dbByChatId.set(
|
||||
"chat-visibility",
|
||||
new FakeDb("chat-visibility", {
|
||||
meta: {
|
||||
schemaVersion: 1,
|
||||
chatId: "chat-visibility",
|
||||
revision: 2,
|
||||
lastModified: 12,
|
||||
deviceId: "",
|
||||
nodeCount: 0,
|
||||
edgeCount: 0,
|
||||
tombstoneCount: 0,
|
||||
},
|
||||
nodes: [],
|
||||
edges: [],
|
||||
tombstones: [],
|
||||
state: { lastProcessedFloor: -1, extractionCount: 0 },
|
||||
}),
|
||||
);
|
||||
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
runtime.getCurrentChatId = () => "chat-visibility";
|
||||
|
||||
const originalDocument = globalThis.document;
|
||||
const visibilityDocument = createVisibilityMockDocument("hidden");
|
||||
globalThis.document = visibilityDocument.document;
|
||||
|
||||
try {
|
||||
const installResult = autoSyncOnVisibility(runtime);
|
||||
assert.equal(installResult.installed, true);
|
||||
assert.ok(
|
||||
typeof visibilityDocument.getListener("visibilitychange") === "function",
|
||||
);
|
||||
|
||||
visibilityDocument.emitVisibilityChange("visible");
|
||||
await sleep(30);
|
||||
assert.equal(logs.uploadCalls, 1, "visibility visible 应触发一次自动同步");
|
||||
|
||||
const secondInstallResult = autoSyncOnVisibility(runtime);
|
||||
assert.equal(secondInstallResult.installed, true);
|
||||
} finally {
|
||||
globalThis.document = originalDocument;
|
||||
}
|
||||
}
|
||||
|
||||
async function testSyncNowRemoteReadErrorPath() {
|
||||
const base = createMockFetchEnvironment();
|
||||
const fetch = async (url, options = {}) => {
|
||||
if (String(url).startsWith("/user/files/")) {
|
||||
return createJsonResponse(500, "server-error");
|
||||
}
|
||||
return await base.fetch(url, options);
|
||||
};
|
||||
|
||||
const dbByChatId = new Map();
|
||||
dbByChatId.set("chat-remote-error", new FakeDb("chat-remote-error"));
|
||||
const runtime = buildRuntimeOptions({ dbByChatId, fetch });
|
||||
|
||||
const result = await syncNow("chat-remote-error", runtime);
|
||||
assert.equal(result.synced, false);
|
||||
assert.equal(result.reason, "http-error");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`${PREFIX} debounce=${BME_SYNC_UPLOAD_DEBOUNCE_MS}`);
|
||||
await testDeviceId();
|
||||
await testRemoteStatusMissing();
|
||||
await testUploadPayloadMetaFirstAndDebounce();
|
||||
await testDownloadImport();
|
||||
await testMergeRules();
|
||||
await testSyncNowLockAndAutoSync();
|
||||
await testDeleteRemoteSyncFile();
|
||||
await testAutoSyncOnVisibility();
|
||||
await testSyncNowRemoteReadErrorPath();
|
||||
console.log("indexeddb-sync tests passed");
|
||||
}
|
||||
|
||||
await main();
|
||||
11
ui-status.js
11
ui-status.js
@@ -48,6 +48,17 @@ export function createGraphPersistenceState() {
|
||||
metadataIntegrity: "",
|
||||
writesBlocked: false,
|
||||
pendingPersist: false,
|
||||
storagePrimary: "indexeddb",
|
||||
storageMode: "indexeddb",
|
||||
dbReady: false,
|
||||
indexedDbRevision: 0,
|
||||
indexedDbLastError: "",
|
||||
syncState: "idle",
|
||||
lastSyncUploadedAt: 0,
|
||||
lastSyncDownloadedAt: 0,
|
||||
lastSyncedRevision: 0,
|
||||
lastSyncError: "",
|
||||
dualWriteLastResult: null,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user