diff --git a/graph-persistence.js b/graph-persistence.js index eb676c2..1984671 100644 --- a/graph-persistence.js +++ b/graph-persistence.js @@ -21,6 +21,7 @@ export const GRAPH_LOAD_STATES = Object.freeze({ }); export const GRAPH_LOAD_PENDING_CHAT_ID = "__pending_chat__"; export const GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX = `${MODULE_NAME}:graph-shadow:`; +export const GRAPH_IDENTITY_ALIAS_STORAGE_KEY = `${MODULE_NAME}:chat-identity-aliases`; export const GRAPH_STARTUP_RECONCILE_DELAYS_MS = [150, 600, 1800, 4000]; // ═══════════════════════════════════════════════════════════ @@ -51,6 +52,247 @@ export function createLocalIntegritySlug() { export const GRAPH_PERSISTENCE_SESSION_ID = createLocalIntegritySlug(); +function normalizeIdentityValue(value) { + return String(value ?? "").trim(); +} + +function getLocalStorageSafe() { + const storage = globalThis.localStorage; + if ( + !storage || + typeof storage.getItem !== "function" || + typeof storage.setItem !== "function" + ) { + return null; + } + return storage; +} + +function getSessionStorageSafe() { + const storage = globalThis.sessionStorage; + if (!storage || typeof storage.getItem !== "function") { + return null; + } + return storage; +} + +function listStorageKeys(storage) { + if (!storage) return []; + + if (typeof storage.length === "number" && typeof storage.key === "function") { + const keys = []; + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (typeof key === "string" && key) { + keys.push(key); + } + } + return keys; + } + + if (storage.__store instanceof Map) { + return Array.from(storage.__store.keys()).map((key) => String(key)); + } + + return []; +} + +function readGraphIdentityAliasRegistryRaw() { + const storage = getLocalStorageSafe(); + if (!storage) { + return { + byIntegrity: {}, + }; + } + + try { + const raw = storage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY); + if (!raw) { + return { + byIntegrity: {}, + }; + } + + const parsed = JSON.parse(raw); + const byIntegrity = + parsed?.byIntegrity && + typeof parsed.byIntegrity === "object" && + !Array.isArray(parsed.byIntegrity) + ? parsed.byIntegrity + : {}; + + return { + byIntegrity, + }; + } catch { + return { + byIntegrity: {}, + }; + } +} + +function writeGraphIdentityAliasRegistryRaw(registry = null) { + const storage = getLocalStorageSafe(); + if (!storage) return false; + + try { + storage.setItem( + GRAPH_IDENTITY_ALIAS_STORAGE_KEY, + JSON.stringify({ + byIntegrity: + registry?.byIntegrity && + typeof registry.byIntegrity === "object" && + !Array.isArray(registry.byIntegrity) + ? registry.byIntegrity + : {}, + }), + ); + return true; + } catch { + return false; + } +} + +function normalizeGraphIdentityAliasEntry(entry = {}, integrity = "") { + const normalizedIntegrity = normalizeIdentityValue(integrity || entry.integrity); + const normalizedPersistenceChatId = normalizeIdentityValue( + entry.persistenceChatId || normalizedIntegrity, + ); + const normalizedHostChatIds = Array.from( + new Set( + (Array.isArray(entry.hostChatIds) ? entry.hostChatIds : []) + .map((value) => normalizeIdentityValue(value)) + .filter(Boolean), + ), + ).slice(-16); + + return { + integrity: normalizedIntegrity, + persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity, + hostChatIds: normalizedHostChatIds, + updatedAt: String(entry.updatedAt || ""), + }; +} + +export function rememberGraphIdentityAlias({ + integrity = "", + hostChatId = "", + persistenceChatId = "", +} = {}) { + const normalizedIntegrity = normalizeIdentityValue(integrity); + if (!normalizedIntegrity) return null; + + const normalizedHostChatId = normalizeIdentityValue(hostChatId); + const normalizedPersistenceChatId = normalizeIdentityValue( + persistenceChatId || normalizedIntegrity, + ); + const registry = readGraphIdentityAliasRegistryRaw(); + const existingEntry = normalizeGraphIdentityAliasEntry( + registry.byIntegrity?.[normalizedIntegrity] || {}, + normalizedIntegrity, + ); + const hostChatIds = Array.from( + new Set( + [normalizedHostChatId, ...existingEntry.hostChatIds].filter(Boolean), + ), + ).slice(-16); + const nextEntry = { + integrity: normalizedIntegrity, + persistenceChatId: normalizedPersistenceChatId || normalizedIntegrity, + hostChatIds, + updatedAt: new Date().toISOString(), + }; + + registry.byIntegrity[normalizedIntegrity] = nextEntry; + writeGraphIdentityAliasRegistryRaw(registry); + return nextEntry; +} + +export function resolveGraphIdentityAliasByHostChatId(hostChatId = "") { + const normalizedHostChatId = normalizeIdentityValue(hostChatId); + if (!normalizedHostChatId) return ""; + + const registry = readGraphIdentityAliasRegistryRaw(); + let bestEntry = null; + + for (const [integrity, value] of Object.entries(registry.byIntegrity || {})) { + const entry = normalizeGraphIdentityAliasEntry(value, integrity); + if (!entry.hostChatIds.includes(normalizedHostChatId)) { + continue; + } + + if (!bestEntry) { + bestEntry = entry; + continue; + } + + if (String(entry.updatedAt || "") > String(bestEntry.updatedAt || "")) { + bestEntry = entry; + } + } + + return normalizeIdentityValue(bestEntry?.persistenceChatId || ""); +} + +export function getGraphIdentityAliasCandidates({ + integrity = "", + hostChatId = "", + persistenceChatId = "", +} = {}) { + const normalizedIntegrity = normalizeIdentityValue(integrity); + const normalizedHostChatId = normalizeIdentityValue(hostChatId); + const normalizedPersistenceChatId = normalizeIdentityValue(persistenceChatId); + const registry = readGraphIdentityAliasRegistryRaw(); + const candidates = []; + const seen = new Set(); + const pushCandidate = (value) => { + const normalized = normalizeIdentityValue(value); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + candidates.push(normalized); + }; + + if (normalizedIntegrity) { + const entry = normalizeGraphIdentityAliasEntry( + registry.byIntegrity?.[normalizedIntegrity] || {}, + normalizedIntegrity, + ); + pushCandidate(entry.persistenceChatId); + for (const value of entry.hostChatIds) { + pushCandidate(value); + } + } else if (normalizedHostChatId) { + pushCandidate(resolveGraphIdentityAliasByHostChatId(normalizedHostChatId)); + } + + pushCandidate(normalizedHostChatId); + pushCandidate(normalizedPersistenceChatId); + return candidates; +} + +function normalizeShadowSnapshotPayload(snapshot = null) { + if (!snapshot || typeof snapshot !== "object") { + return null; + } + + const serializedGraph = String(snapshot.serializedGraph || ""); + const chatId = normalizeIdentityValue(snapshot.chatId); + if (!chatId || !serializedGraph) { + return null; + } + + return { + chatId, + revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0, + serializedGraph, + updatedAt: String(snapshot.updatedAt || ""), + reason: String(snapshot.reason || ""), + integrity: normalizeIdentityValue(snapshot.integrity), + persistedChatId: normalizeIdentityValue(snapshot.persistedChatId), + debugReason: String(snapshot.debugReason || snapshot.reason || ""), + }; +} + // ═══════════════════════════════════════════════════════════ // 图谱持久化元数据 // ═══════════════════════════════════════════════════════════ @@ -146,33 +388,72 @@ export function readGraphShadowSnapshot(chatId = "") { if (!storageKey) return null; try { - const raw = globalThis.sessionStorage?.getItem(storageKey); + const raw = getSessionStorageSafe()?.getItem(storageKey); if (!raw) return null; - const snapshot = JSON.parse(raw); - if ( - !snapshot || - typeof snapshot !== "object" || - String(snapshot.chatId || "") !== String(chatId || "") || - typeof snapshot.serializedGraph !== "string" || - !snapshot.serializedGraph - ) { + const snapshot = normalizeShadowSnapshotPayload(JSON.parse(raw)); + if (!snapshot || snapshot.chatId !== String(chatId || "")) { return null; } - return { - chatId: String(snapshot.chatId || ""), - revision: Number.isFinite(snapshot.revision) ? snapshot.revision : 0, - serializedGraph: snapshot.serializedGraph, - updatedAt: String(snapshot.updatedAt || ""), - reason: String(snapshot.reason || ""), - integrity: String(snapshot.integrity || ""), - persistedChatId: String(snapshot.persistedChatId || ""), - debugReason: String(snapshot.debugReason || snapshot.reason || ""), - }; + return snapshot; } catch { return null; } } +export function findGraphShadowSnapshotByIntegrity( + integrity = "", + { excludeChatIds = [] } = {}, +) { + const normalizedIntegrity = normalizeIdentityValue(integrity); + if (!normalizedIntegrity) return null; + + const storage = getSessionStorageSafe(); + if (!storage) return null; + + const excludedChatIds = new Set( + (Array.isArray(excludeChatIds) ? excludeChatIds : []) + .map((value) => normalizeIdentityValue(value)) + .filter(Boolean), + ); + + let bestSnapshot = null; + for (const key of listStorageKeys(storage)) { + if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) { + continue; + } + + try { + const snapshot = normalizeShadowSnapshotPayload( + JSON.parse(storage.getItem(key)), + ); + if (!snapshot || snapshot.integrity !== normalizedIntegrity) { + continue; + } + if (excludedChatIds.has(snapshot.chatId)) { + continue; + } + + const bestRevision = Number(bestSnapshot?.revision || 0); + const nextRevision = Number(snapshot.revision || 0); + if (!bestSnapshot || nextRevision > bestRevision) { + bestSnapshot = snapshot; + continue; + } + + if ( + nextRevision === bestRevision && + String(snapshot.updatedAt || "") > String(bestSnapshot.updatedAt || "") + ) { + bestSnapshot = snapshot; + } + } catch { + // ignore broken shadow snapshot payloads + } + } + + return bestSnapshot; +} + /** * @param {string} chatId * @param {object} graph @@ -191,7 +472,7 @@ export function writeGraphShadowSnapshot( try { const serializedGraph = serializeGraph(graph); const persistedMeta = getGraphPersistenceMeta(graph) || {}; - globalThis.sessionStorage?.setItem( + getSessionStorageSafe()?.setItem( storageKey, JSON.stringify({ chatId: String(chatId || ""), @@ -216,7 +497,7 @@ export function removeGraphShadowSnapshot(chatId = "") { if (!storageKey) return false; try { - globalThis.sessionStorage?.removeItem(storageKey); + getSessionStorageSafe()?.removeItem(storageKey); return true; } catch { return false; diff --git a/index.js b/index.js index 7b00c7f..6681156 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,8 @@ import { import { BmeChatManager } from "./bme-chat-manager.js"; import { + BmeDatabase, + buildBmeDbName, buildGraphFromSnapshot, buildSnapshotFromGraph, ensureDexieLoaded, @@ -82,6 +84,7 @@ import { generateSynopsis, } from "./extractor.js"; import { + findGraphShadowSnapshotByIntegrity, GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_STATES, GRAPH_METADATA_KEY, @@ -90,7 +93,12 @@ import { cloneGraphForPersistence, cloneRuntimeDebugValue, getGraphPersistedRevision, + getGraphPersistenceMeta, + getGraphIdentityAliasCandidates, + readGraphShadowSnapshot, removeGraphShadowSnapshot, + rememberGraphIdentityAlias, + resolveGraphIdentityAliasByHostChatId, stampGraphPersistenceMeta, writeChatMetadataPatch, writeGraphShadowSnapshot, @@ -930,10 +938,19 @@ function throwIfAborted(signal, message = "操作已终止") { function assertRecoveryChatStillActive(expectedChatId, label = "") { if (!expectedChatId) return; - const currentId = getCurrentChatId(); - if (currentId && currentId !== expectedChatId) { + const currentIdentity = resolveCurrentChatIdentity(getContext()); + const currentId = normalizeChatIdCandidate(currentIdentity.chatId); + const normalizedExpectedChatId = normalizeChatIdCandidate(expectedChatId); + if ( + currentId && + normalizedExpectedChatId && + !doesChatIdMatchResolvedGraphIdentity( + normalizedExpectedChatId, + currentIdentity, + ) + ) { throw createAbortError( - `历史恢复已终止:聊天已从 ${expectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`, + `历史恢复已终止:聊天已从 ${normalizedExpectedChatId} 切换到 ${currentId}${label ? ` (${label})` : ""}`, ); } } @@ -3309,7 +3326,7 @@ function isHostChatMetadataReady(context = getContext()) { return false; } -function resolveCurrentChatIdentity(context = getContext()) { +function resolveCurrentHostChatId(context = getContext()) { const candidates = [ context?.chatId, context?.getCurrentChatId?.(), @@ -3320,13 +3337,43 @@ function resolveCurrentChatIdentity(context = getContext()) { context?.chatMetadata?.sessionId, ]; - const chatId = + return ( candidates .map((candidate) => normalizeChatIdCandidate(candidate)) - .find(Boolean) || ""; + .find(Boolean) || "" + ); +} + +function resolveCurrentChatIdentity(context = getContext()) { + const hostChatId = resolveCurrentHostChatId(context); + const integrity = + typeof getChatMetadataIntegrity === "function" + ? getChatMetadataIntegrity(context) + : normalizeChatIdCandidate( + context?.chatMetadata?.integrity || + context?.chatMetadata?.chat_id || + context?.chatMetadata?.chatId || + "", + ); + const aliasedChatId = + !integrity && + hostChatId && + typeof resolveGraphIdentityAliasByHostChatId === "function" + ? resolveGraphIdentityAliasByHostChatId(hostChatId) + : ""; + const chatId = integrity || aliasedChatId || hostChatId; return { chatId, + hostChatId, + integrity, + identitySource: integrity + ? "integrity" + : aliasedChatId + ? "alias" + : hostChatId + ? "host-chat-id" + : "", hasLikelySelectedChat: hasLikelySelectedChatContext(context), }; } @@ -3335,6 +3382,250 @@ function getCurrentChatId(context = getContext()) { return resolveCurrentChatIdentity(context).chatId; } +function rememberResolvedGraphIdentityAlias( + context = getContext(), + persistenceChatId = getCurrentChatId(context), +) { + const identity = resolveCurrentChatIdentity(context); + if (!identity.integrity || !persistenceChatId) { + return null; + } + + return rememberGraphIdentityAlias({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId, + }); +} + +function buildLegacyGraphIdentityCandidates( + targetChatId, + context = getContext(), + { shadowSnapshot = null } = {}, +) { + const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId); + const identity = resolveCurrentChatIdentity(context); + const candidates = new Set(); + const addCandidate = (value) => { + const normalized = normalizeChatIdCandidate(value); + if (!normalized || normalized === normalizedTargetChatId) return; + candidates.add(normalized); + }; + + addCandidate(identity.hostChatId); + for (const aliasCandidate of getGraphIdentityAliasCandidates({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId: normalizedTargetChatId, + })) { + addCandidate(aliasCandidate); + } + + const currentGraphMeta = getGraphPersistenceMeta(currentGraph) || {}; + const runtimeGraphIntegrity = normalizeChatIdCandidate( + currentGraphMeta.integrity || graphPersistenceState.metadataIntegrity, + ); + if ( + identity.integrity && + runtimeGraphIntegrity && + runtimeGraphIntegrity === identity.integrity + ) { + addCandidate(graphPersistenceState.chatId); + addCandidate(currentGraph?.historyState?.chatId); + addCandidate(currentGraphMeta.chatId); + } + + addCandidate(shadowSnapshot?.chatId); + addCandidate(shadowSnapshot?.persistedChatId); + return Array.from(candidates); +} + +async function doesIndexedDbChatStoreExist(chatId = "") { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) return false; + + const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded()); + if (typeof DexieCtor?.exists === "function") { + return await DexieCtor.exists(buildBmeDbName(normalizedChatId)); + } + + if (typeof DexieCtor?.getDatabaseNames === "function") { + const names = await DexieCtor.getDatabaseNames(); + return Array.isArray(names) + ? names.includes(buildBmeDbName(normalizedChatId)) + : false; + } + + return false; +} + +async function exportIndexedDbSnapshotForChat(chatId = "") { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return null; + } + + if (!(await doesIndexedDbChatStoreExist(normalizedChatId))) { + return null; + } + + const DexieCtor = globalThis.Dexie || (await ensureDexieLoaded()); + const db = new BmeDatabase(normalizedChatId, { + dexieClass: DexieCtor, + }); + + try { + await db.open(); + return await db.exportSnapshot(); + } finally { + await db.close(); + } +} + +function buildRecoveredSnapshotForChatIdentity( + graph, + targetChatId, + { + revision = 0, + integrity = "", + source = "identity-recovery", + legacyChatId = "", + } = {}, +) { + const normalizedTargetChatId = normalizeChatIdCandidate(targetChatId); + const normalizedIntegrity = normalizeChatIdCandidate(integrity); + const normalizedLegacyChatId = normalizeChatIdCandidate(legacyChatId); + const normalizedGraph = cloneGraphForPersistence(graph, normalizedTargetChatId); + const effectiveRevision = Math.max( + 1, + normalizeIndexedDbRevision( + revision || graphPersistenceState.revision || getGraphPersistedRevision(graph), + ), + ); + + stampGraphPersistenceMeta(normalizedGraph, { + revision: effectiveRevision, + reason: source, + chatId: normalizedTargetChatId, + integrity: normalizedIntegrity, + }); + + return buildSnapshotFromGraph(normalizedGraph, { + chatId: normalizedTargetChatId, + revision: effectiveRevision, + lastModified: Date.now(), + meta: { + storagePrimary: "indexeddb", + lastMutationReason: String(source || "identity-recovery"), + integrity: normalizedIntegrity, + migratedFromChatId: normalizedLegacyChatId, + identityMigrationSource: String(source || "identity-recovery"), + }, + }); +} + +async function importRecoveredSnapshotToIndexedDb( + targetDb, + targetChatId, + graph, + { revision = 0, integrity = "", source = "identity-recovery", legacyChatId = "" } = {}, +) { + const snapshot = buildRecoveredSnapshotForChatIdentity(graph, targetChatId, { + revision, + integrity, + source, + legacyChatId, + }); + const importResult = await targetDb.importSnapshot(snapshot, { + mode: "replace", + preserveRevision: true, + revision: snapshot.meta.revision, + markSyncDirty: true, + }); + snapshot.meta.revision = normalizeIndexedDbRevision( + importResult?.revision, + snapshot.meta.revision, + ); + return snapshot; +} + +function doesChatIdMatchResolvedGraphIdentity( + candidateChatId, + identity = resolveCurrentChatIdentity(getContext()), +) { + const normalizedCandidate = normalizeChatIdCandidate(candidateChatId); + if (!normalizedCandidate || !identity || typeof identity !== "object") { + return false; + } + + const knownChatIds = new Set(); + const addKnownChatId = (value) => { + const normalized = normalizeChatIdCandidate(value); + if (normalized) { + knownChatIds.add(normalized); + } + }; + + addKnownChatId(identity.chatId); + addKnownChatId(identity.hostChatId); + addKnownChatId(identity.integrity); + + for (const aliasCandidate of getGraphIdentityAliasCandidates({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId: identity.chatId, + })) { + addKnownChatId(aliasCandidate); + } + + return knownChatIds.has(normalizedCandidate); +} + +function resolveCompatibleGraphShadowSnapshot( + identity = resolveCurrentChatIdentity(getContext()), +) { + if (!identity || typeof identity !== "object") { + return null; + } + + const directSnapshot = readGraphShadowSnapshot(identity.chatId); + if (directSnapshot) { + return directSnapshot; + } + + const seenChatIds = new Set( + [identity.chatId].map((value) => normalizeChatIdCandidate(value)).filter(Boolean), + ); + const readByChatId = (value) => { + const normalized = normalizeChatIdCandidate(value); + if (!normalized || seenChatIds.has(normalized)) { + return null; + } + seenChatIds.add(normalized); + return readGraphShadowSnapshot(normalized); + }; + + const hostSnapshot = readByChatId(identity.hostChatId); + if (hostSnapshot) { + return hostSnapshot; + } + + for (const aliasCandidate of getGraphIdentityAliasCandidates({ + integrity: identity.integrity, + hostChatId: identity.hostChatId, + persistenceChatId: identity.chatId, + })) { + const aliasSnapshot = readByChatId(aliasCandidate); + if (aliasSnapshot) { + return aliasSnapshot; + } + } + + return findGraphShadowSnapshotByIntegrity(identity.integrity, { + excludeChatIds: Array.from(seenChatIds), + }); +} + async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { const action = String(syncPayload?.action || "") .trim() @@ -3348,8 +3639,14 @@ async function refreshRuntimeGraphAfterSyncApplied(syncPayload = {}) { } const syncedChatId = normalizeChatIdCandidate(syncPayload?.chatId); - const activeChatId = normalizeChatIdCandidate(getCurrentChatId()); - const targetChatId = syncedChatId || activeChatId; + const activeIdentity = resolveCurrentChatIdentity(getContext()); + const activeChatId = normalizeChatIdCandidate(activeIdentity.chatId); + const targetChatId = + activeChatId && + syncedChatId && + doesChatIdMatchResolvedGraphIdentity(syncedChatId, activeIdentity) + ? activeChatId + : syncedChatId || activeChatId; if (!targetChatId) { return { @@ -3671,6 +3968,223 @@ function readLegacyGraphFromChatMetadata(chatId, context = getContext()) { } } +async function maybeRecoverIndexedDbGraphFromStableIdentity( + chatId, + context = getContext(), + { source = "unknown", db = null } = {}, +) { + const normalizedChatId = normalizeChatIdCandidate(chatId); + if (!normalizedChatId) { + return { + migrated: false, + reason: "identity-recovery-missing-chat-id", + chatId: "", + }; + } + + const identity = resolveCurrentChatIdentity(context); + if (!identity.integrity) { + return { + migrated: false, + reason: "identity-recovery-integrity-missing", + chatId: normalizedChatId, + }; + } + + const manager = ensureBmeChatManager(); + if (!manager) { + return { + migrated: false, + reason: "identity-recovery-manager-unavailable", + chatId: normalizedChatId, + }; + } + + const targetDb = db || (await manager.getCurrentDb(normalizedChatId)); + if (!targetDb) { + return { + migrated: false, + reason: "identity-recovery-db-unavailable", + chatId: normalizedChatId, + }; + } + + const emptyStatus = await targetDb.isEmpty(); + if (!emptyStatus?.empty) { + return { + migrated: false, + reason: "identity-recovery-target-not-empty", + chatId: normalizedChatId, + emptyStatus, + }; + } + + const finalizeMigration = async ( + graph, + { + revision = 0, + legacyChatId = "", + migrationSource = "identity-recovery", + shadowChatId = "", + } = {}, + ) => { + const snapshot = await importRecoveredSnapshotToIndexedDb( + targetDb, + normalizedChatId, + graph, + { + revision, + integrity: identity.integrity, + source: migrationSource, + legacyChatId, + }, + ); + cacheIndexedDbSnapshot(normalizedChatId, snapshot); + rememberResolvedGraphIdentityAlias(context, normalizedChatId); + + if (shadowChatId && shadowChatId !== normalizedChatId) { + removeGraphShadowSnapshot(shadowChatId); + } + + let syncResult = { + synced: false, + 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); + syncResult = { + synced: false, + reason: "identity-recovery-sync-failed", + chatId: normalizedChatId, + error: syncError?.message || String(syncError), + }; + } + + return { + migrated: true, + reason: "identity-recovery-completed", + chatId: normalizedChatId, + legacyChatId: normalizeChatIdCandidate(legacyChatId), + source: migrationSource, + snapshot, + syncResult, + }; + }; + + const currentGraphMeta = getGraphPersistenceMeta(currentGraph) || {}; + const runtimeGraphIntegrity = normalizeChatIdCandidate( + currentGraphMeta.integrity || graphPersistenceState.metadataIntegrity, + ); + const runtimeGraphChatId = normalizeChatIdCandidate( + currentGraph?.historyState?.chatId || + currentGraphMeta.chatId || + graphPersistenceState.chatId, + ); + + if ( + currentGraph && + !isGraphEffectivelyEmpty(currentGraph) && + runtimeGraphIntegrity && + runtimeGraphIntegrity === identity.integrity && + runtimeGraphChatId && + runtimeGraphChatId !== normalizedChatId + ) { + return await finalizeMigration(currentGraph, { + revision: Math.max( + graphPersistenceState.revision || 0, + getGraphPersistedRevision(currentGraph), + 1, + ), + legacyChatId: runtimeGraphChatId, + migrationSource: "runtime-identity-promotion", + }); + } + + const aliasShadowSnapshot = findGraphShadowSnapshotByIntegrity( + identity.integrity, + { + excludeChatIds: [normalizedChatId], + }, + ); + if (aliasShadowSnapshot?.serializedGraph) { + try { + const shadowGraph = normalizeGraphRuntimeState( + deserializeGraph(aliasShadowSnapshot.serializedGraph), + normalizedChatId, + ); + if (!isGraphEffectivelyEmpty(shadowGraph)) { + return await finalizeMigration(shadowGraph, { + revision: Math.max( + Number(aliasShadowSnapshot.revision || 0), + getGraphPersistedRevision(shadowGraph), + 1, + ), + legacyChatId: + aliasShadowSnapshot.persistedChatId || aliasShadowSnapshot.chatId, + migrationSource: "shadow-identity-recovery", + shadowChatId: aliasShadowSnapshot.chatId, + }); + } + } catch (error) { + console.warn("[ST-BME] 通过影子快照恢复聊天身份失败:", error); + } + } + + const legacyCandidates = buildLegacyGraphIdentityCandidates( + normalizedChatId, + context, + { + shadowSnapshot: aliasShadowSnapshot, + }, + ); + + for (const legacyChatId of legacyCandidates) { + try { + const legacySnapshot = await exportIndexedDbSnapshotForChat(legacyChatId); + if (!isIndexedDbSnapshotMeaningful(legacySnapshot)) { + continue; + } + + const legacyGraph = buildGraphFromSnapshot(legacySnapshot, { + chatId: legacyChatId, + }); + if (isGraphEffectivelyEmpty(legacyGraph)) { + continue; + } + + return await finalizeMigration(legacyGraph, { + revision: Math.max( + normalizeIndexedDbRevision(legacySnapshot?.meta?.revision), + getGraphPersistedRevision(legacyGraph), + 1, + ), + legacyChatId, + migrationSource: "indexeddb-identity-alias", + }); + } catch (error) { + console.warn("[ST-BME] 读取旧身份 IndexedDB 图谱失败:", { + legacyChatId, + error, + }); + } + } + + return { + migrated: false, + reason: "identity-recovery-no-match", + chatId: normalizedChatId, + }; +} + async function maybeMigrateLegacyGraphToIndexedDb( chatId, context = getContext(), @@ -3980,6 +4494,14 @@ function applyIndexedDbSnapshotToRuntime( normalizeGraphRuntimeState(graphFromSnapshot, normalizedChatId), normalizedChatId, ); + stampGraphPersistenceMeta(currentGraph, { + revision, + reason: `indexeddb:${String(source || "indexeddb")}`, + chatId: normalizedChatId, + integrity: + normalizeChatIdCandidate(snapshot?.meta?.integrity) || + getChatMetadataIntegrity(getContext()), + }); currentGraph.vectorIndexState.lastIntegrityIssue = null; extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) @@ -4045,6 +4567,7 @@ function applyIndexedDbSnapshotToRuntime( at: Date.now(), }, }); + rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); removeGraphShadowSnapshot(normalizedChatId); refreshPanelLiveState(); @@ -4101,14 +4624,59 @@ async function loadGraphFromIndexedDb( } const db = await manager.getCurrentDb(normalizedChatId); - const migrationResult = await maybeMigrateLegacyGraphToIndexedDb( - normalizedChatId, - getContext(), - { - source, - db, - }, - ); + const identityRecoveryResult = + await maybeRecoverIndexedDbGraphFromStableIdentity( + normalizedChatId, + getContext(), + { + source, + db, + }, + ); + + if (identityRecoveryResult?.migrated) { + const recoveredRevision = normalizeIndexedDbRevision( + identityRecoveryResult?.snapshot?.meta?.revision, + ); + updateGraphPersistenceState({ + storagePrimary: "indexeddb", + storageMode: "indexeddb", + indexedDbRevision: recoveredRevision, + indexedDbLastError: "", + lastSyncError: "", + dualWriteLastResult: { + action: "identity-recovery", + source: String(identityRecoveryResult?.source || "indexeddb"), + success: true, + chatId: normalizedChatId, + legacyChatId: String(identityRecoveryResult?.legacyChatId || ""), + revision: recoveredRevision, + reason: String( + identityRecoveryResult?.reason || "identity-recovery", + ), + at: Date.now(), + syncResult: cloneRuntimeDebugValue( + identityRecoveryResult?.syncResult, + null, + ), + }, + }); + } + + const migrationResult = identityRecoveryResult?.migrated + ? { + migrated: false, + reason: "identity-recovery-already-applied", + chatId: normalizedChatId, + } + : await maybeMigrateLegacyGraphToIndexedDb( + normalizedChatId, + getContext(), + { + source, + db, + }, + ); if (migrationResult?.migrated) { const migratedRevision = normalizeIndexedDbRevision( @@ -4146,8 +4714,11 @@ async function loadGraphFromIndexedDb( }, }); } + const snapshot = + identityRecoveryResult?.snapshot || + migrationResult?.snapshot || + (await db.exportSnapshot()); - const snapshot = migrationResult?.snapshot || (await db.exportSnapshot()); cacheIndexedDbSnapshot(normalizedChatId, snapshot); if (!isIndexedDbSnapshotMeaningful(snapshot)) { @@ -4713,6 +5284,7 @@ function persistGraphToChatMetadata( queuedPersistRotateIntegrity: false, queuedPersistReason: "", }); + rememberResolvedGraphIdentityAlias(context, chatId); return buildGraphPersistResult({ saved: true, @@ -5949,7 +6521,7 @@ function loadGraphFromChat(options = {}) { normalizeGraphRuntimeState(deserializeGraph(savedData), chatId), chatId, ); - const shadowSnapshot = readGraphShadowSnapshot(chatId); + const shadowSnapshot = resolveCompatibleGraphShadowSnapshot(chatIdentity); const shadowDecision = shouldPreferShadowSnapshotOverOfficial( officialGraph, shadowSnapshot, @@ -5976,6 +6548,12 @@ function loadGraphFromChat(options = {}) { clearPendingGraphLoadRetry(); currentGraph = officialGraph; + stampGraphPersistenceMeta(currentGraph, { + revision: officialRevision, + reason: `${source}:metadata-compat-provisional`, + chatId, + integrity: getChatMetadataIntegrity(context), + }); extractionCount = Number.isFinite( currentGraph?.historyState?.extractionCount, ) @@ -6043,6 +6621,7 @@ function loadGraphFromChat(options = {}) { at: Date.now(), }, }); + rememberResolvedGraphIdentityAlias(context, chatId); scheduleIndexedDbGraphProbe(chatId, { source: `${source}:indexeddb-probe`, @@ -6126,6 +6705,7 @@ async function saveGraphToIndexedDb( }; } const db = await manager.getCurrentDb(normalizedChatId); + const currentIdentity = resolveCurrentChatIdentity(getContext()); const baseSnapshot = readCachedIndexedDbSnapshot(normalizedChatId) || (await db.exportSnapshot()); @@ -6137,6 +6717,9 @@ async function saveGraphToIndexedDb( meta: { storagePrimary: "indexeddb", lastMutationReason: String(reason || "graph-save"), + integrity: + currentIdentity.integrity || graphPersistenceState.metadataIntegrity, + hostChatId: currentIdentity.hostChatId || "", }, }); const importResult = await db.importSnapshot(snapshot, { @@ -6179,6 +6762,7 @@ async function saveGraphToIndexedDb( at: Date.now(), }, }); + rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); return { saved: true, diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index 7e7b9ed..e106086 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -4,15 +4,22 @@ import path from "node:path"; import { fileURLToPath } from "node:url"; import vm from "node:vm"; -import { buildGraphFromSnapshot, buildSnapshotFromGraph } from "../bme-db.js"; +import { + buildBmeDbName, + buildGraphFromSnapshot, + buildSnapshotFromGraph, +} from "../bme-db.js"; import { onMessageReceivedController } from "../event-binding.js"; import { cloneGraphForPersistence, cloneRuntimeDebugValue, + findGraphShadowSnapshotByIntegrity, getGraphPersistedRevision, + getGraphIdentityAliasCandidates, getGraphPersistenceMeta, getGraphShadowSnapshotStorageKey, GRAPH_LOAD_PENDING_CHAT_ID, + GRAPH_IDENTITY_ALIAS_STORAGE_KEY, GRAPH_LOAD_STATES, GRAPH_METADATA_KEY, GRAPH_PERSISTENCE_META_KEY, @@ -21,7 +28,9 @@ import { GRAPH_STARTUP_RECONCILE_DELAYS_MS, MODULE_NAME, readGraphShadowSnapshot, + rememberGraphIdentityAlias, removeGraphShadowSnapshot, + resolveGraphIdentityAliasByHostChatId, shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, @@ -83,6 +92,28 @@ const messageSnippet = extractSnippet( ); function createSessionStorage(seed = null) { + const store = seed instanceof Map ? seed : new Map(); + return { + __store: store, + get length() { + return store.size; + }, + key(index) { + return Array.from(store.keys())[Number(index)] ?? null; + }, + getItem(key) { + return store.has(key) ? store.get(key) : null; + }, + setItem(key, value) { + store.set(String(key), String(value)); + }, + removeItem(key) { + store.delete(String(key)); + }, + }; +} + +function createLocalStorage(seed = null) { const store = seed instanceof Map ? seed : new Map(); return { __store: store, @@ -154,15 +185,75 @@ async function createGraphPersistenceHarness({ chatId = "chat-test", chatMetadata = undefined, sessionStore = null, + localStore = null, globalChatId = "", characterId = "", groupId = null, indexedDbSnapshot = null, + indexedDbSnapshots = null, chat = [], } = {}) { const timers = new Map(); let nextTimerId = 1; const storage = createSessionStorage(sessionStore); + const localStorage = createLocalStorage(localStore); + const indexedDbSnapshotMap = + indexedDbSnapshots instanceof Map + ? new Map(indexedDbSnapshots) + : new Map( + Object.entries( + indexedDbSnapshots && + typeof indexedDbSnapshots === "object" && + !Array.isArray(indexedDbSnapshots) + ? indexedDbSnapshots + : {}, + ), + ); + + if (indexedDbSnapshot) { + const primaryChatId = String(chatId || globalChatId || ""); + if (primaryChatId) { + indexedDbSnapshotMap.set(primaryChatId, structuredClone(indexedDbSnapshot)); + } + } + + function buildEmptyIndexedDbSnapshot(targetChatId = "") { + return { + meta: { revision: 0, chatId: String(targetChatId || "") }, + nodes: [], + edges: [], + tombstones: [], + state: { lastProcessedFloor: -1, extractionCount: 0 }, + }; + } + + function getIndexedDbSnapshotForChat(targetChatId = "") { + const normalizedChatId = String(targetChatId || ""); + if (normalizedChatId && indexedDbSnapshotMap.has(normalizedChatId)) { + return structuredClone(indexedDbSnapshotMap.get(normalizedChatId)); + } + + if ( + normalizedChatId && + indexedDbSnapshot && + !indexedDbSnapshotMap.size && + normalizedChatId === String(chatId || globalChatId || "") + ) { + return structuredClone(indexedDbSnapshot); + } + + return buildEmptyIndexedDbSnapshot(normalizedChatId); + } + + function setIndexedDbSnapshotForChat(targetChatId = "", snapshot = null) { + const normalizedChatId = String(targetChatId || ""); + if (!normalizedChatId) return; + if (!snapshot) { + indexedDbSnapshotMap.delete(normalizedChatId); + return; + } + indexedDbSnapshotMap.set(normalizedChatId, structuredClone(snapshot)); + } const runtimeContext = { console, @@ -176,8 +267,12 @@ async function createGraphPersistenceHarness({ Boolean, structuredClone, result: null, - __indexedDbSnapshot: indexedDbSnapshot, + __indexedDbSnapshot: getIndexedDbSnapshotForChat( + String(chatId || globalChatId || ""), + ), + __indexedDbSnapshots: indexedDbSnapshotMap, sessionStorage: storage, + localStorage, setTimeout(fn, delay) { const id = nextTimerId++; timers.set(id, { fn, delay }); @@ -211,6 +306,21 @@ async function createGraphPersistenceHarness({ }, }, __globalChatId: String(globalChatId || ""), + Dexie: { + async exists(dbName = "") { + return Array.from(indexedDbSnapshotMap.keys()).some( + (candidateChatId) => buildBmeDbName(candidateChatId) === String(dbName), + ); + }, + async getDatabaseNames() { + return Array.from(indexedDbSnapshotMap.keys()).map((candidateChatId) => + buildBmeDbName(candidateChatId), + ); + }, + }, + async ensureDexieLoaded() { + return runtimeContext.Dexie; + }, refreshPanelLiveState() { runtimeContext.__panelRefreshCount += 1; }, @@ -241,7 +351,9 @@ async function createGraphPersistenceHarness({ onMessageReceivedController, getGraphPersistenceMeta, getGraphPersistedRevision, + getGraphIdentityAliasCandidates, getGraphShadowSnapshotStorageKey, + GRAPH_IDENTITY_ALIAS_STORAGE_KEY, GRAPH_LOAD_PENDING_CHAT_ID, GRAPH_LOAD_STATES, GRAPH_METADATA_KEY, @@ -250,14 +362,141 @@ async function createGraphPersistenceHarness({ GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX, GRAPH_STARTUP_RECONCILE_DELAYS_MS, MODULE_NAME, + findGraphShadowSnapshotByIntegrity, readGraphShadowSnapshot, + rememberGraphIdentityAlias, removeGraphShadowSnapshot, + resolveGraphIdentityAliasByHostChatId, shouldPreferShadowSnapshotOverOfficial, stampGraphPersistenceMeta, writeChatMetadataPatch, writeGraphShadowSnapshot, // Shadow snapshot functions need VM-local sessionStorage overrides // because imported versions use the outer globalThis (no sessionStorage) + rememberGraphIdentityAlias({ + integrity = "", + hostChatId = "", + persistenceChatId = "", + } = {}) { + const normalizedIntegrity = String(integrity || "").trim(); + if (!normalizedIntegrity) return null; + + const normalizedHostChatId = String(hostChatId || "").trim(); + const normalizedPersistenceChatId = String( + persistenceChatId || normalizedIntegrity, + ).trim(); + let registry = { byIntegrity: {} }; + try { + const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY); + if (raw) { + const parsed = JSON.parse(raw); + if ( + parsed?.byIntegrity && + typeof parsed.byIntegrity === "object" && + !Array.isArray(parsed.byIntegrity) + ) { + registry = { byIntegrity: parsed.byIntegrity }; + } + } + } catch { + registry = { byIntegrity: {} }; + } + + const current = registry.byIntegrity[normalizedIntegrity] || {}; + const hostChatIds = Array.from( + new Set( + [ + normalizedHostChatId, + ...(Array.isArray(current.hostChatIds) ? current.hostChatIds : []), + ].filter(Boolean), + ), + ); + const next = { + integrity: normalizedIntegrity, + persistenceChatId: normalizedPersistenceChatId, + hostChatIds, + updatedAt: new Date().toISOString(), + }; + registry.byIntegrity[normalizedIntegrity] = next; + localStorage.setItem( + GRAPH_IDENTITY_ALIAS_STORAGE_KEY, + JSON.stringify(registry), + ); + return next; + }, + resolveGraphIdentityAliasByHostChatId(hostChatId = "") { + const normalizedHostChatId = String(hostChatId || "").trim(); + if (!normalizedHostChatId) return ""; + try { + const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} }; + let best = ""; + let bestUpdatedAt = ""; + for (const value of Object.values(parsed.byIntegrity || {})) { + const hostChatIds = Array.isArray(value?.hostChatIds) + ? value.hostChatIds.map((item) => String(item || "").trim()) + : []; + if (!hostChatIds.includes(normalizedHostChatId)) continue; + const persistenceChatId = String( + value?.persistenceChatId || value?.integrity || "", + ).trim(); + if (!persistenceChatId) continue; + const updatedAt = String(value?.updatedAt || ""); + if (!best || updatedAt > bestUpdatedAt) { + best = persistenceChatId; + bestUpdatedAt = updatedAt; + } + } + return best; + } catch { + return ""; + } + }, + getGraphIdentityAliasCandidates({ + integrity = "", + hostChatId = "", + persistenceChatId = "", + } = {}) { + const normalizedIntegrity = String(integrity || "").trim(); + const normalizedHostChatId = String(hostChatId || "").trim(); + const normalizedPersistenceChatId = String( + persistenceChatId || "", + ).trim(); + const candidates = []; + const seen = new Set(); + const addCandidate = (value) => { + const normalized = String(value || "").trim(); + if (!normalized || seen.has(normalized)) return; + seen.add(normalized); + candidates.push(normalized); + }; + + try { + const raw = localStorage.getItem(GRAPH_IDENTITY_ALIAS_STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : { byIntegrity: {} }; + if (normalizedIntegrity) { + const value = parsed.byIntegrity?.[normalizedIntegrity] || {}; + addCandidate(value?.persistenceChatId || value?.integrity || ""); + for (const candidate of Array.isArray(value?.hostChatIds) + ? value.hostChatIds + : []) { + addCandidate(candidate); + } + } else if (normalizedHostChatId) { + addCandidate( + runtimeContext.resolveGraphIdentityAliasByHostChatId( + normalizedHostChatId, + ), + ); + } + } catch { + // ignore + } + + addCandidate(normalizedHostChatId); + addCandidate(normalizedPersistenceChatId); + return candidates; + }, readGraphShadowSnapshot(chatId = "") { const key = getGraphShadowSnapshotStorageKey(chatId); if (!key) return null; @@ -286,6 +525,56 @@ async function createGraphPersistenceHarness({ return null; } }, + findGraphShadowSnapshotByIntegrity(integrity = "", { excludeChatIds = [] } = {}) { + const normalizedIntegrity = String(integrity || "").trim(); + if (!normalizedIntegrity) return null; + const excluded = new Set( + (Array.isArray(excludeChatIds) ? excludeChatIds : []) + .map((value) => String(value || "").trim()) + .filter(Boolean), + ); + let best = null; + for (const key of storage.__store.keys()) { + if (!String(key || "").startsWith(GRAPH_SHADOW_SNAPSHOT_STORAGE_PREFIX)) { + continue; + } + try { + const snap = JSON.parse(storage.getItem(key)); + if ( + !snap || + String(snap.integrity || "") !== normalizedIntegrity || + typeof snap.serializedGraph !== "string" || + !snap.serializedGraph + ) { + continue; + } + const normalizedChatId = String(snap.chatId || "").trim(); + if (!normalizedChatId || excluded.has(normalizedChatId)) { + continue; + } + if ( + !best || + Number(snap.revision || 0) > Number(best.revision || 0) || + (Number(snap.revision || 0) === Number(best.revision || 0) && + String(snap.updatedAt || "") > String(best.updatedAt || "")) + ) { + best = { + chatId: normalizedChatId, + revision: Number.isFinite(snap.revision) ? snap.revision : 0, + serializedGraph: snap.serializedGraph, + updatedAt: String(snap.updatedAt || ""), + reason: String(snap.reason || ""), + integrity: String(snap.integrity || ""), + persistedChatId: String(snap.persistedChatId || ""), + debugReason: String(snap.debugReason || snap.reason || ""), + }; + } + } catch { + // ignore + } + } + return best; + }, writeGraphShadowSnapshot( chatId = "", graph = null, @@ -350,6 +639,8 @@ async function createGraphPersistenceHarness({ return true; }, notifyExtractionIssue() {}, + debugDebug() {}, + debugLog() {}, async runExtraction() {}, getRequestHeaders() { return {}; @@ -394,24 +685,37 @@ async function createGraphPersistenceHarness({ __contextImmediateSaveCalls: 0, buildGraphFromSnapshot, buildSnapshotFromGraph, + buildBmeDbName, scheduleUpload() {}, + BmeDatabase: class { + constructor(dbChatId = "") { + this.chatId = String(dbChatId || ""); + } + async open() {} + async close() {} + async exportSnapshot() { + return getIndexedDbSnapshotForChat(this.chatId); + } + async importSnapshot(snapshot) { + setIndexedDbSnapshotForChat(this.chatId, snapshot); + return { + revision: Number(snapshot?.meta?.revision) || 0, + }; + } + }, BmeChatManager: class { constructor() { - this._db = { + this._currentChatId = ""; + } + _createDb(dbChatId = "") { + return { async exportSnapshot() { - if (runtimeContext.__indexedDbSnapshot) { - return structuredClone(runtimeContext.__indexedDbSnapshot); - } - return { - meta: { revision: 0, chatId: "" }, - nodes: [], - edges: [], - tombstones: [], - state: { lastProcessedFloor: -1, extractionCount: 0 }, - }; + return getIndexedDbSnapshotForChat(dbChatId); }, async importSnapshot(snapshot) { - runtimeContext.__indexedDbSnapshot = structuredClone(snapshot); + setIndexedDbSnapshotForChat(dbChatId, snapshot); + runtimeContext.__indexedDbSnapshot = + getIndexedDbSnapshotForChat(dbChatId); return { revision: Number(snapshot?.meta?.revision) || @@ -420,19 +724,18 @@ async function createGraphPersistenceHarness({ }; }, async getMeta(key, fallbackValue = 0) { - const snapshot = runtimeContext.__indexedDbSnapshot || {}; + const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {}; if (!snapshot?.meta || !(key in snapshot.meta)) { return fallbackValue; } return snapshot.meta[key]; }, async getRevision() { - return ( - Number(runtimeContext.__indexedDbSnapshot?.meta?.revision) || 0 - ); + const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {}; + return Number(snapshot?.meta?.revision) || 0; }, async isEmpty() { - const snapshot = runtimeContext.__indexedDbSnapshot || {}; + const snapshot = getIndexedDbSnapshotForChat(dbChatId) || {}; const nodes = Array.isArray(snapshot?.nodes) ? snapshot.nodes.length : 0; @@ -451,33 +754,44 @@ async function createGraphPersistenceHarness({ }, async importLegacyGraph(graph, options = {}) { const revision = Number(options?.revision) || 1; - runtimeContext.__indexedDbSnapshot = buildSnapshotFromGraph(graph, { - chatId: runtimeContext.__chatContext?.chatId || "", + const migratedSnapshot = buildSnapshotFromGraph(graph, { + chatId: dbChatId || runtimeContext.__chatContext?.chatId || "", revision, meta: { migrationCompletedAt: Date.now(), migrationSource: "chat_metadata", }, }); + setIndexedDbSnapshotForChat(dbChatId, migratedSnapshot); + runtimeContext.__indexedDbSnapshot = + getIndexedDbSnapshotForChat(dbChatId); return { migrated: true, revision, imported: { - nodes: runtimeContext.__indexedDbSnapshot.nodes.length, - edges: runtimeContext.__indexedDbSnapshot.edges.length, + nodes: runtimeContext.__indexedDbSnapshot?.nodes?.length || 0, + edges: runtimeContext.__indexedDbSnapshot?.edges?.length || 0, tombstones: - runtimeContext.__indexedDbSnapshot.tombstones.length, + runtimeContext.__indexedDbSnapshot?.tombstones?.length || 0, }, }; }, async markSyncDirty() {}, }; } - async getCurrentDb() { - return this._db; + async getCurrentDb(dbChatId = this._currentChatId) { + this._currentChatId = String(dbChatId || this._currentChatId || ""); + runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat( + this._currentChatId, + ); + return this._createDb(this._currentChatId); } - async switchChat() { - return this._db; + async switchChat(dbChatId = "") { + this._currentChatId = String(dbChatId || ""); + runtimeContext.__indexedDbSnapshot = getIndexedDbSnapshotForChat( + this._currentChatId, + ); + return this._createDb(this._currentChatId); } async closeCurrent() {} }, @@ -544,11 +858,33 @@ result = { return globalThis.__chatContext; }, setIndexedDbSnapshot(snapshot) { - globalThis.__indexedDbSnapshot = snapshot; + const activeChatId = + String(globalThis.__chatContext?.chatId || globalThis.__globalChatId || ""); + if (activeChatId) { + globalThis.__indexedDbSnapshots.set( + activeChatId, + structuredClone(snapshot), + ); + } + globalThis.__indexedDbSnapshot = structuredClone(snapshot); }, getIndexedDbSnapshot() { return globalThis.__indexedDbSnapshot; }, + setIndexedDbSnapshotForChat(chatId, snapshot) { + const normalizedChatId = String(chatId || ""); + if (!normalizedChatId) return; + globalThis.__indexedDbSnapshots.set( + normalizedChatId, + structuredClone(snapshot), + ); + }, + getIndexedDbSnapshotForChat(chatId) { + const normalizedChatId = String(chatId || ""); + if (!normalizedChatId) return null; + const snapshot = globalThis.__indexedDbSnapshots.get(normalizedChatId); + return snapshot ? structuredClone(snapshot) : null; + }, }; `, ].join("\n"), @@ -708,7 +1044,10 @@ result = { assert.equal(result.synced, true); assert.equal(result.loadState, "loading"); - assert.equal(harness.api.getCurrentGraph().historyState.chatId, "chat-late"); + assert.equal( + harness.api.getCurrentGraph().historyState.chatId, + "chat-late-ready", + ); assert.equal(harness.api.getGraphPersistenceState().dbReady, true); assert.equal( harness.api.getGraphPersistenceState().storagePrimary,