fix(migration): 7 critical fixes for Authority migration safety and data integrity

- 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
This commit is contained in:
Youzini-afk
2026-04-29 01:15:37 +08:00
parent 79fbd369ba
commit d702e267d3
5 changed files with 574 additions and 11 deletions

460
index.js
View File

@@ -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) {

View File

@@ -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: [],

View File

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

View File

@@ -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,

View File

@@ -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,