Automate issuer overlay creation from ticker searches
This commit is contained in:
331
lib/server/repos/issuer-overlays.ts
Normal file
331
lib/server/repos/issuer-overlays.ts
Normal file
@@ -0,0 +1,331 @@
|
||||
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";
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
export 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);
|
||||
}
|
||||
Reference in New Issue
Block a user