From 74d661e433af30533ed0185b94f327f2c20d1e97 Mon Sep 17 00:00:00 2001 From: Youzini-afk <13153778771cx@gmail.com> Date: Mon, 13 Apr 2026 23:03:25 +0800 Subject: [PATCH] Harden local store cache invalidation for storage mode switching --- index.js | 99 +++++++++++++++++++++++++++++++-- tests/indexeddb-persistence.mjs | 50 +++++++++++++++++ 2 files changed, 143 insertions(+), 6 deletions(-) diff --git a/index.js b/index.js index 3c49af1..00122a9 100644 --- a/index.js +++ b/index.js @@ -3630,6 +3630,41 @@ function resolveSnapshotGraphStorePresentation( return buildIndexedDbStorePresentation(); } +function buildGraphLocalStoreSelectorKey( + presentation = buildIndexedDbStorePresentation(), +) { + const normalizedPresentation = + presentation && typeof presentation === "object" + ? presentation + : buildIndexedDbStorePresentation(); + const storagePrimary = + normalizedPresentation.storagePrimary === "opfs" || + isGraphLocalStorageModeOpfs(normalizedPresentation.storageMode) + ? "opfs" + : "indexeddb"; + const storageMode = + storagePrimary === "opfs" + ? normalizeGraphLocalStorageMode( + normalizedPresentation.storageMode, + BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW, + ) + : BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB; + return `${storagePrimary}:${storageMode}`; +} + +function isGraphLocalStorePresentationCompatible(left, right) { + return ( + buildGraphLocalStoreSelectorKey(left) === + buildGraphLocalStoreSelectorKey(right) + ); +} + +function isCachedIndexedDbSnapshotCompatible(snapshot = null, expectedStore = null) { + if (!expectedStore || typeof expectedStore !== "object") return true; + const snapshotStore = resolveSnapshotGraphStorePresentation(snapshot, expectedStore); + return isGraphLocalStorePresentationCompatible(snapshotStore, expectedStore); +} + async function getGraphLocalStoreCapability(forceRefresh = false) { if (!forceRefresh && bmeLocalStoreCapabilitySnapshot.checked) { return bmeLocalStoreCapabilitySnapshot; @@ -3787,6 +3822,10 @@ function ensureBmeChatManager() { bmeChatManager = new BmeChatManager({ databaseFactory: async (chatId) => await createPreferredGraphLocalStore(chatId), + selectorKeyResolver: async () => + buildGraphLocalStoreSelectorKey( + await resolvePreferredGraphLocalStorePresentation(), + ), }); } return bmeChatManager; @@ -3916,19 +3955,33 @@ function isIndexedDbSnapshotMeaningful(snapshot = null) { function cacheIndexedDbSnapshot(chatId, snapshot = null) { const normalizedChatId = normalizeChatIdCandidate(chatId); if (!normalizedChatId || !snapshot || typeof snapshot !== "object") return; + const snapshotStore = resolveSnapshotGraphStorePresentation(snapshot); bmeIndexedDbSnapshotCacheByChatId.set(normalizedChatId, { chatId: normalizedChatId, revision: normalizeIndexedDbRevision(snapshot?.meta?.revision), + selectorKey: buildGraphLocalStoreSelectorKey(snapshotStore), snapshot, updatedAt: Date.now(), }); } -function readCachedIndexedDbSnapshot(chatId) { +function readCachedIndexedDbSnapshot(chatId, expectedStore = null) { const normalizedChatId = normalizeChatIdCandidate(chatId); if (!normalizedChatId) return null; const cacheEntry = bmeIndexedDbSnapshotCacheByChatId.get(normalizedChatId); if (!cacheEntry?.snapshot) return null; + if (expectedStore && typeof expectedStore === "object") { + const expectedSelectorKey = buildGraphLocalStoreSelectorKey(expectedStore); + if (cacheEntry.selectorKey && cacheEntry.selectorKey !== expectedSelectorKey) { + return null; + } + if ( + !cacheEntry.selectorKey && + !isCachedIndexedDbSnapshotCompatible(cacheEntry.snapshot, expectedStore) + ) { + return null; + } + } return cacheEntry.snapshot; } @@ -7400,11 +7453,15 @@ function syncGraphLoadFromLiveContext(options = {}) { }); } - const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); + const cachedPreferredLocalStore = getPreferredGraphLocalStorePresentationSync(); + const cachedSnapshot = readCachedIndexedDbSnapshot( + chatId, + cachedPreferredLocalStore, + ); if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { const cachedStore = resolveSnapshotGraphStorePresentation( cachedSnapshot, - getPreferredGraphLocalStorePresentationSync(), + cachedPreferredLocalStore, ); const result = applyIndexedDbSnapshotToRuntime(chatId, cachedSnapshot, { source: `${source}:indexeddb-cache`, @@ -8232,6 +8289,9 @@ function updateModuleSettings(patch = {}) { const previousCloudStorageMode = String( settings.cloudStorageMode || "automatic", ); + const previousGraphLocalStorageMode = getRequestedGraphLocalStorageMode( + settings, + ); Object.assign(settings, patch); extension_settings[MODULE_NAME] = settings; globalThis.__stBmeDebugLoggingEnabled = Boolean( @@ -8298,6 +8358,21 @@ function updateModuleSettings(patch = {}) { refreshVisibleStageNotices(); } + const currentGraphLocalStorageMode = getRequestedGraphLocalStorageMode( + settings, + ); + if (previousGraphLocalStorageMode !== currentGraphLocalStorageMode) { + clearAllCachedIndexedDbSnapshots(); + scheduleBmeIndexedDbTask(async () => { + if (bmeChatManager && typeof bmeChatManager.closeAll === "function") { + await bmeChatManager.closeAll(); + } + await syncBmeChatManagerWithCurrentChat( + "settings:graph-local-storage-mode-changed", + ); + }); + } + const currentCloudStorageMode = String( settings.cloudStorageMode || "automatic", ); @@ -8468,17 +8543,29 @@ function loadGraphFromChat(options = {}) { }); } - const cachedSnapshot = readCachedIndexedDbSnapshot(chatId); + const preferredLocalStore = getPreferredGraphLocalStorePresentationSync(); + const cachedSnapshot = readCachedIndexedDbSnapshot( + chatId, + preferredLocalStore, + ); if (isIndexedDbSnapshotMeaningful(cachedSnapshot)) { + const cachedStore = resolveSnapshotGraphStorePresentation( + cachedSnapshot, + preferredLocalStore, + ); const cachedResult = applyIndexedDbSnapshotToRuntime( chatId, cachedSnapshot, { source: `${source}:indexeddb-cache`, attemptIndex, + storagePrimary: cachedStore.storagePrimary, + storageMode: cachedStore.storageMode, + statusLabel: cachedStore.statusLabel, + reasonPrefix: cachedStore.reasonPrefix, }, ); - if (cachedResult?.reason === "indexeddb-stale-runtime") { + if (cachedResult?.reason === `${cachedStore.reasonPrefix}-stale-runtime`) { clearPendingGraphLoadRetry(); refreshPanelLiveState(); return { @@ -8818,7 +8905,7 @@ async function saveGraphToIndexedDb( localStore = resolveDbGraphStorePresentation(db); const currentIdentity = resolveCurrentChatIdentity(getContext()); const baseSnapshot = - readCachedIndexedDbSnapshot(normalizedChatId) || + readCachedIndexedDbSnapshot(normalizedChatId, localStore) || (await db.exportSnapshot()); const requestedRevision = resolvePersistRevisionFloor(revision, graph); const snapshot = buildSnapshotFromGraph(graph, { diff --git a/tests/indexeddb-persistence.mjs b/tests/indexeddb-persistence.mjs index 3a30c41..4945a4d 100644 --- a/tests/indexeddb-persistence.mjs +++ b/tests/indexeddb-persistence.mjs @@ -19,6 +19,7 @@ const chatIdsForCleanup = new Set([ "chat-b", "chat-manager-a", "chat-manager-b", + "chat-manager-selector", "chat-replace-reset", ]); @@ -418,6 +419,54 @@ async function testChatIsolationAndManager() { assert.equal(manager.getCurrentChatId(), ""); } +async function testManagerRecreatesDbWhenSelectorKeyChanges() { + let selectorKey = "indexeddb:indexeddb"; + let instanceCounter = 0; + const closeLog = []; + const manager = new BmeChatManager({ + selectorKeyResolver: async () => selectorKey, + databaseFactory: async (chatId) => { + instanceCounter += 1; + const instanceId = instanceCounter; + return { + chatId, + instanceId, + openCount: 0, + closed: false, + async open() { + this.openCount += 1; + return this; + }, + async close() { + this.closed = true; + closeLog.push(instanceId); + }, + }; + }, + }); + + const dbA = await manager.getCurrentDb("chat-manager-selector"); + assert.equal(dbA.instanceId, 1); + assert.equal(dbA.openCount, 1); + + const reopenedSameSelector = await manager.getCurrentDb("chat-manager-selector"); + assert.equal(reopenedSameSelector, dbA); + assert.equal(dbA.openCount, 2); + assert.deepEqual(closeLog, []); + + selectorKey = "opfs:opfs-shadow"; + const dbB = await manager.getCurrentDb("chat-manager-selector"); + assert.notEqual(dbB, dbA); + assert.equal(dbB.instanceId, 2); + assert.equal(dbB.openCount, 1); + assert.equal(dbA.closed, true); + assert.deepEqual(closeLog, [1]); + + await manager.closeAll(); + assert.equal(dbB.closed, true); + assert.deepEqual(closeLog, [1, 2]); +} + async function testGraphSnapshotConverters() { const graph = createEmptyGraph(); graph.historyState.chatId = "chat-a"; @@ -548,6 +597,7 @@ async function main() { await testRevisionMonotonicity(); await testTombstonePrune(); await testChatIsolationAndManager(); + await testManagerRecreatesDbWhenSelectorKeyChanges(); await testGraphSnapshotConverters(); await cleanupDatabases();