fix: isolate dynamic rectification persistence
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
-2
@@ -142,8 +142,6 @@ begin
|
||||
|
||||
update public.profiles
|
||||
set reported_birth_time = nullif(p_profile ->> 'reportedBirthTime', '')::time,
|
||||
active_birth_time = nullif(p_profile ->> 'activeBirthTime', '')::time,
|
||||
birth_time = nullif(p_profile ->> 'birthTime', '')::time,
|
||||
birth_time_source = p_profile ->> 'birthTimeSource',
|
||||
birth_time_period = nullif(p_profile ->> 'birthTimePeriod', ''),
|
||||
birth_time_clue = nullif(p_profile ->> 'birthTimeClue', ''),
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
begin;
|
||||
|
||||
alter function public.create_birth_time_scoring_job(
|
||||
uuid, uuid, uuid, bigint, uuid, text, text, timestamptz,
|
||||
jsonb, jsonb, jsonb, integer, text[]
|
||||
) rename to create_birth_time_scoring_job_without_protocol_guard;
|
||||
alter function public.claim_birth_time_scoring_job(
|
||||
uuid, uuid, uuid, text, text, timestamptz
|
||||
) rename to claim_birth_time_scoring_job_without_protocol_guard;
|
||||
alter function public.complete_birth_time_scoring_job(
|
||||
uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb,
|
||||
time without time zone, time without time zone, integer, text[]
|
||||
) rename to complete_birth_time_scoring_job_without_protocol_guard;
|
||||
alter function public.fail_birth_time_scoring_job(
|
||||
uuid, uuid, uuid, bigint, text, text, jsonb
|
||||
) rename to fail_birth_time_scoring_job_without_protocol_guard;
|
||||
|
||||
create function public.create_birth_time_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_expected_version bigint, p_action_id uuid,
|
||||
p_evidence_fingerprint text, p_algorithm_version text,
|
||||
p_expires_at timestamptz, p_snapshot jsonb, p_turn_state jsonb,
|
||||
p_life_events jsonb, p_adaptive_round integer, p_asked_domains text[]
|
||||
) returns uuid language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
return public.create_birth_time_scoring_job_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_job_id, p_expected_version, p_action_id,
|
||||
p_evidence_fingerprint, p_algorithm_version, p_expires_at,
|
||||
p_snapshot, p_turn_state, p_life_events, p_adaptive_round, p_asked_domains
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.claim_birth_time_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_evidence_fingerprint text, p_algorithm_version text, p_now timestamptz
|
||||
) returns table (claim_state text, algorithm_version text)
|
||||
language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
return query select *
|
||||
from public.claim_birth_time_scoring_job_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_job_id, p_evidence_fingerprint,
|
||||
p_algorithm_version, p_now
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.complete_birth_time_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_expected_version bigint, p_evidence_fingerprint text,
|
||||
p_snapshot jsonb, p_turn_state jsonb, p_candidate_result jsonb,
|
||||
p_candidate_start time without time zone,
|
||||
p_candidate_end time without time zone,
|
||||
p_adaptive_round integer, p_asked_domains text[]
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
perform public.complete_birth_time_scoring_job_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_job_id, p_expected_version,
|
||||
p_evidence_fingerprint, p_snapshot, p_turn_state, p_candidate_result,
|
||||
p_candidate_start, p_candidate_end, p_adaptive_round, p_asked_domains
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.fail_birth_time_scoring_job(
|
||||
p_user_id uuid, p_case_id uuid, p_job_id uuid,
|
||||
p_expected_version bigint, p_evidence_fingerprint text,
|
||||
p_failure_code text, p_turn_state jsonb
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
perform public.fail_birth_time_scoring_job_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_job_id, p_expected_version,
|
||||
p_evidence_fingerprint, p_failure_code, p_turn_state
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.create_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.claim_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.complete_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.fail_birth_time_scoring_job_without_protocol_guard(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) from public, anon, authenticated;
|
||||
revoke all on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) from public, anon, authenticated;
|
||||
revoke all on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) from public, anon, authenticated;
|
||||
revoke all on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) from public, anon, authenticated;
|
||||
grant execute on function public.create_birth_time_scoring_job(uuid, uuid, uuid, bigint, uuid, text, text, timestamptz, jsonb, jsonb, jsonb, integer, text[]) to service_role;
|
||||
grant execute on function public.claim_birth_time_scoring_job(uuid, uuid, uuid, text, text, timestamptz) to service_role;
|
||||
grant execute on function public.complete_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, jsonb, jsonb, jsonb, time without time zone, time without time zone, integer, text[]) to service_role;
|
||||
grant execute on function public.fail_birth_time_scoring_job(uuid, uuid, uuid, bigint, text, text, jsonb) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -0,0 +1,71 @@
|
||||
begin;
|
||||
|
||||
alter function public.confirm_birth_time_candidate(
|
||||
uuid, uuid, uuid, time without time zone, jsonb
|
||||
) rename to confirm_birth_time_candidate_without_protocol_guard;
|
||||
alter function public.save_guided_birth_time_candidate(
|
||||
uuid, uuid, uuid, uuid, integer, jsonb
|
||||
) rename to save_guided_birth_time_candidate_without_protocol_guard;
|
||||
alter function public.confirm_guided_birth_time_candidate(
|
||||
uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb
|
||||
) rename to confirm_guided_birth_time_candidate_without_protocol_guard;
|
||||
|
||||
create function public.confirm_birth_time_candidate(
|
||||
p_user_id uuid, p_case_id uuid, p_result_id uuid,
|
||||
p_time time without time zone, p_snapshot jsonb
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
perform public.confirm_birth_time_candidate_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_result_id, p_time, p_snapshot
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.save_guided_birth_time_candidate(
|
||||
p_user_id uuid, p_case_id uuid, p_result_id uuid,
|
||||
p_action_id uuid, p_expected_version integer, p_turn_state jsonb
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
perform public.save_guided_birth_time_candidate_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_result_id, p_action_id,
|
||||
p_expected_version, p_turn_state
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
create function public.confirm_guided_birth_time_candidate(
|
||||
p_user_id uuid, p_case_id uuid, p_result_id uuid,
|
||||
p_time time without time zone, p_action_id uuid,
|
||||
p_expected_version integer, p_snapshot jsonb, p_turn_state jsonb
|
||||
) returns void language plpgsql security definer set search_path = '' as $$
|
||||
begin
|
||||
perform 1 from public.birth_time_rectification_cases
|
||||
where id = p_case_id and user_id = p_user_id
|
||||
and journey_protocol = 'legacy-guided-v1' for update;
|
||||
if not found then raise exception 'birth_time_legacy_protocol_required'; end if;
|
||||
perform public.confirm_guided_birth_time_candidate_without_protocol_guard(
|
||||
p_user_id, p_case_id, p_result_id, p_time, p_action_id,
|
||||
p_expected_version, p_snapshot, p_turn_state
|
||||
);
|
||||
end;
|
||||
$$;
|
||||
|
||||
revoke all on function public.confirm_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.save_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.confirm_guided_birth_time_candidate_without_protocol_guard(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated, service_role;
|
||||
revoke all on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) from public, anon, authenticated;
|
||||
revoke all on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) from public, anon, authenticated;
|
||||
grant execute on function public.confirm_birth_time_candidate(uuid, uuid, uuid, time without time zone, jsonb) to service_role;
|
||||
grant execute on function public.save_guided_birth_time_candidate(uuid, uuid, uuid, uuid, integer, jsonb) to service_role;
|
||||
grant execute on function public.confirm_guided_birth_time_candidate(uuid, uuid, uuid, time without time zone, uuid, integer, jsonb, jsonb) to service_role;
|
||||
|
||||
commit;
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user