Files
Jyotisha/frontend/src/lib/conversational-rectification/store.ts
T
2026-07-20 22:24:43 +08:00

389 lines
14 KiB
TypeScript

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<Record<string, unknown>>,
): PromiseLike<Readonly<{
data: unknown;
error: ConversationalRectificationRpcError | null;
}>>;
};
type DeepReadonly<T> = T extends (...args: never[]) => unknown
? T
: T extends ReadonlyArray<infer Item>
? ReadonlyArray<DeepReadonly<Item>>
: T extends object
? { readonly [Key in keyof T]: DeepReadonly<T[Key]> }
: T;
export type ConversationalRectificationTurnInput = DeepReadonly<ConversationalRectificationTurn>;
export type PrivateCandidateInput = DeepReadonly<PrivateCandidate>;
export type ValidationReceiptInput = DeepReadonly<ValidationReceipt>;
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<DeclaredBirthInput>;
privateCandidate?: DeepReadonly<PrivateCandidate>;
eventEvidence?: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipts?: ReadonlyArray<DeepReadonly<ValidationReceipt>>;
}>;
export type LoadedConversationalRectificationCase =
StoredConversationalRectificationCase & Readonly<{
declaredBirthInput: DeepReadonly<DeclaredBirthInput>;
privateCandidate: DeepReadonly<PrivateCandidate>;
eventEvidence: ReadonlyArray<LifeEventEvidenceInput>;
validationReceipts: ReadonlyArray<DeepReadonly<ValidationReceipt>>;
}>;
type MutationIdentity = Readonly<{
userId: string;
caseId: string;
expectedVersion: number;
actionId: string;
}>;
export type CreateConversationalRectificationCaseInput = MutationIdentity & Readonly<{
revisionOfCaseId: string | null;
pendingConsultationQuestion: string | null;
declaredBirthInput: DeepReadonly<DeclaredBirthInput>;
firstTurn: ConversationalRectificationTurnInput;
validationReceipt: ValidationReceiptInput;
privateCandidate: PrivateCandidateInput;
}>;
export type LifeEventEvidenceInput = DeepReadonly<LifeEventEvidence>;
export type SaveConversationalRectificationTurnInput = MutationIdentity & Readonly<{
turn: ConversationalRectificationTurnInput;
evidence: ReadonlyArray<LifeEventEvidenceInput>;
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<Record<string, ConversationalRectificationErrorCode>> = {
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>): 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<LifeEventEvidenceInput>): ReadonlyArray<LifeEventEvidence> {
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<Record<string, unknown>> {
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<Record<string, unknown>>,
allowNull = false,
): Promise<StoredConversationalRectificationCase | null> {
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<StoredConversationalRectificationCase> {
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<LoadedConversationalRectificationCase | null> {
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<StoredConversationalRectificationCase> {
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<StoredConversationalRectificationCase> {
return this.requireTransition("pause_conversational_rectification_case", input);
}
async abandon(
input: ConversationalRectificationTransitionInput,
): Promise<StoredConversationalRectificationCase> {
return this.requireTransition("abandon_conversational_rectification_case", input);
}
private async requireTransition(
functionName: string,
input: ConversationalRectificationTransitionInput,
): Promise<StoredConversationalRectificationCase> {
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<StoredConversationalRectificationCase> {
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<StoredConversationalRectificationCase> {
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);
}