From 0f0da1fbe8d390072734f8e4a098c17909ab7417 Mon Sep 17 00:00:00 2001 From: youzini Date: Tue, 19 May 2026 07:36:35 +0000 Subject: [PATCH] fix(persistence): repair legacy pending state safely --- index.js | 80 +++++++---- maintenance/extraction-controller.js | 15 +- sync/legacy-persistence-repair.js | 208 +++++++++++++++++++++++++++ tests/graph-persistence.mjs | 170 ++++++++++++++++++++++ tests/legacy-persistence-repair.mjs | 191 ++++++++++++++++++++++++ 5 files changed, 626 insertions(+), 38 deletions(-) create mode 100644 sync/legacy-persistence-repair.js create mode 100644 tests/legacy-persistence-repair.mjs diff --git a/index.js b/index.js index d3a66ca..48dd690 100644 --- a/index.js +++ b/index.js @@ -60,6 +60,12 @@ import { scheduleUpload, syncNow, } from "./sync/bme-sync.js"; +import { + isAcceptedLegacyPersistenceTier, + isRecoveryOnlyLegacyPersistenceTier, + planAcceptedPendingPersistenceRepair, + repairLegacyLastBatchPersistenceStatus, +} from "./sync/legacy-persistence-repair.js"; import { buildExtractionMessages, clampRecoveryStartFloor, @@ -887,13 +893,11 @@ function clearCurrentChatRecoveryAnchors( } function isAcceptedPersistTier(storageTier = "none") { - const normalizedTier = String(storageTier || "none").trim().toLowerCase(); - return normalizedTier === "indexeddb" || normalizedTier === "chat-state"; + return isAcceptedLegacyPersistenceTier(storageTier); } function isRecoveryOnlyPersistTier(storageTier = "none") { - const normalizedTier = String(storageTier || "none").trim().toLowerCase(); - return normalizedTier === "shadow" || normalizedTier === "metadata-full"; + return isRecoveryOnlyLegacyPersistenceTier(storageTier); } function resolvePersistRevisionFloor( @@ -12929,6 +12933,30 @@ function applyIndexedDbSnapshotToRuntime( normalizeChatIdCandidate(snapshot?.meta?.integrity) || getChatMetadataIntegrity(getContext()), }); + const currentCommitMarker = getChatCommitMarker(getContext()); + const currentCommitMarkerChatId = normalizeChatIdCandidate(currentCommitMarker?.chatId); + const currentIdentity = resolveCurrentChatIdentity(getContext()); + const legacyBatchRepair = repairLegacyLastBatchPersistenceStatus({ + batchStatus: currentGraph.historyState?.lastBatchStatus || null, + persistenceState: graphPersistenceState, + commitMarker: currentCommitMarker, + activeChatId: normalizedChatId, + commitMarkerChatMatchesActive: + Boolean(currentCommitMarkerChatId) && + (areChatIdsEquivalentForResolvedIdentity( + currentCommitMarkerChatId, + normalizedChatId, + currentIdentity, + ) || + areChatIdsEquivalentForResolvedIdentity( + normalizedChatId, + currentCommitMarkerChatId, + currentIdentity, + )), + }); + if (legacyBatchRepair.repaired && currentGraph.historyState) { + currentGraph.historyState.lastBatchStatus = legacyBatchRepair.batchStatus; + } currentGraph.vectorIndexState.lastIntegrityIssue = null; extractionCount = Number.isFinite(currentGraph?.historyState?.extractionCount) @@ -13002,7 +13030,8 @@ function applyIndexedDbSnapshotToRuntime( persistencePatch.indexedDbRevision = revision; } updateGraphPersistenceState(persistencePatch); - const shouldPersistPostLoadRepairs = hasGraphPersistDirtyState(currentGraph); + const shouldPersistPostLoadRepairs = + hasGraphPersistDirtyState(currentGraph) || legacyBatchRepair.repaired === true; rememberResolvedGraphIdentityAlias(getContext(), normalizedChatId); if (shouldPersistPostLoadRepairs) { @@ -13017,14 +13046,17 @@ function applyIndexedDbSnapshotToRuntime( ) { return; } - debugDebug("[ST-BME] 已检测到加载后作用域自愈,后台写回修复结果", { + debugDebug("[ST-BME] 已检测到加载后图谱自愈,后台写回修复结果", { chatId: normalizedChatId, repairedNodeCount, repairedEdgeCount, + legacyBatchPersistenceRepaired: legacyBatchRepair.repaired === true, source, }); saveGraphToChat({ - reason: "scope-auto-repair-after-load", + reason: legacyBatchRepair.repaired + ? "legacy-persistence-auto-repair-after-load" + : "scope-auto-repair-after-load", markMutation: false, immediate: false, }); @@ -15050,15 +15082,6 @@ function maybeClearAcceptedPendingPersistState( const batchStatus = currentGraph?.historyState?.lastBatchStatus || null; const persistence = batchStatus?.persistence || null; - const persistenceRevision = Number(persistence?.revision || 0); - const queuedRevision = Number(graphPersistenceState.queuedPersistRevision || 0); - const targetRevision = Math.max( - Number.isFinite(persistenceRevision) ? persistenceRevision : 0, - Number.isFinite(queuedRevision) ? queuedRevision : 0, - ); - if (!Number.isFinite(targetRevision) || targetRevision <= 0) { - return false; - } const commitMarker = syncCommitMarkerToPersistenceState(getContext()); const context = getContext(); @@ -15096,27 +15119,26 @@ function maybeClearAcceptedPendingPersistState( markerChatId, currentIdentity, )); - const acceptedRevision = Math.max( - Number(graphPersistenceState.lastAcceptedRevision || 0), - markerAcceptedForQueuedChat ? Number(markerAcceptedRevision || 0) : 0, - ); - if (!Number.isFinite(acceptedRevision) || acceptedRevision < targetRevision) { + const plan = planAcceptedPendingPersistenceRepair({ + batchPersistence: persistence, + persistenceState: graphPersistenceState, + commitMarker, + activeChatId, + queuedChatId, + markerChatMatchesQueued: markerAcceptedForQueuedChat, + }); + if (plan.action !== "clear-stale-pending") { return false; } - const acceptedStorageTier = - String(graphPersistenceState.acceptedStorageTier || "").trim() || - String(commitMarker?.storageTier || "").trim() || - String(persistence?.storageTier || "").trim() || - "none"; const acceptedResult = buildGraphPersistResult({ saved: true, accepted: true, reason: `${String(source || "accepted-pending-persist-reconcile")}:accepted-revision`, - revision: targetRevision, + revision: plan.targetRevision, saveMode: "accepted-revision-reconcile", - storageTier: acceptedStorageTier, - acceptedBy: acceptedStorageTier, + storageTier: plan.tier, + acceptedBy: plan.tier, }); applyAcceptedPendingPersistState(acceptedResult, { lastProcessedAssistantFloor: resolvePendingPersistLastProcessedAssistantFloor(), diff --git a/maintenance/extraction-controller.js b/maintenance/extraction-controller.js index ed96aff..7d7ee09 100644 --- a/maintenance/extraction-controller.js +++ b/maintenance/extraction-controller.js @@ -2,6 +2,7 @@ // 通过 runtime 依赖注入,避免直接访问 index.js 模块级状态。 import { debugLog } from "../runtime/debug-logging.js"; +import { getPendingPersistenceTargetRevision } from "../sync/legacy-persistence-repair.js"; import { buildDialogueFloorMap, normalizeDialogueFloorRange, @@ -547,15 +548,11 @@ function isPersistenceRevisionAccepted(runtime, persistence = null) { function isPendingPersistenceRevisionAccepted(runtime, persistence = null) { const persistenceState = runtime?.getGraphPersistenceState?.() || {}; - const persistenceRevision = Number(persistence?.revision || 0); - const queuedRevision = Number(persistenceState.queuedPersistRevision || 0); - const targetRevision = Math.max( - Number.isFinite(persistenceRevision) ? persistenceRevision : 0, - Number.isFinite(queuedRevision) ? queuedRevision : 0, - ); - if (!Number.isFinite(targetRevision) || targetRevision <= 0) { - return false; - } + const targetRevision = getPendingPersistenceTargetRevision({ + batchPersistence: persistence, + persistenceState, + }); + if (targetRevision <= 0) return false; const lastAcceptedRevision = Number(persistenceState.lastAcceptedRevision || 0); return Number.isFinite(lastAcceptedRevision) && lastAcceptedRevision >= targetRevision; } diff --git a/sync/legacy-persistence-repair.js b/sync/legacy-persistence-repair.js new file mode 100644 index 0000000..c6aed7e --- /dev/null +++ b/sync/legacy-persistence-repair.js @@ -0,0 +1,208 @@ +// ST-BME: legacy persistence repair policy +// Pure helpers only: no IO, no runtime mutation. + +const ACCEPTED_GRAPH_TIERS = new Set([ + "authority-sql", + "opfs", + "indexeddb", + "chat-state", + "luker-chat-state", +]); + +const RECOVERY_ONLY_GRAPH_TIERS = new Set([ + "shadow", + "metadata-full", + "authority-blob", + "authority-blob-checkpoint", + "runtime-recovery", +]); + +const REPLICA_ONLY_GRAPH_TIERS = new Set([ + "trivium", + "authority-trivium", + "vector", +]); + +export function normalizeLegacyPersistenceTier(storageTier = "none") { + return String(storageTier || "none").trim().toLowerCase() || "none"; +} + +export function classifyLegacyPersistenceTier(storageTier = "none") { + const tier = normalizeLegacyPersistenceTier(storageTier); + if (ACCEPTED_GRAPH_TIERS.has(tier)) { + return { tier, role: "accepted", accepted: true, recoverable: true }; + } + if (RECOVERY_ONLY_GRAPH_TIERS.has(tier)) { + return { tier, role: "recovery-only", accepted: false, recoverable: true }; + } + if (REPLICA_ONLY_GRAPH_TIERS.has(tier)) { + return { tier, role: "replica-only", accepted: false, recoverable: false }; + } + return { tier, role: "unknown", accepted: false, recoverable: false }; +} + +export function isAcceptedLegacyPersistenceTier(storageTier = "none") { + return classifyLegacyPersistenceTier(storageTier).accepted === true; +} + +export function isRecoveryOnlyLegacyPersistenceTier(storageTier = "none") { + const classified = classifyLegacyPersistenceTier(storageTier); + return classified.role === "recovery-only"; +} + +function firstMeaningfulTier(...values) { + for (const value of values) { + const tier = normalizeLegacyPersistenceTier(value); + if (tier && tier !== "none") return tier; + } + return "none"; +} + +export function getPendingPersistenceTargetRevision({ + batchPersistence = null, + persistenceState = null, +} = {}) { + const persistenceRevision = Number(batchPersistence?.revision || 0); + const queuedRevision = Number(persistenceState?.queuedPersistRevision || 0); + const targetRevision = Math.max( + Number.isFinite(persistenceRevision) ? persistenceRevision : 0, + Number.isFinite(queuedRevision) ? queuedRevision : 0, + ); + return Number.isFinite(targetRevision) && targetRevision > 0 ? targetRevision : 0; +} + +export function getAcceptedCommitMarkerRevision(marker = null) { + if (!marker || typeof marker !== "object") return 0; + if (marker.accepted !== true) return 0; + const revision = Number(marker.revision || marker.acceptedRevision || 0); + return Number.isFinite(revision) && revision > 0 ? Math.floor(revision) : 0; +} + +export function planAcceptedPendingPersistenceRepair({ + batchPersistence = null, + persistenceState = null, + commitMarker = null, + activeChatId = "", + queuedChatId = "", + markerChatMatchesQueued = false, +} = {}) { + const targetRevision = getPendingPersistenceTargetRevision({ + batchPersistence, + persistenceState, + }); + if (persistenceState?.pendingPersist !== true) { + return { action: "keep", reason: "not-pending", targetRevision }; + } + if (targetRevision <= 0) { + return { action: "keep", reason: "missing-target-revision", targetRevision }; + } + const normalizedActiveChatId = String(activeChatId || "").trim(); + const normalizedQueuedChatId = String(queuedChatId || "").trim(); + if (!normalizedActiveChatId || !normalizedQueuedChatId) { + return { action: "keep", reason: "missing-chat-id", targetRevision }; + } + if (normalizedActiveChatId !== normalizedQueuedChatId) { + return { action: "keep", reason: "queued-chat-mismatch", targetRevision }; + } + + const stateAcceptedRevision = Number(persistenceState?.lastAcceptedRevision || 0); + const markerAcceptedRevision = markerChatMatchesQueued + ? getAcceptedCommitMarkerRevision(commitMarker) + : 0; + const acceptedRevision = Math.max( + Number.isFinite(stateAcceptedRevision) ? stateAcceptedRevision : 0, + Number.isFinite(markerAcceptedRevision) ? markerAcceptedRevision : 0, + ); + if (acceptedRevision < targetRevision) { + return { + action: "keep", + reason: "accepted-revision-behind", + targetRevision, + acceptedRevision, + }; + } + + const tier = firstMeaningfulTier( + persistenceState?.acceptedStorageTier, + commitMarker?.storageTier, + batchPersistence?.storageTier, + ); + if (!isAcceptedLegacyPersistenceTier(tier)) { + return { + action: "keep", + reason: "accepted-tier-not-canonical", + targetRevision, + acceptedRevision, + tier, + }; + } + + return { + action: "clear-stale-pending", + reason: "accepted-revision-covers-pending", + targetRevision, + acceptedRevision, + tier, + }; +} + +export function repairLegacyLastBatchPersistenceStatus({ + batchStatus = null, + persistenceState = null, + commitMarker = null, + activeChatId = "", + commitMarkerChatMatchesActive = false, +} = {}) { + const persistence = batchStatus?.persistence; + if (!batchStatus || !persistence || typeof persistence !== "object") { + return { repaired: false, batchStatus }; + } + const targetRevision = getPendingPersistenceTargetRevision({ + batchPersistence: persistence, + persistenceState, + }); + const normalizedActiveChatId = String(activeChatId || "").trim(); + const stateChatId = String(persistenceState?.chatId || "").trim(); + const stateMatchesActive = + !normalizedActiveChatId || !stateChatId || normalizedActiveChatId === stateChatId; + const acceptedRevision = Math.max( + stateMatchesActive ? Number(persistenceState?.lastAcceptedRevision || 0) : 0, + commitMarkerChatMatchesActive ? getAcceptedCommitMarkerRevision(commitMarker) : 0, + ); + const tier = firstMeaningfulTier( + stateMatchesActive ? persistenceState?.acceptedStorageTier : "", + commitMarkerChatMatchesActive ? commitMarker?.storageTier : "", + persistence.storageTier, + ); + if ( + targetRevision <= 0 || + acceptedRevision < targetRevision || + !isAcceptedLegacyPersistenceTier(tier) + ) { + return { repaired: false, batchStatus }; + } + return { + repaired: true, + batchStatus: { + ...batchStatus, + historyAdvanceAllowed: true, + historyAdvanced: batchStatus.historyAdvanced === true, + persistence: { + ...persistence, + outcome: "accepted", + accepted: true, + saved: true, + queued: false, + blocked: false, + recoverable: false, + attempted: true, + storageTier: tier, + reason: "legacy-accepted-revision-repair", + revision: targetRevision, + }, + }, + targetRevision, + acceptedRevision, + tier, + }; +} diff --git a/tests/graph-persistence.mjs b/tests/graph-persistence.mjs index bf53832..de68927 100644 --- a/tests/graph-persistence.mjs +++ b/tests/graph-persistence.mjs @@ -119,6 +119,12 @@ import { AUTHORITY_GRAPH_STORE_MODE, AuthorityGraphStore, } from "../sync/authority-graph-store.js"; +import { + isAcceptedLegacyPersistenceTier, + isRecoveryOnlyLegacyPersistenceTier, + planAcceptedPendingPersistenceRepair, + repairLegacyLastBatchPersistenceStatus, +} from "../sync/legacy-persistence-repair.js"; import { clampFloat, clampInt, @@ -702,6 +708,10 @@ async function createGraphPersistenceHarness({ AUTHORITY_GRAPH_STORE_KIND, AUTHORITY_GRAPH_STORE_MODE, AuthorityGraphStore: HarnessAuthorityGraphStore, + isAcceptedLegacyPersistenceTier, + isRecoveryOnlyLegacyPersistenceTier, + planAcceptedPendingPersistenceRepair, + repairLegacyLastBatchPersistenceStatus, migrateLegacyTaskProfiles(settings = {}) { return { taskProfilesVersion: Number(settings?.taskProfilesVersion || 0), @@ -4029,6 +4039,166 @@ result = { ); } +{ + const graph = createMeaningfulGraph( + "chat-load-legacy-pending-repair", + "load-legacy-pending-repair", + ); + graph.historyState.lastProcessedAssistantFloor = 1; + graph.lastProcessedSeq = 1; + graph.historyState.lastBatchStatus = { + processedRange: [1, 1], + completed: true, + persistence: { + outcome: "queued", + accepted: false, + storageTier: "metadata-full", + reason: "old-version-pending", + revision: 4, + saveMode: "immediate", + saved: false, + queued: true, + blocked: true, + }, + historyAdvanceAllowed: false, + historyAdvanced: false, + }; + stampPersistedGraph(graph, { + revision: 4, + chatId: "chat-load-legacy-pending-repair", + reason: "legacy-pending-repair-seed", + }); + + const snapshot = buildSnapshotFromGraph(graph, { + chatId: "meta-load-legacy-pending-repair", + revision: 4, + meta: { + integrity: "meta-load-legacy-pending-repair", + }, + }); + const harness = await createGraphPersistenceHarness({ + chatId: "chat-load-legacy-pending-repair", + globalChatId: "chat-load-legacy-pending-repair", + chatMetadata: { + integrity: "meta-load-legacy-pending-repair", + [GRAPH_COMMIT_MARKER_KEY]: { + accepted: true, + revision: 4, + storageTier: "indexeddb", + chatId: "meta-load-legacy-pending-repair", + }, + }, + indexedDbSnapshots: { + "meta-load-legacy-pending-repair": snapshot, + }, + chat: [ + { is_user: true, mes: "旧聊天" }, + { is_user: false, mes: "旧回复" }, + ], + }); + harness.runtimeContext.extension_settings[MODULE_NAME] = { + graphLocalStorageMode: "indexeddb", + }; + + const result = await harness.api.loadGraphFromIndexedDb( + "meta-load-legacy-pending-repair", + { + source: "legacy-pending-load-repair-test", + allowOverride: true, + }, + ); + + assert.equal(result.loaded, true, result.reason); + const repairedStatus = harness.api.getCurrentGraph().historyState.lastBatchStatus; + assert.equal(repairedStatus.historyAdvanceAllowed, true); + assert.equal(repairedStatus.persistence.accepted, true); + assert.equal(repairedStatus.persistence.saved, true); + assert.equal(repairedStatus.persistence.queued, false); + assert.equal(repairedStatus.persistence.blocked, false); + assert.equal(repairedStatus.persistence.storageTier, "indexeddb"); + await new Promise((resolve) => setTimeout(resolve, 0)); + assert.equal( + harness.api.getIndexedDbSnapshotForChat("meta-load-legacy-pending-repair")?.meta?.lastMutationReason, + "legacy-persistence-auto-repair-after-load", + "加载旧 pending 状态后应将归一化结果写回 canonical store", + ); +} + +{ + const graph = createMeaningfulGraph( + "chat-load-legacy-pending-no-proof", + "load-legacy-pending-no-proof", + ); + graph.historyState.lastProcessedAssistantFloor = 1; + graph.lastProcessedSeq = 1; + graph.historyState.lastBatchStatus = { + processedRange: [1, 1], + completed: true, + persistence: { + outcome: "queued", + accepted: false, + storageTier: "metadata-full", + reason: "old-version-pending", + revision: 4, + saveMode: "immediate", + saved: false, + queued: true, + blocked: true, + }, + historyAdvanceAllowed: false, + historyAdvanced: false, + }; + stampPersistedGraph(graph, { + revision: 4, + chatId: "chat-load-legacy-pending-no-proof", + reason: "legacy-pending-no-proof-seed", + }); + + const snapshot = buildSnapshotFromGraph(graph, { + chatId: "meta-load-legacy-pending-no-proof", + revision: 4, + meta: { + integrity: "meta-load-legacy-pending-no-proof", + }, + }); + const harness = await createGraphPersistenceHarness({ + chatId: "chat-load-legacy-pending-no-proof", + globalChatId: "chat-load-legacy-pending-no-proof", + chatMetadata: { + integrity: "meta-load-legacy-pending-no-proof", + }, + indexedDbSnapshots: { + "meta-load-legacy-pending-no-proof": snapshot, + }, + chat: [ + { is_user: true, mes: "旧聊天" }, + { is_user: false, mes: "旧回复" }, + ], + }); + harness.runtimeContext.extension_settings[MODULE_NAME] = { + graphLocalStorageMode: "indexeddb", + }; + + const result = await harness.api.loadGraphFromIndexedDb( + "meta-load-legacy-pending-no-proof", + { + source: "legacy-pending-load-no-proof-test", + allowOverride: true, + }, + ); + + assert.equal(result.loaded, true, result.reason); + const unrepairedStatus = harness.api.getCurrentGraph().historyState.lastBatchStatus; + assert.equal( + unrepairedStatus.historyAdvanceAllowed, + false, + "无独立 accepted 证据时,加载旧 pending 状态不得自动放行历史推进", + ); + assert.equal(unrepairedStatus.persistence.accepted, false); + assert.equal(unrepairedStatus.persistence.queued, true); + assert.equal(unrepairedStatus.persistence.blocked, true); +} + { const harness = await createGraphPersistenceHarness({ chatId: "chat-pending-persist-already-accepted", diff --git a/tests/legacy-persistence-repair.mjs b/tests/legacy-persistence-repair.mjs new file mode 100644 index 0000000..0450c12 --- /dev/null +++ b/tests/legacy-persistence-repair.mjs @@ -0,0 +1,191 @@ +// ST-BME: regression tests — centralized legacy persistence repair policy + +import assert from "node:assert/strict"; +import { + classifyLegacyPersistenceTier, + isAcceptedLegacyPersistenceTier, + isRecoveryOnlyLegacyPersistenceTier, + planAcceptedPendingPersistenceRepair, + repairLegacyLastBatchPersistenceStatus, +} from "../sync/legacy-persistence-repair.js"; + +const acceptedTiers = [ + "authority-sql", + "opfs", + "indexeddb", + "chat-state", + "luker-chat-state", +]; +for (const tier of acceptedTiers) { + assert.equal(isAcceptedLegacyPersistenceTier(tier), true, `${tier} should be canonical`); + assert.equal(classifyLegacyPersistenceTier(tier).role, "accepted"); +} + +for (const tier of ["metadata-full", "shadow", "authority-blob-checkpoint"]) { + assert.equal( + isRecoveryOnlyLegacyPersistenceTier(tier), + true, + `${tier} should be recovery-only`, + ); + assert.equal(isAcceptedLegacyPersistenceTier(tier), false); +} + +for (const tier of ["trivium", "authority-trivium", "vector"]) { + const classified = classifyLegacyPersistenceTier(tier); + assert.equal(classified.role, "replica-only"); + assert.equal(classified.accepted, false); +} + +console.log(" ✓ legacy persistence tier roles are centralized"); + +const coveredPlan = planAcceptedPendingPersistenceRepair({ + batchPersistence: { + revision: 5, + storageTier: "metadata-full", + }, + persistenceState: { + pendingPersist: true, + queuedPersistRevision: 7, + queuedPersistChatId: "chat-old", + lastAcceptedRevision: 7, + acceptedStorageTier: "authority-sql", + }, + commitMarker: { + chatId: "chat-old", + accepted: true, + revision: 7, + storageTier: "authority-sql", + }, + activeChatId: "chat-old", + queuedChatId: "chat-old", + markerChatMatchesQueued: true, +}); +assert.equal(coveredPlan.action, "clear-stale-pending"); +assert.equal(coveredPlan.targetRevision, 7); +assert.equal(coveredPlan.tier, "authority-sql"); + +console.log(" ✓ accepted canonical revision can clear stale legacy pending state"); + +const behindPlan = planAcceptedPendingPersistenceRepair({ + batchPersistence: { revision: 8, storageTier: "indexeddb" }, + persistenceState: { + pendingPersist: true, + queuedPersistRevision: 9, + queuedPersistChatId: "chat-old", + lastAcceptedRevision: 8, + acceptedStorageTier: "indexeddb", + }, + activeChatId: "chat-old", + queuedChatId: "chat-old", +}); +assert.equal(behindPlan.action, "keep"); +assert.equal(behindPlan.reason, "accepted-revision-behind"); + +const wrongTierPlan = planAcceptedPendingPersistenceRepair({ + batchPersistence: { revision: 8, storageTier: "metadata-full" }, + persistenceState: { + pendingPersist: true, + queuedPersistRevision: 8, + queuedPersistChatId: "chat-old", + lastAcceptedRevision: 8, + acceptedStorageTier: "metadata-full", + }, + activeChatId: "chat-old", + queuedChatId: "chat-old", +}); +assert.equal(wrongTierPlan.action, "keep"); +assert.equal(wrongTierPlan.reason, "accepted-tier-not-canonical"); + +const wrongChatPlan = planAcceptedPendingPersistenceRepair({ + batchPersistence: { revision: 8, storageTier: "indexeddb" }, + persistenceState: { + pendingPersist: true, + queuedPersistRevision: 8, + queuedPersistChatId: "chat-a", + lastAcceptedRevision: 8, + acceptedStorageTier: "indexeddb", + }, + activeChatId: "chat-b", + queuedChatId: "chat-a", +}); +assert.equal(wrongChatPlan.action, "keep"); +assert.equal(wrongChatPlan.reason, "queued-chat-mismatch"); + +console.log(" ✓ stale pending repair keeps unsafe legacy states blocked"); + +const legacyBatchStatus = { + processedRange: [0, 2], + historyAdvanced: false, + historyAdvanceAllowed: false, + persistence: { + outcome: "pending", + accepted: false, + saved: false, + queued: true, + blocked: true, + storageTier: "metadata-full", + revision: 4, + reason: "old-version-pending", + }, +}; +const repairedBatch = repairLegacyLastBatchPersistenceStatus({ + batchStatus: legacyBatchStatus, + persistenceState: { + chatId: "chat-old", + lastAcceptedRevision: 4, + acceptedStorageTier: "opfs", + }, + activeChatId: "chat-old", +}); +assert.equal(repairedBatch.repaired, true); +assert.equal(repairedBatch.batchStatus.historyAdvanceAllowed, true); +assert.equal(repairedBatch.batchStatus.persistence.accepted, true); +assert.equal(repairedBatch.batchStatus.persistence.saved, true); +assert.equal(repairedBatch.batchStatus.persistence.queued, false); +assert.equal(repairedBatch.batchStatus.persistence.blocked, false); +assert.equal(repairedBatch.batchStatus.persistence.storageTier, "opfs"); + +const unrepairedBatch = repairLegacyLastBatchPersistenceStatus({ + batchStatus: legacyBatchStatus, + persistenceState: { + chatId: "chat-old", + lastAcceptedRevision: 4, + acceptedStorageTier: "metadata-full", + }, + activeChatId: "chat-old", +}); +assert.equal(unrepairedBatch.repaired, false); + +const wrongChatAcceptedBatch = repairLegacyLastBatchPersistenceStatus({ + batchStatus: legacyBatchStatus, + persistenceState: { + chatId: "other-chat", + lastAcceptedRevision: 4, + acceptedStorageTier: "opfs", + }, + activeChatId: "chat-old", +}); +assert.equal(wrongChatAcceptedBatch.repaired, false); + +const markerRepairBatch = repairLegacyLastBatchPersistenceStatus({ + batchStatus: legacyBatchStatus, + persistenceState: { + chatId: "chat-old", + lastAcceptedRevision: 0, + acceptedStorageTier: "none", + }, + commitMarker: { + accepted: true, + revision: 4, + storageTier: "authority-sql", + chatId: "chat-old", + }, + activeChatId: "chat-old", + commitMarkerChatMatchesActive: true, +}); +assert.equal(markerRepairBatch.repaired, true); +assert.equal(markerRepairBatch.batchStatus.persistence.storageTier, "authority-sql"); + +console.log(" ✓ legacy lastBatchStatus is repaired only after canonical acceptance"); + +console.log("legacy-persistence-repair tests passed");