fix: close dynamic receipt replay gaps
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user