feat(report): add personal report contract and persistence
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
canonicalEvidence,
|
||||
safeParseReportDocument,
|
||||
ReportDocumentValidationError,
|
||||
type EvidenceAppendix,
|
||||
type ReportDocumentParseResult,
|
||||
type ReportDocumentV1,
|
||||
} from "./personal-report-contract.ts";
|
||||
|
||||
/**
|
||||
* Server hash core for ReportDocument v1.
|
||||
*
|
||||
* Pure Node implementation (node:crypto) without the server-only marker so
|
||||
* tests can import it directly; the production entry
|
||||
* personal-report-contract.server.ts adds `import "server-only"` and
|
||||
* re-exports this module. Client bundles must never import this file: besides
|
||||
* the marker on the production entry, node:crypto fails Next.js client builds.
|
||||
*
|
||||
* Flow per the architecture ruling: canonical isomorphic parse first, then
|
||||
* verify provenance.evidenceHash against the recomputed hash. The hash is
|
||||
* never trusted as a model self-report. The Python validator
|
||||
* (scripts/personal_report_contract.py) performs the same recomputation.
|
||||
*/
|
||||
|
||||
export function computeEvidenceHash(appendix: EvidenceAppendix): string {
|
||||
const canonical = canonicalEvidence(appendix);
|
||||
return createHash("sha256").update(JSON.stringify(canonical), "utf8").digest("hex");
|
||||
}
|
||||
|
||||
export function safeParseServerReportDocument(input: unknown): ReportDocumentParseResult {
|
||||
const parsed = safeParseReportDocument(input);
|
||||
if (!parsed.ok) return parsed;
|
||||
const recomputed = computeEvidenceHash(parsed.document.evidenceAppendix);
|
||||
if (parsed.document.provenance.evidenceHash !== recomputed) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{
|
||||
path: "provenance.evidenceHash",
|
||||
message: `does not match recomputed evidence hash ${recomputed}`,
|
||||
}],
|
||||
};
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function parseServerReportDocument(input: unknown): ReportDocumentV1 {
|
||||
const result = safeParseServerReportDocument(input);
|
||||
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
|
||||
return result.document;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export * from "./personal-report-contract.server-core";
|
||||
@@ -0,0 +1,413 @@
|
||||
/**
|
||||
* ReportDocument v1 contract (isomorphic Zod side).
|
||||
*
|
||||
* This file is importable from server and client bundles: it contains no
|
||||
* node:crypto and no hash recomputation. Semantics are shared with:
|
||||
* - contracts/personal-report/report-document.v1.schema.json (JSON Schema)
|
||||
* - scripts/personal_report_contract.py (stdlib Python validator)
|
||||
* - frontend/src/lib/personal-report-contract.server-core.ts (server hash)
|
||||
*
|
||||
* JSON Schema draft-07 cannot express every rule; the runtime-enforced
|
||||
* semantics below (chart-set invariants, evidenceRefs existence, blocked
|
||||
* non-determinism, forbidden content, evidence-id uniqueness, serialization
|
||||
* cap) are implemented identically in this file and in the Python validator,
|
||||
* with tests on both sides. The cryptographic evidence hash is verified only
|
||||
* by the server runtime (personal-report-contract.server.ts) and the Python
|
||||
* validator; it is never part of this isomorphic parse.
|
||||
*/
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
export const REPORT_DOCUMENT_SCHEMA_VERSION = "report_document.v1" as const;
|
||||
export const REPORT_CONTRACT_VERSION = "1" as const;
|
||||
export const REPORT_DOCUMENT_MAX_BYTES = 1_572_864; // 1.5 MiB hard cap.
|
||||
|
||||
export const CLAIM_STATUSES = [
|
||||
"multi_system_consensus",
|
||||
"single_system_inference",
|
||||
"parameter_sensitive",
|
||||
"unclosed_divisional_chart",
|
||||
"user_history_verification_required",
|
||||
"blocked",
|
||||
] as const;
|
||||
export type ClaimStatus = (typeof CLAIM_STATUSES)[number];
|
||||
|
||||
export const REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
|
||||
export const PRESENTATION_MODES = ["default", "research"] as const;
|
||||
export const BIRTH_TIME_STATUSES = ["reported", "candidate", "accepted", "confirmed"] as const;
|
||||
export const TECHNIQUE_STATUSES = ["verified", "partial", "blocked"] as const;
|
||||
export const CONFLICT_STATUSES = ["unresolved", "partial", "resolved"] as const;
|
||||
export const CHART_IDS = ["D1", "D9", "D10"] as const;
|
||||
|
||||
const claimStatusSchema = z.enum(CLAIM_STATUSES);
|
||||
const evidenceIdSchema = z.string().regex(/^ev-[a-z0-9_-]{1,63}$/, "invalid evidence id");
|
||||
const sha256HexSchema = z.string().regex(/^[0-9a-f]{64}$/, "invalid sha256 hex");
|
||||
const iso8601Schema = z.string()
|
||||
.regex(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{1,9})?(Z|[+-]\d{2}:\d{2})$/, "invalid ISO-8601 timestamp");
|
||||
|
||||
const text = (maxLength: number, minLength = 1) => z.string().min(minLength).max(maxLength);
|
||||
const textArray = (maxItems: number, maxLength: number) => z.array(text(maxLength)).max(maxItems);
|
||||
|
||||
const houseSchema = z.strictObject({
|
||||
houseNumber: z.number().int().min(1).max(12),
|
||||
sign: text(40),
|
||||
occupants: textArray(12, 40),
|
||||
});
|
||||
|
||||
const planetSchema = z.strictObject({
|
||||
name: text(40),
|
||||
sign: text(40),
|
||||
longitudeDegrees: z.number().min(0).lt(360),
|
||||
houseNumber: z.number().int().min(1).max(12),
|
||||
retrograde: z.boolean(),
|
||||
});
|
||||
|
||||
const chartSchema = z.strictObject({
|
||||
id: z.enum(CHART_IDS),
|
||||
title: text(120),
|
||||
houses: z.array(houseSchema).max(12),
|
||||
planets: z.array(planetSchema).max(12).optional(),
|
||||
claimStatus: claimStatusSchema,
|
||||
});
|
||||
|
||||
const thematicSectionSchema = z.strictObject({
|
||||
id: z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/, "invalid section id"),
|
||||
title: text(160),
|
||||
narrative: text(4000),
|
||||
actions: textArray(12, 400),
|
||||
caveats: textArray(12, 400),
|
||||
claimStatus: claimStatusSchema,
|
||||
evidenceRefs: z.array(evidenceIdSchema).max(24),
|
||||
});
|
||||
|
||||
const techniqueAuditRowSchema = z.strictObject({
|
||||
id: evidenceIdSchema,
|
||||
techniqueId: z.string().regex(/^[a-z0-9_.-]{1,80}$/, "invalid technique id"),
|
||||
techniqueName: text(160),
|
||||
status: z.enum(TECHNIQUE_STATUSES),
|
||||
used: z.boolean(),
|
||||
notes: z.string().max(500).optional(),
|
||||
});
|
||||
|
||||
const conflictRowSchema = z.strictObject({
|
||||
id: evidenceIdSchema,
|
||||
description: text(1000),
|
||||
impact: text(500),
|
||||
status: z.enum(CONFLICT_STATUSES),
|
||||
});
|
||||
|
||||
const calculationEvidenceRowSchema = z.strictObject({
|
||||
id: evidenceIdSchema,
|
||||
label: text(160),
|
||||
value: text(500),
|
||||
source: text(200),
|
||||
});
|
||||
|
||||
const evidenceAppendixSchema = z.strictObject({
|
||||
expandedByDefault: z.boolean(),
|
||||
techniqueAudit: z.array(techniqueAuditRowSchema).max(100),
|
||||
conflicts: z.array(conflictRowSchema).max(50),
|
||||
calculationEvidence: z.array(calculationEvidenceRowSchema).max(100),
|
||||
blockedTechniques: textArray(100, 120),
|
||||
});
|
||||
|
||||
const reportDocumentShape = {
|
||||
schemaVersion: z.literal(REPORT_DOCUMENT_SCHEMA_VERSION),
|
||||
reportId: z.string().uuid(),
|
||||
reportType: z.enum(REPORT_TYPES),
|
||||
presentationMode: z.enum(PRESENTATION_MODES),
|
||||
generatedAt: iso8601Schema,
|
||||
subject: z.strictObject({
|
||||
displayName: text(120),
|
||||
birthTimeStatus: z.enum(BIRTH_TIME_STATUSES),
|
||||
birthPlaceLabel: text(200),
|
||||
}),
|
||||
provenance: z.strictObject({
|
||||
skillSourceCommit: z.string().regex(/^[0-9a-f]{40}$/, "invalid commit sha").nullable(),
|
||||
skillSnapshotSha256: sha256HexSchema,
|
||||
calculationHash: sha256HexSchema,
|
||||
evidenceHash: sha256HexSchema,
|
||||
reportContractVersion: z.literal(REPORT_CONTRACT_VERSION),
|
||||
}),
|
||||
executiveSummary: z.strictObject({
|
||||
headline: text(200),
|
||||
summary: text(2000),
|
||||
priorities: textArray(8, 200),
|
||||
overallClaimStatus: claimStatusSchema,
|
||||
}),
|
||||
charts: z.array(chartSchema).min(1).max(3),
|
||||
thematicNarrative: z.array(thematicSectionSchema).max(12),
|
||||
evidenceAppendix: evidenceAppendixSchema,
|
||||
disclaimer: text(2000),
|
||||
};
|
||||
|
||||
export const reportDocumentSchema = z.strictObject(reportDocumentShape);
|
||||
|
||||
export type ReportDocumentV1 = z.infer<typeof reportDocumentSchema>;
|
||||
export type EvidenceAppendix = ReportDocumentV1["evidenceAppendix"];
|
||||
export type ChartV1 = ReportDocumentV1["charts"][number];
|
||||
export type ThematicSectionV1 = ReportDocumentV1["thematicNarrative"][number];
|
||||
|
||||
export type ReportDocumentParseError = Readonly<{
|
||||
path: string;
|
||||
message: string;
|
||||
}>;
|
||||
|
||||
export class ReportDocumentValidationError extends Error {
|
||||
readonly errors: readonly ReportDocumentParseError[];
|
||||
|
||||
constructor(errors: readonly ReportDocumentParseError[]) {
|
||||
super(errors.map((error) => `${error.path}: ${error.message}`).join("; "));
|
||||
this.name = "ReportDocumentValidationError";
|
||||
this.errors = errors;
|
||||
}
|
||||
}
|
||||
|
||||
/** Canonical evidence object used by the evidence hash on both language sides. */
|
||||
export function canonicalEvidence(appendix: EvidenceAppendix): Record<string, unknown> {
|
||||
return {
|
||||
techniqueAudit: appendix.techniqueAudit.map((row) => ({
|
||||
id: row.id,
|
||||
techniqueId: row.techniqueId,
|
||||
techniqueName: row.techniqueName,
|
||||
status: row.status,
|
||||
used: row.used,
|
||||
...(row.notes !== undefined ? { notes: row.notes } : {}),
|
||||
})),
|
||||
conflicts: appendix.conflicts.map((row) => ({
|
||||
id: row.id,
|
||||
description: row.description,
|
||||
impact: row.impact,
|
||||
status: row.status,
|
||||
})),
|
||||
calculationEvidence: appendix.calculationEvidence.map((row) => ({
|
||||
id: row.id,
|
||||
label: row.label,
|
||||
value: row.value,
|
||||
source: row.source,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function serializedReportDocumentBytes(document: ReportDocumentV1): number {
|
||||
return new TextEncoder().encode(JSON.stringify(document)).length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Forbidden content patterns. Keep byte-for-byte equivalent to
|
||||
* FORBIDDEN_PATTERNS in scripts/personal_report_contract.py.
|
||||
*/
|
||||
export const FORBIDDEN_CONTENT_PATTERNS: readonly Readonly<{ name: string; pattern: RegExp }>[] = [
|
||||
{ name: "html_tag_open", pattern: /<\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\b/i },
|
||||
{ name: "html_tag_close", pattern: /<\/\s*(?:script|iframe|object|embed|style|link|meta|form|svg|img|video|audio|source|template|base|applet)\s*>/i },
|
||||
{ name: "event_handler", pattern: /\bon(?:load|error|click|mouseover|mouseout|submit|focus|blur|change|dblclick|keydown|keyup|pointerdown|pointerup)\s*=/i },
|
||||
{ name: "executable_url", pattern: /\b(?:javascript|vbscript|data:text\/html|data:text\/javascript|file):/i },
|
||||
{ name: "processing_instruction", pattern: /<\?/i },
|
||||
{ name: "template_literal", pattern: /\$\{/i },
|
||||
{ name: "stack_trace", pattern: /(?:Traceback \(most recent call last\)|node:internal\/| at (?:Object|async|node)\.)/i },
|
||||
{ name: "dunder_path", pattern: /__(?:dirname|filename)(?![A-Za-z0-9_])|__proto__/i },
|
||||
{ name: "process_env", pattern: /\bprocess\.env\b/i },
|
||||
{ name: "unix_home_path", pattern: /(?:^|[\\/:])(?:Users|home|opt|var|tmp|root|srv)[\\/]/i },
|
||||
{ name: "windows_drive_path", pattern: /^[a-zA-Z]:[\\/]/i },
|
||||
{ name: "jwt_token", pattern: /\beyJ[A-Za-z0-9_-]{20,}\b/i },
|
||||
{ name: "secret_marker", pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|AUTH_SECRET|BEGIN RSA PRIVATE KEY|BEGIN EC PRIVATE KEY|BEGIN OPENSSH PRIVATE KEY)\b/i },
|
||||
{ name: "tool_trace", pattern: /\b(?:tool_call_id|tool_result|assistant_tool_calls|system_prompt)\b/i },
|
||||
{ name: "chain_of_thought", pattern: /\bchain[\s_-]?of[\s_-]?thought\b/i },
|
||||
];
|
||||
|
||||
/**
|
||||
* Deterministic-prediction phrases forbidden inside blocked sections.
|
||||
* Keep equivalent to DETERMINISTIC_PHRASES in the Python validator.
|
||||
*/
|
||||
export const BLOCKED_DETERMINISTIC_PHRASES: readonly string[] = [
|
||||
"必然", "必定", "一定会", "肯定会", "绝对会", "保证会", "无疑将", "百分之百", "确定无疑",
|
||||
"guaranteed", "definitely will", "certainly will", "will certainly", "is certain to",
|
||||
];
|
||||
|
||||
export function findForbiddenContent(value: string): readonly string[] {
|
||||
return FORBIDDEN_CONTENT_PATTERNS
|
||||
.filter(({ pattern }) => pattern.test(value))
|
||||
.map(({ name }) => name);
|
||||
}
|
||||
|
||||
function blockedTexts(document: ReportDocumentV1): readonly Readonly<{ path: string; text: string }>[] {
|
||||
const entries: { path: string; text: string }[] = [];
|
||||
if (document.executiveSummary.overallClaimStatus === "blocked") {
|
||||
entries.push({ path: "executiveSummary.headline", text: document.executiveSummary.headline });
|
||||
entries.push({ path: "executiveSummary.summary", text: document.executiveSummary.summary });
|
||||
document.executiveSummary.priorities.forEach((priority, index) => {
|
||||
entries.push({ path: `executiveSummary.priorities[${index}]`, text: priority });
|
||||
});
|
||||
}
|
||||
document.charts.forEach((chart, index) => {
|
||||
if (chart.claimStatus === "blocked") {
|
||||
entries.push({ path: `charts[${index}].title`, text: chart.title });
|
||||
}
|
||||
});
|
||||
document.thematicNarrative.forEach((section, index) => {
|
||||
if (section.claimStatus !== "blocked") return;
|
||||
entries.push({ path: `thematicNarrative[${index}].title`, text: section.title });
|
||||
entries.push({ path: `thematicNarrative[${index}].narrative`, text: section.narrative });
|
||||
section.actions.forEach((action, actionIndex) => {
|
||||
entries.push({ path: `thematicNarrative[${index}].actions[${actionIndex}]`, text: action });
|
||||
});
|
||||
section.caveats.forEach((caveat, caveatIndex) => {
|
||||
entries.push({ path: `thematicNarrative[${index}].caveats[${caveatIndex}]`, text: caveat });
|
||||
});
|
||||
});
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function findBlockedDeterministicClaims(document: ReportDocumentV1): readonly string[] {
|
||||
const phrases = BLOCKED_DETERMINISTIC_PHRASES.map((phrase) => new RegExp(phrase.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i"));
|
||||
return blockedTexts(document)
|
||||
.filter(({ text }) => phrases.some((pattern) => pattern.test(text)))
|
||||
.map(({ path }) => path);
|
||||
}
|
||||
|
||||
export function findDanglingEvidenceRefs(document: ReportDocumentV1): readonly string[] {
|
||||
const knownIds = new Set<string>([
|
||||
...document.evidenceAppendix.techniqueAudit.map((row) => row.id),
|
||||
...document.evidenceAppendix.conflicts.map((row) => row.id),
|
||||
...document.evidenceAppendix.calculationEvidence.map((row) => row.id),
|
||||
]);
|
||||
return document.thematicNarrative.flatMap((section) =>
|
||||
section.evidenceRefs.filter((ref) => !knownIds.has(ref)).map((ref) => `${section.id}:${ref}`),
|
||||
);
|
||||
}
|
||||
|
||||
/** Evidence ids must be globally unique across the whole appendix. */
|
||||
export function findDuplicateEvidenceIds(document: ReportDocumentV1): readonly string[] {
|
||||
const locations = new Map<string, string>();
|
||||
const duplicates: string[] = [];
|
||||
const rows: Readonly<{ key: string; index: number; id: string }>[] = [
|
||||
...document.evidenceAppendix.techniqueAudit.map((row, index) => ({ key: "techniqueAudit", index, id: row.id })),
|
||||
...document.evidenceAppendix.conflicts.map((row, index) => ({ key: "conflicts", index, id: row.id })),
|
||||
...document.evidenceAppendix.calculationEvidence.map((row, index) => ({ key: "calculationEvidence", index, id: row.id })),
|
||||
];
|
||||
for (const { key, index, id } of rows) {
|
||||
const location = `${key}[${index}]`;
|
||||
const first = locations.get(id);
|
||||
if (first !== undefined) {
|
||||
duplicates.push(`evidence id ${id} used in both ${first} and ${location}`);
|
||||
} else {
|
||||
locations.set(id, location);
|
||||
}
|
||||
}
|
||||
return duplicates;
|
||||
}
|
||||
|
||||
export function findChartSetViolations(document: ReportDocumentV1): readonly string[] {
|
||||
const violations: string[] = [];
|
||||
const ids = document.charts.map((chart) => chart.id);
|
||||
const d1Count = ids.filter((id) => id === "D1").length;
|
||||
if (d1Count !== 1) violations.push(`charts must contain exactly one D1 chart, found ${d1Count}`);
|
||||
const seen = new Set<string>();
|
||||
for (const id of ids) {
|
||||
if (seen.has(id)) violations.push(`duplicate chart id ${id}`);
|
||||
seen.add(id);
|
||||
}
|
||||
const d1 = document.charts.find((chart) => chart.id === "D1");
|
||||
if (d1) {
|
||||
const numbers = d1.houses.map((house) => house.houseNumber);
|
||||
const unique = new Set(numbers);
|
||||
if (unique.size !== numbers.length) violations.push("D1 chart contains duplicate house numbers");
|
||||
const expected = Array.from({ length: 12 }, (_, index) => index + 1);
|
||||
if (numbers.length !== 12 || expected.some((number) => !unique.has(number))) {
|
||||
violations.push("D1 chart must contain all twelve house numbers 1..12 exactly once");
|
||||
}
|
||||
}
|
||||
return violations;
|
||||
}
|
||||
|
||||
export function validateReportDocumentGuards(document: ReportDocumentV1): readonly string[] {
|
||||
const errors: string[] = [];
|
||||
errors.push(...findChartSetViolations(document));
|
||||
errors.push(...findDuplicateEvidenceIds(document));
|
||||
errors.push(...findBlockedDeterministicClaims(document).map((path) => `${path}: blocked section contains deterministic prediction`));
|
||||
errors.push(...findDanglingEvidenceRefs(document).map((ref) => `thematicNarrative.evidenceRefs: unknown evidence id ${ref}`));
|
||||
|
||||
const forbidden: { path: string; hits: readonly string[] }[] = [];
|
||||
const collectTexts = (path: string, value: string) => {
|
||||
const hits = findForbiddenContent(value);
|
||||
if (hits.length > 0) forbidden.push({ path, hits });
|
||||
};
|
||||
collectTexts("subject.displayName", document.subject.displayName);
|
||||
collectTexts("subject.birthPlaceLabel", document.subject.birthPlaceLabel);
|
||||
collectTexts("executiveSummary.headline", document.executiveSummary.headline);
|
||||
collectTexts("executiveSummary.summary", document.executiveSummary.summary);
|
||||
document.executiveSummary.priorities.forEach((priority, index) => collectTexts(`executiveSummary.priorities[${index}]`, priority));
|
||||
document.charts.forEach((chart, chartIndex) => {
|
||||
collectTexts(`charts[${chartIndex}].title`, chart.title);
|
||||
chart.houses.forEach((house, houseIndex) => {
|
||||
collectTexts(`charts[${chartIndex}].houses[${houseIndex}].sign`, house.sign);
|
||||
house.occupants.forEach((occupant, occupantIndex) => collectTexts(`charts[${chartIndex}].houses[${houseIndex}].occupants[${occupantIndex}]`, occupant));
|
||||
});
|
||||
(chart.planets ?? []).forEach((planet, planetIndex) => {
|
||||
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].name`, planet.name);
|
||||
collectTexts(`charts[${chartIndex}].planets[${planetIndex}].sign`, planet.sign);
|
||||
});
|
||||
});
|
||||
document.thematicNarrative.forEach((section, sectionIndex) => {
|
||||
collectTexts(`thematicNarrative[${sectionIndex}].title`, section.title);
|
||||
collectTexts(`thematicNarrative[${sectionIndex}].narrative`, section.narrative);
|
||||
section.actions.forEach((action, actionIndex) => collectTexts(`thematicNarrative[${sectionIndex}].actions[${actionIndex}]`, action));
|
||||
section.caveats.forEach((caveat, caveatIndex) => collectTexts(`thematicNarrative[${sectionIndex}].caveats[${caveatIndex}]`, caveat));
|
||||
});
|
||||
document.evidenceAppendix.techniqueAudit.forEach((row, rowIndex) => {
|
||||
collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].techniqueName`, row.techniqueName);
|
||||
if (row.notes !== undefined) collectTexts(`evidenceAppendix.techniqueAudit[${rowIndex}].notes`, row.notes);
|
||||
});
|
||||
document.evidenceAppendix.conflicts.forEach((row, rowIndex) => {
|
||||
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].description`, row.description);
|
||||
collectTexts(`evidenceAppendix.conflicts[${rowIndex}].impact`, row.impact);
|
||||
});
|
||||
document.evidenceAppendix.calculationEvidence.forEach((row, rowIndex) => {
|
||||
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].label`, row.label);
|
||||
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].value`, row.value);
|
||||
collectTexts(`evidenceAppendix.calculationEvidence[${rowIndex}].source`, row.source);
|
||||
});
|
||||
document.evidenceAppendix.blockedTechniques.forEach((technique, rowIndex) => {
|
||||
collectTexts(`evidenceAppendix.blockedTechniques[${rowIndex}]`, technique);
|
||||
});
|
||||
collectTexts("disclaimer", document.disclaimer);
|
||||
forbidden.forEach(({ path, hits }) => errors.push(`${path}: forbidden content ${hits.join(",")}`));
|
||||
|
||||
const size = serializedReportDocumentBytes(document);
|
||||
if (size > REPORT_DOCUMENT_MAX_BYTES) {
|
||||
errors.push(`serialized document is ${size} bytes, exceeding ${REPORT_DOCUMENT_MAX_BYTES}`);
|
||||
}
|
||||
return errors;
|
||||
}
|
||||
|
||||
export type ReportDocumentParseResult =
|
||||
| Readonly<{ ok: true; document: ReportDocumentV1 }>
|
||||
| Readonly<{ ok: false; errors: readonly ReportDocumentParseError[] }>;
|
||||
|
||||
export function safeParseReportDocument(input: unknown): ReportDocumentParseResult {
|
||||
const parsed = reportDocumentSchema.safeParse(input);
|
||||
if (!parsed.success) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: parsed.error.issues.map((issue) => ({
|
||||
path: issue.path.join(".") || "(root)",
|
||||
message: issue.message,
|
||||
})),
|
||||
};
|
||||
}
|
||||
const document = parsed.data;
|
||||
const guardErrors = validateReportDocumentGuards(document);
|
||||
if (guardErrors.length > 0) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: guardErrors.map((message) => ({ path: "(guard)", message })),
|
||||
};
|
||||
}
|
||||
return { ok: true, document };
|
||||
}
|
||||
|
||||
export function parseReportDocument(input: unknown): ReportDocumentV1 {
|
||||
const result = safeParseReportDocument(input);
|
||||
if (!result.ok) throw new ReportDocumentValidationError(result.errors);
|
||||
return result.document;
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
import {
|
||||
parseServerReportDocument,
|
||||
computeEvidenceHash,
|
||||
} from "./personal-report-contract.server-core.ts";
|
||||
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
|
||||
import type { ReportDocumentV1 } from "./personal-report-contract.ts";
|
||||
|
||||
/**
|
||||
* Server-only persistence layer for personal reports (pure core).
|
||||
*
|
||||
* Routing: production callers pass the result of createServerSupabaseClient()
|
||||
* (which already resolves self-hosted PostgreSQL vs Supabase). This module
|
||||
* never inspects query-builder or pg specifics: it depends on the narrow
|
||||
* PersonalReportDataClient port below, so unit tests can inject an in-memory
|
||||
* fake and neither Supabase QueryBuilder nor node-postgres shapes leak into
|
||||
* the API layer. The production entry personal-report-service.ts adds
|
||||
* `import "server-only"` and re-exports this module.
|
||||
*
|
||||
* Ownership: every operation is scoped by userId; completeReady additionally
|
||||
* verifies the validated document's reportId against the row id and the row's
|
||||
* stored hash columns against the document (hashes are recomputed here, never
|
||||
* trusted as model self-report).
|
||||
*
|
||||
* Idempotency: unique (user_id, request_id) is the primary lock. Replaying a
|
||||
* known requestId requires the same requestFingerprint; a different
|
||||
* fingerprint under the same requestId returns request_conflict instead of
|
||||
* silently overwriting. Failed records are never implicitly resurrected by
|
||||
* this service; callers reuse a failed record only via a new requestId.
|
||||
*
|
||||
* Privacy: this module never logs, indexes or returns birth details, report
|
||||
* bodies, prompts, model text or exception stacks. failures carry a stable
|
||||
* failure_code enum only.
|
||||
*/
|
||||
|
||||
export const PERSONAL_REPORT_FAILURE_CODES = [
|
||||
"profile_incomplete",
|
||||
"birth_time_not_usable",
|
||||
"report_generation_in_progress",
|
||||
"report_rate_limited",
|
||||
"calculation_unavailable",
|
||||
"model_unavailable",
|
||||
"report_schema_invalid",
|
||||
"report_guard_rejected",
|
||||
"report_not_found",
|
||||
] as const;
|
||||
export type PersonalReportFailureCode = (typeof PERSONAL_REPORT_FAILURE_CODES)[number];
|
||||
|
||||
export const PERSONAL_REPORT_STATUSES = ["generating", "ready", "failed"] as const;
|
||||
export type PersonalReportStatus = (typeof PERSONAL_REPORT_STATUSES)[number];
|
||||
|
||||
export const PERSONAL_REPORT_TYPES = ["personal_full", "personal_thematic"] as const;
|
||||
export const PERSONAL_REPORT_PRESENTATION_MODES = ["default", "research"] as const;
|
||||
|
||||
export type PersonalReportServiceErrorCode =
|
||||
| "invalid_request"
|
||||
| "not_found"
|
||||
| "invalid_state"
|
||||
| "generation_in_progress"
|
||||
| "request_conflict"
|
||||
| "invalid_document"
|
||||
| "invalid_failure_code"
|
||||
| "storage_failed";
|
||||
|
||||
export class PersonalReportServiceError extends Error {
|
||||
readonly code: PersonalReportServiceErrorCode;
|
||||
|
||||
constructor(code: PersonalReportServiceErrorCode, message?: string) {
|
||||
super(message ?? `Personal report service error: ${code}`);
|
||||
this.name = "PersonalReportServiceError";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export type PersonalReportRecord = Readonly<{
|
||||
id: string;
|
||||
userId: string;
|
||||
sessionId: string | null;
|
||||
chartProfileId: string | null;
|
||||
requestId: string;
|
||||
requestFingerprint: string;
|
||||
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
|
||||
status: PersonalReportStatus;
|
||||
schemaVersion: string;
|
||||
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
|
||||
requestedThemes: readonly string[];
|
||||
reportDocument: ReportDocumentV1 | null;
|
||||
calculationHash: string | null;
|
||||
evidenceHash: string | null;
|
||||
skillSourceCommit: string | null;
|
||||
skillSnapshotSha256: string;
|
||||
failureCode: PersonalReportFailureCode | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
completedAt: string | null;
|
||||
}>;
|
||||
|
||||
export type CreateGeneratingInput = Readonly<{
|
||||
userId: string;
|
||||
requestId: string;
|
||||
requestFingerprint: string;
|
||||
reportType: (typeof PERSONAL_REPORT_TYPES)[number];
|
||||
presentationMode: (typeof PERSONAL_REPORT_PRESENTATION_MODES)[number];
|
||||
requestedThemes?: readonly string[];
|
||||
sessionId?: string | null;
|
||||
chartProfileId?: string | null;
|
||||
skillSourceCommit?: string | null;
|
||||
skillSnapshotSha256: string;
|
||||
}>;
|
||||
|
||||
export type CreateGeneratingResult =
|
||||
| Readonly<{ kind: "created"; record: PersonalReportRecord }>
|
||||
| Readonly<{ kind: "replayed"; record: PersonalReportRecord }>
|
||||
| Readonly<{ kind: "request_conflict"; record: PersonalReportRecord }>
|
||||
| Readonly<{ kind: "generation_in_progress"; record: PersonalReportRecord }>;
|
||||
|
||||
export type PersonalReportQueryResult = Readonly<{
|
||||
data: unknown;
|
||||
error: Readonly<{ message: string; code?: string }> | null;
|
||||
count?: number | null;
|
||||
}>;
|
||||
|
||||
/** Narrow structural port implemented by the in-memory fake and the adapter. */
|
||||
export interface PersonalReportQueryBuilder extends PromiseLike<PersonalReportQueryResult> {
|
||||
select(columns: string): PersonalReportQueryBuilder;
|
||||
insert(row: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
|
||||
update(values: Readonly<Record<string, unknown>>): PersonalReportQueryBuilder;
|
||||
delete(options?: Readonly<{ count?: string }>): PersonalReportQueryBuilder;
|
||||
eq(column: string, value: unknown): PersonalReportQueryBuilder;
|
||||
order(column: string, options?: Readonly<{ ascending?: boolean }>): PersonalReportQueryBuilder;
|
||||
limit(value: number): PersonalReportQueryBuilder;
|
||||
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
|
||||
single(): PromiseLike<PersonalReportQueryResult>;
|
||||
}
|
||||
|
||||
export interface PersonalReportDataClient {
|
||||
from(table: "personal_reports"): PersonalReportQueryBuilder;
|
||||
}
|
||||
|
||||
export type PersonalReportServiceDeps = Readonly<{
|
||||
now?: () => Date;
|
||||
}>;
|
||||
|
||||
const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const sha256Pattern = /^[0-9a-f]{64}$/;
|
||||
const sha1Pattern = /^[0-9a-f]{40}$/;
|
||||
|
||||
const RECORD_COLUMNS = [
|
||||
"id",
|
||||
"user_id",
|
||||
"session_id",
|
||||
"chart_profile_id",
|
||||
"request_id",
|
||||
"request_fingerprint",
|
||||
"report_type",
|
||||
"status",
|
||||
"schema_version",
|
||||
"presentation_mode",
|
||||
"requested_themes",
|
||||
"report_document",
|
||||
"calculation_hash",
|
||||
"evidence_hash",
|
||||
"skill_source_commit",
|
||||
"skill_snapshot_sha256",
|
||||
"failure_code",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
].join(",");
|
||||
|
||||
type DbRow = Readonly<Record<string, unknown>>;
|
||||
|
||||
function requireUuid(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !uuidPattern.test(value)) {
|
||||
throw new PersonalReportServiceError("invalid_request", `${field} must be a uuid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalUuid(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return requireUuid(value, field);
|
||||
}
|
||||
|
||||
function requireSha256(value: unknown, field: string): string {
|
||||
if (typeof value !== "string" || !sha256Pattern.test(value)) {
|
||||
throw new PersonalReportServiceError("invalid_request", `${field} must be a 64-char sha256 hex`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function optionalHash(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
return requireSha256(value, field);
|
||||
}
|
||||
|
||||
function optionalCommit(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) return null;
|
||||
if (typeof value !== "string" || !sha1Pattern.test(value)) {
|
||||
throw new PersonalReportServiceError("invalid_request", `${field} must be a 40-char commit sha`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stringOrNull(value: unknown): string | null {
|
||||
return typeof value === "string" ? value : null;
|
||||
}
|
||||
|
||||
function recordFromRow(row: DbRow | null | undefined): PersonalReportRecord | null {
|
||||
if (!row) return null;
|
||||
const requestedThemes = Array.isArray(row.requested_themes)
|
||||
? row.requested_themes.filter((theme): theme is string => typeof theme === "string")
|
||||
: [];
|
||||
const reportDocument = row.report_document === null || row.report_document === undefined
|
||||
? null
|
||||
: row.report_document as ReportDocumentV1;
|
||||
return {
|
||||
id: stringOrNull(row.id) ?? "",
|
||||
userId: stringOrNull(row.user_id) ?? "",
|
||||
sessionId: stringOrNull(row.session_id),
|
||||
chartProfileId: stringOrNull(row.chart_profile_id),
|
||||
requestId: stringOrNull(row.request_id) ?? "",
|
||||
requestFingerprint: stringOrNull(row.request_fingerprint) ?? "",
|
||||
reportType: row.report_type as PersonalReportRecord["reportType"],
|
||||
status: row.status as PersonalReportStatus,
|
||||
schemaVersion: stringOrNull(row.schema_version) ?? "",
|
||||
presentationMode: row.presentation_mode as PersonalReportRecord["presentationMode"],
|
||||
requestedThemes,
|
||||
reportDocument,
|
||||
calculationHash: stringOrNull(row.calculation_hash),
|
||||
evidenceHash: stringOrNull(row.evidence_hash),
|
||||
skillSourceCommit: stringOrNull(row.skill_source_commit),
|
||||
skillSnapshotSha256: stringOrNull(row.skill_snapshot_sha256) ?? "",
|
||||
failureCode: stringOrNull(row.failure_code) as PersonalReportFailureCode | null,
|
||||
createdAt: stringOrNull(row.created_at) ?? "",
|
||||
updatedAt: stringOrNull(row.updated_at) ?? "",
|
||||
completedAt: stringOrNull(row.completed_at),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizedResult(result: PersonalReportQueryResult): PersonalReportQueryResult {
|
||||
return {
|
||||
data: result.data,
|
||||
error: result.error ? { message: result.error.message, code: result.error.code } : null,
|
||||
...(typeof result.count === "number" ? { count: result.count } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps any routed client (real Supabase or the local PostgreSQL compatibility
|
||||
* client) into the narrow port. Every chain step is re-wrapped so callers only
|
||||
* ever see PersonalReportQueryBuilder shapes.
|
||||
*/
|
||||
export function createPersonalReportDataClient(supabase: {
|
||||
from(table: string): unknown;
|
||||
}): PersonalReportDataClient {
|
||||
type AnyBuilder = {
|
||||
select(columns: string): unknown;
|
||||
insert(row: unknown): unknown;
|
||||
update(values: unknown): unknown;
|
||||
delete(options?: unknown): unknown;
|
||||
eq(column: string, value: unknown): unknown;
|
||||
order(column: string, options?: unknown): unknown;
|
||||
limit(value: number): unknown;
|
||||
maybeSingle(): PromiseLike<PersonalReportQueryResult>;
|
||||
single(): PromiseLike<PersonalReportQueryResult>;
|
||||
then: PromiseLike<PersonalReportQueryResult>["then"];
|
||||
};
|
||||
|
||||
const wrap = (builder: AnyBuilder): PersonalReportQueryBuilder => ({
|
||||
select: (columns) => wrap(builder.select(columns) as AnyBuilder),
|
||||
insert: (row) => wrap(builder.insert(row) as AnyBuilder),
|
||||
update: (values) => wrap(builder.update(values) as AnyBuilder),
|
||||
delete: (options) => wrap(builder.delete(options) as AnyBuilder),
|
||||
eq: (column, value) => wrap(builder.eq(column, value) as AnyBuilder),
|
||||
order: (column, options) => wrap(builder.order(column, options) as AnyBuilder),
|
||||
limit: (value) => wrap(builder.limit(value) as AnyBuilder),
|
||||
maybeSingle: () => builder.maybeSingle().then(normalizedResult),
|
||||
single: () => builder.single().then(normalizedResult),
|
||||
then: (onfulfilled, onrejected) =>
|
||||
Promise.resolve(builder.then(normalizedResult)).then(onfulfilled, onrejected),
|
||||
});
|
||||
|
||||
return {
|
||||
from: (table) => wrap(supabase.from(table) as unknown as AnyBuilder),
|
||||
};
|
||||
}
|
||||
|
||||
export function createPersonalReportService(
|
||||
client: PersonalReportDataClient,
|
||||
deps: PersonalReportServiceDeps = {},
|
||||
): PersonalReportService {
|
||||
const now = deps.now ?? (() => new Date());
|
||||
const records = () => client.from("personal_reports");
|
||||
|
||||
async function loadRow(
|
||||
userId: string,
|
||||
reportId: string,
|
||||
): Promise<DbRow | null> {
|
||||
const { data, error } = await records()
|
||||
.select(RECORD_COLUMNS)
|
||||
.eq("id", reportId)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
||||
return data as DbRow | null;
|
||||
}
|
||||
|
||||
async function loadByRequest(
|
||||
userId: string,
|
||||
requestId: string,
|
||||
): Promise<DbRow | null> {
|
||||
const { data, error } = await records()
|
||||
.select(RECORD_COLUMNS)
|
||||
.eq("user_id", userId)
|
||||
.eq("request_id", requestId)
|
||||
.maybeSingle();
|
||||
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
||||
return data as DbRow | null;
|
||||
}
|
||||
|
||||
async function anyGeneratingFor(userId: string): Promise<DbRow | null> {
|
||||
const { data, error } = await records()
|
||||
.select(RECORD_COLUMNS)
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "generating")
|
||||
.limit(1)
|
||||
.maybeSingle();
|
||||
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
||||
return data as DbRow | null;
|
||||
}
|
||||
|
||||
return {
|
||||
async createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult> {
|
||||
const userId = requireUuid(input.userId, "userId");
|
||||
const requestId = requireUuid(input.requestId, "requestId");
|
||||
const requestFingerprint = requireSha256(input.requestFingerprint, "requestFingerprint");
|
||||
if (!PERSONAL_REPORT_TYPES.includes(input.reportType)) {
|
||||
throw new PersonalReportServiceError("invalid_request", "unsupported reportType");
|
||||
}
|
||||
if (!PERSONAL_REPORT_PRESENTATION_MODES.includes(input.presentationMode)) {
|
||||
throw new PersonalReportServiceError("invalid_request", "unsupported presentationMode");
|
||||
}
|
||||
optionalUuid(input.sessionId, "sessionId");
|
||||
optionalUuid(input.chartProfileId, "chartProfileId");
|
||||
optionalHash(input.skillSnapshotSha256, "skillSnapshotSha256");
|
||||
optionalCommit(input.skillSourceCommit, "skillSourceCommit");
|
||||
const themes = input.requestedThemes ?? [];
|
||||
if (!Array.isArray(themes) || themes.length > 12 || themes.some((theme) => typeof theme !== "string" || theme.length > 64)) {
|
||||
throw new PersonalReportServiceError("invalid_request", "invalid requestedThemes");
|
||||
}
|
||||
|
||||
const row: Record<string, unknown> = {
|
||||
user_id: userId,
|
||||
request_id: requestId,
|
||||
request_fingerprint: requestFingerprint,
|
||||
report_type: input.reportType,
|
||||
status: "generating",
|
||||
schema_version: REPORT_DOCUMENT_SCHEMA_VERSION,
|
||||
presentation_mode: input.presentationMode,
|
||||
requested_themes: themes,
|
||||
skill_snapshot_sha256: input.skillSnapshotSha256,
|
||||
session_id: input.sessionId ?? null,
|
||||
chart_profile_id: input.chartProfileId ?? null,
|
||||
skill_source_commit: input.skillSourceCommit ?? null,
|
||||
created_at: now().toISOString(),
|
||||
updated_at: now().toISOString(),
|
||||
};
|
||||
|
||||
const { data, error } = await records()
|
||||
.insert(row)
|
||||
.select(RECORD_COLUMNS)
|
||||
.single();
|
||||
if (!error && data) return { kind: "created", record: recordFromRow(data as DbRow)! };
|
||||
|
||||
// The (user_id, request_id) unique constraint already holds this
|
||||
// request, or the per-user in-flight index rejected a second
|
||||
// generation. Re-read state instead of trusting backend error codes.
|
||||
const existing = await loadByRequest(userId, requestId);
|
||||
if (existing) {
|
||||
if (existing.request_fingerprint === requestFingerprint) {
|
||||
return { kind: "replayed", record: recordFromRow(existing)! };
|
||||
}
|
||||
return { kind: "request_conflict", record: recordFromRow(existing)! };
|
||||
}
|
||||
const inFlight = await anyGeneratingFor(userId);
|
||||
if (inFlight) return { kind: "generation_in_progress", record: recordFromRow(inFlight)! };
|
||||
throw new PersonalReportServiceError("storage_failed", error?.message ?? "insert failed");
|
||||
},
|
||||
|
||||
async getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(requestId, "requestId");
|
||||
const row = await loadByRequest(userId, requestId);
|
||||
return recordFromRow(row);
|
||||
},
|
||||
|
||||
async getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(reportId, "reportId");
|
||||
const row = await loadRow(userId, reportId);
|
||||
return recordFromRow(row);
|
||||
},
|
||||
|
||||
async completeReady(
|
||||
userId: string,
|
||||
reportId: string,
|
||||
document: unknown,
|
||||
): Promise<PersonalReportRecord> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(reportId, "reportId");
|
||||
let parsed: ReportDocumentV1;
|
||||
try {
|
||||
parsed = parseServerReportDocument(document);
|
||||
} catch (error) {
|
||||
throw new PersonalReportServiceError(
|
||||
"invalid_document",
|
||||
error instanceof Error ? error.message : "report document failed contract validation",
|
||||
);
|
||||
}
|
||||
if (parsed.reportId !== reportId) {
|
||||
throw new PersonalReportServiceError("invalid_document", "document reportId does not match record id");
|
||||
}
|
||||
const row = await loadRow(userId, reportId);
|
||||
if (!row) throw new PersonalReportServiceError("not_found");
|
||||
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
|
||||
|
||||
// Hashes are recomputed here and cross-checked against the stored row;
|
||||
// provenance.evidenceHash was already verified by parseServerReportDocument.
|
||||
const evidenceHash = computeEvidenceHash(parsed.evidenceAppendix);
|
||||
if (row.evidence_hash !== null && row.evidence_hash !== evidenceHash) {
|
||||
throw new PersonalReportServiceError("invalid_document", "evidence hash does not match stored row");
|
||||
}
|
||||
if (row.calculation_hash !== null && row.calculation_hash !== parsed.provenance.calculationHash) {
|
||||
throw new PersonalReportServiceError("invalid_document", "calculation hash does not match stored row");
|
||||
}
|
||||
|
||||
const completedAt = now().toISOString();
|
||||
const { data, error } = await records()
|
||||
.update({
|
||||
status: "ready",
|
||||
report_document: parsed,
|
||||
evidence_hash: evidenceHash,
|
||||
calculation_hash: parsed.provenance.calculationHash,
|
||||
completed_at: completedAt,
|
||||
updated_at: completedAt,
|
||||
})
|
||||
.eq("id", reportId)
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "generating")
|
||||
.select(RECORD_COLUMNS)
|
||||
.single();
|
||||
if (!error && data) return recordFromRow(data as DbRow)!;
|
||||
const current = await loadRow(userId, reportId);
|
||||
if (!current) throw new PersonalReportServiceError("not_found");
|
||||
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
|
||||
},
|
||||
|
||||
async markFailed(
|
||||
userId: string,
|
||||
reportId: string,
|
||||
failureCode: string,
|
||||
): Promise<PersonalReportRecord> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(reportId, "reportId");
|
||||
if (!PERSONAL_REPORT_FAILURE_CODES.includes(failureCode as PersonalReportFailureCode)) {
|
||||
throw new PersonalReportServiceError("invalid_failure_code", `unsupported failure code ${failureCode}`);
|
||||
}
|
||||
const row = await loadRow(userId, reportId);
|
||||
if (!row) throw new PersonalReportServiceError("not_found");
|
||||
if (row.status !== "generating") throw new PersonalReportServiceError("invalid_state", `status is ${String(row.status)}`);
|
||||
|
||||
const updatedAt = now().toISOString();
|
||||
const { data, error } = await records()
|
||||
.update({
|
||||
status: "failed",
|
||||
failure_code: failureCode,
|
||||
updated_at: updatedAt,
|
||||
})
|
||||
.eq("id", reportId)
|
||||
.eq("user_id", userId)
|
||||
.eq("status", "generating")
|
||||
.select(RECORD_COLUMNS)
|
||||
.single();
|
||||
if (!error && data) return recordFromRow(data as DbRow)!;
|
||||
const current = await loadRow(userId, reportId);
|
||||
if (!current) throw new PersonalReportServiceError("not_found");
|
||||
throw new PersonalReportServiceError("invalid_state", `status is ${String(current.status)}`);
|
||||
},
|
||||
|
||||
async deleteOwned(userId: string, reportId: string): Promise<boolean> {
|
||||
requireUuid(userId, "userId");
|
||||
requireUuid(reportId, "reportId");
|
||||
const { error, count } = await records()
|
||||
.delete({ count: "exact" })
|
||||
.eq("id", reportId)
|
||||
.eq("user_id", userId);
|
||||
if (error) throw new PersonalReportServiceError("storage_failed", error.message);
|
||||
return (count ?? 0) > 0;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export interface PersonalReportService {
|
||||
createGenerating(input: CreateGeneratingInput): Promise<CreateGeneratingResult>;
|
||||
getByUserAndRequestId(userId: string, requestId: string): Promise<PersonalReportRecord | null>;
|
||||
getOwnedById(userId: string, reportId: string): Promise<PersonalReportRecord | null>;
|
||||
completeReady(userId: string, reportId: string, document: unknown): Promise<PersonalReportRecord>;
|
||||
markFailed(userId: string, reportId: string, failureCode: string): Promise<PersonalReportRecord>;
|
||||
deleteOwned(userId: string, reportId: string): Promise<boolean>;
|
||||
}
|
||||
|
||||
/** Production wiring: routes through the caller's resolved backend client. */
|
||||
export function createSupabasePersonalReportService(
|
||||
supabase: { from(table: string): unknown },
|
||||
deps?: PersonalReportServiceDeps,
|
||||
): PersonalReportService {
|
||||
return createPersonalReportService(createPersonalReportDataClient(supabase), deps);
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import "server-only";
|
||||
|
||||
export * from "./personal-report-service-core";
|
||||
Reference in New Issue
Block a user