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
@@ -117,6 +117,9 @@ export async function POST(request: Request) {
case "poll_scoring":
{
const before = await service.resume(user.id, parsed.data.caseId);
if (before.journeyProtocol === "dynamic-choice-v2") {
throw new GuidedJourneyLegacyMutationError(parsed.data.caseId);
}
const response = await service.pollScoringJob(user.id, parsed.data.caseId, parsed.data.jobId);
recordScoringJourneyMetric(before, response);
return NextResponse.json(response);
@@ -54,7 +54,10 @@ export function createSupabaseGuidedCandidateStore(
const { error } = await supabase.rpc(functionName, args);
if (error) {
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(command.actionId.toLowerCase())) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(command.actionId.toLowerCase())
) return current;
if (isDomainError(error, "stale_guided_candidate_turn")) {
throw new StaleJourneyTurnError(
value.id,
@@ -68,7 +71,9 @@ export function createSupabaseGuidedCandidateStore(
throw new BirthTimeJourneyStoreError("update_case");
}
const current = await loadCase(value.userId, value.id);
if (!current) throw new BirthTimeJourneyStoreError("load_case");
if (!current || current.journeyProtocol !== "legacy-guided-v1") {
throw new BirthTimeJourneyStoreError("load_case");
}
return current;
},
};
@@ -162,6 +162,10 @@ export async function loadStoredRectificationCase(
: null;
if (dynamicTurn && !dynamicTurn.success) throw new BirthTimeJourneyStoreError("load_case");
const dynamicTurnState = dynamicTurn?.data ?? null;
if (
dynamicTurnState !== null
&& dynamicTurnState.turnVersion !== parsed.turn_version
) throw new BirthTimeJourneyStoreError("load_case");
const evidenceDraft = parseEvidenceDraft(parsed.evidence_draft);
let dynamicPrivate = null;
if (parsed.journey_protocol === "dynamic-choice-v2") {
@@ -53,8 +53,6 @@ export async function saveDynamicAssessment(
p_private_state: initial.privateState,
p_profile: {
reportedBirthTime: details.reportedTime,
activeBirthTime: value.snapshot.activeTime,
birthTime: value.snapshot.activeTime,
birthTimeSource: value.assessment.source,
birthTimePeriod: details.period,
birthTimeClue: details.clue,
@@ -6,6 +6,7 @@ import {
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type {
DynamicStoredRectificationCase,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import {
@@ -119,9 +120,8 @@ export function createDynamicTurnPersistence(
},
async upgradeLegacyActiveCase(
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
): Promise<StoredRectificationCase> {
if (value.journeyProtocol === "dynamic-choice-v2") return value;
if (isTerminalLegacyCase(value)) return value;
const upgraded = prepareLegacyDynamicUpgrade(value, asOfDate());
const result = await client.rpc("upgrade_birth_time_legacy_case", {
@@ -221,6 +221,7 @@ export function prepareLegacyDynamicUpgrade(
journeyProtocol: "dynamic-choice-v2",
turnVersion: value.turnVersion ?? 0,
processedActionIds: value.processedActionIds ?? [],
persistedProgress: value.persistedProgress ?? { adaptiveRound: 0, askedDomains: [] },
turnState: null,
evidenceDraft: null,
dynamicTurnState,
@@ -4,6 +4,8 @@ import {
} from "./birth-time-journey-turn.ts";
import type { PersistedJourneyProgress } from "./birth-time-journey-turn-persistence.ts";
import type {
DynamicStoredRectificationCase,
DynamicVersionedJourneyResponse,
JourneyResponseBase,
StoredRectificationCase,
VersionedJourneyResponse,
@@ -132,6 +134,22 @@ export function storedJourneyResponse(
};
}
export function storedDynamicJourneyResponse(
stored: DynamicStoredRectificationCase,
): DynamicVersionedJourneyResponse {
return {
caseId: stored.id,
snapshot: stored.snapshot,
questionnaire: stored.questionnaire,
scoring: stored.scoring ?? null,
answers: stored.answers,
lifeEvents: stored.lifeEvents ?? [],
candidateResult: stored.candidateResult ?? null,
evidenceDraft: null,
...stored.dynamicTurnState,
};
}
export function persistedJourneyResponse(
stored: StoredRectificationCase,
): VersionedJourneyResponse {
+27 -32
View File
@@ -1,33 +1,21 @@
import { assessBirthTime, withRectificationScoring, type BirthTimeAssessment, type JourneySnapshot, type RectificationScoring } from "./birth-time-journey.ts";
import { createJourneyTurnActions } from "./birth-time-journey-actions.ts";
import {
assertLegacyJourneyMutation,
createBirthTimeEvidenceActions,
} from "./birth-time-evidence-service.ts";
import { assertLegacyJourneyMutation, createBirthTimeEvidenceActions } from "./birth-time-evidence-service.ts";
import type { CandidateResult, LifeEvent } from "./birth-time-evidence.ts";
import type { CandidateVargaSample } from "./birth-time-question-planner.ts";
import { projectJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import { projectJourneyResponse, storedDynamicJourneyResponse, storedJourneyResponse } from "./birth-time-journey-response.ts";
import type { JourneyTurnState } from "./birth-time-journey-turn.ts";
import type { DynamicJourneyTurnState } from "./birth-time-journey-turn-protocol.ts";
import type { ScoringJobClaim, ScoringJobIdentity, ScoringJobSpec } from "./birth-time-scoring-job.ts";
import { createBirthTimeScoringService } from "./birth-time-scoring-service.ts";
import { scanAssessment } from "./birth-time-journey-assessment.ts";
import type { GuidedCandidateCommit } from "./birth-time-guided-candidate.ts";
import { createGuidedCandidateActions } from "./birth-time-guided-candidate.ts";
import { createGuidedDraftRevisionActions } from "./birth-time-guided-draft-revision.ts";
import type {
CandidateDifferenceBuild,
DynamicChoiceScoringResult,
ServerChoiceEvidence,
} from "./birth-time-dynamic-choice-internal.ts";
import type { CandidateDifferenceBuild, DynamicChoiceScoringResult, ServerChoiceEvidence } from "./birth-time-dynamic-choice-internal.ts";
import type { TimeRange } from "./birth-time-dynamic-choice.ts";
import type {
DynamicStoredFields,
LegacyStoredFields,
} from "./birth-time-journey-stored-protocol.ts";
import {
RectificationCaseNotFoundError,
RectificationQuestionsUnavailableError,
} from "./birth-time-journey-errors.ts";
import type { DynamicStoredFields, LegacyStoredFields } from "./birth-time-journey-stored-protocol.ts";
import { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError } from "./birth-time-journey-errors.ts";
export { RectificationCaseNotFoundError, RectificationQuestionsUnavailableError };
@@ -152,18 +140,18 @@ export type StoredRectificationCase =
export interface BirthTimeJourneyStore {
saveAssessment(value: PersistedJourneyAssessment): Promise<string>;
loadCase(userId: string, caseId: string): Promise<StoredRectificationCase | null>;
saveScoring(value: StoredRectificationCase): Promise<void>;
saveTurn(value: StoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveScoring(value: LegacyStoredRectificationCase): Promise<void>;
saveTurn(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string): Promise<StoredRectificationCase>;
saveDynamicTurn(value: DynamicStoredRectificationCase, expectedVersion: number, actionId: string): Promise<DynamicStoredRectificationCase>;
upgradeLegacyActiveCase(value: StoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: StoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
upgradeLegacyActiveCase(value: LegacyStoredRectificationCase): Promise<StoredRectificationCase>;
createScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, actionId: string, job: ScoringJobSpec): Promise<StoredRectificationCase>;
claimScoringJob(identity: ScoringJobIdentity): Promise<ScoringJobClaim>;
completeScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
failScoringJob(value: StoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise<StoredRectificationCase>;
saveCandidateResult(value: StoredRectificationCase): Promise<void>;
saveCandidate(value: StoredRectificationCase): Promise<void>;
confirmCandidate(value: StoredRectificationCase): Promise<void>;
commitGuidedCandidate(value: StoredRectificationCase, command: GuidedCandidateCommit): Promise<StoredRectificationCase>;
completeScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string): Promise<StoredRectificationCase>;
failScoringJob(value: LegacyStoredRectificationCase, expectedVersion: number, jobId: string, evidenceFingerprint: string, failureCode: string): Promise<StoredRectificationCase>;
saveCandidateResult(value: LegacyStoredRectificationCase): Promise<void>;
saveCandidate(value: LegacyStoredRectificationCase): Promise<void>;
confirmCandidate(value: LegacyStoredRectificationCase): Promise<void>;
commitGuidedCandidate(value: LegacyStoredRectificationCase, command: GuidedCandidateCommit): Promise<StoredRectificationCase>;
}
export type BirthTimeJourneyPorts = {
@@ -182,11 +170,15 @@ export type JourneyResponseBase = {
readonly candidateResult: CandidateResult | null;
};
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState;
export type VersionedJourneyResponse = JourneyResponseBase & JourneyTurnState
& { readonly journeyProtocol?: undefined };
export type DynamicVersionedJourneyResponse = JourneyResponseBase & DynamicJourneyTurnState
& { readonly evidenceDraft: null };
export type LegacyJourneyResponse = JourneyResponseBase & { readonly turnVersion?: undefined; readonly nextAction?: undefined; readonly progress?: undefined; readonly permissions?: undefined; readonly evidenceDraft?: undefined; };
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse;
export type JourneyResponse = LegacyJourneyResponse | VersionedJourneyResponse | DynamicVersionedJourneyResponse;
export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const evidenceActions = createBirthTimeEvidenceActions(ports);
@@ -217,9 +209,12 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
}, 0);
},
async resume(userId: string, caseId: string): Promise<VersionedJourneyResponse> {
async resume(userId: string, caseId: string): Promise<VersionedJourneyResponse | DynamicVersionedJourneyResponse> {
const stored = await ports.store.loadCase(userId, caseId);
if (!stored) throw new RectificationCaseNotFoundError(caseId);
if (stored.journeyProtocol === "dynamic-choice-v2") {
return storedDynamicJourneyResponse(stored);
}
const completedLegacyQuestionnaire = stored.snapshot.input === "rectification_questions"
&& stored.scoring?.nextRound === null
&& stored.scoring.nextRoundQuestions.length === 0
@@ -245,7 +240,7 @@ export function createBirthTimeJourneyService(ports: BirthTimeJourneyPorts) {
const answers = { ...stored.answers, [questionId]: answer };
const scoring = await ports.engine.score({ questionnaire: stored.questionnaire, answers });
const snapshot = withRectificationScoring(stored.snapshot, scoring);
const updated = { ...stored, answers, scoring, snapshot } satisfies StoredRectificationCase;
const updated = { ...stored, answers, scoring, snapshot } satisfies LegacyStoredRectificationCase;
await ports.store.saveScoring(updated);
return projectJourneyResponse({
caseId,
+18 -9
View File
@@ -54,7 +54,7 @@ export function createSupabaseBirthTimeJourneyStore(
...guidedCandidates,
async saveScoring(value) {
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({
status: caseStatus(value.snapshot),
@@ -64,8 +64,11 @@ export function createSupabaseBirthTimeJourneyStore(
updated_at: new Date().toISOString(),
})
.eq("id", value.id)
.eq("user_id", value.userId);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
const { error: profileError } = await supabase
.from("profiles")
@@ -77,7 +80,7 @@ export function createSupabaseBirthTimeJourneyStore(
async saveCandidateResult(value) {
const winner = value.candidateResult?.winningSegment ?? null;
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({
status: caseStatus(value.snapshot),
@@ -91,8 +94,11 @@ export function createSupabaseBirthTimeJourneyStore(
updated_at: new Date().toISOString(),
})
.eq("id", value.id)
.eq("user_id", value.userId);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
const { error: profileError } = await supabase
.from("profiles")
@@ -106,13 +112,16 @@ export function createSupabaseBirthTimeJourneyStore(
},
async saveCandidate(value) {
const { error } = await supabase
const { data, error } = await supabase
.from("birth_time_rectification_cases")
.update({ candidate_saved_at: new Date().toISOString(), updated_at: new Date().toISOString() })
.eq("id", value.id)
.eq("user_id", value.userId)
.eq("candidate_result_id", value.candidateResult?.resultId ?? null);
if (error) throw new BirthTimeJourneyStoreError("update_case");
.eq("journey_protocol", "legacy-guided-v1")
.eq("candidate_result_id", value.candidateResult?.resultId ?? null)
.select("id")
.maybeSingle();
if (error || !data) throw new BirthTimeJourneyStoreError("update_case");
},
async confirmCandidate(value) {
@@ -14,7 +14,7 @@ type LegacyProgress = {
};
export type LegacyStoredFields = {
readonly journeyProtocol?: "legacy-guided-v1";
readonly journeyProtocol: "legacy-guided-v1";
readonly turnVersion?: number;
readonly turnState?: JourneyTurnState | null;
readonly evidenceDraft?: EvidenceDraft | null;
@@ -32,11 +32,11 @@ export type LegacyStoredFields = {
export type DynamicStoredFields = {
readonly journeyProtocol: "dynamic-choice-v2";
readonly turnVersion: number;
readonly turnState?: null;
readonly turnState: null;
readonly dynamicTurnState: DynamicJourneyTurnState;
readonly evidenceDraft?: null;
readonly evidenceDraft: null;
readonly processedActionIds: readonly string[];
readonly persistedProgress?: LegacyProgress;
readonly persistedProgress: LegacyProgress;
readonly candidateModel: Readonly<Record<string, unknown>> | null;
readonly currentChoiceQuestion: PersistedDynamicChoiceQuestion | null;
readonly choiceAnswers: readonly StoredChoiceAnswer[];
@@ -1,7 +1,7 @@
import type { SupabaseClient } from "@supabase/supabase-js";
import { z } from "zod";
import type { JourneySnapshot } from "./birth-time-journey.ts";
import type { StoredRectificationCase } from "./birth-time-journey-service.ts";
import type { LegacyStoredRectificationCase, StoredRectificationCase } from "./birth-time-journey-service.ts";
import { evidenceDomains } from "./birth-time-question-planner.ts";
import type { EvidenceDomain } from "./birth-time-question-planner.ts";
import type { EvidenceDraft, JourneyTurnState } from "./birth-time-journey-turn.ts";
@@ -61,7 +61,7 @@ export function createJourneyTurnPersistence(
) {
return {
async saveTurn(
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
expectedVersion: number,
actionId: string,
): Promise<StoredRectificationCase> {
@@ -87,6 +87,7 @@ export function createJourneyTurnPersistence(
})
.eq("id", value.id)
.eq("user_id", value.userId)
.eq("journey_protocol", "legacy-guided-v1")
.eq("turn_version", expectedVersion)
.not("processed_action_ids", "cs", `{${parsedActionId}}`)
.select("id")
@@ -104,7 +105,10 @@ export function createJourneyTurnPersistence(
} satisfies StoredRectificationCase;
}
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(parsedActionId)) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(parsedActionId)
) return current;
throw new StaleJourneyTurnError(value.id, expectedVersion, current?.turnVersion ?? 0);
},
};
@@ -1,6 +1,7 @@
import { createScoringJobSpec } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
@@ -11,7 +12,7 @@ export type JourneyTurnPersistencePorts = {
export async function persistGuidedJourneyTurn(
ports: JourneyTurnPersistencePorts,
value: StoredRectificationCase,
value: LegacyStoredRectificationCase,
expectedVersion: number,
actionId: string,
): Promise<StoredRectificationCase> {
@@ -3,6 +3,7 @@ import { z } from "zod";
import { BirthTimeScoringJobError } from "./birth-time-scoring-job.ts";
import type {
BirthTimeJourneyStore,
LegacyStoredRectificationCase,
StoredRectificationCase,
} from "./birth-time-journey-service.ts";
import { BirthTimeJourneyStoreError } from "./birth-time-journey-turn-persistence.ts";
@@ -48,9 +49,11 @@ async function requireReloaded(
loadCase: (userId: string, caseId: string) => Promise<StoredRectificationCase | null>,
userId: string,
caseId: string,
): Promise<StoredRectificationCase> {
): Promise<LegacyStoredRectificationCase> {
const stored = await loadCase(userId, caseId);
if (!stored) throw new BirthTimeJourneyStoreError("load_case");
if (!stored || stored.journeyProtocol !== "legacy-guided-v1") {
throw new BirthTimeJourneyStoreError("load_case");
}
return stored;
}
@@ -78,7 +81,10 @@ export function createSupabaseScoringJobStore(
});
if (error) {
const current = await loadCase(value.userId, value.id);
if (current?.processedActionIds?.includes(actionId.toLowerCase())) return current;
if (
current?.journeyProtocol === "legacy-guided-v1"
&& current.processedActionIds?.includes(actionId.toLowerCase())
) return current;
throw mapScoringRpcError(error.message, error.code);
}
return requireReloaded(loadCase, value.userId, value.id);