From 3f70d63a86eb99c4daa42019d9f429a0862aa00c Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Tue, 28 Apr 2026 03:02:43 +0800 Subject: [PATCH] feat(authority): migrate local graphs to server primary --- index.js | 355 ++++++++++++++++-- sync/bme-sync.js | 2 +- tests/graph-persistence.mjs | 393 +++++++++++++++++++- tests/helpers/generation-recall-harness.mjs | 2 + ui/ui-status.js | 5 + 5 files changed, 715 insertions(+), 42 deletions(-) diff --git a/index.js b/index.js index 998591c..3ff32b5 100644 --- a/index.js +++ b/index.js @@ -50,6 +50,7 @@ import { autoSyncOnVisibility, backupToServer, buildRestoreSafetyChatId, + createRestoreSafetySnapshot, deleteRemoteSyncFile, deleteServerBackup, getRestoreSafetySnapshotStatus, @@ -264,6 +265,7 @@ import { createAuthorityBrowserState, getAuthorityBrowserStateSnapshot, normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, } from "./sync/authority-browser-state.js"; import { retrieve } from "./retrieval/retriever.js"; import { @@ -1502,6 +1504,103 @@ function buildAuthorityPersistenceStatePatch(settings = getSettings()) { }; } +function isAuthorityGraphStorePresentation(presentation = null) { + if (!presentation || typeof presentation !== "object") return false; + return ( + presentation.storagePrimary === AUTHORITY_GRAPH_STORE_KIND || + presentation.storageMode === AUTHORITY_GRAPH_STORE_MODE + ); +} + +function isAuthorityGraphStoreDb(db = null) { + return ( + db?.storeKind === AUTHORITY_GRAPH_STORE_KIND || + db?.storeMode === AUTHORITY_GRAPH_STORE_MODE + ); +} + +function recordAuthorityAcceptedRevisionPointer({ + revision = 0, + integrity = "", + committedAt = Date.now(), +} = {}) { + const settings = getSettings(); + authorityBrowserState = recordAuthorityAcceptedRevision( + authorityBrowserState, + { + revision: normalizeIndexedDbRevision(revision), + integrity: String(integrity || ""), + committedAt, + }, + settings, + committedAt, + ); + updateGraphPersistenceState(buildAuthorityPersistenceStatePatch(settings)); + return authorityBrowserState; +} + +async function captureAuthorityMigrationSafetySnapshot( + chatId, + snapshot, + { source = "authority-migration", reason = "authority-migration-safety" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId || !isIndexedDbSnapshotMeaningful(snapshot)) { + return { + captured: false, + reason: "authority-migration-safety-source-empty", + chatId: normalizedChatId || "", + }; + } + + try { + await createRestoreSafetySnapshot( + normalizedChatId, + snapshot, + buildBmeSyncRuntimeOptions({ + reason, + trigger: String(source || "authority-migration"), + }), + ); + const migrationGraph = buildGraphFromSnapshot(snapshot, { + chatId: normalizedChatId, + }); + const revision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + const identity = resolveCurrentChatIdentity(getContext()); + const integrity = String( + snapshot?.meta?.integrity || identity?.integrity || "", + ).trim(); + let shadowCaptured = false; + try { + shadowCaptured = writeGraphShadowSnapshot(normalizedChatId, migrationGraph, { + revision, + reason, + integrity, + debugReason: String(source || "authority-migration"), + }); + } catch (shadowError) { + console.warn("[ST-BME] Authority 迁移影子安全快照创建失败:", shadowError); + } + return { + captured: true, + restoreSafetyCaptured: true, + shadowCaptured, + reason: "authority-migration-restore-safety-created", + chatId: normalizedChatId, + revision, + integrity, + }; + } catch (error) { + console.warn("[ST-BME] Authority 迁移安全快照创建失败:", error); + return { + captured: false, + reason: "authority-migration-safety-failed", + chatId: normalizedChatId, + error: error?.message || String(error), + }; + } +} + async function refreshAuthorityRuntimeState({ force = false, source = "authority-refresh", @@ -1688,6 +1787,22 @@ function getGraphPersistenceLiveState() { authorityRuntime.capability.lastError || "", ), + authorityMigrationState: String( + graphPersistenceState.authorityMigrationState || "idle", + ), + authorityMigrationSource: String( + graphPersistenceState.authorityMigrationSource || "", + ), + authorityMigrationRevision: Number( + graphPersistenceState.authorityMigrationRevision || 0, + ), + authorityMigrationLastError: String( + graphPersistenceState.authorityMigrationLastError || "", + ), + lastAuthorityMigrationResult: cloneRuntimeDebugValue( + graphPersistenceState.lastAuthorityMigrationResult, + null, + ), resolvedLocalStore: String( graphPersistenceState.resolvedLocalStore || buildGraphLocalStoreSelectorKey(getPreferredGraphLocalStorePresentationSync()), @@ -5714,7 +5829,14 @@ async function importRecoveredSnapshotToIndexedDb( targetDb, targetChatId, graph, - { revision = 0, integrity = "", source = "identity-recovery", legacyChatId = "" } = {}, + { + revision = 0, + integrity = "", + source = "identity-recovery", + legacyChatId = "", + markSyncDirty = true, + beforeImport = null, + } = {}, ) { const snapshot = buildRecoveredSnapshotForChatIdentity(graph, targetChatId, { revision, @@ -5722,11 +5844,14 @@ async function importRecoveredSnapshotToIndexedDb( source, legacyChatId, }); + if (typeof beforeImport === "function") { + await beforeImport(snapshot); + } const importResult = await targetDb.importSnapshot(snapshot, { mode: "replace", preserveRevision: true, revision: snapshot.meta.revision, - markSyncDirty: true, + markSyncDirty, }); snapshot.meta.revision = normalizeIndexedDbRevision( importResult?.revision, @@ -6154,9 +6279,11 @@ function buildBmeSyncRuntimeOptions(extra = {}) { return await manager.getCurrentDb(chatId); }, getSafetyDb: async (chatId) => { - const safetyDb = await createPreferredGraphLocalStore( - buildRestoreSafetyChatId(chatId), - ); + const safetyChatId = buildRestoreSafetyChatId(chatId); + const safetyStore = await resolvePreferredGraphLocalStorePresentation(); + const safetyDb = isAuthorityGraphStorePresentation(safetyStore) + ? new BmeDatabase(safetyChatId) + : await createPreferredGraphLocalStore(safetyChatId); await safetyDb.open(); return safetyDb; }, @@ -6558,6 +6685,7 @@ function cacheIndexedDbSnapshot(chatId, snapshot = null) { if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return; if (snapshot.__stBmeTombstonesOmitted === true) return; const snapshotStore = resolveSnapshotGraphStorePresentation(snapshot); + if (snapshotStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND) return; bmeIndexedDbSnapshotCacheByChatId.set(normalizedChatId, { chatId: normalizedChatId, revision: normalizeIndexedDbRevision(snapshot?.meta?.revision), @@ -8797,6 +8925,8 @@ async function maybeRecoverIndexedDbGraphFromStableIdentity( chatId: normalizedChatId, }; } + const targetStore = resolveDbGraphStorePresentation(targetDb); + const authorityTarget = isAuthorityGraphStorePresentation(targetStore); const emptyStatus = await targetDb.isEmpty(); if (!emptyStatus?.empty) { @@ -8817,6 +8947,7 @@ async function maybeRecoverIndexedDbGraphFromStableIdentity( shadowChatId = "", } = {}, ) => { + let safetySnapshotResult = null; const snapshot = await importRecoveredSnapshotToIndexedDb( targetDb, normalizedChatId, @@ -8826,9 +8957,24 @@ async function maybeRecoverIndexedDbGraphFromStableIdentity( integrity: identity.integrity, source: migrationSource, legacyChatId, + markSyncDirty: !authorityTarget, + beforeImport: authorityTarget + ? async (candidateSnapshot) => { + safetySnapshotResult = await captureAuthorityMigrationSafetySnapshot( + normalizedChatId, + candidateSnapshot, + { + source: migrationSource, + reason: "authority-identity-recovery-safety", + }, + ); + } + : null, }, ); - cacheIndexedDbSnapshot(normalizedChatId, snapshot); + if (!authorityTarget) { + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + } rememberResolvedGraphIdentityAlias(context, normalizedChatId); if (shadowChatId && shadowChatId !== normalizedChatId) { @@ -8840,32 +8986,49 @@ async function maybeRecoverIndexedDbGraphFromStableIdentity( reason: "identity-recovery-sync-skipped", chatId: normalizedChatId, }; - try { - syncResult = await syncNow( - normalizedChatId, - buildBmeSyncRuntimeOptions({ - reason: "identity-recovery", - trigger: `${String(source || "identity-recovery")}:identity-recovery`, - }), - ); - } catch (syncError) { - console.warn("[ST-BME] 身份恢复后的同步失败:", syncError); + if (authorityTarget) { + const acceptedRevision = normalizeIndexedDbRevision(snapshot?.meta?.revision); + recordAuthorityAcceptedRevisionPointer({ + revision: acceptedRevision, + integrity: snapshot?.meta?.integrity || identity.integrity, + }); syncResult = { synced: false, - reason: "identity-recovery-sync-failed", + reason: "authority-primary-legacy-sync-skipped", chatId: normalizedChatId, - error: syncError?.message || String(syncError), }; + } else { + try { + syncResult = await syncNow( + normalizedChatId, + buildBmeSyncRuntimeOptions({ + reason: "identity-recovery", + trigger: `${String(source || "identity-recovery")}:identity-recovery`, + }), + ); + } catch (syncError) { + console.warn("[ST-BME] 身份恢复后的同步失败:", syncError); + syncResult = { + synced: false, + reason: "identity-recovery-sync-failed", + chatId: normalizedChatId, + error: syncError?.message || String(syncError), + }; + } } return { migrated: true, - reason: "identity-recovery-completed", + reason: authorityTarget + ? "authority-identity-recovery-completed" + : "identity-recovery-completed", chatId: normalizedChatId, legacyChatId: normalizeChatIdCandidate(legacyChatId), source: migrationSource, snapshot, syncResult, + safetySnapshotResult, + targetStore, }; }; @@ -9013,6 +9176,8 @@ async function maybeMigrateLegacyGraphToIndexedDb( chatId: normalizedChatId, }; } + const targetStore = resolveDbGraphStorePresentation(targetDb); + const authorityTarget = isAuthorityGraphStorePresentation(targetStore); const contextChatId = resolveCurrentChatIdentity(context).chatId; if (contextChatId && contextChatId !== normalizedChatId) { @@ -9062,9 +9227,26 @@ async function maybeMigrateLegacyGraphToIndexedDb( normalizeIndexedDbRevision(getGraphPersistedRevision(legacyGraph), 0), 1, ); + const safetySnapshotResult = authorityTarget + ? await captureAuthorityMigrationSafetySnapshot( + normalizedChatId, + buildSnapshotFromGraph(legacyGraph, { + chatId: normalizedChatId, + revision: legacyRevision, + meta: { + migrationSource: "chat_metadata", + }, + }), + { + source: "chat_metadata", + reason: "authority-chat-metadata-migration-safety", + }, + ) + : null; const migrationResult = await targetDb.importLegacyGraph(legacyGraph, { source: "chat_metadata", revision: legacyRevision, + markSyncDirty: !authorityTarget, }); if (!migrationResult?.migrated) { return { @@ -9076,7 +9258,18 @@ async function maybeMigrateLegacyGraphToIndexedDb( } const postMigrationSnapshot = await targetDb.exportSnapshot(); - cacheIndexedDbSnapshot(normalizedChatId, postMigrationSnapshot); + if (!authorityTarget) { + cacheIndexedDbSnapshot(normalizedChatId, postMigrationSnapshot); + } + if (authorityTarget) { + recordAuthorityAcceptedRevisionPointer({ + revision: + postMigrationSnapshot?.meta?.revision || + migrationResult?.revision || + legacyRevision, + integrity: postMigrationSnapshot?.meta?.integrity, + }); + } debugDebug("[ST-BME] legacy chat_metadata 图谱迁移完成", { source, chatId: normalizedChatId, @@ -9092,31 +9285,43 @@ async function maybeMigrateLegacyGraphToIndexedDb( 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); + if (authorityTarget) { syncResult = { synced: false, - reason: "post-migration-sync-failed", + reason: "authority-primary-legacy-sync-skipped", chatId: normalizedChatId, - error: syncError?.message || String(syncError), }; + } else { + 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", + reason: authorityTarget + ? "authority-chat-metadata-migration-completed" + : "migration-completed", chatId: normalizedChatId, migrationResult, snapshot: postMigrationSnapshot, syncResult, + safetySnapshotResult, + targetStore, }; } catch (error) { console.warn("[ST-BME] legacy chat_metadata 迁移失败:", error); @@ -9179,6 +9384,7 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( } const targetStore = resolveDbGraphStorePresentation(targetDb); + const authorityTarget = isAuthorityGraphStorePresentation(targetStore); if (targetStore.storagePrimary === "indexeddb") { return { migrated: false, @@ -9225,14 +9431,29 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( !Array.isArray(legacySnapshot.state) ? legacySnapshot.state : {}; + const migrationSource = authorityTarget + ? "legacy_indexeddb_to_authority" + : "legacy_indexeddb_snapshot"; + const safetySnapshotResult = authorityTarget + ? await captureAuthorityMigrationSafetySnapshot(normalizedChatId, legacySnapshot, { + source: migrationSource, + reason: "authority-indexeddb-migration-safety", + }) + : null; const importSnapshot = { meta: { ...legacyMeta, chatId: normalizedChatId, migrationCompletedAt: nowMs, - migrationSource: "legacy_indexeddb_snapshot", + migrationSource, migratedFromStoragePrimary: "indexeddb", migratedFromStorageMode: BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB, + migratedToStoragePrimary: authorityTarget + ? AUTHORITY_GRAPH_STORE_KIND + : targetStore.storagePrimary, + migratedToStorageMode: authorityTarget + ? AUTHORITY_GRAPH_STORE_MODE + : targetStore.storageMode, }, state: { ...legacyState, @@ -9258,9 +9479,15 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( mode: "replace", preserveRevision: true, revision: normalizedRevision, - markSyncDirty: Boolean(legacyMeta.syncDirty), + markSyncDirty: authorityTarget ? false : Boolean(legacyMeta.syncDirty), }); const snapshot = await targetDb.exportSnapshot(); + if (authorityTarget) { + recordAuthorityAcceptedRevisionPointer({ + revision: snapshot?.meta?.revision || migrationResult?.revision || normalizedRevision, + integrity: snapshot?.meta?.integrity || legacyMeta.integrity, + }); + } debugDebug("[ST-BME] 已将 legacy IndexedDB 快照迁移到当前本地存储", { source, @@ -9272,12 +9499,15 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( return { migrated: true, - reason: "migration-local-store-completed", - source: "legacy_indexeddb_snapshot", + reason: authorityTarget + ? "authority-indexeddb-migration-completed" + : "migration-local-store-completed", + source: migrationSource, chatId: normalizedChatId, migrationResult, snapshot, targetStore, + safetySnapshotResult, }; } catch (error) { console.warn("[ST-BME] 迁移 legacy IndexedDB 快照到当前本地存储失败:", { @@ -10148,6 +10378,8 @@ async function loadGraphFromIndexedDb( identityRecoveryResult?.snapshot, localStore, ); + const recoveredAuthorityStore = + isAuthorityGraphStorePresentation(recoveredStore); const recoveredRevision = normalizeIndexedDbRevision( identityRecoveryResult?.snapshot?.meta?.revision, ); @@ -10158,6 +10390,19 @@ async function loadGraphFromIndexedDb( localStoreFormatVersion: recoveredStore.storagePrimary === "opfs" ? 2 : 1, localStoreMigrationState: "completed", + authorityMigrationState: recoveredAuthorityStore ? "completed" : graphPersistenceState.authorityMigrationState, + authorityMigrationSource: recoveredAuthorityStore + ? String(identityRecoveryResult?.source || "identity-recovery") + : graphPersistenceState.authorityMigrationSource, + authorityMigrationRevision: recoveredAuthorityStore + ? recoveredRevision + : graphPersistenceState.authorityMigrationRevision, + authorityMigrationLastError: recoveredAuthorityStore + ? "" + : graphPersistenceState.authorityMigrationLastError, + lastAuthorityMigrationResult: recoveredAuthorityStore + ? cloneRuntimeDebugValue(identityRecoveryResult, null) + : graphPersistenceState.lastAuthorityMigrationResult, indexedDbRevision: recoveredRevision, indexedDbLastError: "", lastSyncError: "", @@ -10195,7 +10440,9 @@ async function loadGraphFromIndexedDb( ); const migrationResult = - identityRecoveryResult?.migrated || localStoreMigrationResult?.migrated + identityRecoveryResult?.migrated || + localStoreMigrationResult?.migrated || + localStoreMigrationResult?.reason === "migration-local-store-failed" ? localStoreMigrationResult : await maybeMigrateLegacyGraphToIndexedDb( normalizedChatId, @@ -10211,6 +10458,8 @@ async function loadGraphFromIndexedDb( migrationResult?.snapshot, localStore, ); + const migratedAuthorityStore = + isAuthorityGraphStorePresentation(migratedStore); const migratedRevision = normalizeIndexedDbRevision( migrationResult?.snapshot?.meta?.revision || migrationResult?.migrationResult?.revision, @@ -10222,6 +10471,19 @@ async function loadGraphFromIndexedDb( localStoreFormatVersion: migratedStore.storagePrimary === "opfs" ? 2 : 1, localStoreMigrationState: "completed", + authorityMigrationState: migratedAuthorityStore ? "completed" : graphPersistenceState.authorityMigrationState, + authorityMigrationSource: migratedAuthorityStore + ? String(migrationResult?.source || migrationResult?.reason || "") + : graphPersistenceState.authorityMigrationSource, + authorityMigrationRevision: migratedAuthorityStore + ? migratedRevision + : graphPersistenceState.authorityMigrationRevision, + authorityMigrationLastError: migratedAuthorityStore + ? "" + : graphPersistenceState.authorityMigrationLastError, + lastAuthorityMigrationResult: migratedAuthorityStore + ? cloneRuntimeDebugValue(migrationResult, null) + : graphPersistenceState.lastAuthorityMigrationResult, indexedDbRevision: migratedRevision, indexedDbLastError: "", lastSyncError: "", @@ -10236,12 +10498,27 @@ async function loadGraphFromIndexedDb( syncResult: cloneRuntimeDebugValue(migrationResult?.syncResult, null), }, }); - } else if (migrationResult?.reason === "migration-failed") { + } else if ( + migrationResult?.reason === "migration-failed" || + migrationResult?.reason === "migration-local-store-failed" + ) { updateGraphPersistenceState({ indexedDbLastError: String( migrationResult?.error || "migration-failed", ), localStoreMigrationState: "failed", + authorityMigrationState: + localStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND + ? "failed" + : graphPersistenceState.authorityMigrationState, + authorityMigrationLastError: + localStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND + ? String(migrationResult?.error || migrationResult?.reason || "migration-failed") + : graphPersistenceState.authorityMigrationLastError, + lastAuthorityMigrationResult: + localStore.storagePrimary === AUTHORITY_GRAPH_STORE_KIND + ? cloneRuntimeDebugValue(migrationResult, null) + : graphPersistenceState.lastAuthorityMigrationResult, dualWriteLastResult: { action: "migration", source: "chat_metadata", diff --git a/sync/bme-sync.js b/sync/bme-sync.js index 80b66e0..7fd4ac7 100644 --- a/sync/bme-sync.js +++ b/sync/bme-sync.js @@ -662,7 +662,7 @@ async function writeBackupEnvelope(envelope, chatId, options = {}) { }; } -async function createRestoreSafetySnapshot(chatId, snapshot, options = {}) { +export async function createRestoreSafetySnapshot(chatId, snapshot, options = {}) { const safetyDb = await getSafetyDb(chatId, options); const revision = normalizeRevision(snapshot?.meta?.revision); try { diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 35444e2..a2db2ee 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -111,6 +111,7 @@ import { createAuthorityBrowserState, getAuthorityBrowserStateSnapshot, normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, } from "../sync/authority-browser-state.js"; import { AUTHORITY_GRAPH_STORE_KIND, @@ -323,6 +324,173 @@ async function createGraphPersistenceHarness({ indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot)); } + const authoritySnapshotMap = new Map(); + + function getAuthoritySnapshotForChat(targetChatId = "") { + const normalizedChatId = String(targetChatId || ""); + if (normalizedChatId && authoritySnapshotMap.has(normalizedChatId)) { + return structuredClone(authoritySnapshotMap.get(normalizedChatId)); + } + return { + meta: { + revision: 0, + chatId: normalizedChatId, + storagePrimary: AUTHORITY_GRAPH_STORE_KIND, + storageMode: AUTHORITY_GRAPH_STORE_MODE, + }, + nodes: [], + edges: [], + tombstones: [], + state: { lastProcessedFloor: -1, extractionCount: 0 }, + }; + } + + function setAuthoritySnapshotForChat(targetChatId = "", snapshot = null) { + const normalizedChatId = String(targetChatId || ""); + if (!normalizedChatId) return; + if (!snapshot) { + authoritySnapshotMap.delete(normalizedChatId); + return; + } + authoritySnapshotMap.set(normalizedChatId, structuredClone(snapshot)); + } + + class HarnessAuthorityGraphStore { + constructor(dbChatId = "") { + this.chatId = String(dbChatId || ""); + this.storeKind = AUTHORITY_GRAPH_STORE_KIND; + this.storeMode = AUTHORITY_GRAPH_STORE_MODE; + } + async open() {} + async close() {} + getStorageDiagnosticsSync() { + return { + formatVersion: 1, + migrationState: "idle", + resolvedStoreMode: AUTHORITY_GRAPH_STORE_MODE, + storageKind: AUTHORITY_GRAPH_STORE_KIND, + browserCacheMode: "minimal", + }; + } + async exportSnapshot() { + return getAuthoritySnapshotForChat(this.chatId); + } + async exportSnapshotProbe() { + return getAuthoritySnapshotForChat(this.chatId); + } + async importSnapshot(snapshot = {}, options = {}) { + const current = getAuthoritySnapshotForChat(this.chatId); + const currentRevision = Number(current?.meta?.revision || 0); + const incomingRevision = Number(snapshot?.meta?.revision || 0); + const requestedRevision = Number.isFinite(Number(options?.revision)) + ? Math.max(0, Math.floor(Number(options.revision))) + : options?.preserveRevision === true + ? Math.max(0, Math.floor(incomingRevision)) + : currentRevision + 1; + const nextRevision = Math.max(currentRevision + 1, requestedRevision); + const nowMs = Date.now(); + const nodes = Array.isArray(snapshot?.nodes) + ? snapshot.nodes.map((node) => structuredClone(node)) + : []; + const edges = Array.isArray(snapshot?.edges) + ? snapshot.edges.map((edge) => structuredClone(edge)) + : []; + const tombstones = Array.isArray(snapshot?.tombstones) + ? snapshot.tombstones.map((record) => structuredClone(record)) + : []; + const nextSnapshot = { + meta: { + ...(snapshot?.meta && typeof snapshot.meta === "object" + ? structuredClone(snapshot.meta) + : {}), + chatId: this.chatId, + storagePrimary: AUTHORITY_GRAPH_STORE_KIND, + storageMode: AUTHORITY_GRAPH_STORE_MODE, + revision: nextRevision, + lastModified: nowMs, + lastMutationReason: "importSnapshot", + syncDirty: options?.markSyncDirty !== false, + syncDirtyReason: options?.markSyncDirty === false ? "" : "importSnapshot", + nodeCount: nodes.length, + edgeCount: edges.length, + tombstoneCount: tombstones.length, + }, + nodes, + edges, + tombstones, + state: + snapshot?.state && typeof snapshot.state === "object" + ? structuredClone(snapshot.state) + : { lastProcessedFloor: -1, extractionCount: 0 }, + }; + setAuthoritySnapshotForChat(this.chatId, nextSnapshot); + return { + mode: String(options?.mode || "replace"), + revision: nextRevision, + imported: { + nodes: nodes.length, + edges: edges.length, + tombstones: tombstones.length, + }, + }; + } + async importLegacyGraph(graph, options = {}) { + const revision = Math.max(1, Math.floor(Number(options?.revision) || 1)); + const snapshot = buildSnapshotFromGraph(graph, { + chatId: this.chatId, + revision, + meta: { + migrationCompletedAt: Number(options?.nowMs || Date.now()), + migrationSource: String(options?.source || "chat_metadata"), + }, + }); + const importResult = await this.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision, + markSyncDirty: options?.markSyncDirty, + }); + return { + migrated: true, + revision: importResult.revision, + imported: importResult.imported, + }; + } + async isEmpty() { + const snapshot = getAuthoritySnapshotForChat(this.chatId); + 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 getRevision() { + return Number(getAuthoritySnapshotForChat(this.chatId)?.meta?.revision || 0); + } + async getMeta(key, fallbackValue = 0) { + const snapshot = getAuthoritySnapshotForChat(this.chatId); + if (!snapshot?.meta || !(key in snapshot.meta)) { + return fallbackValue; + } + return snapshot.meta[key]; + } + async patchMeta(record = {}) { + const snapshot = getAuthoritySnapshotForChat(this.chatId); + snapshot.meta = { + ...(snapshot.meta || {}), + ...(record && typeof record === "object" ? structuredClone(record) : {}), + }; + setAuthoritySnapshotForChat(this.chatId, snapshot); + return record; + } + } + function commitIndexedDbDelta(targetChatId = "", delta = {}, options = {}) { const normalizedChatId = String(targetChatId || ""); const currentSnapshot = getIndexedDbSnapshotForChat(normalizedChatId); @@ -442,6 +610,9 @@ async function createGraphPersistenceHarness({ String(chatId || globalChatId || ""), ), __indexedDbSnapshots: indexedDbSnapshotMap, + __authoritySnapshots: authoritySnapshotMap, + __getAuthoritySnapshotForChat: getAuthoritySnapshotForChat, + __setAuthoritySnapshotForChat: setAuthoritySnapshotForChat, sessionStorage: storage, localStorage, extension_settings: { @@ -457,9 +628,10 @@ async function createGraphPersistenceHarness({ createAuthorityBrowserState, getAuthorityBrowserStateSnapshot, normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, AUTHORITY_GRAPH_STORE_KIND, AUTHORITY_GRAPH_STORE_MODE, - AuthorityGraphStore, + AuthorityGraphStore: HarnessAuthorityGraphStore, migrateLegacyTaskProfiles(settings = {}) { return { taskProfilesVersion: Number(settings?.taskProfilesVersion || 0), @@ -1071,6 +1243,32 @@ async function createGraphPersistenceHarness({ hasGraphPersistDirtyState, pruneGraphPersistDirtyState, buildBmeDbName, + buildRestoreSafetyChatId(chatId = "") { + return `__restore_safety__${String(chatId || "").trim()}`; + }, + async createRestoreSafetySnapshot(chatId, snapshot, options = {}) { + const safetyDb = + typeof options?.getSafetyDb === "function" + ? await options.getSafetyDb(chatId) + : new runtimeContext.BmeDatabase( + runtimeContext.buildRestoreSafetyChatId(chatId), + ); + await safetyDb.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: Number(snapshot?.meta?.revision || 0), + markSyncDirty: false, + }); + if (typeof safetyDb.patchMeta === "function") { + await safetyDb.patchMeta({ + restoreSafetySnapshotExists: true, + restoreSafetySnapshotChatId: String(chatId || "").trim(), + }); + } + if (typeof safetyDb.close === "function") { + await safetyDb.close(); + } + }, BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto", BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb", BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY: "opfs-primary", @@ -1154,10 +1352,78 @@ async function createGraphPersistenceHarness({ revision: Number(snapshot?.meta?.revision) || 0, }; } + async patchMeta(record = {}) { + const snapshot = getIndexedDbSnapshotForChat(this.chatId); + snapshot.meta = { + ...(snapshot.meta || {}), + ...(record && typeof record === "object" ? structuredClone(record) : {}), + }; + setIndexedDbSnapshotForChat(this.chatId, snapshot); + return record; + } + async getMeta(key, fallbackValue = 0) { + const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {}; + if (!snapshot?.meta || !(key in snapshot.meta)) { + return fallbackValue; + } + return snapshot.meta[key]; + } + async getRevision() { + const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {}; + return Number(snapshot?.meta?.revision) || 0; + } + async isEmpty() { + const snapshot = getIndexedDbSnapshotForChat(this.chatId) || {}; + 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; + const migratedSnapshot = buildSnapshotFromGraph(graph, { + chatId: this.chatId || runtimeContext.__chatContext?.chatId || "", + revision, + meta: { + migrationCompletedAt: Date.now(), + migrationSource: "chat_metadata", + }, + }); + setIndexedDbSnapshotForChat(this.chatId, migratedSnapshot); + return { + migrated: true, + revision, + imported: { + nodes: migratedSnapshot?.nodes?.length || 0, + edges: migratedSnapshot?.edges?.length || 0, + tombstones: migratedSnapshot?.tombstones?.length || 0, + }, + }; + } + async markSyncDirty() { + if (runtimeContext.__markSyncDirtyShouldThrow) { + throw new Error("mark-sync-dirty-failed"); + } + } }, BmeChatManager: class { - constructor() { + constructor(options = {}) { this._currentChatId = ""; + this._databaseFactory = + typeof options?.databaseFactory === "function" + ? options.databaseFactory + : null; + this._selectorKeyResolver = + typeof options?.selectorKeyResolver === "function" + ? options.selectorKeyResolver + : null; } _createDb(dbChatId = "") { return { @@ -1249,6 +1515,16 @@ async function createGraphPersistenceHarness({ if (runtimeContext.__indexedDbGetCurrentDbShouldThrow) { throw new Error("indexeddb-get-current-db-failed"); } + const selectorKey = this._selectorKeyResolver + ? String(await this._selectorKeyResolver(this._currentChatId) || "") + : ""; + if (this._databaseFactory && selectorKey.startsWith("authority:")) { + const db = await this._databaseFactory(this._currentChatId); + if (typeof db?.open === "function") { + await db.open(); + } + return db; + } return this._createDb(this._currentChatId); } async switchChat(dbChatId = "") { @@ -1256,6 +1532,16 @@ async function createGraphPersistenceHarness({ runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat( this._currentChatId, ); + const selectorKey = this._selectorKeyResolver + ? String(await this._selectorKeyResolver(this._currentChatId) || "") + : ""; + if (this._databaseFactory && selectorKey.startsWith("authority:")) { + const db = await this._databaseFactory(this._currentChatId); + if (typeof db?.open === "function") { + await db.open(); + } + return db; + } return this._createDb(this._currentChatId); } async closeCurrent() {} @@ -1327,6 +1613,20 @@ result = { }; return bmeLocalStoreCapabilitySnapshot; }, + setAuthorityCapabilityState(patch = {}) { + authorityCapabilityState = normalizeAuthorityCapabilityState( + { + ...authorityCapabilityState, + ...(patch || {}), + }, + getSettings(), + ); + authorityBrowserState = normalizeAuthorityBrowserState( + authorityBrowserState, + getSettings(), + ); + return authorityCapabilityState; + }, setChatContext(nextContext) { globalThis.__chatContext = nextContext; return globalThis.__chatContext; @@ -1362,6 +1662,9 @@ result = { const snapshot = globalThis.__indexedDbSnapshots.get(normalizedChatId); return snapshot ? structuredClone(snapshot) : null; }, + getAuthoritySnapshotForChat(chatId) { + return globalThis.__getAuthoritySnapshotForChat(chatId); + }, }; `, ].join("\n"), @@ -3642,6 +3945,92 @@ result = { ); } +{ + const chatId = "meta-authority-indexeddb-migration"; + const legacyGraph = stampPersistedGraph( + createMeaningfulGraph(chatId, "authority-indexeddb-migration"), + { + revision: 4, + integrity: "meta-authority-indexeddb-migration", + chatId, + reason: "legacy-indexeddb", + }, + ); + const legacySnapshot = buildSnapshotFromGraph(legacyGraph, { + chatId, + revision: 4, + meta: { + integrity: "meta-authority-indexeddb-migration", + storagePrimary: "indexeddb", + storageMode: "indexeddb", + syncDirty: true, + }, + }); + const harness = await createGraphPersistenceHarness({ + chatId, + globalChatId: chatId, + chatMetadata: { + integrity: "meta-authority-indexeddb-migration", + }, + indexedDbSnapshot: legacySnapshot, + }); + harness.runtimeContext.extension_settings[MODULE_NAME] = { + authorityEnabled: "on", + authorityPrimaryWhenAvailable: true, + authorityStorageMode: "server-primary", + authoritySqlPrimary: true, + authorityBrowserCacheMode: "minimal", + }; + harness.api.setAuthorityCapabilityState({ + installed: true, + healthy: true, + sessionReady: true, + permissionReady: true, + features: ["sql.query", "sql.mutation", "trivium.search", "jobs", "blob"], + reason: "ok", + lastProbeAt: Date.now(), + }); + + const result = await harness.api.loadGraphFromIndexedDb(chatId, { + source: "authority-indexeddb-migration", + attemptIndex: 0, + }); + + assert.equal(result.loaded, true); + assert.equal(result.reason, "authority-sql:authority-indexeddb-migration"); + assert.equal(harness.runtimeContext.__syncNowCalls.length, 0); + const authoritySnapshot = harness.api.getAuthoritySnapshotForChat(chatId); + assert.equal(authoritySnapshot?.nodes?.length, 1); + assert.equal(authoritySnapshot?.meta?.storagePrimary, AUTHORITY_GRAPH_STORE_KIND); + assert.equal(authoritySnapshot?.meta?.storageMode, AUTHORITY_GRAPH_STORE_MODE); + assert.equal( + authoritySnapshot?.meta?.migrationSource, + "legacy_indexeddb_to_authority", + ); + assert.equal(authoritySnapshot?.meta?.syncDirty, false); + const safetySnapshot = harness.api.getIndexedDbSnapshotForChat( + harness.runtimeContext.buildRestoreSafetyChatId(chatId), + ); + assert.equal(safetySnapshot?.nodes?.length, 1); + assert.equal(safetySnapshot?.meta?.restoreSafetySnapshotExists, true); + assert.equal(safetySnapshot?.meta?.restoreSafetySnapshotChatId, chatId); + const live = harness.api.getGraphPersistenceLiveState(); + assert.equal(live.storagePrimary, AUTHORITY_GRAPH_STORE_KIND); + assert.equal(live.storageMode, AUTHORITY_GRAPH_STORE_MODE); + assert.equal(live.authorityMigrationState, "completed"); + assert.equal(live.authorityMigrationSource, "legacy_indexeddb_to_authority"); + assert.equal(Number(live.authorityMigrationRevision), 4); + assert.equal( + live.lastAuthorityMigrationResult?.safetySnapshotResult?.restoreSafetyCaptured, + true, + ); + assert.equal( + harness.runtimeContext.__indexedDbSnapshots.has(chatId), + true, + "迁移成功后仍保留 legacy IndexedDB 源数据,不删除本地数据", + ); +} + { const harness = await createGraphPersistenceHarness({ chatId: "chat-b", diff --git a/tests/helpers/generation-recall-harness.mjs b/tests/helpers/generation-recall-harness.mjs index 3e31a35..4859afc 100644 --- a/tests/helpers/generation-recall-harness.mjs +++ b/tests/helpers/generation-recall-harness.mjs @@ -56,6 +56,7 @@ import { createAuthorityBrowserState, getAuthorityBrowserStateSnapshot, normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, } from "../../sync/authority-browser-state.js"; const moduleDir = path.dirname(fileURLToPath(import.meta.url)); @@ -107,6 +108,7 @@ export function createGenerationRecallHarness(options = {}) { createAuthorityBrowserState, getAuthorityBrowserStateSnapshot, normalizeAuthorityBrowserState, + recordAuthorityAcceptedRevision, settings: {}, graphPersistenceState: createGraphPersistenceState(), extension_settings: { [MODULE_NAME]: {} }, diff --git a/ui/ui-status.js b/ui/ui-status.js index bfa325f..2264b8c 100644 --- a/ui/ui-status.js +++ b/ui/ui-status.js @@ -141,6 +141,11 @@ export function createGraphPersistenceState() { authorityOfflineQueueBytes: 0, authorityOfflineQueueItems: 0, authorityDegradedReason: "", + authorityMigrationState: "idle", + authorityMigrationSource: "", + authorityMigrationRevision: 0, + authorityMigrationLastError: "", + lastAuthorityMigrationResult: null, localStoreFormatVersion: 1, localStoreMigrationState: "idle", opfsWriteLockState: {