import { StandardSchemaV1 as StandardSchemaV12 } from "@standard-schema/spec"; import { StandardSchemaV1 } from "@standard-schema/spec"; import { RetryStrategy as RetryStrategy_dwxcm3 } from "@workkit/errors"; type ChannelName = string; type DeliveryStatus = "queued" | "sent" | "delivered" | "read" | "failed" | "bounced" | "skipped" | "duplicate"; type Priority = "normal" | "high"; type DispatchMode = "live" | "test"; interface RecipientChannelAddress { channel: ChannelName; address: string; verified?: boolean; } interface Recipient { userId: string; timezone?: string; channels: RecipientChannelAddress[]; } /** * Caller-supplied resolver: given a userId, return the recipient record. * Notify does not query your user table; it asks you for what it needs. */ type Resolver = (userId: string) => Promise; interface QuietHours { start: string; end: string; timezone: string; } interface NotificationPreferences { channels: ChannelName[]; quietHours?: QuietHours; } interface ChannelTemplate

{ /** * The template body. Adapters interpret it differently: * - `email`: `string` HTML, or a React Email element rendered via the * optional `@react-email/render` peer. * - `whatsapp`: typically a template id or full template object. * - others: as the adapter documents. * * Typed as `unknown` so adapters can accept their own narrower shape * without forcing every other adapter to deal with it. */ template?: unknown; variables?: (payload: P) => Record; props?: (payload: P) => unknown; attachments?: (payload: P) => Array<{ filename?: string; r2Key: string; type?: string; }>; title?: (payload: P) => string; body?: (payload: P) => string; deepLink?: (payload: P) => string; } interface DefineNotificationOptions

{ id: string; schema: StandardSchemaV1

; channels: Record>; /** Ordered chain of channels to try when earlier channels fail/skip. */ fallback?: ChannelName[]; priority?: Priority; } interface SendOptions { idempotencyKey?: string; mode?: DispatchMode; } interface SendResult { id: string; status: "queued" | "duplicate"; idempotencyKey: string; } interface AdapterSendArgs

{ userId: string; notificationId: string; channel: ChannelName; address: string; template: ChannelTemplate

; payload: P; deliveryId: string; mode: DispatchMode; } interface AdapterSendResult { providerId?: string; status: Exclude; error?: string; /** * Optional. Whether the failure should be retried. Adapters that catch * a `WorkkitError` populate this from `WorkkitError.retryable`; other * adapters can leave it undefined. See ADR-002. */ retryable?: boolean; /** * Optional. Recommended backoff strategy for the failure. Adapters that * catch a `WorkkitError` populate this from `WorkkitError.retryStrategy`. * Consumers / queue policy can opt into reading this field; today it is * not yet acted on by `createNotifyConsumer` (see ADR-002 follow-ups). */ retryStrategy?: RetryStrategy_dwxcm3; } interface WebhookEvent { channel: ChannelName; providerId: string; status: Extract; at: number; raw?: unknown; } interface Adapter

{ send(args: AdapterSendArgs

): Promise; parseWebhook?(req: Request): Promise; verifySignature?(req: Request, secret: string): Promise; } interface NotifyConfig { /** Notification IDs allowed to bypass quiet hours when priority:'high'. */ priorityAllowlist: ReadonlyArray; /** Default delivery-record retention in days. */ deliveryRetentionDays: number; } interface DispatchJob

{ id: string; userId: string; notificationId: string; payload: P; idempotencyKey: string; priority: Priority; mode: DispatchMode; createdAt: number; } /** Minimal D1-shape we depend on. Matches @cloudflare/workers-types' D1Database. */ interface NotifyD1 { prepare(query: string): NotifyPreparedStatement; batch(statements: NotifyPreparedStatement[]): Promise; } interface NotifyPreparedStatement { bind(...values: unknown[]): NotifyPreparedStatement; first>(): Promise; all>(): Promise<{ results?: T[]; }>; run(): Promise<{ success?: boolean; meta?: { changes?: number; }; }>; } interface NotifyDeps

{ db: NotifyD1; resolver: Resolver; adapters: Record>; config?: Partial; logger?: { info: (msg: string, meta?: Record) => void; error?: (msg: string, meta?: Record) => void; }; now?: () => number; } interface Notification

{ readonly id: string; readonly priority: Priority; readonly channels: Record>; readonly fallback: ReadonlyArray; readonly schema: StandardSchemaV12

; send(payload: P, target: { userId: string; }, options?: SendOptions): Promise; } interface DefineDeps { enqueue: (job: DispatchJob) => Promise; now?: () => number; } /** * Define a notification. Returns an object with a typed `send()`. Validation * of inputs happens here: * - duplicate channel in `fallback` → ConfigError * - unknown channel referenced from `fallback` → ConfigError * - empty `channels` map → ConfigError */ declare function define

(options: DefineNotificationOptions

, deps: DefineDeps): Notification

; /** * Per-NotifyDeps adapter registry. Keep instances local to your dispatch * setup — global module-level registries make tests painful. */ declare class AdapterRegistry { private map; register

(name: ChannelName, adapter: Adapter

): void; get(name: ChannelName): Adapter | undefined; require(name: ChannelName): Adapter; channels(): ChannelName[]; } declare function buildRegistry(adapters: Record>): AdapterRegistry; interface DispatchInput

{ job: DispatchJob

; template: Record>; fallback: ReadonlyArray; } interface DispatchOutcome { jobId: string; finalStatus: "delivered" | "sent" | "read" | "failed" | "skipped" | "duplicate"; channelAttempted: ChannelName | null; deliveryId: string | null; } /** * The dispatcher pipeline. Runs INSIDE a queue consumer for race-safety * (opt-out, quiet-hours, idempotency are all re-read here). * * Inserts ONE delivery row per dispatch (keyed by idempotency_key). Each * channel attempt updates the row's `channel` + `status` + `error`. This * keeps fallback chains race-safe with the UNIQUE(idempotency_key) index. */ declare function dispatch

(deps: NotifyDeps

, registry: AdapterRegistry, input: DispatchInput

): Promise; interface NotifyConsumerOptions

extends NotifyDeps

{} type ConsumerLookup

= (notificationId: string) => { template: Record>; fallback: ReadonlyArray; } | undefined; /** * Build a queue consumer function that takes a DispatchJob and runs the * full dispatch pipeline. Wire this into your @workkit/queue consumer or a * Worker `queue` handler. * * `lookup` resolves a notification id to the template + fallback chain. Use * a closed-over Map of `notify.define()` results. */ declare function createNotifyConsumer

(opts: NotifyConsumerOptions

, lookup: ConsumerLookup

): (job: DispatchJob

) => Promise; /** * Convert any thrown value into a failed `AdapterSendResult`. When the * value is a `WorkkitError`, populates `retryable` and `retryStrategy` * from it; otherwise the structured fields are left undefined and only * `error` (the stringified message) is set. See ADR-002. * * Adapter authors can use this helper in their `catch` blocks to avoid * inlining the `instanceof WorkkitError` check on every provider: * * ```ts * try { * const { messageId } = await provider.deliver(...); * return { status: "sent", providerId: messageId }; * } catch (err) { * return adapterFailedFromError(err); * } * ``` */ declare function adapterFailedFromError(err: unknown): AdapterSendResult; declare function readPreferences(db: NotifyD1, userId: string, notificationId: string): Promise; declare function upsertPreferences(db: NotifyD1, userId: string, notificationId: string, prefs: NotificationPreferences): Promise; interface OptOutRecord { channel: ChannelName; notificationId: string | null; optedOutAt: number; reason?: string; } /** * Returns true when the user is opted out of (channel, notificationId) — either * via a notification-specific row OR a global opt-out (notificationId IS NULL). */ declare function isOptedOut(db: NotifyD1, userId: string, channel: ChannelName, notificationId: string): Promise; declare function optOut(db: NotifyD1, userId: string, channel: ChannelName, notificationId: string | null, reason?: string, now?: number): Promise; declare function listOptOuts(db: NotifyD1, userId: string): Promise; /** * Returns true when `at` falls inside the quiet-hours window (in the * recipient's IANA timezone). Handles midnight wrap (start > end) and DST * by computing local time via `Intl.DateTimeFormat` rather than offset * arithmetic. */ declare function isWithinQuietHours(window: QuietHours, at?: Date): boolean; interface DeliveryRow { id: string; userId: string; notificationId: string; channel: ChannelName; status: DeliveryStatus; idempotencyKey: string; payload: string | null; providerId: string | null; error: string | null; attemptedAt: number; deliveredAt: number | null; } interface InsertDeliveryArgs { id: string; userId: string; notificationId: string; channel: ChannelName; status: DeliveryStatus; idempotencyKey: string; payload?: string | null; providerId?: string | null; error?: string | null; attemptedAt: number; deliveredAt?: number | null; } /** Returns true when the row was inserted; false on UNIQUE collision (duplicate). */ declare function insertDelivery(db: NotifyD1, args: InsertDeliveryArgs): Promise; declare function updateDeliveryStatus(db: NotifyD1, id: string, status: DeliveryStatus, patch?: { providerId?: string; error?: string; deliveredAt?: number; }): Promise; declare function findByIdempotencyKey(db: NotifyD1, idempotencyKey: string): Promise; declare function purgeOlderThan(db: NotifyD1, olderThanMs: number, now?: number): Promise<{ deleted: number; }>; /** * Recursively sort object keys so two payloads with the same data hash to * the same string regardless of key insertion order. Rejects NaN/Infinity * and circular references — both indicate caller bugs. Shared (DAG-style) * references that are not cyclic are allowed — we track only the active * recursion path, not every value seen. */ declare function canonicalJson(value: unknown, stack?: WeakSet): string; /** SHA-256 → hex via Web Crypto (available in Workers). */ declare function sha256Hex(input: string): Promise; /** * Build the dispatch idempotency key from `(userId, notificationId, payload)`. * Caller can short-circuit by supplying `override` for explicit retry-dedup * scenarios. */ declare function buildIdempotencyKey(args: { userId: string; notificationId: string; payload: unknown; override?: string; }): Promise; interface WebhookHandlerOptions { channel: ChannelName; db: NotifyD1; registry: AdapterRegistry; secret?: string; /** Tolerated webhook age in ms — older events rejected. Default 5 min. */ maxAgeMs?: number; } /** * Framework-agnostic webhook handler. Returns `(req: Request) => Promise`. * - 404 if no adapter for channel. * - 401 if signature verification fails. * - 422 if body is unparseable. * - 200 with `{ accepted, rejected }` count otherwise. */ declare function webhookHandler(opts: WebhookHandlerOptions): (req: Request) => Promise; interface ForgetUserResult { prefsDeleted: number; optOutsDeleted: number; deliveriesDeleted: number; } /** * Cascade-delete a user's notification footprint from D1. Queue draining * is OUT OF SCOPE here — it requires a queue-side primitive that does not * exist yet. Document loudly so callers know to also drain their queue. */ declare function forgetUser(db: NotifyD1, userId: string): Promise; declare const DEFAULT_CONFIG: NotifyConfig; declare function resolveConfig(partial?: Partial): NotifyConfig; /** * D1 schema for @workkit/notify. Run these once during your migration setup. * Stored as plain strings so consumers can pipe them into their migration * runner of choice (`@workkit/d1` or `wrangler d1 migrations`). */ declare const NOTIFICATION_PREFS_SQL: string; declare const NOTIFICATION_OPTOUTS_SQL: string; declare const NOTIFICATION_DELIVERIES_SQL: string; declare const ALL_MIGRATIONS: ReadonlyArray; import { ConfigError, ValidationError, WorkkitError } from "@workkit/errors"; import { RetryStrategy } from "@workkit/errors"; declare class NotifyConfigError extends ConfigError {} declare class PayloadValidationError extends ValidationError { constructor(notificationId: string, issues: Array<{ path: ReadonlyArray; message: string; }>); } declare class NoRecipientError extends WorkkitError { readonly code: "WORKKIT_NOT_FOUND"; readonly statusCode = 404; readonly retryable: false; readonly defaultRetryStrategy: RetryStrategy; constructor(userId: string); } export { webhookHandler, upsertPreferences, updateDeliveryStatus, sha256Hex, resolveConfig, readPreferences, purgeOlderThan, optOut, listOptOuts, isWithinQuietHours, isOptedOut, insertDelivery, forgetUser, findByIdempotencyKey, dispatch, define, createNotifyConsumer, canonicalJson, buildRegistry, buildIdempotencyKey, adapterFailedFromError, WebhookHandlerOptions, WebhookEvent, SendResult, SendOptions, Resolver, RecipientChannelAddress, Recipient, QuietHours, Priority, PayloadValidationError, OptOutRecord, NotifyPreparedStatement, NotifyDeps, NotifyD1, NotifyConsumerOptions, NotifyConfigError, NotifyConfig, NotificationPreferences, Notification, NoRecipientError, NOTIFICATION_PREFS_SQL, NOTIFICATION_OPTOUTS_SQL, NOTIFICATION_DELIVERIES_SQL, InsertDeliveryArgs, ForgetUserResult, DispatchOutcome, DispatchMode, DispatchJob, DispatchInput, DeliveryStatus, DeliveryRow, DefineNotificationOptions, DefineDeps, DEFAULT_CONFIG, ConsumerLookup, ChannelTemplate, ChannelName, AdapterSendResult, AdapterSendArgs, AdapterRegistry, Adapter, ALL_MIGRATIONS };