/** * Cross-vertical event delivery (#1705): the half both adapters share. * * A vertical exports an event type by declaring it (`events.exports`), and another vertical of * the same tenant imports it by declaring `events.consumes: [{ from, … }]`. Nothing is written * when an exported event commits. The producer's outbox is retained and is already the durable * record, so an export is a READ over it, and a consumer's progress is a watermark the consumer * keeps. Each platform sweep pass then: * * 1. asks the consumer scope for its watermark on this producer (`IMPORT_CURSORS_SQL`); * 2. reads the producer's outbox after it, in the producer's own scope, through its own * running `exports` (`exportReadQuery` + `planExportBatch`); * 3. hands the batch to the consumer, which journals each event and runs its handlers, * then moves the watermark in the same store (`IMPORT_CURSOR_ADVANCE_SQL`). * * The tables here are the consumer's half of that, and they hold NO payload. The event's only * copy stays in the producer's outbox, where a subject erasure already reaches. An imported * payload would be one more resting copy for the erasure to find, and #1600 and #1632 were * each about a copy it had missed. What the consumer keeps is the envelope. That is enough to * dedupe, to show a dead letter, and to resolve a `caused_by` that points across the edge. * * Shared from here for `CAPABILITY_DDL`'s reason: the self-host builds these tables and * production builds them, and one definition is what keeps the two identical. * `lint:spine-ddl` holds each adapter to including it. */ import { type ImportCursorMove, type ImportBatch, type ImportCursorMoved, type ImportResult, type ScopeId, type TenantId, type VerticalResolution, type EventExport, type ModuleManifest, type ExportedBatch, type WantedEvent, type WithheldEvent } from '@substrat-run/contracts'; import { type OutboxEnvelopeRow } from './outbox-event.js'; import type { ImportHandler } from './scope-host.js'; import type { SwitchSql } from './system-switch.js'; export declare const VERTICAL_EVENTS_DDL = "\n -- #1705: every event this scope has received from another vertical. The envelope only, and\n -- never the payload. The producer's outbox keeps the one copy, where erasure already reaches.\n CREATE TABLE IF NOT EXISTS _substrat_imports (\n -- The PRODUCER's event id. A ULID the producer's host minted, so it is unique across\n -- every source this scope imports from, and it is the dedupe key: a batch delivered\n -- twice finds its events here and its handlers' journal rows in _substrat_deliveries.\n event_id TEXT PRIMARY KEY,\n source_scope_id TEXT NOT NULL,\n source_vertical TEXT NOT NULL,\n type TEXT NOT NULL,\n schema_version INTEGER NOT NULL,\n occurred_at TEXT NOT NULL,\n entity_type TEXT NOT NULL,\n entity_id TEXT NOT NULL,\n -- How many vertical boundaries this event's cause chain has crossed, this one included.\n -- An export read in THIS scope counts from here when one of its own events was caused by\n -- an import, which is how a loop between two verticals is stopped.\n hops INTEGER NOT NULL,\n -- NULL: released, and handed to this scope's handlers. Otherwise the producer's reason\n -- for NOT releasing it ('pii', 'version', 'cascade', 'undecodable'). Such a row was\n -- named but never carried.\n withheld TEXT,\n imported_at TEXT NOT NULL\n );\n -- #1705: how far this scope has read each producer. Written in the same store as the\n -- handlers' writes, so a restore of this scope rewinds the watermark with the data and the\n -- producer re-delivers what the restore undid. A watermark the platform held instead would\n -- skip it.\n CREATE TABLE IF NOT EXISTS _substrat_import_cursors (\n source_scope_id TEXT PRIMARY KEY,\n source_vertical TEXT NOT NULL,\n -- The id of the last producer row the reads walked (released or withheld). NULL is not\n -- stored: a producer with no row here has never been read, which the absence says.\n cursor TEXT NOT NULL,\n updated_at TEXT NOT NULL\n );\n -- #1705: the export read, on the PRODUCER side. It asks for a few types after a watermark,\n -- in id order. Without this index SQLite walks the primary key from the watermark forward and\n -- steps over every row of every other type. A consumer that is caught up would then pay, on\n -- every pass, for all the unexported activity since the last exported event. With it, the read\n -- seeks each type's range and costs what it returns.\n CREATE INDEX IF NOT EXISTS _substrat_outbox_type_id ON _substrat_outbox (type, id);\n -- #1705 PR 3: what a REPLAY moved out of the live journal. A replay re-delivers events this\n -- scope already took, and the live tables are also the dedupe (a row in _substrat_deliveries\n -- means \"do not run this handler again\"). So the rows for the replayed range leave them. They\n -- are moved here rather than dropped, because they are the record that a handler ran on an\n -- event, when, and what it reported: evidence the spine keeps. One row per moved row, the\n -- whole row as JSON under the replay that moved it. Nothing reads this to deliver.\n -- ERASURE: today no erasure path touches imported copies, because only piiClass 'none' crosses\n -- and the delivery-error rewrite (#1632) reaches only this scope's OWN outbox events. If a\n -- consumer-side erasure of imported copies is ever added, it must reach `row` here too (a\n -- moved delivery row's `error` is a handler's own sentence), or the history keeps what the\n -- live tables gave up. The contract suite holds the two copies to agree after an erasure\n -- (vertical-events-suite, #1757), so widening only the live half goes red.\n CREATE TABLE IF NOT EXISTS _substrat_import_replays (\n -- The act (a ULID). The admin log's row for the replay carries the same id.\n replay_id TEXT NOT NULL,\n replayed_at TEXT NOT NULL,\n source_scope_id TEXT NOT NULL,\n -- 'journal': a _substrat_imports row. 'delivery': a _substrat_deliveries row.\n kind TEXT NOT NULL,\n event_id TEXT NOT NULL,\n -- The handler's module on a 'delivery' row, and '' on a 'journal' row.\n consumer_module TEXT NOT NULL,\n row TEXT NOT NULL,\n PRIMARY KEY (replay_id, kind, event_id, consumer_module)\n );\n"; /** * The producer-side read: rows of the given types after the watermark, oldest first. * * `types` is already the intersection of what the consumer wants and what this scope exports. * The caller computes it from its own registered manifests, never from the request alone, so * nothing here can widen what leaves. */ export declare function exportReadQuery(types: readonly string[], after: string | null, limit: number): { sql: string; params: unknown[]; }; /** * How many vertical boundaries the cause chain of an outbox row with this `caused_by` has * already crossed. The answer is the `hops` of the NEAREST import in its chain, or no row when * the chain never crossed one. * * Walks `caused_by` backwards through this scope's outbox. The chain ends at an import, which * is in `_substrat_imports` and not in the outbox, or at an event nothing caused. Bounded at 64 * steps: a chain that long inside ONE scope has already been cut many times by the per-call * cascade cap. Past the bound, the row counts as having crossed nothing. That under-counts, and * it is stated rather than hidden. It can only let a loop run for more passes than the cap * intends. It cannot release an event that should not cross. */ export declare const EXPORT_HOPS_SQL = "\n WITH RECURSIVE chain(id, depth) AS (\n SELECT ?, 0\n UNION ALL\n SELECT o.caused_by, c.depth + 1\n FROM chain c JOIN _substrat_outbox o ON o.id = c.id\n WHERE o.caused_by IS NOT NULL AND c.depth < 64\n )\n SELECT i.hops AS hops FROM chain c JOIN _substrat_imports i ON i.event_id = c.id\n ORDER BY c.depth LIMIT 1\n"; /** A stored outbox row, as the export read sees it: the envelope, plus the cause column. */ export type ExportRow = OutboxEnvelopeRow & { caused_by?: string | null; }; /** * The producer-side decision, over rows already read: what is released, what is withheld and * why, and the watermark the consumer should hold afterwards. * * Pure, so both adapters make the same decision about the same rows. Authority is NOT decided * here. The adapter checks the consumer's principal before it reads anything, and a missing * key pauses the edge without walking a row. Every reason this function withholds for is * fixed at write time (the classification, the version, the cause chain, the stored text), * so stepping the watermark past a withheld row can never lose an event that a later pass * would have released. */ export declare function planExportBatch(input: { rows: readonly ExportRow[]; /** What the consumer declares it takes, by type. */ wanted: ReadonlyMap; /** How many boundaries a row's cause chain already crossed (0 = none). */ hopsBefore: (row: ExportRow) => number; after: string | null; limit: number; hopCap?: number; }): Pick; /** * The read plan for one request: which types to read, which keys the consumer must hold for * them, and what it asked for that this scope does not export. * * `exports` is THIS scope's running declaration, the union over its registered modules. A * wanted type absent from it is reported and never read. That is the producer's own code * deciding what leaves, whatever the platform asked for. */ export declare function exportReadPlan(exports: ReadonlyMap, wants: readonly WantedEvent[]): { types: string[]; keys: string[]; wanted: Map; unexported: WantedEvent[]; }; /** * The outbox's insertion mark (#1705 PR 2): the kick's "before", taken ahead of an invoke. * * `rowid`, not the event id. A ULID minted in the same millisecond as the newest row can sort * BELOW it, so "ids above the newest id" can miss an event this invoke wrote. SQLite assigns a * new row `max(rowid) + 1` while the newest row is still there. No code path deletes outbox rows * inside an invoke: the only removals are a restore and a wipe, and they replace the whole table * outside any invocation. So every row the invoke and its consumers add sits above the mark. If * that ever stopped being true, the effect is bounded: an exported event would miss its kick and * wait for the sweep, and no event would be lost or sent twice. */ export declare const OUTBOX_MARK_SQL = "SELECT COALESCE(MAX(rowid), 0) AS mark FROM _substrat_outbox"; /** * How many rows of the exported `types` were added after `mark` (#1705 PR 2). This is the * count `ScopeStubOptions.onExportedEvents` reports. A seek on the rowid, so it walks only * what the invoke added. Callers skip it entirely when the deployment exports nothing. */ export declare function exportedSinceQuery(types: readonly string[], mark: number): { sql: string; params: unknown[]; }; /** * The events an invoke itself emitted, read back after its commit (#1746) — what * `InvokeOptions.onEmitted` reports. Params: (mark, cap). * * Run between the commit and the consumer drain, so only the operation's own rows sit * above the mark: the drain's emits land later and are not counted. `total` is a window * count over the same seek, so one statement answers both the capped list and how long the * uncapped one was. */ export declare function emittedSinceQuery(mark: number, cap: number): { sql: string; params: unknown[]; }; /** The rows `emittedSinceQuery` returns, as the report `onEmitted` receives. */ export declare function emittedReportOf(rows: readonly Readonly>[]): { events: { type: string; entity: string; }[]; total: number; }; /** * A delivery that ran nothing (#1705): the answer every consumer door starts from, and the one a * refusal (a pause, a stale watermark) returns with its one field set. The watermark stays at * `batch.after`. */ export declare const emptyImportResult: (batch: Pick) => ImportResult; /** The consumer's watermark per producer, oldest source first. */ export declare const IMPORT_CURSORS_SQL = "SELECT source_scope_id, source_vertical, cursor, updated_at FROM _substrat_import_cursors ORDER BY source_scope_id"; /** One producer's watermark, for the compare-and-set a batch is applied under. */ export declare const IMPORT_CURSOR_OF_SQL = "SELECT cursor FROM _substrat_import_cursors WHERE source_scope_id = ?"; /** * Move one producer's watermark. Params: (source_scope_id, source_vertical, cursor, updated_at). * * Only ever forward. ULIDs sort by time, and a batch's `next` is the last id its read walked, * so a smaller value can only come from a pass that read less than one already applied. The * batch's own compare-and-set normally refuses that pass before it gets here, and this is the * second line. */ export declare const IMPORT_CURSOR_ADVANCE_SQL = "\n INSERT INTO _substrat_import_cursors (source_scope_id, source_vertical, cursor, updated_at)\n VALUES (?, ?, ?, ?)\n ON CONFLICT (source_scope_id) DO UPDATE SET\n source_vertical = excluded.source_vertical,\n cursor = excluded.cursor,\n updated_at = excluded.updated_at\n WHERE excluded.cursor > _substrat_import_cursors.cursor\n"; /** * Journal one received (or withheld) event's envelope. Params in column order. Ignored on a * repeat: the first journaling is the fact, and a redelivered batch must not move * `imported_at` forward or rewrite what was withheld and why. */ export declare const IMPORT_RECORD_SQL = "\n INSERT OR IGNORE INTO _substrat_imports\n (event_id, source_scope_id, source_vertical, type, schema_version, occurred_at,\n entity_type, entity_id, hops, withheld, imported_at)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)\n"; /** * The producer end of the edge a lever moves, resolved by the sweep's own rule (#1705 PR 3). * * Both ends must be their tenant's one primary instance, exactly as `sweepEdge` requires. A * watermark on any other pair is one no pass reads, so a move there would report success and * change nothing. `resolve` is the directory's `resolveVerticalInstance`, and it takes the * CONSUMER's tenant, so a producer in another tenant is not found rather than moved. */ export declare function importCursorSourceOf(resolve: (tenantId: TenantId, vertical: string) => Promise, consumer: { tenantId: TenantId; scopeId: ScopeId; vertical: string | null; }, from: string): Promise<{ vertical: string; scopeId: ScopeId; }>; /** * Move one edge's watermark (#1705 PR 3). The adapter supplies the store (as the switches' * `SwitchSql`), the transaction, the queue, the clock and the act's id. Run on the scope's own * queue, so a pass delivering on the same edge can never interleave with the move. Everything the move decides is decided here, once, for * both adapters. * * Why a bare rewind is not a replay. Three guards stand between an event and a second run of * its handler, and a replay must pass each one on purpose: * * 1. `IMPORT_CURSOR_ADVANCE_SQL` moves the watermark forward only. The lever sets it here. * 2. `_substrat_deliveries` holds a row per (event, module) that ran, and the delivery path * skips any pair it finds. Rewound but not cleared, every replayed event would come back * as a `duplicate` and nothing would run. * 3. `_substrat_imports` is `INSERT OR IGNORE`. Left in place, an event first withheld for its * version would stay recorded as withheld after the consumer's upgrade released it. * * So a replay moves the range's rows in (2) and (3) into `_substrat_import_replays` under * `replayId`, then clears them, then sets the watermark. The evidence stays, and the live tables * take the events again. Two things are true between the move and the redelivery, and are * stated rather than hidden: a cause walk through a moved event's id ends at `missing`, and an * export read in this scope counts fewer hops for a chain through it. Both heal when the next * pass redelivers. The undercount only loosens the loop bound (the hop cap bounds loops, and * authorizes nothing), so a loop may run a round longer; no event crosses that exports, grants * and the PII rule would not already release. * * Refused, with the reason a person reads: * - a replay whose `after` is AHEAD of the watermark (that is a skip), or a skip whose `through` * is BEHIND it (that is a replay). The acknowledgement names one direction, and it must be * the direction that moves; * - a skip past now, which would pass over events not yet written while the edge read as * caught up; * - a replay on an edge that has delivered nothing yet: there is nothing to run again. */ export declare function moveImportCursor(sql: SwitchSql, input: { move: ImportCursorMove; source: { vertical: string; scopeId: string; }; replayId: string; /** Epoch ms: the rows' time, and "now" for a skip, as the watermark's ceiling. */ now: number; /** * What the code running this scope imports (`CrossVerticalRegistry.consumes()`). A move names * an edge only if the consumer declares it. Otherwise a skip would plant a watermark for an * edge that does not exist yet, and the backfill of a later version that adds the import would * silently start from it. */ imports: readonly { from: string; }[]; }): ImportCursorMoved; /** The note a withheld event's dead letter carries at the consumer. Names the reason, never the content. */ export declare function withheldNote(reason: WithheldEvent['reason'], vertical: string): string; /** One registered import handler: which module, from which vertical, for which (type, version). */ export interface RegisteredImport { moduleId: string; from: string; type: string; schemaVersion: number; handler: ImportHandler; } /** * A host's cross-vertical declarations (#1705), accumulated module by module. It is held by * every place that registers modules (the pure host, the Cloudflare coordinator and the * scope DO), so the three refuse the same wiring with the same words. * * What it refuses, and why each would otherwise be silent: * - **An import handler for a (source, type) the manifest does not declare `from` it.** It * would run for events nobody reviewed the module receiving, because the declaration is * what the permission diff shows. * - **A declared import with no handler.** The edge would be read, the events journaled and * the watermark moved, and nothing would ever run: a delivery that reports success and * does nothing. (A LOCAL consume without a handler stays allowed, as it always was.) * - **Two modules importing one (source, type) at different versions**, or **exporting one * type under different versions or keys**. The edge asks for one version and gates on one * key, and which module won would depend on registration order. */ export declare class CrossVerticalRegistry { private readonly exportsByType; private readonly importVersion; private readonly registered; /** `exportTypes()`, kept current by `register`: an invoke reads it, and must not rebuild a map to. */ private exportTypeList; register(manifest: Pick, handlers: Record> | undefined): void; /** type → export, over every registered module: what this deployment releases. */ exports(): Map; /** The exported type names (#1705 PR 2), for the per-invoke kick count. Empty: exports nothing. */ exportTypes(): readonly string[]; /** What this deployment imports, one row per (source, type), sorted. */ consumes(): { from: string; type: string; schemaVersion: number; }[]; /** The handlers for one (source, type), in registration order. */ handlersFor(from: string, type: string): RegisteredImport[]; /** Every module that imports anything from `from`: who a withheld event's dead letter is filed under. */ modulesImporting(from: string, type: string): string[]; } //# sourceMappingURL=vertical-events.d.ts.map