better-ship 0.23.2 → 0.25.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/dist/application-C_xN_1pa.js.map +1 -1
  2. package/dist/application.d.ts +2 -2
  3. package/dist/cloudflare/queue.d.ts +2 -2
  4. package/dist/cloudflare/queue.d.ts.map +1 -1
  5. package/dist/cloudflare/queue.js +20 -3
  6. package/dist/cloudflare/queue.js.map +1 -1
  7. package/dist/cloudflare/tracing.d.ts +24 -0
  8. package/dist/cloudflare/tracing.d.ts.map +1 -0
  9. package/dist/cloudflare/tracing.js +21 -0
  10. package/dist/cloudflare/tracing.js.map +1 -0
  11. package/dist/cloudflare.d.ts +2 -2
  12. package/dist/{index-tNZHSCpJ.d.ts → index-lS_g1olK.d.ts} +17 -2
  13. package/dist/index-lS_g1olK.d.ts.map +1 -0
  14. package/dist/{outbox-repository-options-_B8l8l6l.d.ts → outbox-repository-options-15Sv9WnG.d.ts} +2 -2
  15. package/dist/{outbox-repository-options-_B8l8l6l.d.ts.map → outbox-repository-options-15Sv9WnG.d.ts.map} +1 -1
  16. package/dist/postgres.d.ts +2 -2
  17. package/dist/posthog/metrics.d.ts +1 -1
  18. package/dist/posthog/server.d.ts +1 -1
  19. package/dist/resend.d.ts +6 -1
  20. package/dist/resend.d.ts.map +1 -1
  21. package/dist/resend.js +35 -0
  22. package/dist/resend.js.map +1 -1
  23. package/dist/sentry/server.d.ts +1 -1
  24. package/dist/tanstack.d.ts +10 -1
  25. package/dist/tanstack.d.ts.map +1 -1
  26. package/dist/tanstack.js +11 -0
  27. package/dist/tanstack.js.map +1 -1
  28. package/dist/testing/outbox-repository.d.ts +1 -1
  29. package/package.json +2 -1
  30. package/src/application/email-service.ts +17 -0
  31. package/src/application/index.ts +2 -0
  32. package/src/infrastructure/cloudflare/queue/queue-consumer.ts +23 -3
  33. package/src/infrastructure/cloudflare/tracing.ts +36 -0
  34. package/src/infrastructure/resend/resend-email-service.ts +39 -0
  35. package/src/infrastructure/tanstack/auth.middleware.ts +20 -0
  36. package/src/infrastructure/tanstack/index.ts +1 -0
  37. package/dist/index-tNZHSCpJ.d.ts.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"application-C_xN_1pa.js","names":["logger"],"sources":["../src/application/analytics-service.ts","../src/application/metrics-service.ts","../src/application/email-service.ts","../src/application/record-email-event.ts","../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/outbox-relay.ts","../src/application/outbox-retention.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'\n\n/** Product analytics without an SDK dependency, ported from Typist and Porte. */\nexport interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {\n /** Attach traits to one person. */\n identify(distinctId: string, properties?: AnalyticsProperties): void\n /** Merge a previous ID into this person; the application must establish that both identify the same person. */\n alias(distinctId: string, previousDistinctId: string): void\n /** Schedule an event for a person; returning does not confirm remote acceptance. */\n track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n /** Schedule an event without creating a person profile for its distinct ID. */\n trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n}\n\n/** Reports to nobody. The composition root selects this service when analytics is disabled. */\nexport const NULL_ANALYTICS: AnalyticsService = {\n identify: () => {},\n alias: () => {},\n track: () => {},\n trackAnonymous: () => {},\n}\n","/** A metric dimension; use a small, bounded value set and never user or request IDs. */\nexport type MetricAttributeValue = string | number | boolean\n\n/** Dimensions shared by observations in one time series. */\nexport type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>\n\n/** Unit and dimensions for an operational measurement. */\nexport interface MetricOptions {\n readonly attributes?: MetricAttributes\n readonly unit?: string\n}\n\n/** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */\nexport interface MetricsService<TName extends string = string> {\n /** Add a nonnegative value to a total; the default increment is one. */\n count(name: TName, value?: number, options?: MetricOptions): void\n /** Record a current value. */\n gauge(name: TName, value: number, options?: MetricOptions): void\n /** Record one observation in a distribution. */\n histogram(name: TName, value: number, options?: MetricOptions): void\n}\n\n/** Records nothing when the composition root disables metrics. */\nexport const NULL_METRICS_SERVICE: MetricsService = {\n count: () => {},\n gauge: () => {},\n histogram: () => {},\n}\n","import type { EmailIdempotencyKey } from '@/core'\n\n/** A media type such as `application/pdf`. */\nexport type MimeType = `${string}/${string}`\n\n/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */\nexport type EmailAttachment = {\n readonly filename: string\n readonly content: string | Uint8Array\n /** Optional MIME metadata; Resend accepts filename and content without it. */\n readonly contentType?: MimeType\n}\n\n/**\n * One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,\n * and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email\n * and https://resend.com/docs/dashboard/emails/tags\n */\nexport type OutboundEmail = {\n /** Override the default for this email, for example \"Display Name <address@domain.com>\". */\n readonly from?: string\n readonly to: string | readonly string[]\n readonly subject: string\n readonly html: string\n /** Optional stable identity of this send. Resend deduplicates supplied keys for 24 hours. */\n readonly idempotencyKey?: EmailIdempotencyKey\n /** Resend generates plain text from html when omitted. An empty string disables that generation. */\n readonly text?: string\n readonly replyTo?: string\n /** Labels the provider stores with the email and returns on every delivery event. */\n readonly tags?: Readonly<Record<string, string>>\n readonly attachments?: readonly EmailAttachment[]\n}\n\n/** Received email headers, without the body or attachments. */\nexport type ReceivedEmail = { readonly headers: Readonly<Record<string, string>> }\n\n/** The provider accepted the email and assigned its id. */\nexport type EmailAccepted = { readonly providerId: string }\n\n/**\n * The adapter supplies the default sender and maps provider errors. Each email can override the sender.\n * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.\n */\nexport interface IEmailService {\n /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */\n sendEmail(email: OutboundEmail): Promise<EmailAccepted>\n}\n\n/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */\nexport class RecordingEmailService implements IEmailService {\n /** Sends in call order, including repeated idempotency keys. */\n readonly sent: OutboundEmail[] = []\n\n /** Record the send before returning its provider id. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n this.sent.push(email)\n return { providerId: `recorded-${this.sent.length}` }\n }\n}\n","import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'\n\nimport type { IUnitOfWork } from './unit-of-work.ts'\n\n/** What a bounce records for one address. */\nexport type EmailBounceRecord = {\n readonly type: BounceType\n readonly subType: string | null\n}\n\n/**\n * The application's deliverability state per address. An implementation keeps the higher bounce\n * severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.\n */\nexport interface IEmailDeliverabilityStore {\n /** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */\n recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>\n /** Record a complaint without clearing existing suppression state. */\n recordComplaint(address: EmailAddress): Promise<void>\n}\n\n/** The repositories the handler needs from the unit of work. */\nexport type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }\n\n/** The container slice the handler reads. */\nexport type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }\n\n/** The command, under whatever name the application registered the payload. */\nexport type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload\n\n/**\n * Record a bounce or a complaint for every recipient inside the command's unit of work.\n * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.\n */\nexport async function recordEmailEvent(\n command: RecordEmailEventCommand,\n deps: RecordEmailEventDeps,\n): Promise<void> {\n await deps.uow.run(command.id, async ({ emailDeliverability }) => {\n for (const address of command.recipients) {\n if (command.event.type === 'bounced') {\n // oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.\n await emailDeliverability.recordBounce(address, {\n type: command.event.bounceType,\n subType: command.event.bounceSubType,\n })\n } else {\n // oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.\n await emailDeliverability.recordComplaint(address)\n }\n }\n })\n}\n","import { AppError, DatabaseError, type MessageId } from '@/core'\n\n/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */\nexport class MessageAlreadyProcessedError extends AppError {\n readonly _tag = 'MessageAlreadyProcessedError'\n readonly classification = 'terminal'\n\n constructor(\n readonly messageId: MessageId,\n options?: { readonly cause: unknown },\n ) {\n super(`message ${messageId} already processed`, options)\n }\n\n /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */\n static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {\n if (cause instanceof MessageAlreadyProcessedError) return cause\n if (!(cause instanceof DatabaseError)) return undefined\n const details = cause.details\n const duplicate =\n details.database === 'd1'\n ? details.kind === 'unique' &&\n details.table === 'processed_messages' &&\n details.column === 'message_id'\n : details.code === '23505' &&\n details.table_name === 'processed_messages' &&\n details.constraint_name === 'processed_messages_pkey'\n if (!duplicate) return undefined\n return new MessageAlreadyProcessedError(messageId, { cause })\n }\n}\n","import { panic } from 'better-result'\n\nimport {\n createLogger,\n unreachable,\n type DomainCommand,\n type DomainEvent,\n type DomainMessage,\n type DomainQuery,\n} from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { Handlers, MessageResult, RegistryMessage } from './registry.ts'\n\nconst logger = createLogger('message-bus')\n\n/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */\nexport interface IMessageBus<TRegistry extends Handlers<never>> {\n handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>>\n}\n\n/**\n * Routes one message to its slot. The bus opens no transaction and keeps no outbox:\n * a handler that writes opens the unit of work itself.\n */\nexport class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {\n constructor(\n private readonly deps: Deps,\n private readonly registry: TRegistry,\n ) {}\n\n /** Resolves to the handler's own value for a command or query, and to void for an event. */\n async handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>> {\n // SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.\n return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.\n private dispatch(message: DomainMessage): Promise<unknown> {\n if (message.type === 'command') return this.handleCommand(message)\n if (message.type === 'query') return this.handleQuery(message)\n if (message.type === 'event') return this.handleEvent(message)\n // A message parsed at a boundary cannot reach here; one that does must not resolve silently.\n return unreachable(message)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleCommand(command: DomainCommand): Promise<unknown> {\n // `satisfies` proved the registry complete, so a missing slot is a broken registry object.\n const handler = this.registry.commands[command.name]\n if (handler === undefined) return panic(`No handler for command ${command.name}`)\n logger.debug('handling_command', { name: command.name, id: command.id })\n return handler(command, this.deps)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleQuery(query: DomainQuery): Promise<unknown> {\n const handler = this.registry.queries[query.name]\n if (handler === undefined) return panic(`No handler for query ${query.name}`)\n return handler(query, this.deps)\n }\n\n /**\n * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as\n * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on\n * an earlier delivery, so it counts as delivered.\n */\n private async handleEvent(event: DomainEvent): Promise<void> {\n const subscriptions = this.registry.events[event.name] ?? []\n const outcomes = await Promise.allSettled(\n subscriptions.map((subscription) => subscription(event, this.deps)),\n )\n const failures: unknown[] = []\n for (const [subscriber, outcome] of outcomes.entries()) {\n if (outcome.status === 'fulfilled') continue\n if (outcome.reason instanceof MessageAlreadyProcessedError) {\n // The bus swallows this error, so this line is the only record that the skip happened.\n logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })\n continue\n }\n failures.push(outcome.reason)\n }\n if (failures.length === 1) throw failures[0]\n if (failures.length > 1) {\n throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)\n }\n }\n}\n","import { classifyFailure, createLogger, type DomainEvent } from '@/core'\n\nimport type {\n DeadLetterReason,\n IOutboxRepository,\n OutboxClaim,\n OutboxMessage,\n} from './outbox-repository.ts'\n\n/**\n * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote\n * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.\n * The sweep is the source of truth, so a lost wake loses no events.\n */\nexport interface IOutboxRelay {\n wake(): void\n}\n\n/** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */\nexport interface IOutboxPublisher {\n /** Resolves after queue acceptance; a rejected batch may already have sent some events. */\n sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>\n}\n\n/** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */\nexport type OutboxRelayOptions = {\n readonly batchSize?: number\n readonly maxBatches?: number\n readonly maxAttempts?: number\n}\n\nconst logger = createLogger('outbox-relay')\n\n/**\n * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.\n * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.\n */\nexport class OutboxRelay {\n private readonly options: Required<OutboxRelayOptions>\n\n constructor(\n private readonly publisher: IOutboxPublisher,\n private readonly repository: IOutboxRepository,\n options: OutboxRelayOptions = {},\n ) {\n this.options = {\n batchSize: options.batchSize ?? 50,\n maxBatches: options.maxBatches ?? 10,\n maxAttempts: options.maxAttempts ?? 10,\n }\n }\n\n /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */\n async flush(): Promise<number> {\n const claim = await this.repository.claimPending(this.options.batchSize)\n const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')\n const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)\n if (pending.length === 0) return claim.messages.length\n\n let oversized: ReadonlyArray<DomainEvent>\n try {\n oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))\n } catch (error) {\n await this.handleSendFailure(claim, pending, error)\n return claim.messages.length\n }\n\n const oversizedIds = new Set(oversized.map((event) => event.id))\n await this.deadLetter(\n claim,\n pending.filter((row) => oversizedIds.has(row.payload.id)),\n 'oversized',\n 'Exceeds queue message size limit',\n )\n const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))\n if (sent.length > 0) await this.repository.markPublished(claim, sent)\n return claim.messages.length\n }\n\n /** Drain until an empty claim or the batch limit; return the total claimed count. */\n async drain(): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.\n const claimed = await this.flush()\n total += claimed\n if (claimed === 0) break\n }\n return total\n }\n\n private async handleSendFailure(\n claim: OutboxClaim,\n pending: ReadonlyArray<OutboxMessage>,\n cause: unknown,\n ): Promise<void> {\n const classification = classifyFailure(cause)\n const message =\n cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'\n if (classification === 'terminal') {\n await this.deadLetter(claim, pending, 'terminal', message, cause)\n return\n }\n const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', message, cause)\n const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)\n if (retry.length > 0) await this.repository.releaseClaim(claim, retry)\n }\n\n private async deadLetter(\n claim: OutboxClaim,\n messages: ReadonlyArray<OutboxMessage>,\n reason: DeadLetterReason,\n errorMessage: string,\n cause?: unknown,\n ): Promise<void> {\n if (messages.length === 0) return\n await this.repository.deadLetter(claim, messages, reason, errorMessage)\n logger.error('outbox_messages_dead_lettered', {\n error: cause,\n claimToken: claim.token,\n count: messages.length,\n reason,\n })\n }\n}\n","import type { IOutboxRetentionRepository } from './outbox-repository.ts'\n\n/** Typist's retention defaults; receipt retention must cover the application's supported replay period. */\nexport type OutboxRetentionOptions = {\n readonly publishedRetentionMs?: number\n readonly failedRetentionMs?: number\n readonly receiptRetentionMs?: number\n readonly batchSize?: number\n readonly maxBatches?: number\n}\n\n/** Deleted records per category during one bounded sweep. */\nexport type OutboxRetentionResult = {\n readonly published: number\n readonly failed: number\n readonly receipts: number\n}\n\n/** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */\nexport class OutboxRetention {\n private readonly options: Required<OutboxRetentionOptions>\n\n constructor(\n private readonly repository: IOutboxRetentionRepository,\n options: OutboxRetentionOptions = {},\n ) {\n this.options = {\n publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,\n failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,\n receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,\n batchSize: options.batchSize ?? 1_000,\n maxBatches: options.maxBatches ?? 10,\n }\n }\n\n /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */\n async sweep(at: Date): Promise<OutboxRetentionResult> {\n const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options\n const published = await this.deleteBatches((size) =>\n this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),\n )\n const failed = await this.deleteBatches((size) =>\n this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),\n )\n const receipts = await this.deleteBatches((size) =>\n this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),\n )\n return { published, failed, receipts }\n }\n\n private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.\n const deleted = await deleteBatch(this.options.batchSize)\n total += deleted\n if (deleted < this.options.batchSize) break\n }\n return total\n }\n}\n","import type { MessageId } from '@/core'\n\nimport type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.ts'\nimport type { IOutboxRelay } from './outbox-relay.ts'\nimport type { ITransaction } from './transaction.ts'\n\n/**\n * Commits the processed-message record, repository writes, and emitted events together before resolving.\n * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.\n * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.\n */\nexport interface IUnitOfWork<Repos> {\n run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>\n}\n\nexport type UnitOfWorkDeps<Tx, Repos> = {\n readonly transaction: ITransaction<Tx>\n /** The processed-messages and outbox repository for this transaction. */\n readonly messageStore: (tx: Tx) => IMessageStore\n /** Repositories participate in the current transaction. */\n readonly createRepositories: (tx: Tx) => Repos\n readonly eventCollector: IEventCollector\n readonly relay: IOutboxRelay\n}\n\n/** The unit of work: the boundary, the repositories, the events. Every database call is a port. */\nexport class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {\n constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}\n\n async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {\n const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps\n let written = 0\n\n let value: T\n try {\n value = await transaction.transaction(async (tx) => {\n const store = messageStore(tx)\n await store.claim(messageId)\n const repos = createRepositories(tx)\n const result = await work(repos)\n const pending = eventCollector.collect()\n await store.persistEvents(pending)\n written = pending.length\n return result\n })\n } catch (error) {\n throw MessageAlreadyProcessedError.from(error, messageId) ?? error\n }\n\n if (written > 0) relay.wake()\n return value\n }\n}\n","import { AppError } from '@/core'\n\n/** A boundary was opened inside another. One message, one atomic scope. */\nexport class UnitOfWorkAlreadyActiveError extends AppError {\n readonly _tag = 'UnitOfWorkAlreadyActiveError'\n readonly classification = 'terminal'\n\n constructor() {\n super('a unit of work is already active')\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"application-C_xN_1pa.js","names":["logger"],"sources":["../src/application/analytics-service.ts","../src/application/metrics-service.ts","../src/application/email-service.ts","../src/application/record-email-event.ts","../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/outbox-relay.ts","../src/application/outbox-retention.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'\n\n/** Product analytics without an SDK dependency, ported from Typist and Porte. */\nexport interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {\n /** Attach traits to one person. */\n identify(distinctId: string, properties?: AnalyticsProperties): void\n /** Merge a previous ID into this person; the application must establish that both identify the same person. */\n alias(distinctId: string, previousDistinctId: string): void\n /** Schedule an event for a person; returning does not confirm remote acceptance. */\n track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n /** Schedule an event without creating a person profile for its distinct ID. */\n trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n}\n\n/** Reports to nobody. The composition root selects this service when analytics is disabled. */\nexport const NULL_ANALYTICS: AnalyticsService = {\n identify: () => {},\n alias: () => {},\n track: () => {},\n trackAnonymous: () => {},\n}\n","/** A metric dimension; use a small, bounded value set and never user or request IDs. */\nexport type MetricAttributeValue = string | number | boolean\n\n/** Dimensions shared by observations in one time series. */\nexport type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>\n\n/** Unit and dimensions for an operational measurement. */\nexport interface MetricOptions {\n readonly attributes?: MetricAttributes\n readonly unit?: string\n}\n\n/** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */\nexport interface MetricsService<TName extends string = string> {\n /** Add a nonnegative value to a total; the default increment is one. */\n count(name: TName, value?: number, options?: MetricOptions): void\n /** Record a current value. */\n gauge(name: TName, value: number, options?: MetricOptions): void\n /** Record one observation in a distribution. */\n histogram(name: TName, value: number, options?: MetricOptions): void\n}\n\n/** Records nothing when the composition root disables metrics. */\nexport const NULL_METRICS_SERVICE: MetricsService = {\n count: () => {},\n gauge: () => {},\n histogram: () => {},\n}\n","import type { EmailIdempotencyKey } from '@/core'\n\n/** A media type such as `application/pdf`. */\nexport type MimeType = `${string}/${string}`\n\n/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */\nexport type EmailAttachment = {\n readonly filename: string\n readonly content: string | Uint8Array\n /** Optional MIME metadata; Resend accepts filename and content without it. */\n readonly contentType?: MimeType\n}\n\n/**\n * One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,\n * and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email\n * and https://resend.com/docs/dashboard/emails/tags\n */\nexport type OutboundEmail = {\n /** Override the default for this email, for example \"Display Name <address@domain.com>\". */\n readonly from?: string\n readonly to: string | readonly string[]\n readonly subject: string\n readonly html: string\n /** Optional stable identity of this send. Resend deduplicates supplied keys for 24 hours. */\n readonly idempotencyKey?: EmailIdempotencyKey\n /** Resend generates plain text from html when omitted. An empty string disables that generation. */\n readonly text?: string\n readonly replyTo?: string\n /** Labels the provider stores with the email and returns on every delivery event. */\n readonly tags?: Readonly<Record<string, string>>\n readonly attachments?: readonly EmailAttachment[]\n}\n\n/** Received email headers, without the body or attachments. */\nexport type ReceivedEmail = { readonly headers: Readonly<Record<string, string>> }\n\n/** One attachment of a received email. `downloadUrl` is a signed link that stops working at `expiresAt`. */\nexport type ReceivedAttachment = {\n readonly filename: string | null\n readonly contentType: string\n readonly size: number\n readonly downloadUrl: string\n readonly expiresAt: string\n}\n\n/** A received email's sender, plain text body, and attachments, for an application that reads mail it receives. */\nexport type ReceivedContent = {\n /** The sender as the email states it, for example \"Display Name <address@domain.com>\". */\n readonly from: string\n readonly text: string | null\n readonly attachments: readonly ReceivedAttachment[]\n}\n\n/** The provider accepted the email and assigned its id. */\nexport type EmailAccepted = { readonly providerId: string }\n\n/**\n * The adapter supplies the default sender and maps provider errors. Each email can override the sender.\n * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.\n */\nexport interface IEmailService {\n /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */\n sendEmail(email: OutboundEmail): Promise<EmailAccepted>\n}\n\n/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */\nexport class RecordingEmailService implements IEmailService {\n /** Sends in call order, including repeated idempotency keys. */\n readonly sent: OutboundEmail[] = []\n\n /** Record the send before returning its provider id. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n this.sent.push(email)\n return { providerId: `recorded-${this.sent.length}` }\n }\n}\n","import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'\n\nimport type { IUnitOfWork } from './unit-of-work.ts'\n\n/** What a bounce records for one address. */\nexport type EmailBounceRecord = {\n readonly type: BounceType\n readonly subType: string | null\n}\n\n/**\n * The application's deliverability state per address. An implementation keeps the higher bounce\n * severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.\n */\nexport interface IEmailDeliverabilityStore {\n /** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */\n recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>\n /** Record a complaint without clearing existing suppression state. */\n recordComplaint(address: EmailAddress): Promise<void>\n}\n\n/** The repositories the handler needs from the unit of work. */\nexport type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }\n\n/** The container slice the handler reads. */\nexport type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }\n\n/** The command, under whatever name the application registered the payload. */\nexport type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload\n\n/**\n * Record a bounce or a complaint for every recipient inside the command's unit of work.\n * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.\n */\nexport async function recordEmailEvent(\n command: RecordEmailEventCommand,\n deps: RecordEmailEventDeps,\n): Promise<void> {\n await deps.uow.run(command.id, async ({ emailDeliverability }) => {\n for (const address of command.recipients) {\n if (command.event.type === 'bounced') {\n // oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.\n await emailDeliverability.recordBounce(address, {\n type: command.event.bounceType,\n subType: command.event.bounceSubType,\n })\n } else {\n // oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.\n await emailDeliverability.recordComplaint(address)\n }\n }\n })\n}\n","import { AppError, DatabaseError, type MessageId } from '@/core'\n\n/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */\nexport class MessageAlreadyProcessedError extends AppError {\n readonly _tag = 'MessageAlreadyProcessedError'\n readonly classification = 'terminal'\n\n constructor(\n readonly messageId: MessageId,\n options?: { readonly cause: unknown },\n ) {\n super(`message ${messageId} already processed`, options)\n }\n\n /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */\n static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {\n if (cause instanceof MessageAlreadyProcessedError) return cause\n if (!(cause instanceof DatabaseError)) return undefined\n const details = cause.details\n const duplicate =\n details.database === 'd1'\n ? details.kind === 'unique' &&\n details.table === 'processed_messages' &&\n details.column === 'message_id'\n : details.code === '23505' &&\n details.table_name === 'processed_messages' &&\n details.constraint_name === 'processed_messages_pkey'\n if (!duplicate) return undefined\n return new MessageAlreadyProcessedError(messageId, { cause })\n }\n}\n","import { panic } from 'better-result'\n\nimport {\n createLogger,\n unreachable,\n type DomainCommand,\n type DomainEvent,\n type DomainMessage,\n type DomainQuery,\n} from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { Handlers, MessageResult, RegistryMessage } from './registry.ts'\n\nconst logger = createLogger('message-bus')\n\n/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */\nexport interface IMessageBus<TRegistry extends Handlers<never>> {\n handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>>\n}\n\n/**\n * Routes one message to its slot. The bus opens no transaction and keeps no outbox:\n * a handler that writes opens the unit of work itself.\n */\nexport class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {\n constructor(\n private readonly deps: Deps,\n private readonly registry: TRegistry,\n ) {}\n\n /** Resolves to the handler's own value for a command or query, and to void for an event. */\n async handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>> {\n // SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.\n return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.\n private dispatch(message: DomainMessage): Promise<unknown> {\n if (message.type === 'command') return this.handleCommand(message)\n if (message.type === 'query') return this.handleQuery(message)\n if (message.type === 'event') return this.handleEvent(message)\n // A message parsed at a boundary cannot reach here; one that does must not resolve silently.\n return unreachable(message)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleCommand(command: DomainCommand): Promise<unknown> {\n // `satisfies` proved the registry complete, so a missing slot is a broken registry object.\n const handler = this.registry.commands[command.name]\n if (handler === undefined) return panic(`No handler for command ${command.name}`)\n logger.debug('handling_command', { name: command.name, id: command.id })\n return handler(command, this.deps)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleQuery(query: DomainQuery): Promise<unknown> {\n const handler = this.registry.queries[query.name]\n if (handler === undefined) return panic(`No handler for query ${query.name}`)\n return handler(query, this.deps)\n }\n\n /**\n * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as\n * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on\n * an earlier delivery, so it counts as delivered.\n */\n private async handleEvent(event: DomainEvent): Promise<void> {\n const subscriptions = this.registry.events[event.name] ?? []\n const outcomes = await Promise.allSettled(\n subscriptions.map((subscription) => subscription(event, this.deps)),\n )\n const failures: unknown[] = []\n for (const [subscriber, outcome] of outcomes.entries()) {\n if (outcome.status === 'fulfilled') continue\n if (outcome.reason instanceof MessageAlreadyProcessedError) {\n // The bus swallows this error, so this line is the only record that the skip happened.\n logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })\n continue\n }\n failures.push(outcome.reason)\n }\n if (failures.length === 1) throw failures[0]\n if (failures.length > 1) {\n throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)\n }\n }\n}\n","import { classifyFailure, createLogger, type DomainEvent } from '@/core'\n\nimport type {\n DeadLetterReason,\n IOutboxRepository,\n OutboxClaim,\n OutboxMessage,\n} from './outbox-repository.ts'\n\n/**\n * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote\n * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.\n * The sweep is the source of truth, so a lost wake loses no events.\n */\nexport interface IOutboxRelay {\n wake(): void\n}\n\n/** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */\nexport interface IOutboxPublisher {\n /** Resolves after queue acceptance; a rejected batch may already have sent some events. */\n sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>\n}\n\n/** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */\nexport type OutboxRelayOptions = {\n readonly batchSize?: number\n readonly maxBatches?: number\n readonly maxAttempts?: number\n}\n\nconst logger = createLogger('outbox-relay')\n\n/**\n * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.\n * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.\n */\nexport class OutboxRelay {\n private readonly options: Required<OutboxRelayOptions>\n\n constructor(\n private readonly publisher: IOutboxPublisher,\n private readonly repository: IOutboxRepository,\n options: OutboxRelayOptions = {},\n ) {\n this.options = {\n batchSize: options.batchSize ?? 50,\n maxBatches: options.maxBatches ?? 10,\n maxAttempts: options.maxAttempts ?? 10,\n }\n }\n\n /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */\n async flush(): Promise<number> {\n const claim = await this.repository.claimPending(this.options.batchSize)\n const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')\n const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)\n if (pending.length === 0) return claim.messages.length\n\n let oversized: ReadonlyArray<DomainEvent>\n try {\n oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))\n } catch (error) {\n await this.handleSendFailure(claim, pending, error)\n return claim.messages.length\n }\n\n const oversizedIds = new Set(oversized.map((event) => event.id))\n await this.deadLetter(\n claim,\n pending.filter((row) => oversizedIds.has(row.payload.id)),\n 'oversized',\n 'Exceeds queue message size limit',\n )\n const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))\n if (sent.length > 0) await this.repository.markPublished(claim, sent)\n return claim.messages.length\n }\n\n /** Drain until an empty claim or the batch limit; return the total claimed count. */\n async drain(): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.\n const claimed = await this.flush()\n total += claimed\n if (claimed === 0) break\n }\n return total\n }\n\n private async handleSendFailure(\n claim: OutboxClaim,\n pending: ReadonlyArray<OutboxMessage>,\n cause: unknown,\n ): Promise<void> {\n const classification = classifyFailure(cause)\n const message =\n cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'\n if (classification === 'terminal') {\n await this.deadLetter(claim, pending, 'terminal', message, cause)\n return\n }\n const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', message, cause)\n const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)\n if (retry.length > 0) await this.repository.releaseClaim(claim, retry)\n }\n\n private async deadLetter(\n claim: OutboxClaim,\n messages: ReadonlyArray<OutboxMessage>,\n reason: DeadLetterReason,\n errorMessage: string,\n cause?: unknown,\n ): Promise<void> {\n if (messages.length === 0) return\n await this.repository.deadLetter(claim, messages, reason, errorMessage)\n logger.error('outbox_messages_dead_lettered', {\n error: cause,\n claimToken: claim.token,\n count: messages.length,\n reason,\n })\n }\n}\n","import type { IOutboxRetentionRepository } from './outbox-repository.ts'\n\n/** Typist's retention defaults; receipt retention must cover the application's supported replay period. */\nexport type OutboxRetentionOptions = {\n readonly publishedRetentionMs?: number\n readonly failedRetentionMs?: number\n readonly receiptRetentionMs?: number\n readonly batchSize?: number\n readonly maxBatches?: number\n}\n\n/** Deleted records per category during one bounded sweep. */\nexport type OutboxRetentionResult = {\n readonly published: number\n readonly failed: number\n readonly receipts: number\n}\n\n/** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */\nexport class OutboxRetention {\n private readonly options: Required<OutboxRetentionOptions>\n\n constructor(\n private readonly repository: IOutboxRetentionRepository,\n options: OutboxRetentionOptions = {},\n ) {\n this.options = {\n publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,\n failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,\n receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,\n batchSize: options.batchSize ?? 1_000,\n maxBatches: options.maxBatches ?? 10,\n }\n }\n\n /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */\n async sweep(at: Date): Promise<OutboxRetentionResult> {\n const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options\n const published = await this.deleteBatches((size) =>\n this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),\n )\n const failed = await this.deleteBatches((size) =>\n this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),\n )\n const receipts = await this.deleteBatches((size) =>\n this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),\n )\n return { published, failed, receipts }\n }\n\n private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.\n const deleted = await deleteBatch(this.options.batchSize)\n total += deleted\n if (deleted < this.options.batchSize) break\n }\n return total\n }\n}\n","import type { MessageId } from '@/core'\n\nimport type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.ts'\nimport type { IOutboxRelay } from './outbox-relay.ts'\nimport type { ITransaction } from './transaction.ts'\n\n/**\n * Commits the processed-message record, repository writes, and emitted events together before resolving.\n * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.\n * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.\n */\nexport interface IUnitOfWork<Repos> {\n run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>\n}\n\nexport type UnitOfWorkDeps<Tx, Repos> = {\n readonly transaction: ITransaction<Tx>\n /** The processed-messages and outbox repository for this transaction. */\n readonly messageStore: (tx: Tx) => IMessageStore\n /** Repositories participate in the current transaction. */\n readonly createRepositories: (tx: Tx) => Repos\n readonly eventCollector: IEventCollector\n readonly relay: IOutboxRelay\n}\n\n/** The unit of work: the boundary, the repositories, the events. Every database call is a port. */\nexport class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {\n constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}\n\n async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {\n const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps\n let written = 0\n\n let value: T\n try {\n value = await transaction.transaction(async (tx) => {\n const store = messageStore(tx)\n await store.claim(messageId)\n const repos = createRepositories(tx)\n const result = await work(repos)\n const pending = eventCollector.collect()\n await store.persistEvents(pending)\n written = pending.length\n return result\n })\n } catch (error) {\n throw MessageAlreadyProcessedError.from(error, messageId) ?? error\n }\n\n if (written > 0) relay.wake()\n return value\n }\n}\n","import { AppError } from '@/core'\n\n/** A boundary was opened inside another. One message, one atomic scope. */\nexport class UnitOfWorkAlreadyActiveError extends AppError {\n readonly _tag = 'UnitOfWorkAlreadyActiveError'\n readonly classification = 'terminal'\n\n constructor() {\n super('a unit of work is already active')\n }\n}\n"],"mappings":"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"}
@@ -1,3 +1,3 @@
1
1
  import { G as DomainMessage, K as DomainQuery, U as DomainCommand, W as DomainEvent, q as MessageId } from "./index-DUFS0SJ_.js";
2
- import { $ as CommandHandler, A as IOutboxRepository, B as OutboundEmail, C as UnitOfWorkDeps, D as OutboxRelay, E as IOutboxRelay, F as IEventCollector, G as ObservabilityUser, H as RecordingEmailService, I as EmailAccepted, J as MetricOptions, K as MetricAttributeValue, L as EmailAttachment, M as OutboxClaim, N as OutboxMessage, O as OutboxRelayOptions, P as IMessageStore, Q as NULL_ANALYTICS, R as IEmailService, S as UnitOfWork, T as IOutboxPublisher, U as ObservabilityScope, V as ReceivedEmail, W as ObservabilityService, X as NULL_METRICS_SERVICE, Y as MetricsService, Z as AnalyticsService, _ as RecordEmailEventCommand, a as OutboxRetentionResult, b as recordEmailEvent, c as CommandRegistry, d as MessageRegistry, et as EventHandler, f as MessageResult, g as IEmailDeliverabilityStore, h as EmailBounceRecord, i as OutboxRetentionOptions, j as IOutboxRetentionRepository, k as DeadLetterReason, l as EventRegistry, m as RegistryMessage, n as MessageAlreadyProcessedError, o as IMessageBus, p as QueryRegistry, q as MetricAttributes, r as OutboxRetention, s as MessageBus, t as UnitOfWorkAlreadyActiveError, tt as QueryHandler, u as Handlers, v as RecordEmailEventDeps, w as ITransaction, x as IUnitOfWork, y as RecordEmailEventRepos, z as MimeType } from "./index-tNZHSCpJ.js";
3
- export { type AnalyticsService, type CommandHandler, type CommandRegistry, type DeadLetterReason, type DomainCommand, type DomainEvent, type DomainMessage, type DomainQuery, type EmailAccepted, type EmailAttachment, type EmailBounceRecord, type EventHandler, type EventRegistry, type Handlers, type IEmailDeliverabilityStore, type IEmailService, type IEventCollector, type IMessageBus, type IMessageStore, type IOutboxPublisher, type IOutboxRelay, type IOutboxRepository, type IOutboxRetentionRepository, type ITransaction, type IUnitOfWork, MessageAlreadyProcessedError, MessageBus, type MessageId, type MessageRegistry, type MessageResult, type MetricAttributeValue, type MetricAttributes, type MetricOptions, type MetricsService, type MimeType, NULL_ANALYTICS, NULL_METRICS_SERVICE, type ObservabilityScope, type ObservabilityService, type ObservabilityUser, type OutboundEmail, type OutboxClaim, type OutboxMessage, OutboxRelay, type OutboxRelayOptions, OutboxRetention, type OutboxRetentionOptions, type OutboxRetentionResult, type QueryHandler, type QueryRegistry, type ReceivedEmail, type RecordEmailEventCommand, type RecordEmailEventDeps, type RecordEmailEventRepos, RecordingEmailService, type RegistryMessage, UnitOfWork, UnitOfWorkAlreadyActiveError, type UnitOfWorkDeps, recordEmailEvent };
2
+ import { $ as AnalyticsService, A as IOutboxRepository, B as OutboundEmail, C as UnitOfWorkDeps, D as OutboxRelay, E as IOutboxRelay, F as IEventCollector, G as ObservabilityScope, H as ReceivedContent, I as EmailAccepted, J as MetricAttributeValue, K as ObservabilityService, L as EmailAttachment, M as OutboxClaim, N as OutboxMessage, O as OutboxRelayOptions, P as IMessageStore, Q as NULL_METRICS_SERVICE, R as IEmailService, S as UnitOfWork, T as IOutboxPublisher, U as ReceivedEmail, V as ReceivedAttachment, W as RecordingEmailService, X as MetricOptions, Y as MetricAttributes, Z as MetricsService, _ as RecordEmailEventCommand, a as OutboxRetentionResult, b as recordEmailEvent, c as CommandRegistry, d as MessageRegistry, et as NULL_ANALYTICS, f as MessageResult, g as IEmailDeliverabilityStore, h as EmailBounceRecord, i as OutboxRetentionOptions, j as IOutboxRetentionRepository, k as DeadLetterReason, l as EventRegistry, m as RegistryMessage, n as MessageAlreadyProcessedError, nt as EventHandler, o as IMessageBus, p as QueryRegistry, q as ObservabilityUser, r as OutboxRetention, rt as QueryHandler, s as MessageBus, t as UnitOfWorkAlreadyActiveError, tt as CommandHandler, u as Handlers, v as RecordEmailEventDeps, w as ITransaction, x as IUnitOfWork, y as RecordEmailEventRepos, z as MimeType } from "./index-lS_g1olK.js";
3
+ export { type AnalyticsService, type CommandHandler, type CommandRegistry, type DeadLetterReason, type DomainCommand, type DomainEvent, type DomainMessage, type DomainQuery, type EmailAccepted, type EmailAttachment, type EmailBounceRecord, type EventHandler, type EventRegistry, type Handlers, type IEmailDeliverabilityStore, type IEmailService, type IEventCollector, type IMessageBus, type IMessageStore, type IOutboxPublisher, type IOutboxRelay, type IOutboxRepository, type IOutboxRetentionRepository, type ITransaction, type IUnitOfWork, MessageAlreadyProcessedError, MessageBus, type MessageId, type MessageRegistry, type MessageResult, type MetricAttributeValue, type MetricAttributes, type MetricOptions, type MetricsService, type MimeType, NULL_ANALYTICS, NULL_METRICS_SERVICE, type ObservabilityScope, type ObservabilityService, type ObservabilityUser, type OutboundEmail, type OutboxClaim, type OutboxMessage, OutboxRelay, type OutboxRelayOptions, OutboxRetention, type OutboxRetentionOptions, type OutboxRetentionResult, type QueryHandler, type QueryRegistry, type ReceivedAttachment, type ReceivedContent, type ReceivedEmail, type RecordEmailEventCommand, type RecordEmailEventDeps, type RecordEmailEventRepos, RecordingEmailService, type RegistryMessage, UnitOfWork, UnitOfWorkAlreadyActiveError, type UnitOfWorkDeps, recordEmailEvent };
@@ -1,5 +1,5 @@
1
1
  import { U as DomainCommand, W as DomainEvent, Xt as AppError, Zt as FailureClassification } from "../index-DUFS0SJ_.js";
2
- import { D as OutboxRelay, E as IOutboxRelay } from "../index-tNZHSCpJ.js";
2
+ import { D as OutboxRelay, E as IOutboxRelay } from "../index-lS_g1olK.js";
3
3
  import { z } from "zod";
4
4
  //#region src/infrastructure/cloudflare/queue/queue-message.d.ts
5
5
  /** A message a queue carries: a command from outside the request, or an event from the outbox. */
@@ -60,7 +60,7 @@ type DispatchBus<TMessage extends QueueMessage> = {
60
60
  /**
61
61
  * Parses and dispatches each queue message, then selects acknowledgement or retry.
62
62
  * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
63
- * The consumer logs each failure once; handlers do not.
63
+ * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.
64
64
  */
65
65
  export declare class QueueConsumer<TMessage extends QueueMessage> {
66
66
  private readonly bus;
@@ -1 +1 @@
1
- {"version":3,"file":"queue.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/queue/queue-message.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"mappings":";;;;;KAGY,eAAe,gBAAgB;;;;KCa/B;WAA8B;;;KAG9B,aAAa,QAAQ,KAAK,MAAM;;;;;KAMhC,mBAAmB,iBAAiB,cAAc;WACnD,QAAQ,SAAS,OAAO,MAAM,aAAa;;WAE3C,YAAY,KAAK;WACjB,SAAS,SAAS,aAAa;;;UAIzB,aAAa,iBAAiB;EAC7C,KAAK,SAAS,UAAU,UAAU,mBAAmB;EACrD,UAAU,UAAU,cAAc,YAAY;;EAE9C,iBAAiB,aAAa,UAC5B,UAAU,cAAc,QACvB,QAAQ,cAAc;;EAEzB,UAAU,SAAS,UAAU;;;;;;qBA8BlB,YACX,iBAAiB,cACjB,gCACW,aAAa;mBACK;EAA7B,YAA6B,SAAS,mBAAmB,UAAU;;EAG7D,KAAK,SAAS,UAAU,UAAU,mBAAmB;;EA4BrD,UAAU,UAAU,cAAc,YAAY;EAO9C,iBAAiB,aAAa,UAClC,UAAU,cAAc,QACvB,QAAQ,cAAc;EAMnB,UAAU,SAAS,UAAU;;UAQrB;UA4BN;UAIM;;;;;KCpJJ,gBAAgB,iBAAiB,iBAC3C,SAAS,UACT,iBACA,WAAW,kBACR;;KAGO,YAAY,iBAAiB;EAEvC,OAAO,SAAS,WAAW;;;;;;;qBAQhB,cAAc,iBAAiB;mBAQvB;mBACA;mBACA;mBACA;;;;;;;EAJnB,YACmB,KAAK,YAAY,WACjB,aAAa,KAAK,aAAa,yBAC/B,QAAQ,EAAE,QAAQ,WAClB,eAAc,gBAAgB;;EAI3C,aAAa,OAAO,eAAe;UAc3B;;UA2BA;;UAMA;;;;;UAaN;;;;;;;;;qBClGG;WACX;WACA;WACA;;;wBAIc,kBAAkB,SAAS;;KAK/B,aAAa;WAAuB,SAAS;WAAmB;;;wBAG5D,aAAa,iBAAiB,cAC5C,UAAU,cAAc,YACvB,aAAa;;wBAKA,YAAY,OAAO,aAAa;;wBAKhC,cAAc,UAC5B,OAAO,cAAc,aAAa,aACjC;;;;KCjCS;;wBA4CI,mBAAmB,iBAAiB;;qBASvC,uBAAuB;WACzB;WACA,gBAAgB;WAChB,WAAW;EAEpB,YAAY;aAAiB,WAAW;aAAyB;;;;;;;qBAWtD,8BAA8B;WAChC;WACA;WACA;WACA;WACA;EAET,YAAY;aACD;aACA;aACA;;;;;;;qBAeA,mCAAmC;WACrC;WACA;EAET,YAAY;;;;;KCrFF;WACG;WAAwB;;WAExB;WACA,gBAAgB,QAAQ;WACxB;;;;;;;;wBASC,iBAAiB,gBAAgB,kBAAkB;;;;wBCxBnD,cACd,SAAS,KAAK,gCACd,OAAO,KAAK,wBACX"}
1
+ {"version":3,"file":"queue.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/queue/queue-message.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"mappings":";;;;;KAGY,eAAe,gBAAgB;;;;KCa/B;WAA8B;;;KAG9B,aAAa,QAAQ,KAAK,MAAM;;;;;KAMhC,mBAAmB,iBAAiB,cAAc;WACnD,QAAQ,SAAS,OAAO,MAAM,aAAa;;WAE3C,YAAY,KAAK;WACjB,SAAS,SAAS,aAAa;;;UAIzB,aAAa,iBAAiB;EAC7C,KAAK,SAAS,UAAU,UAAU,mBAAmB;EACrD,UAAU,UAAU,cAAc,YAAY;;EAE9C,iBAAiB,aAAa,UAC5B,UAAU,cAAc,QACvB,QAAQ,cAAc;;EAEzB,UAAU,SAAS,UAAU;;;;;;qBA8BlB,YACX,iBAAiB,cACjB,gCACW,aAAa;mBACK;EAA7B,YAA6B,SAAS,mBAAmB,UAAU;;EAG7D,KAAK,SAAS,UAAU,UAAU,mBAAmB;;EA4BrD,UAAU,UAAU,cAAc,YAAY;EAO9C,iBAAiB,aAAa,UAClC,UAAU,cAAc,QACvB,QAAQ,cAAc;EAMnB,UAAU,SAAS,UAAU;;UAQrB;UA4BN;UAIM;;;;;KCpJJ,gBAAgB,iBAAiB,iBAC3C,SAAS,UACT,iBACA,WAAW,kBACR;;KAGO,YAAY,iBAAiB;EAEvC,OAAO,SAAS,WAAW;;;;;;;qBAQhB,cAAc,iBAAiB;mBAQvB;mBACA;mBACA;mBACA;;;;;;;EAJnB,YACmB,KAAK,YAAY,WACjB,aAAa,KAAK,aAAa,yBAC/B,QAAQ,EAAE,QAAQ,WAClB,eAAc,gBAAgB;;EAI3C,aAAa,OAAO,eAAe;UAc3B;;UA+CA;;UAMA;;;;;UAaN;;;;;;;;;qBCtHG;WACX;WACA;WACA;;;wBAIc,kBAAkB,SAAS;;KAK/B,aAAa;WAAuB,SAAS;WAAmB;;;wBAG5D,aAAa,iBAAiB,cAC5C,UAAU,cAAc,YACvB,aAAa;;wBAKA,YAAY,OAAO,aAAa;;wBAKhC,cAAc,UAC5B,OAAO,cAAc,aAAa,aACjC;;;;KCjCS;;wBA4CI,mBAAmB,iBAAiB;;qBASvC,uBAAuB;WACzB;WACA,gBAAgB;WAChB,WAAW;EAEpB,YAAY;aAAiB,WAAW;aAAyB;;;;;;;qBAWtD,8BAA8B;WAChC;WACA;WACA;WACA;WACA;EAET,YAAY;aACD;aACA;aACA;;;;;;;qBAeA,mCAAmC;WACrC;WACA;EAET,YAAY;;;;;KCrFF;WACG;WAAwB;;WAExB;WACA,gBAAgB,QAAQ;WACxB;;;;;;;;wBASC,iBAAiB,gBAAgB,kBAAkB;;;;wBCxBnD,cACd,SAAS,KAAK,gCACd,OAAO,KAAK,wBACX"}
@@ -308,7 +308,7 @@ const logger$1 = createLogger("queue-consumer");
308
308
  /**
309
309
  * Parses and dispatches each queue message, then selects acknowledgement or retry.
310
310
  * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.
311
- * The consumer logs each failure once; handlers do not.
311
+ * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.
312
312
  */
313
313
  var QueueConsumer = class {
314
314
  bus;
@@ -335,6 +335,8 @@ var QueueConsumer = class {
335
335
  }
336
336
  async processMessage(message) {
337
337
  let parsed = null;
338
+ let handlerFailed = false;
339
+ let settled = false;
338
340
  try {
339
341
  const body = await this.parseMessage(message);
340
342
  if (body === null) {
@@ -347,10 +349,25 @@ var QueueConsumer = class {
347
349
  }
348
350
  parsed = body;
349
351
  await this.wrapDispatch(body, message.attempts, async () => {
350
- await this.bus.handle(body);
352
+ try {
353
+ await this.bus.handle(body);
354
+ } catch (cause) {
355
+ handlerFailed = true;
356
+ this.handleFailure(message, body, cause);
357
+ settled = true;
358
+ throw cause;
359
+ }
351
360
  });
361
+ } catch (cause) {
362
+ if (settled) return;
363
+ if (handlerFailed) throw cause;
364
+ this.handleFailure(message, parsed, cause);
365
+ return;
366
+ }
367
+ if (parsed === null || handlerFailed) return;
368
+ try {
352
369
  message.ack();
353
- logger$1.debug("queue_message_processed", { name: body.name });
370
+ logger$1.debug("queue_message_processed", { name: parsed.name });
354
371
  } catch (cause) {
355
372
  this.handleFailure(message, parsed, cause);
356
373
  }
@@ -1 +1 @@
1
- {"version":3,"file":"queue.js","names":["logger","logger"],"sources":["../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"sourcesContent":["import type { QueueMessage } from './queue-message.ts'\n\n/**\n * The Cloudflare Queues producer limits with metadata headroom, checked before each send so a\n * violation never reaches the binding. The dead letter has the same per-message limit.\n * https://developers.cloudflare.com/queues/platform/limits/\n */\nexport const QUEUE_LIMITS = {\n messageBytes: 127_000,\n batchBytes: 250_000,\n batchCount: 100,\n} as const\n\n/** The size the binding measures: the JSON encoding of the body, in UTF-8 bytes. */\nexport function messageByteLength(message: QueueMessage): number {\n return new TextEncoder().encode(JSON.stringify(message)).length\n}\n\n/** A message with its measured size. */\nexport type SizedMessage<TMessage> = { readonly message: TMessage; readonly bytes: number }\n\n/** Measure every message once. */\nexport function sizeMessages<TMessage extends QueueMessage>(\n messages: ReadonlyArray<TMessage>,\n): SizedMessage<TMessage>[] {\n return messages.map((message) => ({ message, bytes: messageByteLength(message) }))\n}\n\n/** Whether one message is over the per-message limit. */\nexport function isOversized(sized: SizedMessage<QueueMessage>): boolean {\n return sized.bytes > QUEUE_LIMITS.messageBytes\n}\n\n/** Split a batch at the message-count and byte limits, keeping the order. */\nexport function chunkByLimits<TMessage>(\n sized: ReadonlyArray<SizedMessage<TMessage>>,\n): TMessage[][] {\n const chunks: TMessage[][] = []\n let current: TMessage[] = []\n let bytes = 0\n for (const item of sized) {\n const full =\n current.length >= QUEUE_LIMITS.batchCount || bytes + item.bytes > QUEUE_LIMITS.batchBytes\n if (current.length > 0 && full) {\n chunks.push(current)\n current = []\n bytes = 0\n }\n current.push(item.message)\n bytes += item.bytes\n }\n if (current.length > 0) chunks.push(current)\n return chunks\n}\n","import { AppError, classifyBoundaryError, type FailureClassification } from '@/core'\n\n/** The Queue binding calls the client makes. Each is safe to repeat. */\nexport type QueueOperation = 'send' | 'send_batch' | 'send_dlq'\n\nconst QUEUE_OPERATION_MESSAGES = {\n send: 'Failed to send queue message',\n send_batch: 'Failed to send queue batch',\n send_dlq: 'Failed to send DLQ message',\n} satisfies Readonly<Record<QueueOperation, string>>\n\n/**\n * Cloudflare Queues error codes, by whether a repeat could change the answer. The reference\n * marks only 10201 and 10250 for retry; 10251 waits for processing, 15000 is nobody's call.\n * https://developers.cloudflare.com/queues/reference/error-codes/\n */\nconst QUEUE_ERROR_CLASSIFICATIONS = new Map<number, FailureClassification>([\n [10104, 'terminal'], // QueueNotFound\n [10106, 'terminal'], // Unauthorized\n [10107, 'terminal'], // QueueIDMalformed\n [10201, 'transient'], // ClientDisconnected\n [10202, 'terminal'], // BatchDelayInvalid\n [10203, 'terminal'], // MessageMetadataInvalid\n [10204, 'terminal'], // MessageSizeOutOfBounds\n [10205, 'terminal'], // BatchSizeOutOfBounds\n [10206, 'terminal'], // BatchCountOutOfBounds\n [10207, 'terminal'], // JSONRequestBodyInvalid\n [10208, 'terminal'], // JSONRequestBodyMalformed\n [10250, 'transient'], // QueueOverloaded\n [10251, 'transient'], // QueueStorageLimitExceeded\n [10252, 'terminal'], // QueueDisabled\n [10253, 'terminal'], // FreeTierLimitExceeded\n [15000, 'unknown'], // UnknownInternalError\n])\n\nfunction extractQueueErrorCode(cause: unknown): number | undefined {\n if (!(cause instanceof Error)) return undefined\n const match = /(\\d{5})\\)?\\s*$/.exec(cause.message)\n return match?.[1] === undefined ? undefined : Number(match[1])\n}\n\nfunction classifyQueueBoundaryError(cause: unknown): FailureClassification | undefined {\n const code = extractQueueErrorCode(cause)\n return code === undefined ? undefined : QUEUE_ERROR_CLASSIFICATIONS.get(code)\n}\n\n/** Classify a raw Queue binding failure: the code table first, then Cloudflare's signals, then transport. */\nexport function classifyQueueError(cause: unknown): FailureClassification {\n return classifyBoundaryError({\n cause,\n classifyBoundary: classifyQueueBoundaryError,\n includeCloudflare: true,\n })\n}\n\n/** A send through a Queue binding failed. The classification says whether an immediate retry may help. */\nexport class QueueSendError extends AppError {\n readonly _tag = 'QueueSendError'\n readonly classification: FailureClassification\n readonly operation: QueueOperation\n\n constructor(args: { readonly operation: QueueOperation; readonly cause: unknown }) {\n super(QUEUE_OPERATION_MESSAGES[args.operation], { cause: args.cause })\n this.classification = classifyQueueError(args.cause)\n this.operation = args.operation\n }\n}\n\n/**\n * A message over the per-message cap can never be sent on any queue; the dead letter has the\n * same cap. The client detects it before the send, so it never comes from a platform error.\n */\nexport class OversizedMessageError extends AppError {\n readonly _tag = 'OversizedMessageError'\n readonly classification = 'terminal'\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n\n constructor(args: {\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n }) {\n super(\n `Queue message \"${args.messageName}\" is ${String(args.bytes)} bytes, over the ${String(args.limit)} limit`,\n )\n this.messageName = args.messageName\n this.bytes = args.bytes\n this.limit = args.limit\n }\n}\n\n/**\n * The dead letter did not accept an unparseable message. Always transient, whatever the send\n * failure was, so the source delivery retries instead of acknowledging and losing the body.\n */\nexport class DeadLetterUnavailableError extends AppError {\n readonly _tag = 'DeadLetterUnavailableError'\n readonly classification = 'transient'\n\n constructor(cause: unknown) {\n super('The dead letter did not accept the message', { cause })\n }\n}\n","import { Result } from 'better-result'\n\nimport { createLogger, shouldRetryFailure } from '@/core'\n\nimport {\n chunkByLimits,\n isOversized,\n messageByteLength,\n QUEUE_LIMITS,\n sizeMessages,\n type SizedMessage,\n} from './queue-limits.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { OversizedMessageError, QueueSendError, type QueueOperation } from './queue.errors.ts'\n\n/** Cloudflare's delivery delay, in seconds. */\nexport type QueueSendOptions = { readonly delaySeconds: number }\n\n/** The two calls the client makes on a queue binding. */\nexport type QueueBinding<Body> = Pick<Queue<Body>, 'send' | 'sendBatch'>\n\n/**\n * The lanes and the dead letter of one application. A lane is a queue with its own consumer\n * settings; `laneOf` is the product's routing rule, written once at the composition root.\n */\nexport type QueueClientOptions<TMessage extends QueueMessage, Lane extends string> = {\n readonly queues: Readonly<Record<Lane, QueueBinding<TMessage>>>\n /** Where the consumer parks poison bodies. Cloudflare also routes exhausted retries here. */\n readonly deadLetter: Pick<Queue, 'send'>\n readonly laneOf: (message: TMessage) => Lane\n}\n\n/** The port an entrypoint, a relay, or a consumer sends through. */\nexport interface IQueueClient<TMessage extends QueueMessage> {\n send(message: TMessage, options?: QueueSendOptions): Promise<void>\n sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>\n /** Sends every message within the limits and returns the oversized ones. */\n sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>>\n /** Parks a delivered message's body as it arrived. */\n sendToDlq(message: Message): Promise<void>\n}\n\nconst logger = createLogger('queue-client')\n\n/** Immediate retries of one binding call: three attempts, 250 ms then 500 ms, with jitter. */\nconst RETRY_TIMES = 2\nconst RETRY_BASE_DELAY_MS = 250\n\n/** Every send is safe to repeat, so a transient failure gets an immediate retry; terminal and unknown stop. */\nfunction shouldRetrySend(error: QueueSendError): boolean {\n return shouldRetryFailure({\n classification: error.classification,\n repeatSafe: true,\n owner: 'immediate',\n })\n}\n\nfunction oversizedError(sized: SizedMessage<QueueMessage>): OversizedMessageError {\n return new OversizedMessageError({\n messageName: sized.message.name,\n bytes: sized.bytes,\n limit: QUEUE_LIMITS.messageBytes,\n })\n}\n\n/**\n * Sends messages to the application's queues. Checks the size limits before a send, retries a\n * transient binding failure a bounded number of times, and converts the final failure to `QueueSendError`.\n */\nexport class QueueClient<\n TMessage extends QueueMessage,\n Lane extends string,\n> implements IQueueClient<TMessage> {\n constructor(private readonly options: QueueClientOptions<TMessage, Lane>) {}\n\n /** @throws OversizedMessageError before the send. @throws QueueSendError after the retries. */\n async send(message: TMessage, options?: QueueSendOptions): Promise<void> {\n const sized = { message, bytes: messageByteLength(message) }\n if (isOversized(sized)) {\n logger.warn('queue_message_oversized', {\n name: message.name,\n type: message.type,\n bytes: sized.bytes,\n })\n throw oversizedError(sized)\n }\n\n const queue = this.queueFor(message)\n logger.debug('sending_message', {\n name: message.name,\n type: message.type,\n delaySeconds: options?.delaySeconds,\n })\n\n await this.callQueue('send', async () => {\n if (options) {\n await queue.send(message, options)\n } else {\n await queue.send(message)\n }\n })\n }\n\n /** Rejects the whole batch before any send when one message is oversized. */\n async sendBatch(messages: ReadonlyArray<TMessage>): Promise<void> {\n const sized = sizeMessages(messages)\n const oversized = sized.find(isOversized)\n if (oversized) throw oversizedError(oversized)\n await this.sendSizedBatch(sized)\n }\n\n async sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>> {\n const sized = sizeMessages(messages)\n await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))\n return sized.filter(isOversized).map((item) => item.message)\n }\n\n async sendToDlq(message: Message): Promise<void> {\n logger.debug('sending_to_dlq', { queueMessageId: message.id })\n await this.callQueue('send_dlq', async () => {\n await this.options.deadLetter.send(message.body)\n })\n }\n\n /** Each lane's chunks retry on their own, so one failed chunk does not resend the others. */\n private async sendSizedBatch(sized: ReadonlyArray<SizedMessage<TMessage>>): Promise<void> {\n const byLane = new Map<Lane, SizedMessage<TMessage>[]>()\n for (const item of sized) {\n const lane = this.options.laneOf(item.message)\n const items = byLane.get(lane) ?? []\n items.push(item)\n byLane.set(lane, items)\n }\n\n logger.debug('sending_batch', {\n count: sized.length,\n lanes: [...byLane].map(([lane, items]) => ({ lane, count: items.length })),\n })\n\n const sends: Promise<void>[] = []\n for (const [lane, items] of byLane) {\n const queue = this.options.queues[lane]\n for (const group of chunkByLimits(items)) {\n sends.push(\n this.callQueue('send_batch', async () => {\n await queue.sendBatch(group.map((body) => ({ body })))\n }),\n )\n }\n }\n await Promise.all(sends)\n }\n\n private queueFor(message: TMessage): QueueBinding<TMessage> {\n return this.options.queues[this.options.laneOf(message)]\n }\n\n private async callQueue<T>(operation: QueueOperation, call: () => Promise<T>): Promise<T> {\n const result = await Result.tryPromise(\n {\n try: call,\n catch: (cause) => new QueueSendError({ operation, cause }),\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: RETRY_BASE_DELAY_MS,\n backoff: 'exponential',\n jitter: true,\n shouldRetry: shouldRetrySend,\n },\n },\n )\n if (result.isErr()) throw result.error\n return result.value\n }\n}\n","import {\n classifyFailure,\n exponentialEqualJitterMs,\n retryAfterSecondsOf,\n shouldRetryFailure,\n spreadJitterMs,\n type FailureClassification,\n} from '@/core'\n\nconst BASE_RETRY_DELAY_MS = 5_000\nconst MAX_RETRY_DELAY_MS = 120_000\nconst RETRY_AFTER_SPREAD_MS = 5_000\n/** Cloudflare Queues accept a redelivery delay of at most one day. */\nconst MAX_QUEUE_DELAY_SECONDS = 24 * 60 * 60\n\n/** How the consumer settles one failed message: acknowledge it, or have Cloudflare redeliver it after a delay. */\nexport type RedeliveryDecision =\n | { readonly action: 'ack'; readonly classification: 'terminal' }\n | {\n readonly action: 'retry'\n readonly classification: Exclude<FailureClassification, 'terminal'>\n readonly delaySeconds: number\n }\n\n/**\n * Decide whether the consumer acknowledges a failed message or asks Cloudflare to redeliver it.\n * A terminal failure acknowledges. Transient and unknown failures redeliver: after the advertised\n * wait plus a spread when the error carries one, else with exponential equal-jitter backoff.\n * An `AggregateError` from event delivery redelivers when any member does, after the longest wait.\n */\nexport function decideRedelivery(cause: unknown, attempt: number): RedeliveryDecision {\n if (cause instanceof AggregateError && cause.errors.length > 0) {\n const decisions = cause.errors.map((member) => decideRedelivery(member, attempt))\n const retries = decisions.filter((decision) => decision.action === 'retry')\n if (retries.length === 0) return { action: 'ack', classification: 'terminal' }\n\n // Redelivery must respect every failed subscriber's required wait.\n return {\n action: 'retry',\n classification: retries.some((decision) => decision.classification === 'unknown')\n ? 'unknown'\n : 'transient',\n delaySeconds: Math.max(...retries.map((decision) => decision.delaySeconds)),\n }\n }\n\n const classification = classifyFailure(cause)\n if (classification === 'terminal') return { action: 'ack', classification }\n\n const canRetry = shouldRetryFailure({ classification, repeatSafe: true, owner: 'durable' })\n if (!canRetry) return { action: 'ack', classification: 'terminal' }\n\n const retryAfterSeconds = retryAfterSecondsOf(cause)\n const delayMs =\n retryAfterSeconds === null\n ? exponentialEqualJitterMs(BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, attempt)\n : retryAfterSeconds * 1_000 + spreadJitterMs(RETRY_AFTER_SPREAD_MS)\n\n return {\n action: 'retry',\n classification,\n delaySeconds: Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(1, Math.ceil(delayMs / 1_000))),\n }\n}\n","import type { z } from 'zod'\n\nimport { MessageAlreadyProcessedError } from '@/application'\nimport { createLogger } from '@/core'\n\nimport type { IQueueClient } from './queue-client.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { DeadLetterUnavailableError } from './queue.errors.ts'\nimport { decideRedelivery } from './redelivery-policy.ts'\n\nconst logger = createLogger('queue-consumer')\n\n/** Wraps one dispatch: an error-tracking scope, an analytics context. The default runs it as is. */\nexport type DispatchWrapper<TMessage extends QueueMessage> = (\n message: TMessage,\n attempt: number,\n run: () => Promise<void>,\n) => Promise<void>\n\n/** The one call the consumer makes on the bus. */\nexport type DispatchBus<TMessage extends QueueMessage> = {\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- the bus resolves to each handler's own value; the consumer discards it.\n handle(message: TMessage): Promise<unknown>\n}\n\n/**\n * Parses and dispatches each queue message, then selects acknowledgement or retry.\n * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.\n * The consumer logs each failure once; handlers do not.\n */\nexport class QueueConsumer<TMessage extends QueueMessage> {\n /**\n * @param bus - Where a parsed message goes.\n * @param queueClient - Parks an unparseable body in the dead letter.\n * @param schema - What a body may be: the application's `messages.queueSchema`.\n * @param wrapDispatch - Wraps one dispatch in the application's context. Default: run it as is.\n */\n constructor(\n private readonly bus: DispatchBus<TMessage>,\n private readonly queueClient: Pick<IQueueClient<TMessage>, 'sendToDlq'>,\n private readonly schema: z.ZodType<TMessage>,\n private readonly wrapDispatch: DispatchWrapper<TMessage> = (_message, _attempt, run) => run(),\n ) {}\n\n /** Every message settles on its own. A settlement that throws rejects the batch after its siblings settle. */\n async processBatch(batch: MessageBatch): Promise<void> {\n logger.debug('queue_batch_received', { count: batch.messages.length })\n const results = await Promise.allSettled(\n batch.messages.map((message) => this.processMessage(message)),\n )\n const rejected = results\n .filter((result) => result.status === 'rejected')\n .map((result) => result.reason)\n\n if (rejected.length > 0) {\n throw new AggregateError(rejected, 'Queue message settlement failed')\n }\n }\n\n private async processMessage(message: Message): Promise<void> {\n let parsed: TMessage | null = null\n\n try {\n const body = await this.parseMessage(message)\n if (body === null) {\n // An unparseable body cannot succeed on retry, so park it for inspection.\n logger.error('invalid_message_format', {\n attempt: message.attempts,\n queueMessageId: message.id,\n })\n await this.parkPoison(message)\n return\n }\n parsed = body\n\n await this.wrapDispatch(body, message.attempts, async () => {\n await this.bus.handle(body)\n })\n message.ack()\n logger.debug('queue_message_processed', { name: body.name })\n } catch (cause) {\n this.handleFailure(message, parsed, cause)\n }\n }\n\n /** Returns null on failure; the caller owns the single log point. */\n private async parseMessage(message: Message): Promise<TMessage | null> {\n const result = await this.schema.safeParseAsync(message.body)\n return result.success ? result.data : null\n }\n\n /** Park an unparseable message in the dead letter, then acknowledge its source delivery. */\n private async parkPoison(message: Message): Promise<void> {\n try {\n await this.queueClient.sendToDlq(message)\n message.ack()\n } catch (cause) {\n throw new DeadLetterUnavailableError(cause)\n }\n }\n\n /**\n * Retry declared transient and unknown failures so Cloudflare can enforce the attempt ceiling.\n * Acknowledge only declared terminal failures.\n */\n private handleFailure(message: Message, parsed: TMessage | null, cause: unknown): void {\n if (cause instanceof MessageAlreadyProcessedError) {\n logger.debug('duplicate_skipped', {\n name: parsed?.name,\n messageId: cause.messageId,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const decision = decideRedelivery(cause, message.attempts)\n if (decision.action === 'ack') {\n logger.error('message_discarded', {\n error: cause,\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const context = {\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n classification: decision.classification,\n delaySeconds: decision.delaySeconds,\n }\n if (decision.classification === 'unknown' && message.attempts === 1) {\n logger.error('queue_unknown_failure', { error: cause, ...context })\n } else {\n logger.debug('redelivery_requested', context)\n }\n message.retry({ delaySeconds: decision.delaySeconds })\n }\n}\n","import type { IOutboxRelay, OutboxRelay } from '@/application'\nimport { createLogger } from '@/core'\n\nconst logger = createLogger('outbox-wake')\n\n/** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */\nexport function waitUntilWake(\n context: Pick<ExecutionContext, 'waitUntil'>,\n relay: Pick<OutboxRelay, 'drain'>,\n): IOutboxRelay {\n return {\n wake: () =>\n context.waitUntil(\n relay.drain().catch((cause: unknown) => {\n logger.error('outbox_wake_failed', { error: cause })\n }),\n ),\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"queue.js","names":["logger","logger"],"sources":["../../src/infrastructure/cloudflare/queue/queue-limits.ts","../../src/infrastructure/cloudflare/queue/queue.errors.ts","../../src/infrastructure/cloudflare/queue/queue-client.ts","../../src/infrastructure/cloudflare/queue/redelivery-policy.ts","../../src/infrastructure/cloudflare/queue/queue-consumer.ts","../../src/infrastructure/cloudflare/queue/wait-until-wake.ts"],"sourcesContent":["import type { QueueMessage } from './queue-message.ts'\n\n/**\n * The Cloudflare Queues producer limits with metadata headroom, checked before each send so a\n * violation never reaches the binding. The dead letter has the same per-message limit.\n * https://developers.cloudflare.com/queues/platform/limits/\n */\nexport const QUEUE_LIMITS = {\n messageBytes: 127_000,\n batchBytes: 250_000,\n batchCount: 100,\n} as const\n\n/** The size the binding measures: the JSON encoding of the body, in UTF-8 bytes. */\nexport function messageByteLength(message: QueueMessage): number {\n return new TextEncoder().encode(JSON.stringify(message)).length\n}\n\n/** A message with its measured size. */\nexport type SizedMessage<TMessage> = { readonly message: TMessage; readonly bytes: number }\n\n/** Measure every message once. */\nexport function sizeMessages<TMessage extends QueueMessage>(\n messages: ReadonlyArray<TMessage>,\n): SizedMessage<TMessage>[] {\n return messages.map((message) => ({ message, bytes: messageByteLength(message) }))\n}\n\n/** Whether one message is over the per-message limit. */\nexport function isOversized(sized: SizedMessage<QueueMessage>): boolean {\n return sized.bytes > QUEUE_LIMITS.messageBytes\n}\n\n/** Split a batch at the message-count and byte limits, keeping the order. */\nexport function chunkByLimits<TMessage>(\n sized: ReadonlyArray<SizedMessage<TMessage>>,\n): TMessage[][] {\n const chunks: TMessage[][] = []\n let current: TMessage[] = []\n let bytes = 0\n for (const item of sized) {\n const full =\n current.length >= QUEUE_LIMITS.batchCount || bytes + item.bytes > QUEUE_LIMITS.batchBytes\n if (current.length > 0 && full) {\n chunks.push(current)\n current = []\n bytes = 0\n }\n current.push(item.message)\n bytes += item.bytes\n }\n if (current.length > 0) chunks.push(current)\n return chunks\n}\n","import { AppError, classifyBoundaryError, type FailureClassification } from '@/core'\n\n/** The Queue binding calls the client makes. Each is safe to repeat. */\nexport type QueueOperation = 'send' | 'send_batch' | 'send_dlq'\n\nconst QUEUE_OPERATION_MESSAGES = {\n send: 'Failed to send queue message',\n send_batch: 'Failed to send queue batch',\n send_dlq: 'Failed to send DLQ message',\n} satisfies Readonly<Record<QueueOperation, string>>\n\n/**\n * Cloudflare Queues error codes, by whether a repeat could change the answer. The reference\n * marks only 10201 and 10250 for retry; 10251 waits for processing, 15000 is nobody's call.\n * https://developers.cloudflare.com/queues/reference/error-codes/\n */\nconst QUEUE_ERROR_CLASSIFICATIONS = new Map<number, FailureClassification>([\n [10104, 'terminal'], // QueueNotFound\n [10106, 'terminal'], // Unauthorized\n [10107, 'terminal'], // QueueIDMalformed\n [10201, 'transient'], // ClientDisconnected\n [10202, 'terminal'], // BatchDelayInvalid\n [10203, 'terminal'], // MessageMetadataInvalid\n [10204, 'terminal'], // MessageSizeOutOfBounds\n [10205, 'terminal'], // BatchSizeOutOfBounds\n [10206, 'terminal'], // BatchCountOutOfBounds\n [10207, 'terminal'], // JSONRequestBodyInvalid\n [10208, 'terminal'], // JSONRequestBodyMalformed\n [10250, 'transient'], // QueueOverloaded\n [10251, 'transient'], // QueueStorageLimitExceeded\n [10252, 'terminal'], // QueueDisabled\n [10253, 'terminal'], // FreeTierLimitExceeded\n [15000, 'unknown'], // UnknownInternalError\n])\n\nfunction extractQueueErrorCode(cause: unknown): number | undefined {\n if (!(cause instanceof Error)) return undefined\n const match = /(\\d{5})\\)?\\s*$/.exec(cause.message)\n return match?.[1] === undefined ? undefined : Number(match[1])\n}\n\nfunction classifyQueueBoundaryError(cause: unknown): FailureClassification | undefined {\n const code = extractQueueErrorCode(cause)\n return code === undefined ? undefined : QUEUE_ERROR_CLASSIFICATIONS.get(code)\n}\n\n/** Classify a raw Queue binding failure: the code table first, then Cloudflare's signals, then transport. */\nexport function classifyQueueError(cause: unknown): FailureClassification {\n return classifyBoundaryError({\n cause,\n classifyBoundary: classifyQueueBoundaryError,\n includeCloudflare: true,\n })\n}\n\n/** A send through a Queue binding failed. The classification says whether an immediate retry may help. */\nexport class QueueSendError extends AppError {\n readonly _tag = 'QueueSendError'\n readonly classification: FailureClassification\n readonly operation: QueueOperation\n\n constructor(args: { readonly operation: QueueOperation; readonly cause: unknown }) {\n super(QUEUE_OPERATION_MESSAGES[args.operation], { cause: args.cause })\n this.classification = classifyQueueError(args.cause)\n this.operation = args.operation\n }\n}\n\n/**\n * A message over the per-message cap can never be sent on any queue; the dead letter has the\n * same cap. The client detects it before the send, so it never comes from a platform error.\n */\nexport class OversizedMessageError extends AppError {\n readonly _tag = 'OversizedMessageError'\n readonly classification = 'terminal'\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n\n constructor(args: {\n readonly messageName: string\n readonly bytes: number\n readonly limit: number\n }) {\n super(\n `Queue message \"${args.messageName}\" is ${String(args.bytes)} bytes, over the ${String(args.limit)} limit`,\n )\n this.messageName = args.messageName\n this.bytes = args.bytes\n this.limit = args.limit\n }\n}\n\n/**\n * The dead letter did not accept an unparseable message. Always transient, whatever the send\n * failure was, so the source delivery retries instead of acknowledging and losing the body.\n */\nexport class DeadLetterUnavailableError extends AppError {\n readonly _tag = 'DeadLetterUnavailableError'\n readonly classification = 'transient'\n\n constructor(cause: unknown) {\n super('The dead letter did not accept the message', { cause })\n }\n}\n","import { Result } from 'better-result'\n\nimport { createLogger, shouldRetryFailure } from '@/core'\n\nimport {\n chunkByLimits,\n isOversized,\n messageByteLength,\n QUEUE_LIMITS,\n sizeMessages,\n type SizedMessage,\n} from './queue-limits.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { OversizedMessageError, QueueSendError, type QueueOperation } from './queue.errors.ts'\n\n/** Cloudflare's delivery delay, in seconds. */\nexport type QueueSendOptions = { readonly delaySeconds: number }\n\n/** The two calls the client makes on a queue binding. */\nexport type QueueBinding<Body> = Pick<Queue<Body>, 'send' | 'sendBatch'>\n\n/**\n * The lanes and the dead letter of one application. A lane is a queue with its own consumer\n * settings; `laneOf` is the product's routing rule, written once at the composition root.\n */\nexport type QueueClientOptions<TMessage extends QueueMessage, Lane extends string> = {\n readonly queues: Readonly<Record<Lane, QueueBinding<TMessage>>>\n /** Where the consumer parks poison bodies. Cloudflare also routes exhausted retries here. */\n readonly deadLetter: Pick<Queue, 'send'>\n readonly laneOf: (message: TMessage) => Lane\n}\n\n/** The port an entrypoint, a relay, or a consumer sends through. */\nexport interface IQueueClient<TMessage extends QueueMessage> {\n send(message: TMessage, options?: QueueSendOptions): Promise<void>\n sendBatch(messages: ReadonlyArray<TMessage>): Promise<void>\n /** Sends every message within the limits and returns the oversized ones. */\n sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>>\n /** Parks a delivered message's body as it arrived. */\n sendToDlq(message: Message): Promise<void>\n}\n\nconst logger = createLogger('queue-client')\n\n/** Immediate retries of one binding call: three attempts, 250 ms then 500 ms, with jitter. */\nconst RETRY_TIMES = 2\nconst RETRY_BASE_DELAY_MS = 250\n\n/** Every send is safe to repeat, so a transient failure gets an immediate retry; terminal and unknown stop. */\nfunction shouldRetrySend(error: QueueSendError): boolean {\n return shouldRetryFailure({\n classification: error.classification,\n repeatSafe: true,\n owner: 'immediate',\n })\n}\n\nfunction oversizedError(sized: SizedMessage<QueueMessage>): OversizedMessageError {\n return new OversizedMessageError({\n messageName: sized.message.name,\n bytes: sized.bytes,\n limit: QUEUE_LIMITS.messageBytes,\n })\n}\n\n/**\n * Sends messages to the application's queues. Checks the size limits before a send, retries a\n * transient binding failure a bounded number of times, and converts the final failure to `QueueSendError`.\n */\nexport class QueueClient<\n TMessage extends QueueMessage,\n Lane extends string,\n> implements IQueueClient<TMessage> {\n constructor(private readonly options: QueueClientOptions<TMessage, Lane>) {}\n\n /** @throws OversizedMessageError before the send. @throws QueueSendError after the retries. */\n async send(message: TMessage, options?: QueueSendOptions): Promise<void> {\n const sized = { message, bytes: messageByteLength(message) }\n if (isOversized(sized)) {\n logger.warn('queue_message_oversized', {\n name: message.name,\n type: message.type,\n bytes: sized.bytes,\n })\n throw oversizedError(sized)\n }\n\n const queue = this.queueFor(message)\n logger.debug('sending_message', {\n name: message.name,\n type: message.type,\n delaySeconds: options?.delaySeconds,\n })\n\n await this.callQueue('send', async () => {\n if (options) {\n await queue.send(message, options)\n } else {\n await queue.send(message)\n }\n })\n }\n\n /** Rejects the whole batch before any send when one message is oversized. */\n async sendBatch(messages: ReadonlyArray<TMessage>): Promise<void> {\n const sized = sizeMessages(messages)\n const oversized = sized.find(isOversized)\n if (oversized) throw oversizedError(oversized)\n await this.sendSizedBatch(sized)\n }\n\n async sendBatchPartial<Sent extends TMessage>(\n messages: ReadonlyArray<Sent>,\n ): Promise<ReadonlyArray<Sent>> {\n const sized = sizeMessages(messages)\n await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))\n return sized.filter(isOversized).map((item) => item.message)\n }\n\n async sendToDlq(message: Message): Promise<void> {\n logger.debug('sending_to_dlq', { queueMessageId: message.id })\n await this.callQueue('send_dlq', async () => {\n await this.options.deadLetter.send(message.body)\n })\n }\n\n /** Each lane's chunks retry on their own, so one failed chunk does not resend the others. */\n private async sendSizedBatch(sized: ReadonlyArray<SizedMessage<TMessage>>): Promise<void> {\n const byLane = new Map<Lane, SizedMessage<TMessage>[]>()\n for (const item of sized) {\n const lane = this.options.laneOf(item.message)\n const items = byLane.get(lane) ?? []\n items.push(item)\n byLane.set(lane, items)\n }\n\n logger.debug('sending_batch', {\n count: sized.length,\n lanes: [...byLane].map(([lane, items]) => ({ lane, count: items.length })),\n })\n\n const sends: Promise<void>[] = []\n for (const [lane, items] of byLane) {\n const queue = this.options.queues[lane]\n for (const group of chunkByLimits(items)) {\n sends.push(\n this.callQueue('send_batch', async () => {\n await queue.sendBatch(group.map((body) => ({ body })))\n }),\n )\n }\n }\n await Promise.all(sends)\n }\n\n private queueFor(message: TMessage): QueueBinding<TMessage> {\n return this.options.queues[this.options.laneOf(message)]\n }\n\n private async callQueue<T>(operation: QueueOperation, call: () => Promise<T>): Promise<T> {\n const result = await Result.tryPromise(\n {\n try: call,\n catch: (cause) => new QueueSendError({ operation, cause }),\n },\n {\n retry: {\n times: RETRY_TIMES,\n delayMs: RETRY_BASE_DELAY_MS,\n backoff: 'exponential',\n jitter: true,\n shouldRetry: shouldRetrySend,\n },\n },\n )\n if (result.isErr()) throw result.error\n return result.value\n }\n}\n","import {\n classifyFailure,\n exponentialEqualJitterMs,\n retryAfterSecondsOf,\n shouldRetryFailure,\n spreadJitterMs,\n type FailureClassification,\n} from '@/core'\n\nconst BASE_RETRY_DELAY_MS = 5_000\nconst MAX_RETRY_DELAY_MS = 120_000\nconst RETRY_AFTER_SPREAD_MS = 5_000\n/** Cloudflare Queues accept a redelivery delay of at most one day. */\nconst MAX_QUEUE_DELAY_SECONDS = 24 * 60 * 60\n\n/** How the consumer settles one failed message: acknowledge it, or have Cloudflare redeliver it after a delay. */\nexport type RedeliveryDecision =\n | { readonly action: 'ack'; readonly classification: 'terminal' }\n | {\n readonly action: 'retry'\n readonly classification: Exclude<FailureClassification, 'terminal'>\n readonly delaySeconds: number\n }\n\n/**\n * Decide whether the consumer acknowledges a failed message or asks Cloudflare to redeliver it.\n * A terminal failure acknowledges. Transient and unknown failures redeliver: after the advertised\n * wait plus a spread when the error carries one, else with exponential equal-jitter backoff.\n * An `AggregateError` from event delivery redelivers when any member does, after the longest wait.\n */\nexport function decideRedelivery(cause: unknown, attempt: number): RedeliveryDecision {\n if (cause instanceof AggregateError && cause.errors.length > 0) {\n const decisions = cause.errors.map((member) => decideRedelivery(member, attempt))\n const retries = decisions.filter((decision) => decision.action === 'retry')\n if (retries.length === 0) return { action: 'ack', classification: 'terminal' }\n\n // Redelivery must respect every failed subscriber's required wait.\n return {\n action: 'retry',\n classification: retries.some((decision) => decision.classification === 'unknown')\n ? 'unknown'\n : 'transient',\n delaySeconds: Math.max(...retries.map((decision) => decision.delaySeconds)),\n }\n }\n\n const classification = classifyFailure(cause)\n if (classification === 'terminal') return { action: 'ack', classification }\n\n const canRetry = shouldRetryFailure({ classification, repeatSafe: true, owner: 'durable' })\n if (!canRetry) return { action: 'ack', classification: 'terminal' }\n\n const retryAfterSeconds = retryAfterSecondsOf(cause)\n const delayMs =\n retryAfterSeconds === null\n ? exponentialEqualJitterMs(BASE_RETRY_DELAY_MS, MAX_RETRY_DELAY_MS, attempt)\n : retryAfterSeconds * 1_000 + spreadJitterMs(RETRY_AFTER_SPREAD_MS)\n\n return {\n action: 'retry',\n classification,\n delaySeconds: Math.min(MAX_QUEUE_DELAY_SECONDS, Math.max(1, Math.ceil(delayMs / 1_000))),\n }\n}\n","import type { z } from 'zod'\n\nimport { MessageAlreadyProcessedError } from '@/application'\nimport { createLogger } from '@/core'\n\nimport type { IQueueClient } from './queue-client.ts'\nimport type { QueueMessage } from './queue-message.ts'\nimport { DeadLetterUnavailableError } from './queue.errors.ts'\nimport { decideRedelivery } from './redelivery-policy.ts'\n\nconst logger = createLogger('queue-consumer')\n\n/** Wraps one dispatch: an error-tracking scope, an analytics context. The default runs it as is. */\nexport type DispatchWrapper<TMessage extends QueueMessage> = (\n message: TMessage,\n attempt: number,\n run: () => Promise<void>,\n) => Promise<void>\n\n/** The one call the consumer makes on the bus. */\nexport type DispatchBus<TMessage extends QueueMessage> = {\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- the bus resolves to each handler's own value; the consumer discards it.\n handle(message: TMessage): Promise<unknown>\n}\n\n/**\n * Parses and dispatches each queue message, then selects acknowledgement or retry.\n * Cloudflare owns the retry limit and sends exhausted messages to the configured dead letter.\n * The consumer logs each failure once, inside the dispatch wrapper; handlers do not.\n */\nexport class QueueConsumer<TMessage extends QueueMessage> {\n /**\n * @param bus - Where a parsed message goes.\n * @param queueClient - Parks an unparseable body in the dead letter.\n * @param schema - What a body may be: the application's `messages.queueSchema`.\n * @param wrapDispatch - Wraps one dispatch in the application's context. Default: run it as is.\n */\n constructor(\n private readonly bus: DispatchBus<TMessage>,\n private readonly queueClient: Pick<IQueueClient<TMessage>, 'sendToDlq'>,\n private readonly schema: z.ZodType<TMessage>,\n private readonly wrapDispatch: DispatchWrapper<TMessage> = (_message, _attempt, run) => run(),\n ) {}\n\n /** Every message settles on its own. A settlement that throws rejects the batch after its siblings settle. */\n async processBatch(batch: MessageBatch): Promise<void> {\n logger.debug('queue_batch_received', { count: batch.messages.length })\n const results = await Promise.allSettled(\n batch.messages.map((message) => this.processMessage(message)),\n )\n const rejected = results\n .filter((result) => result.status === 'rejected')\n .map((result) => result.reason)\n\n if (rejected.length > 0) {\n throw new AggregateError(rejected, 'Queue message settlement failed')\n }\n }\n\n private async processMessage(message: Message): Promise<void> {\n let parsed: TMessage | null = null\n let handlerFailed = false\n let settled = false\n\n try {\n const body = await this.parseMessage(message)\n if (body === null) {\n // An unparseable body cannot succeed on retry, so park it for inspection.\n logger.error('invalid_message_format', {\n attempt: message.attempts,\n queueMessageId: message.id,\n })\n await this.parkPoison(message)\n return\n }\n parsed = body\n\n await this.wrapDispatch(body, message.attempts, async () => {\n try {\n await this.bus.handle(body)\n } catch (cause) {\n handlerFailed = true\n this.handleFailure(message, body, cause)\n settled = true\n // The wrapper still sees the rejection, and this method does not settle it again.\n throw cause\n }\n })\n } catch (cause) {\n if (settled) return\n // A settlement inside run threw: reject the batch, as a settlement failure does elsewhere.\n if (handlerFailed) throw cause\n this.handleFailure(message, parsed, cause)\n return\n }\n // A wrapper can resolve after run rejects; that message is already settled.\n if (parsed === null || handlerFailed) return\n try {\n message.ack()\n logger.debug('queue_message_processed', { name: parsed.name })\n } catch (cause) {\n this.handleFailure(message, parsed, cause)\n }\n }\n\n /** Returns null on failure; the caller owns the single log point. */\n private async parseMessage(message: Message): Promise<TMessage | null> {\n const result = await this.schema.safeParseAsync(message.body)\n return result.success ? result.data : null\n }\n\n /** Park an unparseable message in the dead letter, then acknowledge its source delivery. */\n private async parkPoison(message: Message): Promise<void> {\n try {\n await this.queueClient.sendToDlq(message)\n message.ack()\n } catch (cause) {\n throw new DeadLetterUnavailableError(cause)\n }\n }\n\n /**\n * Retry declared transient and unknown failures so Cloudflare can enforce the attempt ceiling.\n * Acknowledge only declared terminal failures.\n */\n private handleFailure(message: Message, parsed: TMessage | null, cause: unknown): void {\n if (cause instanceof MessageAlreadyProcessedError) {\n logger.debug('duplicate_skipped', {\n name: parsed?.name,\n messageId: cause.messageId,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const decision = decideRedelivery(cause, message.attempts)\n if (decision.action === 'ack') {\n logger.error('message_discarded', {\n error: cause,\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n })\n message.ack()\n return\n }\n\n const context = {\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n classification: decision.classification,\n delaySeconds: decision.delaySeconds,\n }\n if (decision.classification === 'unknown' && message.attempts === 1) {\n logger.error('queue_unknown_failure', { error: cause, ...context })\n } else {\n logger.debug('redelivery_requested', context)\n }\n message.retry({ delaySeconds: decision.delaySeconds })\n }\n}\n","import type { IOutboxRelay, OutboxRelay } from '@/application'\nimport { createLogger } from '@/core'\n\nconst logger = createLogger('outbox-wake')\n\n/** Register delivery after commit; log a failed drain once and leave recovery to the scheduled sweep. */\nexport function waitUntilWake(\n context: Pick<ExecutionContext, 'waitUntil'>,\n relay: Pick<OutboxRelay, 'drain'>,\n): IOutboxRelay {\n return {\n wake: () =>\n context.waitUntil(\n relay.drain().catch((cause: unknown) => {\n logger.error('outbox_wake_failed', { error: cause })\n }),\n ),\n }\n}\n"],"mappings":"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"}
@@ -0,0 +1,24 @@
1
+ //#region src/infrastructure/cloudflare/tracing.d.ts
2
+ /**
3
+ * Workers tracing for Workers Issues.
4
+ *
5
+ * Ported from Suparelay `apps/web/src/server/infrastructure/tracing/span.ts`.
6
+ * Pass `tracing` in. This file does not import `cloudflare:workers`.
7
+ * A client bundle can import a server module that uses these helpers.
8
+ *
9
+ * Issues shows the active span's attributes beside a failure. Put identifiers on the span.
10
+ * Outside an invocation there is no active span, and a missing span records nothing.
11
+ */
12
+ /** Attribute values a Workers span accepts. `undefined` leaves that attribute unset. */
13
+ export type SpanAttributes = Record<string, string | number | boolean | undefined>;
14
+ /** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */
15
+ export declare function annotateSpan(tracing: Tracing, attributes: SpanAttributes): void;
16
+ /**
17
+ * Run `operation` in a child span.
18
+ * Use it when one invocation runs several units of work, such as each message in a queue batch.
19
+ * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.
20
+ * An expected throw, such as a duplicate message, is not an exception record.
21
+ */
22
+ export declare function withSpan<T>(tracing: Tracing, name: string, attributes: SpanAttributes, operation: () => Promise<T>): Promise<T>;
23
+ //#endregion
24
+ //# sourceMappingURL=tracing.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracing.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/tracing.ts"],"mappings":";;;;;;;;;;;;YAYY,iBAAiB;;wBAGb,aAAa,SAAS,SAAS,YAAY;;;;;;;wBAU3C,SAAS,GACvB,SAAS,SACT,cACA,YAAY,gBACZ,iBAAiB,QAAQ,KACxB,QAAQ"}
@@ -0,0 +1,21 @@
1
+ //#region src/infrastructure/cloudflare/tracing.ts
2
+ /** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */
3
+ function annotateSpan(tracing, attributes) {
4
+ tracing.getActiveSpan()?.setAttributes(attributes);
5
+ }
6
+ /**
7
+ * Run `operation` in a child span.
8
+ * Use it when one invocation runs several units of work, such as each message in a queue batch.
9
+ * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.
10
+ * An expected throw, such as a duplicate message, is not an exception record.
11
+ */
12
+ function withSpan(tracing, name, attributes, operation) {
13
+ return tracing.enterSpan(name, (span) => {
14
+ span.setAttributes(attributes);
15
+ return operation();
16
+ });
17
+ }
18
+
19
+ //#endregion
20
+ export { annotateSpan, withSpan };
21
+ //# sourceMappingURL=tracing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tracing.js","names":[],"sources":["../../src/infrastructure/cloudflare/tracing.ts"],"sourcesContent":["/**\n * Workers tracing for Workers Issues.\n *\n * Ported from Suparelay `apps/web/src/server/infrastructure/tracing/span.ts`.\n * Pass `tracing` in. This file does not import `cloudflare:workers`.\n * A client bundle can import a server module that uses these helpers.\n *\n * Issues shows the active span's attributes beside a failure. Put identifiers on the span.\n * Outside an invocation there is no active span, and a missing span records nothing.\n */\n\n/** Attribute values a Workers span accepts. `undefined` leaves that attribute unset. */\nexport type SpanAttributes = Record<string, string | number | boolean | undefined>\n\n/** Add attributes to the active span. That span is the invocation root unless `withSpan` encloses the call. */\nexport function annotateSpan(tracing: Tracing, attributes: SpanAttributes): void {\n tracing.getActiveSpan()?.setAttributes(attributes)\n}\n\n/**\n * Run `operation` in a child span.\n * Use it when one invocation runs several units of work, such as each message in a queue batch.\n * This helper does not record an exception. A failure reaches Issues as the uncaught error or `logger.error`.\n * An expected throw, such as a duplicate message, is not an exception record.\n */\nexport function withSpan<T>(\n tracing: Tracing,\n name: string,\n attributes: SpanAttributes,\n operation: () => Promise<T>,\n): Promise<T> {\n return tracing.enterSpan(name, (span) => {\n span.setAttributes(attributes)\n return operation()\n })\n}\n"],"mappings":";;AAeA,SAAgB,aAAa,SAAkB,YAAkC;CAC/E,QAAQ,cAAc,CAAC,EAAE,cAAc,UAAU;AACnD;;;;;;;AAQA,SAAgB,SACd,SACA,MACA,YACA,WACY;CACZ,OAAO,QAAQ,UAAU,OAAO,SAAS;EACvC,KAAK,cAAc,UAAU;EAC7B,OAAO,UAAU;CACnB,CAAC;AACH"}
@@ -1,6 +1,6 @@
1
1
  import { W as DomainEvent, et as D1ConstraintKind, nt as DatabaseFailure, q as MessageId, tt as DatabaseError } from "./index-DUFS0SJ_.js";
2
- import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-tNZHSCpJ.js";
3
- import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-_B8l8l6l.js";
2
+ import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-lS_g1olK.js";
3
+ import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-15Sv9WnG.js";
4
4
  import { BatchItem } from "drizzle-orm/batch";
5
5
  import { AnyD1Database, DrizzleD1Database } from "drizzle-orm/d1";
6
6
  //#region src/infrastructure/cloudflare/d1-database-types.d.ts
@@ -100,6 +100,21 @@ type OutboundEmail = {
100
100
  type ReceivedEmail = {
101
101
  readonly headers: Readonly<Record<string, string>>;
102
102
  };
103
+ /** One attachment of a received email. `downloadUrl` is a signed link that stops working at `expiresAt`. */
104
+ type ReceivedAttachment = {
105
+ readonly filename: string | null;
106
+ readonly contentType: string;
107
+ readonly size: number;
108
+ readonly downloadUrl: string;
109
+ readonly expiresAt: string;
110
+ };
111
+ /** A received email's sender, plain text body, and attachments, for an application that reads mail it receives. */
112
+ type ReceivedContent = {
113
+ /** The sender as the email states it, for example "Display Name <address@domain.com>". */
114
+ readonly from: string;
115
+ readonly text: string | null;
116
+ readonly attachments: readonly ReceivedAttachment[];
117
+ };
103
118
  /** The provider accepted the email and assigned its id. */
104
119
  type EmailAccepted = {
105
120
  readonly providerId: string;
@@ -395,5 +410,5 @@ declare class UnitOfWorkAlreadyActiveError extends AppError {
395
410
  constructor();
396
411
  }
397
412
  //#endregion
398
- export { CommandHandler as $, IOutboxRepository as A, OutboundEmail as B, UnitOfWorkDeps as C, OutboxRelay as D, IOutboxRelay as E, IEventCollector as F, ObservabilityUser as G, RecordingEmailService as H, EmailAccepted as I, MetricOptions as J, MetricAttributeValue as K, EmailAttachment as L, OutboxClaim as M, OutboxMessage as N, OutboxRelayOptions as O, IMessageStore as P, NULL_ANALYTICS as Q, IEmailService as R, UnitOfWork as S, IOutboxPublisher as T, ObservabilityScope as U, ReceivedEmail as V, ObservabilityService as W, NULL_METRICS_SERVICE as X, MetricsService as Y, AnalyticsService as Z, RecordEmailEventCommand as _, OutboxRetentionResult as a, recordEmailEvent as b, CommandRegistry as c, MessageRegistry as d, EventHandler as et, MessageResult as f, IEmailDeliverabilityStore as g, EmailBounceRecord as h, OutboxRetentionOptions as i, IOutboxRetentionRepository as j, DeadLetterReason as k, EventRegistry as l, RegistryMessage as m, MessageAlreadyProcessedError as n, IMessageBus as o, QueryRegistry as p, MetricAttributes as q, OutboxRetention as r, MessageBus as s, UnitOfWorkAlreadyActiveError as t, QueryHandler as tt, Handlers as u, RecordEmailEventDeps as v, ITransaction as w, IUnitOfWork as x, RecordEmailEventRepos as y, MimeType as z };
399
- //# sourceMappingURL=index-tNZHSCpJ.d.ts.map
413
+ export { AnalyticsService as $, IOutboxRepository as A, OutboundEmail as B, UnitOfWorkDeps as C, OutboxRelay as D, IOutboxRelay as E, IEventCollector as F, ObservabilityScope as G, ReceivedContent as H, EmailAccepted as I, MetricAttributeValue as J, ObservabilityService as K, EmailAttachment as L, OutboxClaim as M, OutboxMessage as N, OutboxRelayOptions as O, IMessageStore as P, NULL_METRICS_SERVICE as Q, IEmailService as R, UnitOfWork as S, IOutboxPublisher as T, ReceivedEmail as U, ReceivedAttachment as V, RecordingEmailService as W, MetricOptions as X, MetricAttributes as Y, MetricsService as Z, RecordEmailEventCommand as _, OutboxRetentionResult as a, recordEmailEvent as b, CommandRegistry as c, MessageRegistry as d, NULL_ANALYTICS as et, MessageResult as f, IEmailDeliverabilityStore as g, EmailBounceRecord as h, OutboxRetentionOptions as i, IOutboxRetentionRepository as j, DeadLetterReason as k, EventRegistry as l, RegistryMessage as m, MessageAlreadyProcessedError as n, EventHandler as nt, IMessageBus as o, QueryRegistry as p, ObservabilityUser as q, OutboxRetention as r, QueryHandler as rt, MessageBus as s, UnitOfWorkAlreadyActiveError as t, CommandHandler as tt, Handlers as u, RecordEmailEventDeps as v, ITransaction as w, IUnitOfWork as x, RecordEmailEventRepos as y, MimeType as z };
414
+ //# sourceMappingURL=index-lS_g1olK.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index-lS_g1olK.d.ts","names":[],"sources":["../src/application/handlers.ts","../src/application/analytics-service.ts","../src/application/metrics-service.ts","../src/application/observability-service.ts","../src/application/email-service.ts","../src/application/event-collector.ts","../src/application/message-store.ts","../src/application/outbox-repository.ts","../src/application/outbox-relay.ts","../src/application/transaction.ts","../src/application/unit-of-work.ts","../src/application/record-email-event.ts","../src/application/registry.ts","../src/application/message-bus.ts","../src/application/outbox-retention.ts","../src/application/message-already-processed.error.ts","../src/application/unit-of-work.errors.ts"],"mappings":"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"}
@@ -1,5 +1,5 @@
1
1
  import "./index-DUFS0SJ_.js";
2
- import { F as IEventCollector } from "./index-tNZHSCpJ.js";
2
+ import { F as IEventCollector } from "./index-lS_g1olK.js";
3
3
  import { BatchItem } from "drizzle-orm/batch";
4
4
  //#region src/infrastructure/drizzle-schema.d.ts
5
5
  /**
@@ -23,4 +23,4 @@ declare const OUTBOX_REPOSITORY_DEFAULTS: {
23
23
  type OutboxRepositoryOptions = Readonly<Partial<typeof OUTBOX_REPOSITORY_DEFAULTS>>;
24
24
  //#endregion
25
25
  export { eventCollector as n, DrizzleSchema as r, OutboxRepositoryOptions as t };
26
- //# sourceMappingURL=outbox-repository-options-_B8l8l6l.d.ts.map
26
+ //# sourceMappingURL=outbox-repository-options-15Sv9WnG.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"outbox-repository-options-_B8l8l6l.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts","../src/infrastructure/outbox-repository-options.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB;;;;cC3BhB;EACX;EACA;EACA;;;KAIU,0BAA0B,SAAS,eAAe"}
1
+ {"version":3,"file":"outbox-repository-options-15Sv9WnG.d.ts","names":[],"sources":["../src/infrastructure/drizzle-schema.ts","../src/infrastructure/unit-of-work-context.ts","../src/infrastructure/outbox-repository-options.ts"],"mappings":";;;;;;;;KAKY,gBAAgB;;;;cCuBf,gBAAgB;;;;cC3BhB;EACX;EACA;EACA;;;KAIU,0BAA0B,SAAS,eAAe"}
@@ -1,6 +1,6 @@
1
1
  import { W as DomainEvent, nt as DatabaseFailure, q as MessageId, tt as DatabaseError } from "./index-DUFS0SJ_.js";
2
- import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-tNZHSCpJ.js";
3
- import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-_B8l8l6l.js";
2
+ import { A as IOutboxRepository, M as OutboxClaim, N as OutboxMessage, P as IMessageStore, k as DeadLetterReason, w as ITransaction } from "./index-lS_g1olK.js";
3
+ import { n as eventCollector, r as DrizzleSchema, t as OutboxRepositoryOptions } from "./outbox-repository-options-15Sv9WnG.js";
4
4
  import { ExtractTablesWithRelations } from "drizzle-orm";
5
5
  import { PostgresJsDatabase, PostgresJsTransaction, drizzle } from "drizzle-orm/postgres-js";
6
6
  //#region src/infrastructure/postgres/postgres-database-types.d.ts
@@ -1,4 +1,4 @@
1
- import { J as MetricOptions, Y as MetricsService } from "../index-tNZHSCpJ.js";
1
+ import { X as MetricOptions, Z as MetricsService } from "../index-lS_g1olK.js";
2
2
  import { PostHog } from "posthog-node";
3
3
  //#region src/infrastructure/posthog/metrics.d.ts
4
4
  /** PostHog Metrics with Worker lifetime management, ported from Typist's metrics-service.ts. */
@@ -1,5 +1,5 @@
1
1
  import { Gt as AnalyticsEventIdentity, Kt as AnalyticsEventInput, qt as AnalyticsProperties } from "../index-DUFS0SJ_.js";
2
- import { Z as AnalyticsService } from "../index-tNZHSCpJ.js";
2
+ import { $ as AnalyticsService } from "../index-lS_g1olK.js";
3
3
  import { PostHogMetricsService } from "./metrics.js";
4
4
  import { PostHog, PostHogOptions } from "posthog-node";
5
5
  //#region src/infrastructure/posthog/server.d.ts
package/dist/resend.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { jt as BounceType } from "./index-DUFS0SJ_.js";
2
- import { B as OutboundEmail, I as EmailAccepted, R as IEmailService, V as ReceivedEmail } from "./index-tNZHSCpJ.js";
2
+ import { B as OutboundEmail, H as ReceivedContent, I as EmailAccepted, R as IEmailService, U as ReceivedEmail } from "./index-lS_g1olK.js";
3
3
  import { n as WebhookVerifier } from "./webhook-verification-D1c7BY4q.js";
4
4
  import "resend";
5
5
  //#region src/infrastructure/resend/resend-email-service.d.ts
@@ -19,6 +19,11 @@ export declare class ResendEmailService implements IEmailService {
19
19
  sendEmail(email: OutboundEmail): Promise<EmailAccepted>;
20
20
  /** Retrieve headers by the received email's provider ID. Provider failures throw EmailServiceError. */
21
21
  getReceivedEmail(emailId: string): Promise<ReceivedEmail>;
22
+ /**
23
+ * Retrieve the sender, plain text body, and attachments by the received email's provider ID.
24
+ * Each attachment carries a signed download link. Provider failures throw EmailServiceError.
25
+ */
26
+ getReceivedContent(emailId: string): Promise<ReceivedContent>;
22
27
  private getClient;
23
28
  }
24
29
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"resend.d.ts","names":[],"sources":["../src/infrastructure/resend/resend-email-service.ts","../src/infrastructure/resend/email-event.ts","../src/infrastructure/resend/resend-webhook.ts"],"mappings":";;;;;;;;;qBA+Ea,8BAA8B;mBAUtB;mBACA;;SATZ,OAAO,gBAAgB,sBAAsB;UAI5C;;EAGR,YACmB,gBACA;;EAIb,UAAU,OAAO,gBAAgB,QAAQ;;EA+BzC,iBAAiB,kBAAkB,QAAQ;UAgBnC;;;;cCxIV;;KAYM,yBACA;KAiDP;WACM;WACA;WACA;WACA;WACA;WACA,MAAM,SAAS;WACf;;;KAIC,cACP;WACU;WACA;aACE,MAAM;aACN;aACA;;MAGZ;WAA4B;WAA+B;MAC3D;WAA4B;WAAgC;MAC5D;WAA4B,cAAc;;;;;qBC7ElC,yBAAyB,gBAAgB;mBASvB;;SAPtB,OAAO,wBAAwB;UAI9B;;EAGR,YAA6B;;EAGvB,OAAO,cAAc,SAAS,UAAU,QAAQ;UA0BxC"}
1
+ {"version":3,"file":"resend.d.ts","names":[],"sources":["../src/infrastructure/resend/resend-email-service.ts","../src/infrastructure/resend/email-event.ts","../src/infrastructure/resend/resend-webhook.ts"],"mappings":";;;;;;;;;qBA8Fa,8BAA8B;mBAUtB;mBACA;;SATZ,OAAO,gBAAgB,sBAAsB;UAI5C;;EAGR,YACmB,gBACA;;EAIb,UAAU,OAAO,gBAAgB,QAAQ;;EA+BzC,iBAAiB,kBAAkB,QAAQ;;;;;EAoB3C,mBAAmB,kBAAkB,QAAQ;UAoBrC;;;;cC/KV;;KAYM,yBACA;KAiDP;WACM;WACA;WACA;WACA;WACA;WACA,MAAM,SAAS;WACf;;;KAIC,cACP;WACU;WACA;aACE,MAAM;aACN;aACA;;MAGZ;WAA4B;WAA+B;MAC3D;WAA4B;WAAgC;MAC5D;WAA4B,cAAc;;;;;qBC7ElC,yBAAyB,gBAAgB;mBASvB;;SAPtB,OAAO,wBAAwB;UAI9B;;EAGR,YAA6B;;EAGvB,OAAO,cAAc,SAAS,UAAU,QAAQ;UA0BxC"}