feat: rebuild birth time rectification workflow
This commit is contained in:
@@ -0,0 +1,321 @@
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
import {
|
||||
candidateSnapshotSchema,
|
||||
lifeEventRevisionSchema,
|
||||
rectificationV4CaseSchema,
|
||||
rectificationV4JobSchema,
|
||||
type CandidateSnapshot,
|
||||
type LifeEventRevision,
|
||||
type RectificationV4Case,
|
||||
type RectificationV4Job,
|
||||
} from "./contracts.ts";
|
||||
import type {
|
||||
ClaimedRectificationV4Job,
|
||||
CompleteRectificationV4JobInput,
|
||||
RectificationV4Store,
|
||||
RectificationV4Turn,
|
||||
} from "./store.ts";
|
||||
import { RectificationV4StoreError } from "./store.ts";
|
||||
import { evidenceSetHash } from "./fingerprints.ts";
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
function timestamp(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString() : String(value);
|
||||
}
|
||||
|
||||
function date(value: unknown): string {
|
||||
return value instanceof Date ? value.toISOString().slice(0, 10) : String(value);
|
||||
}
|
||||
|
||||
function storeError(error: unknown): RectificationV4StoreError {
|
||||
const message = error && typeof error === "object" && "message" in error ? String(error.message) : String(error);
|
||||
if (message.includes("not_found")) return new RectificationV4StoreError("not_found");
|
||||
if (message.includes("stale_rectification_v4_case")) return new RectificationV4StoreError("stale_version");
|
||||
if (message.includes("stale_rectification_v4_job")) return new RectificationV4StoreError("stale_job");
|
||||
if (message.includes("lease_lost")) return new RectificationV4StoreError("lease_lost");
|
||||
return new RectificationV4StoreError("invalid_state");
|
||||
}
|
||||
|
||||
function snapshot(row: Row | null): CandidateSnapshot | null {
|
||||
if (!row) return null;
|
||||
return candidateSnapshotSchema.parse({
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
caseVersion: Number(row.case_version),
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
algorithmVersion: row.algorithm_version,
|
||||
candidates: row.candidates,
|
||||
clusters: row.clusters,
|
||||
robustness: row.robustness,
|
||||
canConfirmExactMinute: false,
|
||||
canAcceptRange: row.can_accept_range,
|
||||
gateReasons: row.gate_reasons,
|
||||
createdAt: timestamp(row.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
function caseValue(row: Row, latestSnapshot: CandidateSnapshot | null): RectificationV4Case {
|
||||
return rectificationV4CaseSchema.parse({
|
||||
id: row.id,
|
||||
userId: row.user_id,
|
||||
protocol: row.protocol,
|
||||
version: Number(row.version),
|
||||
status: row.status,
|
||||
phase: row.phase,
|
||||
calculationSpec: row.calculation_spec,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
currentQuestion: row.current_question,
|
||||
latestSnapshot,
|
||||
acceptedRange: row.accepted_range_start && row.accepted_range_end
|
||||
? { start: row.accepted_range_start, end: row.accepted_range_end }
|
||||
: null,
|
||||
createdAt: timestamp(row.created_at),
|
||||
updatedAt: timestamp(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
function eventRevision(row: Row): LifeEventRevision {
|
||||
return lifeEventRevisionSchema.parse({
|
||||
id: row.id,
|
||||
eventId: row.event_id,
|
||||
revision: Number(row.revision),
|
||||
domain: row.domain,
|
||||
eventKind: row.event_kind,
|
||||
summary: row.summary,
|
||||
rawText: row.raw_text,
|
||||
dateRange: {
|
||||
start: date(row.date_start),
|
||||
end: date(row.date_end),
|
||||
precision: row.date_precision,
|
||||
label: row.date_label,
|
||||
},
|
||||
scoreability: row.scoreability,
|
||||
supersedesRevisionId: row.supersedes_revision_id,
|
||||
createdAt: timestamp(row.created_at),
|
||||
});
|
||||
}
|
||||
|
||||
function jobValue(row: Row): RectificationV4Job {
|
||||
return rectificationV4JobSchema.parse({
|
||||
id: row.id,
|
||||
caseId: row.case_id,
|
||||
status: row.status,
|
||||
phase: row.phase,
|
||||
expectedCaseVersion: Number(row.expected_case_version),
|
||||
evidenceSetHash: row.evidence_set_hash,
|
||||
calculationSpecHash: row.calculation_spec_hash,
|
||||
errorCode: row.error_code,
|
||||
createdAt: timestamp(row.created_at),
|
||||
updatedAt: timestamp(row.updated_at),
|
||||
});
|
||||
}
|
||||
|
||||
function turnValue(row: Row): RectificationV4Turn {
|
||||
return {
|
||||
id: String(row.id),
|
||||
caseId: String(row.case_id),
|
||||
caseVersion: Number(row.case_version),
|
||||
questionId: row.question_id ? String(row.question_id) : null,
|
||||
questionDomain: row.question_domain as RectificationV4Turn["questionDomain"],
|
||||
questionTargetEventId: row.question_target_event_id ? String(row.question_target_event_id) : null,
|
||||
question: String(row.question),
|
||||
answer: String(row.answer),
|
||||
actionId: String(row.action_id),
|
||||
createdAt: timestamp(row.created_at),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRectificationV4SupabaseStore(supabase: SupabaseClient): RectificationV4Store {
|
||||
async function rowById(table: string, id: string): Promise<Row | null> {
|
||||
const { data, error } = await supabase.from(table).select("*").eq("id", id).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
return data as Row | null;
|
||||
}
|
||||
|
||||
async function loadCaseById(userId: string, caseId: string): Promise<RectificationV4Case | null> {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
|
||||
.select("*").eq("id", caseId).eq("user_id", userId).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
if (!data) return null;
|
||||
const row = data as Row;
|
||||
const latest = row.latest_snapshot_id
|
||||
? snapshot(await rowById("birth_time_rectification_v4_candidate_snapshots", String(row.latest_snapshot_id)))
|
||||
: null;
|
||||
return caseValue(row, latest);
|
||||
}
|
||||
|
||||
async function loadJobRow(jobId: string): Promise<Row | null> {
|
||||
return rowById("birth_time_rectification_v4_jobs", jobId);
|
||||
}
|
||||
|
||||
async function loadEventsByCase(userId: string, caseId: string): Promise<readonly LifeEventRevision[]> {
|
||||
if (!await loadCaseById(userId, caseId)) throw new RectificationV4StoreError("not_found");
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_event_revisions")
|
||||
.select("*").eq("case_id", caseId).eq("user_id", userId)
|
||||
.order("created_at", { ascending: true });
|
||||
if (error) throw storeError(error);
|
||||
return ((data ?? []) as Row[]).map(eventRevision);
|
||||
}
|
||||
|
||||
async function rpc(name: string, args: Row): Promise<unknown> {
|
||||
const { data, error } = await supabase.rpc(name, args);
|
||||
if (error) throw storeError(error);
|
||||
return data;
|
||||
}
|
||||
|
||||
return {
|
||||
async findActiveCase(userId) {
|
||||
const { data, error } = await supabase.from("birth_time_rectification_v4_cases")
|
||||
.select("id").eq("user_id", userId).neq("status", "abandoned").is("accepted_range_start", null)
|
||||
.order("created_at", { ascending: false }).limit(1).maybeSingle();
|
||||
if (error) throw storeError(error);
|
||||
return data ? loadCaseById(userId, String((data as Row).id)) : null;
|
||||
},
|
||||
loadCase: loadCaseById,
|
||||
loadEvents: loadEventsByCase,
|
||||
async createCase(input) {
|
||||
const id = String(await rpc("create_birth_time_rectification_v4_case", {
|
||||
p_user_id: input.case.userId,
|
||||
p_case_id: input.case.id,
|
||||
p_action_id: input.actionId,
|
||||
p_status: input.case.status,
|
||||
p_phase: input.case.phase,
|
||||
p_calculation_spec: input.case.calculationSpec,
|
||||
p_calculation_spec_hash: input.case.calculationSpecHash,
|
||||
p_evidence_set_hash: input.case.evidenceSetHash,
|
||||
p_current_question: input.case.currentQuestion,
|
||||
p_now: input.case.createdAt,
|
||||
}));
|
||||
const value = await loadCaseById(input.case.userId, id);
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async submitAnswer(input) {
|
||||
const jobId = String(await rpc("submit_birth_time_rectification_v4_answer", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_turn_id: input.turnId,
|
||||
p_question_id: input.question.id,
|
||||
p_question_domain: input.question.domain,
|
||||
p_question_target_event_id: input.question.targetEventId,
|
||||
p_question: input.question.prompt,
|
||||
p_answer: input.answer,
|
||||
p_job_id: input.jobId,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
|
||||
if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
|
||||
return { case: caseResult, job: jobValue(jobRow) };
|
||||
},
|
||||
async reviseEvent(input) {
|
||||
const current = await loadEventsByCase(input.userId, input.caseId);
|
||||
const outputHash = evidenceSetHash([...current, input.revision]);
|
||||
const jobId = String(await rpc("revise_birth_time_rectification_v4_event", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_revision: input.revision,
|
||||
p_output_evidence_set_hash: outputHash,
|
||||
p_turn_id: input.revision.id,
|
||||
p_job_id: input.jobId,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const [caseResult, jobRow] = await Promise.all([loadCaseById(input.userId, input.caseId), loadJobRow(jobId)]);
|
||||
if (!caseResult || !jobRow) throw new RectificationV4StoreError("not_found");
|
||||
return { case: caseResult, job: jobValue(jobRow) };
|
||||
},
|
||||
async transitionCase(input) {
|
||||
const id = String(await rpc("transition_birth_time_rectification_v4_case", {
|
||||
p_user_id: input.userId,
|
||||
p_case_id: input.caseId,
|
||||
p_action_id: input.actionId,
|
||||
p_expected_version: input.expectedCaseVersion,
|
||||
p_status: input.status,
|
||||
p_phase: input.phase,
|
||||
p_accepted_range_start: input.acceptedRange?.start ?? null,
|
||||
p_accepted_range_end: input.acceptedRange?.end ?? null,
|
||||
p_now: input.now,
|
||||
}));
|
||||
const value = await loadCaseById(input.userId, id);
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async loadJob(userId, jobId) {
|
||||
const row = await loadJobRow(jobId);
|
||||
if (!row || row.user_id !== userId) return null;
|
||||
return jobValue(row);
|
||||
},
|
||||
async updateJobPhase(input) {
|
||||
await rpc("update_birth_time_rectification_v4_job_phase", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_phase: input.phase,
|
||||
p_now: input.now,
|
||||
});
|
||||
},
|
||||
async claimNextJob(workerId, now): Promise<ClaimedRectificationV4Job | null> {
|
||||
const claimed = await rpc("claim_next_birth_time_rectification_v4_job", { p_worker_id: workerId, p_now: now });
|
||||
if (!claimed) return null;
|
||||
const jobRow = await loadJobRow(String(claimed));
|
||||
if (!jobRow) throw new RectificationV4StoreError("not_found");
|
||||
const userId = String(jobRow.user_id);
|
||||
const caseId = String(jobRow.case_id);
|
||||
const [caseResult, turnRow, events, turnRows] = await Promise.all([
|
||||
loadCaseById(userId, caseId),
|
||||
rowById("birth_time_rectification_v4_turns", String(jobRow.turn_id)),
|
||||
loadEventsByCase(userId, caseId),
|
||||
supabase.from("birth_time_rectification_v4_turns")
|
||||
.select("question_target_event_id").eq("case_id", caseId),
|
||||
]);
|
||||
if (!caseResult || !turnRow) throw new RectificationV4StoreError("not_found");
|
||||
if (turnRows.error) throw storeError(turnRows.error);
|
||||
return {
|
||||
job: jobValue(jobRow),
|
||||
case: caseResult,
|
||||
turn: turnValue(turnRow),
|
||||
events,
|
||||
attemptedRefinementEventIds: [...new Set(
|
||||
((turnRows.data ?? []) as Row[])
|
||||
.flatMap((row) => row.question_target_event_id ? [String(row.question_target_event_id)] : []),
|
||||
)],
|
||||
};
|
||||
},
|
||||
async completeJob(input: CompleteRectificationV4JobInput, now) {
|
||||
const jobRow = await loadJobRow(input.jobId);
|
||||
if (!jobRow) throw new RectificationV4StoreError("not_found");
|
||||
await rpc("complete_birth_time_rectification_v4_job", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_expected_case_version: input.expectedCaseVersion,
|
||||
p_input_evidence_set_hash: input.inputEvidenceSetHash,
|
||||
p_output_evidence_set_hash: input.outputEvidenceSetHash,
|
||||
p_calculation_spec_hash: input.calculationSpecHash,
|
||||
p_event_revisions: input.newEventRevisions,
|
||||
p_snapshot: input.snapshot,
|
||||
p_next_question: input.nextQuestion,
|
||||
p_status: input.status,
|
||||
p_phase: input.phase,
|
||||
p_now: now,
|
||||
});
|
||||
const value = await loadCaseById(String(jobRow.user_id), String(jobRow.case_id));
|
||||
if (!value) throw new RectificationV4StoreError("not_found");
|
||||
return value;
|
||||
},
|
||||
async failJob(input) {
|
||||
await rpc("fail_birth_time_rectification_v4_job", {
|
||||
p_worker_id: input.workerId,
|
||||
p_job_id: input.jobId,
|
||||
p_expected_case_version: input.expectedCaseVersion,
|
||||
p_error_code: input.errorCode,
|
||||
p_restore_question: input.restoreQuestion,
|
||||
p_now: input.now,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user