UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

1,430 lines (1,324 loc) 39.1 kB
import type { StandardSchemaV1 } from "@standard-schema/spec"; import { createJobs, type JobDef, type JobDispatcher, type JobHook, type JobRetryOptions, type JobTimeoutDuration, retry, } from "../jobs/index.js"; import type { SendMailOptions } from "../mail/index.js"; import type { ProviderInstrumentationTarget } from "../providers/index.js"; import { createProvider, createProviderInstrumentation, } from "../providers/index.js"; /** * Any Standard Schema compatible validator. */ export type StandardSchema = StandardSchemaV1<unknown, unknown>; /** * Value or promise of that value. */ export type MaybePromise<T> = T | Promise<T>; /** * Infer the parsed output type from a Standard Schema. */ export type InferSchemaOutput<T extends StandardSchemaV1> = StandardSchemaV1.InferOutput<T>; /** * Minimal notification definition shape accepted by notification ports. */ export interface NotificationPayloadDef< Name extends string = string, Payload extends StandardSchema = StandardSchema, > { /** * Stable notification name used by dispatchers, tests, and tooling. */ readonly name: Name; /** * Standard Schema payload validator. */ readonly payload: Payload; /** * Optional human-readable description for docs and tooling. */ readonly description?: string; } /** * Infer the parsed payload type for a notification definition. */ export type InferNotificationPayload<N extends NotificationPayloadDef> = N["payload"] extends StandardSchemaV1<unknown, infer Output> ? Output : never; /** * Result for one notification channel. */ export interface NotificationChannelResult { /** * Channel name, such as `email`, `sms`, `push`, or `inApp`. */ channel: string; /** * Delivery outcome for this channel. */ status: "queued" | "sent" | "skipped" | "failed"; /** * Provider delivery ID when available. */ id?: string; /** * Provider name when available. */ provider?: string; /** * Human-readable skip or failure reason. */ reason?: string; /** * Channel-specific metadata. Dispatchers should keep this safe to log. */ details?: Record<string, unknown>; } /** * Original error captured for one failed notification channel. */ export interface NotificationChannelError { channel: string; error: unknown; } /** * Arguments passed to a notification channel handler. */ export interface NotificationChannelHandleArgs< Payload extends StandardSchema, Ctx, > { /** * Notification definition being delivered. */ notification: NotificationDef<string, Payload, Ctx>; /** * Parsed notification payload. */ payload: InferSchemaOutput<Payload>; /** * Handler context. */ ctx: Ctx; /** * Channel name being delivered. */ channel: string; } /** * Handler for one notification channel. */ export type NotificationChannelHandler<Payload extends StandardSchema, Ctx> = ( args: NotificationChannelHandleArgs<Payload, Ctx>, ) => MaybePromise<NotificationChannelResult | undefined>; /** * Notification channel handlers keyed by channel name. */ export type NotificationChannels<Payload extends StandardSchema, Ctx> = Record< string, NotificationChannelHandler<Payload, Ctx> >; /** * Arguments passed to an app-owned notification preference evaluator. */ export interface NotificationPreferenceArgs< Payload extends StandardSchema = StandardSchema, Ctx = unknown, > extends NotificationChannelHandleArgs<Payload, Ctx> { /** * Optional metadata supplied by the notification sender. */ metadata?: Record<string, unknown>; } /** * App-owned decision for one notification channel. */ export interface NotificationPreferenceDecision { /** * Whether this channel should deliver. */ deliver: boolean; /** * Optional reason recorded when delivery is skipped. */ reason?: string; } /** * Optional app-facing port for notification channel preferences and opt-outs. */ export interface NotificationPreferencesPort<Ctx = unknown> { /** * Evaluate the current preference immediately before channel delivery. */ evaluate( args: NotificationPreferenceArgs<StandardSchema, Ctx>, ): MaybePromise<NotificationPreferenceDecision>; } /** * Notification definition created by `defineNotification(...)`. */ export interface NotificationDef< Name extends string = string, Payload extends StandardSchema = StandardSchema, Ctx = unknown, > extends NotificationPayloadDef<Name, Payload> { /** * Discriminator for notification definitions. */ readonly kind: "notification"; /** * Channel handlers that deliver the notification. */ readonly channels: NotificationChannels<Payload, Ctx>; } /** * Options for declaring a typed notification. */ export interface DefineNotificationOptions< Payload extends StandardSchema, Ctx, > { /** * Standard Schema payload validator. */ payload: Payload; /** * Optional human-readable description for docs and tooling. */ description?: string; /** * Channel handlers that deliver the notification. */ channels: NotificationChannels<Payload, Ctx>; } /** * Options passed when sending a notification. */ export interface SendNotificationOptions { /** * Subset of channels to deliver. Defaults to all channels on the definition. */ channels?: readonly string[]; /** * Optional app metadata attached to memory deliveries and instrumentation. */ metadata?: Record<string, unknown>; /** * Request correlation ID for instrumentation. */ requestId?: string; /** * Trace identifier for instrumentation. */ traceId?: string; /** * Span identifier for instrumentation. */ spanId?: string; /** * Parent span identifier for instrumentation. */ parentSpanId?: string; /** * W3C traceparent header value for instrumentation. */ traceparent?: string; } /** * Result returned after a notification send attempt. */ export interface SendNotificationResult { /** * Notification name. */ notificationName: string; /** * Parsed notification payload. */ payload: unknown; /** * Channels selected for delivery. */ channels: readonly string[]; /** * Per-channel delivery results. */ results: readonly NotificationChannelResult[]; } /** * App-facing notification port. */ export interface NotificationPort { /** * Send a typed notification. */ send<N extends NotificationDef>( notification: N, payload: InferNotificationPayload<N>, options?: SendNotificationOptions, ): Promise<SendNotificationResult>; } /** * Options for the inline notification dispatcher. */ export interface InlineNotificationDispatcherOptions<Ctx> { /** * Static notification context or factory evaluated for each send. */ ctx?: Ctx | (() => MaybePromise<Ctx>); /** * Called when a channel handler or preference check fails. A returned result * replaces the default failed result. Observer failures are ignored so the * remaining channels still run. */ onError?: ( error: unknown, args: NotificationChannelHandleArgs<StandardSchema, Ctx>, ) => MaybePromise<NotificationChannelResult | undefined>; /** * How completed channel failures are surfaced. Defaults to `"report"`. * `"throw"` still runs every selected channel before rejecting. */ failureMode?: "report" | "throw"; /** * Optional app-owned notification preference evaluator. */ preferences?: NotificationPreferencesPort<Ctx>; /** * Optional devtools/provider instrumentation target. */ instrumentation?: ProviderInstrumentationTarget; } /** * Delivery captured by the memory notification port. */ export interface MemoryNotificationDelivery { /** * Generated delivery ID. */ id: string; /** * Notification name. */ notificationName: string; /** * Parsed payload that would have been sent. */ payload: unknown; /** * Selected channels. */ channels: readonly string[]; /** * Optional app metadata supplied by the caller. */ metadata?: Record<string, unknown>; /** * Timestamp assigned by the memory port. */ sentAt: Date; } /** * In-memory notification port for tests and local examples. */ export interface MemoryNotificationPort extends NotificationPort { /** * Captured notification sends. */ readonly deliveries: readonly MemoryNotificationDelivery[]; /** * Clear captured notification sends. */ clear(): void; } /** * Options for `createMemoryNotificationPort(...)`. */ export interface CreateMemoryNotificationPortOptions { /** * Clock used for captured deliveries. */ now?: () => Date; /** * ID factory used for captured deliveries. */ id?: () => string; /** * Observer called after a delivery is captured. */ onSend?: (delivery: MemoryNotificationDelivery) => MaybePromise<void>; } /** * Context shape required by `defineMailNotificationChannel(...)`. */ export interface MailNotificationContext { ports: { mailer: { send(message: SendMailOptions): MaybePromise<{ id?: string; provider?: string; }>; }; }; } /** * Render a mail message for one notification payload. */ export type MailNotificationRenderer< Payload extends StandardSchema, Ctx extends MailNotificationContext, > = ( args: NotificationChannelHandleArgs<Payload, Ctx>, ) => MaybePromise<SendMailOptions | undefined>; /** * Context-bound notification helper factory. */ export interface Notifications<Ctx> { /** * Define a notification with the bound context type. */ defineNotification<Name extends string, Payload extends StandardSchema>( name: Name, options: DefineNotificationOptions<Payload, Ctx>, ): NotificationDef<Name, Payload, Ctx>; } /** * Notification definitions available to durable delivery workers. */ export interface NotificationRegistry<Ctx = unknown> { /** * Registered definitions in declaration order. */ readonly definitions: readonly NotificationDef<string, StandardSchema, Ctx>[]; /** * Resolve a notification definition by its stable name. */ get(name: string): NotificationDef<string, StandardSchema, Ctx> | undefined; } /** * Payload carried by the first-party notification delivery job. */ export interface NotificationDeliveryJobPayload { notificationName: string; channel: string; payload: unknown; options: Omit<SendNotificationOptions, "channels">; } type NotificationDeliveryPayloadSchema = StandardSchemaV1< unknown, NotificationDeliveryJobPayload >; /** * Job definition used by queued notification dispatchers and workers. */ export interface NotificationDeliveryJob< Name extends string = string, Ctx = unknown, > extends JobDef<Name, NotificationDeliveryPayloadSchema, Ctx> { /** * Registry used by both enqueue-time checks and worker delivery. */ readonly registry: NotificationRegistry<Ctx>; } /** * Options for the first-party notification delivery job. */ export interface DefineNotificationDeliveryJobOptions< Name extends string, Ctx, > { /** * Stable job name. Defaults to `"notifications.deliver"`. */ name?: Name; /** * Notification definitions available to the worker. */ registry: NotificationRegistry<Ctx>; /** * Optional app-owned preferences evaluated when the job runs. */ preferences?: NotificationPreferencesPort<Ctx>; /** * Retry policy. Defaults to exponential backoff with three attempts. */ retry?: JobRetryOptions; /** * Optional maximum duration for each channel delivery attempt. */ timeout?: JobTimeoutDuration; /** * Optional execution hooks applied to each delivery attempt. */ hooks?: readonly JobHook< JobDef<Name, NotificationDeliveryPayloadSchema, Ctx>, Ctx >[]; } /** * Options for a notification dispatcher backed by Beignet jobs. */ export interface QueuedNotificationDispatcherOptions<Name extends string, Ctx> { /** * Job dispatcher used to enqueue one delivery job per channel. */ jobs: JobDispatcher; /** * Registered notification delivery job. */ deliveryJob: NotificationDeliveryJob<Name, Ctx>; /** * Optional devtools/provider instrumentation target. */ instrumentation?: ProviderInstrumentationTarget; } /** * Error thrown when notification payload validation fails. */ export class NotificationValidationError extends Error { /** * Raw Standard Schema validation issues. */ readonly issues: readonly StandardSchemaV1.Issue[]; constructor(args: { name: string; issues: readonly StandardSchemaV1.Issue[]; }) { super( `Notification "${args.name}" payload validation failed: ${formatIssues(args.issues)}`, ); this.name = "NotificationValidationError"; this.issues = args.issues; } } /** * Error thrown when notification delivery fails. */ export class NotificationDeliveryError extends Error { /** * Notification name. */ readonly notificationName: string; /** * First channel that failed, retained for concise error handling. */ readonly channel: string; /** * Original error for the first failed channel when available. */ readonly cause: unknown; /** * Complete notification result after every selected channel ran. */ readonly result: SendNotificationResult; /** * Failed channel results. */ readonly failures: readonly NotificationChannelResult[]; /** * Original channel errors in delivery order. */ readonly errors: readonly NotificationChannelError[]; constructor(args: { result: SendNotificationResult; errors?: readonly NotificationChannelError[]; }) { const failures = args.result.results.filter( (result) => result.status === "failed", ); const firstFailure = failures[0]; const errors = args.errors ?? []; super( `Notification "${args.result.notificationName}" failed on ${failures.length} channel${failures.length === 1 ? "" : "s"}: ${failures.map((failure) => failure.channel).join(", ")}.`, ); this.name = "NotificationDeliveryError"; this.notificationName = args.result.notificationName; this.channel = firstFailure?.channel ?? "unknown"; this.cause = errors.find( (entry) => entry.channel === firstFailure?.channel, )?.error; this.result = args.result; this.failures = failures; this.errors = errors; } } /** * Error thrown when a notification registry cannot safely resolve a delivery. */ export class NotificationRegistryError extends Error { constructor(message: string) { super(message); this.name = "NotificationRegistryError"; } } function formatPath(path: StandardSchemaV1.Issue["path"]): string { if (!path?.length) return ""; return path .map((segment) => typeof segment === "object" && segment !== null && "key" in segment ? String(segment.key) : String(segment), ) .join("."); } function formatIssues(issues: readonly StandardSchemaV1.Issue[]): string { return issues .map((issue) => { const path = formatPath(issue.path); return path ? `${path}: ${issue.message}` : issue.message; }) .join("; "); } async function parsePayload<Schema extends StandardSchemaV1>( schema: Schema, input: unknown, args: { name: string }, ): Promise<InferSchemaOutput<Schema>> { const result = await schema["~standard"].validate(input); if (result.issues?.length) { throw new NotificationValidationError({ name: args.name, issues: result.issues, }); } if ("value" in result) { return result.value as InferSchemaOutput<Schema>; } throw new Error("Invalid Standard Schema result: missing value"); } async function resolveCtx<Ctx>( ctx: Ctx | (() => MaybePromise<Ctx>) | undefined, ): Promise<Ctx> { if (typeof ctx === "function") { return (ctx as () => MaybePromise<Ctx>)(); } return ctx as Ctx; } function resolveChannelNames( notification: { name: string; channels: Record<string, unknown>; }, options: | { channels?: readonly string[]; } | undefined, ): readonly string[] { const available = Object.keys(notification.channels); const selected = options?.channels ?? available; for (const channel of selected) { if (typeof notification.channels[channel] !== "function") { throw new Error( `Notification "${notification.name}" does not define channel "${channel}".`, ); } } return selected; } function summarizeResults(results: readonly NotificationChannelResult[]) { const queued = results.filter((result) => result.status === "queued").length; const sent = results.filter((result) => result.status === "sent").length; const skipped = results.filter( (result) => result.status === "skipped", ).length; const failed = results.filter((result) => result.status === "failed").length; return { queued, sent, skipped, failed }; } function resultSummary(results: readonly NotificationChannelResult[]): string { const summary = summarizeResults(results); const parts = [ summary.queued ? `${summary.queued} queued` : undefined, `${summary.sent} sent`, `${summary.skipped} skipped`, `${summary.failed} failed`, ].filter((part): part is string => Boolean(part)); return parts.join(", "); } function recordChannelResult( instrumentation: ReturnType<typeof createProviderInstrumentation>, notificationName: string, result: NotificationChannelResult, options: SendNotificationOptions, ): void { instrumentation.custom({ name: `notification.channel.${result.status}`, label: `Notification channel ${result.status}`, summary: `${notificationName} (${result.channel})`, requestId: options.requestId, traceId: options.traceId, spanId: options.spanId, parentSpanId: options.parentSpanId, traceparent: options.traceparent, details: { notificationName, channel: result.channel, status: result.status, id: result.id, provider: result.provider, reason: result.reason, resultDetails: result.details, metadata: options.metadata, }, }); } async function failedChannelResult<Ctx>( error: unknown, args: NotificationChannelHandleArgs<StandardSchema, Ctx>, onError: InlineNotificationDispatcherOptions<Ctx>["onError"], ): Promise<NotificationChannelResult> { if (onError) { try { const handled = await onError(error, args); if (handled) return { ...handled, channel: args.channel }; } catch { // Error observers cannot interrupt delivery of the remaining channels. } } return { channel: args.channel, status: "failed", reason: error instanceof Error ? error.message : String(error), }; } function defineNotificationImpl< Name extends string, Payload extends StandardSchema, Ctx = unknown, >( name: Name, options: DefineNotificationOptions<Payload, Ctx>, ): NotificationDef<Name, Payload, Ctx> { return { kind: "notification", name, payload: options.payload, description: options.description, channels: options.channels, }; } function isRecord(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); } function contextInstrumentationTarget( ctx: unknown, ): ProviderInstrumentationTarget { if (!isRecord(ctx)) return undefined; return isRecord(ctx.ports) ? ctx.ports : ctx; } const notificationDeliveryPayloadSchema: NotificationDeliveryPayloadSchema = { "~standard": { version: 1, vendor: "beignet", validate(value: unknown) { if (!isRecord(value)) { return { issues: [{ message: "Expected an object." }] }; } const issues: StandardSchemaV1.Issue[] = []; if ( typeof value.notificationName !== "string" || value.notificationName.length === 0 ) { issues.push({ message: "Expected a non-empty string.", path: ["notificationName"], }); } if (typeof value.channel !== "string" || value.channel.length === 0) { issues.push({ message: "Expected a non-empty string.", path: ["channel"], }); } if (!("payload" in value)) { issues.push({ message: "Required.", path: ["payload"] }); } if (!isRecord(value.options)) { issues.push({ message: "Expected an object.", path: ["options"] }); } const deliveryOptions = isRecord(value.options) ? value.options : {}; const correlationFields = [ "requestId", "traceId", "spanId", "parentSpanId", "traceparent", ] as const; for (const field of correlationFields) { if ( deliveryOptions[field] !== undefined && typeof deliveryOptions[field] !== "string" ) { issues.push({ message: "Expected a string.", path: ["options", field], }); } } if ( deliveryOptions.metadata !== undefined && !isRecord(deliveryOptions.metadata) ) { issues.push({ message: "Expected an object.", path: ["options", "metadata"], }); } if (issues.length > 0) return { issues }; return { value: { notificationName: value.notificationName as string, channel: value.channel as string, payload: value.payload, options: queuedSendOptions(deliveryOptions), }, }; }, }, }; /** * Define the notification catalog available to durable delivery workers. * Duplicate names throw because queued delivery resolves definitions by name. */ export function defineNotificationRegistry<Ctx>( definitions: readonly NotificationDef<string, StandardSchema, Ctx>[], ): NotificationRegistry<Ctx> { const byName = new Map< string, NotificationDef<string, StandardSchema, Ctx> >(); for (const notification of definitions) { if (byName.has(notification.name)) { throw new NotificationRegistryError( `Duplicate notification definition "${notification.name}" in notification registry.`, ); } byName.set(notification.name, notification); } return { definitions: [...definitions], get(name) { return byName.get(name); }, }; } /** * Define the generic job that resolves and delivers one notification channel. * Register the returned job with every worker or outbox registry that can * receive queued notifications. */ export function defineNotificationDeliveryJob< Ctx, Name extends string = "notifications.deliver", >( options: DefineNotificationDeliveryJobOptions<Name, Ctx>, ): NotificationDeliveryJob<Name, Ctx> { const name = (options.name ?? "notifications.deliver") as Name; const { defineJob } = createJobs<Ctx>(); const job = defineJob(name, { payload: notificationDeliveryPayloadSchema, description: "Deliver one notification channel.", retry: options.retry ?? retry.exponential({ attempts: 3 }), timeout: options.timeout, hooks: options.hooks, async handle({ payload, ctx }) { const notification = options.registry.get(payload.notificationName); if (!notification) { throw new NotificationRegistryError( `Notification "${payload.notificationName}" is not registered for queued delivery.`, ); } if (!notification.channels[payload.channel]) { throw new NotificationRegistryError( `Notification "${payload.notificationName}" does not define queued channel "${payload.channel}".`, ); } const dispatcher = createInlineNotificationDispatcher<Ctx>({ ctx, preferences: options.preferences, failureMode: "throw", instrumentation: contextInstrumentationTarget(ctx), }); await dispatcher.send(notification as NotificationDef, payload.payload, { ...payload.options, channels: [payload.channel], }); }, }); return Object.assign(job, { registry: options.registry, }); } /** * Validate and parse a notification payload with the notification's Standard * Schema. */ export async function parseNotificationPayload< N extends NotificationPayloadDef, >(notification: N, payload: unknown): Promise<InferNotificationPayload<N>> { return (await parsePayload(notification.payload, payload, { name: notification.name, })) as InferNotificationPayload<N>; } /** * Create an inline notification dispatcher. * * The dispatcher validates payloads and runs selected channel handlers * immediately. Channel failures are isolated and reported after every selected * channel runs. Use this directly in tests and local apps, or use * `createQueuedNotificationDispatcher(...)` for background execution. */ export function createInlineNotificationDispatcher<Ctx>( options: InlineNotificationDispatcherOptions<Ctx> = {}, ): NotificationPort { const instrumentation = createProviderInstrumentation( options.instrumentation, { providerName: "notifications", watcher: "notifications", }, ); return { async send<N extends NotificationDef<string, StandardSchema, Ctx>>( notification: N, payload: InferNotificationPayload<N>, sendOptions: SendNotificationOptions = {}, ) { const parsed = await parseNotificationPayload(notification, payload); const channels = resolveChannelNames(notification, sendOptions); const ctx = await resolveCtx(options.ctx); const results: NotificationChannelResult[] = []; const errors: NotificationChannelError[] = []; instrumentation.custom({ name: "notification.send.started", label: "Notification started", summary: notification.name, requestId: sendOptions.requestId, traceId: sendOptions.traceId, spanId: sendOptions.spanId, parentSpanId: sendOptions.parentSpanId, traceparent: sendOptions.traceparent, details: { notificationName: notification.name, channels, metadata: sendOptions.metadata, }, }); for (const channel of channels) { const handler = notification.channels[channel]; const args = { notification, payload: parsed, ctx, channel, } satisfies NotificationChannelHandleArgs<StandardSchema, Ctx>; try { const preference = await options.preferences?.evaluate({ ...args, metadata: sendOptions.metadata, }); if (preference && !preference.deliver) { const result = { channel, status: "skipped", reason: preference.reason ?? "Disabled by notification preferences.", details: { source: "preferences", }, } satisfies NotificationChannelResult; results.push(result); recordChannelResult( instrumentation, notification.name, result, sendOptions, ); continue; } const handled = await handler(args); const result = handled ? { ...handled, channel } : ({ channel, status: "sent", } satisfies NotificationChannelResult); results.push(result); recordChannelResult( instrumentation, notification.name, result, sendOptions, ); } catch (error) { errors.push({ channel, error }); const result = await failedChannelResult( error, args, options.onError, ); results.push(result); recordChannelResult( instrumentation, notification.name, result, sendOptions, ); } } const result = { notificationName: notification.name, payload: parsed, channels, results, } satisfies SendNotificationResult; instrumentation.custom({ name: "notification.send.completed", label: "Notification completed", summary: `${notification.name} (${resultSummary(results)})`, requestId: sendOptions.requestId, traceId: sendOptions.traceId, spanId: sendOptions.spanId, parentSpanId: sendOptions.parentSpanId, traceparent: sendOptions.traceparent, details: { notificationName: notification.name, channels, results, metadata: sendOptions.metadata, }, }); if ( options.failureMode === "throw" && results.some((channelResult) => channelResult.status === "failed") ) { throw new NotificationDeliveryError({ result, errors }); } return result; }, }; } /** * Options for the inline notifications provider. */ export interface InlineNotificationsProviderOptions extends Omit< InlineNotificationDispatcherOptions<unknown>, "ctx" | "instrumentation" > { /** * Provider name. Defaults to "inline-notifications". */ name?: string; } /** * Ports contributed by the inline notifications provider. */ export interface InlineNotificationsProviderPorts { /** * Beignet notification port. */ notifications: NotificationPort; } /** * Create a provider that contributes an inline notification dispatcher. * * Use it as the dev-default `notifications` port in `server/providers.ts`. * Channel handlers run with an app service context built lazily through the * server context blueprint on each send, so the provider is safe to register * before all providers have started. Sends are recorded as devtools events * through the `notifications` watcher when an instrumentation port is * installed. */ export function createInlineNotificationsProvider( options: InlineNotificationsProviderOptions = {}, ) { const { name = "inline-notifications", ...dispatcherOptions } = options; return createProvider({ name, setup({ ports, createServiceContext }) { const notifications = createInlineNotificationDispatcher({ ...dispatcherOptions, ctx: () => createServiceContext(), instrumentation: ports, }); return { ports: { notifications, } satisfies InlineNotificationsProviderPorts, }; }, }); } function queuedSendOptions( options: Partial<Record<keyof SendNotificationOptions, unknown>>, ): Omit<SendNotificationOptions, "channels"> { const queued: Omit<SendNotificationOptions, "channels"> = {}; if (isRecord(options.metadata)) queued.metadata = options.metadata; if (typeof options.requestId === "string") queued.requestId = options.requestId; if (typeof options.traceId === "string") queued.traceId = options.traceId; if (typeof options.spanId === "string") queued.spanId = options.spanId; if (typeof options.parentSpanId === "string") { queued.parentSpanId = options.parentSpanId; } if (typeof options.traceparent === "string") queued.traceparent = options.traceparent; return queued; } /** * Create a notification dispatcher that enqueues one delivery job per channel. * Separate jobs keep provider retries from resending channels that already * completed successfully. */ export function createQueuedNotificationDispatcher<Name extends string, Ctx>( options: QueuedNotificationDispatcherOptions<Name, Ctx>, ): NotificationPort { const instrumentation = createProviderInstrumentation( options.instrumentation, { providerName: "queued-notifications", watcher: "notifications", }, ); return { async send<N extends NotificationDef>( notification: N, payload: InferNotificationPayload<N>, sendOptions: SendNotificationOptions = {}, ) { const registered = options.deliveryJob.registry.get(notification.name); if (!registered) { throw new NotificationRegistryError( `Notification "${notification.name}" is not registered for queued delivery.`, ); } const parsed = await parseNotificationPayload(registered, payload); const channels = resolveChannelNames(registered, sendOptions); const results: NotificationChannelResult[] = []; instrumentation.custom({ name: "notification.enqueue.started", label: "Notification enqueue started", summary: notification.name, requestId: sendOptions.requestId, traceId: sendOptions.traceId, spanId: sendOptions.spanId, parentSpanId: sendOptions.parentSpanId, traceparent: sendOptions.traceparent, details: { notificationName: notification.name, channels, metadata: sendOptions.metadata, jobName: options.deliveryJob.name, }, }); for (const channel of channels) { let result: NotificationChannelResult; try { await options.jobs.dispatch(options.deliveryJob, { notificationName: notification.name, channel, payload, options: queuedSendOptions(sendOptions), }); result = { channel, status: "queued", provider: "jobs", details: { jobName: options.deliveryJob.name, }, }; } catch (error) { result = { channel, status: "failed", reason: error instanceof Error ? error.message : String(error), details: { phase: "enqueue", jobName: options.deliveryJob.name, }, }; } results.push(result); recordChannelResult( instrumentation, notification.name, result, sendOptions, ); } const result = { notificationName: notification.name, payload: parsed, channels, results, } satisfies SendNotificationResult; instrumentation.custom({ name: "notification.enqueue.completed", label: "Notification enqueue completed", summary: `${notification.name} (${resultSummary(results)})`, requestId: sendOptions.requestId, traceId: sendOptions.traceId, spanId: sendOptions.spanId, parentSpanId: sendOptions.parentSpanId, traceparent: sendOptions.traceparent, details: { notificationName: notification.name, channels, results, metadata: sendOptions.metadata, jobName: options.deliveryJob.name, }, }); return result; }, }; } /** * Options for the queued notifications provider. */ export interface QueuedNotificationsProviderOptions<Name extends string, Ctx> { /** * Registered notification delivery job. */ deliveryJob: NotificationDeliveryJob<Name, Ctx>; /** * Provider name. Defaults to `"queued-notifications"`. */ name?: string; } /** * Create a provider that contributes a job-backed notification dispatcher. */ export function createQueuedNotificationsProvider<Name extends string, Ctx>( options: QueuedNotificationsProviderOptions<Name, Ctx>, ) { return createProvider<{ jobs: JobDispatcher }>()({ name: options.name ?? "queued-notifications", setup({ ports }) { return { ports: { notifications: createQueuedNotificationDispatcher({ jobs: ports.jobs, deliveryJob: options.deliveryJob, instrumentation: ports, }), } satisfies InlineNotificationsProviderPorts, }; }, }); } /** * Define a mail-backed notification channel. * * Return `undefined` from the renderer when the channel should be skipped, for * example when a recipient does not have an email address. */ export function defineMailNotificationChannel< Payload extends StandardSchema, Ctx extends MailNotificationContext, >( render: MailNotificationRenderer<Payload, Ctx>, ): NotificationChannelHandler<Payload, Ctx> { return async (args) => { const message = await render(args); if (!message) { return { channel: args.channel, status: "skipped", reason: "No mail message was returned.", }; } const result = await args.ctx.ports.mailer.send(message); return { channel: args.channel, status: "sent", id: result.id, provider: result.provider, }; }; } /** * Create an in-memory notification port for tests and examples. * * The memory port validates payloads and records notification intent without * running channel handlers. */ export function createMemoryNotificationPort( options: CreateMemoryNotificationPortOptions = {}, ): MemoryNotificationPort { const deliveries: MemoryNotificationDelivery[] = []; const now = options.now ?? (() => new Date()); const id = options.id ?? (() => crypto.randomUUID()); return { get deliveries() { return deliveries; }, async send<N extends NotificationDef>( notification: N, payload: InferNotificationPayload<N>, sendOptions: SendNotificationOptions = {}, ) { const parsed = await parseNotificationPayload(notification, payload); const channels = resolveChannelNames(notification, sendOptions); const delivery: MemoryNotificationDelivery = { id: id(), notificationName: notification.name, payload: parsed, channels, metadata: sendOptions.metadata, sentAt: now(), }; deliveries.push(delivery); await options.onSend?.(delivery); return { notificationName: notification.name, payload: parsed, channels, results: channels.map((channel) => ({ channel, status: "sent", id: delivery.id, provider: "memory", details: { mode: "intent", }, })), }; }, clear() { deliveries.length = 0; }, }; } /** * Create notification helper methods bound to an application context type. * * Call it once in `lib/notifications.ts`: * * ```ts * export const { defineNotification } = createNotifications<AppContext>(); * ``` * * Notifications represent user-facing communication intent. Channel handlers * decide how that intent becomes mail, SMS, push, in-app delivery, or another * app-owned channel. */ export function createNotifications<Ctx>(): Notifications<Ctx> { return { defineNotification<Name extends string, Payload extends StandardSchema>( name: Name, options: DefineNotificationOptions<Payload, Ctx>, ): NotificationDef<Name, Payload, Ctx> { return defineNotificationImpl(name, options); }, }; }