fix(persistence): repair legacy pending state safely

This commit is contained in:
youzini
2026-05-19 07:36:35 +00:00
parent 68ea62f52f
commit 0f0da1fbe8
5 changed files with 626 additions and 38 deletions

View File

@@ -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(),

View File

@@ -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;
}

View File

@@ -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,
};
}

View File

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

View File

@@ -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");