import type { Kysely } from 'kysely'; import type { Database, WebhookDeliveryRow } from '../db/schema.js'; import { type WebhookEndpoint } from './endpoints.js'; import { type WebhookEventInput } from './events.js'; /** * Sending an event, and remembering what happened to it. * * **Enqueue first, then attempt.** This is the one place the webhook queue deliberately differs from * the purge queue, which attempts first and records only failures. A purge that vanishes because the * isolate was killed between the response and the `fetch` costs staleness the TTL already bounds; an * event that vanishes is gone — nothing regenerates it, and the consumer waits forever for a * "published" that is never coming. So the row is written before the request leaves, which turns a * dead isolate into work the next sweep finds. * * The cost is one insert per endpoint per event, and it is paid **after the response** — the * middleware dispatches from the same place it purges, for the same ordering reason. */ /** * How many attempts before a delivery is left alone. * * Same ceiling as the purge queue and for the same reason: past this it is a misconfiguration rather * than a blip, and retrying forever turns one broken URL into an unbounded stream of outbound * requests. What a `failed` row buys instead is something the screen can *report*. */ export declare const MAX_DELIVERY_ATTEMPTS: number; export interface WebhookDeliveryOptions { /** Injected in tests; `globalThis.fetch` otherwise. */ fetch?: typeof globalThis.fetch; /** Injected in tests. Unix seconds. */ timestamp?: number; } /** A delivery row paired with where it is going, which is what a send needs. */ export interface PendingDelivery { delivery: WebhookDeliveryRow; endpoint: WebhookEndpoint; } /** * Write one pending row per subscribed endpoint, and return them ready to send. * * **One endpoint query per request, not per event.** A save that renames a page emits `item.updated` * and `item.published` together, and a release publish emits one per item — so asking "who wants * this" per event would put a query on a write path in proportion to how much it changed. The * endpoints are loaded once and matched in memory, which is also what makes the common case free: * with nothing configured this is a single indexed miss and no inserts at all. * * The rows go out in **one multi-row insert** for the reason `batchWrite` exists — a statement per * row is a round trip per row, on the path that runs after every save. * * **Never throws.** It is called from the same place the purge is, after a write that has already * been reported successful to an editor, and failing their save over a webhook they cannot see would * be the trade `recordAuditEntry` refuses. */ export declare function enqueueWebhookEvents(db: Kysely, inputs: WebhookEventInput[]): Promise; /** One event, for the callers that only ever have one. */ export declare function enqueueWebhookEvent(db: Kysely, input: WebhookEventInput): Promise; /** * A test send, which is not a subscription and does not go through the queue's matching. * * It writes a row like any other so the delivery log shows the attempt — the whole point is to find * out what a real send would do — and it goes to the endpoint whether or not it is paused, because * "pause it, then work out why it was failing" is the order somebody does those in. */ export declare function enqueueWebhookTest(db: Kysely, endpoint: WebhookEndpoint): Promise; export interface DeliveryOutcome { ok: boolean; status?: number; error?: string; } /** * Make the request. No database access, so the outcome can be recorded by the caller that owns it. * * **Never throws**, for the reason the whole path never does. */ export declare function sendWebhook(pending: PendingDelivery, options?: WebhookDeliveryOptions): Promise; /** * Write the outcome onto the row. * * A success is an **update, not a delete**, which is the other half of this table being a log: * `pending_purges` deletes a row that landed because nothing ever asks about a purge that worked, * and the first question anyone asks about a webhook is whether it arrived. */ export declare function recordDeliveryOutcome(db: Kysely, delivery: WebhookDeliveryRow, outcome: DeliveryOutcome): Promise; /** * Send one delivery and record what happened, which is what both callers want. * * Never throws: `sendWebhook` does not, and the outcome is *read* rather than caught — the same * shape `drainPurgeQueue` uses, and for the same reason. A `try`/`catch` around a function that * cannot reject is dead code that would mark every row delivered whether or not it arrived. */ export declare function attemptWebhookDelivery(db: Kysely, pending: PendingDelivery, options?: WebhookDeliveryOptions): Promise; /** * Queue a request's events and send what can be sent now. * * The one entry point for a write path. Sequential rather than parallel, following * `drainPurgeQueue`: this runs on a production deployment after every save, and finishing a tick * later is a better trade than saturating the outbound request budget — or than aiming twenty * concurrent requests at one receiver that has just told us it is struggling. * * **Never throws**, and the outcome of each send is *read* rather than caught, because * `attemptWebhookDelivery` cannot reject. */ export declare function dispatchWebhookEvents(db: Kysely, inputs: WebhookEventInput[], options?: WebhookDeliveryOptions): Promise; /** * Deliveries whose backoff has elapsed, oldest first, bounded so one sweep cannot run long. * * Joined to the endpoint rather than loaded per row: a send needs the URL and the secret, and N+1 * lookups on the one path that runs unattended is the cost `npm run query-count` exists to notice. * An endpoint deleted since the row was written takes its deliveries with it (`on delete cascade`), * so the join can be inner without dropping work silently. */ export declare function dueWebhookDeliveries(db: Kysely, limit?: number): Promise; export interface WebhookQueueStatus { /** Waiting, and still being retried. A few is a sweep that has not run yet, which is normal. */ pending: number; /** Given up on — the number somebody has to do something about. */ failed: number; /** The most recent failure, so the screen can say why and not only how many. */ lastError: string | null; } /** * What Settings → System reports. * * `failed` is separate from `pending` for the reason `purgeQueueStatus` splits them: one total lets * the ordinary case hide the actionable one. */ export declare function webhookQueueStatus(db: Kysely): Promise; export interface WebhookEndpointStats { pending: number; delivered: number; failed: number; /** The most recent attempt of any outcome, or null for an endpoint nothing has been sent to. */ lastAt: string | null; } /** * Per-endpoint delivery counts for the list screen, in one grouped query. * * Not a lookup per endpoint. The list is single digits, so N+1 would be survivable and it is still * the habit that produced the two real costs `npm run query-count` was written to catch — and this * is a screen an admin reloads while diagnosing something, which is exactly when the queries are * being watched. * * Counts rather than "the last outcome", which is what a `row_number()` window would be needed for * and is the less useful answer: "eleven delivered, one failed" tells somebody whether an endpoint * is working, where one green row above ten red ones does not. */ export declare function webhookEndpointStats(db: Kysely): Promise>; export interface ListDeliveriesOptions { endpointId?: string; limit?: number; offset?: number; } /** * The delivery log, newest first, with a total counted before the limit. * * The total is what lets the screen page rather than truncate silently — the rule the content lists * learned: a count above a capped list is a number that is right, rows that are right, and the two * together saying something false. */ export declare function listWebhookDeliveries(db: Kysely, options?: ListDeliveriesOptions): Promise<{ deliveries: WebhookDeliveryRow[]; total: number; }>; /** * Drop delivery rows older than a cutoff. * * Retention rather than a delete-on-success, and unlike the two logs it is **not** opt-in: a * delivery row is operational rather than historical — it exists to be retried and to answer "did * last night's publish arrive" — so keeping one forever is hoarding rather than history. * `TAPROOT_AUDIT_LOG_RETENTION_DAYS` covers the question a webhook row cannot answer anyway, which * is who did the thing that caused it. * * Bounded and batched for `purgeAuditLogBefore`'s reason: the first sweep after an upgrade can face * a table that has grown since, and `delete … limit` needs a SQLite build flag D1 cannot be asked * about — so the ids are selected first and deleted by key. */ export declare function purgeExpiredWebhookDeliveries(db: Kysely, days?: number, limit?: number): Promise;