fix: isolate dynamic rectification persistence

This commit is contained in:
Jesse_Chen
2026-07-19 07:20:40 +08:00
parent cba3ba0094
commit 9bd93f33cf
28 changed files with 483 additions and 74 deletions
@@ -7,6 +7,7 @@ import { createGuidedCandidateActions } from "../src/lib/birth-time-guided-candi
import {
createBirthTimeJourneyService,
type LegacyBirthTimeJourneyEngine,
type DynamicVersionedJourneyResponse,
type StoredRectificationCase,
type VersionedJourneyResponse,
} from "../src/lib/birth-time-journey-service.ts";
@@ -107,7 +108,12 @@ export function createHarness(input: {
};
}
export function assertLegalTurn(turn: VersionedJourneyResponse): void {
export function assertLegalTurn(
turn: VersionedJourneyResponse | DynamicVersionedJourneyResponse,
): asserts turn is VersionedJourneyResponse {
if (turn.journeyProtocol === "dynamic-choice-v2") {
assert.fail("legacy flow unexpectedly resumed a dynamic journey");
}
assert.ok(turn.nextAction, "every legal journey response has nextAction");
assert.ok(turn.turnVersion >= 0);
switch (turn.nextAction.kind) {
@@ -142,13 +142,16 @@ export const privateRow = {
export function loadClient(
privateState: typeof privateRow | null,
omitProcessedActions = false,
publicTurnVersion = publicRow.turn_version,
) {
const { processed_action_ids: ignoredReceipts, ...rowWithoutReceipts } = publicRow;
void ignoredReceipts;
return {
from(table: string) {
const row = table === "birth_time_rectification_cases"
? omitProcessedActions ? rowWithoutReceipts : publicRow
? omitProcessedActions
? rowWithoutReceipts
: { ...publicRow, turn_version: publicTurnVersion }
: table === "birth_time_rectification_dynamic_state"
? privateState
: { latitude: 31.2304, longitude: 121.4737, timezone_offset: 8 };
@@ -173,8 +176,11 @@ export function dynamicCase(): DynamicStoredRectificationCase {
lifeEvents: [],
candidateResult: null,
turnVersion: 7,
turnState: null,
dynamicTurnState,
evidenceDraft: null,
processedActionIds: [],
persistedProgress: { adaptiveRound: 0, askedDomains: [] },
candidateModel: privateRow.candidate_model,
currentChoiceQuestion: persistedQuestion,
choiceAnswers: [],
@@ -215,6 +221,7 @@ export function legacyCase(active: boolean): LegacyStoredRectificationCase {
return {
id: caseId,
userId: ownerId,
journeyProtocol: "legacy-guided-v1",
snapshot,
questionnaire: null,
answers: { q1: "A" },
@@ -75,6 +75,19 @@ test("unknown birth time initializes the full-day dynamic range", () => {
]);
});
test("mixed-null reported ranges fail instead of inventing one boundary", () => {
const mixedRange = {
...snapshot,
reportedRange: {
label: "04:00—未知",
startTime: "04:00",
endTime: null,
},
};
assert.throws(() => createInitialDynamicState(mixedRange, "2026-07-18"));
});
test("new v2 case creation crosses one atomic RPC boundary", async () => {
const calls: { readonly name: string; readonly args: Readonly<Record<string, unknown>> }[] = [];
const savedId = await saveDynamicAssessment({
@@ -100,6 +113,15 @@ test("new v2 case creation crosses one atomic RPC boundary", async () => {
assert.equal(calls[0]?.name, "create_birth_time_dynamic_case");
assert.equal(calls[0]?.args.p_user_id, ownerId);
assert.equal(JSON.stringify(calls[0]?.args.p_public_case).includes("candidateModel"), false);
assert.deepEqual(calls[0]?.args.p_profile, {
reportedBirthTime: null,
birthTimeSource: "period_only",
birthTimePeriod: "early_morning",
birthTimeClue: null,
uncertaintyBeforeMinutes: null,
uncertaintyAfterMinutes: null,
birthTimeStatus: "rectifying",
});
assert.deepEqual(calls[0]?.args.p_private_state, createInitialDynamicState(
snapshot,
"2026-07-18",
@@ -120,6 +142,13 @@ test("v2 load rejects missing required public persistence fields", async () => {
);
});
test("v2 load rejects incoherent public row and JSON turn versions", async () => {
await assert.rejects(
loadStoredRectificationCase(loadClient(privateRow, false, 8), ownerId, caseId),
BirthTimeJourneyStoreError,
);
});
test("v2 cases cannot fall through to legacy mutation paths", () => {
assert.throws(() => assertLegacyJourneyMutation(dynamicCase()), {
name: "GuidedJourneyLegacyMutationError",
@@ -166,11 +195,12 @@ test("memory replay returns the stored advanced dynamic turn", async () => {
const changed = { ...initial, agentContext: ["persisted context"] };
const saved = await memory.store.saveDynamicTurn(changed, 7, actionId);
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId);
const replay = await memory.store.saveDynamicTurn(initial, 7, actionId.toUpperCase());
assert.deepEqual(replay, saved);
assert.equal(replay.turnVersion, 8);
assert.deepEqual(replay.agentContext, ["persisted context"]);
assert.deepEqual(replay.processedActionIds, [actionId]);
assert.equal(memory.committedTurnWrites(), 1);
});
@@ -203,6 +233,7 @@ test("legacy active upgrade preserves evidence and starts v2 without legacy fing
journeyProtocol: "dynamic-choice-v2",
turnVersion: loaded.turnVersion ?? 0,
processedActionIds: loaded.processedActionIds ?? [],
persistedProgress: loaded.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
dynamicTurnState: parsedTurn,
turnState: null,
evidenceDraft: null,
@@ -0,0 +1,37 @@
import assert from "node:assert/strict";
import test from "node:test";
import { createBirthTimeJourneyService } from "../src/lib/birth-time-journey-service.ts";
import {
caseId,
dynamicCase,
ownerId,
} from "./birth-time-dynamic-persistence-fixture.ts";
import { memoryStore } from "./birth-time-journey-memory-store.ts";
import { unusedJourneyEngine } from "./birth-time-journey-test-support.ts";
test("v2 resume returns the stored dynamic turn without legacy scoring writes", async () => {
const stored = {
...dynamicCase(),
scoring: {
answeredCount: 5,
candidateClusterRankings: [],
nextRound: null,
nextRoundQuestions: [],
raw: {},
},
};
const memory = memoryStore(stored);
const service = createBirthTimeJourneyService({ store: memory.store, engine: unusedJourneyEngine });
const resumed = await service.resume(ownerId, caseId);
assert.equal(resumed.journeyProtocol, "dynamic-choice-v2");
assert.deepEqual({
journeyProtocol: resumed.journeyProtocol,
turnVersion: resumed.turnVersion,
nextAction: resumed.nextAction,
progress: resumed.progress,
permissions: resumed.permissions,
}, stored.dynamicTurnState);
assert.equal(memory.legacyWrites(), 0);
});
@@ -44,6 +44,7 @@ function storedCase(): LegacyStoredRectificationCase {
return {
id: caseId,
userId: "owner-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
@@ -53,6 +53,9 @@ export function memoryStore(
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(savedCase.id, expectedVersion, savedCase.turnVersion);
}
const processedActionIds = savedCase.processedActionIds ?? [];
if (processedActionIds.includes(actionId)) {
return savedCase;
@@ -70,8 +73,9 @@ export function memoryStore(
},
async saveDynamicTurn(value, expectedVersion, actionId) {
if (!savedCase) throw new MissingTestCaseError();
const receipt = actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(actionId)) {
if (receipts.includes(receipt)) {
if (!savedDynamicCase) throw new MissingTestCaseError();
return savedDynamicCase;
}
@@ -82,7 +86,7 @@ export function memoryStore(
...value,
turnVersion: expectedVersion + 1,
dynamicTurnState: { ...value.dynamicTurnState, turnVersion: expectedVersion + 1 },
processedActionIds: [...receipts, actionId],
processedActionIds: [...receipts, receipt],
};
savedCase = savedDynamic;
savedDynamicCase = savedDynamic;
@@ -90,7 +94,7 @@ export function memoryStore(
return savedDynamic;
},
async upgradeLegacyActiveCase(value) {
if (value.journeyProtocol === "dynamic-choice-v2" || isTerminalLegacyCase(value)) {
if (isTerminalLegacyCase(value)) {
return value;
}
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate);
@@ -115,6 +119,13 @@ export function memoryStore(
if (!savedCase) {
throw new MissingTestCaseError();
}
if (savedCase.journeyProtocol !== "legacy-guided-v1") {
throw new StaleJourneyTurnError(
savedCase.id,
command.expectedVersion,
savedCase.turnVersion,
);
}
const receipt = command.actionId.toLowerCase();
const receipts = savedCase.processedActionIds ?? [];
if (receipts.includes(receipt)) {
@@ -48,6 +48,7 @@ test("legacy low-score resume displays adaptive round one exactly once", async (
const first = await flow.service.resume("user-1", journeyCaseId);
const second = await flow.service.resume("user-1", journeyCaseId);
if (first.journeyProtocol === "dynamic-choice-v2") assert.fail("expected legacy response");
assert.equal(first.nextAction.kind, "ask_adaptive_evidence");
assert.equal(first.progress.adaptiveRound, 1);
assert.deepEqual(second.nextAction, first.nextAction);
@@ -78,6 +78,7 @@ test("journey service accumulates legacy answers while preserving the applicatio
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: assessed.snapshot,
questionnaire,
answers: { education_environment_shift: "A" },
@@ -121,6 +122,7 @@ test("journey service resumes an owner-scoped unfinished legacy case", async ()
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "continue_rectification_questions",
@@ -150,6 +152,7 @@ test("journey service heals a completed legacy questionnaire into life-event col
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "candidate",
assistantIntent: "present_saved_candidate_range",
@@ -184,6 +187,7 @@ test("journey service resumes a fail-closed case without a questionnaire", async
const storedCase: StoredRectificationCase = {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: "explain_assessment_unavailable",
@@ -128,6 +128,7 @@ export function guidedCase(input: GuidedCaseInput = {}): LegacyStoredRectificati
return {
id: journeyCaseId,
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: {
state: "rectifying",
assistantIntent: candidateResult?.confidence === "low"
@@ -11,6 +11,7 @@ const actionId = "45857b75-4718-4590-aaf5-7113a03ea765";
const storedCase = {
id: "case-1",
userId: "user-1",
journeyProtocol: "legacy-guided-v1",
snapshot: { state: "rectifying" },
answers: {},
turnState: { nextAction: { kind: "paused" } },
@@ -79,6 +80,7 @@ test("saveTurn uses one owner-and-version-constrained update", async () => {
}],
["id", "case-1"],
["user_id", "user-1"],
["journey_protocol", "legacy-guided-v1"],
["turn_version", 4],
["not", "processed_action_ids", "cs", `{${actionId}}`],
["select", "id"],
@@ -127,7 +129,25 @@ test("saveTurn treats an uppercase UUID replay as the stored lowercase receipt",
assert.equal(saved, current);
assert.equal(fake.calls[0][1].processed_action_ids[0], actionId);
assert.deepEqual(fake.calls[4], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
assert.deepEqual(fake.calls[5], ["not", "processed_action_ids", "cs", `{${actionId}}`]);
});
test("saveTurn cannot overwrite a case upgraded between load and write", async () => {
const fake = updateClient({ data: null, error: null });
const upgraded = {
...storedCase,
journeyProtocol: "dynamic-choice-v2",
turnVersion: 4,
processedActionIds: [],
};
const persistence = createJourneyTurnPersistence(fake.client, async () => upgraded);
await assert.rejects(
persistence.saveTurn(storedCase, 4, actionId),
StaleJourneyTurnError,
);
assert.deepEqual(fake.calls[3], ["journey_protocol", "legacy-guided-v1"]);
});
test("saveTurn rejects a non-UUID action receipt before writing", async () => {
@@ -181,6 +201,7 @@ test("load accepts only the exact empty legacy turn state", async () => {
const value = await loadStoredRectificationCase(loadClient(storedRow()), "12dc56f0-1f17-4a2f-86bf-1056ab78def9", "45857b75-4718-4590-aaf5-7113a03ea765");
assert.equal(value?.turnState, null);
assert.equal(value?.journeyProtocol, "legacy-guided-v1");
});
test("load rejects a malformed nonempty persisted turn state", async () => {