fix: harden mobile local store recovery flow

This commit is contained in:
Youzini-afk
2026-04-15 12:43:13 +08:00
parent 4846f1587b
commit 05d3fe3fbd
3 changed files with 515 additions and 12 deletions

379
index.js
View File

@@ -544,6 +544,147 @@ function clearCurrentChatCommitMarker(
};
}
function clearCurrentChatMetadataGraphFallback(
{
context = getContext(),
reason = "manual-clear-graph-metadata-fallback",
immediate = true,
clearPendingPersist = false,
} = {},
) {
if (!context) {
return {
cleared: false,
reason: "missing-context",
saveMode: "",
};
}
const hadGraphMetadata =
context?.chatMetadata &&
Object.prototype.hasOwnProperty.call(context.chatMetadata, GRAPH_METADATA_KEY) &&
context.chatMetadata[GRAPH_METADATA_KEY] != null;
writeChatMetadataPatch(context, {
[GRAPH_METADATA_KEY]: null,
});
const saveMode = triggerChatMetadataSave(context, { immediate });
updateGraphPersistenceState({
persistMismatchReason: "",
lastPersistReason: String(
reason || "manual-clear-graph-metadata-fallback",
),
lastPersistMode: `metadata-full-clear:${saveMode}`,
lastRecoverableStorageTier:
graphPersistenceState.lastRecoverableStorageTier === "metadata-full"
? "none"
: graphPersistenceState.lastRecoverableStorageTier,
pendingPersist:
clearPendingPersist === true ? false : graphPersistenceState.pendingPersist,
writesBlocked:
clearPendingPersist === true ? false : graphPersistenceState.writesBlocked,
queuedPersistRevision:
clearPendingPersist === true ? 0 : graphPersistenceState.queuedPersistRevision,
queuedPersistChatId:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistChatId,
queuedPersistMode:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistMode,
queuedPersistRotateIntegrity:
clearPendingPersist === true
? false
: graphPersistenceState.queuedPersistRotateIntegrity,
queuedPersistReason:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistReason,
});
if (clearPendingPersist === true) {
clearPendingGraphPersistRetry();
}
return {
cleared: hadGraphMetadata,
reason: String(reason || "manual-clear-graph-metadata-fallback"),
saveMode,
};
}
function clearCurrentChatRecoveryAnchors(
{
context = getContext(),
chatId = getCurrentChatId(context),
reason = "manual-clear-recovery-anchors",
immediate = true,
clearMetadataFull = true,
clearCommitMarker = true,
clearPendingPersist = true,
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
const shadowCleared = normalizedChatId
? removeGraphShadowSnapshot(normalizedChatId)
: false;
const metadataResult = clearMetadataFull
? clearCurrentChatMetadataGraphFallback({
context,
reason: `${reason}:metadata-full`,
immediate,
clearPendingPersist,
})
: {
cleared: false,
reason: "metadata-full-retained",
saveMode: "",
};
const markerResult = clearCommitMarker
? clearCurrentChatCommitMarker({
context,
reason: `${reason}:commit-marker`,
immediate,
resetAcceptedRevision: clearPendingPersist === true,
})
: {
cleared: false,
reason: "commit-marker-retained",
saveMode: "",
marker: null,
};
updateGraphPersistenceState({
shadowSnapshotUsed: false,
shadowSnapshotRevision: 0,
shadowSnapshotUpdatedAt: "",
shadowSnapshotReason: "",
lastRecoverableStorageTier:
shadowCleared || metadataResult?.cleared ? "none" : graphPersistenceState.lastRecoverableStorageTier,
pendingPersist:
clearPendingPersist === true ? false : graphPersistenceState.pendingPersist,
writesBlocked:
clearPendingPersist === true ? false : graphPersistenceState.writesBlocked,
queuedPersistRevision:
clearPendingPersist === true ? 0 : graphPersistenceState.queuedPersistRevision,
queuedPersistChatId:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistChatId,
queuedPersistMode:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistMode,
queuedPersistRotateIntegrity:
clearPendingPersist === true
? false
: graphPersistenceState.queuedPersistRotateIntegrity,
queuedPersistReason:
clearPendingPersist === true ? "" : graphPersistenceState.queuedPersistReason,
});
if (clearPendingPersist === true) {
clearPendingGraphPersistRetry();
}
return {
chatId: normalizedChatId,
shadowCleared,
metadataCleared: metadataResult?.cleared === true,
markerCleared: markerResult?.cleared === true,
metadataResult,
markerResult,
};
}
function isAcceptedPersistTier(storageTier = "none") {
const normalizedTier = String(storageTier || "none").trim().toLowerCase();
return normalizedTier === "indexeddb" || normalizedTier === "chat-state";
@@ -4112,6 +4253,182 @@ async function createPreferredGraphLocalStore(
return new BmeDatabase(chatId);
}
async function refreshCurrentChatLocalStoreBinding(
{
chatId = getCurrentChatId(getContext()),
forceCapabilityRefresh = false,
reopenCurrentDb = false,
source = "manual-refresh",
} = {},
) {
const normalizedChatId = normalizeChatIdCandidate(chatId);
const settings = getSettings();
const requestedMode = getRequestedGraphLocalStorageMode(settings);
const shouldProbeCapability =
forceCapabilityRefresh === true ||
!bmeLocalStoreCapabilitySnapshot.checked ||
requestedMode === "auto" ||
isGraphLocalStorageModeOpfs(requestedMode);
if (shouldProbeCapability) {
await getGraphLocalStoreCapability(forceCapabilityRefresh === true);
}
const preferredLocalStore =
await resolvePreferredGraphLocalStorePresentation(settings);
let resolvedLocalStore = preferredLocalStore;
let localStoreDiagnostics = {
resolvedLocalStore: buildGraphLocalStoreSelectorKey(preferredLocalStore),
localStoreFormatVersion:
preferredLocalStore.storagePrimary === "opfs" ? 2 : 1,
localStoreMigrationState: "idle",
opfsWalDepth: 0,
opfsPendingBytes: 0,
opfsCompactionState: null,
};
let opfsWriteLockState = cloneRuntimeDebugValue(
graphPersistenceState.opfsWriteLockState,
null,
);
let reopenError = "";
if (
reopenCurrentDb === true &&
normalizedChatId &&
bmeChatManager &&
typeof bmeChatManager.getCurrentChatId === "function" &&
typeof bmeChatManager.closeCurrent === "function" &&
bmeChatManager.getCurrentChatId() === normalizedChatId
) {
await bmeChatManager.closeCurrent();
}
if (normalizedChatId) {
clearCachedIndexedDbSnapshot(normalizedChatId);
try {
const manager = ensureBmeChatManager();
if (manager) {
const db = await manager.getCurrentDb(normalizedChatId);
resolvedLocalStore = resolveDbGraphStorePresentation(db);
localStoreDiagnostics = readLocalStoreDiagnosticsSync(
db,
resolvedLocalStore,
);
opfsWriteLockState =
typeof db?.getWriteLockSnapshot === "function"
? cloneRuntimeDebugValue(db.getWriteLockSnapshot(), null)
: opfsWriteLockState;
}
} catch (error) {
reopenError = error?.message || String(error);
console.warn(
"[ST-BME] 刷新当前聊天本地存储绑定失败:",
{
chatId: normalizedChatId,
source,
requestedMode,
error: reopenError,
},
);
}
}
const persistenceEnvironment = buildPersistenceEnvironment(
getContext(),
resolvedLocalStore,
);
updateGraphPersistenceState({
hostProfile: persistenceEnvironment.hostProfile,
primaryStorageTier: persistenceEnvironment.primaryStorageTier,
cacheStorageTier: persistenceEnvironment.cacheStorageTier,
storagePrimary: resolvedLocalStore.storagePrimary,
storageMode: resolvedLocalStore.storageMode,
resolvedLocalStore: localStoreDiagnostics.resolvedLocalStore,
localStoreFormatVersion: localStoreDiagnostics.localStoreFormatVersion,
localStoreMigrationState: localStoreDiagnostics.localStoreMigrationState,
opfsWriteLockState,
opfsWalDepth: localStoreDiagnostics.opfsWalDepth,
opfsPendingBytes: localStoreDiagnostics.opfsPendingBytes,
opfsCompactionState: localStoreDiagnostics.opfsCompactionState,
indexedDbLastError: reopenError ? reopenError : "",
});
return {
capability: cloneRuntimeDebugValue(bmeLocalStoreCapabilitySnapshot, null),
requestedMode,
resolvedLocalStore,
localStoreDiagnostics,
reopenError,
};
}
function buildPanelOpenLocalStoreRefreshPlan(
context = getContext(),
settings = getSettings(),
) {
const requestedMode = getRequestedGraphLocalStorageMode(settings);
const usesOpfsPreference =
requestedMode === "auto" || isGraphLocalStorageModeOpfs(requestedMode);
const activeChatId = normalizeChatIdCandidate(getCurrentChatId(context));
const preferredLocalStore = getPreferredGraphLocalStorePresentationSync(settings);
const resolvedLocalStoreKey = String(
graphPersistenceState.resolvedLocalStore ||
buildGraphLocalStoreSelectorKey(preferredLocalStore),
).trim();
const resolvedIsOpfs = resolvedLocalStoreKey.startsWith("opfs:");
const preferredIsOpfs = preferredLocalStore.storagePrimary === "opfs";
const capabilityUnchecked = bmeLocalStoreCapabilitySnapshot.checked !== true;
const pendingPersist = graphPersistenceState.pendingPersist === true;
const writesBlocked = graphPersistenceState.writesBlocked === true;
const loadState = String(graphPersistenceState.loadState || "");
const loadingWithoutDb =
loadState === GRAPH_LOAD_STATES.LOADING && graphPersistenceState.dbReady !== true;
const blocked = loadState === GRAPH_LOAD_STATES.BLOCKED;
const persistError = String(graphPersistenceState.indexedDbLastError || "").trim();
const localStoreMismatch =
Boolean(activeChatId) &&
preferredIsOpfs &&
Boolean(resolvedLocalStoreKey) &&
!resolvedIsOpfs;
const shouldRefresh =
usesOpfsPreference &&
(capabilityUnchecked ||
pendingPersist ||
writesBlocked ||
blocked ||
loadingWithoutDb ||
Boolean(persistError) ||
localStoreMismatch);
const forceCapabilityRefresh =
capabilityUnchecked ||
pendingPersist ||
blocked ||
loadingWithoutDb ||
Boolean(persistError) ||
localStoreMismatch;
const reopenCurrentDb =
Boolean(activeChatId) &&
(pendingPersist || writesBlocked || blocked || Boolean(persistError) || localStoreMismatch);
const reasons = [];
if (capabilityUnchecked) reasons.push("capability-unchecked");
if (pendingPersist) reasons.push("pending-persist");
if (writesBlocked) reasons.push("writes-blocked");
if (blocked) reasons.push("load-blocked");
if (loadingWithoutDb) reasons.push("loading-without-db");
if (persistError) reasons.push("local-store-error");
if (localStoreMismatch) reasons.push("resolved-store-mismatch");
return {
shouldRefresh,
forceCapabilityRefresh,
reopenCurrentDb,
requestedMode,
resolvedLocalStoreKey,
preferredLocalStore,
reasons,
};
}
function getMessageHideSettings(settings = null) {
let sourceSettings = settings;
if (!sourceSettings || typeof sourceSettings !== "object") {
@@ -8631,6 +8948,21 @@ async function retryPendingGraphPersist({
});
}
const requestedLocalStoreMode = getRequestedGraphLocalStorageMode(
getSettings(),
);
if (
requestedLocalStoreMode === "auto" ||
isGraphLocalStorageModeOpfs(requestedLocalStoreMode)
) {
await refreshCurrentChatLocalStoreBinding({
chatId: activeChatId,
forceCapabilityRefresh: true,
reopenCurrentDb: true,
source: reason,
});
}
const pendingPersistGraphSource = resolvePendingPersistGraphSource(
queuedChatId,
);
@@ -11082,6 +11414,18 @@ async function saveGraphToIndexedDb(
typeof db?.getWriteLockSnapshot === "function"
? cloneRuntimeDebugValue(db.getWriteLockSnapshot(), null)
: null;
const localStoreDiagnostics =
typeof readLocalStoreDiagnosticsSync === "function"
? readLocalStoreDiagnosticsSync(db, localStore)
: {
resolvedLocalStore: `${localStore?.storagePrimary || "indexeddb"}:${localStore?.storageMode || "indexeddb"}`,
localStoreFormatVersion:
localStore?.storagePrimary === "opfs" ? 2 : 1,
localStoreMigrationState: "idle",
opfsWalDepth: 0,
opfsPendingBytes: 0,
opfsCompactionState: null,
};
updateGraphPersistenceState({
hostProfile: persistenceEnvironment.hostProfile,
primaryStorageTier: persistenceEnvironment.primaryStorageTier,
@@ -11092,8 +11436,14 @@ async function saveGraphToIndexedDb(
: graphPersistenceState.cacheMirrorState,
storagePrimary: localStore.storagePrimary,
storageMode: localStore.storageMode,
resolvedLocalStore: localStoreDiagnostics.resolvedLocalStore,
localStoreFormatVersion: localStoreDiagnostics.localStoreFormatVersion,
localStoreMigrationState: localStoreDiagnostics.localStoreMigrationState,
indexedDbLastError: error?.message || String(error),
opfsWriteLockState,
opfsWalDepth: localStoreDiagnostics.opfsWalDepth,
opfsPendingBytes: localStoreDiagnostics.opfsPendingBytes,
opfsCompactionState: localStoreDiagnostics.opfsCompactionState,
dualWriteLastResult: {
action: persistRole === "cache-mirror" ? "cache-mirror" : "save",
target: localStore.storagePrimary,
@@ -11112,7 +11462,7 @@ async function saveGraphToIndexedDb(
reason:
persistRole === "cache-mirror"
? "cache-mirror-write-failed"
: "indexeddb-write-failed",
: `${String(localStore?.reasonPrefix || "indexeddb")}-write-failed`,
error,
};
}
@@ -15306,6 +15656,8 @@ const _cleanupRuntime = () => ({
clearCachedIndexedDbSnapshot,
clearAllCachedIndexedDbSnapshots,
clearCurrentChatCommitMarker,
clearCurrentChatRecoveryAnchors,
refreshCurrentChatLocalStoreBinding,
deleteCurrentChatOpfsStorage: async (chatId) =>
await deleteOpfsChatStorage(chatId),
deleteAllOpfsStorage: async () =>
@@ -15564,6 +15916,11 @@ async function onRollbackLastRestore() {
}
async function onRetryPendingPersist() {
await refreshCurrentChatLocalStoreBinding({
forceCapabilityRefresh: true,
reopenCurrentDb: true,
source: "panel-manual-persist-retry",
});
const hadPending = graphPersistenceState.pendingPersist === true;
const result = await retryPendingGraphPersist({
reason: "panel-manual-persist-retry",
@@ -15589,6 +15946,11 @@ async function onRetryPendingPersist() {
}
async function onProbeGraphLoad() {
await refreshCurrentChatLocalStoreBinding({
forceCapabilityRefresh: true,
reopenCurrentDb: true,
source: "panel-manual-graph-probe",
});
const result = syncGraphLoadFromLiveContext({
source: "panel-manual-graph-probe",
force: true,
@@ -15617,10 +15979,19 @@ async function onProbeGraphLoad() {
await initializePanelBridgeController({
$,
actions: {
syncGraphLoad: () =>
syncGraphLoadFromLiveContext({
syncGraphLoad: async () => {
const refreshPlan = buildPanelOpenLocalStoreRefreshPlan();
if (refreshPlan.shouldRefresh) {
await refreshCurrentChatLocalStoreBinding({
forceCapabilityRefresh: refreshPlan.forceCapabilityRefresh,
reopenCurrentDb: refreshPlan.reopenCurrentDb,
source: `panel-open-sync:${refreshPlan.reasons.join(",") || "refresh"}`,
});
}
return syncGraphLoadFromLiveContext({
source: "panel-open-sync",
}),
});
},
extractTask: onExtractionTask,
extract: onManualExtract,
compress: onManualCompress,

View File

@@ -883,7 +883,9 @@ async function createGraphPersistenceHarness({
buildSnapshotFromGraph,
evaluatePersistNativeDeltaGate,
buildBmeDbName,
BME_GRAPH_LOCAL_STORAGE_MODE_AUTO: "auto",
BME_GRAPH_LOCAL_STORAGE_MODE_INDEXEDDB: "indexeddb",
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_PRIMARY: "opfs-primary",
BME_GRAPH_LOCAL_STORAGE_MODE_OPFS_SHADOW: "opfs-shadow",
detectOpfsSupport: async () => ({
available: false,
@@ -1089,6 +1091,7 @@ result = {
writeGraphShadowSnapshot,
removeGraphShadowSnapshot,
maybeCaptureGraphShadowSnapshot,
buildPanelOpenLocalStoreRefreshPlan,
loadGraphFromChat,
loadGraphFromIndexedDb,
saveGraphToChat,
@@ -1129,6 +1132,13 @@ result = {
getGraphPersistenceState() {
return graphPersistenceState;
},
setLocalStoreCapabilitySnapshot(patch = {}) {
bmeLocalStoreCapabilitySnapshot = {
...bmeLocalStoreCapabilitySnapshot,
...(patch || {}),
};
return bmeLocalStoreCapabilitySnapshot;
},
setChatContext(nextContext) {
globalThis.__chatContext = nextContext;
return globalThis.__chatContext;
@@ -1848,6 +1858,84 @@ result = {
assert.equal(result.reason, "no-sync-needed");
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-panel-open-healthy",
globalChatId: "chat-panel-open-healthy",
chatMetadata: {
integrity: "chat-panel-open-healthy-integrity",
},
});
harness.runtimeContext.extension_settings[MODULE_NAME] = {
graphLocalStorageMode: "auto",
};
harness.api.setLocalStoreCapabilitySnapshot({
checked: true,
opfsAvailable: true,
reason: "ok",
});
harness.api.setGraphPersistenceState({
loadState: "loaded",
chatId: "chat-panel-open-healthy",
reason: "healthy",
dbReady: true,
writesBlocked: false,
pendingPersist: false,
indexedDbLastError: "",
resolvedLocalStore: "opfs:opfs-primary",
storagePrimary: "opfs",
storageMode: "opfs-primary",
});
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
assert.equal(
plan.shouldRefresh,
false,
"健康态的面板打开不应每次都强刷本地引擎绑定",
);
assert.equal(Array.isArray(plan.reasons), true);
assert.equal(plan.reasons.length, 0);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-panel-open-pending",
globalChatId: "chat-panel-open-pending",
chatMetadata: {
integrity: "chat-panel-open-pending-integrity",
},
});
harness.runtimeContext.extension_settings[MODULE_NAME] = {
graphLocalStorageMode: "auto",
};
harness.api.setLocalStoreCapabilitySnapshot({
checked: true,
opfsAvailable: true,
reason: "ok",
});
harness.api.setGraphPersistenceState({
loadState: "blocked",
chatId: "chat-panel-open-pending",
reason: "persist-queued",
dbReady: false,
writesBlocked: true,
pendingPersist: true,
indexedDbLastError: "opfs-write-failed",
resolvedLocalStore: "indexeddb:indexeddb",
storagePrimary: "indexeddb",
storageMode: "indexeddb",
});
const plan = harness.api.buildPanelOpenLocalStoreRefreshPlan();
assert.equal(plan.shouldRefresh, true);
assert.equal(plan.forceCapabilityRefresh, true);
assert.equal(plan.reopenCurrentDb, true);
assert.equal(plan.reasons.includes("pending-persist"), true);
assert.equal(plan.reasons.includes("resolved-store-mismatch"), true);
}
{
const harness = await createGraphPersistenceHarness({
chatId: "chat-luker-panel-open",

View File

@@ -1216,10 +1216,37 @@ export async function onDeleteCurrentIdbController(runtime) {
: null;
runtime.clearCachedIndexedDbSnapshot?.(chatId);
runtime.clearCachedIndexedDbSnapshot?.(restoreSafetyChatId);
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-current-local-storage",
immediate: true,
resetAcceptedRevision: true,
if (typeof runtime.clearCurrentChatRecoveryAnchors === "function") {
runtime.clearCurrentChatRecoveryAnchors({
chatId,
reason: "manual-delete-current-local-storage",
immediate: true,
clearMetadataFull: true,
clearCommitMarker: true,
clearPendingPersist: true,
});
if (restoreSafetyChatId && restoreSafetyChatId !== chatId) {
runtime.clearCurrentChatRecoveryAnchors({
chatId: restoreSafetyChatId,
reason: "manual-delete-current-local-storage:restore-safety",
immediate: true,
clearMetadataFull: false,
clearCommitMarker: false,
clearPendingPersist: false,
});
}
} else {
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-current-local-storage",
immediate: true,
resetAcceptedRevision: true,
});
}
await runtime.refreshCurrentChatLocalStoreBinding?.({
chatId,
forceCapabilityRefresh: true,
reopenCurrentDb: true,
source: "manual-delete-current-local-storage",
});
runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-current-local-storage",
@@ -1279,10 +1306,27 @@ export async function onDeleteAllIdbController(runtime) {
runtime.clearAllCachedIndexedDbSnapshots?.();
const activeChatId = runtime.getCurrentChatId?.();
if (activeChatId) {
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-all-local-storage",
immediate: true,
resetAcceptedRevision: true,
if (typeof runtime.clearCurrentChatRecoveryAnchors === "function") {
runtime.clearCurrentChatRecoveryAnchors({
chatId: activeChatId,
reason: "manual-delete-all-local-storage",
immediate: true,
clearMetadataFull: true,
clearCommitMarker: true,
clearPendingPersist: true,
});
} else {
runtime.clearCurrentChatCommitMarker?.({
reason: "manual-delete-all-local-storage",
immediate: true,
resetAcceptedRevision: true,
});
}
await runtime.refreshCurrentChatLocalStoreBinding?.({
chatId: activeChatId,
forceCapabilityRefresh: true,
reopenCurrentDb: true,
source: "manual-delete-all-local-storage",
});
runtime.syncGraphLoadFromLiveContext?.({
source: "manual-delete-all-local-storage",