diff --git a/CLAUDE.md b/CLAUDE.md index abe5af11..d56628cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -165,6 +165,25 @@ sec issuer deal [--format json] > operate on the company tier. Family-tier coverage/purge wiring is deferred (see the > status doc's deferred cleanups). +### SPAC consolidated report + +A CIK-keyed `spac` row consolidates the SPAC lifecycle for a quick report: +status, three-era names/SIC/tickers (`spac_*` / `post_merger_*` / `current_*`), +amounts (`ipo_proceeds`, `trust_amount`, `pipe_amount`, `total_redemption_amount`), +and rolled-up key dates. It is **derived** from two append-only tables — `spac_deal` +(one row per business-combination attempt) and `spac_event` (the dated timeline) — +so replays are idempotent; an `as_of` guard protects filing-sourced scalar fields +from out-of-order writes, and `spac_history` + `ChangeLog` version the row. + +Today only the IPO half is populated (S-1/DRS → `registration`, priced 424B1/424B4 +→ `ipo`); de-SPAC events (8-K items, S-4/proxy, redemptions, PIPE, de-registration) +are defined-but-deferred slots. + +```bash +sec spac report [--format json] # consolidated report +sec spac history [--format json] # state-change history +``` + ### Reg A / Reg CF / funding portals All 12 Form C submission types (including post-offering C-U / C-AR / C-TR), diff --git a/src/commands/index.ts b/src/commands/index.ts index aff73f61..34af88bd 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -20,6 +20,7 @@ import { addCanonicalCommands } from "../cli/groups/canonical"; import { addExtractorCommands } from "../cli/groups/extractor"; import { registerSponsorFamilyCommands } from "./sponsorFamily"; import { registerUnderwriterFamilyCommands } from "./underwriterFamily"; +import { registerSpacCommands } from "./spac"; import { DefaultDI } from "../config/DefaultDI"; import { EnvToDI } from "../config/EnvToDI"; import { SEC_DRY_RUN } from "../config/tokens"; @@ -70,5 +71,6 @@ export const AddCommands = (program: Command): void => { addCanonicalCommands(program); registerSponsorFamilyCommands(program); registerUnderwriterFamilyCommands(program); + registerSpacCommands(program); addExtractorCommands(program); }; diff --git a/src/commands/spac.test.ts b/src/commands/spac.test.ts new file mode 100644 index 00000000..087003a6 --- /dev/null +++ b/src/commands/spac.test.ts @@ -0,0 +1,34 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { resetDependencyInjectionsForTesting } from "../config/TestingDI"; +import { setupAllDatabases } from "../config/setupAllDatabases"; +import { assembleSpacReport } from "./spac"; +import { SpacReportWriter } from "../storage/spac/SpacReportWriter"; + +describe("assembleSpacReport", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + + it("assembles row + events for a populated SPAC", async () => { + await new SpacReportWriter().recordRegistration({ + cik: 99, + accession_number: "reg", + filing_date: "2020-12-01", + form: "S-1", + primary_document: null, + spac_name: "Test SPAC", + spac_sic: 6770, + }); + const report = await assembleSpacReport(99); + expect(report.spac?.spac_name).toBe("Test SPAC"); + expect(report.events.length).toBe(1); + expect(report.sponsorCount).toBe(0); + }); +}); diff --git a/src/commands/spac.ts b/src/commands/spac.ts new file mode 100644 index 00000000..fb8532f7 --- /dev/null +++ b/src/commands/spac.ts @@ -0,0 +1,113 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Command } from "commander"; +import { globalServiceRegistry } from "workglow"; +import { SpacRepo } from "../storage/spac/SpacRepo"; +import { SPAC_SPONSOR_LINK_REPOSITORY_TOKEN } from "../storage/canonical/SpacSponsorLinkSchema"; +import { UNDERWRITER_LINK_REPOSITORY_TOKEN } from "../storage/canonical/UnderwriterLinkSchema"; + +export interface SpacReport { + readonly cik: number; + readonly spac: Awaited>; + readonly deals: Awaited>; + readonly events: Awaited>; + readonly sponsorCount: number; + readonly underwriterCount: number; +} + +/** + * Assemble the consolidated report from the spac row + deals + events + linked lists. + * Sponsor and underwriter counts are obtained by querying the link tables by issuer_cik, + * since neither SpacSponsorLinkRepo nor UnderwriterLinkRepo exposes a listByIssuer method. + */ +export async function assembleSpacReport(cik: number, repo: SpacRepo = new SpacRepo()): Promise { + const [spac, deals, events] = await Promise.all([ + repo.getSpac(cik), + repo.getDeals(cik), + repo.getEvents(cik), + ]); + + const sponsorStorage = globalServiceRegistry.get(SPAC_SPONSOR_LINK_REPOSITORY_TOKEN); + const underwriterStorage = globalServiceRegistry.get(UNDERWRITER_LINK_REPOSITORY_TOKEN); + + const [sponsorRows, underwriterRows] = await Promise.all([ + sponsorStorage.query({ issuer_cik: cik }).then((r) => r ?? []), + underwriterStorage.query({ issuer_cik: cik }).then((r) => r ?? []), + ]); + + return { + cik, + spac, + deals, + events, + sponsorCount: sponsorRows.length, + underwriterCount: underwriterRows.length, + }; +} + +/** Parse a CLI CIK argument, returning null (after printing an error) when it is not a non-negative integer. */ +function parseCikArg(cikArg: string): number | null { + const cik = Number(cikArg); + if (!Number.isInteger(cik) || cik < 0) { + console.error(`Invalid CIK: ${cikArg}`); + process.exitCode = 1; + return null; + } + return cik; +} + +export function registerSpacCommands(program: Command): void { + // The sponsorFamily module may have already created the `spac` command (for + // `by-family`); reuse it so Commander doesn't see a duplicate subcommand. + let spacCmd = program.commands.find((c) => c.name() === "spac"); + if (!spacCmd) { + spacCmd = program.command("spac").description("SPAC consolidated report and history"); + } + + spacCmd + .command("report ") + .description("Consolidated SPAC report for a CIK") + .option("--format ", "output format: text | json", "text") + .action(async (cikArg: string, opts: { format: string }) => { + const cik = parseCikArg(cikArg); + if (cik === null) return; + const report = await assembleSpacReport(cik); + if (opts.format === "json") { + console.log(JSON.stringify(report, null, 2)); + return; + } + if (!report.spac) { + console.log(`No SPAC record for CIK ${cik}`); + return; + } + const s = report.spac; + console.log(`SPAC ${cik}: ${s.spac_name ?? "(unknown)"} [${s.status}]`); + console.log(` registration: ${s.registration_date ?? "-"} ipo: ${s.ipo_date ?? "-"}`); + console.log(` ipo proceeds: ${s.ipo_proceeds ?? "-"} trust: ${s.trust_amount ?? "-"}`); + console.log(` deals: ${report.deals.length} events: ${report.events.length}`); + console.log(` sponsors: ${report.sponsorCount} underwriters: ${report.underwriterCount}`); + }); + + spacCmd + .command("history ") + .description("State-change history for a SPAC") + .option("--format ", "output format: text | json", "text") + .action(async (cikArg: string, opts: { format: string }) => { + const cik = parseCikArg(cikArg); + if (cik === null) return; + const history = await new SpacRepo().getHistory(cik); + if (opts.format === "json") { + console.log(JSON.stringify(history, null, 2)); + return; + } + for (const h of history) { + console.log( + `${h.valid_from} [${h.status ?? "-"}] via ${h.change_source}${h.valid_to ? "" : " (current)"}` + ); + } + }); +} diff --git a/src/config/DefaultDI.ts b/src/config/DefaultDI.ts index 62d36406..eb41e92e 100644 --- a/src/config/DefaultDI.ts +++ b/src/config/DefaultDI.ts @@ -324,6 +324,26 @@ import { Form8KEventPrimaryKeyNames, Form8KEventSchema, } from "../storage/form-8k-event/Form8KEventSchema"; +import { + SPAC_REPOSITORY_TOKEN, + SpacPrimaryKeyNames, + SpacSchema, +} from "../storage/spac/SpacSchema"; +import { + SPAC_DEAL_REPOSITORY_TOKEN, + SpacDealPrimaryKeyNames, + SpacDealSchema, +} from "../storage/spac/SpacDealSchema"; +import { + SPAC_EVENT_REPOSITORY_TOKEN, + SpacEventPrimaryKeyNames, + SpacEventSchema, +} from "../storage/spac/SpacEventSchema"; +import { + SPAC_HISTORY_REPOSITORY_TOKEN, + SpacHistoryPrimaryKeyNames, + SpacHistorySchema, +} from "../storage/spac/SpacHistorySchema"; import { createStorage } from "./createStorage"; export const DefaultDI = () => { @@ -625,6 +645,27 @@ export const DefaultDI = () => { ]) ); + // ------------------------------ SPAC ------------------------------------------ + globalServiceRegistry.registerInstance( + SPAC_REPOSITORY_TOKEN, + createStorage("spac", SpacSchema, SpacPrimaryKeyNames, [["status"], ["current_cik"]]) + ); + globalServiceRegistry.registerInstance( + SPAC_DEAL_REPOSITORY_TOKEN, + createStorage("spac_deal", SpacDealSchema, SpacDealPrimaryKeyNames, [["cik"], ["outcome"]]) + ); + globalServiceRegistry.registerInstance( + SPAC_EVENT_REPOSITORY_TOKEN, + createStorage("spac_event", SpacEventSchema, SpacEventPrimaryKeyNames, [ + ["cik"], + ["event_type"], + ]) + ); + globalServiceRegistry.registerInstance( + SPAC_HISTORY_REPOSITORY_TOKEN, + createStorage("spac_history", SpacHistorySchema, SpacHistoryPrimaryKeyNames, [["cik"]]) + ); + // ----- Observation / Canonical / Resolver ----- globalServiceRegistry.registerInstance( PERSON_OBSERVATION_REPOSITORY_TOKEN, diff --git a/src/config/TestingDI.ts b/src/config/TestingDI.ts index f1ed0368..8391d2c7 100644 --- a/src/config/TestingDI.ts +++ b/src/config/TestingDI.ts @@ -238,6 +238,26 @@ import { Form8KEventPrimaryKeyNames, Form8KEventSchema, } from "../storage/form-8k-event/Form8KEventSchema"; +import { + SPAC_REPOSITORY_TOKEN, + SpacPrimaryKeyNames, + SpacSchema, +} from "../storage/spac/SpacSchema"; +import { + SPAC_DEAL_REPOSITORY_TOKEN, + SpacDealPrimaryKeyNames, + SpacDealSchema, +} from "../storage/spac/SpacDealSchema"; +import { + SPAC_EVENT_REPOSITORY_TOKEN, + SpacEventPrimaryKeyNames, + SpacEventSchema, +} from "../storage/spac/SpacEventSchema"; +import { + SPAC_HISTORY_REPOSITORY_TOKEN, + SpacHistoryPrimaryKeyNames, + SpacHistorySchema, +} from "../storage/spac/SpacHistorySchema"; import { CANONICAL_COMPANY_REPOSITORY_TOKEN, CanonicalCompanyPrimaryKeyNames, @@ -453,6 +473,27 @@ export function resetDependencyInjectionsForTesting() { ]) ); + // SPAC repositories + globalServiceRegistry.registerInstance( + SPAC_REPOSITORY_TOKEN, + new InMemoryTabularStorage(SpacSchema, SpacPrimaryKeyNames, [["status"], ["current_cik"]]) + ); + globalServiceRegistry.registerInstance( + SPAC_DEAL_REPOSITORY_TOKEN, + new InMemoryTabularStorage(SpacDealSchema, SpacDealPrimaryKeyNames, [["cik"], ["outcome"]]) + ); + globalServiceRegistry.registerInstance( + SPAC_EVENT_REPOSITORY_TOKEN, + new InMemoryTabularStorage(SpacEventSchema, SpacEventPrimaryKeyNames, [ + ["cik"], + ["event_type"], + ]) + ); + globalServiceRegistry.registerInstance( + SPAC_HISTORY_REPOSITORY_TOKEN, + new InMemoryTabularStorage(SpacHistorySchema, SpacHistoryPrimaryKeyNames, [["cik"]]) + ); + // Initialize Crowdfunding repositories globalServiceRegistry.registerInstance( CROWDFUNDING_REPOSITORY_TOKEN, diff --git a/src/config/setupAllDatabases.ts b/src/config/setupAllDatabases.ts index fbdaeddb..48f1e415 100644 --- a/src/config/setupAllDatabases.ts +++ b/src/config/setupAllDatabases.ts @@ -50,6 +50,10 @@ import { REGA_FINANCIAL_DATA_REPOSITORY_TOKEN } from "../storage/reg-a/RegAFinan import { REGA_OFFERING_HISTORY_REPOSITORY_TOKEN } from "../storage/reg-a/RegAOfferingHistorySchema"; import { REGA_OFFERING_REPOSITORY_TOKEN } from "../storage/reg-a/RegAOfferingSchema"; import { REGA_SERVICE_PROVIDER_REPOSITORY_TOKEN } from "../storage/reg-a/RegAServiceProviderSchema"; +import { SPAC_REPOSITORY_TOKEN } from "../storage/spac/SpacSchema"; +import { SPAC_DEAL_REPOSITORY_TOKEN } from "../storage/spac/SpacDealSchema"; +import { SPAC_EVENT_REPOSITORY_TOKEN } from "../storage/spac/SpacEventSchema"; +import { SPAC_HISTORY_REPOSITORY_TOKEN } from "../storage/spac/SpacHistorySchema"; import { CANONICAL_COMPANY_ALIAS_REPOSITORY_TOKEN, CANONICAL_PERSON_ALIAS_REPOSITORY_TOKEN, @@ -137,6 +141,10 @@ export async function setupAllDatabases(): Promise { await globalServiceRegistry.get(REGA_SERVICE_PROVIDER_REPOSITORY_TOKEN).setupDatabase(); await globalServiceRegistry.get(REGA_FINANCIAL_DATA_REPOSITORY_TOKEN).setupDatabase(); await globalServiceRegistry.get(REGA_EQUITY_CLASS_REPOSITORY_TOKEN).setupDatabase(); + await globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN).setupDatabase(); + await globalServiceRegistry.get(SPAC_DEAL_REPOSITORY_TOKEN).setupDatabase(); + await globalServiceRegistry.get(SPAC_EVENT_REPOSITORY_TOKEN).setupDatabase(); + await globalServiceRegistry.get(SPAC_HISTORY_REPOSITORY_TOKEN).setupDatabase(); await globalServiceRegistry.get(CIK_LAST_UPDATE_REPOSITORY_TOKEN).setupDatabase(); await globalServiceRegistry.get(PROCESSED_FACTS_REPOSITORY_TOKEN).setupDatabase(); await globalServiceRegistry.get(PROCESSED_SUBMISSIONS_REPOSITORY_TOKEN).setupDatabase(); diff --git a/src/sec/forms/registration-statements/Form_424.spac-report.test.ts b/src/sec/forms/registration-statements/Form_424.spac-report.test.ts new file mode 100644 index 00000000..dc6007a2 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_424.spac-report.test.ts @@ -0,0 +1,199 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { processFormS1 } from "./Form_S_1.storage"; +import { processForm424 } from "./Form_424.storage"; +import { SpacRepo } from "../../../storage/spac/SpacRepo"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const CIK = 2114227; +const S1_ACCESSION = "0000000000-26-000901"; +const B4_ACCESSION = "0000000000-26-000902"; + +const NULL_HEADER = { + sic: null, + sicDescription: null, + cik: null, + companyName: null, + filingDate: null, +}; + +const SPAC_S1_HEADER = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: CIK, + companyName: "Synthetic SPAC Corp", + filingDate: null, +}; + +const SPAC_424_HEADER = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: CIK, + companyName: "Synthetic SPAC Corp", + filingDate: "20260428", +}; + +// Minimal S-1 HTML body with the three entity sections so the segmenter succeeds. +const S1_HTML = [ + "

MANAGEMENT

Jane Roe — Director

", + "

PRINCIPAL AND SELLING STOCKHOLDERS

None.

", + "

CERTAIN RELATIONSHIPS AND RELATED TRANSACTIONS

None.

", + "

LEGAL MATTERS

x

", +].join(""); + +// 424B4 HTML with offering + underwriting sections that the fake model will respond to. +const OFFERING_HTML = [ + "

THE OFFERING

We are offering 30,000,000 units at $10.00.

", + "

UNDERWRITING

BTIG, LLC is the book-running manager.

", +].join(""); + +let cleanup: (() => void) | undefined; + +describe("processFormS1 + processForm424 → SPAC report", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("registration then priced 424B4 produces an ipo-status row with tickers", async () => { + // S-1 pass: no entity sections yield rows (empty fake responses). + const s1Reg = registerFakeStructuredProvider([ + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + await processFormS1({ + cik: CIK, + file_number: "333-000002", + accession_number: S1_ACCESSION, + filing_date: "2026-04-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { header: SPAC_S1_HEADER, html: S1_HTML, xbrlInstanceXml: null, feeExhibitHtml: null }, + model: fakeS1Model(), + }); + s1Reg.unregister(); + + // 424B4 pass: the offering-terms model call returns SPAC unit terms with tickers. + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Units", + shares_offered: null, + price: null, + price_low: null, + price_high: null, + gross_proceeds: 300000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: 30000000, + price_per_unit: 10, + unit_composition: "one share and one-quarter warrant", + warrant_fraction_per_unit: 0.25, + right_fraction_per_unit: null, + trust_per_unit: 10.0, + over_allotment_units: 4500000, + exchange: "NASDAQ", + par_value: null, + confidence: 0.9, + source_span: "30,000,000 units", + tickers: [ + { ticker: "CCXII.U", exchange: "NASDAQ", security_type: "Units", is_primary: false }, + { ticker: "CCXII", exchange: "NASDAQ", security_type: "Common", is_primary: true }, + { ticker: "CCXII.WS", exchange: "NASDAQ", security_type: "Warrants", is_primary: false }, + ], + }, + { underwriters: [] }, + ]); + cleanup = unregister; + + await processForm424({ + cik: CIK, + file_number: "333-000002", + accession_number: B4_ACCESSION, + filing_date: "2026-04-28", + primary_doc: "424b4.htm", + form: "424B4", + form424: { + header: SPAC_424_HEADER, + html: OFFERING_HTML, + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const row = await new SpacRepo().getSpac(CIK); + expect(row?.status).toBe("ipo"); + expect(row?.ipo_date).toBe("2026-04-28"); + // spac_tickers is stored as a JSON string (per SpacSchema); the history() + // sort puts the primary ticker first, then alphabetical: CCXII, CCXII.U, CCXII.WS. + // All three SPAC-era tickers (common + units + warrants) must be present. + const tickers = row?.spac_tickers != null ? (JSON.parse(row.spac_tickers) as string[]) : null; + expect(tickers).toEqual(["CCXII", "CCXII.U", "CCXII.WS"]); + }); + + it("a non-SPAC priced 424B4 does not create a spac row", async () => { + // No S-1 registration first; process a 424B4 with a non-SPAC (null) header. + const { unregister } = registerFakeStructuredProvider([ + { + security_type: "Common Stock", + shares_offered: 5000000, + price: 15.0, + price_low: null, + price_high: null, + gross_proceeds: 75000000, + net_proceeds: null, + over_allotment_shares: null, + units_offered: null, + price_per_unit: null, + unit_composition: null, + warrant_fraction_per_unit: null, + right_fraction_per_unit: null, + trust_per_unit: null, + over_allotment_units: null, + exchange: "NYSE", + par_value: 0.001, + confidence: 0.9, + source_span: "5,000,000 shares", + tickers: [{ ticker: "ACME", exchange: "NYSE", security_type: "Common Stock", is_primary: true }], + }, + { underwriters: [] }, + ]); + cleanup = unregister; + + const NON_SPAC_CIK = 9999001; + await processForm424({ + cik: NON_SPAC_CIK, + file_number: "333-999001", + accession_number: "0000000000-26-009901", + filing_date: "2026-04-28", + primary_doc: "424b4.htm", + form: "424B4", + form424: { + header: NULL_HEADER, + html: [ + "

THE OFFERING

We are offering 5,000,000 shares at $15.00.

", + "

UNDERWRITING

Goldman Sachs is the underwriter.

", + ].join(""), + xbrlInstanceXml: null, + feeExhibitHtml: null, + }, + model: fakeS1Model(), + }); + + const row = await new SpacRepo().getSpac(NON_SPAC_CIK); + expect(row).toBeUndefined(); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_424.storage.ts b/src/sec/forms/registration-statements/Form_424.storage.ts index 80d1f24a..32c1b99e 100644 --- a/src/sec/forms/registration-statements/Form_424.storage.ts +++ b/src/sec/forms/registration-statements/Form_424.storage.ts @@ -8,6 +8,9 @@ import { globalServiceRegistry, type ModelConfig } from "workglow"; import { buildEntityObserver } from "../../../resolver/buildEntityObserver"; import { ExtractionDeadLetterRepo } from "../../../storage/dead-letter/ExtractionDeadLetterRepo"; import { ObservationProvenanceRepo } from "../../../storage/provenance/ObservationProvenanceRepo"; +import { IssuerTickerRepo } from "../../../storage/offering/IssuerTickerRepo"; +import { SpacUnitTermsRepo } from "../../../storage/offering/SpacUnitTermsRepo"; +import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter"; import { COMPONENT_VERSION_REPOSITORY_TOKEN } from "../../../storage/versioning/ComponentVersionSchema"; import { VersionRegistry } from "../../../storage/versioning/VersionRegistry"; import { getActiveSlot } from "../../../storage/versioning/getActiveSlot"; @@ -155,4 +158,26 @@ export async function processForm424(args: ProcessForm424Args): Promise { activeUnderwriterFamilyVersion, byName, }); + + // Consolidated SPAC report: record the IPO event for priced SPAC prospectuses. + if (isSpac) { + const unitTerms = await new SpacUnitTermsRepo().get(EXTRACTOR_ID, accession_number); + const tickerRows = (await new IssuerTickerRepo().history(cik)).filter( + (t) => t.accession_number === accession_number + ); + const tickers = [...new Set(tickerRows.map((t) => t.ticker))]; + await new SpacReportWriter().recordIpo({ + cik, + accession_number, + filing_date: args.filing_date, + form, + primary_document: null, + ipo_proceeds: unitTerms?.gross_proceeds ?? null, + trust_amount: + unitTerms?.trust_per_unit != null && unitTerms?.units_offered != null + ? unitTerms.trust_per_unit * unitTerms.units_offered + : null, + spac_tickers: tickers.length > 0 ? tickers : null, + }); + } } diff --git a/src/sec/forms/registration-statements/Form_S_1.spac-report.test.ts b/src/sec/forms/registration-statements/Form_S_1.spac-report.test.ts new file mode 100644 index 00000000..242909f9 --- /dev/null +++ b/src/sec/forms/registration-statements/Form_S_1.spac-report.test.ts @@ -0,0 +1,103 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { resetDependencyInjectionsForTesting } from "../../../config/TestingDI"; +import { setupAllDatabases } from "../../../config/setupAllDatabases"; +import { processFormS1 } from "./Form_S_1.storage"; +import { SpacRepo } from "../../../storage/spac/SpacRepo"; +import { fakeS1Model, registerFakeStructuredProvider } from "./s1/testing/fakeStructuredProvider"; + +const SPAC_CIK = 1821595; +const NON_SPAC_CIK = 1018724; + +const SPAC_HEADER = { + sic: 6770, + sicDescription: "BLANK CHECKS", + cik: SPAC_CIK, + companyName: "Test SPAC Corp", + filingDate: null, +}; + +const NULL_HEADER = { + sic: null, + sicDescription: null, + cik: null, + companyName: null, + filingDate: null, +}; + +// Minimal HTML body — enough for the segmenter to parse without throwing. +const MINIMAL_HTML = [ + "

MANAGEMENT

Jane Roe — Director

", + "

PRINCIPAL AND SELLING STOCKHOLDERS

None.

", + "

CERTAIN RELATIONSHIPS AND RELATED TRANSACTIONS

None.

", + "

LEGAL MATTERS

x

", +].join(""); + +let cleanup: (() => void) | undefined; + +describe("processFormS1 → SPAC report", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + afterEach(() => { + cleanup?.(); + cleanup = undefined; + resetDependencyInjectionsForTesting(); + }); + + it("creates a registered SPAC row when the S-1 header has SIC 6770", async () => { + const { unregister } = registerFakeStructuredProvider([ + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + cleanup = unregister; + + const FILING_DATE = "2020-12-21"; + await processFormS1({ + cik: SPAC_CIK, + file_number: "333-1", + accession_number: "0000000000-20-000001", + filing_date: FILING_DATE, + primary_doc: "s1.htm", + form: "S-1", + formS1: { header: SPAC_HEADER, html: MINIMAL_HTML, xbrlInstanceXml: null, feeExhibitHtml: null }, + model: fakeS1Model(), + }); + + const row = await new SpacRepo().getSpac(SPAC_CIK); + expect(row?.status).toBe("registered"); + expect(row?.spac_sic).toBe(6770); + expect(row?.registration_date).toBe(FILING_DATE); + expect(row?.spac_name).not.toBeNull(); + }); + + it("creates no spac row when the S-1 has a non-SPAC (null SIC) header", async () => { + const { unregister } = registerFakeStructuredProvider([ + { people: [] }, + { owners: [] }, + { parties: [] }, + ]); + cleanup = unregister; + + await processFormS1({ + cik: NON_SPAC_CIK, + file_number: "333-2", + accession_number: "0000000000-26-000001", + filing_date: "2026-01-02", + primary_doc: "s1.htm", + form: "S-1", + formS1: { header: NULL_HEADER, html: MINIMAL_HTML, xbrlInstanceXml: null, feeExhibitHtml: null }, + model: fakeS1Model(), + }); + + const row = await new SpacRepo().getSpac(NON_SPAC_CIK); + expect(row).toBeUndefined(); + }); +}); diff --git a/src/sec/forms/registration-statements/Form_S_1.storage.ts b/src/sec/forms/registration-statements/Form_S_1.storage.ts index 37bc7a49..2c00692f 100644 --- a/src/sec/forms/registration-statements/Form_S_1.storage.ts +++ b/src/sec/forms/registration-statements/Form_S_1.storage.ts @@ -20,6 +20,7 @@ import { CanonicalSponsorFamilyAliasRepo } from "../../../storage/canonical/Cano import { SponsorFamilyResolver } from "../../../resolver/SponsorFamilyResolver"; import { SponsorFamilyMembershipRepo } from "../../../storage/canonical/SponsorFamilyMembershipRepo"; import { SpacSponsorLinkRepo } from "../../../storage/canonical/SpacSponsorLinkRepo"; +import { SpacReportWriter } from "../../../storage/spac/SpacReportWriter"; import type { FormS1Parsed } from "./Form_S_1"; import { parseEdgarHtml } from "../../html/parseEdgarHtml"; import { DocumentTreeSegmenter } from "./s1/DocumentTreeSegmenter"; @@ -152,6 +153,19 @@ export async function processFormS1(args: ProcessFormS1Args): Promise { created_at: new Date().toISOString(), }); + // Consolidated SPAC report: record the registration event (SPAC filings only). + if (isSpac) { + await new SpacReportWriter().recordRegistration({ + cik, + accession_number, + filing_date: args.filing_date, + form: args.form, + primary_document: null, + spac_name: xbrl.name ?? formS1.header?.companyName ?? null, + spac_sic: headerSic, + }); + } + const recordFail = (section: string, reason: string, detail: string | null) => deadLetters.record({ extractor_id: EXTRACTOR_ID, diff --git a/src/storage/change-tracking/ChangeLogSchema.ts b/src/storage/change-tracking/ChangeLogSchema.ts index d2bcff78..82dea135 100644 --- a/src/storage/change-tracking/ChangeLogSchema.ts +++ b/src/storage/change-tracking/ChangeLogSchema.ts @@ -29,6 +29,7 @@ export const ChangeLogSchema = Type.Object({ Type.Literal("entity_person_junction"), Type.Literal("entity_phone_junction"), Type.Literal("crowdfunding"), + Type.Literal("spac"), ], { description: "Type of entity that changed", diff --git a/src/storage/spac/SpacDealSchema.ts b/src/storage/spac/SpacDealSchema.ts new file mode 100644 index 00000000..88e3e082 --- /dev/null +++ b/src/storage/spac/SpacDealSchema.ts @@ -0,0 +1,46 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import type { ITabularStorage } from "workglow"; +import { createServiceToken } from "workglow"; +import { TypeNullable, TypeStringEnum } from "../../util/TypeBoxUtil"; + +/** Outcome of a business-combination attempt. */ +export const SPAC_DEAL_OUTCOMES = ["pending", "completed", "terminated"] as const; +export type SpacDealOutcome = (typeof SPAC_DEAL_OUTCOMES)[number]; + +/** One row per business-combination attempt. Append-only; terminated attempts are retained. */ +export const SpacDealSchema = Type.Object({ + cik: Type.Integer({ minimum: 0, description: "SPAC origin CIK" }), + deal_index: Type.Integer({ minimum: 0, description: "0-based ordinal of the attempt" }), + target_name: TypeNullable(Type.String({ maxLength: 200 })), + target_cik: TypeNullable(Type.Integer({ minimum: 0 })), + announced_date: TypeNullable(Type.String({ format: "date" })), + definitive_agreement_date: TypeNullable(Type.String({ format: "date" })), + proxy_date: TypeNullable(Type.String({ format: "date" })), + vote_date: TypeNullable(Type.String({ format: "date" })), + pipe_amount: TypeNullable(Type.Number()), + redemption_amount: TypeNullable(Type.Number()), + redemption_shares: TypeNullable(Type.Integer({ minimum: 0 })), + outcome: TypeStringEnum(SPAC_DEAL_OUTCOMES, { description: "pending | completed | terminated" }), + outcome_date: TypeNullable(Type.String({ format: "date" })), + source_accession: TypeNullable(Type.String({ maxLength: 25 })), + created_at: Type.String({ format: "date-time" }), +}); + +export type SpacDeal = Static; + +export const SpacDealPrimaryKeyNames = ["cik", "deal_index"] as const; +export type SpacDealRepositoryStorage = ITabularStorage< + typeof SpacDealSchema, + typeof SpacDealPrimaryKeyNames, + SpacDeal +>; + +export const SPAC_DEAL_REPOSITORY_TOKEN = createServiceToken( + "sec.storage.spacDealRepository" +); diff --git a/src/storage/spac/SpacEventSchema.ts b/src/storage/spac/SpacEventSchema.ts new file mode 100644 index 00000000..e8c4f8f9 --- /dev/null +++ b/src/storage/spac/SpacEventSchema.ts @@ -0,0 +1,61 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import type { ITabularStorage } from "workglow"; +import { createServiceToken } from "workglow"; +import { TypeNullable, TypeStringEnum } from "../../util/TypeBoxUtil"; + +/** Lifecycle event vocabulary. Only `registration` and `ipo` are written today. */ +export const SPAC_EVENT_TYPES = [ + "registration", + "ipo", + "unit_split", + "definitive_agreement", + "proxy", + "vote", + "redemption", + "pipe", + "completed", + "terminated", + "liquidation", + "deregistration", + "name_change", + "ticker_change", +] as const; +export type SpacEventType = (typeof SPAC_EVENT_TYPES)[number]; + +/** Append-only lifecycle event; one row per dated event tied to a filing. */ +export const SpacEventSchema = Type.Object({ + cik: Type.Integer({ minimum: 0, description: "SPAC origin CIK" }), + accession_number: Type.String({ maxLength: 25 }), + event_type: TypeStringEnum(SPAC_EVENT_TYPES, { description: "Event type" }), + event_date: Type.String({ format: "date" }), + form: TypeNullable(Type.String({ maxLength: 20 })), + primary_document: TypeNullable(Type.String({ maxLength: 200 })), + source_document_url: TypeNullable( + Type.String({ maxLength: 500, description: "Sub-document URL, e.g. an EX-99.1 investor presentation" }) + ), + deal_index: TypeNullable(Type.Integer({ minimum: 0, description: "Associated spac_deal attempt" })), + amount: TypeNullable(Type.Number()), + shares: TypeNullable(Type.Integer({ minimum: 0 })), + detail: TypeNullable(Type.String({ maxLength: 1024 })), + confidence: TypeNullable(Type.Number()), + created_at: Type.String({ format: "date-time" }), +}); + +export type SpacEvent = Static; + +export const SpacEventPrimaryKeyNames = ["cik", "accession_number", "event_type"] as const; +export type SpacEventRepositoryStorage = ITabularStorage< + typeof SpacEventSchema, + typeof SpacEventPrimaryKeyNames, + SpacEvent +>; + +export const SPAC_EVENT_REPOSITORY_TOKEN = createServiceToken( + "sec.storage.spacEventRepository" +); diff --git a/src/storage/spac/SpacHistorySchema.ts b/src/storage/spac/SpacHistorySchema.ts new file mode 100644 index 00000000..8398ff82 --- /dev/null +++ b/src/storage/spac/SpacHistorySchema.ts @@ -0,0 +1,56 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import type { ITabularStorage } from "workglow"; +import { createServiceToken } from "workglow"; +import { TypeNullable } from "../../util/TypeBoxUtil"; + +/** Versioned snapshot of the mutable `spac` row for point-in-time reconstruction. */ +export const SpacHistorySchema = Type.Object({ + cik: Type.Integer({ minimum: 0 }), + valid_from: Type.String({ format: "date-time", description: "When this version became valid" }), + valid_to: TypeNullable(Type.String({ format: "date-time", description: "null = current" })), + status: TypeNullable(Type.String({ maxLength: 20 })), + current_cik: TypeNullable(Type.Integer({ minimum: 0 })), + spac_name: TypeNullable(Type.String({ maxLength: 200 })), + target_name: TypeNullable(Type.String({ maxLength: 200 })), + surviving_name: TypeNullable(Type.String({ maxLength: 200 })), + current_name: TypeNullable(Type.String({ maxLength: 200 })), + spac_sic: TypeNullable(Type.Integer({ minimum: 0 })), + post_merger_sic: TypeNullable(Type.Integer({ minimum: 0 })), + current_sic: TypeNullable(Type.Integer({ minimum: 0 })), + spac_tickers: TypeNullable(Type.String()), + post_merger_tickers: TypeNullable(Type.String()), + current_tickers: TypeNullable(Type.String()), + ipo_proceeds: TypeNullable(Type.Number()), + trust_amount: TypeNullable(Type.Number()), + pipe_amount: TypeNullable(Type.Number()), + total_redemption_amount: TypeNullable(Type.Number()), + registration_date: TypeNullable(Type.String({ format: "date" })), + ipo_date: TypeNullable(Type.String({ format: "date" })), + unit_split_date: TypeNullable(Type.String({ format: "date" })), + definitive_agreement_date: TypeNullable(Type.String({ format: "date" })), + proxy_date: TypeNullable(Type.String({ format: "date" })), + vote_date: TypeNullable(Type.String({ format: "date" })), + completed_date: TypeNullable(Type.String({ format: "date" })), + failed_date: TypeNullable(Type.String({ format: "date" })), + change_source: Type.String({ maxLength: 50, description: "Form/accession that drove the change" }), + change_date: Type.String({ format: "date-time" }), +}); + +export type SpacHistory = Static; + +export const SpacHistoryPrimaryKeyNames = ["cik", "valid_from"] as const; +export type SpacHistoryRepositoryStorage = ITabularStorage< + typeof SpacHistorySchema, + typeof SpacHistoryPrimaryKeyNames, + SpacHistory +>; + +export const SPAC_HISTORY_REPOSITORY_TOKEN = createServiceToken( + "sec.storage.spacHistoryRepository" +); diff --git a/src/storage/spac/SpacRepo.test.ts b/src/storage/spac/SpacRepo.test.ts new file mode 100644 index 00000000..0756c973 --- /dev/null +++ b/src/storage/spac/SpacRepo.test.ts @@ -0,0 +1,42 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { resetDependencyInjectionsForTesting } from "../../config/TestingDI"; +import { setupAllDatabases } from "../../config/setupAllDatabases"; +import { SpacRepo } from "./SpacRepo"; +import type { SpacEvent } from "./SpacEventSchema"; + +function event(partial: Partial & Pick): SpacEvent { + return { + form: null, + primary_document: null, + source_document_url: null, + deal_index: null, + amount: null, + shares: null, + detail: null, + confidence: null, + created_at: new Date().toISOString(), + ...partial, + }; +} + +describe("SpacRepo", () => { + let repo: SpacRepo; + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + repo = new SpacRepo(); + }); + + it("returns events ascending by event_date", async () => { + await repo.saveEvent(event({ cik: 1, accession_number: "b", event_type: "ipo", event_date: "2021-02-01" })); + await repo.saveEvent(event({ cik: 1, accession_number: "a", event_type: "registration", event_date: "2020-12-01" })); + const events = await repo.getEvents(1); + expect(events.map((e) => e.event_type)).toEqual(["registration", "ipo"]); + }); +}); diff --git a/src/storage/spac/SpacRepo.ts b/src/storage/spac/SpacRepo.ts new file mode 100644 index 00000000..ca16be50 --- /dev/null +++ b/src/storage/spac/SpacRepo.ts @@ -0,0 +1,84 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { globalServiceRegistry } from "workglow"; +import { Spac, SpacStatus, SPAC_REPOSITORY_TOKEN, SpacRepositoryStorage } from "./SpacSchema"; +import { SpacDeal, SPAC_DEAL_REPOSITORY_TOKEN, SpacDealRepositoryStorage } from "./SpacDealSchema"; +import { SpacEvent, SPAC_EVENT_REPOSITORY_TOKEN, SpacEventRepositoryStorage } from "./SpacEventSchema"; +import { + SpacHistory, + SPAC_HISTORY_REPOSITORY_TOKEN, + SpacHistoryRepositoryStorage, +} from "./SpacHistorySchema"; + +interface SpacRepoOptions { + spacRepository?: SpacRepositoryStorage; + dealRepository?: SpacDealRepositoryStorage; + eventRepository?: SpacEventRepositoryStorage; + historyRepository?: SpacHistoryRepositoryStorage; +} + +/** Aggregates the four SPAC tables behind one repository. */ +export class SpacRepo { + readonly spacRepository: SpacRepositoryStorage; + readonly dealRepository: SpacDealRepositoryStorage; + readonly eventRepository: SpacEventRepositoryStorage; + readonly historyRepository: SpacHistoryRepositoryStorage; + + constructor(options: SpacRepoOptions = {}) { + this.spacRepository = options.spacRepository ?? globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN); + this.dealRepository = options.dealRepository ?? globalServiceRegistry.get(SPAC_DEAL_REPOSITORY_TOKEN); + this.eventRepository = + options.eventRepository ?? globalServiceRegistry.get(SPAC_EVENT_REPOSITORY_TOKEN); + this.historyRepository = + options.historyRepository ?? globalServiceRegistry.get(SPAC_HISTORY_REPOSITORY_TOKEN); + } + + async getSpac(cik: number): Promise { + return this.spacRepository.get({ cik }); + } + + async saveSpac(row: Spac): Promise { + await this.spacRepository.put(row); + } + + async getSpacsByStatus(status: SpacStatus): Promise { + return (await this.spacRepository.query({ status })) || []; + } + + async saveDeal(deal: SpacDeal): Promise { + await this.dealRepository.put(deal); + } + + /** Deals for a CIK, ascending by deal_index. */ + async getDeals(cik: number): Promise { + const rows = (await this.dealRepository.query({ cik })) || []; + return rows.sort((a, b) => a.deal_index - b.deal_index); + } + + async saveEvent(event: SpacEvent): Promise { + await this.eventRepository.put(event); + } + + /** Events for a CIK, ascending by event_date then created_at. */ + async getEvents(cik: number): Promise { + const rows = (await this.eventRepository.query({ cik })) || []; + return rows.sort( + (a, b) => + a.event_date.localeCompare(b.event_date) || a.created_at.localeCompare(b.created_at) + ); + } + + async saveHistory(history: SpacHistory): Promise { + await this.historyRepository.put(history); + } + + /** History snapshots for a CIK, ascending by valid_from. */ + async getHistory(cik: number): Promise { + const rows = (await this.historyRepository.query({ cik })) || []; + return rows.sort((a, b) => a.valid_from.localeCompare(b.valid_from)); + } +} diff --git a/src/storage/spac/SpacReportWriter.test.ts b/src/storage/spac/SpacReportWriter.test.ts new file mode 100644 index 00000000..998fb37f --- /dev/null +++ b/src/storage/spac/SpacReportWriter.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { globalServiceRegistry } from "workglow"; +import { resetDependencyInjectionsForTesting } from "../../config/TestingDI"; +import { setupAllDatabases } from "../../config/setupAllDatabases"; +import { SpacRepo } from "./SpacRepo"; +import { SpacReportWriter } from "./SpacReportWriter"; +import { CHANGE_LOG_REPOSITORY_TOKEN } from "../change-tracking/ChangeLogSchema"; + +describe("SpacReportWriter", () => { + let repo: SpacRepo; + let writer: SpacReportWriter; + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + repo = new SpacRepo(); + writer = new SpacReportWriter(); + }); + + it("records registration then ipo and rolls the row forward", async () => { + await writer.recordRegistration({ + cik: 5, + accession_number: "0000-reg", + filing_date: "2020-12-01", + form: "S-1", + primary_document: "s1.htm", + spac_name: "Foo SPAC", + spac_sic: 6770, + }); + let row = await repo.getSpac(5); + expect(row?.status).toBe("registered"); + expect(row?.registration_date).toBe("2020-12-01"); + + await writer.recordIpo({ + cik: 5, + accession_number: "0000-ipo", + filing_date: "2021-01-15", + form: "424B4", + primary_document: "424.htm", + ipo_proceeds: 200_000_000, + trust_amount: 200_000_000, + spac_tickers: ["FOO.U", "FOO", "FOO.WS"], + }); + row = await repo.getSpac(5); + expect(row?.status).toBe("ipo"); + expect(row?.ipo_date).toBe("2021-01-15"); + expect(row?.ipo_proceeds).toBe(200_000_000); + expect(JSON.parse(row!.spac_tickers!)).toEqual(["FOO.U", "FOO", "FOO.WS"]); + expect(row?.spac_name).toBe("Foo SPAC"); // merged, not clobbered by the IPO filing + }); + + it("an out-of-order older registration replay does not regress the row", async () => { + await writer.recordIpo({ + cik: 6, + accession_number: "0000-ipo", + filing_date: "2021-01-15", + form: "424B4", + primary_document: "424.htm", + ipo_proceeds: 200_000_000, + trust_amount: 200_000_000, + spac_tickers: ["BAR.U"], + }); + const before = await repo.getSpac(6); + expect(before?.as_of).toBe("2021-01-15"); + + await writer.recordRegistration({ + cik: 6, + accession_number: "0000-reg", + filing_date: "2020-12-01", // older + form: "S-1", + primary_document: "s1.htm", + spac_name: "Bar SPAC", + spac_sic: 6770, + }); + const after = await repo.getSpac(6); + expect(after?.as_of).toBe("2021-01-15"); // anchor not regressed + expect(after?.registration_date).toBe("2020-12-01"); // but the event date still rolls in + expect(after?.ipo_proceeds).toBe(200_000_000); // IPO scalars preserved + expect(after?.spac_name).toBe("Bar SPAC"); // name was null, fills from the older filing + }); + + it("writes history snapshots and ChangeLog rows on change", async () => { + await writer.recordRegistration({ + cik: 7, + accession_number: "0000-reg", + filing_date: "2020-12-01", + form: "S-1", + primary_document: "s1.htm", + spac_name: "Baz SPAC", + spac_sic: 6770, + }); + const history = await repo.getHistory(7); + expect(history.length).toBe(1); + expect(history[0].valid_to).toBeNull(); + const changeLog = globalServiceRegistry.get(CHANGE_LOG_REPOSITORY_TOKEN); + const changes = (await changeLog.query({ entity_type: "spac", entity_id: "7" })) || []; + expect(changes.length).toBeGreaterThan(0); + }); + + it("keeps a coherent history chain when two writes land in the same millisecond", async () => { + // Freeze the no-arg clock so registration and ipo get an identical + // updated_at, forcing a (cik, valid_from) collision in the history table. + // Explicit-arg `new Date(ms)` (used to bump the colliding timestamp) stays real. + const RealDate = Date; + const FIXED = RealDate.parse("2026-06-01T00:00:00.000Z"); + class FakeDate extends RealDate { + constructor(...args: ConstructorParameters | []) { + if (args.length === 0) super(FIXED); + else super(...(args as ConstructorParameters)); + } + static now(): number { + return FIXED; + } + } + globalThis.Date = FakeDate as DateConstructor; + try { + await writer.recordRegistration({ + cik: 8, + accession_number: "0000-reg", + filing_date: "2020-12-01", + form: "S-1", + primary_document: "s1.htm", + spac_name: "Same MS SPAC", + spac_sic: 6770, + }); + await writer.recordIpo({ + cik: 8, + accession_number: "0000-ipo", + filing_date: "2021-01-15", + form: "424B4", + primary_document: "424.htm", + ipo_proceeds: 100_000_000, + trust_amount: 100_000_000, + spac_tickers: ["SMS.U"], + }); + } finally { + globalThis.Date = RealDate; + } + + const history = await repo.getHistory(8); + // Both changes are retained (no silent overwrite), and exactly one row is open. + expect(history.length).toBe(2); + expect(history.filter((h) => h.valid_to == null).length).toBe(1); + const closed = history.find((h) => h.valid_to != null); + const openRow = history.find((h) => h.valid_to == null); + expect(closed).toBeDefined(); + // The closed row hands off to the open row at the same instant (contiguous). + expect(closed!.valid_to).toBe(openRow!.valid_from); + expect(closed!.valid_from < openRow!.valid_from).toBe(true); + }); + + it("does not erase existing tickers when a later filing carries none", async () => { + await writer.recordIpo({ + cik: 9, + accession_number: "0000-ipo", + filing_date: "2021-01-15", + form: "424B4", + primary_document: "424.htm", + ipo_proceeds: 100_000_000, + trust_amount: 100_000_000, + spac_tickers: ["NEO.U", "NEO"], + }); + // A same-or-newer reprocess that found no tickers must NOT clobber them. + await writer.recordIpo({ + cik: 9, + accession_number: "0000-ipo", + filing_date: "2021-02-01", + form: "424B4", + primary_document: "424.htm", + ipo_proceeds: 100_000_000, + trust_amount: 100_000_000, + spac_tickers: null, + }); + const row = await repo.getSpac(9); + expect(JSON.parse(row!.spac_tickers!)).toEqual(["NEO.U", "NEO"]); + }); +}); diff --git a/src/storage/spac/SpacReportWriter.ts b/src/storage/spac/SpacReportWriter.ts new file mode 100644 index 00000000..00efef47 --- /dev/null +++ b/src/storage/spac/SpacReportWriter.ts @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { globalServiceRegistry, uuid4 } from "workglow"; +import { SpacRepo } from "./SpacRepo"; +import { buildSpacRow, type SpacRowPatch } from "./spacRollup"; +import type { Spac } from "./SpacSchema"; +import type { SpacEvent } from "./SpacEventSchema"; +import type { SpacHistory } from "./SpacHistorySchema"; +import { CHANGE_LOG_REPOSITORY_TOKEN } from "../change-tracking/ChangeLogSchema"; + +interface RecordRegistrationArgs { + readonly cik: number; + readonly accession_number: string; + readonly filing_date: string; + readonly form: string; + readonly primary_document: string | null; + readonly spac_name: string | null; + readonly spac_sic: number | null; +} + +interface RecordIpoArgs { + readonly cik: number; + readonly accession_number: string; + readonly filing_date: string; + readonly form: string; + readonly primary_document: string | null; + readonly ipo_proceeds: number | null; + readonly trust_amount: number | null; + readonly spac_tickers: readonly string[] | null; +} + +/** Fields compared for ChangeLog/history; everything except the volatile timestamp. */ +const TRACKED_FIELDS: readonly (keyof Spac)[] = [ + "current_cik", "status", "spac_name", "target_name", "surviving_name", "current_name", + "spac_sic", "post_merger_sic", "current_sic", "spac_tickers", "post_merger_tickers", + "current_tickers", "ipo_proceeds", "trust_amount", "pipe_amount", "total_redemption_amount", + "registration_date", "ipo_date", "unit_split_date", "definitive_agreement_date", "proxy_date", + "vote_date", "completed_date", "failed_date", +]; + +/** + * Orchestrates SPAC row writes: append the event, rebuild the row from the + * append-only deals/events under the `as_of` guard, and snapshot history + + * ChangeLog when tracked fields change. + */ +export class SpacReportWriter { + private readonly repo: SpacRepo; + + constructor(repo: SpacRepo = new SpacRepo()) { + this.repo = repo; + } + + async recordRegistration(args: RecordRegistrationArgs): Promise { + await this.appendEvent({ + cik: args.cik, + accession_number: args.accession_number, + event_type: "registration", + event_date: args.filing_date, + form: args.form, + primary_document: args.primary_document, + }); + await this.rebuild(args.cik, args.filing_date, `${args.form}:${args.accession_number}`, { + spac_name: args.spac_name, + spac_sic: args.spac_sic, + }); + } + + async recordIpo(args: RecordIpoArgs): Promise { + await this.appendEvent({ + cik: args.cik, + accession_number: args.accession_number, + event_type: "ipo", + event_date: args.filing_date, + form: args.form, + primary_document: args.primary_document, + amount: args.ipo_proceeds, + }); + await this.rebuild(args.cik, args.filing_date, `${args.form}:${args.accession_number}`, { + ipo_proceeds: args.ipo_proceeds, + trust_amount: args.trust_amount, + spac_tickers: + args.spac_tickers && args.spac_tickers.length > 0 + ? JSON.stringify(args.spac_tickers) + : null, + }); + } + + private async appendEvent( + partial: Pick & + Partial + ): Promise { + await this.repo.saveEvent({ + source_document_url: null, + deal_index: null, + amount: null, + shares: null, + detail: null, + confidence: null, + created_at: new Date().toISOString(), + ...partial, + }); + } + + private async rebuild( + cik: number, + filingDate: string, + changeSource: string, + patch: SpacRowPatch + ): Promise { + const existing = await this.repo.getSpac(cik); + const [deals, events] = await Promise.all([this.repo.getDeals(cik), this.repo.getEvents(cik)]); + const next = buildSpacRow({ existing, cik, deals, events, patch, filingDate }); + await this.repo.saveSpac(next); + await this.snapshot(existing, next, changeSource); + } + + private async snapshot(prev: Spac | undefined, next: Spac, changeSource: string): Promise { + const changed = TRACKED_FIELDS.filter((f) => (prev ? prev[f] : null) !== next[f]); + if (changed.length === 0) return; + + // Close the open history row, then append the new snapshot. Guarantee a + // strictly increasing valid_from: two writes for the same CIK in the same + // millisecond would otherwise collide on the (cik, valid_from) primary key + // and the new snapshot would overwrite the just-closed row, losing history. + const history = await this.repo.getHistory(next.cik); + const open = history.find((h) => h.valid_to == null); + let validFrom = next.updated_at; + if (open && validFrom <= open.valid_from) { + validFrom = new Date(Date.parse(open.valid_from) + 1).toISOString(); + } + if (open) { + await this.repo.saveHistory({ ...open, valid_to: validFrom }); + } + await this.repo.saveHistory(this.toHistory(next, validFrom, changeSource)); + + const changeLog = globalServiceRegistry.get(CHANGE_LOG_REPOSITORY_TOKEN); + for (const field of changed) { + await changeLog.put({ + change_id: uuid4(), + entity_type: "spac", + entity_id: String(next.cik), + field_name: String(field), + old_value: prev ? serialize(prev[field]) : null, + new_value: serialize(next[field]), + change_type: prev ? "update" : "create", + change_source: changeSource, + change_date: validFrom, + filing_accession_number: null, + batch_id: null, + user_id: null, + metadata: null, + }); + } + } + + private toHistory(row: Spac, validFrom: string, changeSource: string): SpacHistory { + return { + cik: row.cik, + valid_from: validFrom, + valid_to: null, + status: row.status, + current_cik: row.current_cik, + spac_name: row.spac_name, + target_name: row.target_name, + surviving_name: row.surviving_name, + current_name: row.current_name, + spac_sic: row.spac_sic, + post_merger_sic: row.post_merger_sic, + current_sic: row.current_sic, + spac_tickers: row.spac_tickers, + post_merger_tickers: row.post_merger_tickers, + current_tickers: row.current_tickers, + ipo_proceeds: row.ipo_proceeds, + trust_amount: row.trust_amount, + pipe_amount: row.pipe_amount, + total_redemption_amount: row.total_redemption_amount, + registration_date: row.registration_date, + ipo_date: row.ipo_date, + unit_split_date: row.unit_split_date, + definitive_agreement_date: row.definitive_agreement_date, + proxy_date: row.proxy_date, + vote_date: row.vote_date, + completed_date: row.completed_date, + failed_date: row.failed_date, + change_source: changeSource, + change_date: validFrom, + }; + } +} + +function serialize(value: unknown): string | null { + if (value === null || value === undefined) return null; + return typeof value === "string" ? value : JSON.stringify(value); +} diff --git a/src/storage/spac/SpacSchema.ts b/src/storage/spac/SpacSchema.ts new file mode 100644 index 00000000..3d57ed6a --- /dev/null +++ b/src/storage/spac/SpacSchema.ts @@ -0,0 +1,91 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Static, Type } from "typebox"; +import type { ITabularStorage } from "workglow"; +import { createServiceToken } from "workglow"; +import { TypeNullable, TypeStringEnum } from "../../util/TypeBoxUtil"; + +/** Lifecycle status of a SPAC. Terminal states: completed, liquidated, withdrawn. */ +export const SPAC_STATUSES = [ + "registered", + "ipo", + "searching", + "deal_announced", + "proxy", + "completed", + "liquidated", + "withdrawn", +] as const; +export type SpacStatus = (typeof SPAC_STATUSES)[number]; + +/** + * Mutable consolidated SPAC report row (one per SPAC, keyed by origin CIK). + * Event/deal-derived fields are recomputed by {@link buildSpacRow}; filing-sourced + * scalar fields are merged under the `as_of` out-of-order guard. + */ +export const SpacSchema = Type.Object({ + cik: Type.Integer({ minimum: 0, description: "SPAC origin CIK (primary key)" }), + current_cik: TypeNullable( + Type.Integer({ minimum: 0, description: "Surviving entity CIK if it differs from cik" }) + ), + status: TypeStringEnum(SPAC_STATUSES, { description: "Lifecycle status" }), + + // Names (three eras + close-time snapshot) + spac_name: TypeNullable(Type.String({ maxLength: 200, description: "Blank-check shell name at IPO" })), + target_name: TypeNullable(Type.String({ maxLength: 200, description: "Active deal's target name" })), + surviving_name: TypeNullable( + Type.String({ maxLength: 200, description: "Combined entity name as of de-SPAC close (snapshot)" }) + ), + current_name: TypeNullable(Type.String({ maxLength: 200, description: "Latest known name" })), + + // SIC (three eras) + spac_sic: TypeNullable(Type.Integer({ minimum: 0, description: "SIC at IPO (≈6770)" })), + post_merger_sic: TypeNullable(Type.Integer({ minimum: 0, description: "SIC at de-SPAC close" })), + current_sic: TypeNullable(Type.Integer({ minimum: 0, description: "Latest SIC" })), + + // Tickers (three eras; JSON-encoded string arrays) + spac_tickers: TypeNullable(Type.String({ description: "JSON string[] of SPAC-era tickers" })), + post_merger_tickers: TypeNullable(Type.String({ description: "JSON string[] of post-merger tickers" })), + current_tickers: TypeNullable(Type.String({ description: "JSON string[] of current tickers" })), + + // Amounts + ipo_proceeds: TypeNullable(Type.Number({ description: "Gross IPO proceeds" })), + trust_amount: TypeNullable(Type.Number({ description: "Initial trust amount (redeemable cash)" })), + pipe_amount: TypeNullable(Type.Number({ description: "PIPE financing on the active deal" })), + total_redemption_amount: TypeNullable( + Type.Number({ description: "Cumulative redemptions across all votes" }) + ), + + // Rolled-up key dates + registration_date: TypeNullable(Type.String({ format: "date" })), + ipo_date: TypeNullable(Type.String({ format: "date" })), + unit_split_date: TypeNullable(Type.String({ format: "date" })), + definitive_agreement_date: TypeNullable(Type.String({ format: "date" })), + proxy_date: TypeNullable(Type.String({ format: "date" })), + vote_date: TypeNullable(Type.String({ format: "date" })), + completed_date: TypeNullable(Type.String({ format: "date" })), + failed_date: TypeNullable(Type.String({ format: "date" })), + + // Temporal / provenance + as_of: TypeNullable( + Type.String({ + format: "date", + description: + "Filing date of the filing that last shaped the merged scalar fields; out-of-order guard", + }) + ), + updated_at: Type.String({ format: "date-time", description: "Last write timestamp" }), +}); + +export type Spac = Static; + +export const SpacPrimaryKeyNames = ["cik"] as const; +export type SpacRepositoryStorage = ITabularStorage; + +export const SPAC_REPOSITORY_TOKEN = createServiceToken( + "sec.storage.spacRepository" +); diff --git a/src/storage/spac/spacRollup.test.ts b/src/storage/spac/spacRollup.test.ts new file mode 100644 index 00000000..9e3b8b5f --- /dev/null +++ b/src/storage/spac/spacRollup.test.ts @@ -0,0 +1,182 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, expect, it } from "bun:test"; +import { buildSpacRow } from "./spacRollup"; +import type { SpacDeal } from "./SpacDealSchema"; +import type { SpacEvent } from "./SpacEventSchema"; + +function ev(p: Pick & Partial): SpacEvent { + return { + cik: 1, + accession_number: p.event_date + "-" + p.event_type, + form: null, + primary_document: null, + source_document_url: null, + deal_index: null, + amount: null, + shares: null, + detail: null, + confidence: null, + created_at: "2026-01-01T00:00:00.000Z", + ...p, + }; +} + +function deal(p: Pick & Partial): SpacDeal { + return { + cik: 1, + target_name: null, + target_cik: null, + announced_date: null, + definitive_agreement_date: null, + proxy_date: null, + vote_date: null, + pipe_amount: null, + redemption_amount: null, + redemption_shares: null, + outcome_date: null, + source_accession: null, + created_at: "2026-01-01T00:00:00.000Z", + ...p, + }; +} + +describe("buildSpacRow", () => { + it("derives registration + ipo dates and status from events", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [], + events: [ev({ event_type: "registration", event_date: "2020-12-01" }), ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: { spac_name: "Foo SPAC", spac_sic: 6770 }, + filingDate: "2021-01-15", + }); + expect(row.registration_date).toBe("2020-12-01"); + expect(row.ipo_date).toBe("2021-01-15"); + expect(row.status).toBe("ipo"); + expect(row.spac_name).toBe("Foo SPAC"); + expect(row.current_name).toBe("Foo SPAC"); // mirrors spac_name pre-merger + expect(row.as_of).toBe("2021-01-15"); + }); + + it("earliest registration event wins for registration_date", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [], + events: [ + ev({ event_type: "registration", event_date: "2021-02-01" }), + ev({ event_type: "registration", event_date: "2020-12-01" }), + ], + patch: {}, + filingDate: "2021-02-01", + }); + expect(row.registration_date).toBe("2020-12-01"); + }); + + it("a terminated deal's milestones never shadow the active pending deal", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [ + deal({ deal_index: 0, outcome: "terminated", target_name: "Dead Co", definitive_agreement_date: "2022-01-01", proxy_date: "2022-03-01" }), + deal({ deal_index: 1, outcome: "pending", target_name: "Live Co", announced_date: "2022-06-01", definitive_agreement_date: "2022-07-01" }), + ], + events: [ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: {}, + filingDate: "2022-07-01", + }); + expect(row.target_name).toBe("Live Co"); + expect(row.definitive_agreement_date).toBe("2022-07-01"); + expect(row.proxy_date).toBeNull(); + expect(row.status).toBe("deal_announced"); + }); + + it("a completed deal wins over a later pending one and sets completed_date + status", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [ + deal({ deal_index: 0, outcome: "completed", target_name: "Won Co", definitive_agreement_date: "2022-01-01", outcome_date: "2022-05-01" }), + deal({ deal_index: 1, outcome: "pending", target_name: "Later Co", announced_date: "2023-01-01" }), + ], + events: [ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: {}, + filingDate: "2022-05-01", + }); + expect(row.target_name).toBe("Won Co"); + expect(row.completed_date).toBe("2022-05-01"); + expect(row.status).toBe("completed"); + }); + + it("latest pending deal by announced_date is active", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [ + deal({ deal_index: 0, outcome: "pending", target_name: "Older", announced_date: "2022-01-01" }), + deal({ deal_index: 1, outcome: "pending", target_name: "Newer", announced_date: "2022-09-01" }), + ], + events: [ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: {}, + filingDate: "2022-09-01", + }); + expect(row.target_name).toBe("Newer"); + }); + + it("liquidation with no completed deal sets failed_date and liquidated status", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [deal({ deal_index: 0, outcome: "terminated" })], + events: [ + ev({ event_type: "ipo", event_date: "2021-01-15" }), + ev({ event_type: "liquidation", event_date: "2023-01-01" }), + ], + patch: {}, + filingDate: "2023-01-01", + }); + expect(row.failed_date).toBe("2023-01-01"); + expect(row.status).toBe("liquidated"); + }); + + it("sums redemptions across a standalone event and a deal", () => { + const row = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [deal({ deal_index: 0, outcome: "completed", redemption_amount: 50_000_000, outcome_date: "2022-05-01" })], + events: [ + ev({ event_type: "ipo", event_date: "2021-01-15" }), + ev({ event_type: "redemption", event_date: "2022-01-01", amount: 10_000_000 }), + ], + patch: {}, + filingDate: "2022-05-01", + }); + expect(row.total_redemption_amount).toBe(60_000_000); + }); + + it("stale patch (older filing_date) does not overwrite merged scalar fields", () => { + const existing = buildSpacRow({ + existing: undefined, + cik: 1, + deals: [], + events: [ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: { spac_name: "Real Name", spac_sic: 6770 }, + filingDate: "2021-01-15", + }); + const replayed = buildSpacRow({ + existing, + cik: 1, + deals: [], + events: [ev({ event_type: "ipo", event_date: "2021-01-15" })], + patch: { spac_name: "WRONG OLD NAME" }, + filingDate: "2020-01-01", // older than existing.as_of + }); + expect(replayed.spac_name).toBe("Real Name"); + expect(replayed.as_of).toBe("2021-01-15"); + }); +}); diff --git a/src/storage/spac/spacRollup.ts b/src/storage/spac/spacRollup.ts new file mode 100644 index 00000000..1f367a23 --- /dev/null +++ b/src/storage/spac/spacRollup.ts @@ -0,0 +1,184 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { Spac, SpacStatus } from "./SpacSchema"; +import type { SpacDeal } from "./SpacDealSchema"; +import type { SpacEvent } from "./SpacEventSchema"; + +/** + * Filing-sourced scalar fields a population call can set directly. These are + * merged under the `as_of` out-of-order guard; everything else on the row is + * derived from the append-only deals + events. + */ +export interface SpacRowPatch { + readonly current_cik?: number | null; + readonly spac_name?: string | null; + readonly surviving_name?: string | null; + readonly current_name?: string | null; + readonly spac_sic?: number | null; + readonly post_merger_sic?: number | null; + readonly current_sic?: number | null; + readonly spac_tickers?: string | null; + readonly post_merger_tickers?: string | null; + readonly current_tickers?: string | null; + readonly ipo_proceeds?: number | null; + readonly trust_amount?: number | null; +} + +export interface BuildSpacRowInput { + readonly existing: Spac | undefined; + readonly cik: number; + readonly deals: readonly SpacDeal[]; + readonly events: readonly SpacEvent[]; + readonly patch: SpacRowPatch; + /** Filing date of the filing driving this write; "" if unknown. */ + readonly filingDate: string; +} + +function minEventDate(events: readonly SpacEvent[], type: string): string | null { + const dates = events.filter((e) => e.event_type === type && e.event_date).map((e) => e.event_date); + if (dates.length === 0) return null; + return dates.reduce((a, b) => (a.localeCompare(b) <= 0 ? a : b)); +} + +/** + * The active deal = the completed deal if one exists; else the latest pending + * deal by announced_date (deal_index breaks ties). Terminated deals never win. + */ +function activeDeal(deals: readonly SpacDeal[]): SpacDeal | null { + const completed = deals.filter((d) => d.outcome === "completed"); + if (completed.length > 0) { + return completed.reduce((a, b) => + (a.outcome_date ?? "").localeCompare(b.outcome_date ?? "") >= 0 ? a : b + ); + } + const pending = deals.filter((d) => d.outcome === "pending"); + if (pending.length === 0) return null; + return pending.reduce((a, b) => { + const cmp = (a.announced_date ?? "").localeCompare(b.announced_date ?? ""); + if (cmp !== 0) return cmp >= 0 ? a : b; + return a.deal_index >= b.deal_index ? a : b; + }); +} + +function deriveStatus( + events: readonly SpacEvent[], + active: SpacDeal | null, + hasFailed: boolean, + hasIpo: boolean +): SpacStatus { + if (active?.outcome === "completed") return "completed"; + if (hasFailed) return "liquidated"; + if (active) { + if (active.vote_date || active.proxy_date) return "proxy"; + if (active.definitive_agreement_date || active.announced_date) return "deal_announced"; + } + if (hasIpo) return events.some((e) => e.event_type === "unit_split") ? "searching" : "ipo"; + return "registered"; +} + +function sumRedemptions(deals: readonly SpacDeal[], events: readonly SpacEvent[]): number | null { + let sum = 0; + let seen = false; + for (const d of deals) { + if (d.redemption_amount != null) { + sum += d.redemption_amount; + seen = true; + } + } + for (const e of events) { + if (e.event_type === "redemption" && e.amount != null) { + sum += e.amount; + seen = true; + } + } + return seen ? sum : null; +} + +/** Build the full mutable `spac` row from the append-only deals/events + a filing patch. */ +export function buildSpacRow(input: BuildSpacRowInput): Spac { + const { existing, cik, deals, events, patch, filingDate } = input; + + // The patch only applies when the driving filing is not older than the row's + // anchor. An undated filing ("") cannot be ordered and is treated as stale + // when an existing dated anchor is present. + const isStale = + existing?.as_of != null && + existing.as_of !== "" && + (filingDate === "" || filingDate < existing.as_of); + const applied: SpacRowPatch = isStale ? {} : patch; + + // Filing-sourced scalar fields: take the applied patch value, else keep existing. + // When the patch is stale, it may still fill a null field but cannot overwrite a + // non-null existing value — preserving the most-informative data across replays. + const pick = (key: K): Spac[K & keyof Spac] => { + const existingVal = (existing ? (existing as any)[key] : null) as Spac[K & keyof Spac]; + const fromPatch = patch[key]; + if (isStale) { + // Stale filing may fill a null slot but must not clobber a non-null value. + if (existingVal == null && fromPatch !== undefined) return fromPatch as Spac[K & keyof Spac]; + return existingVal; + } + const fromApplied = applied[key]; + // Treat an explicit null like an absent field: a newer filing that does not + // carry a value must not clobber an existing non-null one (merge, not erase). + if (fromApplied != null) return fromApplied as Spac[K & keyof Spac]; + return existingVal; + }; + + const spac_name = pick("spac_name"); + const spac_sic = pick("spac_sic"); + const spac_tickers = pick("spac_tickers"); + + // Pre-merger, current_* mirrors spac_* unless a later filing set them explicitly. + const surviving_name = pick("surviving_name"); + const current_name = applied.current_name ?? existing?.current_name ?? surviving_name ?? spac_name; + const current_sic = applied.current_sic ?? existing?.current_sic ?? pick("post_merger_sic") ?? spac_sic; + const current_tickers = + applied.current_tickers ?? existing?.current_tickers ?? pick("post_merger_tickers") ?? spac_tickers; + + // Event/deal-derived fields: always recomputed (order-independent, idempotent). + const active = activeDeal(deals); + const hasFailed = + events.some((e) => e.event_type === "liquidation" || e.event_type === "deregistration") && + !deals.some((d) => d.outcome === "completed"); + const hasIpo = events.some((e) => e.event_type === "ipo"); + + // A non-stale dated filing advances the anchor to its filing date; a stale or + // undated write keeps the existing anchor (never regresses it). The arms the + // isStale/"" guards would otherwise reach are dead, so this collapses cleanly. + const nextAsOf = isStale || filingDate === "" ? (existing?.as_of ?? null) : filingDate; + + return { + cik, + current_cik: pick("current_cik"), + status: deriveStatus(events, active, hasFailed, hasIpo), + spac_name, + target_name: active?.target_name ?? null, + surviving_name, + current_name, + spac_sic, + post_merger_sic: pick("post_merger_sic"), + current_sic, + spac_tickers, + post_merger_tickers: pick("post_merger_tickers"), + current_tickers, + ipo_proceeds: pick("ipo_proceeds"), + trust_amount: pick("trust_amount"), + pipe_amount: active?.pipe_amount ?? null, + total_redemption_amount: sumRedemptions(deals, events), + registration_date: minEventDate(events, "registration"), + ipo_date: minEventDate(events, "ipo"), + unit_split_date: minEventDate(events, "unit_split"), + definitive_agreement_date: active?.definitive_agreement_date ?? null, + proxy_date: active?.proxy_date ?? null, + vote_date: active?.vote_date ?? null, + completed_date: active?.outcome === "completed" ? (active.outcome_date ?? null) : null, + failed_date: hasFailed ? minEventDate(events, "liquidation") ?? minEventDate(events, "deregistration") : null, + as_of: nextAsOf, + updated_at: new Date().toISOString(), + }; +} diff --git a/src/storage/spac/spacStorage.smoke.test.ts b/src/storage/spac/spacStorage.smoke.test.ts new file mode 100644 index 00000000..f362e6b4 --- /dev/null +++ b/src/storage/spac/spacStorage.smoke.test.ts @@ -0,0 +1,55 @@ +/** + * @license + * Copyright 2026 Steven Roussey + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it } from "bun:test"; +import { globalServiceRegistry } from "workglow"; +import { resetDependencyInjectionsForTesting } from "../../config/TestingDI"; +import { setupAllDatabases } from "../../config/setupAllDatabases"; +import { SPAC_REPOSITORY_TOKEN, type Spac } from "./SpacSchema"; + +describe("spac storage smoke", () => { + beforeEach(async () => { + resetDependencyInjectionsForTesting(); + await setupAllDatabases(); + }); + + it("round-trips a spac row", async () => { + const repo = globalServiceRegistry.get(SPAC_REPOSITORY_TOKEN); + const row: Spac = { + cik: 1821595, + current_cik: null, + status: "ipo", + spac_name: "10X Capital Venture Acquisition Corp", + target_name: null, + surviving_name: null, + current_name: "10X Capital Venture Acquisition Corp", + spac_sic: 6770, + post_merger_sic: null, + current_sic: 6770, + spac_tickers: JSON.stringify(["VCVC.U", "VCVC", "VCVC.WS"]), + post_merger_tickers: null, + current_tickers: JSON.stringify(["VCVC.U", "VCVC", "VCVC.WS"]), + ipo_proceeds: 201250000, + trust_amount: 201250000, + pipe_amount: null, + total_redemption_amount: null, + registration_date: "2020-12-21", + ipo_date: "2021-01-15", + unit_split_date: null, + definitive_agreement_date: null, + proxy_date: null, + vote_date: null, + completed_date: null, + failed_date: null, + as_of: "2021-01-15", + updated_at: new Date().toISOString(), + }; + await repo.put(row); + const got = await repo.get({ cik: 1821595 }); + expect(got?.spac_name).toBe("10X Capital Venture Acquisition Corp"); + expect(JSON.parse(got!.spac_tickers!)).toEqual(["VCVC.U", "VCVC", "VCVC.WS"]); + }); +});