fix: close dynamic receipt replay gaps

This commit is contained in:
Jesse_Chen
2026-07-19 09:12:45 +08:00
parent 50c1de20d3
commit e90f77bac1
22 changed files with 696 additions and 73 deletions
@@ -0,0 +1,67 @@
import { z } from "zod";
type ReceiptBase = {
readonly actionId: string;
readonly turnVersion: number;
};
export type DynamicActionReceipt = ReceiptBase & (
| { readonly kind: "answer_choice"; readonly questionId: string; readonly optionId: string }
| {
readonly kind: "commit_question";
readonly outcome: "question" | "terminal";
readonly questionId: string | null;
readonly questionFingerprint: string | null;
readonly partitionFingerprint: string | null;
readonly submittedQuestionFingerprint: string | null;
readonly submittedPartitionFingerprint: string | null;
}
| { readonly kind: "unmatched_context"; readonly questionId: string; readonly note: string }
| { readonly kind: "pause" }
| { readonly kind: "finish" }
| { readonly kind: "resume" }
);
const receiptBase = {
actionId: z.string().uuid().refine((value) => value === value.toLowerCase()),
turnVersion: z.number().int().nonnegative(),
} as const;
const identifier = z.string().trim().min(1);
export const dynamicActionReceiptSchema: z.ZodType<DynamicActionReceipt> = z.union([
z.object({
...receiptBase,
kind: z.literal("answer_choice"),
questionId: identifier,
optionId: identifier,
}).strict(),
z.object({
...receiptBase,
kind: z.literal("commit_question"),
outcome: z.literal("question"),
questionId: identifier,
questionFingerprint: identifier,
partitionFingerprint: identifier,
submittedQuestionFingerprint: identifier,
submittedPartitionFingerprint: identifier,
}).strict(),
z.object({
...receiptBase,
kind: z.literal("commit_question"),
outcome: z.literal("terminal"),
questionId: z.null(),
questionFingerprint: z.null(),
partitionFingerprint: z.null(),
submittedQuestionFingerprint: identifier.nullable(),
submittedPartitionFingerprint: identifier.nullable(),
}).strict(),
z.object({
...receiptBase,
kind: z.literal("unmatched_context"),
questionId: identifier,
note: z.string().max(240),
}).strict(),
z.object({ ...receiptBase, kind: z.literal("pause") }).strict(),
z.object({ ...receiptBase, kind: z.literal("finish") }).strict(),
z.object({ ...receiptBase, kind: z.literal("resume") }).strict(),
]).readonly();
@@ -1,4 +1,5 @@
import type { DynamicStoredRectificationCase } from "./birth-time-journey-service.ts";
import { dynamicActionReceiptSchema } from "./birth-time-dynamic-action-receipt.ts";
import { StaleJourneyTurnError } from "./birth-time-journey-store-errors.ts";
export function replayedDynamicAction(
@@ -20,11 +21,10 @@ export function samePersistedDynamicReceipt(
): boolean {
const expected = proposed.dynamicControl.lastActionReceipt;
const actual = current.dynamicControl.lastActionReceipt;
if (expected?.actionId !== actionId) return actual?.actionId !== actionId;
if (expected?.actionId !== actionId) return false;
return current.turnVersion === expectedVersion + 1
&& actual?.actionId === expected.actionId
&& actual.kind === expected.kind
&& actual.turnVersion === expected.turnVersion
&& actual.questionId === expected.questionId
&& actual.note === expected.note;
&& actual !== null
&& actual !== undefined
&& JSON.stringify(dynamicActionReceiptSchema.parse(actual))
=== JSON.stringify(dynamicActionReceiptSchema.parse(expected));
}
+53 -14
View File
@@ -91,9 +91,14 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
const stored = await load(userId, command.caseId);
const lastAnswer = stored.choiceAnswers.at(-1);
const actionKind = stored.dynamicTurnState.nextAction.kind;
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
lastAnswer?.questionId === command.questionId && lastAnswer.optionId === command.optionId
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
&& receipt?.kind === "answer_choice"
&& receipt.actionId === command.actionId.toLowerCase()
&& receipt.turnVersion === command.turnVersion
&& receipt.questionId === command.questionId
&& receipt.optionId === command.optionId
&& (lastAnswer.kind === "primary"
? actionKind === "score_pending"
: lastAnswer.kind === "unknown"
@@ -113,13 +118,26 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
}
const option = question.options.find((item) => item.optionId === command.optionId);
if (!option) throw stale(stored, command.turnVersion);
const updated = answerTransition({
const transitioned = answerTransition({
stored,
option,
answeredAt: (ports.now?.() ?? new Date()).toISOString(),
jobId: globalThis.crypto.randomUUID(),
nextVersion: stored.turnVersion + 1,
});
const updated = {
...transitioned,
dynamicControl: {
...transitioned.dynamicControl,
lastActionReceipt: {
actionId: command.actionId.toLowerCase(),
kind: "answer_choice" as const,
turnVersion: command.turnVersion,
questionId: command.questionId,
optionId: command.optionId,
},
},
};
if (option.kind === "primary") {
const createJob = ports.store.createDynamicScoringJob;
if (!createJob) throw new BirthTimeDynamicActionError("unavailable");
@@ -159,13 +177,13 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
question: PersistedDynamicChoiceQuestion | null,
) {
const stored = await load(userId, command.caseId);
const receipt = stored.dynamicControl.lastActionReceipt;
if (replayedDynamicAction(stored, command.actionId, command.turnVersion, () => (
question === null
? stored.currentChoiceQuestion === null
&& stored.dynamicTurnState.nextAction.kind === "present_low_result"
: stored.currentChoiceQuestion?.questionId === question.questionId
&& stored.currentChoiceQuestion.questionFingerprint === question.questionFingerprint
&& stored.dynamicControl.lastActionReceipt?.actionId !== command.actionId.toLowerCase()
receipt?.kind === "commit_question"
&& receipt.actionId === command.actionId.toLowerCase()
&& receipt.turnVersion === command.turnVersion
&& receipt.submittedQuestionFingerprint === (question?.questionFingerprint ?? null)
&& receipt.submittedPartitionFingerprint === (question?.candidatePartitionFingerprint ?? null)
))) {
return { nextAction: stored.dynamicTurnState.nextAction };
}
@@ -184,14 +202,28 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
? { kind: "present_low_result" as const, resultId: stored.candidateResult?.resultId ?? null }
: publicQuestionAction(nextQuestion);
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
const priorControl = nextQuestion === null ? stored.dynamicControl : {
...stored.dynamicControl,
questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint],
partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint],
};
const saved = await save(stored, {
...updated,
snapshot: nextQuestion === null ? terminalSnapshot(stored) : stored.snapshot,
currentChoiceQuestion: nextQuestion,
dynamicControl: nextQuestion === null ? stored.dynamicControl : {
...stored.dynamicControl,
questionFingerprints: [...stored.dynamicControl.questionFingerprints, nextQuestion.questionFingerprint],
partitionFingerprints: [...stored.dynamicControl.partitionFingerprints, nextQuestion.candidatePartitionFingerprint],
dynamicControl: {
...priorControl,
lastActionReceipt: {
actionId: command.actionId.toLowerCase(),
kind: "commit_question" as const,
turnVersion: command.turnVersion,
outcome: nextQuestion === null ? "terminal" as const : "question" as const,
questionId: nextQuestion?.questionId ?? null,
questionFingerprint: nextQuestion?.questionFingerprint ?? null,
partitionFingerprint: nextQuestion?.candidatePartitionFingerprint ?? null,
submittedQuestionFingerprint: question?.questionFingerprint ?? null,
submittedPartitionFingerprint: question?.candidatePartitionFingerprint ?? null,
},
},
}, command.actionId);
return { nextAction: saved.dynamicTurnState.nextAction };
@@ -211,10 +243,17 @@ export function createDynamicJourneyActions(ports: BirthTimeJourneyPorts) {
: paused;
if (action === null) throw new BirthTimeDynamicActionError("invalid_turn");
const updated = withDynamicAction(stored, action, stored.turnVersion + 1);
const actionId = globalThis.crypto.randomUUID();
const saved = await save(stored, {
...updated,
dynamicControl: { ...stored.dynamicControl, pausedAction: null },
}, globalThis.crypto.randomUUID());
dynamicControl: {
...stored.dynamicControl,
pausedAction: null,
lastActionReceipt: {
actionId, kind: "resume", turnVersion: stored.turnVersion,
},
},
}, actionId);
return storedDynamicJourneyResponse(saved);
},
@@ -1,5 +1,6 @@
import { z } from "zod";
import { candidateResultSchema } from "./birth-time-evidence.ts";
import { dynamicActionReceiptSchema } from "./birth-time-dynamic-action-receipt.ts";
import {
DYNAMIC_QUESTION_LABEL_MAX_LENGTH,
DYNAMIC_QUESTION_PROMPT_MAX_LENGTH,
@@ -11,6 +12,7 @@ import {
validateOptionSet,
} from "./birth-time-dynamic-choice.ts";
import type { CandidateResult } from "./birth-time-evidence.ts";
import type { DynamicActionReceipt } from "./birth-time-dynamic-action-receipt.ts";
import type { PublicChoiceKind, PublicDynamicChoiceQuestion, TimeRange } from "./birth-time-dynamic-choice.ts";
const finiteScoresSchema = z.record(z.number().finite());
@@ -121,13 +123,7 @@ export type DynamicControlState = {
readonly dismissedOpportunityIds: readonly string[];
readonly recentRanges: readonly TimeRange[];
readonly pausedAction: PausedDynamicAction | null;
readonly lastActionReceipt?: {
readonly actionId: string;
readonly kind: "unmatched_context" | "pause" | "finish";
readonly turnVersion: number;
readonly questionId?: string;
readonly note?: string;
} | null;
readonly lastActionReceipt?: DynamicActionReceipt | null;
};
const evidencePartitionBaseSchema = z.object({
@@ -245,13 +241,7 @@ export const dynamicControlStateSchema = z.object({
dismissedOpportunityIds: z.array(z.string().trim().min(1)).readonly(),
recentRanges: z.array(timeRangeSchema).readonly(),
pausedAction: pausedDynamicActionSchema.nullable(),
lastActionReceipt: z.object({
actionId: z.string().uuid(),
kind: z.enum(["unmatched_context", "pause", "finish"]),
turnVersion: z.number().int().nonnegative(),
questionId: z.string().trim().min(1).optional(),
note: z.string().max(240).optional(),
}).strict().readonly().nullable().optional(),
lastActionReceipt: dynamicActionReceiptSchema.nullable().optional(),
}).strict().readonly();
export function toPublicDynamicChoiceQuestion(
@@ -1,4 +1,5 @@
import { z } from "zod";
import { samePersistedDynamicReceipt } from "./birth-time-dynamic-action-replay.ts";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
@@ -89,7 +90,10 @@ export function createDynamicScoringJobStore(
if (result.error) {
const current = await loadCase(value.userId, value.id);
if (current?.journeyProtocol === "dynamic-choice-v2"
&& current.processedActionIds.includes(receipt)) return current;
&& current.processedActionIds.includes(receipt)) {
if (samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) return current;
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
}
if (result.error.message.includes("stale_birth_time_dynamic_scoring_job")) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0);
}
@@ -99,7 +103,12 @@ export function createDynamicScoringJobStore(
if (!version.success || version.data !== expectedVersion + 1) {
throw new BirthTimeJourneyStoreError("update_case");
}
return loadDynamic(loadCase, value.userId, value.id);
const current = await loadDynamic(loadCase, value.userId, value.id);
if (!current.processedActionIds.includes(receipt)
|| !samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
}
return current;
},
async claimDynamicScoringJob(identity) {
@@ -158,8 +158,14 @@ export function createDynamicTurnPersistence(
}
throw new BirthTimeJourneyStoreError("update_case");
}
rpcVersionSchema.parse(result.data);
return loadedDynamic(value.userId, value.id);
const version = rpcVersionSchema.parse(result.data);
const current = await loadedDynamic(value.userId, value.id);
if (version !== expectedVersion + 1
|| !current.processedActionIds.includes(receipt)
|| !samePersistedDynamicReceipt(value, current, receipt, expectedVersion)) {
throw new StaleJourneyTurnError(value.id, expectedVersion, current.turnVersion);
}
return current;
},
async completeDynamicScoringJob(