@zintrust/queue-cloudflare 2.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +158 -0
  2. package/dist/CloudflareJobStore.d.ts +50 -0
  3. package/dist/CloudflareJobStore.js +435 -0
  4. package/dist/CloudflareQueueConsumer.d.ts +39 -0
  5. package/dist/CloudflareQueueConsumer.js +184 -0
  6. package/dist/CloudflareQueueCoordinator.d.ts +21 -0
  7. package/dist/CloudflareQueueCoordinator.js +57 -0
  8. package/dist/CloudflareQueueMigrator.d.ts +14 -0
  9. package/dist/CloudflareQueueMigrator.js +22 -0
  10. package/dist/CloudflareQueueScheduler.d.ts +43 -0
  11. package/dist/CloudflareQueueScheduler.js +99 -0
  12. package/dist/cli/MigrateCloudflareQueueCommand.d.ts +10 -0
  13. package/dist/cli/MigrateCloudflareQueueCommand.js +51 -0
  14. package/dist/cli-register.d.ts +9 -0
  15. package/dist/cli-register.js +21 -0
  16. package/dist/index.d.ts +50 -0
  17. package/dist/index.js +499 -0
  18. package/dist/migrationSql.d.ts +2 -0
  19. package/dist/migrationSql.js +104 -0
  20. package/dist/migrations/20260608000001_create_cloudflare_queue_jobs_table.d.ts +6 -0
  21. package/dist/migrations/20260608000001_create_cloudflare_queue_jobs_table.js +37 -0
  22. package/dist/migrations/20260608000002_create_cloudflare_queue_job_events_table.d.ts +6 -0
  23. package/dist/migrations/20260608000002_create_cloudflare_queue_job_events_table.js +21 -0
  24. package/dist/migrations/20260608000003_create_cloudflare_queue_job_logs_table.d.ts +6 -0
  25. package/dist/migrations/20260608000003_create_cloudflare_queue_job_logs_table.js +22 -0
  26. package/dist/migrations/20260608000004_create_cloudflare_queue_repeatables_table.d.ts +6 -0
  27. package/dist/migrations/20260608000004_create_cloudflare_queue_repeatables_table.js +28 -0
  28. package/dist/migrations/20260608000005_create_cloudflare_queue_flow_dependencies_table.d.ts +6 -0
  29. package/dist/migrations/20260608000005_create_cloudflare_queue_flow_dependencies_table.js +22 -0
  30. package/dist/migrations/index.d.ts +2 -0
  31. package/dist/migrations/index.js +12 -0
  32. package/dist/register.d.ts +5 -0
  33. package/dist/register.js +20 -0
  34. package/dist/types.d.ts +240 -0
  35. package/dist/types.js +1 -0
  36. package/package.json +52 -0
package/README.md ADDED
@@ -0,0 +1,158 @@
1
+ # @zintrust/queue-cloudflare
2
+
3
+ Cloudflare Queues driver for ZinTrust.
4
+
5
+ ```ts
6
+ import '@zintrust/queue-cloudflare/register';
7
+ import { Queue } from '@zintrust/core/queue';
8
+
9
+ await Queue.enqueue('EMAIL_QUEUE', { to: 'user@example.com' }, 'cloudflare');
10
+ ```
11
+
12
+ BullMQ-like stateful jobs are available through the package driver:
13
+
14
+ ```ts
15
+ import { CloudflareQueues } from '@zintrust/queue-cloudflare';
16
+
17
+ const queue = CloudflareQueues.create({
18
+ driver: 'cloudflare',
19
+ bindingName: 'EMAIL_QUEUE',
20
+ state: {
21
+ d1BindingName: 'QUEUE_DB',
22
+ kvBindingName: 'QUEUE_KV',
23
+ coordinatorBindingName: 'QUEUE_COORDINATOR',
24
+ },
25
+ });
26
+
27
+ const job = await queue.add('email-queue', 'send-email', { to: 'user@example.com' }, {
28
+ attempts: 5,
29
+ backoff: { type: 'exponential', delay: 1000 },
30
+ priority: 2,
31
+ delay: 30000,
32
+ });
33
+
34
+ await queue.getJob('email-queue', job.id);
35
+ await queue.getJobCounts('email-queue', 'waiting', 'active', 'completed', 'failed');
36
+ ```
37
+
38
+ Inside a Cloudflare Worker, bind the queue in `wrangler.jsonc`:
39
+
40
+ ```jsonc
41
+ {
42
+ "queues": {
43
+ "producers": [
44
+ { "queue": "email-queue", "binding": "EMAIL_QUEUE" }
45
+ ],
46
+ "consumers": [
47
+ {
48
+ "queue": "email-queue",
49
+ "max_batch_size": 10,
50
+ "max_retries": 3,
51
+ "dead_letter_queue": "email-dlq"
52
+ }
53
+ ]
54
+ }
55
+ }
56
+ ```
57
+
58
+ Add the state bindings when using BullMQ-like metadata:
59
+
60
+ ```jsonc
61
+ {
62
+ "d1_databases": [
63
+ {
64
+ "binding": "QUEUE_DB",
65
+ "database_name": "zintrust-queue",
66
+ "database_id": "<database-id>"
67
+ }
68
+ ],
69
+ "kv_namespaces": [
70
+ {
71
+ "binding": "QUEUE_KV",
72
+ "id": "<namespace-id>"
73
+ }
74
+ ],
75
+ "durable_objects": {
76
+ "bindings": [
77
+ {
78
+ "name": "QUEUE_COORDINATOR",
79
+ "class_name": "CloudflareQueueCoordinator"
80
+ }
81
+ ]
82
+ },
83
+ "migrations": [
84
+ {
85
+ "tag": "queue-cloudflare-v1",
86
+ "new_sqlite_classes": ["CloudflareQueueCoordinator"]
87
+ }
88
+ ],
89
+ "triggers": {
90
+ "crons": ["* * * * *"]
91
+ }
92
+ }
93
+ ```
94
+
95
+ Run the D1 state migration:
96
+
97
+ ```bash
98
+ zin migrate:queue-cloudflare --database zintrust-queue --local
99
+ zin migrate:queue-cloudflare --database zintrust-queue --remote
100
+ ```
101
+
102
+ The same migration is available programmatically:
103
+
104
+ ```ts
105
+ import { CloudflareQueueMigrator } from '@zintrust/queue-cloudflare';
106
+
107
+ await CloudflareQueueMigrator.up({ d1: env.QUEUE_DB });
108
+ ```
109
+
110
+ Use the Worker queue consumer helper:
111
+
112
+ ```ts
113
+ const consumer = queue.createConsumer(async (data, context) => {
114
+ await context.updateProgress({ step: 'sending' });
115
+ return await sendEmail(data);
116
+ }, 'email-queue');
117
+
118
+ export default {
119
+ async queue(batch) {
120
+ await consumer.processBatch(batch);
121
+ },
122
+ async scheduled() {
123
+ await queue.runScheduler('email-queue');
124
+ },
125
+ };
126
+ ```
127
+
128
+ For HTTP pull consumers or non-Worker producers, configure Cloudflare API access:
129
+
130
+ ```ts
131
+ import { CloudflareQueues } from '@zintrust/queue-cloudflare';
132
+
133
+ const queue = CloudflareQueues.create({
134
+ driver: 'cloudflare',
135
+ accountId: process.env.CLOUDFLARE_ACCOUNT_ID,
136
+ queueId: process.env.CLOUDFLARE_QUEUE_ID,
137
+ apiToken: process.env.CLOUDFLARE_API_TOKEN,
138
+ batchSize: 1,
139
+ visibilityTimeoutMs: 30000,
140
+ });
141
+
142
+ const jobId = await queue.enqueue('email-queue', { to: 'user@example.com' });
143
+ const message = await queue.dequeue('email-queue');
144
+ if (message) await queue.ack('email-queue', message.id);
145
+ ```
146
+
147
+ Environment fallbacks:
148
+
149
+ - `CLOUDFLARE_QUEUE_BINDING` or `QUEUE_BINDING`
150
+ - `CLOUDFLARE_ACCOUNT_ID` or `CF_ACCOUNT_ID`
151
+ - `CLOUDFLARE_QUEUE_ID` or `CF_QUEUE_ID`
152
+ - `CLOUDFLARE_API_TOKEN` or `CF_API_TOKEN`
153
+ - `CLOUDFLARE_API_BASE_URL`
154
+
155
+ The driver registers as both `cloudflare` and `cloudflare-queues`.
156
+
157
+ For the BullMQ-compatible state-layer roadmap, see
158
+ [BULLMQ_COMPATIBILITY_PLAN.md](./BULLMQ_COMPATIBILITY_PLAN.md).
@@ -0,0 +1,50 @@
1
+ import type { CloudflareJobOptions, CloudflareFlowInput, CloudflareFlowResult, CloudflareQueueJob, CloudflareQueueMetrics, CloudflareQueueState, CloudflareQueueStateConfig, CloudflareRepeatableRow } from './types.js';
2
+ type CreateJobInput<T = unknown> = {
3
+ queueName: string;
4
+ name: string;
5
+ data: T;
6
+ options?: CloudflareJobOptions;
7
+ };
8
+ type UpdateStateInput = {
9
+ queueName: string;
10
+ jobId: string;
11
+ state: CloudflareQueueState;
12
+ error?: unknown;
13
+ result?: unknown;
14
+ incrementAttempts?: boolean;
15
+ availableAt?: string;
16
+ };
17
+ type RepeatableInput<T = unknown> = {
18
+ id?: string;
19
+ queueName: string;
20
+ name: string;
21
+ data: T;
22
+ options: NonNullable<CloudflareJobOptions['repeat']>;
23
+ };
24
+ declare function createCloudflareJobStore(config?: CloudflareQueueStateConfig): {
25
+ createJob<T = unknown>(input: CreateJobInput<T>): Promise<CloudflareQueueJob<T>>;
26
+ getJob<T = unknown>(queueName: string, jobId: string): Promise<CloudflareQueueJob<T> | null>;
27
+ getJobs<T = unknown>(queueName: string, states?: CloudflareQueueState[], limit?: number): Promise<Array<CloudflareQueueJob<T>>>;
28
+ getJobCounts(queueName: string, ...states: CloudflareQueueState[]): Promise<Record<string, number>>;
29
+ getMetrics(queueName: string): Promise<CloudflareQueueMetrics>;
30
+ updateState(input: UpdateStateInput): Promise<void>;
31
+ updateProgress(queueName: string, jobId: string, progress: unknown): Promise<void>;
32
+ recordLog(queueName: string, jobId: string, level: string, message: string, data?: unknown): Promise<void>;
33
+ getDueJobs<T = unknown>(queueName: string, limit?: number): Promise<Array<CloudflareQueueJob<T>>>;
34
+ clean(queueName: string, states: CloudflareQueueState[], olderThanMs: number): Promise<void>;
35
+ markDispatched(queueName: string, jobId: string): Promise<void>;
36
+ getStalledJobs<T = unknown>(queueName: string, olderThanMs: number, limit?: number): Promise<Array<CloudflareQueueJob<T>>>;
37
+ applyRetention(queueName: string, jobId: string, state: "completed" | "failed"): Promise<void>;
38
+ upsertRepeatable<T = unknown>(input: RepeatableInput<T>): Promise<CloudflareRepeatableRow>;
39
+ getDueRepeatables(limit?: number): Promise<CloudflareRepeatableRow[]>;
40
+ updateRepeatableAfterRun(row: CloudflareRepeatableRow): Promise<void>;
41
+ removeRepeatable(id: string): Promise<void>;
42
+ createFlow<TParent = unknown, TChild = unknown>(input: CloudflareFlowInput<TParent, TChild>): Promise<CloudflareFlowResult<TParent, TChild>>;
43
+ markFlowChildCompleted(queueName: string, childJobId: string): Promise<Array<CloudflareQueueJob>>;
44
+ getFlowChildren<T = unknown>(queueName: string, parentJobId: string): Promise<Array<CloudflareQueueJob<T>>>;
45
+ };
46
+ export type CloudflareJobStore = ReturnType<typeof createCloudflareJobStore>;
47
+ export declare const CloudflareJobStore: Readonly<{
48
+ create: typeof createCloudflareJobStore;
49
+ }>;
50
+ export {};
@@ -0,0 +1,435 @@
1
+ import { generateUuid } from '@zintrust/core/utils';
2
+ const nowIso = () => new Date().toISOString();
3
+ const serialize = (value) => JSON.stringify(value ?? null);
4
+ const deserialize = (value) => {
5
+ if (value === null)
6
+ return null;
7
+ try {
8
+ return JSON.parse(value);
9
+ }
10
+ catch {
11
+ return value;
12
+ }
13
+ };
14
+ const rowNumber = (value) => {
15
+ if (typeof value === 'number')
16
+ return value;
17
+ if (typeof value === 'string') {
18
+ const parsed = Number(value);
19
+ return Number.isFinite(parsed) ? parsed : 0;
20
+ }
21
+ return 0;
22
+ };
23
+ const normalizeLimit = (limit) => {
24
+ if (typeof limit !== 'number' || !Number.isFinite(limit) || limit <= 0)
25
+ return 100;
26
+ return Math.min(Math.floor(limit), 1000);
27
+ };
28
+ const resolveTarget = (config) => {
29
+ if (config?.d1 !== undefined)
30
+ return { kind: 'd1', db: config.d1 };
31
+ if (config?.db !== undefined)
32
+ return { kind: 'zintrust', db: config.db };
33
+ const env = globalThis.env;
34
+ const bindingName = config?.d1BindingName ?? 'QUEUE_DB';
35
+ const d1 = env?.[bindingName];
36
+ if (d1 !== undefined && typeof d1.prepare === 'function')
37
+ return { kind: 'd1', db: d1 };
38
+ throw new Error(`Cloudflare queue state store requires a D1 binding (${bindingName}) or ZinTrust database`);
39
+ };
40
+ const resolveKv = (config) => {
41
+ if (config?.kv !== undefined)
42
+ return config.kv;
43
+ const env = globalThis.env;
44
+ const bindingName = config?.kvBindingName ?? 'QUEUE_KV';
45
+ const kv = env?.[bindingName];
46
+ return kv !== undefined && typeof kv.get === 'function' ? kv : null;
47
+ };
48
+ const queryRows = async (target, sql, params = []) => {
49
+ if (target.kind === 'd1') {
50
+ const result = await target.db.prepare(sql).bind(...params).all();
51
+ return result.results ?? [];
52
+ }
53
+ return (await target.db.query(sql, params));
54
+ };
55
+ const queryOne = async (target, sql, params = []) => {
56
+ if (target.kind === 'd1') {
57
+ return await target.db.prepare(sql).bind(...params).first();
58
+ }
59
+ return (await target.db.queryOne(sql, params)) ?? null;
60
+ };
61
+ const execute = async (target, sql, params = []) => {
62
+ if (target.kind === 'd1') {
63
+ await target.db.prepare(sql).bind(...params).run();
64
+ return;
65
+ }
66
+ await target.db.execute(sql, params);
67
+ };
68
+ const toJob = (row, store) => {
69
+ return {
70
+ id: row.id,
71
+ queueName: row.queue_name,
72
+ name: row.name,
73
+ data: deserialize(row.payload),
74
+ state: row.state,
75
+ attemptsMade: row.attempts,
76
+ maxAttempts: row.max_attempts,
77
+ priority: row.priority,
78
+ progress: deserialize(row.progress),
79
+ result: deserialize(row.result),
80
+ error: deserialize(row.error),
81
+ opts: (deserialize(row.opts) ?? {}),
82
+ availableAt: row.available_at,
83
+ createdAt: row.created_at,
84
+ updatedAt: row.updated_at,
85
+ updateProgress: async (progress) => {
86
+ await store.updateProgress(row.queue_name, row.id, progress);
87
+ },
88
+ log: async (message, data) => {
89
+ await store.recordLog(row.queue_name, row.id, 'info', message, data);
90
+ },
91
+ remove: async () => {
92
+ await store.updateState({
93
+ queueName: row.queue_name,
94
+ jobId: row.id,
95
+ state: 'canceled',
96
+ });
97
+ },
98
+ retry: async () => {
99
+ await store.updateState({
100
+ queueName: row.queue_name,
101
+ jobId: row.id,
102
+ state: 'waiting',
103
+ });
104
+ },
105
+ };
106
+ };
107
+ function createCloudflareJobStore(config) {
108
+ const target = resolveTarget(config);
109
+ const kv = resolveKv(config);
110
+ const recordEvent = async (queueName, jobId, event, data) => {
111
+ await execute(target, 'INSERT INTO cf_queue_job_events (id, job_id, queue_name, event, data, created_at) VALUES (?, ?, ?, ?, ?, ?)', [generateUuid(), jobId, queueName, event, serialize(data), nowIso()]);
112
+ };
113
+ const store = {
114
+ async createJob(input) {
115
+ const options = input.options ?? {};
116
+ const jobId = options.jobId ?? generateUuid();
117
+ const dedupeKey = options.deduplication?.id ?? null;
118
+ const idempotencyKey = options.uniqueId ?? null;
119
+ const dedupeCacheKey = dedupeKey === null ? null : `cfq:dedupe:${input.queueName}:${dedupeKey}`;
120
+ const idempotencyCacheKey = idempotencyKey === null ? null : `cfq:idempotency:${input.queueName}:${idempotencyKey}`;
121
+ if (options.deduplication?.collisionBehavior !== 'enqueue') {
122
+ const cachedId = (dedupeCacheKey !== null ? await kv?.get(dedupeCacheKey) : null) ??
123
+ (idempotencyCacheKey !== null ? await kv?.get(idempotencyCacheKey) : null) ??
124
+ null;
125
+ if (cachedId !== null && cachedId.trim() !== '') {
126
+ const cached = await store.getJob(input.queueName, cachedId);
127
+ if (cached !== null)
128
+ return cached;
129
+ }
130
+ const existing = await queryOne(target, `SELECT * FROM cf_queue_jobs
131
+ WHERE queue_name = ?
132
+ AND (
133
+ id = ?
134
+ OR (? IS NOT NULL AND dedupe_key = ?)
135
+ OR (? IS NOT NULL AND idempotency_key = ?)
136
+ )
137
+ LIMIT 1`, [input.queueName, jobId, dedupeKey, dedupeKey, idempotencyKey, idempotencyKey]);
138
+ if (existing !== null)
139
+ return toJob(existing, store);
140
+ }
141
+ const delayMs = Math.max(0, options.delay ?? 0);
142
+ const availableAt = new Date(Date.now() + delayMs).toISOString();
143
+ const state = delayMs > 0 ? 'delayed' : options.priority !== undefined ? 'prioritized' : 'waiting';
144
+ const timestamp = nowIso();
145
+ const maxAttempts = Math.max(1, Math.floor(options.attempts ?? 1));
146
+ await execute(target, `INSERT INTO cf_queue_jobs (
147
+ id, queue_name, name, payload, opts, state, priority, attempts, max_attempts,
148
+ progress, result, error, dedupe_key, idempotency_key, available_at,
149
+ started_at, completed_at, failed_at, stalled_at, created_at, updated_at
150
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
151
+ jobId,
152
+ input.queueName,
153
+ input.name,
154
+ serialize(input.data),
155
+ serialize(options),
156
+ state,
157
+ Math.max(0, Math.floor(options.priority ?? 0)),
158
+ 0,
159
+ maxAttempts,
160
+ null,
161
+ null,
162
+ null,
163
+ options.deduplication?.id ?? null,
164
+ options.uniqueId ?? null,
165
+ availableAt,
166
+ null,
167
+ null,
168
+ null,
169
+ null,
170
+ timestamp,
171
+ timestamp,
172
+ ]);
173
+ if (dedupeCacheKey !== null) {
174
+ await kv?.put(dedupeCacheKey, jobId, {
175
+ expirationTtl: options.deduplication?.ttl,
176
+ });
177
+ }
178
+ if (idempotencyCacheKey !== null) {
179
+ await kv?.put(idempotencyCacheKey, jobId, {
180
+ expirationTtl: options.deduplication?.ttl,
181
+ });
182
+ }
183
+ await recordEvent(input.queueName, jobId, 'created', { state, options });
184
+ const job = await store.getJob(input.queueName, jobId);
185
+ if (job === null)
186
+ throw new Error(`Created Cloudflare queue job not found: ${jobId}`);
187
+ return job;
188
+ },
189
+ async getJob(queueName, jobId) {
190
+ const row = await queryOne(target, 'SELECT * FROM cf_queue_jobs WHERE queue_name = ? AND id = ? LIMIT 1', [queueName, jobId]);
191
+ return row === null ? null : toJob(row, store);
192
+ },
193
+ async getJobs(queueName, states, limit) {
194
+ const requestedStates = states?.filter((state) => state.trim() !== '') ?? [];
195
+ const normalizedLimit = normalizeLimit(limit);
196
+ const params = [queueName];
197
+ let sql = 'SELECT * FROM cf_queue_jobs WHERE queue_name = ?';
198
+ if (requestedStates.length > 0) {
199
+ sql += ` AND state IN (${requestedStates.map(() => '?').join(', ')})`;
200
+ params.push(...requestedStates);
201
+ }
202
+ sql += ' ORDER BY priority ASC, available_at ASC, created_at ASC LIMIT ?';
203
+ params.push(normalizedLimit);
204
+ const rows = await queryRows(target, sql, params);
205
+ return rows.map((row) => toJob(row, store));
206
+ },
207
+ async getJobCounts(queueName, ...states) {
208
+ const requestedStates = states.length > 0 ? states : undefined;
209
+ const jobs = await queryRows(target, `SELECT state, COUNT(*) AS count FROM cf_queue_jobs WHERE queue_name = ?${requestedStates !== undefined
210
+ ? ` AND state IN (${requestedStates.map(() => '?').join(', ')})`
211
+ : ''} GROUP BY state`, requestedStates !== undefined ? [queueName, ...requestedStates] : [queueName]);
212
+ return Object.fromEntries(jobs.map((row) => [row.state, rowNumber(row.count)]));
213
+ },
214
+ async getMetrics(queueName) {
215
+ const counts = await store.getJobCounts(queueName);
216
+ return {
217
+ queueName,
218
+ counts,
219
+ total: Object.values(counts).reduce((sum, count) => sum + count, 0),
220
+ };
221
+ },
222
+ async updateState(input) {
223
+ const timestamp = nowIso();
224
+ const sets = ['state = ?', 'updated_at = ?'];
225
+ const params = [input.state, timestamp];
226
+ if (input.incrementAttempts === true)
227
+ sets.push('attempts = attempts + 1');
228
+ if (input.availableAt !== undefined) {
229
+ sets.push('available_at = ?');
230
+ params.push(input.availableAt);
231
+ }
232
+ if (input.state === 'active') {
233
+ sets.push('started_at = ?');
234
+ params.push(timestamp);
235
+ }
236
+ if (input.state === 'completed') {
237
+ sets.push('completed_at = ?', 'result = ?');
238
+ params.push(timestamp, serialize(input.result));
239
+ }
240
+ if (input.state === 'failed' || input.state === 'dead_lettered') {
241
+ sets.push('failed_at = ?', 'error = ?');
242
+ params.push(timestamp, serialize(input.error));
243
+ }
244
+ if (input.state === 'stalled') {
245
+ sets.push('stalled_at = ?');
246
+ params.push(timestamp);
247
+ }
248
+ params.push(input.queueName, input.jobId);
249
+ await execute(target, `UPDATE cf_queue_jobs SET ${sets.join(', ')} WHERE queue_name = ? AND id = ?`, params);
250
+ await recordEvent(input.queueName, input.jobId, input.state, {
251
+ error: input.error,
252
+ result: input.result,
253
+ });
254
+ },
255
+ async updateProgress(queueName, jobId, progress) {
256
+ await execute(target, 'UPDATE cf_queue_jobs SET progress = ?, updated_at = ? WHERE queue_name = ? AND id = ?', [serialize(progress), nowIso(), queueName, jobId]);
257
+ await recordEvent(queueName, jobId, 'progress', progress);
258
+ },
259
+ async recordLog(queueName, jobId, level, message, data) {
260
+ await execute(target, 'INSERT INTO cf_queue_job_logs (id, job_id, queue_name, level, message, data, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [generateUuid(), jobId, queueName, level, message, serialize(data), nowIso()]);
261
+ },
262
+ async getDueJobs(queueName, limit) {
263
+ const rows = await queryRows(target, `SELECT * FROM cf_queue_jobs
264
+ WHERE queue_name = ?
265
+ AND state IN ('waiting', 'delayed', 'prioritized', 'retrying')
266
+ AND available_at <= ?
267
+ ORDER BY priority ASC, available_at ASC, created_at ASC
268
+ LIMIT ?`, [queueName, nowIso(), normalizeLimit(limit)]);
269
+ return rows.map((row) => toJob(row, store));
270
+ },
271
+ async clean(queueName, states, olderThanMs) {
272
+ if (states.length === 0)
273
+ return;
274
+ const cutoff = new Date(Date.now() - Math.max(0, olderThanMs)).toISOString();
275
+ await execute(target, `DELETE FROM cf_queue_jobs WHERE queue_name = ? AND state IN (${states
276
+ .map(() => '?')
277
+ .join(', ')}) AND updated_at < ?`, [queueName, ...states, cutoff]);
278
+ },
279
+ async markDispatched(queueName, jobId) {
280
+ await store.updateState({ queueName, jobId, state: 'queued' });
281
+ },
282
+ async getStalledJobs(queueName, olderThanMs, limit) {
283
+ const cutoff = new Date(Date.now() - Math.max(1000, olderThanMs)).toISOString();
284
+ const rows = await queryRows(target, `SELECT * FROM cf_queue_jobs
285
+ WHERE queue_name = ? AND state = 'active' AND updated_at < ?
286
+ ORDER BY updated_at ASC
287
+ LIMIT ?`, [queueName, cutoff, normalizeLimit(limit)]);
288
+ return rows.map((row) => toJob(row, store));
289
+ },
290
+ async applyRetention(queueName, jobId, state) {
291
+ const job = await store.getJob(queueName, jobId);
292
+ if (job === null)
293
+ return;
294
+ const setting = state === 'completed' ? job.opts.removeOnComplete : job.opts.removeOnFail;
295
+ if (setting === false || setting === undefined)
296
+ return;
297
+ if (setting === true) {
298
+ await execute(target, 'DELETE FROM cf_queue_jobs WHERE queue_name = ? AND id = ?', [
299
+ queueName,
300
+ jobId,
301
+ ]);
302
+ return;
303
+ }
304
+ if (typeof setting === 'number') {
305
+ await store.clean(queueName, [state], setting * 1000);
306
+ return;
307
+ }
308
+ if (typeof setting === 'object') {
309
+ if (typeof setting.age === 'number') {
310
+ await store.clean(queueName, [state], setting.age * 1000);
311
+ }
312
+ if (typeof setting.count === 'number' && setting.count >= 0) {
313
+ const rows = await queryRows(target, `SELECT id FROM cf_queue_jobs
314
+ WHERE queue_name = ? AND state = ?
315
+ ORDER BY updated_at DESC
316
+ LIMIT -1 OFFSET ?`, [queueName, state, Math.floor(setting.count)]);
317
+ for (const row of rows) {
318
+ await execute(target, 'DELETE FROM cf_queue_jobs WHERE queue_name = ? AND id = ?', [
319
+ queueName,
320
+ row.id,
321
+ ]);
322
+ }
323
+ }
324
+ }
325
+ },
326
+ async upsertRepeatable(input) {
327
+ const id = input.id ?? generateUuid();
328
+ const timestamp = nowIso();
329
+ const startAt = input.options.startDate?.toISOString() ?? null;
330
+ const nextRunAt = startAt ?? new Date(Date.now() + (input.options.every ?? 60_000)).toISOString();
331
+ await execute(target, `INSERT OR REPLACE INTO cf_queue_repeatables (
332
+ id, queue_name, name, payload, cron, every_ms, start_at, end_at,
333
+ limit_count, run_count, next_run_at, active, created_at, updated_at
334
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, COALESCE((SELECT run_count FROM cf_queue_repeatables WHERE id = ?), 0), ?, 1, ?, ?)`, [
335
+ id,
336
+ input.queueName,
337
+ input.name,
338
+ serialize(input.data),
339
+ input.options.cron ?? null,
340
+ input.options.every ?? null,
341
+ startAt,
342
+ input.options.endDate?.toISOString() ?? null,
343
+ input.options.limit ?? null,
344
+ id,
345
+ nextRunAt,
346
+ timestamp,
347
+ timestamp,
348
+ ]);
349
+ const row = await queryOne(target, 'SELECT * FROM cf_queue_repeatables WHERE id = ? LIMIT 1', [id]);
350
+ if (row === null)
351
+ throw new Error(`Repeatable job not found after upsert: ${id}`);
352
+ return row;
353
+ },
354
+ async getDueRepeatables(limit) {
355
+ return await queryRows(target, `SELECT * FROM cf_queue_repeatables
356
+ WHERE active = 1 AND next_run_at <= ?
357
+ ORDER BY next_run_at ASC
358
+ LIMIT ?`, [nowIso(), normalizeLimit(limit)]);
359
+ },
360
+ async updateRepeatableAfterRun(row) {
361
+ const everyMs = row.every_ms ?? 60_000;
362
+ const runCount = row.run_count + 1;
363
+ const shouldDeactivate = (row.limit_count !== null && runCount >= row.limit_count) ||
364
+ (row.end_at !== null && new Date(row.end_at).getTime() <= Date.now());
365
+ const nextRunAt = new Date(Date.now() + everyMs).toISOString();
366
+ await execute(target, 'UPDATE cf_queue_repeatables SET run_count = ?, next_run_at = ?, active = ?, updated_at = ? WHERE id = ?', [runCount, nextRunAt, shouldDeactivate ? 0 : 1, nowIso(), row.id]);
367
+ },
368
+ async removeRepeatable(id) {
369
+ await execute(target, 'UPDATE cf_queue_repeatables SET active = 0, updated_at = ? WHERE id = ?', [
370
+ nowIso(),
371
+ id,
372
+ ]);
373
+ },
374
+ async createFlow(input) {
375
+ const parent = await store.createJob({
376
+ queueName: input.queueName,
377
+ name: input.parent.name,
378
+ data: input.parent.data,
379
+ options: input.parent.options,
380
+ });
381
+ await store.updateState({
382
+ queueName: input.queueName,
383
+ jobId: parent.id,
384
+ state: 'waiting_children',
385
+ });
386
+ const children = [];
387
+ for (const childInput of input.children) {
388
+ const child = await store.createJob({
389
+ queueName: input.queueName,
390
+ name: childInput.name,
391
+ data: childInput.data,
392
+ options: childInput.options,
393
+ });
394
+ children.push(child);
395
+ await execute(target, 'INSERT INTO cf_queue_flow_dependencies (id, queue_name, parent_job_id, child_job_id, state, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)', [generateUuid(), input.queueName, parent.id, child.id, 'waiting', nowIso(), nowIso()]);
396
+ }
397
+ const updatedParent = await store.getJob(input.queueName, parent.id);
398
+ if (updatedParent === null)
399
+ throw new Error(`Flow parent not found after creation: ${parent.id}`);
400
+ return { parent: updatedParent, children };
401
+ },
402
+ async markFlowChildCompleted(queueName, childJobId) {
403
+ await execute(target, 'UPDATE cf_queue_flow_dependencies SET state = ?, updated_at = ? WHERE queue_name = ? AND child_job_id = ?', ['completed', nowIso(), queueName, childJobId]);
404
+ const rows = await queryRows(target, 'SELECT DISTINCT parent_job_id FROM cf_queue_flow_dependencies WHERE queue_name = ? AND child_job_id = ?', [queueName, childJobId]);
405
+ const released = [];
406
+ for (const row of rows) {
407
+ const pending = await queryOne(target, 'SELECT COUNT(*) AS count FROM cf_queue_flow_dependencies WHERE queue_name = ? AND parent_job_id = ? AND state != ?', [queueName, row.parent_job_id, 'completed']);
408
+ if (rowNumber(pending?.count) === 0) {
409
+ await store.updateState({
410
+ queueName,
411
+ jobId: row.parent_job_id,
412
+ state: 'waiting',
413
+ });
414
+ const parent = await store.getJob(queueName, row.parent_job_id);
415
+ if (parent !== null)
416
+ released.push(parent);
417
+ }
418
+ }
419
+ return released;
420
+ },
421
+ async getFlowChildren(queueName, parentJobId) {
422
+ const rows = await queryRows(target, `SELECT jobs.*
423
+ FROM cf_queue_jobs jobs
424
+ INNER JOIN cf_queue_flow_dependencies deps
425
+ ON deps.child_job_id = jobs.id AND deps.queue_name = jobs.queue_name
426
+ WHERE deps.queue_name = ? AND deps.parent_job_id = ?
427
+ ORDER BY jobs.created_at ASC`, [queueName, parentJobId]);
428
+ return rows.map((row) => toJob(row, store));
429
+ },
430
+ };
431
+ return store;
432
+ }
433
+ export const CloudflareJobStore = Object.freeze({
434
+ create: createCloudflareJobStore,
435
+ });
@@ -0,0 +1,39 @@
1
+ import type { CloudflareJobStore } from './CloudflareJobStore.js';
2
+ import type { CloudflareQueueProcessor, CloudflareQueueStateConfig } from './types.js';
3
+ type CloudflareMessageLike = {
4
+ id: string;
5
+ body: unknown;
6
+ attempts: number;
7
+ ack: () => void;
8
+ retry: (options?: {
9
+ delaySeconds?: number;
10
+ }) => void;
11
+ };
12
+ type CloudflareBatchLike = {
13
+ queue: string;
14
+ messages: CloudflareMessageLike[];
15
+ };
16
+ export type CloudflareQueueConsumerConfig<T = unknown, TResult = unknown> = {
17
+ queueName?: string;
18
+ store: CloudflareJobStore;
19
+ processor: CloudflareQueueProcessor<T, TResult>;
20
+ state?: CloudflareQueueStateConfig;
21
+ retryDelaySeconds?: number;
22
+ queue?: {
23
+ enqueue<TPayload = unknown>(queue: string, payload: TPayload): Promise<unknown>;
24
+ };
25
+ };
26
+ export declare const CloudflareQueueConsumer: Readonly<{
27
+ create: <T = unknown, TResult = unknown>(config: CloudflareQueueConsumerConfig<T, TResult>) => {
28
+ processMessage(message: CloudflareMessageLike, batchQueueName?: string): Promise<void>;
29
+ processBatch(batch: CloudflareBatchLike): Promise<void>;
30
+ };
31
+ createDeadLetter(input: {
32
+ queueName?: string;
33
+ store: CloudflareJobStore;
34
+ }): {
35
+ processMessage(message: CloudflareMessageLike, batchQueueName?: string): Promise<void>;
36
+ processBatch(batch: CloudflareBatchLike): Promise<void>;
37
+ };
38
+ }>;
39
+ export {};