import { z } from "zod"; import { conversationalRectificationTurnSchema, type ConversationalRectificationTurn, } from "./contracts.ts"; import { ConversationalRectificationError, type ConversationalRectificationErrorCode, } from "./errors.ts"; import { declaredBirthInputSchema, lifeEventEvidenceSchema, privateCandidateSchema, storedCaseRowSchema, validationReceiptSchema, type DeclaredBirthInput, type LifeEventEvidence, type PrivateCandidate, type ValidationReceipt, } from "./persistence-contracts.ts"; export type ConversationalRectificationRpcError = Readonly<{ code?: string; message?: string; }>; export type ConversationalRectificationRpcClient = { rpc( functionName: string, args: Readonly>, ): PromiseLike>; }; type DeepReadonly = T extends (...args: never[]) => unknown ? T : T extends ReadonlyArray ? ReadonlyArray> : T extends object ? { readonly [Key in keyof T]: DeepReadonly } : T; export type ConversationalRectificationTurnInput = DeepReadonly; export type PrivateCandidateInput = DeepReadonly; export type ValidationReceiptInput = DeepReadonly; export type StoredConversationalRectificationCase = Readonly<{ caseId: string; userId: string; status: "starting" | "active" | "paused" | "confirming" | "completed" | "abandoned"; turnVersion: number; revisionOfCaseId: string | null; importedFromCaseId: string | null; baselineActiveTime: string | null; pendingConsultationQuestion: string | null; billingState: "reserved" | "charged" | "released" | "migration_waived" | null; latestTurn: ConversationalRectificationTurn; declaredBirthInput?: DeepReadonly; privateCandidate?: DeepReadonly; eventEvidence?: ReadonlyArray; validationReceipts?: ReadonlyArray>; }>; export type LoadedConversationalRectificationCase = StoredConversationalRectificationCase & Readonly<{ declaredBirthInput: DeepReadonly; privateCandidate: DeepReadonly; eventEvidence: ReadonlyArray; validationReceipts: ReadonlyArray>; }>; type MutationIdentity = Readonly<{ userId: string; caseId: string; expectedVersion: number; actionId: string; }>; export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{ revisionOfCaseId: string | null; pendingConsultationQuestion: string | null; declaredBirthInput: DeepReadonly; firstTurn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; }>; export type LifeEventEvidenceInput = DeepReadonly; export type SaveConversationalRectificationTurnInput = MutationIdentity & Readonly<{ turn: ConversationalRectificationTurnInput; evidence: ReadonlyArray; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; }>; export type ConversationalRectificationTransitionInput = MutationIdentity & Readonly<{ turn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; }>; export type ConfirmConversationalRectificationInput = MutationIdentity & Readonly<{ resultId: string; time: string; calculationVersion: string; turn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; }>; export type ImportLegacyConversationalRectificationInput = MutationIdentity & Readonly<{ legacyCaseId: string; price: number; pendingConsultationQuestion: string | null; firstTurn: ConversationalRectificationTurnInput; validationReceipt: ValidationReceiptInput; privateCandidate: PrivateCandidateInput; }>; const stableP0001Codes: Readonly> = { conversational_case_not_found: "case_not_found", conversational_stale_turn: "stale_turn", conversational_action_conflict: "action_conflict", conversational_imported_case_read_only: "action_conflict", conversational_candidate_changed: "candidate_changed", conversational_billing_failed: "billing_failed", }; function safeRpcErrorFields(error: unknown): { code?: string; message?: string } { if (error === null || typeof error !== "object") return {}; try { const candidate = error as { code?: unknown; message?: unknown }; return { code: typeof candidate.code === "string" ? candidate.code : undefined, message: typeof candidate.message === "string" ? candidate.message : undefined, }; } catch { return {}; } } export function mapConversationalRectificationStoreError( error: unknown, ): ConversationalRectificationError { const { code, message } = safeRpcErrorFields(error); const domainCode = code === "P0001" && message ? stableP0001Codes[message] : undefined; return new ConversationalRectificationError(domainCode ?? "store_unavailable"); } function unwrapSingle(data: unknown, allowNull: boolean): unknown { if (Array.isArray(data)) { if (data.length === 0 && allowNull) return null; if (data.length !== 1) throw new ConversationalRectificationError("store_unavailable"); return data[0]; } return data; } function parseStoredCase(data: unknown, allowNull = false): StoredConversationalRectificationCase | null { const candidate = unwrapSingle(data, allowNull); if (candidate === null && allowNull) return null; const parsed = storedCaseRowSchema.safeParse(candidate); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); const value = parsed.data; return Object.freeze({ caseId: value.case_id, userId: value.user_id, status: value.status, turnVersion: value.turn_version, revisionOfCaseId: value.revision_of_case_id, importedFromCaseId: value.imported_from_case_id, baselineActiveTime: value.baseline_active_time, pendingConsultationQuestion: value.pending_consultation_question, billingState: value.billing_state, latestTurn: value.latest_turn, declaredBirthInput: value.declared_birth_input, privateCandidate: value.private_candidate, eventEvidence: value.event_evidence, validationReceipts: value.validation_receipts, }); } function requirePublicTurn(turn: ConversationalRectificationTurnInput): ConversationalRectificationTurn { const parsed = conversationalRectificationTurnSchema.safeParse(turn); if (!parsed.success) throw new ConversationalRectificationError("store_unavailable"); return parsed.data; } function invalidDurableInput(): never { throw new ConversationalRectificationError("action_conflict"); } function requireDeclaredBirthInput(input: DeepReadonly): DeclaredBirthInput { const parsed = declaredBirthInputSchema.safeParse(input); if (!parsed.success) return invalidDurableInput(); return parsed.data; } function requirePrivateCandidate(input: PrivateCandidateInput): PrivateCandidate { const parsed = privateCandidateSchema.safeParse(input); if (!parsed.success) return invalidDurableInput(); return parsed.data; } function requireValidationReceipt(input: ValidationReceiptInput): ValidationReceipt { const parsed = validationReceiptSchema.safeParse(input); if (!parsed.success) return invalidDurableInput(); return parsed.data; } function requireEvidence(input: ReadonlyArray): ReadonlyArray { const parsed = z.array(lifeEventEvidenceSchema).max(20).safeParse(input); if (!parsed.success) return invalidDurableInput(); return parsed.data; } const mutationIdentitySchema = z.object({ userId: z.string().uuid(), caseId: z.string().uuid(), expectedVersion: z.number().int().nonnegative(), actionId: z.string().uuid(), }).strict(); function mutationArgs(input: MutationIdentity): Readonly> { const parsed = mutationIdentitySchema.safeParse({ userId: input.userId, caseId: input.caseId, expectedVersion: input.expectedVersion, actionId: input.actionId, }); if (!parsed.success) return invalidDurableInput(); return { p_user_id: parsed.data.userId, p_case_id: parsed.data.caseId, p_expected_version: parsed.data.expectedVersion, p_action_id: parsed.data.actionId, }; } /** * A public start action is also its durable case identity. This makes a retry * recoverable even when the first response was lost before the caller learned * a separately generated case id. */ export function conversationalRectificationCaseIdForStartAction(actionId: string): string { const parsed = z.string().uuid().safeParse(actionId); if (!parsed.success) throw new ConversationalRectificationError("action_conflict"); return parsed.data; } export class ConversationalRectificationStore { constructor(private readonly supabase: ConversationalRectificationRpcClient) {} private async callCaseRpc( functionName: string, args: Readonly>, allowNull = false, ): Promise { try { const { data, error } = await this.supabase.rpc(functionName, args); if (error) throw mapConversationalRectificationStoreError(error); return parseStoredCase(data, allowNull); } catch (error) { if (error instanceof ConversationalRectificationError) throw error; throw new ConversationalRectificationError("store_unavailable"); } } async createCaseWithFirstTurn( input: CreateConversationalRectificationCaseInput, ): Promise { if (input.caseId !== conversationalRectificationCaseIdForStartAction(input.actionId)) { throw new ConversationalRectificationError("action_conflict"); } const result = await this.callCaseRpc("create_conversational_rectification_case", { ...mutationArgs(input), p_revision_of_case_id: input.revisionOfCaseId, p_pending_consultation_question: input.pendingConsultationQuestion, p_declared_birth_input: requireDeclaredBirthInput(input.declaredBirthInput), p_first_turn: requirePublicTurn(input.firstTurn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; } async loadCase(input: Readonly<{ userId: string; caseId?: string; }>): Promise { const loaded = await this.callCaseRpc("load_conversational_rectification_case", { p_user_id: input.userId, p_case_id: input.caseId ?? null, }, true); if (!loaded) return null; if (loaded.declaredBirthInput === undefined || loaded.privateCandidate === undefined || loaded.eventEvidence === undefined || loaded.validationReceipts === undefined) { throw new ConversationalRectificationError("store_unavailable"); } return loaded as LoadedConversationalRectificationCase; } async saveTurn( input: SaveConversationalRectificationTurnInput, ): Promise { const result = await this.callCaseRpc("save_conversational_rectification_turn", { ...mutationArgs(input), p_turn: requirePublicTurn(input.turn), p_evidence: requireEvidence(input.evidence), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; } async pause( input: ConversationalRectificationTransitionInput, ): Promise { return this.requireTransition("pause_conversational_rectification_case", input); } async abandon( input: ConversationalRectificationTransitionInput, ): Promise { return this.requireTransition("abandon_conversational_rectification_case", input); } private async requireTransition( functionName: string, input: ConversationalRectificationTransitionInput, ): Promise { const result = await this.callCaseRpc(functionName, { ...mutationArgs(input), p_turn: requirePublicTurn(input.turn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; } async confirm( input: ConfirmConversationalRectificationInput, ): Promise { const result = await this.callCaseRpc("confirm_conversational_rectification_candidate", { ...mutationArgs(input), p_result_id: input.resultId, p_time: input.time, p_calculation_version: input.calculationVersion, p_turn: requirePublicTurn(input.turn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; } async importLegacy( input: ImportLegacyConversationalRectificationInput, ): Promise { if (input.caseId !== conversationalRectificationCaseIdForStartAction(input.actionId)) { throw new ConversationalRectificationError("action_conflict"); } if (!Number.isSafeInteger(input.price) || input.price < 1 || input.price > 1_000_000) { throw new ConversationalRectificationError("action_conflict"); } const result = await this.callCaseRpc("import_legacy_conversational_rectification_case", { ...mutationArgs(input), p_legacy_case_id: input.legacyCaseId, p_price: input.price, p_pending_consultation_question: input.pendingConsultationQuestion, p_first_turn: requirePublicTurn(input.firstTurn), p_validation_receipt: requireValidationReceipt(input.validationReceipt), p_private_candidate: requirePrivateCandidate(input.privateCandidate), }); if (!result) throw new ConversationalRectificationError("store_unavailable"); return result; } } export function createSupabaseConversationalRectificationStore( supabase: ConversationalRectificationRpcClient, ): ConversationalRectificationStore { return new ConversationalRectificationStore(supabase); }