/** * SMI-4291 / SMI-4308: WebhookDeadLetterRepository * * Thin persistence layer over the `webhook_dead_letters` table * (migrations 070 + 077). Used by the WebhookQueue deadLetterSink wiring * and by the `webhook-dlq` edge function. * * Plans: * - docs/internal/implementation/github-wave-4-webhook-dlq.md * - docs/internal/implementation/smi-4308-webhook-dlq-retry-real-enqueue.md * * Current operations (three terminal states: never-touched / retried / resolved): * - insertDeadLetter(item, reason, teamId) — sink path * - listOpen(teamId) — edge fn GET + repo consumers * - listUnretried(teamId) — DEPRECATED alias for listOpen * (kept for SMI-4322's delivery worker) * - markRetried(id, success) — reserved for SMI-4322 delivery worker * - markResolved(id, resolvedBy?) — operator acknowledgement (edge fn) * * No `audit_logs` emission is performed by this repository (plan-review finding * C2: cross-tenant exposure risk on the global-read RLS of `audit_logs`). The * edge function emits `webhook:dlq_resolved` for operator actions — see * plan-review finding C-03 — because those writes are team-scoped. */ import type { WebhookQueueItem } from './WebhookQueue.types.js'; /** * Minimal Supabase-client shape this repository depends on. We avoid * importing `@supabase/supabase-js` directly so the module stays usable * from Deno edge functions, Node services, and tests with a fake client. */ export interface SupabaseLikeClient { from(table: string): { insert(row: Record): Promise<{ error: { message: string; } | null; }>; select(columns: string): { eq(column: string, value: string): { order(column: string, opts?: { ascending?: boolean; }): Promise<{ data: DeadLetterRow[] | null; error: { message: string; } | null; }>; }; single?: () => Promise<{ data: DeadLetterRow | null; error: { message: string; } | null; }>; }; update(row: Record): { eq(column: string, value: string): Promise<{ data: DeadLetterRow | null; error: { message: string; } | null; }>; }; }; } export interface DeadLetterRow { id: string; original_event_id: string; endpoint_url: string; payload: Record; failure_reason: string; attempt_count: number; first_failed_at: string; last_failed_at: string; retried_at: string | null; retry_success: boolean | null; resolved_at: string | null; resolved_by: string | null; team_id: string; created_at: string; } export interface InsertDeadLetterInput { originalEventId: string; endpointUrl: string; payload: Record; failureReason: string; attemptCount: number; firstFailedAt: Date | string; lastFailedAt?: Date | string; teamId: string; } /** * WebhookDeadLetterRepository — single-table CRUD wrapper. * * Instances are stateless beyond the client reference; construct freely per * request in edge functions and once per process in long-lived services. */ export declare class WebhookDeadLetterRepository { private readonly client; constructor(client: SupabaseLikeClient); /** * Persist a dead-letter row for an exhausted queue item. * * Validates `endpoint_url` length at the application layer so the DB * CHECK constraint is never hit in practice; a row that would violate the * check throws a sanitized error without the payload. * * SMI-4307 F-03/F-02: When `input.teamId` is null or empty, the caller is an * Individual-tier user (or a user whose team has not been provisioned yet). * `webhook_dead_letters.team_id` is NOT NULL and RLS is team-scoped, so an * insert would fail the FK/CHECK or land unreachable for its owner. * Short-circuit instead: emit a console warning for ops visibility and * return. A follow-on issue can promote this to an `audit_logs` write if * orphan-DLQ observability becomes operationally valuable. * * F-02 overload note: the guard uses `input.teamId` (already resolved by the * caller). Do NOT call `user_team_ids()` from here — the SMI-4309 overload * collision between migrations 070 and 071 makes any such call brittle. */ insertDeadLetter(input: InsertDeadLetterInput): Promise; /** * List open DLQ rows for a team — rows that are neither retried nor * operator-resolved. Ordered by `last_failed_at` descending so fresh * failures surface first. * * RLS guarantees cross-team isolation; the `team_id` filter below is a * belt-and-braces server-side refinement for service-role callers. * * SMI-4308 H-03: the in-process filter now checks both terminal columns. * Prior to this change the filter only checked `retried_at === null`, so * resolved rows would have leaked into `listUnretried` results once the * /resolve path landed. */ listOpen(teamId: string): Promise; /** * @deprecated SMI-4308 — use {@link listOpen}. Retained because the * dormant WebhookQueue sink wiring (SMI-4322) will call this through an * older API contract. Removed once SMI-4322 lands. */ listUnretried(teamId: string): Promise; /** * Mark a DLQ row as retried. Three-state `retry_success` semantics: * - true — retry succeeded (item re-entered the queue and drained) * - false — retry failed (item re-entered the queue and was re-deadlettered) * * NULL is reserved for rows that have never been retried; this method * never writes NULL. * * SMI-4308 status: dormant in production until SMI-4322 ships the * outbound delivery worker. The shipped edge function uses * {@link markResolved} instead. */ markRetried(id: string, success: boolean): Promise; /** * Mark a DLQ row as operator-resolved (SMI-4308). The row is * acknowledged out-of-band; no automated re-delivery took place. `retried_at` * and `retry_success` are left NULL so the three-state retry semantic stays * clean for the future delivery worker (SMI-4322). * * `resolvedBy` is typically the auth.users.id of the operator; when the * edge function cannot extract the `sub` claim from the caller JWT, it * passes `undefined` and the column is set to NULL (the RLS policy has * already scoped the row to the caller's team). */ markResolved(id: string, resolvedBy?: string): Promise; /** * Build a sink function compatible with `WebhookQueueOptions.deadLetterSink`. * * The sink captures `teamId` and a payload-extraction strategy so the * WebhookQueue stays decoupled from Supabase. */ makeSink(opts: { teamId: string; extractEndpointUrl: (item: WebhookQueueItem) => string; extractPayload?: (item: WebhookQueueItem) => Record; }): (item: WebhookQueueItem, reason: string) => Promise; } //# sourceMappingURL=WebhookDeadLetterRepository.d.ts.map