Files
Jyotisha/frontend/src/lib/personal-report-longform-snapshot.ts
T

97 lines
5.4 KiB
TypeScript

import { z } from "zod";
import { hashLongformMarkdown } from "./personal-report-longform-appendix";
import { CHART_IDS, reportDocumentV2Schema } from "./personal-report-contract";
import { reportFactTablesSchema } from "./report-fact-table-schema";
import type { AppendixClient } from "./personal-report-longform-generate";
/** Reserved server-owned cache, never a writer chapter or reader progress row. */
export const LONGFORM_SNAPSHOT_SECTION_ID = "longform-calculation-snapshot-v1";
export const LONGFORM_SNAPSHOT_TABLE = "personal_report_sections";
export type LongformSnapshotIdentity = { reportId: string; userId: string; requestId: string };
const snapshotFields = {
kind: z.literal("longform_calculation_snapshot"),
reportId: z.string().uuid(), userId: z.string().uuid(), requestId: z.string().uuid(),
markdown: z.string().min(1), contentSha256: z.string().regex(/^[a-f0-9]{64}$/),
factTables: reportFactTablesSchema.refine(tables => tables.length === 8),
charts: reportDocumentV2Schema.shape.charts.refine(charts =>
CHART_IDS.every(id => charts.filter(chart => chart.id === id).length === 1)
&& charts.every(chart => chart.houses.length === 12 && chart.houses.some(house => house.occupants.length > 0))),
readerDashaApplicability: z.string().min(1),
};
/**
* The English edition, rendered by the engine from the same packet as the
* Chinese one (TASK-report-english-edition-20260929). Subordinate: when it is
* missing the snapshot carries `englishUnavailable` instead and the report is
* still ready in Chinese.
*/
export const longformEnglishEditionSchema = z.strictObject({
markdown: z.string().min(1),
contentSha256: z.string().regex(/^[a-f0-9]{64}$/),
factTables: reportFactTablesSchema,
});
export type LongformEnglishEdition = z.infer<typeof longformEnglishEditionSchema>;
/** v1: Chinese only (every report before the English edition). v2: both editions, or the reason English is missing. */
const snapshotSchema = z.union([
z.strictObject({ ...snapshotFields, version: z.literal(1) }),
z.strictObject({
...snapshotFields,
version: z.literal(2),
english: longformEnglishEditionSchema.optional(),
englishUnavailable: z.string().regex(/^[a-z_]{1,64}$/).optional(),
}).refine(value => (value.english === undefined) !== (value.englishUnavailable === undefined), {
message: "a v2 snapshot carries exactly one of english / englishUnavailable",
}),
]);
export type LongformSnapshot = z.infer<typeof snapshotSchema>;
export function validateLongformSnapshot(value: unknown, identity: LongformSnapshotIdentity): LongformSnapshot {
const snapshot = snapshotSchema.parse(value);
if (snapshot.reportId !== identity.reportId || snapshot.userId !== identity.userId
|| snapshot.requestId !== identity.requestId || snapshot.contentSha256 !== hashLongformMarkdown(snapshot.markdown)) {
throw new Error("longform_snapshot_binding_invalid");
}
if (snapshot.version === 2 && snapshot.english
&& snapshot.english.contentSha256 !== hashLongformMarkdown(snapshot.english.markdown)) {
throw new Error("longform_snapshot_binding_invalid");
}
return snapshot;
}
type SnapshotFilter = {
eq(column: string, value: unknown): SnapshotFilter;
maybeSingle(): PromiseLike<{ data: unknown; error: unknown }>;
};
export type LongformSnapshotReader = { from(table: string): { select(columns: string): { eq(column: string, value: unknown): SnapshotFilter } } };
export async function readLongformSnapshot(client: LongformSnapshotReader, identity: LongformSnapshotIdentity) {
const result = await client.from(LONGFORM_SNAPSHOT_TABLE)
.select("user_id,request_id,section_id,status,payload")
.eq("user_id", identity.userId).eq("request_id", identity.requestId)
.eq("section_id", LONGFORM_SNAPSHOT_SECTION_ID).maybeSingle();
if (result.error) throw new Error("longform_snapshot_read_failed");
if (result.data === null) return null;
const row = result.data as Record<string, unknown>;
if (!row || row.user_id !== identity.userId || row.request_id !== identity.requestId
|| row.section_id !== LONGFORM_SNAPSHOT_SECTION_ID) throw new Error("longform_snapshot_binding_invalid");
if (row.status === "pending" && row.payload === null) return { status: "pending" as const };
if (row.status !== "ready") throw new Error("longform_snapshot_invalid");
return { status: "ready" as const, snapshot: validateLongformSnapshot(row.payload, identity) };
}
export async function persistLongformSnapshot(client: AppendixClient, identity: LongformSnapshotIdentity, snapshot: LongformSnapshot | null) {
if (snapshot) validateLongformSnapshot(snapshot, identity);
const args = { p_user_id: identity.userId, p_request_id: identity.requestId, p_section_id: LONGFORM_SNAPSHOT_SECTION_ID };
// Existing SQL: INSERT ON CONFLICT DO NOTHING; completion UPDATE ... WHERE status='pending'.
// Neither an in-flight ensure nor a late completion can overwrite a committed winner.
const result = snapshot
? await client.rpc("complete_personal_report_section", { ...args, p_payload: snapshot })
: await client.rpc("ensure_personal_report_section", { ...args, p_max_attempts: 2 });
if (result.error) throw new Error("longform_snapshot_persist_failed");
const stored = await readLongformSnapshot(client, identity);
if (!stored || (snapshot && stored.status !== "ready")) throw new Error("longform_snapshot_persist_failed");
// Always return the committed winner, never the losing worker's local calculation.
return stored;
}