332 lines
9.2 KiB
TypeScript
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);
|
|
}
|