@ontrails/cloudflare 0.2.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.
@@ -0,0 +1,746 @@
1
+ /**
2
+ * Cloudflare Queues producer resource and Worker consumer materializer.
3
+ *
4
+ * `cloudflareQueue` authors a resource for producer trails that send messages
5
+ * through a Queue binding. `createQueueHandler` materializes first-class core
6
+ * `queue()` activation sources into a Workers `queue(batch, env, ctx)` handler.
7
+ */
8
+
9
+ import {
10
+ CancelledError,
11
+ InternalError,
12
+ RateLimitError,
13
+ Result,
14
+ TRACE_CONTEXT_KEY,
15
+ ValidationError,
16
+ buildActivationProvenanceTraceAttrs,
17
+ getActivationWherePredicate,
18
+ getTraceSink,
19
+ isTrailsError,
20
+ matchesTrailPattern,
21
+ deriveActivationSourceFacts,
22
+ resource,
23
+ run,
24
+ traceContextFromRecord,
25
+ validateSurfaceTopo,
26
+ writeActivationTraceRecord,
27
+ withActivationProvenance,
28
+ } from '@ontrails/core';
29
+ import type {
30
+ ActivationEntry,
31
+ AnyTrail,
32
+ BaseSurfaceOptions,
33
+ Layer,
34
+ QueueSource,
35
+ Resource,
36
+ ResourceOverrideMap,
37
+ Topo,
38
+ TraceContext,
39
+ TrailContextInit,
40
+ } from '@ontrails/core';
41
+
42
+ import { registerEnvBinding } from '../env.js';
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Producer binding shape
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export type CloudflareQueuesContentType = 'bytes' | 'json' | 'text' | 'v8';
49
+
50
+ export interface CloudflareQueueMetrics {
51
+ readonly backlogBytes: number;
52
+ readonly backlogCount: number;
53
+ readonly oldestMessageTimestamp: number;
54
+ }
55
+
56
+ export interface CloudflareQueueSendResult {
57
+ readonly metadata: {
58
+ readonly metrics: CloudflareQueueMetrics;
59
+ };
60
+ }
61
+
62
+ export interface CloudflareQueueSendOptions {
63
+ readonly contentType?: CloudflareQueuesContentType | undefined;
64
+ readonly delaySeconds?: number | undefined;
65
+ }
66
+
67
+ export interface CloudflareQueueSendBatchOptions {
68
+ readonly delaySeconds?: number | undefined;
69
+ }
70
+
71
+ export interface CloudflareQueueSendRequest<Body = unknown> {
72
+ readonly body: Body;
73
+ readonly contentType?: CloudflareQueuesContentType | undefined;
74
+ readonly delaySeconds?: number | undefined;
75
+ }
76
+
77
+ /**
78
+ * Structural subset of a Cloudflare Queue producer binding.
79
+ */
80
+ export interface CloudflareQueue<Body = unknown> {
81
+ metrics(): Promise<CloudflareQueueMetrics>;
82
+ send(
83
+ body: Body,
84
+ options?: CloudflareQueueSendOptions
85
+ ): Promise<CloudflareQueueSendResult>;
86
+ sendBatch(
87
+ messages: Iterable<CloudflareQueueSendRequest<Body>>,
88
+ options?: CloudflareQueueSendBatchOptions
89
+ ): Promise<CloudflareQueueSendResult>;
90
+ }
91
+
92
+ export interface MemoryQueueMessage<Body = unknown> {
93
+ readonly body: Body;
94
+ readonly options?: CloudflareQueueSendOptions | undefined;
95
+ }
96
+
97
+ export interface MemoryCloudflareQueue<
98
+ Body = unknown,
99
+ > extends CloudflareQueue<Body> {
100
+ clear(): void;
101
+ messages(): readonly MemoryQueueMessage<Body>[];
102
+ }
103
+
104
+ const emptyMetrics = (): CloudflareQueueMetrics => ({
105
+ backlogBytes: 0,
106
+ backlogCount: 0,
107
+ oldestMessageTimestamp: 0,
108
+ });
109
+
110
+ const sendResult = (): CloudflareQueueSendResult => ({
111
+ metadata: { metrics: emptyMetrics() },
112
+ });
113
+
114
+ /**
115
+ * Create an in-memory Queue producer binding.
116
+ *
117
+ * This is the mock behind `cloudflareQueue`, exported for tests that want to
118
+ * inspect sent messages without a Workers runtime.
119
+ *
120
+ * @example
121
+ * ```ts
122
+ * import { createMemoryQueue } from '@ontrails/cloudflare/queues';
123
+ *
124
+ * const queue = createMemoryQueue<{ id: string }>();
125
+ * await queue.send({ id: 'job-1' });
126
+ * queue.messages()[0]?.body.id; // 'job-1'
127
+ * ```
128
+ */
129
+ export const createMemoryQueue = <
130
+ Body = unknown,
131
+ >(): MemoryCloudflareQueue<Body> => {
132
+ const sent: MemoryQueueMessage<Body>[] = [];
133
+
134
+ return {
135
+ clear() {
136
+ sent.length = 0;
137
+ },
138
+ messages: () => Object.freeze([...sent]),
139
+ metrics: () =>
140
+ Promise.resolve({
141
+ backlogBytes: 0,
142
+ backlogCount: sent.length,
143
+ oldestMessageTimestamp: 0,
144
+ }),
145
+ send: (body, options) => {
146
+ sent.push(
147
+ options === undefined ? { body } : { body, options: { ...options } }
148
+ );
149
+ return Promise.resolve(sendResult());
150
+ },
151
+ sendBatch: (messages, options) => {
152
+ for (const message of messages) {
153
+ const sendOptions: CloudflareQueueSendOptions = {
154
+ ...(message.contentType === undefined
155
+ ? {}
156
+ : { contentType: message.contentType }),
157
+ ...(message.delaySeconds === undefined
158
+ ? {}
159
+ : { delaySeconds: message.delaySeconds }),
160
+ };
161
+ const mergedOptions =
162
+ options?.delaySeconds === undefined
163
+ ? sendOptions
164
+ : { delaySeconds: options.delaySeconds, ...sendOptions };
165
+ sent.push(
166
+ Object.keys(mergedOptions).length === 0
167
+ ? { body: message.body }
168
+ : { body: message.body, options: mergedOptions }
169
+ );
170
+ }
171
+ return Promise.resolve(sendResult());
172
+ },
173
+ };
174
+ };
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // Resource factory
178
+ // ---------------------------------------------------------------------------
179
+
180
+ export interface CloudflareQueueOptions {
181
+ /** The wrangler binding name (a `queues.producers` entry's `binding`). */
182
+ readonly binding: string;
183
+ readonly description?: string | undefined;
184
+ readonly meta?: Readonly<Record<string, unknown>> | undefined;
185
+ }
186
+
187
+ const isQueueBinding = (value: unknown): value is CloudflareQueue => {
188
+ if (typeof value !== 'object' || value === null) {
189
+ return false;
190
+ }
191
+ const candidate = value as Partial<Record<keyof CloudflareQueue, unknown>>;
192
+ return (
193
+ typeof candidate.send === 'function' &&
194
+ typeof candidate.sendBatch === 'function' &&
195
+ typeof candidate.metrics === 'function'
196
+ );
197
+ };
198
+
199
+ /**
200
+ * Author a Trails resource wrapping a Cloudflare Queue producer binding.
201
+ *
202
+ * The real Queue binding arrives through the Workers env bridge. The resource
203
+ * mock records sent messages in memory so producer trails work in `testAll`.
204
+ *
205
+ * @example
206
+ * ```ts
207
+ * import { Result, trail } from '@ontrails/core';
208
+ * import { cloudflareQueue } from '@ontrails/cloudflare/queues';
209
+ * import { z } from 'zod';
210
+ *
211
+ * const jobs = cloudflareQueue<{ id: string }>('jobs', { binding: 'JOBS' });
212
+ *
213
+ * const enqueueJob = trail('job.enqueue', {
214
+ * implementation: async (input, ctx) => {
215
+ * await jobs.from(ctx).send({ id: input.id });
216
+ * return Result.ok({ queued: true });
217
+ * },
218
+ * input: z.object({ id: z.string() }),
219
+ * output: z.object({ queued: z.boolean() }),
220
+ * resources: [jobs],
221
+ * });
222
+ * ```
223
+ */
224
+ export const cloudflareQueue = <Body = unknown>(
225
+ id: string,
226
+ options: CloudflareQueueOptions
227
+ ): Resource<CloudflareQueue<Body>> => {
228
+ const definition = resource<CloudflareQueue<Body>>(id, {
229
+ create: () =>
230
+ Result.err(
231
+ new InternalError(
232
+ `Resource "${id}" wraps Cloudflare Queue binding "${options.binding}", which only exists on a Workers env. Serve the topo with createWorkersHandler from @ontrails/cloudflare/workers, or rely on the in-memory mock in tests.`,
233
+ { context: { binding: options.binding, resourceId: id } }
234
+ )
235
+ ),
236
+ description:
237
+ options.description ??
238
+ `Cloudflare Queue producer bound to "${options.binding}"`,
239
+ meta: {
240
+ ...options.meta,
241
+ 'cloudflare.binding': options.binding,
242
+ 'cloudflare.service': 'queues',
243
+ },
244
+ mock: () => createMemoryQueue<Body>(),
245
+ });
246
+ registerEnvBinding(definition, {
247
+ binding: options.binding,
248
+ fromEnv: (value) =>
249
+ isQueueBinding(value)
250
+ ? Result.ok(value)
251
+ : Result.err(
252
+ new InternalError(
253
+ `Worker env binding "${options.binding}" for resource "${id}" is not a Queue producer. Check the queues.producers entry in your wrangler configuration.`,
254
+ { context: { binding: options.binding, resourceId: id } }
255
+ )
256
+ ),
257
+ });
258
+ return definition;
259
+ };
260
+
261
+ // ---------------------------------------------------------------------------
262
+ // Consumer materializer
263
+ // ---------------------------------------------------------------------------
264
+
265
+ export interface CloudflareQueueRetryOptions {
266
+ readonly delaySeconds?: number | undefined;
267
+ }
268
+
269
+ export interface CloudflareQueueMessage<Body = unknown> {
270
+ readonly attempts: number;
271
+ readonly body: Body;
272
+ readonly id: string;
273
+ readonly timestamp: Date;
274
+ ack(): void;
275
+ retry(options?: CloudflareQueueRetryOptions): void;
276
+ }
277
+
278
+ export interface CloudflareQueueBatch<Body = unknown> {
279
+ readonly messages: readonly CloudflareQueueMessage<Body>[];
280
+ readonly queue: string;
281
+ ackAll(): void;
282
+ retryAll(options?: CloudflareQueueRetryOptions): void;
283
+ }
284
+
285
+ export type CloudflareQueueHandler<Body = unknown> = (
286
+ batch: CloudflareQueueBatch<Body>
287
+ ) => Promise<void>;
288
+
289
+ export interface CreateQueueHandlerOptions extends BaseSurfaceOptions {
290
+ readonly abortSignal?: AbortSignal | undefined;
291
+ readonly createContext?:
292
+ | (() => TrailContextInit | Promise<TrailContextInit>)
293
+ | undefined;
294
+ readonly layers?: readonly Layer[] | undefined;
295
+ readonly resources?: ResourceOverrideMap | undefined;
296
+ }
297
+
298
+ interface QueueConsumerRegistration {
299
+ readonly activation: ActivationEntry;
300
+ readonly source: QueueSource;
301
+ readonly trailId: string;
302
+ }
303
+
304
+ interface SchemaIssue {
305
+ readonly message: string;
306
+ readonly path?: readonly unknown[] | undefined;
307
+ }
308
+
309
+ type SafeParseResult =
310
+ | { readonly data: unknown; readonly success: true }
311
+ | {
312
+ readonly error: { readonly issues: readonly SchemaIssue[] };
313
+ readonly success: false;
314
+ };
315
+
316
+ interface SafeParseSchema {
317
+ safeParse(value: unknown): SafeParseResult;
318
+ }
319
+
320
+ const isObjectRecord = (value: unknown): value is Record<string, unknown> =>
321
+ typeof value === 'object' && value !== null && !Array.isArray(value);
322
+
323
+ const isSafeParseSchema = (value: unknown): value is SafeParseSchema =>
324
+ isObjectRecord(value) && typeof value['safeParse'] === 'function';
325
+
326
+ const queueSourceFrom = (
327
+ activation: ActivationEntry
328
+ ): QueueSource | undefined =>
329
+ activation.source.kind === 'queue' &&
330
+ typeof activation.source.queue === 'string' &&
331
+ activation.source.queue.trim().length > 0
332
+ ? {
333
+ ...(activation.source as QueueSource),
334
+ queue: activation.source.queue.trim(),
335
+ }
336
+ : undefined;
337
+
338
+ const matchesAnyPattern = (
339
+ trailId: string,
340
+ patterns: readonly string[] | undefined
341
+ ): boolean =>
342
+ patterns !== undefined &&
343
+ patterns.some((pattern) => matchesTrailPattern(trailId, pattern));
344
+
345
+ const isExplicitInternalInclude = (
346
+ trailId: string,
347
+ include: readonly string[] | undefined
348
+ ): boolean => include !== undefined && include.includes(trailId);
349
+
350
+ const isInternalTrail = (trail: AnyTrail): boolean =>
351
+ trail.visibility === 'internal' || trail.meta?.['internal'] === true;
352
+
353
+ const shouldIncludeQueueTrail = (
354
+ trail: AnyTrail,
355
+ options: CreateQueueHandlerOptions
356
+ ): boolean => {
357
+ if (
358
+ isInternalTrail(trail) &&
359
+ !isExplicitInternalInclude(trail.id, options.include)
360
+ ) {
361
+ return false;
362
+ }
363
+ if (matchesAnyPattern(trail.id, options.exclude)) {
364
+ return false;
365
+ }
366
+ if (
367
+ options.include !== undefined &&
368
+ options.include.length > 0 &&
369
+ !matchesAnyPattern(trail.id, options.include)
370
+ ) {
371
+ return false;
372
+ }
373
+ return (
374
+ options.intent === undefined ||
375
+ options.intent.length === 0 ||
376
+ options.intent.includes(trail.intent)
377
+ );
378
+ };
379
+
380
+ const collectQueueConsumers = (
381
+ graph: Topo,
382
+ options: CreateQueueHandlerOptions
383
+ ): readonly QueueConsumerRegistration[] => {
384
+ const registrations: QueueConsumerRegistration[] = [];
385
+ for (const graphTrail of graph.list()) {
386
+ if (!shouldIncludeQueueTrail(graphTrail, options)) {
387
+ continue;
388
+ }
389
+ for (const activation of graphTrail.activationSources) {
390
+ const source = queueSourceFrom(activation);
391
+ if (source !== undefined) {
392
+ registrations.push({
393
+ activation,
394
+ source,
395
+ trailId: graphTrail.id,
396
+ });
397
+ }
398
+ }
399
+ }
400
+ return Object.freeze(registrations);
401
+ };
402
+
403
+ const queueInputContractSignature = (source: QueueSource): string =>
404
+ JSON.stringify(
405
+ deriveActivationSourceFacts(source)['parseOutputSchema'] ?? null
406
+ );
407
+
408
+ const assertQueueSourceCompatibility = (
409
+ queueName: string,
410
+ registrations: readonly QueueConsumerRegistration[]
411
+ ): void => {
412
+ const bySource = new Map<string, QueueConsumerRegistration>();
413
+ for (const registration of registrations) {
414
+ bySource.set(registration.source.id, registration);
415
+ }
416
+ const sources = [...bySource.values()];
417
+ const [expected] = sources;
418
+ if (expected === undefined) {
419
+ return;
420
+ }
421
+ const expectedSignature = queueInputContractSignature(expected.source);
422
+ const incompatible = sources.find(
423
+ (registration) =>
424
+ queueInputContractSignature(registration.source) !== expectedSignature
425
+ );
426
+ if (incompatible !== undefined) {
427
+ throw new ValidationError(
428
+ `Cloudflare queue "${queueName}" is bound to incompatible activation source contracts "${expected.source.id}" and "${incompatible.source.id}". Use one shared queue source contract, or bind distinct contracts to distinct physical queues.`
429
+ );
430
+ }
431
+ };
432
+
433
+ const schemaForSource = (source: QueueSource): SafeParseSchema | undefined => {
434
+ if (isSafeParseSchema(source.parse)) {
435
+ return source.parse;
436
+ }
437
+ if (
438
+ isObjectRecord(source.parse) &&
439
+ isSafeParseSchema(source.parse['output'])
440
+ ) {
441
+ return source.parse['output'];
442
+ }
443
+ return undefined;
444
+ };
445
+
446
+ const issuePathText = (path: readonly unknown[] | undefined): string =>
447
+ path === undefined || path.length === 0 ? '<root>' : path.join('.');
448
+
449
+ const formatSchemaIssues = (issues: readonly SchemaIssue[]): string =>
450
+ issues
451
+ .map((issue) => `${issuePathText(issue.path)}: ${issue.message}`)
452
+ .join('; ');
453
+
454
+ const parseQueueMessageBody = (
455
+ registration: QueueConsumerRegistration,
456
+ body: unknown
457
+ ): Result<unknown, Error> => {
458
+ const schema = schemaForSource(registration.source);
459
+ if (schema === undefined) {
460
+ return Result.err(
461
+ new InternalError(
462
+ `Queue source "${registration.source.id}" does not expose a parse schema.`
463
+ )
464
+ );
465
+ }
466
+ const parsed = schema.safeParse(body);
467
+ if (!parsed.success) {
468
+ return Result.err(
469
+ new ValidationError(
470
+ `Queue source "${registration.source.id}" rejected message "${registration.trailId}": ${formatSchemaIssues(parsed.error.issues)}`
471
+ )
472
+ );
473
+ }
474
+ return Result.ok(parsed.data);
475
+ };
476
+
477
+ const errorFromUnknown = (error: unknown): Error =>
478
+ error instanceof Error ? error : new Error(String(error));
479
+
480
+ const createFireId = (): string =>
481
+ globalThis.crypto?.randomUUID?.() ??
482
+ `${Date.now()}-${Math.random().toString(16).slice(2)}`;
483
+
484
+ const activationFor = (
485
+ registration: QueueConsumerRegistration,
486
+ message: CloudflareQueueMessage
487
+ ) => {
488
+ const fireId = createFireId();
489
+ return {
490
+ fireId,
491
+ rootFireId: fireId,
492
+ source: {
493
+ id: registration.source.id,
494
+ kind: 'queue' as const,
495
+ ...(registration.source.meta === undefined
496
+ ? {}
497
+ : { meta: registration.source.meta }),
498
+ queue: registration.source.queue,
499
+ },
500
+ trigger: {
501
+ messageAttempts: message.attempts,
502
+ messageId: message.id,
503
+ messageTimestamp: message.timestamp.toISOString(),
504
+ },
505
+ };
506
+ };
507
+
508
+ const activationAttrs = (
509
+ registration: QueueConsumerRegistration,
510
+ message: CloudflareQueueMessage,
511
+ activation: ReturnType<typeof activationFor>
512
+ ): Readonly<Record<string, unknown>> => ({
513
+ ...buildActivationProvenanceTraceAttrs(activation),
514
+ 'trails.activation.queue.message.attempts': message.attempts,
515
+ 'trails.activation.queue.message.id': message.id,
516
+ 'trails.activation.queue.message.timestamp': message.timestamp.toISOString(),
517
+ 'trails.activation.target_trail.id': registration.trailId,
518
+ });
519
+
520
+ const recordQueueActivationTrace = async (
521
+ graph: Topo,
522
+ registration: QueueConsumerRegistration,
523
+ message: CloudflareQueueMessage,
524
+ activation: ReturnType<typeof activationFor>,
525
+ status: 'cancelled' | 'err' | 'ok',
526
+ error?: Error | undefined
527
+ ): Promise<TraceContext | undefined> => {
528
+ const record = await writeActivationTraceRecord(
529
+ 'activation.queue',
530
+ activationAttrs(registration, message, activation),
531
+ status,
532
+ isTrailsError(error) ? error.category : undefined,
533
+ undefined,
534
+ graph.observe?.trace ?? getTraceSink()
535
+ );
536
+ return record === undefined ? undefined : traceContextFromRecord(record);
537
+ };
538
+
539
+ const contextForActivation = (
540
+ activation: ReturnType<typeof activationFor>,
541
+ traceContext: TraceContext | undefined
542
+ ): Partial<TrailContextInit> =>
543
+ withActivationProvenance(
544
+ {
545
+ extensions:
546
+ traceContext === undefined ? {} : { [TRACE_CONTEXT_KEY]: traceContext },
547
+ },
548
+ activation
549
+ );
550
+
551
+ const shouldRunRegistration = async (
552
+ registration: QueueConsumerRegistration,
553
+ input: unknown
554
+ ): Promise<Result<boolean, Error>> => {
555
+ const predicate = getActivationWherePredicate(registration.activation.where);
556
+ if (predicate === undefined) {
557
+ return Result.ok(true);
558
+ }
559
+ try {
560
+ return Result.ok(await predicate(input));
561
+ } catch (error: unknown) {
562
+ const cause = errorFromUnknown(error);
563
+ return Result.err(
564
+ new InternalError(
565
+ `Queue activation predicate failed for source "${registration.source.id}" and trail "${registration.trailId}": ${cause.message}`,
566
+ {
567
+ cause,
568
+ context: {
569
+ sourceId: registration.source.id,
570
+ trailId: registration.trailId,
571
+ },
572
+ }
573
+ )
574
+ );
575
+ }
576
+ };
577
+
578
+ const retryOptionsFor = (
579
+ error: Error
580
+ ): CloudflareQueueRetryOptions | undefined =>
581
+ error instanceof RateLimitError && error.retryAfter !== undefined
582
+ ? { delaySeconds: Math.max(0, Math.ceil(error.retryAfter)) }
583
+ : undefined;
584
+
585
+ interface MessageDecision {
586
+ readonly action: 'ack' | 'retry';
587
+ readonly retryOptions?: CloudflareQueueRetryOptions | undefined;
588
+ }
589
+
590
+ const ackDecision = Object.freeze({ action: 'ack' as const });
591
+
592
+ const retryDecision = (error: Error): MessageDecision => ({
593
+ action: 'retry',
594
+ ...(retryOptionsFor(error) === undefined
595
+ ? {}
596
+ : { retryOptions: retryOptionsFor(error) }),
597
+ });
598
+
599
+ const failureDecision = (error: Error): MessageDecision =>
600
+ isTrailsError(error) && !error.retryable ? ackDecision : retryDecision(error);
601
+
602
+ const runQueueConsumer = async (
603
+ graph: Topo,
604
+ registration: QueueConsumerRegistration,
605
+ message: CloudflareQueueMessage,
606
+ options: CreateQueueHandlerOptions
607
+ ): Promise<MessageDecision> => {
608
+ const parsed = parseQueueMessageBody(registration, message.body);
609
+ const activation = activationFor(registration, message);
610
+ if (parsed.isErr()) {
611
+ await recordQueueActivationTrace(
612
+ graph,
613
+ registration,
614
+ message,
615
+ activation,
616
+ 'err',
617
+ parsed.error
618
+ );
619
+ return failureDecision(parsed.error);
620
+ }
621
+ const shouldRun = await shouldRunRegistration(registration, parsed.value);
622
+ if (shouldRun.isErr()) {
623
+ await recordQueueActivationTrace(
624
+ graph,
625
+ registration,
626
+ message,
627
+ activation,
628
+ 'err',
629
+ shouldRun.error
630
+ );
631
+ return failureDecision(shouldRun.error);
632
+ }
633
+ if (!shouldRun.value) {
634
+ return ackDecision;
635
+ }
636
+
637
+ const traceContext = await recordQueueActivationTrace(
638
+ graph,
639
+ registration,
640
+ message,
641
+ activation,
642
+ 'ok'
643
+ );
644
+ const result = await run(graph, registration.trailId, parsed.value, {
645
+ ...(options.abortSignal === undefined
646
+ ? {}
647
+ : { abortSignal: options.abortSignal }),
648
+ configValues: options.configValues,
649
+ createContext: options.createContext,
650
+ ctx: contextForActivation(activation, traceContext),
651
+ resources: options.resources,
652
+ surfaceLayers: options.layers,
653
+ topoLayers: graph.layers,
654
+ });
655
+ if (result.isOk()) {
656
+ return ackDecision;
657
+ }
658
+ if (result.error instanceof CancelledError) {
659
+ return ackDecision;
660
+ }
661
+ return failureDecision(result.error);
662
+ };
663
+
664
+ const processMessage = async (
665
+ graph: Topo,
666
+ registrations: readonly QueueConsumerRegistration[],
667
+ message: CloudflareQueueMessage,
668
+ options: CreateQueueHandlerOptions
669
+ ): Promise<MessageDecision> => {
670
+ for (const registration of registrations) {
671
+ try {
672
+ const decision = await runQueueConsumer(
673
+ graph,
674
+ registration,
675
+ message,
676
+ options
677
+ );
678
+ if (decision.action === 'retry') {
679
+ return decision;
680
+ }
681
+ } catch (error: unknown) {
682
+ return failureDecision(errorFromUnknown(error));
683
+ }
684
+ }
685
+ return ackDecision;
686
+ };
687
+
688
+ /**
689
+ * Build a Cloudflare Queues consumer handler for a topo.
690
+ *
691
+ * The handler dispatches each message to every matching first-class core
692
+ * `queue()` activation source for `batch.queue`. A message is acknowledged
693
+ * after all matching consumer trails succeed, skip, cancel, or fail with a
694
+ * non-retryable Trails error. Only failures explicitly marked retryable enter
695
+ * Cloudflare's configured retry/DLQ policy. `RateLimitError.retryAfter`
696
+ * becomes the queue retry's `delaySeconds`; other retryable errors use the
697
+ * queue's configured default delay.
698
+ *
699
+ * @example
700
+ * ```ts
701
+ * import { createQueueHandler } from '@ontrails/cloudflare/queues';
702
+ *
703
+ * export const queue = createQueueHandler(graph);
704
+ * ```
705
+ */
706
+ export const createQueueHandler = (
707
+ graph: Topo,
708
+ options: CreateQueueHandlerOptions = {}
709
+ ): CloudflareQueueHandler => {
710
+ const validated = validateSurfaceTopo(graph, options);
711
+ if (validated.isErr()) {
712
+ throw validated.error;
713
+ }
714
+
715
+ const byQueue = new Map<string, QueueConsumerRegistration[]>();
716
+ for (const registration of collectQueueConsumers(graph, options)) {
717
+ const current = byQueue.get(registration.source.queue) ?? [];
718
+ current.push(registration);
719
+ byQueue.set(registration.source.queue, current);
720
+ }
721
+ for (const [queueName, registrations] of byQueue) {
722
+ assertQueueSourceCompatibility(queueName, registrations);
723
+ }
724
+
725
+ return async (batch) => {
726
+ const registrations = byQueue.get(batch.queue) ?? [];
727
+ if (registrations.length === 0) {
728
+ batch.ackAll();
729
+ return;
730
+ }
731
+
732
+ for (const message of batch.messages) {
733
+ const decision = await processMessage(
734
+ graph,
735
+ registrations,
736
+ message,
737
+ options
738
+ );
739
+ if (decision.action === 'ack') {
740
+ message.ack();
741
+ } else {
742
+ message.retry(decision.retryOptions);
743
+ }
744
+ }
745
+ };
746
+ };