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