feat: switch ST-BME runtime to indexeddb-primary with sync hardening

This commit is contained in:
Youzini-afk
2026-03-30 18:38:58 +08:00
parent 1fd9da738f
commit 1c76ad7a11
14 changed files with 5124 additions and 78 deletions

89
bme-chat-manager.js Normal file
View 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 = "";
}
}

1350
bme-db.js Normal file

File diff suppressed because it is too large Load Diff

1017
bme-sync.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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();

1242
index.js

File diff suppressed because it is too large Load Diff

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
View 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
View File

@@ -0,0 +1,9 @@
{
"dependencies": {
"triviumdb": "^0.4.41"
},
"devDependencies": {
"dexie": "4.0.8",
"fake-indexeddb": "^6.2.5"
}
}

View File

@@ -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";

View File

@@ -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");

View 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();

View 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
View 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();

View File

@@ -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(),
};
}