duron 0.1.1 → 0.2.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 (43) hide show
  1. package/README.md +4 -4
  2. package/dist/adapters/adapter.d.ts +3 -3
  3. package/dist/adapters/adapter.d.ts.map +1 -1
  4. package/dist/adapters/adapter.js +7 -7
  5. package/dist/adapters/postgres/base.d.ts +52 -0
  6. package/dist/adapters/postgres/base.d.ts.map +1 -0
  7. package/dist/adapters/postgres/base.js +834 -0
  8. package/dist/adapters/postgres/pglite.d.ts +10 -5
  9. package/dist/adapters/postgres/pglite.d.ts.map +1 -1
  10. package/dist/adapters/postgres/pglite.js +19 -7
  11. package/dist/adapters/postgres/postgres.d.ts +6 -39
  12. package/dist/adapters/postgres/postgres.d.ts.map +1 -1
  13. package/dist/adapters/postgres/postgres.js +9 -822
  14. package/dist/adapters/postgres/schema.d.ts +90 -136
  15. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  16. package/dist/adapters/postgres/schema.default.d.ts +90 -136
  17. package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
  18. package/dist/adapters/postgres/schema.js +2 -2
  19. package/dist/adapters/schemas.d.ts +6 -3
  20. package/dist/adapters/schemas.d.ts.map +1 -1
  21. package/dist/adapters/schemas.js +2 -1
  22. package/dist/client.d.ts +1 -0
  23. package/dist/client.d.ts.map +1 -1
  24. package/dist/server.d.ts +5 -2
  25. package/dist/server.d.ts.map +1 -1
  26. package/dist/server.js +3 -3
  27. package/dist/utils/p-retry.d.ts.map +1 -1
  28. package/dist/utils/p-retry.js +3 -4
  29. package/migrations/postgres/20251203223656_conscious_johnny_blaze/migration.sql +64 -0
  30. package/migrations/postgres/20251203223656_conscious_johnny_blaze/snapshot.json +941 -0
  31. package/package.json +3 -3
  32. package/src/adapters/adapter.ts +10 -10
  33. package/src/adapters/postgres/base.ts +1299 -0
  34. package/src/adapters/postgres/pglite.ts +36 -18
  35. package/src/adapters/postgres/postgres.ts +19 -1244
  36. package/src/adapters/postgres/schema.ts +2 -2
  37. package/src/adapters/schemas.ts +2 -1
  38. package/src/client.ts +1 -1
  39. package/src/server.ts +2 -2
  40. package/src/utils/p-retry.ts +8 -11
  41. package/migrations/postgres/0000_lethal_speed_demon.sql +0 -64
  42. package/migrations/postgres/meta/0000_snapshot.json +0 -606
  43. package/migrations/postgres/meta/_journal.json +0 -13
@@ -0,0 +1,834 @@
1
+ import { and, asc, between, desc, eq, gt, gte, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm';
2
+ import { JOB_STATUS_ACTIVE, JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_CREATED, JOB_STATUS_FAILED, STEP_STATUS_ACTIVE, STEP_STATUS_CANCELLED, STEP_STATUS_COMPLETED, STEP_STATUS_FAILED, } from '../../constants.js';
3
+ import { Adapter, } from '../adapter.js';
4
+ import createSchema from './schema.js';
5
+ export class PostgresBaseAdapter extends Adapter {
6
+ connection;
7
+ db;
8
+ tables;
9
+ schema = 'duron';
10
+ migrateOnStart = true;
11
+ constructor(options) {
12
+ super();
13
+ this.connection = options.connection;
14
+ this.schema = options.schema ?? 'duron';
15
+ this.migrateOnStart = options.migrateOnStart ?? true;
16
+ this.tables = createSchema(this.schema);
17
+ this._initDb();
18
+ }
19
+ _initDb() {
20
+ throw new Error('Not implemented');
21
+ }
22
+ async _start() {
23
+ await this._listen(`ping-${this.id}`, async (payload) => {
24
+ const fromClientId = JSON.parse(payload).fromClientId;
25
+ await this._notify(`pong-${fromClientId}`, { toClientId: this.id });
26
+ });
27
+ await this._listen(`job-status-changed`, (payload) => {
28
+ if (this.listenerCount('job-status-changed') > 0) {
29
+ const { jobId, status, clientId } = JSON.parse(payload);
30
+ this.emit('job-status-changed', { jobId, status, clientId });
31
+ }
32
+ });
33
+ await this._listen(`job-available`, (payload) => {
34
+ if (this.listenerCount('job-available') > 0) {
35
+ const { jobId } = JSON.parse(payload);
36
+ this.emit('job-available', { jobId });
37
+ }
38
+ });
39
+ }
40
+ async _stop() {
41
+ }
42
+ async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit }) {
43
+ const [result] = await this.db
44
+ .insert(this.tables.jobsTable)
45
+ .values({
46
+ action_name: queue,
47
+ group_key: groupKey,
48
+ checksum,
49
+ input,
50
+ status: JOB_STATUS_CREATED,
51
+ timeout_ms: timeoutMs,
52
+ concurrency_limit: concurrencyLimit,
53
+ })
54
+ .returning({ id: this.tables.jobsTable.id });
55
+ if (!result) {
56
+ return null;
57
+ }
58
+ return result.id;
59
+ }
60
+ async _completeJob({ jobId, output }) {
61
+ const result = await this.db
62
+ .update(this.tables.jobsTable)
63
+ .set({
64
+ status: JOB_STATUS_COMPLETED,
65
+ output,
66
+ finished_at: sql `now()`,
67
+ })
68
+ .where(and(eq(this.tables.jobsTable.id, jobId), eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.client_id, this.id), gt(this.tables.jobsTable.expires_at, sql `now()`)))
69
+ .returning({ id: this.tables.jobsTable.id });
70
+ return result.length > 0;
71
+ }
72
+ async _failJob({ jobId, error }) {
73
+ const result = await this.db
74
+ .update(this.tables.jobsTable)
75
+ .set({
76
+ status: JOB_STATUS_FAILED,
77
+ error,
78
+ finished_at: sql `now()`,
79
+ })
80
+ .where(and(eq(this.tables.jobsTable.id, jobId), eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.client_id, this.id)))
81
+ .returning({ id: this.tables.jobsTable.id });
82
+ return result.length > 0;
83
+ }
84
+ async _cancelJob({ jobId }) {
85
+ const result = await this.db
86
+ .update(this.tables.jobsTable)
87
+ .set({
88
+ status: JOB_STATUS_CANCELLED,
89
+ finished_at: sql `now()`,
90
+ })
91
+ .where(and(eq(this.tables.jobsTable.id, jobId), or(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_CREATED))))
92
+ .returning({ id: this.tables.jobsTable.id });
93
+ return result.length > 0;
94
+ }
95
+ async _retryJob({ jobId }) {
96
+ const result = this._map(await this.db.execute(sql `
97
+ WITH locked_source AS (
98
+ -- Lock the source job row to prevent concurrent retries
99
+ SELECT
100
+ j.action_name,
101
+ j.group_key,
102
+ j.checksum,
103
+ j.input,
104
+ j.timeout_ms,
105
+ j.created_at,
106
+ j.concurrency_limit
107
+ FROM ${this.tables.jobsTable} j
108
+ WHERE j.id = ${jobId}
109
+ AND j.status IN (${JOB_STATUS_COMPLETED}, ${JOB_STATUS_CANCELLED}, ${JOB_STATUS_FAILED})
110
+ FOR UPDATE OF j SKIP LOCKED
111
+ ),
112
+ existing_retry AS (
113
+ -- Check if a retry already exists (a newer job with same checksum, group_key, and input)
114
+ SELECT j.id
115
+ FROM ${this.tables.jobsTable} j
116
+ INNER JOIN locked_source ls
117
+ ON j.action_name = ls.action_name
118
+ AND j.group_key = ls.group_key
119
+ AND j.checksum = ls.checksum
120
+ AND j.input = ls.input
121
+ AND j.created_at > ls.created_at
122
+ WHERE j.status IN (${JOB_STATUS_CREATED}, ${JOB_STATUS_ACTIVE})
123
+ LIMIT 1
124
+ ),
125
+ inserted_retry AS (
126
+ -- Insert the retry only if no existing retry was found
127
+ -- Get concurrency_limit from the latest job at insertion time to avoid stale values
128
+ INSERT INTO ${this.tables.jobsTable} (
129
+ action_name,
130
+ group_key,
131
+ checksum,
132
+ input,
133
+ status,
134
+ timeout_ms,
135
+ concurrency_limit
136
+ )
137
+ SELECT
138
+ ls.action_name,
139
+ ls.group_key,
140
+ ls.checksum,
141
+ ls.input,
142
+ ${JOB_STATUS_CREATED},
143
+ ls.timeout_ms,
144
+ COALESCE(
145
+ (
146
+ SELECT j.concurrency_limit
147
+ FROM ${this.tables.jobsTable} j
148
+ WHERE j.action_name = ls.action_name
149
+ AND j.group_key = ls.group_key
150
+ AND (j.expires_at IS NULL OR j.expires_at > now())
151
+ ORDER BY j.created_at DESC, j.id DESC
152
+ LIMIT 1
153
+ ),
154
+ ls.concurrency_limit
155
+ )
156
+ FROM locked_source ls
157
+ WHERE NOT EXISTS (SELECT 1 FROM existing_retry)
158
+ RETURNING id
159
+ )
160
+ -- Return only the newly inserted retry ID (not existing retries)
161
+ SELECT id FROM inserted_retry
162
+ LIMIT 1
163
+ `));
164
+ if (result.length === 0) {
165
+ return null;
166
+ }
167
+ return result[0].id;
168
+ }
169
+ async _deleteJob({ jobId }) {
170
+ const result = await this.db
171
+ .delete(this.tables.jobsTable)
172
+ .where(and(eq(this.tables.jobsTable.id, jobId), ne(this.tables.jobsTable.status, JOB_STATUS_ACTIVE)))
173
+ .returning({ id: this.tables.jobsTable.id });
174
+ if (result.length > 0) {
175
+ await this.db.delete(this.tables.jobStepsTable).where(eq(this.tables.jobStepsTable.job_id, jobId));
176
+ }
177
+ return result.length > 0;
178
+ }
179
+ async _deleteJobs(options) {
180
+ const jobsTable = this.tables.jobsTable;
181
+ const filters = options?.filters ?? {};
182
+ const where = this._buildJobsWhereClause(filters);
183
+ const result = await this.db.delete(jobsTable).where(where).returning({ id: jobsTable.id });
184
+ return result.length;
185
+ }
186
+ async _fetch({ batch }) {
187
+ const result = this._map(await this.db.execute(sql `
188
+ WITH group_concurrency AS (
189
+ -- Get the concurrency limit from the latest job for each group
190
+ SELECT DISTINCT ON (j.group_key, j.action_name)
191
+ j.group_key as group_key,
192
+ j.action_name as action_name,
193
+ j.concurrency_limit as concurrency_limit
194
+ FROM ${this.tables.jobsTable} j
195
+ WHERE j.group_key IS NOT NULL
196
+ AND (j.expires_at IS NULL OR j.expires_at > now())
197
+ ORDER BY j.group_key, j.action_name, j.created_at DESC, j.id DESC
198
+ ),
199
+ eligible_groups AS (
200
+ -- Find all groups with their active counts that are below their concurrency limit
201
+ SELECT
202
+ gc.group_key,
203
+ gc.action_name,
204
+ gc.concurrency_limit,
205
+ COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) as active_count
206
+ FROM group_concurrency gc
207
+ LEFT JOIN ${this.tables.jobsTable} j
208
+ ON j.group_key = gc.group_key
209
+ AND j.action_name = gc.action_name
210
+ AND (j.expires_at IS NULL OR j.expires_at > now())
211
+ GROUP BY gc.group_key, gc.action_name, gc.concurrency_limit
212
+ HAVING COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) < gc.concurrency_limit
213
+ ),
214
+ candidate_jobs AS (
215
+ -- Lock candidate jobs first (before applying window functions)
216
+ SELECT
217
+ j.id,
218
+ j.action_name,
219
+ j.group_key as job_group_key,
220
+ j.created_at
221
+ FROM ${this.tables.jobsTable} j
222
+ INNER JOIN eligible_groups eg
223
+ ON j.group_key = eg.group_key
224
+ AND j.action_name = eg.action_name
225
+ WHERE j.status = ${JOB_STATUS_CREATED}
226
+ FOR UPDATE OF j SKIP LOCKED
227
+ ),
228
+ ranked_jobs AS (
229
+ -- Rank jobs within each group after locking
230
+ SELECT
231
+ cj.id,
232
+ cj.action_name,
233
+ cj.job_group_key,
234
+ cj.created_at,
235
+ ROW_NUMBER() OVER (
236
+ PARTITION BY cj.job_group_key, cj.action_name
237
+ ORDER BY cj.created_at ASC, cj.id ASC
238
+ ) as job_rank
239
+ FROM candidate_jobs cj
240
+ ),
241
+ next_job AS (
242
+ -- Select only jobs that fit within the concurrency limit per group
243
+ -- Ordered globally by created_at to respect job creation order
244
+ SELECT rj.id, rj.action_name, rj.job_group_key
245
+ FROM ranked_jobs rj
246
+ INNER JOIN eligible_groups eg
247
+ ON rj.job_group_key = eg.group_key
248
+ AND rj.action_name = eg.action_name
249
+ WHERE rj.job_rank <= (eg.concurrency_limit - eg.active_count)
250
+ ORDER BY rj.created_at ASC, rj.id ASC
251
+ LIMIT ${batch}
252
+ ),
253
+ verify_concurrency AS (
254
+ -- Double-check concurrency limit after acquiring lock
255
+ SELECT
256
+ nj.id,
257
+ nj.action_name,
258
+ nj.job_group_key,
259
+ eg.concurrency_limit,
260
+ (SELECT COUNT(*)
261
+ FROM ${this.tables.jobsTable}
262
+ WHERE action_name = nj.action_name
263
+ AND group_key = nj.job_group_key
264
+ AND status = ${JOB_STATUS_ACTIVE}) as current_active
265
+ FROM next_job nj
266
+ INNER JOIN eligible_groups eg
267
+ ON nj.job_group_key = eg.group_key
268
+ AND nj.action_name = eg.action_name
269
+ )
270
+ UPDATE ${this.tables.jobsTable} j
271
+ SET status = ${JOB_STATUS_ACTIVE},
272
+ started_at = now(),
273
+ expires_at = now() + (timeout_ms || ' milliseconds')::interval,
274
+ client_id = ${this.id}
275
+ FROM verify_concurrency vc
276
+ WHERE j.id = vc.id
277
+ AND vc.current_active < vc.concurrency_limit -- Final concurrency check using job's concurrency limit
278
+ RETURNING
279
+ j.id,
280
+ j.action_name as "actionName",
281
+ j.group_key as "groupKey",
282
+ j.input,
283
+ j.output,
284
+ j.error,
285
+ j.status,
286
+ j.timeout_ms as "timeoutMs",
287
+ j.expires_at as "expiresAt",
288
+ j.started_at as "startedAt",
289
+ j.finished_at as "finishedAt",
290
+ j.created_at as "createdAt",
291
+ j.updated_at as "updatedAt",
292
+ j.concurrency_limit as "concurrencyLimit"
293
+ `));
294
+ return result;
295
+ }
296
+ async _recoverJobs(options) {
297
+ const { checksums, multiProcessMode = false, processTimeout = 5_000 } = options;
298
+ const unresponsiveClientIds = [this.id];
299
+ if (multiProcessMode) {
300
+ const result = (await this.db
301
+ .selectDistinct({
302
+ clientId: this.tables.jobsTable.client_id,
303
+ })
304
+ .from(this.tables.jobsTable)
305
+ .where(and(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), ne(this.tables.jobsTable.client_id, this.id))));
306
+ if (result.length > 0) {
307
+ const pongCount = new Set();
308
+ const { unlisten } = await this._listen(`pong-${this.id}`, (payload) => {
309
+ const toClientId = JSON.parse(payload).toClientId;
310
+ pongCount.add(toClientId);
311
+ if (pongCount.size >= result.length) {
312
+ unlisten();
313
+ }
314
+ });
315
+ await Promise.all(result.map((row) => this._notify(`ping-${row.clientId}`, { fromClientId: this.id })));
316
+ let waitForSeconds = processTimeout / 1_000;
317
+ while (pongCount.size < result.length && waitForSeconds > 0) {
318
+ await new Promise((resolve) => setTimeout(resolve, 1000).unref?.());
319
+ waitForSeconds--;
320
+ }
321
+ unresponsiveClientIds.push(...result.filter((row) => !pongCount.has(row.clientId)).map((row) => row.clientId));
322
+ }
323
+ }
324
+ if (unresponsiveClientIds.length > 0) {
325
+ const result = this._map(await this.db.execute(sql `
326
+ WITH locked_jobs AS (
327
+ SELECT j.id
328
+ FROM ${this.tables.jobsTable} j
329
+ WHERE j.status = ${JOB_STATUS_ACTIVE}
330
+ AND j.client_id IN ${unresponsiveClientIds}
331
+ FOR UPDATE OF j SKIP LOCKED
332
+ ),
333
+ updated_jobs AS (
334
+ UPDATE ${this.tables.jobsTable} j
335
+ SET status = ${JOB_STATUS_CREATED},
336
+ started_at = NULL,
337
+ expires_at = NULL,
338
+ finished_at = NULL,
339
+ output = NULL,
340
+ error = NULL
341
+ WHERE EXISTS (SELECT 1 FROM locked_jobs lj WHERE lj.id = j.id)
342
+ RETURNING id, checksum
343
+ ),
344
+ deleted_steps AS (
345
+ DELETE FROM ${this.tables.jobStepsTable} s
346
+ WHERE EXISTS (
347
+ SELECT 1 FROM updated_jobs uj
348
+ WHERE uj.id = s.job_id
349
+ AND uj.checksum NOT IN ${checksums}
350
+ )
351
+ )
352
+ SELECT id FROM updated_jobs
353
+ `));
354
+ return result.length;
355
+ }
356
+ return 0;
357
+ }
358
+ async _createOrRecoverJobStep({ jobId, name, timeoutMs, retriesLimit, }) {
359
+ const [result] = this._map(await this.db.execute(sql `
360
+ WITH job_check AS (
361
+ SELECT j.id
362
+ FROM ${this.tables.jobsTable} j
363
+ WHERE j.id = ${jobId}
364
+ AND j.status = ${JOB_STATUS_ACTIVE}
365
+ AND (j.expires_at IS NULL OR j.expires_at > now())
366
+ ),
367
+ step_existed AS (
368
+ SELECT EXISTS(
369
+ SELECT 1 FROM ${this.tables.jobStepsTable} s
370
+ WHERE s.job_id = ${jobId} AND s.name = ${name}
371
+ ) AS existed
372
+ ),
373
+ upserted_step AS (
374
+ INSERT INTO ${this.tables.jobStepsTable} (
375
+ job_id,
376
+ name,
377
+ timeout_ms,
378
+ retries_limit,
379
+ status,
380
+ started_at,
381
+ expires_at,
382
+ retries_count,
383
+ delayed_ms
384
+ )
385
+ SELECT
386
+ ${jobId},
387
+ ${name},
388
+ ${timeoutMs},
389
+ ${retriesLimit},
390
+ ${STEP_STATUS_ACTIVE},
391
+ now(),
392
+ now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
393
+ 0,
394
+ NULL
395
+ WHERE EXISTS (SELECT 1 FROM job_check)
396
+ ON CONFLICT (job_id, name) DO UPDATE
397
+ SET
398
+ timeout_ms = ${timeoutMs},
399
+ expires_at = now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
400
+ retries_count = 0,
401
+ retries_limit = ${retriesLimit},
402
+ delayed_ms = NULL,
403
+ started_at = now(),
404
+ history_failed_attempts = '{}'::jsonb
405
+ WHERE ${this.tables.jobStepsTable}.status = ${STEP_STATUS_ACTIVE}
406
+ RETURNING
407
+ id,
408
+ status,
409
+ retries_limit AS "retriesLimit",
410
+ retries_count AS "retriesCount",
411
+ timeout_ms AS "timeoutMs",
412
+ error,
413
+ output
414
+ ),
415
+ final_upserted AS (
416
+ SELECT
417
+ us.*,
418
+ CASE WHEN se.existed THEN false ELSE true END AS "isNew"
419
+ FROM upserted_step us
420
+ CROSS JOIN step_existed se
421
+ ),
422
+ existing_step AS (
423
+ SELECT
424
+ s.id,
425
+ s.status,
426
+ s.retries_limit AS "retriesLimit",
427
+ s.retries_count AS "retriesCount",
428
+ s.timeout_ms AS "timeoutMs",
429
+ s.error,
430
+ s.output,
431
+ false AS "isNew"
432
+ FROM ${this.tables.jobStepsTable} s
433
+ INNER JOIN job_check jc ON s.job_id = jc.id
434
+ WHERE s.job_id = ${jobId}
435
+ AND s.name = ${name}
436
+ AND NOT EXISTS (SELECT 1 FROM final_upserted)
437
+ )
438
+ SELECT * FROM final_upserted
439
+ UNION ALL
440
+ SELECT * FROM existing_step
441
+ `));
442
+ if (!result) {
443
+ this.logger?.error({ jobId }, `[PostgresAdapter] Job ${jobId} is not active or has expired`);
444
+ return null;
445
+ }
446
+ return result;
447
+ }
448
+ async _completeJobStep({ stepId, output }) {
449
+ const result = await this.db
450
+ .update(this.tables.jobStepsTable)
451
+ .set({
452
+ status: STEP_STATUS_COMPLETED,
453
+ output,
454
+ finished_at: sql `now()`,
455
+ })
456
+ .from(this.tables.jobsTable)
457
+ .where(and(eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id), eq(this.tables.jobStepsTable.id, stepId), eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), or(isNull(this.tables.jobsTable.expires_at), gt(this.tables.jobsTable.expires_at, sql `now()`))))
458
+ .returning({ id: this.tables.jobStepsTable.id });
459
+ return result.length > 0;
460
+ }
461
+ async _failJobStep({ stepId, error }) {
462
+ const result = await this.db
463
+ .update(this.tables.jobStepsTable)
464
+ .set({
465
+ status: STEP_STATUS_FAILED,
466
+ error,
467
+ finished_at: sql `now()`,
468
+ })
469
+ .from(this.tables.jobsTable)
470
+ .where(and(eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id), eq(this.tables.jobStepsTable.id, stepId), eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE)))
471
+ .returning({ id: this.tables.jobStepsTable.id });
472
+ return result.length > 0;
473
+ }
474
+ async _delayJobStep({ stepId, delayMs, error }) {
475
+ const jobStepsTable = this.tables.jobStepsTable;
476
+ const jobsTable = this.tables.jobsTable;
477
+ const result = await this.db
478
+ .update(jobStepsTable)
479
+ .set({
480
+ delayed_ms: delayMs,
481
+ retries_count: sql `${jobStepsTable.retries_count} + 1`,
482
+ expires_at: sql `now() + (${jobStepsTable.timeout_ms} || ' milliseconds')::interval + (${delayMs} || ' milliseconds')::interval`,
483
+ history_failed_attempts: sql `COALESCE(${jobStepsTable.history_failed_attempts}, '{}'::jsonb) || jsonb_build_object(
484
+ extract(epoch from now())::text,
485
+ jsonb_build_object(
486
+ 'failedAt', now(),
487
+ 'error', ${JSON.stringify(error)}::jsonb,
488
+ 'delayedMs', ${delayMs}::integer
489
+ )
490
+ )`,
491
+ })
492
+ .from(jobsTable)
493
+ .where(and(eq(jobStepsTable.job_id, jobsTable.id), eq(jobStepsTable.id, stepId), eq(jobStepsTable.status, STEP_STATUS_ACTIVE), eq(jobsTable.status, JOB_STATUS_ACTIVE)))
494
+ .returning({ id: jobStepsTable.id });
495
+ return result.length > 0;
496
+ }
497
+ async _cancelJobStep({ stepId }) {
498
+ const result = await this.db
499
+ .update(this.tables.jobStepsTable)
500
+ .set({
501
+ status: STEP_STATUS_CANCELLED,
502
+ finished_at: sql `now()`,
503
+ })
504
+ .from(this.tables.jobsTable)
505
+ .where(and(eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id), eq(this.tables.jobStepsTable.id, stepId), eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE), or(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_CANCELLED))))
506
+ .returning({ id: this.tables.jobStepsTable.id });
507
+ return result.length > 0;
508
+ }
509
+ async _getJobById(jobId) {
510
+ const [job] = await this.db
511
+ .select({
512
+ id: this.tables.jobsTable.id,
513
+ actionName: this.tables.jobsTable.action_name,
514
+ groupKey: this.tables.jobsTable.group_key,
515
+ input: this.tables.jobsTable.input,
516
+ output: this.tables.jobsTable.output,
517
+ error: this.tables.jobsTable.error,
518
+ status: this.tables.jobsTable.status,
519
+ timeoutMs: this.tables.jobsTable.timeout_ms,
520
+ expiresAt: this.tables.jobsTable.expires_at,
521
+ startedAt: this.tables.jobsTable.started_at,
522
+ finishedAt: this.tables.jobsTable.finished_at,
523
+ createdAt: this.tables.jobsTable.created_at,
524
+ updatedAt: this.tables.jobsTable.updated_at,
525
+ concurrencyLimit: this.tables.jobsTable.concurrency_limit,
526
+ clientId: this.tables.jobsTable.client_id,
527
+ })
528
+ .from(this.tables.jobsTable)
529
+ .where(eq(this.tables.jobsTable.id, jobId))
530
+ .limit(1);
531
+ return job ?? null;
532
+ }
533
+ async _getJobSteps(options) {
534
+ const { jobId, page = 1, pageSize = 10, search } = options;
535
+ const jobStepsTable = this.tables.jobStepsTable;
536
+ const fuzzySearch = search?.trim();
537
+ const where = and(eq(jobStepsTable.job_id, jobId), fuzzySearch && fuzzySearch.length > 0
538
+ ? or(ilike(jobStepsTable.name, `%${fuzzySearch}%`), sql `to_tsvector('english', ${jobStepsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`)
539
+ : undefined, options.updatedAfter
540
+ ? sql `date_trunc('milliseconds', ${jobStepsTable.updated_at}) > ${options.updatedAfter.toISOString()}::timestamptz`
541
+ : undefined);
542
+ const total = await this.db.$count(jobStepsTable, where);
543
+ if (!total) {
544
+ return {
545
+ steps: [],
546
+ total: 0,
547
+ page,
548
+ pageSize,
549
+ };
550
+ }
551
+ const steps = await this.db
552
+ .select({
553
+ id: jobStepsTable.id,
554
+ jobId: jobStepsTable.job_id,
555
+ name: jobStepsTable.name,
556
+ status: jobStepsTable.status,
557
+ error: jobStepsTable.error,
558
+ startedAt: jobStepsTable.started_at,
559
+ finishedAt: jobStepsTable.finished_at,
560
+ timeoutMs: jobStepsTable.timeout_ms,
561
+ expiresAt: jobStepsTable.expires_at,
562
+ retriesLimit: jobStepsTable.retries_limit,
563
+ retriesCount: jobStepsTable.retries_count,
564
+ delayedMs: jobStepsTable.delayed_ms,
565
+ historyFailedAttempts: jobStepsTable.history_failed_attempts,
566
+ createdAt: jobStepsTable.created_at,
567
+ updatedAt: jobStepsTable.updated_at,
568
+ })
569
+ .from(jobStepsTable)
570
+ .where(where)
571
+ .orderBy(asc(jobStepsTable.created_at))
572
+ .limit(pageSize)
573
+ .offset((page - 1) * pageSize);
574
+ return {
575
+ steps,
576
+ total,
577
+ page,
578
+ pageSize,
579
+ };
580
+ }
581
+ _buildJobsWhereClause(filters) {
582
+ if (!filters) {
583
+ return undefined;
584
+ }
585
+ const jobsTable = this.tables.jobsTable;
586
+ const fuzzySearch = filters.search?.trim();
587
+ return and(filters.status
588
+ ? inArray(jobsTable.status, Array.isArray(filters.status) ? filters.status : [filters.status])
589
+ : undefined, filters.actionName
590
+ ? inArray(jobsTable.action_name, Array.isArray(filters.actionName) ? filters.actionName : [filters.actionName])
591
+ : undefined, filters.groupKey && Array.isArray(filters.groupKey)
592
+ ? sql `j.group_key LIKE ANY(ARRAY[${sql.raw(filters.groupKey.map((key) => `'${key}'`).join(','))}]::text[])`
593
+ : undefined, filters.groupKey && !Array.isArray(filters.groupKey)
594
+ ? ilike(jobsTable.group_key, `%${filters.groupKey}%`)
595
+ : undefined, filters.clientId
596
+ ? inArray(jobsTable.client_id, Array.isArray(filters.clientId) ? filters.clientId : [filters.clientId])
597
+ : undefined, filters.createdAt && Array.isArray(filters.createdAt)
598
+ ? between(sql `date_trunc('second', ${jobsTable.created_at})`, filters.createdAt[0].toISOString(), filters.createdAt[1].toISOString())
599
+ : undefined, filters.createdAt && !Array.isArray(filters.createdAt)
600
+ ? gte(sql `date_trunc('second', ${jobsTable.created_at})`, filters.createdAt.toISOString())
601
+ : undefined, filters.startedAt && Array.isArray(filters.startedAt)
602
+ ? between(sql `date_trunc('second', ${jobsTable.started_at})`, filters.startedAt[0].toISOString(), filters.startedAt[1].toISOString())
603
+ : undefined, filters.startedAt && !Array.isArray(filters.startedAt)
604
+ ? gte(sql `date_trunc('second', ${jobsTable.started_at})`, filters.startedAt.toISOString())
605
+ : undefined, filters.finishedAt && Array.isArray(filters.finishedAt)
606
+ ? between(sql `date_trunc('second', ${jobsTable.finished_at})`, filters.finishedAt[0].toISOString(), filters.finishedAt[1].toISOString())
607
+ : undefined, filters.finishedAt && !Array.isArray(filters.finishedAt)
608
+ ? gte(sql `date_trunc('second', ${jobsTable.finished_at})`, filters.finishedAt.toISOString())
609
+ : undefined, filters.updatedAfter
610
+ ? sql `date_trunc('milliseconds', ${jobsTable.updated_at}) > ${filters.updatedAfter.toISOString()}::timestamptz`
611
+ : undefined, fuzzySearch && fuzzySearch.length > 0
612
+ ? or(ilike(jobsTable.action_name, `%${fuzzySearch}%`), ilike(jobsTable.group_key, `%${fuzzySearch}%`), ilike(jobsTable.client_id, `%${fuzzySearch}%`), sql `${jobsTable.id}::text ilike ${`%${fuzzySearch}%`}`, sql `to_tsvector('english', ${jobsTable.input}::text) @@ plainto_tsquery('english', ${fuzzySearch})`, sql `to_tsvector('english', ${jobsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`)
613
+ : undefined, ...(filters.inputFilter && Object.keys(filters.inputFilter).length > 0
614
+ ? this.#buildJsonbWhereConditions(filters.inputFilter, jobsTable.input)
615
+ : []), ...(filters.outputFilter && Object.keys(filters.outputFilter).length > 0
616
+ ? this.#buildJsonbWhereConditions(filters.outputFilter, jobsTable.output)
617
+ : []));
618
+ }
619
+ async _getJobs(options) {
620
+ const jobsTable = this.tables.jobsTable;
621
+ const page = options?.page ?? 1;
622
+ const pageSize = options?.pageSize ?? 10;
623
+ const filters = options?.filters ?? {};
624
+ const sortInput = options?.sort ?? { field: 'startedAt', order: 'desc' };
625
+ const sorts = Array.isArray(sortInput) ? sortInput : [sortInput];
626
+ const where = this._buildJobsWhereClause(filters);
627
+ const total = await this.db.$count(jobsTable, where);
628
+ if (!total) {
629
+ return {
630
+ jobs: [],
631
+ total: 0,
632
+ page,
633
+ pageSize,
634
+ };
635
+ }
636
+ const sortFieldMap = {
637
+ createdAt: jobsTable.created_at,
638
+ startedAt: jobsTable.started_at,
639
+ finishedAt: jobsTable.finished_at,
640
+ status: jobsTable.status,
641
+ actionName: jobsTable.action_name,
642
+ expiresAt: jobsTable.expires_at,
643
+ };
644
+ const jobs = await this.db
645
+ .select({
646
+ id: jobsTable.id,
647
+ actionName: jobsTable.action_name,
648
+ groupKey: jobsTable.group_key,
649
+ input: jobsTable.input,
650
+ output: jobsTable.output,
651
+ error: jobsTable.error,
652
+ status: jobsTable.status,
653
+ timeoutMs: jobsTable.timeout_ms,
654
+ expiresAt: jobsTable.expires_at,
655
+ startedAt: jobsTable.started_at,
656
+ finishedAt: jobsTable.finished_at,
657
+ createdAt: jobsTable.created_at,
658
+ updatedAt: jobsTable.updated_at,
659
+ concurrencyLimit: jobsTable.concurrency_limit,
660
+ clientId: jobsTable.client_id,
661
+ })
662
+ .from(jobsTable)
663
+ .where(where)
664
+ .orderBy(...sorts
665
+ .filter((sortItem) => sortItem.field in sortFieldMap)
666
+ .map((sortItem) => {
667
+ const sortField = sortFieldMap[sortItem.field];
668
+ if (sortItem.order.toUpperCase() === 'ASC') {
669
+ return asc(sortField);
670
+ }
671
+ else {
672
+ return desc(sortField);
673
+ }
674
+ }))
675
+ .limit(pageSize)
676
+ .offset((page - 1) * pageSize);
677
+ return {
678
+ jobs,
679
+ total,
680
+ page,
681
+ pageSize,
682
+ };
683
+ }
684
+ async _getJobStepById(stepId) {
685
+ const [step] = await this.db
686
+ .select({
687
+ id: this.tables.jobStepsTable.id,
688
+ jobId: this.tables.jobStepsTable.job_id,
689
+ name: this.tables.jobStepsTable.name,
690
+ output: this.tables.jobStepsTable.output,
691
+ status: this.tables.jobStepsTable.status,
692
+ error: this.tables.jobStepsTable.error,
693
+ startedAt: this.tables.jobStepsTable.started_at,
694
+ finishedAt: this.tables.jobStepsTable.finished_at,
695
+ timeoutMs: this.tables.jobStepsTable.timeout_ms,
696
+ expiresAt: this.tables.jobStepsTable.expires_at,
697
+ retriesLimit: this.tables.jobStepsTable.retries_limit,
698
+ retriesCount: this.tables.jobStepsTable.retries_count,
699
+ delayedMs: this.tables.jobStepsTable.delayed_ms,
700
+ historyFailedAttempts: this.tables.jobStepsTable.history_failed_attempts,
701
+ createdAt: this.tables.jobStepsTable.created_at,
702
+ updatedAt: this.tables.jobStepsTable.updated_at,
703
+ })
704
+ .from(this.tables.jobStepsTable)
705
+ .where(eq(this.tables.jobStepsTable.id, stepId))
706
+ .limit(1);
707
+ return step ?? null;
708
+ }
709
+ async _getJobStatus(jobId) {
710
+ const [job] = await this.db
711
+ .select({
712
+ status: this.tables.jobsTable.status,
713
+ updatedAt: this.tables.jobsTable.updated_at,
714
+ })
715
+ .from(this.tables.jobsTable)
716
+ .where(eq(this.tables.jobsTable.id, jobId))
717
+ .limit(1);
718
+ return job ?? null;
719
+ }
720
+ async _getJobStepStatus(stepId) {
721
+ const [step] = await this.db
722
+ .select({
723
+ status: this.tables.jobStepsTable.status,
724
+ updatedAt: this.tables.jobStepsTable.updated_at,
725
+ })
726
+ .from(this.tables.jobStepsTable)
727
+ .where(eq(this.tables.jobStepsTable.id, stepId))
728
+ .limit(1);
729
+ return step ?? null;
730
+ }
731
+ async _getActions() {
732
+ const actionStats = this.db.$with('action_stats').as(this.db
733
+ .select({
734
+ name: this.tables.jobsTable.action_name,
735
+ last_job_created: sql `MAX(${this.tables.jobsTable.created_at})`.as('last_job_created'),
736
+ active: sql `COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_ACTIVE})`.as('active'),
737
+ completed: sql `COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_COMPLETED})`.as('completed'),
738
+ failed: sql `COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_FAILED})`.as('failed'),
739
+ cancelled: sql `COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_CANCELLED})`.as('cancelled'),
740
+ })
741
+ .from(this.tables.jobsTable)
742
+ .groupBy(this.tables.jobsTable.action_name));
743
+ const actions = await this.db
744
+ .with(actionStats)
745
+ .select({
746
+ name: actionStats.name,
747
+ lastJobCreated: actionStats.last_job_created,
748
+ active: sql `${actionStats.active}::int`,
749
+ completed: sql `${actionStats.completed}::int`,
750
+ failed: sql `${actionStats.failed}::int`,
751
+ cancelled: sql `${actionStats.cancelled}::int`,
752
+ })
753
+ .from(actionStats)
754
+ .orderBy(actionStats.name);
755
+ return {
756
+ actions: actions.map((action) => ({
757
+ ...action,
758
+ lastJobCreated: action.lastJobCreated ?? null,
759
+ })),
760
+ };
761
+ }
762
+ #buildJsonbWhereConditions(filter, jsonbColumn) {
763
+ const conditions = [];
764
+ for (const [key, value] of Object.entries(filter)) {
765
+ const parts = key.split('.').filter((p) => p.length > 0);
766
+ if (parts.length === 0) {
767
+ continue;
768
+ }
769
+ let jsonbPath = sql `${jsonbColumn}`;
770
+ if (parts.length === 1) {
771
+ jsonbPath = sql `${jsonbPath} ->> ${parts[0]}`;
772
+ }
773
+ else {
774
+ for (let i = 0; i < parts.length - 1; i++) {
775
+ const part = parts[i];
776
+ if (part) {
777
+ jsonbPath = sql `${jsonbPath} -> ${part}`;
778
+ }
779
+ }
780
+ const lastPart = parts[parts.length - 1];
781
+ if (lastPart) {
782
+ jsonbPath = sql `${jsonbPath} ->> ${lastPart}`;
783
+ }
784
+ }
785
+ if (Array.isArray(value)) {
786
+ const arrayValueConditions = value.map((arrayValue) => {
787
+ const arrayValueStr = String(arrayValue);
788
+ let arrayPath = sql `${jsonbColumn}`;
789
+ for (let i = 0; i < parts.length - 1; i++) {
790
+ const part = parts[i];
791
+ if (part) {
792
+ arrayPath = sql `${arrayPath} -> ${part}`;
793
+ }
794
+ }
795
+ const lastPart = parts[parts.length - 1];
796
+ if (lastPart) {
797
+ arrayPath = sql `${arrayPath} -> ${lastPart}`;
798
+ }
799
+ if (typeof arrayValue === 'string') {
800
+ return sql `EXISTS (
801
+ SELECT 1
802
+ FROM jsonb_array_elements_text(${arrayPath}) AS elem
803
+ WHERE LOWER(elem) ILIKE LOWER(${`%${arrayValueStr}%`})
804
+ )`;
805
+ }
806
+ else {
807
+ return sql `${arrayPath} @> ${sql.raw(JSON.stringify([arrayValue]))}::jsonb`;
808
+ }
809
+ });
810
+ if (arrayValueConditions.length > 0) {
811
+ conditions.push(arrayValueConditions.reduce((acc, condition, idx) => (idx === 0 ? condition : sql `${acc} OR ${condition}`)));
812
+ }
813
+ }
814
+ else if (typeof value === 'string') {
815
+ conditions.push(sql `COALESCE(${jsonbPath}, '') ILIKE ${`%${value}%`}`);
816
+ }
817
+ else {
818
+ conditions.push(sql `${jsonbPath}::text = ${String(value)}`);
819
+ }
820
+ }
821
+ return conditions;
822
+ }
823
+ async _notify(_event, _data) {
824
+ }
825
+ async _listen(_event, _callback) {
826
+ return {
827
+ unlisten: () => {
828
+ },
829
+ };
830
+ }
831
+ _map(result) {
832
+ return result;
833
+ }
834
+ }