Files
Jyotisha/frontend/src/lib/personal-report-service-core.ts
T

565 lines
23 KiB
TypeScript

import {
parseServerReportDocument,
computeEvidenceHash,
} from "./personal-report-contract.server-core.ts";
import { REPORT_DOCUMENT_SCHEMA_VERSION } from "./personal-report-contract.ts";
import type { ReportDocument } 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 const PERSONAL_REPORT_DEPTHS = ["concise", "standard", "deep", "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];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes: readonly string[];
reportDocument: ReportDocument | null;
calculationHash: string | null;
evidenceHash: string | null;
skillName: string | null;
skillVersion: 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];
depth: (typeof PERSONAL_REPORT_DEPTHS)[number];
requestedThemes?: readonly string[];
sessionId?: string | null;
chartProfileId?: string | null;
skillName: string;
skillVersion: string;
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 skillNamePattern = /^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$/;
const skillVersionPattern =
/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
const RECORD_COLUMNS = [
"id",
"user_id",
"session_id",
"chart_profile_id",
"request_id",
"request_fingerprint",
"report_type",
"status",
"schema_version",
"presentation_mode",
"depth",
"requested_themes",
"report_document",
"calculation_hash",
"evidence_hash",
"skill_name",
"skill_version",
"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 requireSkillName(value: unknown): string {
if (typeof value !== "string" || !skillNamePattern.test(value)) {
throw new PersonalReportServiceError("invalid_request", "skillName must be a registry package name");
}
return value;
}
function requireSkillVersion(value: unknown): string {
if (typeof value !== "string" || !skillVersionPattern.test(value)) {
throw new PersonalReportServiceError("invalid_request", "skillVersion must be a semantic version");
}
return value;
}
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 ReportDocument;
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"],
depth: row.depth as PersonalReportRecord["depth"],
requestedThemes,
reportDocument,
calculationHash: stringOrNull(row.calculation_hash),
evidenceHash: stringOrNull(row.evidence_hash),
skillName: stringOrNull(row.skill_name),
skillVersion: stringOrNull(row.skill_version),
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");
}
if (!PERSONAL_REPORT_DEPTHS.includes(input.depth)) {
throw new PersonalReportServiceError("invalid_request", "unsupported depth");
}
optionalUuid(input.sessionId, "sessionId");
optionalUuid(input.chartProfileId, "chartProfileId");
requireSkillName(input.skillName);
requireSkillVersion(input.skillVersion);
requireSha256(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,
depth: input.depth,
requested_themes: themes,
skill_name: input.skillName,
skill_version: input.skillVersion,
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: ReportDocument;
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)}`);
// The generating row is the immutable server-side identity lock. The
// document may only become ready when all four Skill provenance fields
// exactly match it (including nullable sourceCommit). Optional name/version
// remain a read-compatibility allowance for historical ready documents,
// never an allowance for newly completed rows.
if ((parsed.provenance.skillName ?? null) !== (stringOrNull(row.skill_name) ?? null)
|| (parsed.provenance.skillVersion ?? null) !== (stringOrNull(row.skill_version) ?? null)
|| parsed.provenance.skillSnapshotSha256 !== stringOrNull(row.skill_snapshot_sha256)
|| parsed.provenance.skillSourceCommit !== (stringOrNull(row.skill_source_commit) ?? null)) {
throw new PersonalReportServiceError("invalid_document", "skill identity does not match stored row");
}
// 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",
schema_version: parsed.schemaVersion,
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);
}