Automate issuer overlay creation from ticker searches
This commit is contained in:
@@ -9,7 +9,7 @@ import { companyFinancialBundle } from '@/lib/server/db/schema';
|
||||
|
||||
export const CURRENT_COMPANY_FINANCIAL_BUNDLE_VERSION = 15;
|
||||
|
||||
export type CompanyFinancialBundleRecord = {
|
||||
type CompanyFinancialBundleRecord = {
|
||||
id: number;
|
||||
ticker: string;
|
||||
surface_kind: FinancialSurfaceKind;
|
||||
@@ -107,6 +107,6 @@ export async function deleteCompanyFinancialBundlesForTicker(ticker: string) {
|
||||
.where(eq(companyFinancialBundle.ticker, ticker.trim().toUpperCase()));
|
||||
}
|
||||
|
||||
export const __companyFinancialBundlesInternals = {
|
||||
const __companyFinancialBundlesInternals = {
|
||||
BUNDLE_VERSION: CURRENT_COMPANY_FINANCIAL_BUNDLE_VERSION
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { companyOverviewCache, schema } from '@/lib/server/db/schema';
|
||||
|
||||
export const CURRENT_COMPANY_OVERVIEW_CACHE_VERSION = 1;
|
||||
|
||||
export type CompanyOverviewCacheRecord = {
|
||||
type CompanyOverviewCacheRecord = {
|
||||
id: number;
|
||||
user_id: string;
|
||||
ticker: string;
|
||||
@@ -86,7 +86,7 @@ export async function upsertCompanyOverviewCache(input: {
|
||||
return toRecord(saved);
|
||||
}
|
||||
|
||||
export async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||
async function deleteCompanyOverviewCache(input: { userId: string; ticker: string }) {
|
||||
const normalizedTicker = input.ticker.trim().toUpperCase();
|
||||
|
||||
return await getDb()
|
||||
@@ -97,6 +97,6 @@ export async function deleteCompanyOverviewCache(input: { userId: string; ticker
|
||||
));
|
||||
}
|
||||
|
||||
export const __companyOverviewCacheInternals = {
|
||||
const __companyOverviewCacheInternals = {
|
||||
CACHE_VERSION: CURRENT_COMPANY_OVERVIEW_CACHE_VERSION
|
||||
};
|
||||
|
||||
@@ -80,7 +80,7 @@ export type DimensionStatementBundle = {
|
||||
statements: Record<FinancialStatementKind, DimensionStatementSnapshotRow[]>;
|
||||
};
|
||||
|
||||
export type FilingStatementSnapshotRecord = {
|
||||
type FilingStatementSnapshotRecord = {
|
||||
id: number;
|
||||
filing_id: number;
|
||||
ticker: string;
|
||||
@@ -97,7 +97,7 @@ export type FilingStatementSnapshotRecord = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertFilingStatementSnapshotInput = {
|
||||
type UpsertFilingStatementSnapshotInput = {
|
||||
filing_id: number;
|
||||
ticker: string;
|
||||
filing_date: string;
|
||||
@@ -191,7 +191,7 @@ export async function upsertFilingStatementSnapshot(
|
||||
return toSnapshotRecord(saved);
|
||||
}
|
||||
|
||||
export async function listFilingStatementSnapshotsByTicker(input: {
|
||||
async function listFilingStatementSnapshotsByTicker(input: {
|
||||
ticker: string;
|
||||
window: "10y" | "all";
|
||||
limit?: number;
|
||||
@@ -235,7 +235,7 @@ export async function listFilingStatementSnapshotsByTicker(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function countFilingStatementSnapshotStatuses(ticker: string) {
|
||||
async function countFilingStatementSnapshotStatuses(ticker: string) {
|
||||
const rows = await db
|
||||
.select({
|
||||
status: filingStatementSnapshot.parse_status,
|
||||
|
||||
@@ -149,6 +149,10 @@ describe("filing taxonomy snapshot normalization", () => {
|
||||
kpi_row_count: 1,
|
||||
unmapped_row_count: 0,
|
||||
material_unmapped_row_count: 0,
|
||||
residual_primary_count: 0,
|
||||
residual_disclosure_count: 0,
|
||||
unsupported_concept_count: 0,
|
||||
issuer_overlay_match_count: 0,
|
||||
warnings: ["legacy_warning"],
|
||||
},
|
||||
facts_count: 3,
|
||||
@@ -223,6 +227,10 @@ describe("filing taxonomy snapshot normalization", () => {
|
||||
kpiRowCount: 1,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
residualPrimaryCount: 0,
|
||||
residualDisclosureCount: 0,
|
||||
unsupportedConceptCount: 0,
|
||||
issuerOverlayMatchCount: 0,
|
||||
warnings: ["legacy_warning"],
|
||||
});
|
||||
});
|
||||
@@ -338,6 +346,10 @@ describe("filing taxonomy snapshot normalization", () => {
|
||||
kpiRowCount: 0,
|
||||
unmapped_row_count: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
residualPrimaryCount: 0,
|
||||
residualDisclosureCount: 0,
|
||||
unsupportedConceptCount: 0,
|
||||
issuerOverlayMatchCount: 0,
|
||||
warnings: [],
|
||||
},
|
||||
});
|
||||
@@ -355,6 +367,10 @@ describe("filing taxonomy snapshot normalization", () => {
|
||||
kpiRowCount: 0,
|
||||
unmappedRowCount: 0,
|
||||
materialUnmappedRowCount: 0,
|
||||
residualPrimaryCount: 0,
|
||||
residualDisclosureCount: 0,
|
||||
unsupportedConceptCount: 0,
|
||||
issuerOverlayMatchCount: 0,
|
||||
warnings: [],
|
||||
});
|
||||
expect(normalized.computed_definitions).toEqual([
|
||||
|
||||
@@ -73,6 +73,7 @@ export type FilingTaxonomySnapshotRecord = {
|
||||
derived_metrics: Filing["metrics"];
|
||||
validation_result: MetricValidationResult | null;
|
||||
normalization_summary: NormalizationSummary | null;
|
||||
issuer_overlay_revision_id: number | null;
|
||||
facts_count: number;
|
||||
concepts_count: number;
|
||||
dimensions_count: number;
|
||||
@@ -80,7 +81,7 @@ export type FilingTaxonomySnapshotRecord = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type FilingTaxonomyContextRecord = {
|
||||
type FilingTaxonomyContextRecord = {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
context_id: string;
|
||||
@@ -94,7 +95,7 @@ export type FilingTaxonomyContextRecord = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FilingTaxonomyAssetRecord = {
|
||||
type FilingTaxonomyAssetRecord = {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
asset_type: FilingTaxonomyAssetType;
|
||||
@@ -133,7 +134,7 @@ export type FilingTaxonomyConceptRecord = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FilingTaxonomyFactRecord = {
|
||||
type FilingTaxonomyFactRecord = {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
concept_key: string;
|
||||
@@ -164,7 +165,7 @@ export type FilingTaxonomyFactRecord = {
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export type FilingTaxonomyMetricValidationRecord = {
|
||||
type FilingTaxonomyMetricValidationRecord = {
|
||||
id: number;
|
||||
snapshot_id: number;
|
||||
metric_key: keyof NonNullable<Filing["metrics"]>;
|
||||
@@ -182,7 +183,7 @@ export type FilingTaxonomyMetricValidationRecord = {
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type UpsertFilingTaxonomySnapshotInput = {
|
||||
type UpsertFilingTaxonomySnapshotInput = {
|
||||
filing_id: number;
|
||||
ticker: string;
|
||||
filing_date: string;
|
||||
@@ -204,6 +205,7 @@ export type UpsertFilingTaxonomySnapshotInput = {
|
||||
derived_metrics: Filing["metrics"];
|
||||
validation_result: MetricValidationResult | null;
|
||||
normalization_summary: NormalizationSummary | null;
|
||||
issuer_overlay_revision_id?: number | null;
|
||||
facts_count: number;
|
||||
concepts_count: number;
|
||||
dimensions_count: number;
|
||||
@@ -294,6 +296,7 @@ const FINANCIAL_STATEMENT_KINDS = [
|
||||
"income",
|
||||
"balance",
|
||||
"cash_flow",
|
||||
"disclosure",
|
||||
"equity",
|
||||
"comprehensive_income",
|
||||
] as const satisfies FinancialStatementKind[];
|
||||
@@ -351,6 +354,7 @@ function asStatementKind(value: unknown): FinancialStatementKind | null {
|
||||
return value === "income" ||
|
||||
value === "balance" ||
|
||||
value === "cash_flow" ||
|
||||
value === "disclosure" ||
|
||||
value === "equity" ||
|
||||
value === "comprehensive_income"
|
||||
? value
|
||||
@@ -576,7 +580,9 @@ function normalizeSurfaceRows(
|
||||
if (
|
||||
normalizedStatement === "income" ||
|
||||
normalizedStatement === "balance" ||
|
||||
normalizedStatement === "cash_flow"
|
||||
normalizedStatement === "cash_flow" ||
|
||||
normalizedStatement === "equity" ||
|
||||
normalizedStatement === "disclosure"
|
||||
) {
|
||||
normalizedRow.statement = normalizedStatement;
|
||||
}
|
||||
@@ -856,6 +862,17 @@ function normalizeNormalizationSummary(value: unknown) {
|
||||
asNumber(
|
||||
row.materialUnmappedRowCount ?? row.material_unmapped_row_count,
|
||||
) ?? 0,
|
||||
residualPrimaryCount:
|
||||
asNumber(row.residualPrimaryCount ?? row.residual_primary_count) ?? 0,
|
||||
residualDisclosureCount:
|
||||
asNumber(row.residualDisclosureCount ?? row.residual_disclosure_count) ??
|
||||
0,
|
||||
unsupportedConceptCount:
|
||||
asNumber(row.unsupportedConceptCount ?? row.unsupported_concept_count) ??
|
||||
0,
|
||||
issuerOverlayMatchCount:
|
||||
asNumber(row.issuerOverlayMatchCount ?? row.issuer_overlay_match_count) ??
|
||||
0,
|
||||
warnings: normalizeStringArray(row.warnings),
|
||||
} satisfies NormalizationSummary;
|
||||
}
|
||||
@@ -962,6 +979,7 @@ function toSnapshotRecord(
|
||||
derived_metrics: row.derived_metrics ?? null,
|
||||
validation_result: row.validation_result ?? null,
|
||||
normalization_summary: normalized.normalization_summary,
|
||||
issuer_overlay_revision_id: row.issuer_overlay_revision_id ?? null,
|
||||
facts_count: row.facts_count,
|
||||
concepts_count: row.concepts_count,
|
||||
dimensions_count: row.dimensions_count,
|
||||
@@ -1107,7 +1125,7 @@ export async function getFilingTaxonomySnapshotByFilingId(filingId: number) {
|
||||
return row ? toSnapshotRecord(row) : null;
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyAssets(snapshotId: number) {
|
||||
async function listFilingTaxonomyAssets(snapshotId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyAsset)
|
||||
@@ -1117,7 +1135,7 @@ export async function listFilingTaxonomyAssets(snapshotId: number) {
|
||||
return rows.map(toAssetRecord);
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyContexts(snapshotId: number) {
|
||||
async function listFilingTaxonomyContexts(snapshotId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyContext)
|
||||
@@ -1127,7 +1145,7 @@ export async function listFilingTaxonomyContexts(snapshotId: number) {
|
||||
return rows.map(toContextRecord);
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyConcepts(snapshotId: number) {
|
||||
async function listFilingTaxonomyConcepts(snapshotId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyConcept)
|
||||
@@ -1137,7 +1155,7 @@ export async function listFilingTaxonomyConcepts(snapshotId: number) {
|
||||
return rows.map(toConceptRecord);
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyFacts(snapshotId: number) {
|
||||
async function listFilingTaxonomyFacts(snapshotId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyFact)
|
||||
@@ -1147,7 +1165,7 @@ export async function listFilingTaxonomyFacts(snapshotId: number) {
|
||||
return rows.map(toFactRecord);
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyMetricValidations(snapshotId: number) {
|
||||
async function listFilingTaxonomyMetricValidations(snapshotId: number) {
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyMetricValidation)
|
||||
@@ -1188,6 +1206,7 @@ export async function upsertFilingTaxonomySnapshot(
|
||||
derived_metrics: input.derived_metrics,
|
||||
validation_result: input.validation_result,
|
||||
normalization_summary: normalized.normalization_summary,
|
||||
issuer_overlay_revision_id: input.issuer_overlay_revision_id ?? null,
|
||||
facts_count: input.facts_count,
|
||||
concepts_count: input.concepts_count,
|
||||
dimensions_count: input.dimensions_count,
|
||||
@@ -1217,6 +1236,7 @@ export async function upsertFilingTaxonomySnapshot(
|
||||
derived_metrics: input.derived_metrics,
|
||||
validation_result: input.validation_result,
|
||||
normalization_summary: normalized.normalization_summary,
|
||||
issuer_overlay_revision_id: input.issuer_overlay_revision_id ?? null,
|
||||
facts_count: input.facts_count,
|
||||
concepts_count: input.concepts_count,
|
||||
dimensions_count: input.dimensions_count,
|
||||
@@ -1579,7 +1599,7 @@ export async function listTaxonomyFactsByTicker(input: {
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTaxonomyAssetsBySnapshotIds(snapshotIds: number[]) {
|
||||
async function listTaxonomyAssetsBySnapshotIds(snapshotIds: number[]) {
|
||||
if (snapshotIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
@@ -1593,6 +1613,22 @@ export async function listTaxonomyAssetsBySnapshotIds(snapshotIds: number[]) {
|
||||
return rows.map(toAssetRecord);
|
||||
}
|
||||
|
||||
export async function listFilingTaxonomyConceptsBySnapshotIds(
|
||||
snapshotIds: number[],
|
||||
) {
|
||||
if (snapshotIds.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(filingTaxonomyConcept)
|
||||
.where(inArray(filingTaxonomyConcept.snapshot_id, snapshotIds))
|
||||
.orderBy(desc(filingTaxonomyConcept.id));
|
||||
|
||||
return rows.map(toConceptRecord);
|
||||
}
|
||||
|
||||
export const __filingTaxonomyInternals = {
|
||||
normalizeFilingTaxonomySnapshotPayload,
|
||||
toSnapshotRecord,
|
||||
|
||||
146
lib/server/repos/issuer-overlays.test.ts
Normal file
146
lib/server/repos/issuer-overlays.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtempSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { Database } from "bun:sqlite";
|
||||
import { __dbInternals } from "@/lib/server/db";
|
||||
import type {
|
||||
IssuerOverlayDefinition,
|
||||
IssuerOverlayDiagnostics,
|
||||
IssuerOverlayStats,
|
||||
} from "@/lib/server/db/schema";
|
||||
|
||||
let tempDir: string | null = null;
|
||||
let sqliteClient: Database | null = null;
|
||||
let overlayRepo: typeof import("./issuer-overlays") | null = null;
|
||||
|
||||
function resetDbSingletons() {
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__fiscalSqliteClient?: Database;
|
||||
__fiscalDrizzleDb?: unknown;
|
||||
__financialIngestionSchemaStatus?: unknown;
|
||||
};
|
||||
|
||||
globalState.__fiscalSqliteClient?.close();
|
||||
globalState.__fiscalSqliteClient = undefined;
|
||||
globalState.__fiscalDrizzleDb = undefined;
|
||||
globalState.__financialIngestionSchemaStatus = undefined;
|
||||
}
|
||||
|
||||
function sampleDefinition(): IssuerOverlayDefinition {
|
||||
return {
|
||||
version: "fiscal-v1",
|
||||
ticker: "AAPL",
|
||||
pack: "core",
|
||||
mappings: [
|
||||
{
|
||||
surface_key: "revenue",
|
||||
statement: "income",
|
||||
allowed_source_concepts: ["aapl:ServicesRevenue", "aapl:ProductRevenue"],
|
||||
allowed_authoritative_concepts: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function sampleDiagnostics(): IssuerOverlayDiagnostics {
|
||||
return {
|
||||
pack: "core",
|
||||
sampledSnapshotIds: [11, 12],
|
||||
acceptedMappings: [
|
||||
{
|
||||
qname: "aapl:ServicesRevenue",
|
||||
surface_key: "revenue",
|
||||
statement: "income",
|
||||
reason: "local_name_match",
|
||||
source_snapshot_ids: [11, 12],
|
||||
},
|
||||
],
|
||||
rejectedMappings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function sampleStats(): IssuerOverlayStats {
|
||||
return {
|
||||
pack: "core",
|
||||
sampledSnapshotCount: 2,
|
||||
sampledSnapshotIds: [11, 12],
|
||||
acceptedMappingCount: 1,
|
||||
rejectedMappingCount: 0,
|
||||
publishedRevisionNumber: null,
|
||||
};
|
||||
}
|
||||
|
||||
describe("issuer overlay repo", () => {
|
||||
beforeAll(async () => {
|
||||
tempDir = mkdtempSync(join(tmpdir(), "fiscal-issuer-overlay-"));
|
||||
const env = process.env as Record<string, string | undefined>;
|
||||
env.DATABASE_URL = `file:${join(tempDir, "repo.sqlite")}`;
|
||||
env.NODE_ENV = "test";
|
||||
|
||||
resetDbSingletons();
|
||||
|
||||
sqliteClient = new Database(join(tempDir, "repo.sqlite"), { create: true });
|
||||
sqliteClient.exec("PRAGMA foreign_keys = ON;");
|
||||
__dbInternals.ensureLocalSqliteSchema(sqliteClient);
|
||||
|
||||
const globalState = globalThis as typeof globalThis & {
|
||||
__fiscalSqliteClient?: Database;
|
||||
__fiscalDrizzleDb?: unknown;
|
||||
};
|
||||
globalState.__fiscalSqliteClient = sqliteClient;
|
||||
globalState.__fiscalDrizzleDb = undefined;
|
||||
|
||||
overlayRepo = await import("./issuer-overlays");
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
sqliteClient?.close();
|
||||
resetDbSingletons();
|
||||
if (tempDir) {
|
||||
rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
sqliteClient?.exec("DELETE FROM issuer_overlay;");
|
||||
sqliteClient?.exec("DELETE FROM issuer_overlay_revision;");
|
||||
});
|
||||
|
||||
it("creates an empty overlay row on ensure", async () => {
|
||||
if (!overlayRepo) {
|
||||
throw new Error("overlay repo not initialized");
|
||||
}
|
||||
|
||||
const overlay = await overlayRepo.ensureIssuerOverlayRow("aapl");
|
||||
expect(overlay?.ticker).toBe("AAPL");
|
||||
expect(overlay?.status).toBe("empty");
|
||||
expect(overlay?.active_revision).toBeNull();
|
||||
});
|
||||
|
||||
it("publishes and deduplicates overlay revisions by content hash", async () => {
|
||||
if (!overlayRepo) {
|
||||
throw new Error("overlay repo not initialized");
|
||||
}
|
||||
|
||||
const first = await overlayRepo.publishIssuerOverlayRevision({
|
||||
ticker: "AAPL",
|
||||
definition: sampleDefinition(),
|
||||
diagnostics: sampleDiagnostics(),
|
||||
stats: sampleStats(),
|
||||
});
|
||||
const second = await overlayRepo.publishIssuerOverlayRevision({
|
||||
ticker: "AAPL",
|
||||
definition: sampleDefinition(),
|
||||
diagnostics: sampleDiagnostics(),
|
||||
stats: sampleStats(),
|
||||
});
|
||||
const overlay = await overlayRepo.getIssuerOverlay("AAPL");
|
||||
const revisions = await overlayRepo.listIssuerOverlayRevisions("AAPL");
|
||||
|
||||
expect(first.published).toBe(true);
|
||||
expect(second.published).toBe(false);
|
||||
expect(overlay?.active_revision?.id).toBe(first.revision.id);
|
||||
expect(revisions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
@@ -457,7 +457,7 @@ export async function createResearchArtifactRecord(input: {
|
||||
return toResearchArtifact(created);
|
||||
}
|
||||
|
||||
export async function upsertSystemResearchArtifact(input: {
|
||||
async function upsertSystemResearchArtifact(input: {
|
||||
userId: string;
|
||||
organizationId?: string | null;
|
||||
ticker: string;
|
||||
@@ -837,7 +837,7 @@ export async function deleteResearchMemoEvidenceLink(userId: string, memoId: num
|
||||
return rows.length > 0;
|
||||
}
|
||||
|
||||
export async function listResearchMemoEvidenceLinks(userId: string, ticker: string): Promise<ResearchMemoEvidenceLink[]> {
|
||||
async function listResearchMemoEvidenceLinks(userId: string, ticker: string): Promise<ResearchMemoEvidenceLink[]> {
|
||||
const memo = await getResearchMemoByTicker(userId, ticker);
|
||||
if (!memo) {
|
||||
return [];
|
||||
@@ -1116,7 +1116,7 @@ export async function getResearchArtifactFileResponse(userId: string, id: number
|
||||
});
|
||||
}
|
||||
|
||||
export function rebuildResearchArtifactIndex() {
|
||||
function rebuildResearchArtifactIndex() {
|
||||
rebuildArtifactSearchIndex();
|
||||
}
|
||||
|
||||
|
||||
@@ -153,7 +153,7 @@ export async function createTaskRunRecord(input: CreateTaskInput) {
|
||||
});
|
||||
}
|
||||
|
||||
export type AtomicCreateResult =
|
||||
type AtomicCreateResult =
|
||||
| { task: Task; created: true }
|
||||
| { task: null; created: false };
|
||||
|
||||
|
||||
Reference in New Issue
Block a user