fix: recover committed dynamic transport errors

This commit is contained in:
Jesse_Chen
2026-07-19 09:24:23 +08:00
parent e90f77bac1
commit 5c5b60b3e4
3 changed files with 75 additions and 4 deletions
@@ -144,7 +144,7 @@ export function createDynamicTurnPersistence(
});
if (result.error) {
const current = await loadCase(value.userId, value.id);
if (isStaleRpc(result.error) && current?.journeyProtocol === "dynamic-choice-v2"
if (current?.journeyProtocol === "dynamic-choice-v2"
&& current.processedActionIds.includes(receipt)) {
if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current;
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
@@ -3,7 +3,10 @@ import test from "node:test";
import { createDynamicScoringJobStore } from "../src/lib/birth-time-dynamic-scoring-job-store.ts";
import { createDynamicTurnPersistence } from "../src/lib/birth-time-journey-dynamic-persistence.ts";
import { createDynamicScoringJobSpec } from "../src/lib/birth-time-scoring-job.ts";
import { StaleJourneyTurnError } from "../src/lib/birth-time-journey-turn-persistence.ts";
import {
BirthTimeJourneyStoreError,
StaleJourneyTurnError,
} from "../src/lib/birth-time-journey-turn-persistence.ts";
import { answerTransition } from "../src/lib/birth-time-dynamic-transitions.ts";
import type { DynamicStoredRectificationCase } from "../src/lib/birth-time-journey-service.ts";
import { actionId, dynamicCase, persistedQuestion } from "./birth-time-dynamic-persistence-fixture.ts";
@@ -55,6 +58,43 @@ test("a concurrent different action cannot use a successful RPC as replay", asyn
);
});
test("a transport error returns an exact committed dynamic turn", async () => {
const loaded = savedTurn();
const proposed = { ...loaded, turnVersion: 7 };
const persistence = createDynamicTurnPersistence({
async rpc() { return { data: null, error: { message: "connection reset" } }; },
}, async () => loaded, () => "2026-07-18");
const replay = await persistence.saveDynamicTurn(proposed, 7, actionId);
assert.equal(replay, loaded);
});
test("a transport error maps a conflicting committed receipt to stale", async () => {
const proposed = { ...savedTurn(), turnVersion: 7 };
const concurrent = savedTurn("finish");
const persistence = createDynamicTurnPersistence({
async rpc() { return { data: null, error: { message: "connection reset" } }; },
}, async () => concurrent, () => "2026-07-18");
await assert.rejects(
persistence.saveDynamicTurn(proposed, 7, actionId),
StaleJourneyTurnError,
);
});
test("a transport error remains a store error when nothing committed", async () => {
const proposed = dynamicCase();
const persistence = createDynamicTurnPersistence({
async rpc() { return { data: null, error: { message: "connection reset" } }; },
}, async () => proposed, () => "2026-07-18");
await assert.rejects(
persistence.saveDynamicTurn(proposed, 7, actionId),
BirthTimeJourneyStoreError,
);
});
function pendingTurn(): DynamicStoredRectificationCase {
const stored = dynamicCase();
const option = persistedQuestion.options.find((item) => item.kind === "primary");
@@ -120,3 +160,27 @@ test("dynamic scoring creation accepts only exact duplicate-success receipts", a
StaleJourneyTurnError,
);
});
test("dynamic scoring transport errors return an exact committed receipt", async () => {
const pending = pendingTurn();
const spec = createDynamicScoringJobSpec(
"85b22d7e-3adc-473d-81e1-6ad29e9b06f4",
pending.choiceEvidence,
new Date("2026-07-18T08:00:00.000Z"),
);
const loaded = {
...pending,
turnVersion: 8,
dynamicTurnState: { ...pending.dynamicTurnState, turnVersion: 8 },
processedActionIds: [actionId],
};
const store = createDynamicScoringJobStore({
async rpc() { return { data: null, error: { message: "connection reset" } }; },
}, async () => loaded);
const replay = await store.createDynamicScoringJob(
pending, 7, actionId, persistedQuestion.questionId, spec,
);
assert.equal(replay, loaded);
});