From d702e267d3a90995fdccc39f3abc89ff31b56c50 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Wed, 29 Apr 2026 01:15:37 +0800 Subject: [PATCH] fix(migration): 7 critical fixes for Authority migration safety and data integrity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix #2: _executeStatements fallback now batches transactions (150/batch) and reorders upsert-before-delete to prevent data loss on payload overflow - Fix #3: Read/write migratedToAuthority marker in chat_metadata to prevent re-overwriting from legacy sources after Authority migration - Fix #1: Add OPFS → Authority migration channel (exportOpfsSnapshotForChat, maybeImportLegacyOpfsSnapshotToLocalStore) inserted before IndexedDB in migration chain - Fix #4: Mark runtimeVectorIndexState dirty with triviumRebuildRequired and trigger submitAuthorityVectorRebuildJob after all three migration paths - Fix #5: Dual-save safety snapshots to Authority blob; rollback can now recover from blob when local IndexedDB snapshot is unavailable - Fix #6: Add isEmptyCheck detail and console.warn for near-empty stores to help diagnose residual vs real data in store-not-empty skips - Fix #7: Add overflow warning logs and sessionStorage persistence for Authority offline queue max-items/max-bytes exceeded events --- index.js | 460 +++++++++++++++++++++++++++++++- sync/authority-browser-state.js | 27 ++ sync/authority-graph-store.js | 42 ++- sync/bme-opfs-store.js | 12 + sync/bme-sync.js | 44 +++ 5 files changed, 574 insertions(+), 11 deletions(-) diff --git a/index.js b/index.js index 5c79ffd..170707c 100644 --- a/index.js +++ b/index.js @@ -174,6 +174,7 @@ import { removeGraphShadowSnapshot, rememberGraphIdentityAlias, readGraphCommitMarker, + normalizeGraphCommitMarker, readGraphChatStateSnapshot, readLukerGraphSidecarV2, replaceLukerGraphJournalV2, @@ -1383,6 +1384,7 @@ const bmeIndexedDbWriteInFlightByChatId = new Map(); const bmeIndexedDbRuntimeRepairInFlightByChatId = new Set(); const bmeIndexedDbLegacyMigrationInFlightByChatId = new Map(); const bmeIndexedDbLocalStoreMigrationInFlightByChatId = new Map(); +const bmeIndexedDbOpfsMigrationInFlightByChatId = new Map(); const bmeIndexedDbLatestQueuedRevisionByChatId = new Map(); const bmeChatStateManifestCacheByChatId = new Map(); const bmeChatStateLoadInFlightByChatId = new Map(); @@ -1622,10 +1624,25 @@ async function captureAuthorityMigrationSafetySnapshot( } catch (shadowError) { console.warn("[ST-BME] Authority 迁移影子安全快照创建失败:", shadowError); } + let blobCaptured = false; + try { + const blobAdapter = getAuthorityBlobAdapter(); + if (blobAdapter && typeof blobAdapter.writeJson === "function") { + await blobAdapter.writeJson( + `ST-BME/migration-safety/${normalizedChatId}.json`, + snapshot, + { namespace: "st-bme-safety" }, + ); + blobCaptured = true; + } + } catch (blobError) { + console.warn("[ST-BME] 安全快照写入 Authority blob 失败(非致命):", blobError); + } return { captured: true, restoreSafetyCaptured: true, shadowCaptured, + blobCaptured, reason: "authority-migration-restore-safety-created", chatId: normalizedChatId, revision, @@ -7864,6 +7881,34 @@ async function exportIndexedDbSnapshotForChat(chatId = "") { } } +async function exportOpfsSnapshotForChat(chatId) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return null; + if (!bmeLocalStoreCapabilitySnapshot?.opfsAvailable) return null; + try { + if (typeof OpfsGraphStore !== "function") return null; + const opfsDb = new OpfsGraphStore(normalizedChatId); + await opfsDb.open(); + try { + const emptyStatus = await opfsDb.isEmpty(); + if (emptyStatus?.empty) return null; + const snapshot = await opfsDb.exportSnapshot({ includeTombstones: true }); + if (!isIndexedDbSnapshotMeaningful(snapshot)) return null; + snapshot.meta = { + ...snapshot.meta, + migratedFromStoragePrimary: "opfs", + migratedFromStorageMode: opfsDb.storeMode || "opfs-primary", + }; + return snapshot; + } finally { + if (typeof opfsDb.close === "function") await opfsDb.close(); + } + } catch (error) { + console.warn("[ST-BME] 导出 OPFS 旧快照失败:", error); + return null; + } +} + function buildRecoveredSnapshotForChatIdentity( graph, targetChatId, @@ -10915,10 +10960,19 @@ function scheduleGraphChatStateProbe(chatId, options = {}) { }); } +function isChatMetadataMigratedToAuthority(context = null) { + const marker = readGraphCommitMarker(context || getContext()); + return marker?.migratedToAuthority === true; +} + function readLegacyGraphFromChatMetadata(chatId, context = getContext()) { const normalizedChatId = normalizeChatIdCandidate(chatId); if (!normalizedChatId) return null; + if (isChatMetadataMigratedToAuthority(context)) { + return null; + } + const legacyGraph = context?.chatMetadata?.[GRAPH_METADATA_KEY]; if (!legacyGraph) return null; @@ -11312,6 +11366,16 @@ async function maybeMigrateLegacyGraphToIndexedDb( const emptyStatus = await targetDb.isEmpty(); if (!emptyStatus?.empty) { + const existingNodes = Number(emptyStatus?.nodes || 0); + const existingEdges = Number(emptyStatus?.edges || 0); + if (existingNodes + existingEdges < 5) { + console.warn( + "[ST-BME] Authority store 非空但数据量极少,可能是残留数据。" + + `节点: ${existingNodes}, 边: ${existingEdges}。` + + "如需强制重新迁移,请在面板中清除 Authority 数据后重试。", + { chatId: normalizedChatId, emptyStatus }, + ); + } return { migrated: false, reason: "migration-indexeddb-not-empty", @@ -11367,6 +11431,52 @@ async function maybeMigrateLegacyGraphToIndexedDb( integrity: postMigrationSnapshot?.meta?.integrity, }); } + + if (authorityTarget && migrationResult?.migrated) { + try { + writeChatMetadataPatch(context, { + [GRAPH_COMMIT_MARKER_KEY]: { + ...normalizeGraphCommitMarker(readGraphCommitMarker(context)), + migratedToAuthority: true, + migratedAt: new Date().toISOString(), + migratedRevision: migrationResult.revision || legacyRevision, + }, + }); + } catch (markerError) { + console.warn("[ST-BME] 写入迁移完成标记失败(非致命):", markerError); + } + } + + if (authorityTarget && migrationResult?.migrated) { + try { + await targetDb.patchMeta({ + runtimeVectorIndexState: { + dirty: true, + dirtyReason: "authority-migration-trivium-rebuild", + triviumRebuildRequired: true, + lastWarning: "Authority 迁移完成,Trivium 向量需重建", + }, + }); + } catch (metaError) { + console.warn("[ST-BME] 写入 Trivium 重建标记失败(非致命):", metaError); + } + try { + const settings = getSettings(); + if (shouldUseAuthorityJobs(settings)) { + const vectorConfig = normalizeAuthorityVectorConfig(settings, buildAuthorityGraphStoreOptions(settings)); + if (vectorConfig?.mode === "authority") { + await submitAuthorityVectorRebuildJob({ + config: vectorConfig, + purge: true, + reason: "authority-migration-trivium-rebuild", + }); + } + } + } catch (vectorJobError) { + console.warn("[ST-BME] 迁移后触发 Trivium 重建 Job 失败(非阻塞):", vectorJobError); + } + } + debugDebug("[ST-BME] legacy chat_metadata 图谱迁移完成", { source, chatId: normalizedChatId, @@ -11492,6 +11602,16 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( const emptyStatus = await targetDb.isEmpty(); if (!emptyStatus?.empty) { + const existingNodes = Number(emptyStatus?.nodes || 0); + const existingEdges = Number(emptyStatus?.edges || 0); + if (existingNodes + existingEdges < 5) { + console.warn( + "[ST-BME] 本地存储非空但数据量极少,可能是残留数据。" + + `节点: ${existingNodes}, 边: ${existingEdges}。` + + "如需强制重新迁移,请在面板中清除数据后重试。", + { chatId: normalizedChatId, emptyStatus }, + ); + } return { migrated: false, reason: "migration-local-store-not-empty", @@ -11586,6 +11706,53 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( }); } + if (authorityTarget && migrationResult?.imported !== undefined) { + try { + const ctx = getContext(); + writeChatMetadataPatch(ctx, { + [GRAPH_COMMIT_MARKER_KEY]: { + ...normalizeGraphCommitMarker(readGraphCommitMarker(ctx)), + migratedToAuthority: true, + migratedAt: new Date().toISOString(), + migratedRevision: migrationResult.revision || normalizedRevision, + migrationSource: migrationSource, + }, + }); + } catch (markerError) { + console.warn("[ST-BME] 写入 IndexedDB→Authority 迁移完成标记失败(非致命):", markerError); + } + } + + if (authorityTarget) { + try { + await targetDb.patchMeta({ + runtimeVectorIndexState: { + dirty: true, + dirtyReason: "authority-migration-trivium-rebuild", + triviumRebuildRequired: true, + lastWarning: "Authority 迁移完成,Trivium 向量需重建", + }, + }); + } catch (metaError) { + console.warn("[ST-BME] 写入 Trivium 重建标记失败(非致命):", metaError); + } + try { + const settings = getSettings(); + if (shouldUseAuthorityJobs(settings)) { + const vectorConfig = normalizeAuthorityVectorConfig(settings, buildAuthorityGraphStoreOptions(settings)); + if (vectorConfig?.mode === "authority") { + await submitAuthorityVectorRebuildJob({ + config: vectorConfig, + purge: true, + reason: "authority-migration-trivium-rebuild", + }); + } + } + } catch (vectorJobError) { + console.warn("[ST-BME] 迁移后触发 Trivium 重建 Job 失败(非阻塞):", vectorJobError); + } + } + debugDebug("[ST-BME] 已将 legacy IndexedDB 快照迁移到当前本地存储", { source, chatId: normalizedChatId, @@ -11636,6 +11803,265 @@ async function maybeImportLegacyIndexedDbSnapshotToLocalStore( return await migrationTask; } +async function maybeImportLegacyOpfsSnapshotToLocalStore( + chatId, + targetDb, + { source = "unknown" } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + migrated: false, + reason: "migration-opfs-missing-chat-id", + chatId: "", + }; + } + + const inFlightMigration = + bmeIndexedDbOpfsMigrationInFlightByChatId.get(normalizedChatId); + if (inFlightMigration) { + return await inFlightMigration; + } + + const migrationTask = (async () => { + try { + if ( + !targetDb || + typeof targetDb.isEmpty !== "function" || + typeof targetDb.importSnapshot !== "function" || + typeof targetDb.exportSnapshot !== "function" + ) { + return { + migrated: false, + reason: "migration-opfs-store-unavailable", + chatId: normalizedChatId, + }; + } + + const targetStore = resolveDbGraphStorePresentation(targetDb); + const authorityTarget = isAuthorityGraphStorePresentation(targetStore); + if (targetStore.storagePrimary === "opfs") { + return { + migrated: false, + reason: "migration-opfs-same-storage", + chatId: normalizedChatId, + }; + } + + 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 emptyStatus = await targetDb.isEmpty(); + if (!emptyStatus?.empty) { + const existingNodes = Number(emptyStatus?.nodes || 0); + const existingEdges = Number(emptyStatus?.edges || 0); + if (existingNodes + existingEdges < 5) { + console.warn( + "[ST-BME] OPFS 迁移目标非空但数据量极少,可能是残留数据。" + + `节点: ${existingNodes}, 边: ${existingEdges}。` + + "如需强制重新迁移,请在面板中清除数据后重试。", + { chatId: normalizedChatId, emptyStatus }, + ); + } + return { + migrated: false, + reason: "migration-opfs-target-not-empty", + chatId: normalizedChatId, + emptyStatus, + }; + } + + const legacySnapshot = await exportOpfsSnapshotForChat(normalizedChatId); + if (!isIndexedDbSnapshotMeaningful(legacySnapshot)) { + return { + migrated: false, + reason: "migration-opfs-legacy-snapshot-missing", + chatId: normalizedChatId, + }; + } + + const nowMs = Date.now(); + const normalizedRevision = Math.max( + normalizeIndexedDbRevision(legacySnapshot?.meta?.revision), + 1, + ); + const legacyMeta = + legacySnapshot?.meta && + typeof legacySnapshot.meta === "object" && + !Array.isArray(legacySnapshot.meta) + ? legacySnapshot.meta + : {}; + const legacyState = + legacySnapshot?.state && + typeof legacySnapshot.state === "object" && + !Array.isArray(legacySnapshot.state) + ? legacySnapshot.state + : {}; + const migrationSource = authorityTarget + ? "legacy_opfs_to_authority" + : "legacy_opfs_snapshot"; + const safetySnapshotResult = authorityTarget + ? await captureAuthorityMigrationSafetySnapshot(normalizedChatId, legacySnapshot, { + source: migrationSource, + reason: "authority-opfs-migration-safety", + }) + : null; + const importSnapshot = { + meta: { + ...legacyMeta, + chatId: normalizedChatId, + migrationCompletedAt: nowMs, + migrationSource, + migratedFromStoragePrimary: "opfs", + migratedFromStorageMode: legacyMeta.migratedFromStorageMode || "opfs-primary", + migratedToStoragePrimary: authorityTarget + ? AUTHORITY_GRAPH_STORE_KIND + : targetStore.storagePrimary, + migratedToStorageMode: authorityTarget + ? AUTHORITY_GRAPH_STORE_MODE + : targetStore.storageMode, + opfsMigrationCompletedAt: nowMs, + }, + state: { + ...legacyState, + }, + nodes: Array.isArray(legacySnapshot?.nodes) + ? legacySnapshot.nodes.map((node) => + cloneRuntimeDebugValue(node, node), + ) + : [], + edges: Array.isArray(legacySnapshot?.edges) + ? legacySnapshot.edges.map((edge) => + cloneRuntimeDebugValue(edge, edge), + ) + : [], + tombstones: Array.isArray(legacySnapshot?.tombstones) + ? legacySnapshot.tombstones.map((record) => + cloneRuntimeDebugValue(record, record), + ) + : [], + }; + + const migrationResult = await targetDb.importSnapshot(importSnapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizedRevision, + 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, + }); + } + + if (authorityTarget) { + try { + const ctx = getContext(); + writeChatMetadataPatch(ctx, { + [GRAPH_COMMIT_MARKER_KEY]: { + ...normalizeGraphCommitMarker(readGraphCommitMarker(ctx)), + migratedToAuthority: true, + migratedAt: new Date().toISOString(), + migratedRevision: migrationResult.revision || normalizedRevision, + migrationSource: migrationSource, + }, + }); + } catch (markerError) { + console.warn("[ST-BME] 写入 OPFS→Authority 迁移完成标记失败(非致命):", markerError); + } + } + + if (authorityTarget) { + try { + await targetDb.patchMeta({ + runtimeVectorIndexState: { + dirty: true, + dirtyReason: "authority-migration-trivium-rebuild", + triviumRebuildRequired: true, + lastWarning: "Authority 迁移完成,Trivium 向量需重建", + }, + }); + } catch (metaError) { + console.warn("[ST-BME] 写入 Trivium 重建标记失败(非致命):", metaError); + } + try { + const settings = getSettings(); + if (shouldUseAuthorityJobs(settings)) { + const vectorConfig = normalizeAuthorityVectorConfig(settings, buildAuthorityGraphStoreOptions(settings)); + if (vectorConfig?.mode === "authority") { + await submitAuthorityVectorRebuildJob({ + config: vectorConfig, + purge: true, + reason: "authority-migration-trivium-rebuild", + }); + } + } + } catch (vectorJobError) { + console.warn("[ST-BME] 迁移后触发 Trivium 重建 Job 失败(非阻塞):", vectorJobError); + } + } + + debugDebug("[ST-BME] 已将 legacy OPFS 快照迁移到当前本地存储", { + source, + chatId: normalizedChatId, + targetStore: cloneRuntimeDebugValue(targetStore, null), + revision: + snapshot?.meta?.revision || migrationResult?.revision || normalizedRevision, + }); + + return { + migrated: true, + reason: authorityTarget + ? "authority-opfs-migration-completed" + : "migration-opfs-completed", + source: migrationSource, + chatId: normalizedChatId, + migrationResult, + snapshot, + targetStore, + safetySnapshotResult, + }; + } catch (error) { + console.warn("[ST-BME] 迁移 legacy OPFS 快照到当前本地存储失败:", { + chatId: normalizedChatId, + error, + }); + return { + migrated: false, + reason: "migration-opfs-failed", + chatId: normalizedChatId, + error: error?.message || String(error), + }; + } + })().finally(() => { + if ( + bmeIndexedDbOpfsMigrationInFlightByChatId.get(normalizedChatId) === + migrationTask + ) { + bmeIndexedDbOpfsMigrationInFlightByChatId.delete( + normalizedChatId, + ); + } + }); + + bmeIndexedDbOpfsMigrationInFlightByChatId.set( + normalizedChatId, + migrationTask, + ); + return await migrationTask; +} + function applyIndexedDbEmptyToRuntime( chatId, { source = "indexeddb-empty", attemptIndex = 0 } = {}, @@ -12522,13 +12948,13 @@ async function loadGraphFromIndexedDb( }); } - const localStoreMigrationResult = identityRecoveryResult?.migrated + const opfsMigrationResult = identityRecoveryResult?.migrated ? { migrated: false, reason: "identity-recovery-already-applied", chatId: normalizedChatId, } - : await maybeImportLegacyIndexedDbSnapshotToLocalStore( + : await maybeImportLegacyOpfsSnapshotToLocalStore( normalizedChatId, db, { @@ -12536,18 +12962,36 @@ async function loadGraphFromIndexedDb( }, ); + const localStoreMigrationResult = + identityRecoveryResult?.migrated || opfsMigrationResult?.migrated + ? { + migrated: false, + reason: opfsMigrationResult?.migrated + ? "opfs-migration-already-applied" + : "identity-recovery-already-applied", + chatId: normalizedChatId, + } + : await maybeImportLegacyIndexedDbSnapshotToLocalStore( + normalizedChatId, + db, + { + source, + }, + ); + const migrationResult = identityRecoveryResult?.migrated || + opfsMigrationResult?.migrated || localStoreMigrationResult?.migrated || localStoreMigrationResult?.reason === "migration-local-store-failed" ? localStoreMigrationResult : await maybeMigrateLegacyGraphToIndexedDb( - normalizedChatId, - getContext(), - { - source, - db, - }, + normalizedChatId, + getContext(), + { + source, + db, + }, ); if (migrationResult?.migrated) { diff --git a/sync/authority-browser-state.js b/sync/authority-browser-state.js index 443b932..5809ef2 100644 --- a/sync/authority-browser-state.js +++ b/sync/authority-browser-state.js @@ -144,6 +144,18 @@ export function enqueueAuthorityOfflineMutation(state = {}, mutation = {}, setti const nextItems = [...current.offlineQueue, item]; const nextSummary = summarizeQueue(nextItems); if (policy.maxItems > 0 && nextSummary.items > policy.maxItems) { + console.warn( + `[ST-BME] Authority 离线队列溢出 (maxItems=${policy.maxItems}),新突变被丢弃。` + + "恢复连接后请手动同步图谱。", + { rejectedMutation: item }, + ); + try { + sessionStorage.setItem("st_bme:authority:overflow:global", JSON.stringify({ + overflowAt: new Date(nowMs).toISOString(), + reason: "max-items-exceeded", + lostItemCount: 1, + })); + } catch {} const nextState = createAuthorityBrowserState({ ...current, offlineQueueOverflow: true, @@ -153,6 +165,18 @@ export function enqueueAuthorityOfflineMutation(state = {}, mutation = {}, setti return { accepted: false, reason: "max-items-exceeded", state: nextState }; } if (policy.maxBytes > 0 && nextSummary.bytes > policy.maxBytes) { + console.warn( + `[ST-BME] Authority 离线队列溢出 (maxBytes=${policy.maxBytes}),新突变被丢弃。` + + "恢复连接后请手动同步图谱。", + { rejectedMutation: item }, + ); + try { + sessionStorage.setItem("st_bme:authority:overflow:global", JSON.stringify({ + overflowAt: new Date(nowMs).toISOString(), + reason: "max-bytes-exceeded", + lostItemCount: 1, + })); + } catch {} const nextState = createAuthorityBrowserState({ ...current, offlineQueueOverflow: true, @@ -176,6 +200,9 @@ export function enqueueAuthorityOfflineMutation(state = {}, mutation = {}, setti export function clearAuthorityOfflineQueue(state = {}, settings = {}, nowMs = Date.now()) { const current = normalizeAuthorityBrowserState(state, settings, nowMs); + try { + sessionStorage.removeItem("st_bme:authority:overflow:global"); + } catch {} return createAuthorityBrowserState({ ...current, offlineQueue: [], diff --git a/sync/authority-graph-store.js b/sync/authority-graph-store.js index 373eaaa..1eb0216 100644 --- a/sync/authority-graph-store.js +++ b/sync/authority-graph-store.js @@ -742,6 +742,12 @@ export class AuthorityGraphStore { edges: emptyStatus.edges, tombstones: emptyStatus.tombstones, }, + isEmptyCheck: { + empty: false, + nodes: emptyStatus.nodes, + edges: emptyStatus.edges, + tombstones: emptyStatus.tombstones, + }, migrationCompletedAt: 0, migrationSource, legacyRetentionUntil, @@ -1032,12 +1038,42 @@ export class AuthorityGraphStore { async _executeStatements(statements = []) { const normalizedStatements = toArray(statements).filter((statement) => statement?.sql); if (!normalizedStatements.length) return null; + + const BATCH_SIZE = 150; if (typeof this.sqlClient?.transaction === "function") { - return await this.sqlClient.transaction(normalizedStatements); + if (normalizedStatements.length <= BATCH_SIZE) { + return await this.sqlClient.transaction(normalizedStatements); + } + let lastResult = null; + for (let i = 0; i < normalizedStatements.length; i += BATCH_SIZE) { + const batch = normalizedStatements.slice(i, i + BATCH_SIZE); + lastResult = await this.sqlClient.transaction(batch); + } + return lastResult; + } + + const upsertStatements = []; + const deleteStatements = []; + for (const stmt of normalizedStatements) { + if (stmt.sql.trim().toUpperCase().startsWith("DELETE")) { + deleteStatements.push(stmt); + } else { + upsertStatements.push(stmt); + } } let result = null; - for (const statement of normalizedStatements) { - result = await this._execute(statement.sql, statement.params || {}); + for (const stmt of upsertStatements) { + result = await this._execute(stmt.sql, stmt.params || {}); + } + for (const stmt of deleteStatements) { + result = await this._execute(stmt.sql, stmt.params || {}); + } + if (deleteStatements.length > 0 && upsertStatements.length > 0) { + console.warn("[ST-BME] _executeStatements fallback 路径执行:先 upsert 后 delete,无事务保护", { + chatId: this.chatId, + upsertCount: upsertStatements.length, + deleteCount: deleteStatements.length, + }); } return result; } diff --git a/sync/bme-opfs-store.js b/sync/bme-opfs-store.js index a6d216b..a3ee11f 100644 --- a/sync/bme-opfs-store.js +++ b/sync/bme-opfs-store.js @@ -1320,6 +1320,12 @@ class LegacyOpfsGraphStore { edges: emptyStatus.edges, tombstones: emptyStatus.tombstones, }, + isEmptyCheck: { + empty: false, + nodes: emptyStatus.nodes, + edges: emptyStatus.edges, + tombstones: emptyStatus.tombstones, + }, migrationCompletedAt: 0, migrationSource, legacyRetentionUntil, @@ -2707,6 +2713,12 @@ export class OpfsGraphStore { edges: emptyStatus.edges, tombstones: emptyStatus.tombstones, }, + isEmptyCheck: { + empty: false, + nodes: emptyStatus.nodes, + edges: emptyStatus.edges, + tombstones: emptyStatus.tombstones, + }, migrationCompletedAt: 0, migrationSource, legacyRetentionUntil, diff --git a/sync/bme-sync.js b/sync/bme-sync.js index 82b869a..e72c628 100644 --- a/sync/bme-sync.js +++ b/sync/bme-sync.js @@ -1156,6 +1156,50 @@ export async function rollbackFromRestoreSafetySnapshot(chatId, options = {}) { try { const status = await getRestoreSafetySnapshotStatus(normalizedChatId, options); if (!status.exists) { + try { + const blobAdapter = getAuthorityBlobAdapter(options); + if (blobAdapter && typeof blobAdapter.readJson === "function") { + const blobSnapshot = await blobAdapter.readJson( + `ST-BME/migration-safety/${normalizedChatId}.json`, + { namespace: "st-bme-safety" }, + ); + if (blobSnapshot && typeof blobSnapshot === "object" && !Array.isArray(blobSnapshot)) { + const snapshot = normalizeSyncSnapshot(blobSnapshot, normalizedChatId); + const hasNodes = Array.isArray(snapshot.nodes) && snapshot.nodes.length > 0; + const hasEdges = Array.isArray(snapshot.edges) && snapshot.edges.length > 0; + if (hasNodes || hasEdges) { + const db = await getDb(normalizedChatId, options); + await db.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: normalizeRevision(snapshot.meta?.revision), + markSyncDirty: false, + }); + await patchDbMeta(db, { + deviceId: getOrCreateDeviceId(), + syncDirty: true, + syncDirtyReason: "restore-safety-rollback-from-blob", + lastBackupRollbackAt: Date.now(), + }); + await invokeSyncAppliedHook(options, { + chatId: normalizedChatId, + action: "restore-backup-from-blob", + revision: normalizeRevision(snapshot.meta?.revision), + }); + return { + restored: true, + chatId: normalizedChatId, + revision: normalizeRevision(snapshot.meta?.revision), + createdAt: 0, + blobRestored: true, + reason: "restored-from-authority-blob", + }; + } + } + } + } catch (blobError) { + console.warn("[ST-BME] 从 Authority blob 恢复安全快照失败:", blobError); + } return { restored: false, chatId: normalizedChatId,