feat(authority): migrate local graphs to server primary

This commit is contained in:
Youzini-afk
2026-04-28 03:02:43 +08:00
parent dc37d22dcf
commit 3f70d63a86
5 changed files with 715 additions and 42 deletions

355
index.js
View File

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

View File

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

View File

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

View File

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

View File

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