mirror of
https://github.com/Youzini-afk/ST-Bionic-Memory-Ecology.git
synced 2026-06-13 18:31:16 +08:00
fix(persistence): repair legacy pending state safely
This commit is contained in:
80
index.js
80
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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
208
sync/legacy-persistence-repair.js
Normal file
208
sync/legacy-persistence-repair.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
191
tests/legacy-persistence-repair.mjs
Normal file
191
tests/legacy-persistence-repair.mjs
Normal 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");
|
||||
Reference in New Issue
Block a user