diff --git a/bme-chat-manager.js b/bme-chat-manager.js new file mode 100644 index 0000000..2278617 --- /dev/null +++ b/bme-chat-manager.js @@ -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 = ""; + } +} diff --git a/bme-db.js b/bme-db.js new file mode 100644 index 0000000..bab2fa6 --- /dev/null +++ b/bme-db.js @@ -0,0 +1,1350 @@ +import { createEmptyGraph, deserializeGraph } from "./graph.js"; +import { buildVectorCollectionId, normalizeGraphRuntimeState } from "./runtime-state.js"; + +const DEXIE_LOAD_PROMISE_KEY = "__stBmeDexieLoadPromise"; +const DEXIE_SCRIPT_MARKER = "data-st-bme-dexie"; +const DEXIE_SCRIPT_SOURCE = "./lib/dexie.min.js"; + +const META_DEFAULT_LAST_PROCESSED_FLOOR = -1; +const META_DEFAULT_EXTRACTION_COUNT = 0; + +export const BME_DB_SCHEMA_VERSION = 1; +export const BME_TOMBSTONE_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; +export const BME_LEGACY_RETENTION_MS = 30 * 24 * 60 * 60 * 1000; + +export const BME_RUNTIME_HISTORY_META_KEY = "runtimeHistoryState"; +export const BME_RUNTIME_VECTOR_META_KEY = "runtimeVectorIndexState"; +export const BME_RUNTIME_BATCH_JOURNAL_META_KEY = "runtimeBatchJournal"; +export const BME_RUNTIME_LAST_RECALL_META_KEY = "runtimeLastRecallResult"; +export const BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY = "runtimeLastProcessedSeq"; +export const BME_RUNTIME_GRAPH_VERSION_META_KEY = "runtimeGraphVersion"; + +export const BME_DB_TABLE_SCHEMAS = Object.freeze({ + nodes: + "&id, type, sourceFloor, archived, updatedAt, deletedAt, isEmbedded, parentId, prevId, nextId", + edges: + "&id, fromId, toId, [fromId+toId], relation, sourceFloor, updatedAt, deletedAt", + meta: "&key, updatedAt", + tombstones: "&id, kind, targetId, deletedAt, sourceDeviceId, [kind+targetId]", +}); + +function normalizeChatId(chatId) { + return String(chatId ?? "").trim(); +} + +function normalizeRecordId(value) { + return String(value ?? "").trim(); +} + +function normalizeRevision(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return Math.floor(parsed); +} + +function normalizeTimestamp(value, fallbackValue = Date.now()) { + const parsed = Number(value); + if (Number.isFinite(parsed)) { + return Math.floor(parsed); + } + return Math.floor(Number(fallbackValue) || Date.now()); +} + +function toPlainData(value, fallbackValue = null) { + if (value == null) { + return fallbackValue; + } + + if (typeof globalThis.structuredClone === "function") { + try { + return globalThis.structuredClone(value); + } catch { + // no-op + } + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return fallbackValue; + } +} + +function toArray(value) { + return Array.isArray(value) ? value : []; +} + +function toMetaMap(rows = []) { + const output = {}; + for (const row of rows) { + if (!row || typeof row !== "object") continue; + const key = normalizeRecordId(row.key); + if (!key) continue; + output[key] = row.value; + } + return output; +} + +function normalizeMode(mode = "replace") { + return String(mode || "").toLowerCase() === "merge" ? "merge" : "replace"; +} + +function sanitizeSnapshot(snapshot = {}) { + if (!snapshot || typeof snapshot !== "object" || Array.isArray(snapshot)) { + return { + meta: {}, + state: {}, + nodes: [], + edges: [], + tombstones: [], + }; + } + + const safeMeta = + snapshot.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta) + ? { ...snapshot.meta } + : {}; + const safeState = + snapshot.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state) + ? { ...snapshot.state } + : {}; + + return { + meta: safeMeta, + state: safeState, + nodes: toArray(snapshot.nodes).map((item) => ({ ...(item || {}) })), + edges: toArray(snapshot.edges).map((item) => ({ ...(item || {}) })), + tombstones: toArray(snapshot.tombstones).map((item) => ({ ...(item || {}) })), + }; +} + +function normalizeStateSnapshot(snapshot = {}) { + const state = + snapshot?.state && typeof snapshot.state === "object" && !Array.isArray(snapshot.state) + ? { ...snapshot.state } + : {}; + + return { + lastProcessedFloor: Number.isFinite(Number(state.lastProcessedFloor)) + ? Number(state.lastProcessedFloor) + : META_DEFAULT_LAST_PROCESSED_FLOOR, + extractionCount: Number.isFinite(Number(state.extractionCount)) + ? Number(state.extractionCount) + : META_DEFAULT_EXTRACTION_COUNT, + }; +} + +function normalizeNodeUpdatedAt(node = {}, fallbackNowMs = Date.now()) { + return normalizeTimestamp( + node.updatedAt ?? node.lastAccessTime ?? node.createdTime, + fallbackNowMs, + ); +} + +function normalizeEdgeUpdatedAt(edge = {}, fallbackNowMs = Date.now()) { + return normalizeTimestamp( + edge.updatedAt ?? edge.validAt ?? edge.createdTime, + fallbackNowMs, + ); +} + +function normalizeSourceFloor(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return null; + return Math.floor(parsed); +} + +function deriveNodeSourceFloor(node = {}) { + const directSourceFloor = normalizeSourceFloor(node?.sourceFloor); + if (directSourceFloor != null) return directSourceFloor; + + const seqRange = Array.isArray(node?.seqRange) ? node.seqRange : []; + const seqRangeEnd = normalizeSourceFloor(seqRange[1]); + if (seqRangeEnd != null) return seqRangeEnd; + + const seq = normalizeSourceFloor(node?.seq); + if (seq != null) return seq; + + return null; +} + +function deriveEdgeSourceFloor(edge = {}, nodeSourceFloorById = new Map()) { + const directSourceFloor = normalizeSourceFloor(edge?.sourceFloor); + if (directSourceFloor != null) return directSourceFloor; + + const seqRange = Array.isArray(edge?.seqRange) ? edge.seqRange : []; + const seqRangeEnd = normalizeSourceFloor(seqRange[1]); + if (seqRangeEnd != null) return seqRangeEnd; + + const seq = normalizeSourceFloor(edge?.seq); + if (seq != null) return seq; + + const fromFloor = normalizeSourceFloor( + nodeSourceFloorById.get(normalizeRecordId(edge?.fromId)), + ); + const toFloor = normalizeSourceFloor( + nodeSourceFloorById.get(normalizeRecordId(edge?.toId)), + ); + + if (fromFloor != null && toFloor != null) return Math.max(fromFloor, toFloor); + if (fromFloor != null) return fromFloor; + if (toFloor != null) return toFloor; + return null; +} + +export function buildSnapshotFromGraph(graph, options = {}) { + const baseSnapshot = sanitizeSnapshot(options.baseSnapshot || {}); + const nowMs = normalizeTimestamp(options.nowMs, Date.now()); + const chatId = + normalizeChatId(options.chatId) || + normalizeChatId(graph?.historyState?.chatId) || + normalizeChatId(baseSnapshot.meta?.chatId); + + const graphInput = toPlainData(graph, createEmptyGraph()); + if (!graphInput.historyState || typeof graphInput.historyState !== "object") { + graphInput.historyState = {}; + } + if (!graphInput.vectorIndexState || typeof graphInput.vectorIndexState !== "object") { + graphInput.vectorIndexState = {}; + } + if (chatId) { + graphInput.historyState.chatId = chatId; + } + graphInput.vectorIndexState.collectionId = buildVectorCollectionId( + chatId || graphInput.historyState.chatId || "", + ); + const runtimeGraph = normalizeGraphRuntimeState(graphInput, chatId); + + const nodes = toArray(runtimeGraph?.nodes) + .map((node) => { + if (!node || typeof node !== "object" || Array.isArray(node)) return null; + const id = normalizeRecordId(node.id); + if (!id) return null; + return { + ...node, + id, + updatedAt: normalizeNodeUpdatedAt(node, nowMs), + }; + }) + .filter(Boolean); + + const edges = toArray(runtimeGraph?.edges) + .map((edge) => { + if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null; + const id = normalizeRecordId(edge.id); + if (!id) return null; + return { + ...edge, + id, + fromId: normalizeRecordId(edge.fromId), + toId: normalizeRecordId(edge.toId), + updatedAt: normalizeEdgeUpdatedAt(edge, nowMs), + }; + }) + .filter(Boolean); + + const tombstones = toArray(options.tombstones ?? baseSnapshot.tombstones) + .map((record) => { + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + const id = normalizeRecordId(record.id); + if (!id) return null; + return { + ...record, + id, + kind: normalizeRecordId(record.kind), + targetId: normalizeRecordId(record.targetId), + sourceDeviceId: normalizeRecordId(record.sourceDeviceId), + deletedAt: normalizeTimestamp(record.deletedAt, nowMs), + }; + }) + .filter(Boolean); + + const state = { + ...normalizeStateSnapshot(baseSnapshot), + ...(options.state || {}), + lastProcessedFloor: Number.isFinite( + Number(runtimeGraph?.historyState?.lastProcessedAssistantFloor), + ) + ? Number(runtimeGraph.historyState.lastProcessedAssistantFloor) + : Number(runtimeGraph?.lastProcessedSeq ?? META_DEFAULT_LAST_PROCESSED_FLOOR), + extractionCount: Number.isFinite(Number(runtimeGraph?.historyState?.extractionCount)) + ? Number(runtimeGraph.historyState.extractionCount) + : META_DEFAULT_EXTRACTION_COUNT, + }; + + const mergedMeta = { + ...baseSnapshot.meta, + ...(options.meta || {}), + schemaVersion: BME_DB_SCHEMA_VERSION, + chatId, + revision: normalizeRevision(options.revision ?? baseSnapshot.meta?.revision), + lastModified: normalizeTimestamp( + options.lastModified ?? baseSnapshot.meta?.lastModified, + nowMs, + ), + nodeCount: nodes.length, + edgeCount: edges.length, + tombstoneCount: tombstones.length, + [BME_RUNTIME_HISTORY_META_KEY]: toPlainData(runtimeGraph?.historyState || {}, {}), + [BME_RUNTIME_VECTOR_META_KEY]: toPlainData(runtimeGraph?.vectorIndexState || {}, {}), + [BME_RUNTIME_BATCH_JOURNAL_META_KEY]: toPlainData(runtimeGraph?.batchJournal || [], []), + [BME_RUNTIME_LAST_RECALL_META_KEY]: toPlainData( + runtimeGraph?.lastRecallResult ?? null, + null, + ), + [BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]: Number.isFinite( + Number(runtimeGraph?.lastProcessedSeq), + ) + ? Number(runtimeGraph.lastProcessedSeq) + : state.lastProcessedFloor, + [BME_RUNTIME_GRAPH_VERSION_META_KEY]: Number.isFinite(Number(runtimeGraph?.version)) + ? Number(runtimeGraph.version) + : Number(baseSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY] || 0), + }; + + return { + meta: mergedMeta, + nodes, + edges, + tombstones, + state, + }; +} + +export function buildGraphFromSnapshot(snapshot, options = {}) { + const normalizedSnapshot = sanitizeSnapshot(snapshot); + const chatId = + normalizeChatId(options.chatId) || + normalizeChatId(normalizedSnapshot.meta?.chatId) || + normalizeChatId(normalizedSnapshot.state?.chatId); + + const runtimeGraph = createEmptyGraph(); + runtimeGraph.version = Number.isFinite( + Number(normalizedSnapshot.meta?.[BME_RUNTIME_GRAPH_VERSION_META_KEY]), + ) + ? Number(normalizedSnapshot.meta[BME_RUNTIME_GRAPH_VERSION_META_KEY]) + : runtimeGraph.version; + runtimeGraph.nodes = toArray(normalizedSnapshot.nodes).map((node) => ({ ...(node || {}) })); + runtimeGraph.edges = toArray(normalizedSnapshot.edges).map((edge) => ({ ...(edge || {}) })); + runtimeGraph.batchJournal = toArray( + normalizedSnapshot.meta?.[BME_RUNTIME_BATCH_JOURNAL_META_KEY], + ); + runtimeGraph.lastRecallResult = toPlainData( + normalizedSnapshot.meta?.[BME_RUNTIME_LAST_RECALL_META_KEY], + null, + ); + + runtimeGraph.historyState = { + ...(runtimeGraph.historyState || {}), + ...(normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] || {}), + lastProcessedAssistantFloor: Number.isFinite(Number(normalizedSnapshot.state?.lastProcessedFloor)) + ? Number(normalizedSnapshot.state.lastProcessedFloor) + : Number( + normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY] + ?.lastProcessedAssistantFloor ?? META_DEFAULT_LAST_PROCESSED_FLOOR, + ), + extractionCount: Number.isFinite(Number(normalizedSnapshot.state?.extractionCount)) + ? Number(normalizedSnapshot.state.extractionCount) + : Number( + normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]?.extractionCount ?? + META_DEFAULT_EXTRACTION_COUNT, + ), + }; + runtimeGraph.vectorIndexState = { + ...(runtimeGraph.vectorIndexState || {}), + ...(normalizedSnapshot.meta?.[BME_RUNTIME_VECTOR_META_KEY] || {}), + collectionId: buildVectorCollectionId( + chatId || + normalizedSnapshot.meta?.[BME_RUNTIME_HISTORY_META_KEY]?.chatId || + runtimeGraph.historyState?.chatId || + "", + ), + }; + + runtimeGraph.lastProcessedSeq = Number.isFinite( + Number(normalizedSnapshot.meta?.[BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]), + ) + ? Number(normalizedSnapshot.meta[BME_RUNTIME_LAST_PROCESSED_SEQ_META_KEY]) + : Number(runtimeGraph.historyState.lastProcessedAssistantFloor); + + return normalizeGraphRuntimeState(runtimeGraph, chatId); +} + +async function loadDexieFromNodeFallback() { + try { + const imported = await import("dexie"); + const DexieCtor = imported?.default || imported?.Dexie || imported; + if (typeof DexieCtor === "function") { + globalThis.Dexie = DexieCtor; + return DexieCtor; + } + } catch { + // ignore and continue to throw below. + } + + throw new Error("Dexie 不可用(Node 环境缺少 dexie 依赖)"); +} + +async function loadDexieByScriptInjection() { + const scriptUrl = new URL(DEXIE_SCRIPT_SOURCE, import.meta.url).toString(); + const doc = globalThis.document; + if (!doc || typeof doc.createElement !== "function") { + throw new Error("document 不可用,无法注入 Dexie 脚本"); + } + + await new Promise((resolve, reject) => { + const existingScript = doc.querySelector?.(`script[${DEXIE_SCRIPT_MARKER}="true"]`); + if (existingScript) { + existingScript.addEventListener("load", () => resolve(), { once: true }); + existingScript.addEventListener( + "error", + () => reject(new Error("Dexie 脚本加载失败")), + { once: true }, + ); + + // 兼容脚本已经加载完成的情况 + if (globalThis.Dexie) { + resolve(); + } + return; + } + + const script = doc.createElement("script"); + script.async = true; + script.src = scriptUrl; + script.setAttribute(DEXIE_SCRIPT_MARKER, "true"); + script.addEventListener("load", () => resolve(), { once: true }); + script.addEventListener( + "error", + () => reject(new Error(`Dexie 脚本加载失败: ${scriptUrl}`)), + { once: true }, + ); + + const mountTarget = doc.head || doc.documentElement || doc.body; + if (!mountTarget) { + reject(new Error("无法找到可用的脚本挂载节点")); + return; + } + mountTarget.appendChild(script); + }); + + if (!globalThis.Dexie) { + throw new Error("Dexie 脚本已加载但 window.Dexie 不可用"); + } + + return globalThis.Dexie; +} + +export async function ensureDexieLoaded() { + if (globalThis.Dexie) { + return globalThis.Dexie; + } + + if (!globalThis[DEXIE_LOAD_PROMISE_KEY]) { + globalThis[DEXIE_LOAD_PROMISE_KEY] = (async () => { + if (globalThis.Dexie) { + return globalThis.Dexie; + } + + if (typeof globalThis.document === "undefined") { + return await loadDexieFromNodeFallback(); + } + + return await loadDexieByScriptInjection(); + })() + .then((DexieCtor) => { + globalThis.Dexie = DexieCtor; + return DexieCtor; + }) + .catch((error) => { + console.warn("[ST-BME] Dexie 加载失败:", error); + throw error; + }) + .finally(() => { + if (!globalThis.Dexie) { + delete globalThis[DEXIE_LOAD_PROMISE_KEY]; + } + }); + } + + return await globalThis[DEXIE_LOAD_PROMISE_KEY]; +} + +export function buildBmeDbName(chatId) { + const normalizedChatId = normalizeChatId(chatId); + return `STBME_${normalizedChatId}`; +} + +export class BmeDatabase { + constructor(chatId, options = {}) { + this.chatId = normalizeChatId(chatId); + this.dbName = buildBmeDbName(this.chatId); + this.options = { + dexieClass: options.dexieClass || null, + }; + + this.db = null; + this._openPromise = null; + } + + async open() { + if (this.db?.isOpen?.()) { + return this.db; + } + + if (!this._openPromise) { + this._openPromise = (async () => { + const DexieCtor = + this.options.dexieClass || globalThis.Dexie || (await ensureDexieLoaded()); + if (typeof DexieCtor !== "function") { + throw new Error("Dexie 构造函数不可用"); + } + + const db = new DexieCtor(this.dbName); + db.version(BME_DB_SCHEMA_VERSION).stores(BME_DB_TABLE_SCHEMAS); + await db.open(); + + this.db = db; + await this._ensureMetaDefaults(); + return db; + })().catch((error) => { + try { + this.db?.close?.(); + } catch { + // noop + } + this.db = null; + this._openPromise = null; + throw error; + }); + } + + return await this._openPromise; + } + + async close() { + try { + this.db?.close?.(); + } finally { + this.db = null; + this._openPromise = null; + } + } + + async getMeta(key, fallbackValue = null) { + const db = await this.open(); + const normalizedKey = normalizeRecordId(key); + if (!normalizedKey) return fallbackValue; + + const row = await db.table("meta").get(normalizedKey); + if (!row || !("value" in row)) return fallbackValue; + return row.value; + } + + async setMeta(key, value) { + const db = await this.open(); + const normalizedKey = normalizeRecordId(key); + if (!normalizedKey) return null; + + const nowMs = Date.now(); + const record = { + key: normalizedKey, + value: toPlainData(value, value), + updatedAt: nowMs, + }; + + await db.table("meta").put(record); + return record; + } + + async patchMeta(record) { + if (!record || typeof record !== "object" || Array.isArray(record)) { + return {}; + } + + const db = await this.open(); + const nowMs = Date.now(); + const entries = Object.entries(record).filter(([key]) => normalizeRecordId(key)); + + if (!entries.length) { + return {}; + } + + await db.transaction("rw", db.table("meta"), async () => { + for (const [key, value] of entries) { + await this._setMetaInTx(db, key, value, nowMs); + } + }); + + return Object.fromEntries(entries); + } + + async getRevision() { + const revision = await this.getMeta("revision", 0); + return normalizeRevision(revision); + } + + async bumpRevision(reason = "mutation") { + const db = await this.open(); + let nextRevision = 0; + + await db.transaction("rw", db.table("meta"), async () => { + nextRevision = await this._bumpRevisionInTx(db, reason, Date.now()); + }); + + return nextRevision; + } + + async markSyncDirty(reason = "mutation") { + const db = await this.open(); + const nowMs = Date.now(); + await db.transaction("rw", db.table("meta"), async () => { + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", String(reason || "mutation"), nowMs); + }); + return true; + } + + async bulkUpsertNodes(nodes = []) { + const records = this._normalizeNodeRecords(nodes); + if (!records.length) { + return { + upserted: 0, + revision: await this.getRevision(), + }; + } + + const db = await this.open(); + const nowMs = Date.now(); + let nextRevision = 0; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + await db.table("nodes").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertNodes", nowMs); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertNodes", nowMs); + }, + ); + + return { + upserted: records.length, + revision: nextRevision, + }; + } + + async bulkUpsertEdges(edges = []) { + const records = this._normalizeEdgeRecords(edges); + if (!records.length) { + return { + upserted: 0, + revision: await this.getRevision(), + }; + } + + const db = await this.open(); + const nowMs = Date.now(); + let nextRevision = 0; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + await db.table("edges").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertEdges", nowMs); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertEdges", nowMs); + }, + ); + + return { + upserted: records.length, + revision: nextRevision, + }; + } + + async bulkUpsertTombstones(tombstones = []) { + const records = this._normalizeTombstoneRecords(tombstones); + if (!records.length) { + return { + upserted: 0, + revision: await this.getRevision(), + }; + } + + const db = await this.open(); + const nowMs = Date.now(); + let nextRevision = 0; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + await db.table("tombstones").bulkPut(records); + await this._updateCountMetaInTx(db, nowMs); + nextRevision = await this._bumpRevisionInTx(db, "bulkUpsertTombstones", nowMs); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "bulkUpsertTombstones", nowMs); + }, + ); + + return { + upserted: records.length, + revision: nextRevision, + }; + } + + async listNodes(options = {}) { + const db = await this.open(); + const includeDeleted = options.includeDeleted !== false; + const includeArchived = options.includeArchived !== false; + + let records = await db.table("nodes").toArray(); + + if (!includeDeleted) { + records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt))); + } + + if (!includeArchived) { + records = records.filter((item) => !item?.archived); + } + + if (typeof options.type === "string" && options.type.trim()) { + records = records.filter((item) => String(item?.type || "") === options.type); + } + + return this._applyListOptions(records, options); + } + + async listEdges(options = {}) { + const db = await this.open(); + const includeDeleted = options.includeDeleted !== false; + + let records = await db.table("edges").toArray(); + + if (!includeDeleted) { + records = records.filter((item) => !Number.isFinite(Number(item?.deletedAt))); + } + + if (typeof options.relation === "string" && options.relation.trim()) { + records = records.filter( + (item) => String(item?.relation || "") === options.relation, + ); + } + + return this._applyListOptions(records, options); + } + + async listTombstones(options = {}) { + const db = await this.open(); + let records = await db.table("tombstones").toArray(); + + if (typeof options.kind === "string" && options.kind.trim()) { + records = records.filter((item) => String(item?.kind || "") === options.kind); + } + + if (typeof options.targetId === "string" && options.targetId.trim()) { + records = records.filter( + (item) => String(item?.targetId || "") === options.targetId, + ); + } + + return this._applyListOptions(records, options); + } + + async isEmpty(options = {}) { + const db = await this.open(); + const includeTombstones = options.includeTombstones === true; + + const [nodes, edges, tombstones] = await db.transaction( + "r", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + async () => + await Promise.all([ + db.table("nodes").count(), + db.table("edges").count(), + db.table("tombstones").count(), + ]), + ); + + const empty = includeTombstones + ? nodes === 0 && edges === 0 && tombstones === 0 + : nodes === 0 && edges === 0; + + return { + empty, + nodes, + edges, + tombstones, + includeTombstones, + }; + } + + async importLegacyGraph(legacyGraph, options = {}) { + const db = await this.open(); + const nowMs = normalizeTimestamp(options.nowMs, Date.now()); + const migrationSource = + normalizeRecordId(options.source || "chat_metadata") || "chat_metadata"; + const requestedRetentionMs = Number(options.legacyRetentionMs); + const legacyRetentionMs = + Number.isFinite(requestedRetentionMs) && requestedRetentionMs >= 0 + ? Math.floor(requestedRetentionMs) + : BME_LEGACY_RETENTION_MS; + const legacyRetentionUntil = nowMs + legacyRetentionMs; + + const runtimeLegacyGraph = normalizeGraphRuntimeState( + deserializeGraph(toPlainData(legacyGraph, createEmptyGraph())), + this.chatId, + ); + const snapshot = buildSnapshotFromGraph(runtimeLegacyGraph, { + chatId: this.chatId, + nowMs, + revision: normalizeRevision( + options.revision ?? runtimeLegacyGraph?.__stBmePersistence?.revision, + ), + meta: { + migrationCompletedAt: nowMs, + migrationSource, + legacyRetentionUntil, + }, + }); + + const nodeSourceFloorById = new Map(); + const nodes = this._normalizeNodeRecords(snapshot.nodes, nowMs).map((node) => { + const sourceFloor = deriveNodeSourceFloor(node); + nodeSourceFloorById.set(node.id, sourceFloor); + return sourceFloor == null ? node : { ...node, sourceFloor }; + }); + const edges = this._normalizeEdgeRecords(snapshot.edges, nowMs).map((edge) => { + const sourceFloor = deriveEdgeSourceFloor(edge, nodeSourceFloorById); + return sourceFloor == null ? edge : { ...edge, sourceFloor }; + }); + const tombstones = this._normalizeTombstoneRecords(snapshot.tombstones, nowMs); + + let migrated = false; + let skipReason = ""; + let nextRevision = await this.getRevision(); + let counts = { + nodes: 0, + edges: 0, + tombstones: 0, + }; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + const migrationCompletedAt = normalizeTimestamp( + (await db.table("meta").get("migrationCompletedAt"))?.value, + 0, + ); + if (migrationCompletedAt > 0) { + skipReason = "migration-already-completed"; + nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + counts = { + nodes: await db.table("nodes").count(), + edges: await db.table("edges").count(), + tombstones: await db.table("tombstones").count(), + }; + return; + } + + const [nodeCount, edgeCount] = await Promise.all([ + db.table("nodes").count(), + db.table("edges").count(), + ]); + if (nodeCount > 0 || edgeCount > 0) { + skipReason = "indexeddb-not-empty"; + nextRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + counts = { + nodes: nodeCount, + edges: edgeCount, + tombstones: await db.table("tombstones").count(), + }; + return; + } + + await Promise.all([ + db.table("nodes").clear(), + db.table("edges").clear(), + db.table("tombstones").clear(), + ]); + + if (nodes.length) { + await db.table("nodes").bulkPut(nodes); + } + if (edges.length) { + await db.table("edges").bulkPut(edges); + } + if (tombstones.length) { + await db.table("tombstones").bulkPut(tombstones); + } + + const metaPatch = { + ...snapshot.meta, + ...(snapshot.state || {}), + chatId: this.chatId, + schemaVersion: BME_DB_SCHEMA_VERSION, + migrationCompletedAt: nowMs, + migrationSource, + legacyRetentionUntil, + }; + + delete metaPatch.revision; + + for (const [key, value] of Object.entries(metaPatch)) { + if (!normalizeRecordId(key)) continue; + await this._setMetaInTx(db, key, value, nowMs); + } + + counts = await this._updateCountMetaInTx(db, nowMs); + + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); + const incomingRevision = normalizeRevision(snapshot.meta?.revision); + const explicitRevision = normalizeRevision(options.revision); + const requestedRevision = Number.isFinite(Number(options.revision)) + ? explicitRevision + : Math.max(incomingRevision, 1); + + nextRevision = Math.max(currentRevision + 1, requestedRevision, 1); + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "lastModified", nowMs, nowMs); + await this._setMetaInTx(db, "lastMutationReason", "importLegacyGraph", nowMs); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "legacy-migration", nowMs); + + migrated = true; + }, + ); + + return { + migrated, + skipped: !migrated, + reason: migrated ? "migrated" : skipReason || "migration-skipped", + revision: nextRevision, + imported: toPlainData(counts, counts), + migrationCompletedAt: migrated + ? nowMs + : normalizeTimestamp(await this.getMeta("migrationCompletedAt", 0), 0), + migrationSource, + legacyRetentionUntil, + }; + } + + async exportSnapshot() { + const db = await this.open(); + + const [nodes, edges, tombstones, metaRows] = await db.transaction( + "r", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => + await Promise.all([ + db.table("nodes").toArray(), + db.table("edges").toArray(), + db.table("tombstones").toArray(), + db.table("meta").toArray(), + ]), + ); + + const meta = { + ...toMetaMap(metaRows), + schemaVersion: BME_DB_SCHEMA_VERSION, + chatId: this.chatId, + revision: normalizeRevision(toMetaMap(metaRows)?.revision), + nodeCount: nodes.length, + edgeCount: edges.length, + tombstoneCount: tombstones.length, + }; + + const state = { + lastProcessedFloor: Number.isFinite(Number(meta.lastProcessedFloor)) + ? Number(meta.lastProcessedFloor) + : META_DEFAULT_LAST_PROCESSED_FLOOR, + extractionCount: Number.isFinite(Number(meta.extractionCount)) + ? Number(meta.extractionCount) + : META_DEFAULT_EXTRACTION_COUNT, + }; + + return { + meta, + nodes: toPlainData(nodes, []), + edges: toPlainData(edges, []), + tombstones: toPlainData(tombstones, []), + state, + }; + } + + async importSnapshot(snapshot, options = {}) { + const db = await this.open(); + const normalizedSnapshot = sanitizeSnapshot(snapshot); + const mode = normalizeMode(options.mode); + const shouldMarkSyncDirty = options.markSyncDirty !== false; + const nowMs = Date.now(); + + let nextRevision = 0; + let counts = { + nodes: 0, + edges: 0, + tombstones: 0, + }; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + if (mode === "replace") { + await Promise.all([ + db.table("nodes").clear(), + db.table("edges").clear(), + db.table("tombstones").clear(), + ]); + } + + const nodes = this._normalizeNodeRecords(normalizedSnapshot.nodes, nowMs); + const edges = this._normalizeEdgeRecords(normalizedSnapshot.edges, nowMs); + const tombstones = this._normalizeTombstoneRecords( + normalizedSnapshot.tombstones, + nowMs, + ); + + if (nodes.length) { + await db.table("nodes").bulkPut(nodes); + } + if (edges.length) { + await db.table("edges").bulkPut(edges); + } + if (tombstones.length) { + await db.table("tombstones").bulkPut(tombstones); + } + + const metaPatch = { + ...normalizedSnapshot.meta, + ...(normalizedSnapshot.state || {}), + chatId: this.chatId, + schemaVersion: BME_DB_SCHEMA_VERSION, + }; + + delete metaPatch.revision; + + for (const [key, value] of Object.entries(metaPatch)) { + if (!normalizeRecordId(key)) continue; + await this._setMetaInTx(db, key, value, nowMs); + } + + counts = await this._updateCountMetaInTx(db, nowMs); + + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); + const incomingRevision = normalizeRevision(normalizedSnapshot.meta?.revision); + const explicitRevision = normalizeRevision(options.revision); + const requestedRevision = Number.isFinite(Number(options.revision)) + ? explicitRevision + : options.preserveRevision + ? incomingRevision + : currentRevision + 1; + + nextRevision = Math.max(currentRevision + 1, requestedRevision); + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "lastModified", nowMs, nowMs); + await this._setMetaInTx(db, "lastMutationReason", "importSnapshot", nowMs); + + await this._setMetaInTx(db, "syncDirty", shouldMarkSyncDirty, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "importSnapshot", nowMs); + }, + ); + + return { + mode, + revision: nextRevision, + imported: { + nodes: counts.nodes, + edges: counts.edges, + tombstones: counts.tombstones, + }, + }; + } + + async clearAll() { + const db = await this.open(); + const nowMs = Date.now(); + let nextRevision = 0; + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + await Promise.all([ + db.table("nodes").clear(), + db.table("edges").clear(), + db.table("tombstones").clear(), + ]); + + const currentRevision = normalizeRevision( + (await db.table("meta").get("revision"))?.value, + ); + nextRevision = currentRevision + 1; + + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "chatId", this.chatId, nowMs); + await this._setMetaInTx(db, "schemaVersion", BME_DB_SCHEMA_VERSION, nowMs); + await this._setMetaInTx(db, "nodeCount", 0, nowMs); + await this._setMetaInTx(db, "edgeCount", 0, nowMs); + await this._setMetaInTx(db, "tombstoneCount", 0, nowMs); + await this._setMetaInTx( + db, + "lastProcessedFloor", + META_DEFAULT_LAST_PROCESSED_FLOOR, + nowMs, + ); + await this._setMetaInTx( + db, + "extractionCount", + META_DEFAULT_EXTRACTION_COUNT, + nowMs, + ); + await this._setMetaInTx(db, "lastModified", nowMs, nowMs); + await this._setMetaInTx(db, "lastMutationReason", "clearAll", nowMs); + await this._setMetaInTx(db, "syncDirty", true, nowMs); + await this._setMetaInTx(db, "syncDirtyReason", "clearAll", nowMs); + }, + ); + + return { + cleared: true, + revision: nextRevision, + }; + } + + async pruneExpiredTombstones(nowMs = Date.now()) { + const db = await this.open(); + const normalizedNow = normalizeTimestamp(nowMs, Date.now()); + const cutoffMs = normalizedNow - BME_TOMBSTONE_RETENTION_MS; + + let removedCount = 0; + let nextRevision = await this.getRevision(); + + await db.transaction( + "rw", + db.table("nodes"), + db.table("edges"), + db.table("tombstones"), + db.table("meta"), + async () => { + const staleIds = await db + .table("tombstones") + .where("deletedAt") + .below(cutoffMs) + .primaryKeys(); + + if (!staleIds.length) { + return; + } + + await db.table("tombstones").bulkDelete(staleIds); + removedCount = staleIds.length; + + await this._updateCountMetaInTx(db, normalizedNow); + nextRevision = await this._bumpRevisionInTx( + db, + "pruneExpiredTombstones", + normalizedNow, + ); + await this._setMetaInTx(db, "syncDirty", true, normalizedNow); + await this._setMetaInTx( + db, + "syncDirtyReason", + "pruneExpiredTombstones", + normalizedNow, + ); + }, + ); + + return { + pruned: removedCount, + revision: nextRevision, + cutoffMs, + }; + } + + async _ensureMetaDefaults() { + const db = await this.open(); + const nowMs = Date.now(); + const defaultMeta = { + chatId: this.chatId, + revision: 0, + lastProcessedFloor: META_DEFAULT_LAST_PROCESSED_FLOOR, + extractionCount: META_DEFAULT_EXTRACTION_COUNT, + lastModified: nowMs, + lastSyncUploadedAt: 0, + lastSyncDownloadedAt: 0, + lastSyncedRevision: 0, + deviceId: "", + nodeCount: 0, + edgeCount: 0, + tombstoneCount: 0, + schemaVersion: BME_DB_SCHEMA_VERSION, + syncDirty: false, + migrationCompletedAt: 0, + migrationSource: "", + legacyRetentionUntil: 0, + }; + + await db.transaction("rw", db.table("meta"), async () => { + for (const [key, value] of Object.entries(defaultMeta)) { + const existing = await db.table("meta").get(key); + if (existing && "value" in existing) continue; + await this._setMetaInTx(db, key, value, nowMs); + } + }); + } + + async _setMetaInTx(db, key, value, nowMs = Date.now()) { + const normalizedKey = normalizeRecordId(key); + if (!normalizedKey) return; + + await db.table("meta").put({ + key: normalizedKey, + value: toPlainData(value, value), + updatedAt: normalizeTimestamp(nowMs, Date.now()), + }); + } + + async _bumpRevisionInTx(db, reason = "mutation", nowMs = Date.now()) { + const currentRevision = normalizeRevision((await db.table("meta").get("revision"))?.value); + const nextRevision = currentRevision + 1; + + await this._setMetaInTx(db, "revision", nextRevision, nowMs); + await this._setMetaInTx(db, "lastModified", normalizeTimestamp(nowMs), nowMs); + await this._setMetaInTx(db, "lastMutationReason", String(reason || "mutation"), nowMs); + + return nextRevision; + } + + async _updateCountMetaInTx(db, nowMs = Date.now()) { + const [nodes, edges, tombstones] = await Promise.all([ + db.table("nodes").count(), + db.table("edges").count(), + db.table("tombstones").count(), + ]); + + await this._setMetaInTx(db, "nodeCount", nodes, nowMs); + await this._setMetaInTx(db, "edgeCount", edges, nowMs); + await this._setMetaInTx(db, "tombstoneCount", tombstones, nowMs); + + return { + nodes, + edges, + tombstones, + }; + } + + _applyListOptions(records, options = {}) { + let nextRecords = toArray(records); + + const orderBy = String(options.orderBy || "updatedAt").trim(); + const reverse = options.reverse !== false; + + nextRecords = nextRecords.sort((left, right) => { + const leftValue = Number(left?.[orderBy]); + const rightValue = Number(right?.[orderBy]); + if (!Number.isFinite(leftValue) && !Number.isFinite(rightValue)) return 0; + if (!Number.isFinite(leftValue)) return reverse ? 1 : -1; + if (!Number.isFinite(rightValue)) return reverse ? -1 : 1; + return reverse ? rightValue - leftValue : leftValue - rightValue; + }); + + const limit = Number(options.limit); + if (Number.isFinite(limit) && limit > 0) { + nextRecords = nextRecords.slice(0, Math.floor(limit)); + } + + return toPlainData(nextRecords, []); + } + + _normalizeNodeRecords(nodes = [], fallbackNowMs = Date.now()) { + const nowMs = normalizeTimestamp(fallbackNowMs); + return toArray(nodes) + .map((node) => { + if (!node || typeof node !== "object" || Array.isArray(node)) return null; + const id = normalizeRecordId(node.id); + if (!id) return null; + + return { + ...node, + id, + updatedAt: normalizeTimestamp(node.updatedAt, nowMs), + }; + }) + .filter(Boolean); + } + + _normalizeEdgeRecords(edges = [], fallbackNowMs = Date.now()) { + const nowMs = normalizeTimestamp(fallbackNowMs); + return toArray(edges) + .map((edge) => { + if (!edge || typeof edge !== "object" || Array.isArray(edge)) return null; + const id = normalizeRecordId(edge.id); + if (!id) return null; + + return { + ...edge, + id, + fromId: normalizeRecordId(edge.fromId), + toId: normalizeRecordId(edge.toId), + updatedAt: normalizeTimestamp(edge.updatedAt, nowMs), + }; + }) + .filter(Boolean); + } + + _normalizeTombstoneRecords(tombstones = [], fallbackNowMs = Date.now()) { + const nowMs = normalizeTimestamp(fallbackNowMs); + return toArray(tombstones) + .map((record) => { + if (!record || typeof record !== "object" || Array.isArray(record)) return null; + + const id = normalizeRecordId(record.id); + if (!id) return null; + + return { + ...record, + id, + kind: normalizeRecordId(record.kind), + targetId: normalizeRecordId(record.targetId), + sourceDeviceId: normalizeRecordId(record.sourceDeviceId), + deletedAt: normalizeTimestamp(record.deletedAt, nowMs), + }; + }) + .filter(Boolean); + } +} diff --git a/bme-sync.js b/bme-sync.js new file mode 100644 index 0000000..f383248 --- /dev/null +++ b/bme-sync.js @@ -0,0 +1,1017 @@ +const BME_SYNC_FILE_PREFIX = "ST-BME_sync_"; +const BME_SYNC_FILE_SUFFIX = ".json"; + +export const BME_SYNC_DEVICE_ID_KEY = "st_bme_sync_device_id_v1"; +export const BME_SYNC_UPLOAD_DEBOUNCE_MS = 2500; + +const syncInFlightByChatId = new Map(); +const uploadDebounceTimerByChatId = new Map(); +const sanitizedFilenameByChatId = new Map(); + +let visibilitySyncInstalled = false; +let lastVisibilityState = "visible"; + +function normalizeChatId(chatId) { + return String(chatId ?? "").trim(); +} + +function normalizeRevision(value) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) return 0; + return Math.floor(parsed); +} + +function normalizeTimestamp(value, fallback = Date.now()) { + const parsed = Number(value); + if (!Number.isFinite(parsed)) return Math.floor(Number(fallback) || Date.now()); + return Math.floor(parsed); +} + +function sanitizeSnapshotRecordArray(records) { + return Array.isArray(records) + ? records + .filter((item) => item && typeof item === "object" && !Array.isArray(item)) + .map((item) => ({ ...item })) + : []; +} + +function toSerializableData(value, fallback = null) { + if (value == null) return fallback; + + if (typeof globalThis.structuredClone === "function") { + try { + return globalThis.structuredClone(value); + } catch { + // no-op + } + } + + try { + return JSON.parse(JSON.stringify(value)); + } catch { + return fallback; + } +} + +function getStorage() { + const storage = globalThis.localStorage; + if (!storage || typeof storage.getItem !== "function" || typeof storage.setItem !== "function") { + return null; + } + return storage; +} + +function getRandomBytes(size = 16) { + if (globalThis.crypto?.getRandomValues) { + const buffer = new Uint8Array(size); + globalThis.crypto.getRandomValues(buffer); + return buffer; + } + + const fallback = new Uint8Array(size); + for (let index = 0; index < size; index++) { + fallback[index] = Math.floor(Math.random() * 256); + } + return fallback; +} + +function createFallbackDeviceId() { + const bytes = getRandomBytes(16); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function encodeBase64Utf8(text) { + const normalizedText = String(text ?? ""); + + if (typeof globalThis.btoa === "function" && typeof globalThis.TextEncoder === "function") { + const bytes = new TextEncoder().encode(normalizedText); + const chunkSize = 0x8000; + let binary = ""; + for (let index = 0; index < bytes.length; index += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(index, index + chunkSize)); + } + return globalThis.btoa(binary); + } + + if (typeof Buffer !== "undefined") { + return Buffer.from(normalizedText, "utf8").toString("base64"); + } + + throw new Error("当前环境缺少 base64 编码能力"); +} + +function decodeBase64Utf8(base64Text) { + const normalizedBase64 = String(base64Text ?? ""); + + if (typeof globalThis.atob === "function" && typeof globalThis.TextDecoder === "function") { + const binary = globalThis.atob(normalizedBase64); + const bytes = Uint8Array.from(binary, (char) => char.charCodeAt(0)); + return new TextDecoder().decode(bytes); + } + + if (typeof Buffer !== "undefined") { + return Buffer.from(normalizedBase64, "base64").toString("utf8"); + } + + throw new Error("当前环境缺少 base64 解码能力"); +} + +function getFetch(options = {}) { + const fetchImpl = options.fetch || globalThis.fetch; + if (typeof fetchImpl !== "function") { + throw new Error("fetch 不可用,无法执行 ST-BME 同步请求"); + } + return fetchImpl; +} + +function getRequestHeadersSafe(options = {}) { + if (typeof options.getRequestHeaders === "function") { + try { + return options.getRequestHeaders() || {}; + } catch (error) { + console.warn("[ST-BME] 读取请求头失败,回退为空请求头:", error); + return {}; + } + } + return {}; +} + +function normalizeSyncSnapshot(snapshot = {}, chatId = "") { + const normalizedChatId = normalizeChatId(chatId || snapshot?.meta?.chatId); + const nowMs = Date.now(); + + const nodes = sanitizeSnapshotRecordArray(snapshot?.nodes); + const edges = sanitizeSnapshotRecordArray(snapshot?.edges); + const tombstones = sanitizeSnapshotRecordArray(snapshot?.tombstones); + + const state = { + lastProcessedFloor: Number.isFinite(Number(snapshot?.state?.lastProcessedFloor)) + ? Number(snapshot.state.lastProcessedFloor) + : -1, + extractionCount: Number.isFinite(Number(snapshot?.state?.extractionCount)) + ? Number(snapshot.state.extractionCount) + : 0, + }; + + const incomingMeta = + snapshot?.meta && typeof snapshot.meta === "object" && !Array.isArray(snapshot.meta) + ? { ...snapshot.meta } + : {}; + + const meta = { + ...incomingMeta, + schemaVersion: Number.isFinite(Number(incomingMeta.schemaVersion)) + ? Number(incomingMeta.schemaVersion) + : 1, + chatId: normalizedChatId, + deviceId: String(incomingMeta.deviceId || "").trim(), + revision: normalizeRevision(incomingMeta.revision), + lastModified: normalizeTimestamp(incomingMeta.lastModified, nowMs), + nodeCount: nodes.length, + edgeCount: edges.length, + tombstoneCount: tombstones.length, + }; + + return { + meta, + nodes, + edges, + tombstones, + state, + }; +} + +function createRecordWinnerByUpdatedAt(localRecord, remoteRecord) { + if (!localRecord) return remoteRecord || null; + if (!remoteRecord) return localRecord || null; + + const localUpdatedAt = normalizeTimestamp(localRecord.updatedAt, 0); + const remoteUpdatedAt = normalizeTimestamp(remoteRecord.updatedAt, 0); + + if (remoteUpdatedAt > localUpdatedAt) { + return remoteRecord; + } + + if (localUpdatedAt > remoteUpdatedAt) { + return localRecord; + } + + return remoteRecord; +} + +function buildTombstoneIndex(tombstones = []) { + const tombstoneById = new Map(); + const tombstoneByTarget = new Map(); + + for (const tombstone of tombstones) { + if (!tombstone || typeof tombstone !== "object") continue; + + const normalizedTombstone = { + ...tombstone, + id: String(tombstone.id || "").trim(), + kind: String(tombstone.kind || "").trim(), + targetId: String(tombstone.targetId || "").trim(), + sourceDeviceId: String(tombstone.sourceDeviceId || "").trim(), + deletedAt: normalizeTimestamp(tombstone.deletedAt, 0), + }; + + if (!normalizedTombstone.id) continue; + + const existingById = tombstoneById.get(normalizedTombstone.id); + if (!existingById || normalizedTombstone.deletedAt >= existingById.deletedAt) { + tombstoneById.set(normalizedTombstone.id, normalizedTombstone); + } + + if (normalizedTombstone.kind && normalizedTombstone.targetId) { + const targetKey = `${normalizedTombstone.kind}:${normalizedTombstone.targetId}`; + const existingByTarget = tombstoneByTarget.get(targetKey); + if (!existingByTarget || normalizedTombstone.deletedAt >= existingByTarget.deletedAt) { + tombstoneByTarget.set(targetKey, normalizedTombstone); + } + } + } + + return { + byId: tombstoneById, + byTarget: tombstoneByTarget, + }; +} + +function filterRecordsByTombstones(records = [], kind, tombstoneIndex) { + const normalizedKind = String(kind || "").trim(); + if (!normalizedKind || !tombstoneIndex?.byTarget) return records; + + return records.filter((record) => { + const recordId = String(record?.id || "").trim(); + if (!recordId) return false; + + const targetKey = `${normalizedKind}:${recordId}`; + const tombstone = tombstoneIndex.byTarget.get(targetKey); + if (!tombstone) return true; + + const deletedAt = normalizeTimestamp(tombstone.deletedAt, 0); + const updatedAt = normalizeTimestamp(record?.updatedAt, 0); + return deletedAt <= updatedAt; + }); +} + +function mergeRecordCollectionById(localRecords = [], remoteRecords = []) { + const mergedById = new Map(); + + for (const record of localRecords) { + const id = String(record?.id || "").trim(); + if (!id) continue; + mergedById.set(id, { ...record, id }); + } + + for (const record of remoteRecords) { + const id = String(record?.id || "").trim(); + if (!id) continue; + + const localRecord = mergedById.get(id) || null; + const remoteRecord = { ...record, id }; + const winner = createRecordWinnerByUpdatedAt(localRecord, remoteRecord); + if (winner) mergedById.set(id, winner); + } + + return Array.from(mergedById.values()); +} + +async function getDb(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + throw new Error("chatId 不能为空"); + } + + if (typeof options.getDb !== "function") { + throw new Error("同步运行时缺少 getDb(chatId) 能力"); + } + + const db = await options.getDb(normalizedChatId); + if (!db || typeof db.exportSnapshot !== "function") { + throw new Error("getDb(chatId) 必须返回有效的 BmeDatabase 实例"); + } + + return db; +} + +async function patchDbMeta(db, patch = {}) { + if (!db || !patch || typeof patch !== "object") return; + if (typeof db.patchMeta === "function") { + await db.patchMeta(patch); + return; + } + + for (const [key, value] of Object.entries(patch)) { + if (typeof db.setMeta === "function") { + await db.setMeta(key, value); + } + } +} + +async function sanitizeFilename(fileName, options = {}) { + const fallbackSanitized = String(fileName || "") + .replace(/[<>:"/\\|?*\x00-\x1F]/g, "_") + .replace(/\s+/g, "_") + .replace(/^\.+/g, "") + .slice(0, 180); + + const finalFallback = fallbackSanitized || "ST-BME_sync_unknown.json"; + + if (options.disableRemoteSanitize) { + return finalFallback; + } + + try { + const fetchImpl = getFetch(options); + const response = await fetchImpl("/api/files/sanitize-filename", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ fileName }), + }); + + if (!response.ok) { + return finalFallback; + } + + const payload = await response.json().catch(() => null); + const sanitized = String(payload?.fileName || "").trim(); + return sanitized || finalFallback; + } catch { + return finalFallback; + } +} + +async function resolveSyncFilename(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + throw new Error("chatId 不能为空"); + } + + if (sanitizedFilenameByChatId.has(normalizedChatId)) { + return sanitizedFilenameByChatId.get(normalizedChatId); + } + + const rawFileName = `${BME_SYNC_FILE_PREFIX}${normalizedChatId}${BME_SYNC_FILE_SUFFIX}`; + const sanitized = await sanitizeFilename(rawFileName, options); + const finalName = sanitized || rawFileName; + sanitizedFilenameByChatId.set(normalizedChatId, finalName); + return finalName; +} + +async function readRemoteSnapshot(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + exists: false, + status: "missing-chat-id", + filename: "", + snapshot: null, + }; + } + + const filename = await resolveSyncFilename(normalizedChatId, options); + const fetchImpl = getFetch(options); + const cacheBust = `t=${Date.now()}`; + const url = `/user/files/${encodeURIComponent(filename)}?${cacheBust}`; + + let response; + try { + response = await fetchImpl(url, { + method: "GET", + cache: "no-store", + }); + } catch (error) { + console.warn("[ST-BME] 读取远端同步文件失败:", error); + return { + exists: false, + status: "network-error", + filename, + snapshot: null, + error, + }; + } + + if (response.status === 404) { + return { + exists: false, + status: "not-found", + filename, + snapshot: null, + }; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + const error = new Error(errorText || `HTTP ${response.status}`); + console.warn("[ST-BME] 读取远端同步文件失败:", error); + return { + exists: false, + status: "http-error", + filename, + snapshot: null, + error, + statusCode: response.status, + }; + } + + try { + const remotePayload = await response.json(); + const snapshot = normalizeSyncSnapshot(remotePayload, normalizedChatId); + return { + exists: true, + status: "ok", + filename, + snapshot, + }; + } catch (error) { + console.warn("[ST-BME] 解析远端同步文件失败:", error); + return { + exists: false, + status: "invalid-json", + filename, + snapshot: null, + error, + }; + } +} + +async function writeSnapshotToRemote(snapshot, chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + const normalizedSnapshot = normalizeSyncSnapshot(snapshot, normalizedChatId); + const filename = await resolveSyncFilename(normalizedChatId, options); + const fetchImpl = getFetch(options); + + const payload = { + meta: toSerializableData(normalizedSnapshot.meta, {}), + nodes: toSerializableData(normalizedSnapshot.nodes, []), + edges: toSerializableData(normalizedSnapshot.edges, []), + tombstones: toSerializableData(normalizedSnapshot.tombstones, []), + state: toSerializableData(normalizedSnapshot.state, { + lastProcessedFloor: -1, + extractionCount: 0, + }), + }; + + const response = await fetchImpl("/api/files/upload", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name: filename, + data: encodeBase64Utf8(JSON.stringify(payload, null, 2)), + }), + }); + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + const uploadResult = await response.json().catch(() => ({})); + return { + filename, + path: String(uploadResult?.path || ""), + payload, + }; +} + +function withChatSyncLock(chatId, task) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return Promise.resolve({ + synced: false, + reason: "missing-chat-id", + chatId: "", + }); + } + + if (syncInFlightByChatId.has(normalizedChatId)) { + return syncInFlightByChatId.get(normalizedChatId); + } + + const taskPromise = Promise.resolve() + .then(task) + .catch((error) => { + console.warn("[ST-BME] 同步任务失败:", error); + return { + synced: false, + chatId: normalizedChatId, + reason: "sync-error", + error, + }; + }) + .finally(() => { + if (syncInFlightByChatId.get(normalizedChatId) === taskPromise) { + syncInFlightByChatId.delete(normalizedChatId); + } + }); + + syncInFlightByChatId.set(normalizedChatId, taskPromise); + return taskPromise; +} + +export function getOrCreateDeviceId() { + const storage = getStorage(); + const existingDeviceId = String(storage?.getItem(BME_SYNC_DEVICE_ID_KEY) || "").trim(); + if (existingDeviceId) return existingDeviceId; + + const deviceId = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : createFallbackDeviceId(); + + try { + storage?.setItem(BME_SYNC_DEVICE_ID_KEY, deviceId); + } catch (error) { + console.warn("[ST-BME] 写入 deviceId 到 localStorage 失败:", error); + } + + return deviceId; +} + +export async function getRemoteStatus(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + chatId: "", + exists: false, + revision: 0, + lastModified: 0, + deviceId: "", + filename: "", + status: "missing-chat-id", + }; + } + + const remoteResult = await readRemoteSnapshot(normalizedChatId, options); + if (!remoteResult.exists || !remoteResult.snapshot) { + if (remoteResult.status !== "not-found" && remoteResult.status !== "missing-chat-id") { + console.warn("[ST-BME] 远端同步状态读取异常,已回退为可恢复状态:", { + chatId: normalizedChatId, + status: remoteResult.status, + }); + } + return { + chatId: normalizedChatId, + exists: false, + revision: 0, + lastModified: 0, + deviceId: "", + filename: remoteResult.filename || "", + status: remoteResult.status, + error: remoteResult.error || null, + }; + } + + return { + chatId: normalizedChatId, + exists: true, + revision: normalizeRevision(remoteResult.snapshot.meta?.revision), + lastModified: normalizeTimestamp(remoteResult.snapshot.meta?.lastModified, 0), + deviceId: String(remoteResult.snapshot.meta?.deviceId || "").trim(), + filename: remoteResult.filename, + status: "ok", + }; +} + +export async function upload(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + uploaded: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId); + const nowMs = Date.now(); + + const deviceId = getOrCreateDeviceId(); + localSnapshot.meta.deviceId = localSnapshot.meta.deviceId || deviceId; + localSnapshot.meta.chatId = normalizedChatId; + localSnapshot.meta.lastModified = normalizeTimestamp(localSnapshot.meta.lastModified, nowMs); + + const uploadResult = await writeSnapshotToRemote(localSnapshot, normalizedChatId, options); + + await patchDbMeta(db, { + deviceId, + lastSyncUploadedAt: nowMs, + lastSyncedRevision: normalizeRevision(localSnapshot.meta.revision), + syncDirty: false, + syncDirtyReason: "", + lastModified: localSnapshot.meta.lastModified, + }); + + return { + uploaded: true, + chatId: normalizedChatId, + filename: uploadResult.filename, + remotePath: uploadResult.path, + revision: normalizeRevision(localSnapshot.meta.revision), + }; + } catch (error) { + console.warn("[ST-BME] 上传同步文件失败:", error); + return { + uploaded: false, + chatId: normalizedChatId, + reason: "upload-error", + error, + }; + } +} + +export async function download(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + downloaded: false, + exists: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const db = await getDb(normalizedChatId, options); + const remoteResult = await readRemoteSnapshot(normalizedChatId, options); + + if (!remoteResult.exists || !remoteResult.snapshot) { + return { + downloaded: false, + exists: false, + chatId: normalizedChatId, + filename: remoteResult.filename || "", + reason: remoteResult.status || "remote-missing", + }; + } + + const remoteSnapshot = normalizeSyncSnapshot(remoteResult.snapshot, normalizedChatId); + const remoteRevision = normalizeRevision(remoteSnapshot.meta.revision); + + await db.importSnapshot(remoteSnapshot, { + mode: "replace", + preserveRevision: true, + revision: remoteRevision, + markSyncDirty: false, + }); + + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + lastSyncDownloadedAt: Date.now(), + lastSyncedRevision: remoteRevision, + syncDirty: false, + syncDirtyReason: "", + }); + + return { + downloaded: true, + exists: true, + chatId: normalizedChatId, + filename: remoteResult.filename, + revision: remoteRevision, + }; + } catch (error) { + console.warn("[ST-BME] 下载同步文件失败:", error); + return { + downloaded: false, + exists: false, + chatId: normalizedChatId, + reason: "download-error", + error, + }; + } +} + +export function mergeSnapshots(localSnapshot, remoteSnapshot, options = {}) { + const normalizedChatId = normalizeChatId(options.chatId || localSnapshot?.meta?.chatId || remoteSnapshot?.meta?.chatId); + const local = normalizeSyncSnapshot(localSnapshot, normalizedChatId); + const remote = normalizeSyncSnapshot(remoteSnapshot, normalizedChatId); + + const mergedTombstoneIndex = buildTombstoneIndex([ + ...local.tombstones, + ...remote.tombstones, + ]); + const mergedTombstones = Array.from(mergedTombstoneIndex.byId.values()); + + const mergedNodes = filterRecordsByTombstones( + mergeRecordCollectionById(local.nodes, remote.nodes), + "node", + mergedTombstoneIndex, + ); + const mergedEdges = filterRecordsByTombstones( + mergeRecordCollectionById(local.edges, remote.edges), + "edge", + mergedTombstoneIndex, + ); + + const localRevision = normalizeRevision(local.meta.revision); + const remoteRevision = normalizeRevision(remote.meta.revision); + const mergedRevision = Math.max(localRevision, remoteRevision) + 1; + + const mergedState = { + lastProcessedFloor: Math.max( + Number(local.state?.lastProcessedFloor ?? -1), + Number(remote.state?.lastProcessedFloor ?? -1), + ), + extractionCount: Math.max( + Number(local.state?.extractionCount ?? 0), + Number(remote.state?.extractionCount ?? 0), + ), + }; + + const mergedMeta = { + ...local.meta, + ...remote.meta, + schemaVersion: Math.max( + Number(local.meta?.schemaVersion || 1), + Number(remote.meta?.schemaVersion || 1), + ), + chatId: normalizedChatId, + deviceId: String(local.meta?.deviceId || remote.meta?.deviceId || getOrCreateDeviceId()).trim(), + revision: mergedRevision, + lastModified: Math.max( + normalizeTimestamp(local.meta?.lastModified, 0), + normalizeTimestamp(remote.meta?.lastModified, 0), + Date.now(), + ), + nodeCount: mergedNodes.length, + edgeCount: mergedEdges.length, + tombstoneCount: mergedTombstones.length, + }; + + return { + meta: mergedMeta, + nodes: mergedNodes, + edges: mergedEdges, + tombstones: mergedTombstones, + state: mergedState, + }; +} + +export async function syncNow(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + synced: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + return await withChatSyncLock(normalizedChatId, async () => { + const db = await getDb(normalizedChatId, options); + const localSnapshot = normalizeSyncSnapshot(await db.exportSnapshot(), normalizedChatId); + const localRevision = normalizeRevision(localSnapshot.meta.revision); + const localDirty = Boolean(await db.getMeta("syncDirty", false)); + + const remoteResult = await readRemoteSnapshot(normalizedChatId, options); + if (!remoteResult.exists || !remoteResult.snapshot) { + if (remoteResult.status !== "not-found") { + return { + synced: false, + chatId: normalizedChatId, + reason: remoteResult.status || "remote-read-error", + error: remoteResult.error || null, + }; + } + + const uploadResult = await upload(normalizedChatId, options); + return { + synced: Boolean(uploadResult.uploaded), + chatId: normalizedChatId, + action: uploadResult.uploaded ? "upload" : "none", + ...uploadResult, + }; + } + + const remoteSnapshot = normalizeSyncSnapshot(remoteResult.snapshot, normalizedChatId); + const remoteRevision = normalizeRevision(remoteSnapshot.meta.revision); + + if (remoteRevision > localRevision && !localDirty) { + const downloadResult = await download(normalizedChatId, options); + return { + synced: Boolean(downloadResult.downloaded), + chatId: normalizedChatId, + action: downloadResult.downloaded ? "download" : "none", + ...downloadResult, + }; + } + + if (localRevision > remoteRevision && !options.forceMerge) { + const uploadResult = await upload(normalizedChatId, options); + return { + synced: Boolean(uploadResult.uploaded), + chatId: normalizedChatId, + action: uploadResult.uploaded ? "upload" : "none", + ...uploadResult, + }; + } + + if (localRevision === remoteRevision && !localDirty && !options.forceMerge) { + return { + synced: true, + chatId: normalizedChatId, + action: "noop", + revision: localRevision, + }; + } + + const mergedSnapshot = mergeSnapshots(localSnapshot, remoteSnapshot, { + chatId: normalizedChatId, + }); + + await db.importSnapshot(mergedSnapshot, { + mode: "replace", + preserveRevision: true, + revision: mergedSnapshot.meta.revision, + markSyncDirty: false, + }); + + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + lastSyncDownloadedAt: Date.now(), + lastSyncedRevision: normalizeRevision(mergedSnapshot.meta.revision), + syncDirty: false, + syncDirtyReason: "", + lastProcessedFloor: mergedSnapshot.state.lastProcessedFloor, + extractionCount: mergedSnapshot.state.extractionCount, + }); + + const uploadResult = await writeSnapshotToRemote(mergedSnapshot, normalizedChatId, options); + + await patchDbMeta(db, { + lastSyncUploadedAt: Date.now(), + lastSyncedRevision: normalizeRevision(mergedSnapshot.meta.revision), + syncDirty: false, + syncDirtyReason: "", + }); + + return { + synced: true, + chatId: normalizedChatId, + action: "merge", + filename: uploadResult.filename, + remotePath: uploadResult.path, + revision: normalizeRevision(mergedSnapshot.meta.revision), + }; + }); +} + +export function scheduleUpload(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + scheduled: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + const debounceMs = Number.isFinite(Number(options.debounceMs)) + ? Math.max(0, Math.floor(Number(options.debounceMs))) + : BME_SYNC_UPLOAD_DEBOUNCE_MS; + + const previousTimer = uploadDebounceTimerByChatId.get(normalizedChatId); + if (previousTimer) { + clearTimeout(previousTimer); + } + + const timer = setTimeout(() => { + uploadDebounceTimerByChatId.delete(normalizedChatId); + withChatSyncLock(normalizedChatId, async () => await upload(normalizedChatId, options)); + }, debounceMs); + + uploadDebounceTimerByChatId.set(normalizedChatId, timer); + + return { + scheduled: true, + chatId: normalizedChatId, + debounceMs, + }; +} + +export function autoSyncOnChatChange(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return Promise.resolve({ + synced: false, + chatId: "", + reason: "missing-chat-id", + }); + } + + return syncNow(normalizedChatId, { + ...options, + trigger: options.trigger || "chat-change", + }); +} + +export function autoSyncOnVisibility(options = {}) { + if (visibilitySyncInstalled || typeof document?.addEventListener !== "function") { + return { + installed: visibilitySyncInstalled, + }; + } + + visibilitySyncInstalled = true; + lastVisibilityState = document.visibilityState || "visible"; + + document.addEventListener("visibilitychange", () => { + const currentVisibilityState = document.visibilityState || "visible"; + const becameVisible = + lastVisibilityState === "hidden" && currentVisibilityState === "visible"; + + lastVisibilityState = currentVisibilityState; + + if (!becameVisible) return; + + const chatIdResolver = + typeof options.getCurrentChatId === "function" + ? options.getCurrentChatId + : () => ""; + + const chatId = normalizeChatId(chatIdResolver()); + if (!chatId) return; + + autoSyncOnChatChange(chatId, { + ...options, + trigger: "visibility-visible", + }).catch((error) => { + console.warn("[ST-BME] visibility 自动同步失败:", error); + }); + }); + + return { + installed: true, + }; +} + +export async function deleteRemoteSyncFile(chatId, options = {}) { + const normalizedChatId = normalizeChatId(chatId); + if (!normalizedChatId) { + return { + deleted: false, + chatId: "", + reason: "missing-chat-id", + }; + } + + try { + const filename = await resolveSyncFilename(normalizedChatId, options); + const fetchImpl = getFetch(options); + const response = await fetchImpl("/api/files/delete", { + method: "POST", + headers: { + ...getRequestHeadersSafe(options), + "Content-Type": "application/json", + }, + body: JSON.stringify({ + path: `/user/files/${filename}`, + }), + }); + + if (response.status === 404) { + return { + deleted: false, + chatId: normalizedChatId, + filename, + reason: "not-found", + }; + } + + if (!response.ok) { + const errorText = await response.text().catch(() => response.statusText); + throw new Error(errorText || `HTTP ${response.status}`); + } + + return { + deleted: true, + chatId: normalizedChatId, + filename, + }; + } catch (error) { + console.warn("[ST-BME] 删除远端同步文件失败:", error); + return { + deleted: false, + chatId: normalizedChatId, + reason: "delete-error", + error, + }; + } +} + +export function __testOnlyDecodeBase64Utf8(base64Text) { + return decodeBase64Utf8(base64Text); +} diff --git a/event-binding.js b/event-binding.js index 677ce18..fcf1eaf 100644 --- a/event-binding.js +++ b/event-binding.js @@ -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(); diff --git a/index.js b/index.js index 411ae18..a848e02 100644 --- a/index.js +++ b/index.js @@ -16,6 +16,18 @@ import { saveMetadataDebounced, } from "../../../extensions.js"; +import { BmeChatManager } from "./bme-chat-manager.js"; +import { + buildGraphFromSnapshot, + buildSnapshotFromGraph, + ensureDexieLoaded, +} from "./bme-db.js"; +import { + autoSyncOnChatChange, + autoSyncOnVisibility, + scheduleUpload, + syncNow, +} from "./bme-sync.js"; import { compressAll, sleepCycle } from "./compressor.js"; import { consolidateMemories } from "./consolidator.js"; import { @@ -483,6 +495,28 @@ const stageAbortControllers = { recall: null, history: null, }; +let bmeChatManager = null; +let bmeChatManagerUnavailableWarned = false; +const bmeIndexedDbSnapshotCacheByChatId = new Map(); +const bmeIndexedDbLoadInFlightByChatId = new Map(); +const bmeIndexedDbWriteInFlightByChatId = new Map(); +const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); +const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); +const BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET = new Set([GRAPH_LOAD_STATES.LOADING, GRAPH_LOAD_STATES.BLOCKED, GRAPH_LOAD_STATES.NO_CHAT, GRAPH_LOAD_STATES.SHADOW_RESTORED]); + +function isGraphLoadStateDbReady(loadState = graphPersistenceState.loadState) { + return ( + loadState === GRAPH_LOAD_STATES.LOADED || + loadState === GRAPH_LOAD_STATES.EMPTY_CONFIRMED + ); +} + +function normalizeGraphSyncState(value = "idle") { + const normalized = String(value || "idle").trim().toLowerCase(); + if (["idle", "syncing", "warning", "error"].includes(normalized)) return normalized; + return "idle"; +} + function getGraphPersistenceLiveState() { const snapshot = { @@ -511,6 +545,18 @@ function getGraphPersistenceLiveState() { graphPersistenceState.loadState, ), updatedAt: graphPersistenceState.updatedAt, + storagePrimary: graphPersistenceState.storagePrimary || "indexeddb", + storageMode: graphPersistenceState.storageMode || "indexeddb", + dbReady: + graphPersistenceState.dbReady ?? isGraphLoadStateDbReady(graphPersistenceState.loadState), + indexedDbRevision: graphPersistenceState.indexedDbRevision || 0, + indexedDbLastError: graphPersistenceState.indexedDbLastError || "", + syncState: normalizeGraphSyncState(graphPersistenceState.syncState), + lastSyncUploadedAt: Number(graphPersistenceState.lastSyncUploadedAt) || 0, + lastSyncDownloadedAt: Number(graphPersistenceState.lastSyncDownloadedAt) || 0, + lastSyncedRevision: Number(graphPersistenceState.lastSyncedRevision) || 0, + lastSyncError: String(graphPersistenceState.lastSyncError || ""), + dualWriteLastResult: cloneRuntimeDebugValue(graphPersistenceState.dualWriteLastResult, null), }; return cloneRuntimeDebugValue(snapshot, snapshot); @@ -575,7 +621,7 @@ function createGraphLoadUiStatus() { return createUiStatus( "图谱加载中", chatId - ? `正在读取聊天 ${chatId} 的图谱元数据` + ? `正在读取聊天 ${chatId} 的 IndexedDB 图谱` : "正在等待聊天上下文准备完成", "running", ); @@ -594,7 +640,7 @@ function createGraphLoadUiStatus() { case GRAPH_LOAD_STATES.BLOCKED: return createUiStatus( "图谱加载受阻", - "聊天元数据未就绪,已暂停图谱写回以保护旧数据", + "当前图谱尚未完成 IndexedDB 初始化", "warning", ); case GRAPH_LOAD_STATES.LOADED: @@ -606,6 +652,7 @@ function createGraphLoadUiStatus() { function getPanelRuntimeStatus() { const graphStatus = createGraphLoadUiStatus(); if ( + !graphPersistenceState.dbReady || graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING || graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED || graphPersistenceState.loadState === GRAPH_LOAD_STATES.BLOCKED || @@ -617,17 +664,26 @@ function getPanelRuntimeStatus() { } function getGraphMutationBlockReason(operationLabel = "当前操作") { + const loadState = graphPersistenceState.loadState; + if (!getCurrentChatId()) { + return `${operationLabel}已暂停:当前尚未进入聊天。`; + } + + if (graphPersistenceState.dbReady || isGraphLoadStateDbReady(loadState)) { + return `${operationLabel}暂不可用。`; + } + switch (graphPersistenceState.loadState) { case GRAPH_LOAD_STATES.LOADING: - return `${operationLabel}已暂停:正在加载当前聊天图谱。`; + return `${operationLabel}已暂停:正在加载 IndexedDB 图谱。`; case GRAPH_LOAD_STATES.SHADOW_RESTORED: - return `${operationLabel}已暂停:当前图谱还在临时恢复状态,等待正式聊天元数据。`; + return `${operationLabel}已暂停:当前图谱仍处于旧恢复状态,请等待 IndexedDB 初始化完成。`; case GRAPH_LOAD_STATES.BLOCKED: - return `${operationLabel}已暂停:聊天元数据未就绪,已启用写保护。`; + return `${operationLabel}已暂停:IndexedDB 初始化受阻,请稍后重试。`; case GRAPH_LOAD_STATES.NO_CHAT: return `${operationLabel}已暂停:当前尚未进入聊天。`; default: - return `${operationLabel}暂不可用。`; + return `${operationLabel}已暂停:图谱尚未完成初始化。`; } } @@ -635,7 +691,7 @@ function ensureGraphMutationReady( operationLabel = "当前操作", { notify = true } = {}, ) { - if (isGraphMetadataWriteAllowed()) return true; + if (graphPersistenceState.dbReady || isGraphLoadStateDbReady()) return true; if (notify) { toastr.info(getGraphMutationBlockReason(operationLabel), "ST-BME"); } @@ -656,6 +712,7 @@ function applyGraphLoadState( lastPersistedRevision = graphPersistenceState.lastPersistedRevision, queuedPersistRevision = graphPersistenceState.queuedPersistRevision, pendingPersist = graphPersistenceState.pendingPersist, + dbReady = isGraphLoadStateDbReady(loadState), writesBlocked = !isGraphMetadataWriteAllowed(loadState), } = {}, ) { @@ -673,6 +730,8 @@ function applyGraphLoadState( shadowSnapshotReason, pendingPersist, writesBlocked, + dbReady, + storageMode: "indexeddb", }); } @@ -1859,6 +1918,774 @@ function getCurrentChatId(context = getContext()) { return resolveCurrentChatIdentity(context).chatId; } +function buildBmeSyncRuntimeOptions(extra = {}) { + return { + getDb: async (chatId) => { + const manager = ensureBmeChatManager(); + if (!manager) { + throw new Error("BmeChatManager 不可用"); + } + return await manager.getCurrentDb(chatId); + }, + getCurrentChatId: () => getCurrentChatId(), + getRequestHeaders, + ...extra, + }; +} + +async function syncIndexedDbMetaToPersistenceState( + chatId, + { syncState = "idle", lastSyncError = "" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + + try { + const manager = ensureBmeChatManager(); + if (!manager) return null; + const db = await manager.getCurrentDb(normalizedChatId); + const [revision, lastSyncUploadedAt, lastSyncDownloadedAt, lastSyncedRevision] = + await Promise.all([ + db.getRevision(), + db.getMeta("lastSyncUploadedAt", 0), + db.getMeta("lastSyncDownloadedAt", 0), + db.getMeta("lastSyncedRevision", 0), + ]); + + const patch = { + storagePrimary: "indexeddb", + storageMode: "indexeddb", + indexedDbRevision: normalizeIndexedDbRevision(revision), + syncState: normalizeGraphSyncState(syncState), + lastSyncUploadedAt: Number(lastSyncUploadedAt) || 0, + lastSyncDownloadedAt: Number(lastSyncDownloadedAt) || 0, + lastSyncedRevision: Number(lastSyncedRevision) || 0, + lastSyncError: String(lastSyncError || ""), + }; + + updateGraphPersistenceState(patch); + return patch; + } catch (error) { + console.warn("[ST-BME] 读取 IndexedDB 同步元数据失败:", error); + updateGraphPersistenceState({ + syncState: "error", + lastSyncError: error?.message || String(error), + }); + return null; + } +} + +async function runBmeAutoSyncForChat(source = "unknown", chatId = "") { + const normalizedChatId = String(chatId || "").trim(); + if (!normalizedChatId) return { synced: false, reason: "missing-chat-id" }; + + updateGraphPersistenceState({ + syncState: "syncing", + lastSyncError: "", + }); + + try { + const syncResult = await autoSyncOnChatChange( + normalizedChatId, + buildBmeSyncRuntimeOptions({ + trigger: source, + reason: String(source || "chat-change"), + }), + ); + + await syncIndexedDbMetaToPersistenceState(normalizedChatId, { + syncState: syncResult?.synced ? "idle" : "warning", + lastSyncError: syncResult?.error || "", + }); + + return syncResult; + } catch (error) { + await syncIndexedDbMetaToPersistenceState(normalizedChatId, { + syncState: "error", + lastSyncError: error?.message || String(error), + }); + throw error; + } +} + +function ensureBmeChatManager() { + if (typeof BmeChatManager !== "function") { + if (!bmeChatManagerUnavailableWarned) { + console.warn("[ST-BME] BmeChatManager 不可用,IndexedDB 能力暂时停用"); + bmeChatManagerUnavailableWarned = true; + } + return null; + } + + if (!bmeChatManager) { + bmeChatManager = new BmeChatManager(); + } + return bmeChatManager; +} + +function scheduleBmeIndexedDbTask(task) { + const scheduler = + typeof globalThis.queueMicrotask === "function" + ? globalThis.queueMicrotask.bind(globalThis) + : (callback) => setTimeout(callback, 0); + + scheduler(() => { + Promise.resolve() + .then(task) + .catch((error) => { + console.warn("[ST-BME] IndexedDB 后台任务失败:", error); + }); + }); +} + +async function syncBmeChatManagerWithCurrentChat( + source = "unknown", + context = getContext(), +) { + const manager = ensureBmeChatManager(); + if (!manager) { + return { + chatId: "", + opened: false, + skipped: true, + reason: "manager-unavailable", + }; + } + const chatId = getCurrentChatId(context); + + if (!chatId) { + await manager.closeCurrent(); + console.debug("[ST-BME] IndexedDB 会话已关闭(无活动聊天)", { + source, + }); + return { + chatId: "", + opened: false, + skipped: false, + }; + } + + const db = await manager.switchChat(chatId); + console.debug("[ST-BME] IndexedDB 会话已同步", { + source, + chatId, + }); + return { + chatId, + opened: Boolean(db), + skipped: false, + }; +} + +function scheduleBmeIndexedDbWarmup(source = "init") { + scheduleBmeIndexedDbTask(async () => { + await ensureDexieLoaded(); + await syncBmeChatManagerWithCurrentChat(source); + }); +} + +function normalizeIndexedDbRevision(value, fallbackValue = 0) { + const parsed = Number(value); + if (!Number.isFinite(parsed) || parsed < 0) { + return Math.max(0, Number(fallbackValue) || 0); + } + return Math.floor(parsed); +} + +function isIndexedDbSnapshotMeaningful(snapshot = null) { + if (!snapshot || typeof snapshot !== "object") return false; + + if (Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0) return true; + if (Array.isArray(snapshot.edges) && snapshot.edges.length > 0) return true; + if (Array.isArray(snapshot.tombstones) && snapshot.tombstones.length > 0) return true; + + const state = snapshot.state || {}; + if (Number.isFinite(Number(state.lastProcessedFloor)) && Number(state.lastProcessedFloor) >= 0) { + return true; + } + if (Number.isFinite(Number(state.extractionCount)) && Number(state.extractionCount) > 0) { + return true; + } + + const runtimeHistoryState = snapshot.meta?.runtimeHistoryState; + if ( + runtimeHistoryState && + typeof runtimeHistoryState === "object" && + !Array.isArray(runtimeHistoryState) + ) { + if ( + Number.isFinite(Number(runtimeHistoryState.lastProcessedAssistantFloor)) && + Number(runtimeHistoryState.lastProcessedAssistantFloor) >= 0 + ) { + return true; + } + if ( + runtimeHistoryState.processedMessageHashes && + typeof runtimeHistoryState.processedMessageHashes === "object" && + !Array.isArray(runtimeHistoryState.processedMessageHashes) && + Object.keys(runtimeHistoryState.processedMessageHashes).length > 0 + ) { + return true; + } + } + + return false; +} + +function cacheIndexedDbSnapshot(chatId, snapshot = null) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return; + bmeIndexedDbSnapshotCacheByChatId.set(normalizedChatId, { + chatId: normalizedChatId, + revision: normalizeIndexedDbRevision(snapshot?.meta?.revision), + snapshot, + updatedAt: Date.now(), + }); +} + +function readCachedIndexedDbSnapshot(chatId) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + const cacheEntry = bmeIndexedDbSnapshotCacheByChatId.get(normalizedChatId); + if (!cacheEntry?.snapshot) return null; + return cacheEntry.snapshot; +} + +function readLegacyGraphFromChatMetadata(chatId, context = getContext()) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + + const legacyGraph = context?.chatMetadata?.[GRAPH_METADATA_KEY]; + if (!legacyGraph) return null; + + try { + const hydratedLegacyGraph = + typeof legacyGraph === "string" ? deserializeGraph(legacyGraph) : legacyGraph; + return cloneGraphForPersistence( + normalizeGraphRuntimeState(hydratedLegacyGraph, normalizedChatId), + normalizedChatId, + ); + } catch (error) { + console.warn("[ST-BME] 读取 legacy chat_metadata 图谱失败:", error); + return null; + } +} + +async function maybeMigrateLegacyGraphToIndexedDb( + chatId, + context = getContext(), + { source = "unknown", db = null } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + migrated: false, + reason: "migration-missing-chat-id", + chatId: "", + }; + } + + const inFlightMigration = bmeIndexedDbLegacyMigrationInFlightByChatId.get( + normalizedChatId, + ); + if (inFlightMigration) { + return await inFlightMigration; + } + + const migrationTask = (async () => { + try { + const manager = ensureBmeChatManager(); + if (!manager) { + return { + migrated: false, + reason: "migration-manager-unavailable", + chatId: normalizedChatId, + }; + } + + const targetDb = db || (await manager.getCurrentDb(normalizedChatId)); + if (!targetDb) { + return { + migrated: false, + reason: "migration-db-unavailable", + chatId: normalizedChatId, + }; + } + + const contextChatId = resolveCurrentChatIdentity(context).chatId; + if (contextChatId && contextChatId !== normalizedChatId) { + return { + migrated: false, + reason: "migration-context-chat-mismatch", + chatId: normalizedChatId, + contextChatId, + }; + } + + const migrationCompletedAt = Number( + await targetDb.getMeta("migrationCompletedAt", 0), + ); + if (Number.isFinite(migrationCompletedAt) && migrationCompletedAt > 0) { + return { + migrated: false, + reason: "migration-already-completed", + chatId: normalizedChatId, + migrationCompletedAt, + }; + } + + const legacyGraph = readLegacyGraphFromChatMetadata(normalizedChatId, context); + if (!legacyGraph) { + return { + migrated: false, + reason: "migration-legacy-graph-missing", + chatId: normalizedChatId, + }; + } + + const emptyStatus = await targetDb.isEmpty(); + if (!emptyStatus?.empty) { + return { + migrated: false, + reason: "migration-indexeddb-not-empty", + chatId: normalizedChatId, + emptyStatus, + }; + } + + const legacyRevision = Math.max( + normalizeIndexedDbRevision(getGraphPersistedRevision(legacyGraph), 0), + 1, + ); + const migrationResult = await targetDb.importLegacyGraph(legacyGraph, { + source: "chat_metadata", + revision: legacyRevision, + }); + if (!migrationResult?.migrated) { + return { + migrated: false, + reason: migrationResult?.reason || "migration-skipped", + chatId: normalizedChatId, + migrationResult, + }; + } + + const postMigrationSnapshot = await targetDb.exportSnapshot(); + cacheIndexedDbSnapshot(normalizedChatId, postMigrationSnapshot); + console.debug("[ST-BME] legacy chat_metadata 图谱迁移完成", { + source, + chatId: normalizedChatId, + revision: + postMigrationSnapshot?.meta?.revision || migrationResult?.revision || 0, + imported: migrationResult.imported, + }); + + let syncResult = { + synced: false, + reason: "post-migration-sync-skipped", + chatId: normalizedChatId, + }; + try { + syncResult = await syncNow( + normalizedChatId, + buildBmeSyncRuntimeOptions({ + reason: "post-migration", + trigger: `${String(source || "migration")}:post-migration`, + }), + ); + } catch (syncError) { + console.warn("[ST-BME] legacy 迁移后立即同步失败:", syncError); + syncResult = { + synced: false, + reason: "post-migration-sync-failed", + chatId: normalizedChatId, + error: syncError?.message || String(syncError), + }; + } + + return { + migrated: true, + reason: "migration-completed", + chatId: normalizedChatId, + migrationResult, + snapshot: postMigrationSnapshot, + syncResult, + }; + } catch (error) { + console.warn("[ST-BME] legacy chat_metadata 迁移失败:", error); + return { + migrated: false, + reason: "migration-failed", + chatId: normalizedChatId, + error: error?.message || String(error), + }; + } + })().finally(() => { + if ( + bmeIndexedDbLegacyMigrationInFlightByChatId.get(normalizedChatId) === + migrationTask + ) { + bmeIndexedDbLegacyMigrationInFlightByChatId.delete(normalizedChatId); + } + }); + + bmeIndexedDbLegacyMigrationInFlightByChatId.set(normalizedChatId, migrationTask); + return await migrationTask; +} + +function applyIndexedDbEmptyToRuntime( + chatId, + { source = "indexeddb-empty", attemptIndex = 0 } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + success: false, + loaded: false, + reason: "indexeddb-missing-chat-id", + chatId: "", + attemptIndex, + }; + } + + currentGraph = normalizeGraphRuntimeState(createEmptyGraph(), normalizedChatId); + extractionCount = 0; + lastExtractedItems = []; + lastRecalledItems = []; + lastInjectionContent = ""; + runtimeStatus = createUiStatus("待命", "当前聊天还没有图谱", "idle"); + lastExtractionStatus = createUiStatus("待命", "当前聊天尚未执行提取", "idle"); + lastVectorStatus = createUiStatus("待命", "当前聊天尚未执行向量任务", "idle"); + lastRecallStatus = createUiStatus("待命", "当前聊天尚未建立记忆图谱", "idle"); + + applyGraphLoadState(GRAPH_LOAD_STATES.EMPTY_CONFIRMED, { + chatId: normalizedChatId, + reason: `indexeddb-empty:${String(source || "indexeddb-empty")}`, + attemptIndex, + revision: 0, + lastPersistedRevision: 0, + queuedPersistRevision: 0, + queuedPersistChatId: "", + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + dbReady: true, + writesBlocked: false, + }); + + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbRevision: 0, + indexedDbLastError: "", + dualWriteLastResult: { + action: "load", + source: String(source || "indexeddb-empty"), + success: true, + empty: true, + at: Date.now(), + }, + }); + + refreshPanelLiveState(); + return { + success: true, + loaded: false, + emptyConfirmed: true, + loadState: GRAPH_LOAD_STATES.EMPTY_CONFIRMED, + reason: `indexeddb-empty:${String(source || "indexeddb-empty")}`, + chatId: normalizedChatId, + attemptIndex, + }; +} + + +function applyIndexedDbSnapshotToRuntime( + chatId, + snapshot, + { source = "indexeddb", attemptIndex = 0 } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !isIndexedDbSnapshotMeaningful(snapshot)) { + return { + success: false, + loaded: false, + reason: "indexeddb-empty", + chatId: normalizedChatId, + attemptIndex, + }; + } + + const revision = Math.max(1, normalizeIndexedDbRevision(snapshot?.meta?.revision)); + const graphFromSnapshot = buildGraphFromSnapshot(snapshot, { + chatId: normalizedChatId, + }); + currentGraph = cloneGraphForPersistence( + normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), + normalizedChatId, + ); + + extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) + ? currentGraph.historyState.extractionCount + : 0; + lastExtractedItems = []; + updateLastRecalledItems(currentGraph.lastRecallResult || []); + lastInjectionContent = ""; + runtimeStatus = createUiStatus( + "待命", + "已从 IndexedDB 加载聊天图谱", + "idle", + ); + lastExtractionStatus = createUiStatus( + "待命", + "已从 IndexedDB 加载聊天图谱,等待下一次提取", + "idle", + ); + lastVectorStatus = createUiStatus( + "待命", + currentGraph.vectorIndexState?.lastWarning || + "已从 IndexedDB 加载聊天图谱,等待下一次向量任务", + "idle", + ); + lastRecallStatus = createUiStatus( + "待命", + "已从 IndexedDB 加载聊天图谱,等待下一次召回", + "idle", + ); + + applyGraphLoadState(GRAPH_LOAD_STATES.LOADED, { + chatId: normalizedChatId, + reason: `indexeddb:${source}`, + attemptIndex, + revision, + lastPersistedRevision: Math.max( + graphPersistenceState.lastPersistedRevision || 0, + revision, + ), + queuedPersistRevision: 0, + pendingPersist: false, + shadowSnapshotUsed: false, + shadowSnapshotRevision: 0, + shadowSnapshotUpdatedAt: "", + shadowSnapshotReason: "", + writesBlocked: false, + }); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbRevision: revision, + metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, + indexedDbLastError: "", + lastSyncError: "", + dualWriteLastResult: { + action: "load", + source: String(source || "indexeddb"), + revision, + at: Date.now(), + }, + }); + + removeGraphShadowSnapshot(normalizedChatId); + refreshPanelLiveState(); + console.debug("[ST-BME] 已从 IndexedDB 加载图谱", { + chatId: normalizedChatId, + source, + revision, + ...getGraphStats(currentGraph), + }); + + return { + success: true, + loaded: true, + loadState: GRAPH_LOAD_STATES.LOADED, + reason: `indexeddb:${source}`, + chatId: normalizedChatId, + attemptIndex, + shadowSnapshotUsed: false, + revision, + }; +} + +async function loadGraphFromIndexedDb( + chatId, + { + source = "indexeddb-probe", + attemptIndex = 0, + allowOverride = false, + applyEmptyState = false, + } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + success: false, + loaded: false, + reason: "indexeddb-missing-chat-id", + chatId: "", + attemptIndex, + }; + } + + try { + const manager = ensureBmeChatManager(); + if (!manager) { + return { + success: false, + loaded: false, + reason: "indexeddb-manager-unavailable", + chatId: normalizedChatId, + attemptIndex, + }; + } + const db = await manager.getCurrentDb(normalizedChatId); + + const migrationResult = await maybeMigrateLegacyGraphToIndexedDb( + normalizedChatId, + getContext(), + { + source, + db, + }, + ); + + if (migrationResult?.migrated) { + const migratedRevision = normalizeIndexedDbRevision( + migrationResult?.snapshot?.meta?.revision || migrationResult?.migrationResult?.revision, + ); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + indexedDbRevision: migratedRevision, + indexedDbLastError: "", + lastSyncError: "", + dualWriteLastResult: { + action: "migration", + source: "chat_metadata", + success: true, + chatId: normalizedChatId, + revision: migratedRevision, + reason: migrationResult?.reason || "migration-completed", + at: Date.now(), + syncResult: cloneRuntimeDebugValue(migrationResult?.syncResult, null), + }, + }); + } else if (migrationResult?.reason === "migration-failed") { + updateGraphPersistenceState({ + indexedDbLastError: String(migrationResult?.error || "migration-failed"), + dualWriteLastResult: { + action: "migration", + source: "chat_metadata", + success: false, + error: String(migrationResult?.error || "migration-failed"), + at: Date.now(), + }, + }); + } + + const snapshot = migrationResult?.snapshot || (await db.exportSnapshot()); + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + + if (!isIndexedDbSnapshotMeaningful(snapshot)) { + if ( + applyEmptyState && + getCurrentChatId() === normalizedChatId + ) { + return applyIndexedDbEmptyToRuntime(normalizedChatId, { + source, + attemptIndex, + }); + } + return { + success: false, + loaded: false, + reason: "indexeddb-empty", + chatId: normalizedChatId, + attemptIndex, + }; + } + + const snapshotRevision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + const shouldAllowOverride = + allowOverride || + BME_INDEXEDDB_FALLBACK_LOAD_STATE_SET.has(graphPersistenceState.loadState) || + graphPersistenceState.storagePrimary === "indexeddb" || + snapshotRevision >= normalizeIndexedDbRevision(graphPersistenceState.revision); + + if (!shouldAllowOverride) { + return { + success: false, + loaded: false, + reason: "indexeddb-stale", + chatId: normalizedChatId, + attemptIndex, + revision: snapshotRevision, + }; + } + + if (getCurrentChatId() !== normalizedChatId) { + return { + success: false, + loaded: false, + reason: "indexeddb-chat-switched", + chatId: normalizedChatId, + attemptIndex, + revision: snapshotRevision, + }; + } + + return applyIndexedDbSnapshotToRuntime(normalizedChatId, snapshot, { + source, + attemptIndex, + }); + } catch (error) { + console.warn("[ST-BME] IndexedDB 读取失败,回退 metadata:", error); + updateGraphPersistenceState({ + indexedDbLastError: error?.message || String(error), + dualWriteLastResult: { + action: "load", + source: String(source || "indexeddb"), + success: false, + error: error?.message || String(error), + at: Date.now(), + }, + }); + return { + success: false, + loaded: false, + reason: "indexeddb-read-failed", + chatId: normalizedChatId, + attemptIndex, + error, + }; + } +} + +function scheduleIndexedDbGraphProbe(chatId, options = {}) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || bmeIndexedDbLoadInFlightByChatId.has(normalizedChatId)) { + return; + } + + scheduleBmeIndexedDbTask(() => { + const loadPromise = loadGraphFromIndexedDb(normalizedChatId, options) + .catch((error) => { + console.warn("[ST-BME] IndexedDB 后台加载失败:", error); + }) + .finally(() => { + if (bmeIndexedDbLoadInFlightByChatId.get(normalizedChatId) === loadPromise) { + bmeIndexedDbLoadInFlightByChatId.delete(normalizedChatId); + } + }); + + bmeIndexedDbLoadInFlightByChatId.set(normalizedChatId, loadPromise); + return loadPromise; + }); +} + function resolveInjectionPromptType(settings = {}) { const normalized = String(settings?.injectPosition || "atDepth") .trim() @@ -2119,6 +2946,9 @@ function persistGraphToChatMetadata( lastPersistReason: String(reason || ""), lastPersistMode: saveMode, metadataIntegrity: String(nextIntegrity || ""), + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbLastError: "", queuedPersistChatId: "", queuedPersistMode: "", queuedPersistRotateIntegrity: false, @@ -2274,46 +3104,19 @@ function shouldSyncGraphLoadFromLiveContext( context = getContext(), { force = false } = {}, ) { - if (force) { - return true; - } + if (force) return true; const chatIdentity = resolveCurrentChatIdentity(context); const liveChatId = chatIdentity.chatId; const stateChatId = normalizeChatIdCandidate(graphPersistenceState.chatId); - const liveMetadataReady = isHostChatMetadataReady(context); - if (liveChatId && liveChatId !== stateChatId) { + if (liveChatId !== stateChatId) return true; + + if (!liveChatId && graphPersistenceState.loadState !== GRAPH_LOAD_STATES.NO_CHAT) { return true; } - if ( - graphPersistenceState.loadState === GRAPH_LOAD_STATES.NO_CHAT && - (liveChatId || chatIdentity.hasLikelySelectedChat) - ) { - return true; - } - - if ( - graphPersistenceState.loadState === GRAPH_LOAD_STATES.SHADOW_RESTORED && - liveMetadataReady - ) { - return true; - } - - if ( - graphPersistenceState.loadState === GRAPH_LOAD_STATES.LOADING && - liveMetadataReady - ) { - return true; - } - - if ( - graphPersistenceState.loadState === GRAPH_LOAD_STATES.BLOCKED && - (liveChatId || liveMetadataReady) - ) { - return true; - } + if (liveChatId && !graphPersistenceState.dbReady) return true; return false; } @@ -2330,12 +3133,58 @@ function syncGraphLoadFromLiveContext(options = {}) { }; } - const result = loadGraphFromChat({ - source, + const chatId = resolveCurrentChatIdentity(context).chatId; + if (!chatId) { + const result = loadGraphFromChat({ + source, + attemptIndex: 0, + }); + return { + synced: true, + ...result, + }; + } + + const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); + if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { + const result = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, { + source: `${source}:indexeddb-cache`, + attemptIndex: 0, + }); + return { + synced: true, + ...result, + }; + } + + applyGraphLoadState(GRAPH_LOAD_STATES.LOADING, { + chatId, + reason: `indexeddb-sync:${String(source || "live-context-sync")}`, + attemptIndex: 0, + dbReady: false, + writesBlocked: true, }); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: false, + indexedDbLastError: "", + }); + scheduleIndexedDbGraphProbe(chatId, { + source: `${source}:indexeddb-probe`, + allowOverride: true, + applyEmptyState: true, + }); + refreshPanelLiveState(); + return { synced: true, - ...result, + success: false, + loaded: false, + loadState: GRAPH_LOAD_STATES.LOADING, + reason: "indexeddb-loading", + chatId, + attemptIndex: 0, }; } @@ -3012,6 +3861,26 @@ function loadGraphFromChat(options = {}) { }; } + if (chatId) { + const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); + if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { + const cachedResult = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, { + source: `${source}:indexeddb-cache`, + attemptIndex, + }); + if (cachedResult?.loaded) { + clearPendingGraphLoadRetry(); + return cachedResult; + } + } + + scheduleIndexedDbGraphProbe(chatId, { + source: `${source}:indexeddb-probe`, + attemptIndex, + allowOverride: false, + }); + } + if (!chatId) { const shouldRetry = attemptIndex < GRAPH_LOAD_RETRY_DELAYS_MS.length; if (chatIdentity.hasLikelySelectedChat) { @@ -3199,6 +4068,10 @@ function loadGraphFromChat(options = {}) { queuedPersistMode: "immediate", queuedPersistRotateIntegrity: false, queuedPersistReason: "shadow-snapshot-newer-than-official", + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbRevision: Math.max(graphPersistenceState.indexedDbRevision || 0, officialRevision), + indexedDbLastError: "", }); const persistResult = maybeFlushQueuedGraphPersist( "shadow-snapshot-newer-than-official", @@ -3267,6 +4140,9 @@ function loadGraphFromChat(options = {}) { queuedPersistMode: "", queuedPersistRotateIntegrity: false, queuedPersistReason: "", + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbLastError: "", }); removeGraphShadowSnapshot(chatId); @@ -3338,6 +4214,9 @@ function loadGraphFromChat(options = {}) { queuedPersistMode: "immediate", queuedPersistRotateIntegrity: false, queuedPersistReason: "shadow-snapshot-promoted", + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbLastError: "", }); const persistResult = maybeFlushQueuedGraphPersist( "shadow-snapshot-promoted", @@ -3379,6 +4258,9 @@ function loadGraphFromChat(options = {}) { queuedPersistReason: shouldRetry ? "shadow-snapshot-restored" : "shadow-snapshot-blocked", + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbLastError: "", }); if (shouldRetry) { scheduleGraphLoadRetry( @@ -3511,6 +4393,9 @@ function loadGraphFromChat(options = {}) { queuedPersistMode: "", queuedPersistRotateIntegrity: false, queuedPersistReason: "", + storagePrimary: "metadata", + storageMode: "metadata", + indexedDbLastError: "", }); removeGraphShadowSnapshot(chatId); refreshPanelLiveState(); @@ -3525,6 +4410,156 @@ function loadGraphFromChat(options = {}) { }; } +async function saveGraphToIndexedDb( + chatId, + graph, + { revision = 0, reason = "graph-save" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !graph) { + return { + saved: false, + chatId: normalizedChatId, + reason: "indexeddb-missing-chat-or-graph", + revision: normalizeIndexedDbRevision(revision), + }; + } + + try { + const manager = ensureBmeChatManager(); + if (!manager) { + return { + saved: false, + chatId: normalizedChatId, + reason: "indexeddb-manager-unavailable", + revision: normalizeIndexedDbRevision(revision), + }; + } + const db = await manager.getCurrentDb(normalizedChatId); + const baseSnapshot = + readCachedIndexedDbSnapshot(normalizedChatId) || (await db.exportSnapshot()); + const snapshot = buildSnapshotFromGraph(graph, { + chatId: normalizedChatId, + revision, + baseSnapshot, + lastModified: Date.now(), + meta: { + storagePrimary: "indexeddb", + lastMutationReason: String(reason || "graph-save"), + }, + }); + const importResult = await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision, + markSyncDirty: true, + }); + await db.markSyncDirty(reason); + + snapshot.meta.revision = normalizeIndexedDbRevision(importResult?.revision, revision); + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + scheduleUpload(normalizedChatId, buildBmeSyncRuntimeOptions({ + trigger: `graph-mutation:${String(reason || "graph-save")}`, + })); + + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: true, + indexedDbRevision: snapshot.meta.revision, + metadataIntegrity: getChatMetadataIntegrity(getContext()) || graphPersistenceState.metadataIntegrity, + indexedDbLastError: "", + lastSyncError: "", + dualWriteLastResult: { + action: "save", + target: "indexeddb", + success: true, + chatId: normalizedChatId, + revision: snapshot.meta.revision, + reason: String(reason || "graph-save"), + at: Date.now(), + }, + }); + + return { + saved: true, + chatId: normalizedChatId, + revision: snapshot.meta.revision, + reason: String(reason || "graph-save"), + }; + } catch (error) { + console.warn("[ST-BME] IndexedDB 写入失败,保留 metadata 兜底:", error); + updateGraphPersistenceState({ + indexedDbLastError: error?.message || String(error), + dualWriteLastResult: { + action: "save", + target: "indexeddb", + success: false, + chatId: normalizedChatId, + revision: normalizeIndexedDbRevision(revision), + reason: String(reason || "graph-save"), + error: error?.message || String(error), + at: Date.now(), + }, + }); + return { + saved: false, + chatId: normalizedChatId, + revision: normalizeIndexedDbRevision(revision), + reason: "indexeddb-write-failed", + error, + }; + } +} + +function queueGraphPersistToIndexedDb( + chatId, + graph, + { revision = 0, reason = "graph-save" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !graph) return; + const graphSnapshot = cloneGraphForPersistence(graph, normalizedChatId); + + const normalizedRevision = normalizeIndexedDbRevision(revision); + const latestQueuedRevision = normalizeIndexedDbRevision( + bmeIndexedDbLatestQueuedRevisionByChatId.get(normalizedChatId), + ); + bmeIndexedDbLatestQueuedRevisionByChatId.set( + normalizedChatId, + Math.max(latestQueuedRevision, normalizedRevision), + ); + + const previousWritePromise = + bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) || Promise.resolve(); + const nextWritePromise = previousWritePromise + .catch(() => null) + .then(async () => { + const currentLatestRevision = normalizeIndexedDbRevision( + bmeIndexedDbLatestQueuedRevisionByChatId.get(normalizedChatId), + ); + if (normalizedRevision > 0 && normalizedRevision < currentLatestRevision) { + return { + saved: false, + skipped: true, + reason: "indexeddb-write-superseded", + revision: normalizedRevision, + }; + } + return await saveGraphToIndexedDb(normalizedChatId, graphSnapshot, { + revision: normalizedRevision, + reason, + }); + }) + .finally(() => { + if (bmeIndexedDbWriteInFlightByChatId.get(normalizedChatId) === nextWritePromise) { + bmeIndexedDbWriteInFlightByChatId.delete(normalizedChatId); + } + }); + + bmeIndexedDbWriteInFlightByChatId.set(normalizedChatId, nextWritePromise); +} + function saveGraphToChat(options = {}) { const context = getContext(); if (!context || !currentGraph) { @@ -3538,7 +4573,8 @@ function saveGraphToChat(options = {}) { const { reason = "graph-save", markMutation = true, - captureShadow = true, + persistMetadata = false, + captureShadow = Boolean(persistMetadata), immediate = markMutation, } = options; @@ -3560,6 +4596,17 @@ function saveGraphToChat(options = {}) { maybeCaptureGraphShadowSnapshot(reason); } + const shouldQueueIndexedDbPersist = + markMutation || !isGraphEffectivelyEmpty(currentGraph); + if (shouldQueueIndexedDbPersist) { + queueGraphPersistToIndexedDb(chatId, currentGraph, { + revision, + reason, + }); + } + + const metadataFallbackEnabled = Boolean(persistMetadata) || !ensureBmeChatManager(); + if (!markMutation) { const hasMeaningfulGraphData = !isGraphEffectivelyEmpty(currentGraph); if ( @@ -3577,6 +4624,45 @@ function saveGraphToChat(options = {}) { } } + if (!metadataFallbackEnabled) { + const saveMode = shouldQueueIndexedDbPersist + ? "indexeddb-queued" + : "indexeddb-skip"; + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + dbReady: + graphPersistenceState.dbReady ?? isGraphLoadStateDbReady(graphPersistenceState.loadState), + lastPersistReason: String(reason || "graph-save"), + lastPersistMode: saveMode, + pendingPersist: false, + queuedPersistChatId: "", + queuedPersistMode: "", + queuedPersistReason: "", + queuedPersistRotateIntegrity: false, + dualWriteLastResult: { + action: "save", + target: "indexeddb", + queued: Boolean(shouldQueueIndexedDbPersist), + success: true, + chatId, + revision: normalizeIndexedDbRevision(revision), + reason: String(reason || "graph-save"), + at: Date.now(), + }, + }); + return buildGraphPersistResult({ + saved: Boolean(shouldQueueIndexedDbPersist), + queued: false, + blocked: false, + reason: shouldQueueIndexedDbPersist + ? "indexeddb-queued" + : "indexeddb-empty-skip", + revision, + saveMode, + }); + } + if (!isGraphMetadataWriteAllowed()) { console.warn( `[ST-BME] 图谱写回已被安全保护拦截(chat=${chatId},state=${graphPersistenceState.loadState},reason=${reason})`, @@ -3584,11 +4670,28 @@ function saveGraphToChat(options = {}) { return queueGraphPersist(reason, revision, { immediate }); } - return persistGraphToChatMetadata(context, { + const metadataPersistResult = persistGraphToChatMetadata(context, { reason, revision, immediate, }); + updateGraphPersistenceState({ + storagePrimary: "metadata", + storageMode: "metadata", + dualWriteLastResult: { + action: "save", + target: "metadata", + success: Boolean(metadataPersistResult?.saved), + queued: Boolean(metadataPersistResult?.queued), + blocked: Boolean(metadataPersistResult?.blocked), + chatId, + revision: normalizeIndexedDbRevision(revision), + reason: String(reason || "graph-save"), + at: Date.now(), + }, + }); + + return metadataPersistResult; } function handleGraphShadowSnapshotPageHide() { @@ -5048,7 +6151,7 @@ async function runRecall(options = {}) { // ==================== 事件钩子 ==================== function onChatChanged() { - return onChatChangedController({ + const result = onChatChangedController({ abortAllRunningStages, clearGenerationRecallTransactionsForChat, clearInjectionState, @@ -5077,13 +6180,41 @@ function onChatChanged() { }, syncGraphLoadFromLiveContext, }); + + scheduleBmeIndexedDbTask(async () => { + const syncResult = await syncBmeChatManagerWithCurrentChat("chat-changed"); + if (syncResult?.chatId) { + await runBmeAutoSyncForChat("chat-changed", syncResult.chatId); + await loadGraphFromIndexedDb(syncResult.chatId, { + source: "chat-changed", + allowOverride: true, + applyEmptyState: true, + }); + } + }); + + return result; } function onChatLoaded() { - return onChatLoadedController({ + const result = onChatLoadedController({ refreshPersistedRecallMessageUi: schedulePersistedRecallMessageUiRefresh, syncGraphLoadFromLiveContext, }); + + scheduleBmeIndexedDbTask(async () => { + const syncResult = await syncBmeChatManagerWithCurrentChat("chat-loaded"); + if (syncResult?.chatId) { + await runBmeAutoSyncForChat("chat-loaded", syncResult.chatId); + await loadGraphFromIndexedDb(syncResult.chatId, { + source: "chat-loaded", + allowOverride: true, + applyEmptyState: true, + }); + } + }); + + return result; } function onMessageSent(messageId) { @@ -5453,13 +6584,13 @@ async function onReembedDirect() { (async function init() { await loadServerSettings(); syncGraphPersistenceDebugState(); + + ensureBmeChatManager(); + scheduleBmeIndexedDbWarmup("init"); initializeHostCapabilityBridge(); installSendIntentHooks(); - globalThis.addEventListener?.("pagehide", handleGraphShadowSnapshotPageHide); - document.addEventListener( - "visibilitychange", - handleGraphShadowSnapshotVisibilityChange, - ); + autoSyncOnVisibility(buildBmeSyncRuntimeOptions()); + // 注册事件钩子 registerCoreEventHooksController({ @@ -5481,12 +6612,19 @@ async function onReembedDirect() { }); // 加载当前聊天的图谱 - clearPendingGraphLoadRetry(); - syncGraphLoadFromLiveContext({ - source: "initial-load", - force: true, + scheduleBmeIndexedDbTask(async () => { + const syncResult = await syncBmeChatManagerWithCurrentChat("initial-load"); + if (!syncResult?.chatId) { + syncGraphLoadFromLiveContext({ source: "initial-load:no-chat", force: true }); + return; + } + await runBmeAutoSyncForChat("initial-load", syncResult.chatId); + await loadGraphFromIndexedDb(syncResult.chatId, { + source: "initial-load", + allowOverride: true, + applyEmptyState: true, + }); }); - scheduleStartupGraphReconciliation(); // ==================== 操控面板初始化 ==================== diff --git a/lib/dexie.min.js b/lib/dexie.min.js new file mode 100644 index 0000000..85abce2 --- /dev/null +++ b/lib/dexie.min.js @@ -0,0 +1,4 @@ +// Dexie v4.0.8 vendored from https://cdn.jsdelivr.net/npm/dexie@4.0.8/dist/dexie.min.js + +(function(e,t){"object"==typeof exports&&"undefined"!=typeof module?module.exports=t():"function"==typeof define&&define.amd?define(t):(e="undefined"!=typeof globalThis?globalThis:e||self).Dexie=t()})(this,function(){"use strict";var s=function(e,t){return(s=Object.setPrototypeOf||{__proto__:[]}instanceof Array&&function(e,t){e.__proto__=t}||function(e,t){for(var n in t)Object.prototype.hasOwnProperty.call(t,n)&&(e[n]=t[n])})(e,t)};var w=function(){return(w=Object.assign||function(e){for(var t,n=1,r=arguments.length;n.",Ze="String expected.",et=[],tt="__dbnames",nt="readonly",rt="readwrite";function it(e,t){return e?t?function(){return e.apply(this,arguments)&&t.apply(this,arguments)}:e:t}var ot={type:3,lower:-1/0,lowerOpen:!1,upper:[[]],upperOpen:!1};function at(t){return"string"!=typeof t||/\./.test(t)?function(e){return e}:function(e){return void 0===e[t]&&t in e&&delete(e=S(e))[t],e}}function ut(){throw Y.Type()}function st(e,t){try{var n=ct(e),r=ct(t);if(n!==r)return"Array"===n?1:"Array"===r?-1:"binary"===n?1:"binary"===r?-1:"string"===n?1:"string"===r?-1:"Date"===n?1:"Date"!==r?NaN:-1;switch(n){case"number":case"Date":case"string":return ts+c&&f(s+g)})})}var h=yt(r)&&r.limit===1/0&&("function"!=typeof w||w===Et)&&{index:r.index,range:r.range};return f(0).then(function(){if(0=c})).length?(e.forEach(function(u){t.push(function(){var t=h,e=u._cfg.dbschema;fn(s,t,f),fn(s,e,f),h=s._dbSchema=e;var n=an(t,e);n.add.forEach(function(e){un(f,e[0],e[1].primKey,e[1].indexes)}),n.change.forEach(function(e){if(e.recreate)throw new Y.Upgrade("Not yet support for changing primary key");var t=f.objectStore(e.name);e.add.forEach(function(e){return cn(t,e)}),e.change.forEach(function(e){t.deleteIndex(e.name),cn(t,e)}),e.del.forEach(function(e){return t.deleteIndex(e)})});var r=u._cfg.contentUpgrade;if(r&&u._cfg.version>c){Zt(s,f),l._memoizedTables={};var i=g(e);n.del.forEach(function(e){i[e]=t[e]}),tn(s,[s.Transaction.prototype]),en(s,[s.Transaction.prototype],_(i),i),l.schema=i;var o,a=B(r);a&&Le();n=_e.follow(function(){var e;(o=r(l))&&a&&(e=Ue.bind(null,null),o.then(e,e))});return o&&"function"==typeof o.then?_e.resolve(o):n.then(function(){return o})}}),t.push(function(e){var t,n,r=u._cfg.dbschema;t=r,n=e,[].slice.call(n.db.objectStoreNames).forEach(function(e){return null==t[e]&&n.db.deleteObjectStore(e)}),tn(s,[s.Transaction.prototype]),en(s,[s.Transaction.prototype],s._storeNames,s._dbSchema),l.schema=s._dbSchema}),t.push(function(e){s.idbdb.objectStoreNames.contains("$meta")&&(Math.ceil(s.idbdb.version/10)===u._cfg.version?(s.idbdb.deleteObjectStore("$meta"),delete s._dbSchema.$meta,s._storeNames=s._storeNames.filter(function(e){return"$meta"!==e})):e.objectStore("$meta").put(u._cfg.version,"version"))})}),function e(){return t.length?_e.resolve(t.shift()(l.idbtrans)).then(e):_e.resolve()}().then(function(){sn(h,f)})):_e.resolve();var s,c,l,f,t,h}).catch(u)):(_(o).forEach(function(e){un(i,e,o[e].primKey,o[e].indexes)}),Zt(n,i),void _e.follow(function(){return n.on.populate.fire(a)}).catch(u));var e,t})}function on(e,r){sn(e._dbSchema,r),r.db.version%10!=0||r.objectStoreNames.contains("$meta")||r.db.createObjectStore("$meta").add(Math.ceil(r.db.version/10-1),"version");var t=ln(0,e.idbdb,r);fn(e,e._dbSchema,r);for(var n=0,i=an(t,e._dbSchema).change;nMath.pow(2,62)?0:e.oldVersion,m=e<1,f.idbdb=l.result,p&&on(f,v),rn(f,e/10,v,n))},n),l.onsuccess=Te(function(){v=null;var e,t,n,r,i,o=f.idbdb=l.result,a=b(o.objectStoreNames);if(0h.limit?t.length=h.limit:e.length===h.limit&&t.length=r.limit&&(!r.values||e.req.values)&&Xn(e.req.query.range,r.query.range)}),!1,i,o];case"count":a=o.find(function(e){return Gn(e.req.query.range,r.query.range)});return[a,!!a,i,o]}}(O,c,"query",t),a=o[0],e=o[1],u=o[2],s=o[3];return a&&e?a.obsSet=t.obsSet:(e=l.query(t).then(function(e){var t=e.result;if(a&&(a.res=t),i){for(var n=0,r=t.length;n=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" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..4a1b3c4 --- /dev/null +++ b/package.json @@ -0,0 +1,9 @@ +{ + "dependencies": { + "triviumdb": "^0.4.41" + }, + "devDependencies": { + "dexie": "4.0.8", + "fake-indexeddb": "^6.2.5" + } +} diff --git a/panel.js b/panel.js index f7d69bb..1776fd4 100644 --- a/panel.js +++ b/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"; diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 9e609f9..7891a49 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -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"); diff --git a/tests/indexeddb-migration.mjs b/tests/indexeddb-migration.mjs new file mode 100644 index 0000000..2ecc184 --- /dev/null +++ b/tests/indexeddb-migration.mjs @@ -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(); diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs new file mode 100644 index 0000000..4d0ebf1 --- /dev/null +++ b/tests/indexeddb-persistence.mjs @@ -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(); diff --git a/tests/indexeddb-sync.mjs b/tests/indexeddb-sync.mjs new file mode 100644 index 0000000..4c0e7b1 --- /dev/null +++ b/tests/indexeddb-sync.mjs @@ -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(); diff --git a/ui-status.js b/ui-status.js index 0ee6e78..8cd0fc4 100644 --- a/ui-status.js +++ b/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(), }; }