better-ship 0.17.0 → 0.19.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.
- package/dist/{application-Cf9SER-i.js → application-Dg1jIKPe.js} +5 -7
- package/dist/{application-Cf9SER-i.js.map → application-Dg1jIKPe.js.map} +1 -1
- package/dist/application.d.ts +2 -2
- package/dist/application.js +1 -1
- package/dist/better-auth/apple-client-secret.d.ts +1 -1
- package/dist/better-auth/apple-client-secret.js +2 -2
- package/dist/better-auth/apple-client-secret.js.map +1 -1
- package/dist/better-auth/auth-client.js +1 -1
- package/dist/better-auth/rate-limit.d.ts +1 -1
- package/dist/better-auth/rate-limit.js +4 -6
- package/dist/better-auth/rate-limit.js.map +1 -1
- package/dist/better-auth.js +1 -1
- package/dist/billing/client.d.ts +86 -0
- package/dist/billing/client.d.ts.map +1 -0
- package/dist/billing/client.js +77 -0
- package/dist/billing/client.js.map +1 -0
- package/dist/cloudflare/auth-secondary-storage.d.ts +1 -1
- package/dist/cloudflare/auth-secondary-storage.js +1 -1
- package/dist/cloudflare/durable-object-client.js +1 -1
- package/dist/cloudflare/durable-object-errors.d.ts +1 -1
- package/dist/cloudflare/durable-object-errors.js +1 -1
- package/dist/cloudflare/kv-client.js +1 -1
- package/dist/cloudflare/kv-errors.d.ts +1 -1
- package/dist/cloudflare/kv-errors.js +1 -1
- package/dist/cloudflare/queue.d.ts +2 -2
- package/dist/cloudflare/queue.d.ts.map +1 -1
- package/dist/cloudflare/queue.js +10 -12
- package/dist/cloudflare/queue.js.map +1 -1
- package/dist/cloudflare/r2-client.d.ts +70 -0
- package/dist/cloudflare/r2-client.d.ts.map +1 -0
- package/dist/cloudflare/r2-client.js +153 -0
- package/dist/cloudflare/r2-client.js.map +1 -0
- package/dist/cloudflare/r2-errors.d.ts +34 -0
- package/dist/cloudflare/r2-errors.d.ts.map +1 -0
- package/dist/cloudflare/r2-errors.js +90 -0
- package/dist/cloudflare/r2-errors.js.map +1 -0
- package/dist/cloudflare/scheduled.d.ts +1 -1
- package/dist/cloudflare/scheduled.js +2 -2
- package/dist/cloudflare/scheduled.js.map +1 -1
- package/dist/cloudflare.d.ts +3 -3
- package/dist/cloudflare.js +3 -3
- package/dist/{core-ClSP5-V9.js → core-BT1dSEQI.js} +106 -195
- package/dist/core-BT1dSEQI.js.map +1 -0
- package/dist/core.d.ts +2 -2
- package/dist/core.js +2 -2
- package/dist/http.d.ts +16 -8
- package/dist/http.d.ts.map +1 -1
- package/dist/http.js +51 -12
- package/dist/http.js.map +1 -1
- package/dist/{index-Cchdg2aQ.d.ts → index-CYHXOH08.d.ts} +2 -2
- package/dist/{index-Cchdg2aQ.d.ts.map → index-CYHXOH08.d.ts.map} +1 -1
- package/dist/{index-DvJUcSW1.d.ts → index-DUFS0SJ_.d.ts} +41 -102
- package/dist/index-DUFS0SJ_.d.ts.map +1 -0
- package/dist/{outbox-repository-options-ICOF8g2h.js → outbox-repository-options-B8TnAE1h.js} +2 -2
- package/dist/{outbox-repository-options-ICOF8g2h.js.map → outbox-repository-options-B8TnAE1h.js.map} +1 -1
- package/dist/{outbox-repository-options-AkihEaik.d.ts → outbox-repository-options-DjBgFhMi.d.ts} +3 -3
- package/dist/{outbox-repository-options-AkihEaik.d.ts.map → outbox-repository-options-DjBgFhMi.d.ts.map} +1 -1
- package/dist/postgres.d.ts +3 -3
- package/dist/postgres.js +3 -3
- package/dist/posthog/metrics.d.ts +1 -1
- package/dist/posthog/server.d.ts +2 -2
- package/dist/posthog/server.js +1 -1
- package/dist/resend.d.ts +5 -5
- package/dist/resend.d.ts.map +1 -1
- package/dist/resend.js +30 -57
- package/dist/resend.js.map +1 -1
- package/dist/sentry/client.d.ts +1 -1
- package/dist/sentry/client.d.ts.map +1 -1
- package/dist/sentry/client.js +10 -4
- package/dist/sentry/client.js.map +1 -1
- package/dist/sentry/server.d.ts +2 -2
- package/dist/sentry/server.js +5 -5
- package/dist/sentry/server.js.map +1 -1
- package/dist/stripe/payment-service.d.ts +52 -0
- package/dist/stripe/payment-service.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.d.ts +13 -0
- package/dist/stripe/payment-service.error.d.ts.map +1 -0
- package/dist/stripe/payment-service.error.js +30 -0
- package/dist/stripe/payment-service.error.js.map +1 -0
- package/dist/stripe/payment-service.js +72 -0
- package/dist/stripe/payment-service.js.map +1 -0
- package/dist/stripe/payment-webhook.d.ts +18 -0
- package/dist/stripe/payment-webhook.d.ts.map +1 -0
- package/dist/stripe/payment-webhook.js +35 -0
- package/dist/stripe/payment-webhook.js.map +1 -0
- package/dist/tanstack.d.ts +1 -1
- package/dist/tanstack.js +4 -4
- package/dist/tanstack.js.map +1 -1
- package/dist/testing/outbox-repository.d.ts +2 -2
- package/dist/testing/payment-service.d.ts +10 -0
- package/dist/testing/payment-service.d.ts.map +1 -0
- package/dist/testing/payment-service.js +76 -0
- package/dist/testing/payment-service.js.map +1 -0
- package/dist/webhook-verification-D1c7BY4q.d.ts +21 -0
- package/dist/webhook-verification-D1c7BY4q.d.ts.map +1 -0
- package/dist/{webhook-verification-D6aGQfNi.js → webhook-verification-qSFXv5H3.js} +2 -2
- package/dist/webhook-verification-qSFXv5H3.js.map +1 -0
- package/package.json +13 -6
- package/src/application/outbox-relay.ts +3 -1
- package/src/core/console-log-sink.ts +61 -0
- package/src/core/index.ts +1 -4
- package/src/core/logger.ts +80 -281
- package/src/infrastructure/better-auth/apple-client-secret.ts +1 -1
- package/src/infrastructure/better-auth/rate-limit.ts +3 -1
- package/src/infrastructure/billing/client.ts +173 -0
- package/src/infrastructure/cloudflare/queue/queue-consumer.ts +8 -9
- package/src/infrastructure/cloudflare/r2-client.ts +162 -0
- package/src/infrastructure/cloudflare/r2-errors.ts +111 -0
- package/src/infrastructure/cloudflare/scheduled/scheduled-handler.ts +1 -1
- package/src/infrastructure/http/index.ts +2 -6
- package/src/infrastructure/http/webhook-handler.ts +69 -12
- package/src/infrastructure/http/webhook-verification.ts +6 -9
- package/src/infrastructure/resend/email-event.ts +14 -30
- package/src/infrastructure/resend/resend-webhook.ts +12 -22
- package/src/infrastructure/sentry/client.ts +6 -3
- package/src/infrastructure/sentry/server.ts +4 -4
- package/src/infrastructure/stripe/payment-service.error.ts +42 -0
- package/src/infrastructure/stripe/payment-service.ts +129 -0
- package/src/infrastructure/stripe/payment-webhook.ts +36 -0
- package/src/infrastructure/tanstack/resolve-failure.ts +3 -2
- package/src/infrastructure/testing/payment-service-contract.ts +91 -0
- package/dist/core-ClSP5-V9.js.map +0 -1
- package/dist/index-DvJUcSW1.d.ts.map +0 -1
- package/dist/webhook-verification-BcQQG-vY.d.ts +0 -29
- package/dist/webhook-verification-BcQQG-vY.d.ts.map +0 -1
- package/dist/webhook-verification-D6aGQfNi.js.map +0 -1
package/dist/cloudflare/queue.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { o as MessageAlreadyProcessedError } from "../application-
|
|
1
|
+
import { b as shouldRetryFailure, c as exponentialEqualJitterMs, d as classifyFailure, f as retryAfterSecondsOf, l as spreadJitterMs, nt as AppError, r as createLogger, u as classifyBoundaryError } from "../core-BT1dSEQI.js";
|
|
2
|
+
import { o as MessageAlreadyProcessedError } from "../application-Dg1jIKPe.js";
|
|
3
3
|
import { Result } from "better-result";
|
|
4
4
|
|
|
5
5
|
//#region src/infrastructure/cloudflare/queue/queue-limits.ts
|
|
@@ -338,10 +338,10 @@ var QueueConsumer = class {
|
|
|
338
338
|
try {
|
|
339
339
|
const body = await this.parseMessage(message);
|
|
340
340
|
if (body === null) {
|
|
341
|
-
logger$1.error("invalid_message_format", {
|
|
341
|
+
logger$1.error("invalid_message_format", {
|
|
342
342
|
attempt: message.attempts,
|
|
343
343
|
queueMessageId: message.id
|
|
344
|
-
}
|
|
344
|
+
});
|
|
345
345
|
await this.parkPoison(message);
|
|
346
346
|
return;
|
|
347
347
|
}
|
|
@@ -350,7 +350,7 @@ var QueueConsumer = class {
|
|
|
350
350
|
await this.bus.handle(body);
|
|
351
351
|
});
|
|
352
352
|
message.ack();
|
|
353
|
-
logger$1.debug(
|
|
353
|
+
logger$1.debug("queue_message_processed", { name: body.name });
|
|
354
354
|
} catch (cause) {
|
|
355
355
|
this.handleFailure(message, parsed, cause);
|
|
356
356
|
}
|
|
@@ -387,12 +387,10 @@ var QueueConsumer = class {
|
|
|
387
387
|
if (decision.action === "ack") {
|
|
388
388
|
logger$1.error("message_discarded", {
|
|
389
389
|
error: cause,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
attempt: message.attempts
|
|
395
|
-
}
|
|
390
|
+
name: parsed?.name,
|
|
391
|
+
messageId: parsed?.id,
|
|
392
|
+
queueMessageId: message.id,
|
|
393
|
+
attempt: message.attempts
|
|
396
394
|
});
|
|
397
395
|
message.ack();
|
|
398
396
|
return;
|
|
@@ -407,7 +405,7 @@ var QueueConsumer = class {
|
|
|
407
405
|
};
|
|
408
406
|
if (decision.classification === "unknown" && message.attempts === 1) logger$1.error("queue_unknown_failure", {
|
|
409
407
|
error: cause,
|
|
410
|
-
|
|
408
|
+
...context
|
|
411
409
|
});
|
|
412
410
|
else logger$1.debug("redelivery_requested", context);
|
|
413
411
|
message.retry({ delaySeconds: decision.delaySeconds });
|
|
@@ -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 details: { attempt: message.attempts, 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:${body.name}`, { 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 details: {\n name: parsed?.name,\n messageId: parsed?.id,\n queueMessageId: message.id,\n attempt: message.attempts,\n },\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, details: 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; 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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"}
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
//#region src/infrastructure/cloudflare/r2-client.d.ts
|
|
2
|
+
/** Values accepted by the R2 binding. */
|
|
3
|
+
export type R2Value = Parameters<R2Bucket['put']>[1];
|
|
4
|
+
/** Missing objects, failed conditions, and returned bodies are distinct outcomes. */
|
|
5
|
+
export type R2GetResult = {
|
|
6
|
+
status: 'missing';
|
|
7
|
+
} | {
|
|
8
|
+
status: 'precondition-failed';
|
|
9
|
+
object: R2Object;
|
|
10
|
+
} | {
|
|
11
|
+
status: 'found';
|
|
12
|
+
object: R2ObjectBody;
|
|
13
|
+
};
|
|
14
|
+
/** A failed condition does not establish whether an earlier attempt committed. */
|
|
15
|
+
export type R2PutResult = {
|
|
16
|
+
status: 'precondition-failed';
|
|
17
|
+
} | {
|
|
18
|
+
status: 'stored';
|
|
19
|
+
object: R2Object;
|
|
20
|
+
};
|
|
21
|
+
/** Untrusted JSON data, with absence separate from a stored null. The application supplies its schema. */
|
|
22
|
+
export type R2JsonResult = {
|
|
23
|
+
status: 'missing';
|
|
24
|
+
} | {
|
|
25
|
+
status: 'found';
|
|
26
|
+
value: unknown;
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.
|
|
30
|
+
* Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.
|
|
31
|
+
*/
|
|
32
|
+
export declare class R2Client {
|
|
33
|
+
private readonly bucket;
|
|
34
|
+
constructor(bucket: R2Bucket);
|
|
35
|
+
/** Read native metadata, or return null when the object does not exist. */
|
|
36
|
+
head(key: string): Promise<R2Object | null>;
|
|
37
|
+
/** Return a native body without consuming it. The consumer owns later body-read failures. */
|
|
38
|
+
get(key: string, options?: R2GetOptions): Promise<R2GetResult>;
|
|
39
|
+
/** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */
|
|
40
|
+
getJson(key: string): Promise<R2JsonResult>;
|
|
41
|
+
/** Write once, including conditional writes. Pass streams directly to the binding. */
|
|
42
|
+
put(key: string, body: R2Value, options?: R2PutOptions): Promise<R2PutResult>;
|
|
43
|
+
/** Serialize once and default the content type to application/json without changing caller options. */
|
|
44
|
+
putJson(key: string, value: unknown, options?: R2PutOptions): Promise<R2PutResult>;
|
|
45
|
+
/** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */
|
|
46
|
+
delete(keys: string | string[]): Promise<void>;
|
|
47
|
+
/** Return one native page with the requested metadata, cursor, and delimiter results. */
|
|
48
|
+
list(options?: R2ListOptions): Promise<R2Objects>;
|
|
49
|
+
/** Create once. A failed response can leave an upload whose ID the caller never received. */
|
|
50
|
+
createMultipartUpload(key: string, options?: R2MultipartOptions): Promise<R2MultipartClient>;
|
|
51
|
+
/** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */
|
|
52
|
+
resumeMultipartUpload(key: string, uploadId: string): R2MultipartClient;
|
|
53
|
+
}
|
|
54
|
+
/** An upload handle that binds its key and ID once. Every operation makes one attempt. */
|
|
55
|
+
export declare class R2MultipartClient {
|
|
56
|
+
private readonly upload;
|
|
57
|
+
constructor(upload: R2MultipartUpload);
|
|
58
|
+
/** The destination object key. */
|
|
59
|
+
get key(): string;
|
|
60
|
+
/** The provider ID to persist when the application needs to resume this upload. */
|
|
61
|
+
get uploadId(): string;
|
|
62
|
+
/** Upload one part without buffering or replaying its body. */
|
|
63
|
+
uploadPart(partNumber: number, body: Exclude<R2Value, null>, options?: R2UploadPartOptions): Promise<R2UploadedPart>;
|
|
64
|
+
/** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */
|
|
65
|
+
complete(parts: ReadonlyArray<R2UploadedPart>): Promise<R2Object>;
|
|
66
|
+
/** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */
|
|
67
|
+
abort(): Promise<void>;
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//# sourceMappingURL=r2-client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-client.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/r2-client.ts"],"mappings":";;YAKY,UAAU,WAAW;;YAGrB;EACN;;EACA;EAA+B,QAAQ;;EACvC;EAAiB,QAAQ;;;YAGnB;EAAgB;;EAAoC;EAAkB,QAAQ;;;YAG9E;EAAiB;;EAAwB;EAAiB;;;;;;qBAMzD;mBACkB;EAA7B,YAA6B,QAAQ;;EAGrC,KAAK,cAAc,QAAQ;;EAKrB,IAAI,aAAa,UAAU,eAAe,QAAQ;;EAQlD,QAAQ,cAAc,QAAQ;;EAc9B,IAAI,aAAa,MAAM,SAAS,UAAU,eAAe,QAAQ;;EAMjE,QACJ,aAEA,gBACA,UAAU,eACT,QAAQ;;EAYX,OAAO,0BAA0B;;EAKjC,KAAK,UAAU,gBAAgB,QAAQ;;EAKjC,sBACJ,aACA,UAAU,qBACT,QAAQ;;EAQX,sBAAsB,aAAa,mBAAmB;;;qBAM3C;mBACkB;EAA7B,YAA6B,QAAQ;;MAGjC;;MAKA;;EAKJ,WACE,oBACA,MAAM,QAAQ,gBACd,UAAU,sBACT,QAAQ;;EAKX,SAAS,OAAO,cAAc,kBAAkB,QAAQ;;EAOlD,SAAS"}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { nt as AppError, v as isAbortError } from "../core-BT1dSEQI.js";
|
|
2
|
+
import { R2Error, R2JsonError } from "./r2-errors.js";
|
|
3
|
+
|
|
4
|
+
//#region src/infrastructure/cloudflare/r2-client.ts
|
|
5
|
+
/**
|
|
6
|
+
* R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.
|
|
7
|
+
* Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.
|
|
8
|
+
*/
|
|
9
|
+
var R2Client = class {
|
|
10
|
+
bucket;
|
|
11
|
+
constructor(bucket) {
|
|
12
|
+
this.bucket = bucket;
|
|
13
|
+
}
|
|
14
|
+
/** Read native metadata, or return null when the object does not exist. */
|
|
15
|
+
head(key) {
|
|
16
|
+
return callR2("head", () => this.bucket.head(key));
|
|
17
|
+
}
|
|
18
|
+
/** Return a native body without consuming it. The consumer owns later body-read failures. */
|
|
19
|
+
async get(key, options) {
|
|
20
|
+
const object = await callR2("get", () => this.bucket.get(key, options));
|
|
21
|
+
if (object === null) return { status: "missing" };
|
|
22
|
+
if (!("body" in object)) return {
|
|
23
|
+
status: "precondition-failed",
|
|
24
|
+
object
|
|
25
|
+
};
|
|
26
|
+
return {
|
|
27
|
+
status: "found",
|
|
28
|
+
object
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */
|
|
32
|
+
async getJson(key) {
|
|
33
|
+
const text = await callR2("getJson", async () => {
|
|
34
|
+
const object = await this.bucket.get(key);
|
|
35
|
+
return object === null ? null : await object.text();
|
|
36
|
+
});
|
|
37
|
+
if (text === null) return { status: "missing" };
|
|
38
|
+
try {
|
|
39
|
+
return {
|
|
40
|
+
status: "found",
|
|
41
|
+
value: JSON.parse(text)
|
|
42
|
+
};
|
|
43
|
+
} catch (cause) {
|
|
44
|
+
throw new R2JsonError({
|
|
45
|
+
operation: "decode",
|
|
46
|
+
cause
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
/** Write once, including conditional writes. Pass streams directly to the binding. */
|
|
51
|
+
async put(key, body, options) {
|
|
52
|
+
const object = await callR2("put", () => this.bucket.put(key, body, options));
|
|
53
|
+
return object === null ? { status: "precondition-failed" } : {
|
|
54
|
+
status: "stored",
|
|
55
|
+
object
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** Serialize once and default the content type to application/json without changing caller options. */
|
|
59
|
+
async putJson(key, value, options) {
|
|
60
|
+
let text;
|
|
61
|
+
try {
|
|
62
|
+
text = JSON.stringify(value);
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
throw new R2JsonError({
|
|
65
|
+
operation: "encode",
|
|
66
|
+
cause
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (text === void 0) throw new R2JsonError({
|
|
70
|
+
operation: "encode",
|
|
71
|
+
cause: void 0
|
|
72
|
+
});
|
|
73
|
+
return this.put(key, text, {
|
|
74
|
+
...options,
|
|
75
|
+
httpMetadata: jsonMetadata(options?.httpMetadata)
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
/** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */
|
|
79
|
+
delete(keys) {
|
|
80
|
+
return callR2("delete", () => this.bucket.delete(keys));
|
|
81
|
+
}
|
|
82
|
+
/** Return one native page with the requested metadata, cursor, and delimiter results. */
|
|
83
|
+
list(options) {
|
|
84
|
+
return callR2("list", () => this.bucket.list(options));
|
|
85
|
+
}
|
|
86
|
+
/** Create once. A failed response can leave an upload whose ID the caller never received. */
|
|
87
|
+
async createMultipartUpload(key, options) {
|
|
88
|
+
const upload = await callR2("createMultipartUpload", () => this.bucket.createMultipartUpload(key, options));
|
|
89
|
+
return new R2MultipartClient(upload);
|
|
90
|
+
}
|
|
91
|
+
/** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */
|
|
92
|
+
resumeMultipartUpload(key, uploadId) {
|
|
93
|
+
return new R2MultipartClient(this.bucket.resumeMultipartUpload(key, uploadId));
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
/** An upload handle that binds its key and ID once. Every operation makes one attempt. */
|
|
97
|
+
var R2MultipartClient = class {
|
|
98
|
+
upload;
|
|
99
|
+
constructor(upload) {
|
|
100
|
+
this.upload = upload;
|
|
101
|
+
}
|
|
102
|
+
/** The destination object key. */
|
|
103
|
+
get key() {
|
|
104
|
+
return this.upload.key;
|
|
105
|
+
}
|
|
106
|
+
/** The provider ID to persist when the application needs to resume this upload. */
|
|
107
|
+
get uploadId() {
|
|
108
|
+
return this.upload.uploadId;
|
|
109
|
+
}
|
|
110
|
+
/** Upload one part without buffering or replaying its body. */
|
|
111
|
+
uploadPart(partNumber, body, options) {
|
|
112
|
+
return callR2("uploadPart", () => this.upload.uploadPart(partNumber, body, options));
|
|
113
|
+
}
|
|
114
|
+
/** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */
|
|
115
|
+
complete(parts) {
|
|
116
|
+
return callR2("complete", () => this.upload.complete([...parts].sort((a, b) => a.partNumber - b.partNumber)));
|
|
117
|
+
}
|
|
118
|
+
/** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */
|
|
119
|
+
async abort() {
|
|
120
|
+
try {
|
|
121
|
+
await callR2("abort", () => this.upload.abort());
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
if (cause instanceof R2Error && cause.operation === "abort" && cause.r2?.code === 10024) return;
|
|
124
|
+
throw cause;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
};
|
|
128
|
+
async function callR2(operation, work) {
|
|
129
|
+
try {
|
|
130
|
+
return await work();
|
|
131
|
+
} catch (cause) {
|
|
132
|
+
if (isAbortError(cause) || cause instanceof AppError) throw cause;
|
|
133
|
+
throw new R2Error({
|
|
134
|
+
operation,
|
|
135
|
+
cause
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function jsonMetadata(metadata) {
|
|
140
|
+
if (metadata instanceof Headers) {
|
|
141
|
+
const headers = new Headers(metadata);
|
|
142
|
+
if (!headers.has("content-type")) headers.set("content-type", "application/json");
|
|
143
|
+
return headers;
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
...metadata,
|
|
147
|
+
contentType: metadata?.contentType ?? "application/json"
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
//#endregion
|
|
152
|
+
export { R2Client, R2MultipartClient };
|
|
153
|
+
//# sourceMappingURL=r2-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-client.js","names":[],"sources":["../../src/infrastructure/cloudflare/r2-client.ts"],"sourcesContent":["import { AppError, isAbortError } from '@/core'\n\nimport { R2Error, R2JsonError, type R2Operation } from './r2-errors.ts'\n\n/** Values accepted by the R2 binding. */\nexport type R2Value = Parameters<R2Bucket['put']>[1]\n\n/** Missing objects, failed conditions, and returned bodies are distinct outcomes. */\nexport type R2GetResult =\n | { status: 'missing' }\n | { status: 'precondition-failed'; object: R2Object }\n | { status: 'found'; object: R2ObjectBody }\n\n/** A failed condition does not establish whether an earlier attempt committed. */\nexport type R2PutResult = { status: 'precondition-failed' } | { status: 'stored'; object: R2Object }\n\n/** Untrusted JSON data, with absence separate from a stored null. The application supplies its schema. */\nexport type R2JsonResult = { status: 'missing' } | { status: 'found'; value: unknown }\n\n/**\n * R2 access with one attempt per operation and typed failures. Inject the bucket at the composition root.\n * Callers own retries, stream replay, and recovery. The client does not log or buffer streamed uploads.\n */\nexport class R2Client {\n constructor(private readonly bucket: R2Bucket) {}\n\n /** Read native metadata, or return null when the object does not exist. */\n head(key: string): Promise<R2Object | null> {\n return callR2('head', () => this.bucket.head(key))\n }\n\n /** Return a native body without consuming it. The consumer owns later body-read failures. */\n async get(key: string, options?: R2GetOptions): Promise<R2GetResult> {\n const object = await callR2('get', () => this.bucket.get(key, options))\n if (object === null) return { status: 'missing' }\n if (!('body' in object)) return { status: 'precondition-failed', object }\n return { status: 'found', object }\n }\n\n /** Buffer one stored JSON body. Read failures throw R2Error; invalid JSON throws R2JsonError. */\n async getJson(key: string): Promise<R2JsonResult> {\n const text = await callR2('getJson', async () => {\n const object = await this.bucket.get(key)\n return object === null ? null : await object.text()\n })\n if (text === null) return { status: 'missing' }\n try {\n return { status: 'found', value: JSON.parse(text) }\n } catch (cause) {\n throw new R2JsonError({ operation: 'decode', cause })\n }\n }\n\n /** Write once, including conditional writes. Pass streams directly to the binding. */\n async put(key: string, body: R2Value, options?: R2PutOptions): Promise<R2PutResult> {\n const object = await callR2('put', () => this.bucket.put(key, body, options))\n return object === null ? { status: 'precondition-failed' } : { status: 'stored', object }\n }\n\n /** Serialize once and default the content type to application/json without changing caller options. */\n async putJson(\n key: string,\n // oxlint-disable-next-line anti-slop/no-unknown-parameters -- This serialization boundary accepts any value and rejects values without a JSON representation.\n value: unknown,\n options?: R2PutOptions,\n ): Promise<R2PutResult> {\n let text: string | undefined\n try {\n text = JSON.stringify(value)\n } catch (cause) {\n throw new R2JsonError({ operation: 'encode', cause })\n }\n if (text === undefined) throw new R2JsonError({ operation: 'encode', cause: undefined })\n return this.put(key, text, { ...options, httpMetadata: jsonMetadata(options?.httpMetadata) })\n }\n\n /** Delete keys in one binding call. Missing keys succeed; R2 enforces its batch limit. */\n delete(keys: string | string[]): Promise<void> {\n return callR2('delete', () => this.bucket.delete(keys))\n }\n\n /** Return one native page with the requested metadata, cursor, and delimiter results. */\n list(options?: R2ListOptions): Promise<R2Objects> {\n return callR2('list', () => this.bucket.list(options))\n }\n\n /** Create once. A failed response can leave an upload whose ID the caller never received. */\n async createMultipartUpload(\n key: string,\n options?: R2MultipartOptions,\n ): Promise<R2MultipartClient> {\n const upload = await callR2('createMultipartUpload', () =>\n this.bucket.createMultipartUpload(key, options),\n )\n return new R2MultipartClient(upload)\n }\n\n /** Construct a handle without I/O. Each subsequent operation checks whether the upload exists. */\n resumeMultipartUpload(key: string, uploadId: string): R2MultipartClient {\n return new R2MultipartClient(this.bucket.resumeMultipartUpload(key, uploadId))\n }\n}\n\n/** An upload handle that binds its key and ID once. Every operation makes one attempt. */\nexport class R2MultipartClient {\n constructor(private readonly upload: R2MultipartUpload) {}\n\n /** The destination object key. */\n get key(): string {\n return this.upload.key\n }\n\n /** The provider ID to persist when the application needs to resume this upload. */\n get uploadId(): string {\n return this.upload.uploadId\n }\n\n /** Upload one part without buffering or replaying its body. */\n uploadPart(\n partNumber: number,\n body: Exclude<R2Value, null>,\n options?: R2UploadPartOptions,\n ): Promise<R2UploadedPart> {\n return callR2('uploadPart', () => this.upload.uploadPart(partNumber, body, options))\n }\n\n /** Sort a copy of the parts and return native metadata after completion. Callers own uncertain outcomes. */\n complete(parts: ReadonlyArray<R2UploadedPart>): Promise<R2Object> {\n return callR2('complete', () =>\n this.upload.complete([...parts].sort((a, b) => a.partNumber - b.partNumber)),\n )\n }\n\n /** Ensure no active upload remains. NoSuchUpload succeeds; a completed object remains intact. */\n async abort(): Promise<void> {\n try {\n await callR2('abort', () => this.upload.abort())\n } catch (cause) {\n if (cause instanceof R2Error && cause.operation === 'abort' && cause.r2?.code === 10024)\n return\n throw cause\n }\n }\n}\n\nasync function callR2<T>(operation: R2Operation, work: () => Promise<T>): Promise<T> {\n try {\n return await work()\n } catch (cause) {\n if (isAbortError(cause) || cause instanceof AppError) throw cause\n throw new R2Error({ operation, cause })\n }\n}\n\nfunction jsonMetadata(metadata: R2HTTPMetadata | Headers | undefined): R2HTTPMetadata | Headers {\n if (metadata instanceof Headers) {\n const headers = new Headers(metadata)\n if (!headers.has('content-type')) headers.set('content-type', 'application/json')\n return headers\n }\n return { ...metadata, contentType: metadata?.contentType ?? 'application/json' }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Xt as AppError, Zt as FailureClassification } from "../index-DUFS0SJ_.js";
|
|
2
|
+
//#region src/infrastructure/cloudflare/r2-errors.d.ts
|
|
3
|
+
/** The storage operation that failed. */
|
|
4
|
+
export type R2Operation = 'head' | 'get' | 'getJson' | 'put' | 'delete' | 'list' | 'createMultipartUpload' | 'uploadPart' | 'complete' | 'abort';
|
|
5
|
+
/** Provider diagnostics from the outer failure, without request bodies or credentials. */
|
|
6
|
+
export type R2Failure = {
|
|
7
|
+
readonly code: number;
|
|
8
|
+
readonly action?: string | undefined;
|
|
9
|
+
};
|
|
10
|
+
/** A raw binding failure after one attempt. Classification does not establish whether a mutation committed. */
|
|
11
|
+
export declare class R2Error extends AppError {
|
|
12
|
+
readonly _tag = "R2Error";
|
|
13
|
+
readonly classification: FailureClassification;
|
|
14
|
+
readonly operation: R2Operation;
|
|
15
|
+
readonly r2?: R2Failure | undefined;
|
|
16
|
+
readonly cause: unknown;
|
|
17
|
+
constructor(input: {
|
|
18
|
+
operation: R2Operation;
|
|
19
|
+
cause: unknown;
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
/** JSON serialization or syntax failed. Binding and body-read failures use R2Error. */
|
|
23
|
+
export declare class R2JsonError extends AppError {
|
|
24
|
+
readonly _tag = "R2JsonError";
|
|
25
|
+
readonly classification = "terminal";
|
|
26
|
+
readonly operation: 'encode' | 'decode';
|
|
27
|
+
readonly cause: unknown;
|
|
28
|
+
constructor(input: {
|
|
29
|
+
operation: 'encode' | 'decode';
|
|
30
|
+
cause: unknown;
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
//#endregion
|
|
34
|
+
//# sourceMappingURL=r2-errors.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-errors.d.ts","names":[],"sources":["../../src/infrastructure/cloudflare/r2-errors.ts"],"mappings":";;;YAKY;;YAaA;WACD;WACA;;;qBA8DE,gBAAgB;WAClB;WACA,gBAAgB;WAChB,WAAW;WACX,KAAK;WACI;EAElB,YAAY;IAAS,WAAW;IAAa;;;;qBAUlC,oBAAoB;WACtB;WACA;WACA;WACS;EAElB,YAAY;IAAS;IAAgC"}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { nt as AppError, u as classifyBoundaryError, v as isAbortError } from "../core-BT1dSEQI.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
|
|
4
|
+
//#region src/infrastructure/cloudflare/r2-errors.ts
|
|
5
|
+
const R2FailureSchema = z.object({
|
|
6
|
+
code: z.number().int().positive(),
|
|
7
|
+
action: z.string().optional()
|
|
8
|
+
});
|
|
9
|
+
const classifications = /* @__PURE__ */ new Map([
|
|
10
|
+
[10001, "transient"],
|
|
11
|
+
[10043, "transient"],
|
|
12
|
+
[10054, "transient"],
|
|
13
|
+
[10058, "transient"],
|
|
14
|
+
[10013, "transient"],
|
|
15
|
+
[10002, "terminal"],
|
|
16
|
+
[10003, "terminal"],
|
|
17
|
+
[10018, "terminal"],
|
|
18
|
+
[10035, "terminal"],
|
|
19
|
+
[10042, "terminal"],
|
|
20
|
+
[10005, "terminal"],
|
|
21
|
+
[10006, "terminal"],
|
|
22
|
+
[10008, "terminal"],
|
|
23
|
+
[10009, "terminal"],
|
|
24
|
+
[10073, "terminal"],
|
|
25
|
+
[10007, "terminal"],
|
|
26
|
+
[10020, "terminal"],
|
|
27
|
+
[100100, "terminal"],
|
|
28
|
+
[10012, "terminal"],
|
|
29
|
+
[10069, "terminal"],
|
|
30
|
+
[10033, "terminal"],
|
|
31
|
+
[10014, "terminal"],
|
|
32
|
+
[10037, "terminal"],
|
|
33
|
+
[10039, "terminal"],
|
|
34
|
+
[10031, "terminal"],
|
|
35
|
+
[10011, "terminal"],
|
|
36
|
+
[10024, "terminal"],
|
|
37
|
+
[10025, "terminal"],
|
|
38
|
+
[10048, "terminal"]
|
|
39
|
+
]);
|
|
40
|
+
function extractR2Failure(cause) {
|
|
41
|
+
if (!(cause instanceof Error)) return void 0;
|
|
42
|
+
const parsed = R2FailureSchema.safeParse(cause);
|
|
43
|
+
if (parsed.success) return parsed.data;
|
|
44
|
+
const match = /\((\d{5,6})\)\s*$/.exec(cause.message);
|
|
45
|
+
return match?.[1] === void 0 ? void 0 : { code: Number(match[1]) };
|
|
46
|
+
}
|
|
47
|
+
function classifyR2Code(cause) {
|
|
48
|
+
const failure = extractR2Failure(cause);
|
|
49
|
+
return failure === void 0 ? void 0 : classifications.get(failure.code) ?? "unknown";
|
|
50
|
+
}
|
|
51
|
+
function classifyR2Error(cause) {
|
|
52
|
+
if (isAbortError(cause)) return "terminal";
|
|
53
|
+
if (cause instanceof AppError) return cause.classification;
|
|
54
|
+
return classifyBoundaryError({
|
|
55
|
+
cause,
|
|
56
|
+
classifyBoundary: classifyR2Code,
|
|
57
|
+
includeCloudflare: true
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
/** A raw binding failure after one attempt. Classification does not establish whether a mutation committed. */
|
|
61
|
+
var R2Error = class extends AppError {
|
|
62
|
+
_tag = "R2Error";
|
|
63
|
+
classification;
|
|
64
|
+
operation;
|
|
65
|
+
r2;
|
|
66
|
+
cause;
|
|
67
|
+
constructor(input) {
|
|
68
|
+
super(`R2 ${input.operation} failed`, { cause: input.cause });
|
|
69
|
+
this.operation = input.operation;
|
|
70
|
+
this.cause = input.cause;
|
|
71
|
+
this.r2 = extractR2Failure(input.cause);
|
|
72
|
+
this.classification = classifyR2Error(input.cause);
|
|
73
|
+
}
|
|
74
|
+
};
|
|
75
|
+
/** JSON serialization or syntax failed. Binding and body-read failures use R2Error. */
|
|
76
|
+
var R2JsonError = class extends AppError {
|
|
77
|
+
_tag = "R2JsonError";
|
|
78
|
+
classification = "terminal";
|
|
79
|
+
operation;
|
|
80
|
+
cause;
|
|
81
|
+
constructor(input) {
|
|
82
|
+
super(`R2 JSON ${input.operation} failed`, { cause: input.cause });
|
|
83
|
+
this.operation = input.operation;
|
|
84
|
+
this.cause = input.cause;
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
//#endregion
|
|
89
|
+
export { R2Error, R2JsonError };
|
|
90
|
+
//# sourceMappingURL=r2-errors.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"r2-errors.js","names":[],"sources":["../../src/infrastructure/cloudflare/r2-errors.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport { AppError, classifyBoundaryError, isAbortError, type FailureClassification } from '@/core'\n\n/** The storage operation that failed. */\nexport type R2Operation =\n | 'head'\n | 'get'\n | 'getJson'\n | 'put'\n | 'delete'\n | 'list'\n | 'createMultipartUpload'\n | 'uploadPart'\n | 'complete'\n | 'abort'\n\n/** Provider diagnostics from the outer failure, without request bodies or credentials. */\nexport type R2Failure = {\n readonly code: number\n readonly action?: string | undefined\n}\n\nconst R2FailureSchema = z.object({\n code: z.number().int().positive(),\n action: z.string().optional(),\n})\n\n// R2 codes describe failures; the caller owns retry decisions and repeat safety.\nconst classifications: ReadonlyMap<number, FailureClassification> = new Map([\n [10001, 'transient'],\n [10043, 'transient'],\n [10054, 'transient'],\n [10058, 'transient'],\n [10013, 'transient'],\n [10002, 'terminal'],\n [10003, 'terminal'],\n [10018, 'terminal'],\n [10035, 'terminal'],\n [10042, 'terminal'],\n [10005, 'terminal'],\n [10006, 'terminal'],\n [10008, 'terminal'],\n [10009, 'terminal'],\n [10073, 'terminal'],\n [10007, 'terminal'],\n [10020, 'terminal'],\n [100100, 'terminal'],\n [10012, 'terminal'],\n [10069, 'terminal'],\n [10033, 'terminal'],\n [10014, 'terminal'],\n [10037, 'terminal'],\n [10039, 'terminal'],\n [10031, 'terminal'],\n [10011, 'terminal'],\n [10024, 'terminal'],\n [10025, 'terminal'],\n [10048, 'terminal'],\n])\n\nfunction extractR2Failure(cause: unknown): R2Failure | undefined {\n if (!(cause instanceof Error)) return undefined\n const parsed = R2FailureSchema.safeParse(cause)\n if (parsed.success) return parsed.data\n // Cloudflare documents the numeric suffix for bindings that omit structured fields.\n const match = /\\((\\d{5,6})\\)\\s*$/.exec(cause.message)\n return match?.[1] === undefined ? undefined : { code: Number(match[1]) }\n}\n\nfunction classifyR2Code(cause: unknown): FailureClassification | undefined {\n const failure = extractR2Failure(cause)\n return failure === undefined ? undefined : (classifications.get(failure.code) ?? 'unknown')\n}\n\nfunction classifyR2Error(cause: unknown): FailureClassification {\n if (isAbortError(cause)) return 'terminal'\n if (cause instanceof AppError) return cause.classification\n return classifyBoundaryError({ cause, classifyBoundary: classifyR2Code, includeCloudflare: true })\n}\n\n/** A raw binding failure after one attempt. Classification does not establish whether a mutation committed. */\nexport class R2Error extends AppError {\n readonly _tag = 'R2Error'\n readonly classification: FailureClassification\n readonly operation: R2Operation\n readonly r2?: R2Failure | undefined\n override readonly cause: unknown\n\n constructor(input: { operation: R2Operation; cause: unknown }) {\n super(`R2 ${input.operation} failed`, { cause: input.cause })\n this.operation = input.operation\n this.cause = input.cause\n this.r2 = extractR2Failure(input.cause)\n this.classification = classifyR2Error(input.cause)\n }\n}\n\n/** JSON serialization or syntax failed. Binding and body-read failures use R2Error. */\nexport class R2JsonError extends AppError {\n readonly _tag = 'R2JsonError'\n readonly classification = 'terminal'\n readonly operation: 'encode' | 'decode'\n override readonly cause: unknown\n\n constructor(input: { operation: 'encode' | 'decode'; cause: unknown }) {\n super(`R2 JSON ${input.operation} failed`, { cause: input.cause })\n this.operation = input.operation\n this.cause = input.cause\n }\n}\n"],"mappings":";;;;AAuBA,MAAM,kBAAkB,EAAE,OAAO;CAC/B,MAAM,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS;CAChC,QAAQ,EAAE,OAAO,CAAC,CAAC,SAAS;AAC9B,CAAC;AAGD,MAAM,kCAA8D,IAAI,IAAI;CAC1E,CAAC,OAAO,WAAW;CACnB,CAAC,OAAO,WAAW;CACnB,CAAC,OAAO,WAAW;CACnB,CAAC,OAAO,WAAW;CACnB,CAAC,OAAO,WAAW;CACnB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,QAAQ,UAAU;CACnB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;CAClB,CAAC,OAAO,UAAU;AACpB,CAAC;AAED,SAAS,iBAAiB,OAAuC;CAC/D,IAAI,EAAE,iBAAiB,QAAQ,OAAO;CACtC,MAAM,SAAS,gBAAgB,UAAU,KAAK;CAC9C,IAAI,OAAO,SAAS,OAAO,OAAO;CAElC,MAAM,QAAQ,oBAAoB,KAAK,MAAM,OAAO;CACpD,OAAO,QAAQ,OAAO,SAAY,SAAY,EAAE,MAAM,OAAO,MAAM,EAAE,EAAE;AACzE;AAEA,SAAS,eAAe,OAAmD;CACzE,MAAM,UAAU,iBAAiB,KAAK;CACtC,OAAO,YAAY,SAAY,SAAa,gBAAgB,IAAI,QAAQ,IAAI,KAAK;AACnF;AAEA,SAAS,gBAAgB,OAAuC;CAC9D,IAAI,aAAa,KAAK,GAAG,OAAO;CAChC,IAAI,iBAAiB,UAAU,OAAO,MAAM;CAC5C,OAAO,sBAAsB;EAAE;EAAO,kBAAkB;EAAgB,mBAAmB;CAAK,CAAC;AACnG;;AAGA,IAAa,UAAb,cAA6B,SAAS;CACpC,AAAS,OAAO;CAChB,AAAS;CACT,AAAS;CACT,AAAS;CACT,AAAkB;CAElB,YAAY,OAAmD;EAC7D,MAAM,MAAM,MAAM,UAAU,UAAU,EAAE,OAAO,MAAM,MAAM,CAAC;EAC5D,KAAK,YAAY,MAAM;EACvB,KAAK,QAAQ,MAAM;EACnB,KAAK,KAAK,iBAAiB,MAAM,KAAK;EACtC,KAAK,iBAAiB,gBAAgB,MAAM,KAAK;CACnD;AACF;;AAGA,IAAa,cAAb,cAAiC,SAAS;CACxC,AAAS,OAAO;CAChB,AAAS,iBAAiB;CAC1B,AAAS;CACT,AAAkB;CAElB,YAAY,OAA2D;EACrE,MAAM,WAAW,MAAM,UAAU,UAAU,EAAE,OAAO,MAAM,MAAM,CAAC;EACjE,KAAK,YAAY,MAAM;EACvB,KAAK,QAAQ,MAAM;CACrB;AACF"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { q as MessageId } from "../index-DUFS0SJ_.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
//#region src/infrastructure/cloudflare/scheduled/scheduled-handler.d.ts
|
|
4
4
|
/** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { r as createLogger } from "../core-BT1dSEQI.js";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
|
|
4
4
|
//#region src/infrastructure/cloudflare/scheduled/scheduled-handler.ts
|
|
@@ -17,7 +17,7 @@ function defineSchedules(schedules) {
|
|
|
17
17
|
handler: (runs) => async (controller, deps) => {
|
|
18
18
|
const { cron } = controller;
|
|
19
19
|
if (!schema.validate(cron)) {
|
|
20
|
-
logger.error("scheduled_unknown_cron", {
|
|
20
|
+
logger.error("scheduled_unknown_cron", { cron });
|
|
21
21
|
return;
|
|
22
22
|
}
|
|
23
23
|
await runs[cron](deps, new Date(controller.scheduledTime));
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scheduled.js","names":[],"sources":["../../src/infrastructure/cloudflare/scheduled/scheduled-handler.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport { createLogger, type MessageId } from '@/core'\n\nconst logger = createLogger('scheduled')\n\n/** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */\nexport type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>\n\n/** The cron expressions of a schedule map, as literal types. */\nexport type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> =\n Schedules[keyof Schedules]\n\n/** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */\nexport type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>\n\n/** What `defineSchedules` returns: the map, its parser, and the handler factory. */\nexport type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {\n readonly schedules: Schedules\n /** Parses `controller.cron`, a bare string from the platform, into a known expression. */\n readonly schema: z.ZodType<ScheduleExpression<Schedules>>\n /**\n * Route each schedule to its work. One run per expression, so a schedule added without\n * work fails to compile. An expression Wrangler fires that the map does not know is logged\n * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.\n */\n handler<Deps>(\n runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>,\n ): ScheduledHandler<Deps>\n}\n\n/**\n * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,\n * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`\n * must hold exactly these expressions. Cron does not retry: a run that sweeps many items\n * catches each item's failure itself.\n */\nexport function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(\n schedules: Schedules,\n): AppSchedules<Schedules> {\n const schema = z.enum(schedules)\n return {\n schedules,\n schema,\n handler: (runs) => async (controller, deps) => {\n const { cron } = controller\n if (!schema.validate(cron)) {\n logger.error('scheduled_unknown_cron', {
|
|
1
|
+
{"version":3,"file":"scheduled.js","names":[],"sources":["../../src/infrastructure/cloudflare/scheduled/scheduled-handler.ts"],"sourcesContent":["import { z } from 'zod'\n\nimport { createLogger, type MessageId } from '@/core'\n\nconst logger = createLogger('scheduled')\n\n/** The work one schedule runs. Nothing returns to the platform, so each run reports its own outcome. */\nexport type ScheduledRun<Deps> = (deps: Deps, at: Date) => Promise<void>\n\n/** The cron expressions of a schedule map, as literal types. */\nexport type ScheduleExpression<Schedules extends Readonly<Record<string, string>>> =\n Schedules[keyof Schedules]\n\n/** The Cloudflare `scheduled` export, with the application's dependencies as its second argument. */\nexport type ScheduledHandler<Deps> = (controller: ScheduledController, deps: Deps) => Promise<void>\n\n/** What `defineSchedules` returns: the map, its parser, and the handler factory. */\nexport type AppSchedules<Schedules extends Readonly<Record<string, string>>> = {\n readonly schedules: Schedules\n /** Parses `controller.cron`, a bare string from the platform, into a known expression. */\n readonly schema: z.ZodType<ScheduleExpression<Schedules>>\n /**\n * Route each schedule to its work. One run per expression, so a schedule added without\n * work fails to compile. An expression Wrangler fires that the map does not know is logged\n * once and ignored. A run that throws rejects, so Cloudflare marks the invocation failed.\n */\n handler<Deps>(\n runs: Readonly<Record<ScheduleExpression<Schedules>, ScheduledRun<Deps>>>,\n ): ScheduledHandler<Deps>\n}\n\n/**\n * Define the application's schedules once: `EVERY_MINUTE: '* * * * *'`. Keys name the time,\n * not the work, because one schedule may drive several commands. Wrangler's `triggers.crons`\n * must hold exactly these expressions. Cron does not retry: a run that sweeps many items\n * catches each item's failure itself.\n */\nexport function defineSchedules<const Schedules extends Readonly<Record<string, string>>>(\n schedules: Schedules,\n): AppSchedules<Schedules> {\n const schema = z.enum(schedules)\n return {\n schedules,\n schema,\n handler: (runs) => async (controller, deps) => {\n const { cron } = controller\n if (!schema.validate(cron)) {\n logger.error('scheduled_unknown_cron', { cron })\n return\n }\n await runs[cron](deps, new Date(controller.scheduledTime))\n },\n }\n}\n\n/**\n * The id of a command a schedule issues: `cron:<Name>:<scheduledTime>`. A doubled trigger for\n * the same time is then a duplicate by construction, and the unit of work skips it.\n */\nexport function scheduledMessageId(name: string, at: Date): MessageId {\n return `cron:${name}:${String(at.getTime())}`\n}\n"],"mappings":";;;;AAIA,MAAM,SAAS,aAAa,WAAW;;;;;;;AAiCvC,SAAgB,gBACd,WACyB;CACzB,MAAM,SAAS,EAAE,KAAK,SAAS;CAC/B,OAAO;EACL;EACA;EACA,UAAU,SAAS,OAAO,YAAY,SAAS;GAC7C,MAAM,EAAE,SAAS;GACjB,IAAI,CAAC,OAAO,SAAS,IAAI,GAAG;IAC1B,OAAO,MAAM,0BAA0B,EAAE,KAAK,CAAC;IAC/C;GACF;GACA,MAAM,KAAK,KAAK,CAAC,MAAM,IAAI,KAAK,WAAW,aAAa,CAAC;EAC3D;CACF;AACF;;;;;AAMA,SAAgB,mBAAmB,MAAc,IAAqB;CACpE,OAAO,QAAQ,KAAK,GAAG,OAAO,GAAG,QAAQ,CAAC;AAC5C"}
|