Files
Neon-Desk/lib/server/repos/issuer-overlays.ts

332 lines
9.2 KiB
TypeScript

import { createHash } from "node:crypto";
import { and, desc, eq, max } from "drizzle-orm";
import { drizzle } from "drizzle-orm/bun-sqlite";
import { getSqliteClient } from "@/lib/server/db";
import {
issuerOverlay,
issuerOverlayRevision,
schema,
type IssuerOverlayDefinition,
type IssuerOverlayDiagnostics,
type IssuerOverlayStats,
} from "@/lib/server/db/schema";
type IssuerOverlayRevisionRecord = {
id: number;
ticker: string;
revision_number: number;
definition_hash: string;
definition_json: IssuerOverlayDefinition;
diagnostics_json: IssuerOverlayDiagnostics | null;
source_snapshot_ids: number[];
created_at: string;
};
type IssuerOverlayRecord = {
ticker: string;
status: "empty" | "active" | "error";
active_revision_id: number | null;
last_built_at: string | null;
last_error: string | null;
stats_json: IssuerOverlayStats | null;
created_at: string;
updated_at: string;
active_revision: IssuerOverlayRevisionRecord | null;
};
function getDb() {
return drizzle(getSqliteClient(), { schema });
}
function normalizeTicker(ticker: string) {
return ticker.trim().toUpperCase();
}
function uniqueSorted(values: string[]) {
return [...new Set(values.filter((value) => value.trim().length > 0))].sort(
(left, right) => left.localeCompare(right),
);
}
export function normalizeIssuerOverlayDefinition(
input: IssuerOverlayDefinition,
): IssuerOverlayDefinition {
return {
version: "fiscal-v1",
ticker: normalizeTicker(input.ticker),
pack: input.pack?.trim() ? input.pack.trim() : null,
mappings: [...input.mappings]
.map((mapping) => ({
surface_key: mapping.surface_key,
statement: mapping.statement,
allowed_source_concepts: uniqueSorted(mapping.allowed_source_concepts),
allowed_authoritative_concepts: uniqueSorted(
mapping.allowed_authoritative_concepts,
),
}))
.filter(
(mapping) =>
mapping.allowed_source_concepts.length > 0 ||
mapping.allowed_authoritative_concepts.length > 0,
)
.sort((left, right) => {
return (
left.statement.localeCompare(right.statement) ||
left.surface_key.localeCompare(right.surface_key)
);
}),
};
}
function definitionHash(definition: IssuerOverlayDefinition) {
return createHash("sha256")
.update(JSON.stringify(normalizeIssuerOverlayDefinition(definition)))
.digest("hex");
}
function toRevisionRecord(
row: typeof issuerOverlayRevision.$inferSelect,
): IssuerOverlayRevisionRecord {
const definition = row.definition_json;
if (!definition) {
throw new Error(
`Issuer overlay revision ${row.id} is missing definition_json`,
);
}
return {
id: row.id,
ticker: row.ticker,
revision_number: row.revision_number,
definition_hash: row.definition_hash,
definition_json: normalizeIssuerOverlayDefinition(definition),
diagnostics_json: row.diagnostics_json ?? null,
source_snapshot_ids: row.source_snapshot_ids ?? [],
created_at: row.created_at,
};
}
async function getRevisionById(id: number | null) {
if (!id) {
return null;
}
const [row] = await getDb()
.select()
.from(issuerOverlayRevision)
.where(eq(issuerOverlayRevision.id, id))
.limit(1);
return row ? toRevisionRecord(row) : null;
}
export async function getIssuerOverlay(ticker: string) {
const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) {
return null;
}
const [row] = await getDb()
.select()
.from(issuerOverlay)
.where(eq(issuerOverlay.ticker, normalizedTicker))
.limit(1);
if (!row) {
return null;
}
return {
ticker: row.ticker,
status: row.status,
active_revision_id: row.active_revision_id ?? null,
last_built_at: row.last_built_at,
last_error: row.last_error,
stats_json: row.stats_json ?? null,
created_at: row.created_at,
updated_at: row.updated_at,
active_revision: await getRevisionById(row.active_revision_id ?? null),
} satisfies IssuerOverlayRecord;
}
export async function ensureIssuerOverlayRow(ticker: string) {
const normalizedTicker = normalizeTicker(ticker);
if (!normalizedTicker) {
return null;
}
const now = new Date().toISOString();
await getDb()
.insert(issuerOverlay)
.values({
ticker: normalizedTicker,
status: "empty",
active_revision_id: null,
last_built_at: null,
last_error: null,
stats_json: null,
created_at: now,
updated_at: now,
})
.onConflictDoNothing();
return await getIssuerOverlay(normalizedTicker);
}
export async function markIssuerOverlayBuildState(input: {
ticker: string;
status: "empty" | "active" | "error";
lastError?: string | null;
stats?: IssuerOverlayStats | null;
activeRevisionId?: number | null;
}) {
const normalizedTicker = normalizeTicker(input.ticker);
const now = new Date().toISOString();
await ensureIssuerOverlayRow(normalizedTicker);
await getDb()
.update(issuerOverlay)
.set({
status: input.status,
last_error: input.lastError ?? null,
last_built_at: now,
stats_json: input.stats ?? null,
active_revision_id:
input.activeRevisionId === undefined ? undefined : input.activeRevisionId,
updated_at: now,
})
.where(eq(issuerOverlay.ticker, normalizedTicker));
return await getIssuerOverlay(normalizedTicker);
}
export async function getActiveIssuerOverlayDefinition(ticker: string) {
const overlay = await getIssuerOverlay(ticker);
return overlay?.active_revision?.definition_json ?? null;
}
export async function publishIssuerOverlayRevision(input: {
ticker: string;
definition: IssuerOverlayDefinition;
diagnostics: IssuerOverlayDiagnostics;
stats: IssuerOverlayStats;
}) {
const normalizedTicker = normalizeTicker(input.ticker);
const normalizedDefinition = normalizeIssuerOverlayDefinition({
...input.definition,
ticker: normalizedTicker,
});
const hash = definitionHash(normalizedDefinition);
const now = new Date().toISOString();
return await getDb().transaction(async (tx) => {
const [currentOverlay] = await tx
.select()
.from(issuerOverlay)
.where(eq(issuerOverlay.ticker, normalizedTicker))
.limit(1);
if (!currentOverlay) {
await tx.insert(issuerOverlay).values({
ticker: normalizedTicker,
status: "empty",
active_revision_id: null,
last_built_at: now,
last_error: null,
stats_json: null,
created_at: now,
updated_at: now,
});
}
const [existingRevision] = await tx
.select()
.from(issuerOverlayRevision)
.where(
and(
eq(issuerOverlayRevision.ticker, normalizedTicker),
eq(issuerOverlayRevision.definition_hash, hash),
),
)
.limit(1);
const currentActiveRevisionId = currentOverlay?.active_revision_id ?? null;
if (existingRevision) {
await tx
.update(issuerOverlay)
.set({
status:
normalizedDefinition.mappings.length > 0 ? "active" : "empty",
active_revision_id:
normalizedDefinition.mappings.length > 0
? existingRevision.id
: currentActiveRevisionId,
last_built_at: now,
last_error: null,
stats_json: input.stats,
updated_at: now,
})
.where(eq(issuerOverlay.ticker, normalizedTicker));
return {
published:
normalizedDefinition.mappings.length > 0 &&
currentActiveRevisionId !== existingRevision.id,
revision: toRevisionRecord(existingRevision),
};
}
const [currentRevisionNumberRow] = await tx
.select({
value: max(issuerOverlayRevision.revision_number),
})
.from(issuerOverlayRevision)
.where(eq(issuerOverlayRevision.ticker, normalizedTicker));
const nextRevisionNumber = (currentRevisionNumberRow?.value ?? 0) + 1;
const [savedRevision] = await tx
.insert(issuerOverlayRevision)
.values({
ticker: normalizedTicker,
revision_number: nextRevisionNumber,
definition_hash: hash,
definition_json: normalizedDefinition,
diagnostics_json: input.diagnostics,
source_snapshot_ids: input.diagnostics.sampledSnapshotIds,
created_at: now,
})
.returning();
await tx
.update(issuerOverlay)
.set({
status: normalizedDefinition.mappings.length > 0 ? "active" : "empty",
active_revision_id:
normalizedDefinition.mappings.length > 0
? savedRevision.id
: currentActiveRevisionId,
last_built_at: now,
last_error: null,
stats_json: input.stats,
updated_at: now,
})
.where(eq(issuerOverlay.ticker, normalizedTicker));
return {
published: normalizedDefinition.mappings.length > 0,
revision: toRevisionRecord(savedRevision),
};
});
}
export async function listIssuerOverlayRevisions(ticker: string) {
const normalizedTicker = normalizeTicker(ticker);
const rows = await getDb()
.select()
.from(issuerOverlayRevision)
.where(eq(issuerOverlayRevision.ticker, normalizedTicker))
.orderBy(desc(issuerOverlayRevision.revision_number));
return rows.map(toRevisionRecord);
}