@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.
- package/CHANGELOG.md +160 -0
- package/README.md +269 -0
- package/package.json +57 -0
- package/src/d1/index.ts +1087 -0
- package/src/env.ts +309 -0
- package/src/facts.ts +112 -0
- package/src/index.ts +96 -0
- package/src/kv/index.ts +275 -0
- package/src/queues/index.ts +746 -0
- package/src/r2/index.ts +736 -0
- package/src/workers/index.ts +280 -0
|
@@ -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
|
+
};
|