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,1299 @@
1
+ import { and, asc, between, desc, eq, gt, gte, ilike, inArray, isNull, ne, or, sql } from 'drizzle-orm'
2
+ import type { PgColumn, PgDatabase } from 'drizzle-orm/pg-core'
3
+
4
+ import {
5
+ JOB_STATUS_ACTIVE,
6
+ JOB_STATUS_CANCELLED,
7
+ JOB_STATUS_COMPLETED,
8
+ JOB_STATUS_CREATED,
9
+ JOB_STATUS_FAILED,
10
+ STEP_STATUS_ACTIVE,
11
+ STEP_STATUS_CANCELLED,
12
+ STEP_STATUS_COMPLETED,
13
+ STEP_STATUS_FAILED,
14
+ } from '../../constants.js'
15
+ import {
16
+ Adapter,
17
+ type CancelJobOptions,
18
+ type CancelJobStepOptions,
19
+ type CompleteJobOptions,
20
+ type CompleteJobStepOptions,
21
+ type CreateJobOptions,
22
+ type CreateOrRecoverJobStepOptions,
23
+ type CreateOrRecoverJobStepResult,
24
+ type DelayJobStepOptions,
25
+ type DeleteJobOptions,
26
+ type DeleteJobsOptions,
27
+ type FailJobOptions,
28
+ type FailJobStepOptions,
29
+ type FetchOptions,
30
+ type GetActionsResult,
31
+ type GetJobStepsOptions,
32
+ type GetJobStepsResult,
33
+ type GetJobsOptions,
34
+ type GetJobsResult,
35
+ type Job,
36
+ type JobSort,
37
+ type JobStatusResult,
38
+ type JobStep,
39
+ type JobStepStatusResult,
40
+ type RecoverJobsOptions,
41
+ type RetryJobOptions,
42
+ } from '../adapter.js'
43
+ import createSchema from './schema.js'
44
+
45
+ type Schema = ReturnType<typeof createSchema>
46
+
47
+ // Re-export types for backward compatibility
48
+ export type { Job, JobStep } from '../adapter.js'
49
+
50
+ type DrizzleDatabase = PgDatabase<any, Schema>
51
+
52
+ export interface AdapterOptions<Connection> {
53
+ connection: Connection
54
+ schema?: string
55
+ migrateOnStart?: boolean
56
+ migrationsFolder?: string
57
+ }
58
+
59
+ export class PostgresBaseAdapter<Database extends DrizzleDatabase, Connection> extends Adapter {
60
+ protected connection: Connection
61
+ protected db!: Database
62
+ protected tables: Schema
63
+ protected schema: string = 'duron'
64
+ protected migrateOnStart: boolean = true
65
+
66
+ // ============================================================================
67
+ // Constructor
68
+ // ============================================================================
69
+
70
+ /**
71
+ * Create a new PostgresAdapter instance.
72
+ *
73
+ * @param options - Configuration options for the PostgreSQL adapter
74
+ */
75
+ constructor(options: AdapterOptions<Connection>) {
76
+ super()
77
+
78
+ this.connection = options.connection
79
+ this.schema = options.schema ?? 'duron'
80
+ this.migrateOnStart = options.migrateOnStart ?? true
81
+
82
+ this.tables = createSchema(this.schema)
83
+
84
+ this._initDb()
85
+ }
86
+
87
+ /**
88
+ * Initialize the database connection and Drizzle instance.
89
+ */
90
+ protected _initDb() {
91
+ throw new Error('Not implemented')
92
+ }
93
+
94
+ // ============================================================================
95
+ // Lifecycle Methods
96
+ // ============================================================================
97
+
98
+ /**
99
+ * Start the adapter.
100
+ * Runs migrations if enabled and sets up database listeners.
101
+ *
102
+ * @returns Promise resolving to `true` if started successfully, `false` otherwise
103
+ */
104
+ protected async _start() {
105
+ await this._listen(`ping-${this.id}`, async (payload: string) => {
106
+ const fromClientId = JSON.parse(payload).fromClientId
107
+ await this._notify(`pong-${fromClientId}`, { toClientId: this.id })
108
+ })
109
+
110
+ await this._listen(`job-status-changed`, (payload: string) => {
111
+ if (this.listenerCount('job-status-changed') > 0) {
112
+ const { jobId, status, clientId } = JSON.parse(payload)
113
+ this.emit('job-status-changed', { jobId, status, clientId })
114
+ }
115
+ })
116
+
117
+ await this._listen(`job-available`, (payload: string) => {
118
+ if (this.listenerCount('job-available') > 0) {
119
+ const { jobId } = JSON.parse(payload)
120
+ this.emit('job-available', { jobId })
121
+ }
122
+ })
123
+ }
124
+
125
+ protected async _stop() {
126
+ // do nothing
127
+ }
128
+
129
+ // ============================================================================
130
+ // Job Methods
131
+ // ============================================================================
132
+
133
+ /**
134
+ * Internal method to create a new job in the database.
135
+ *
136
+ * @returns Promise resolving to the job ID, or `null` if creation failed
137
+ */
138
+ protected async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit }: CreateJobOptions) {
139
+ const [result] = await this.db
140
+ .insert(this.tables.jobsTable)
141
+ .values({
142
+ action_name: queue,
143
+ group_key: groupKey,
144
+ checksum,
145
+ input,
146
+ status: JOB_STATUS_CREATED,
147
+ timeout_ms: timeoutMs,
148
+ concurrency_limit: concurrencyLimit,
149
+ })
150
+ .returning({ id: this.tables.jobsTable.id })
151
+
152
+ if (!result) {
153
+ return null
154
+ }
155
+
156
+ return result.id
157
+ }
158
+
159
+ /**
160
+ * Internal method to mark a job as completed.
161
+ *
162
+ * @returns Promise resolving to `true` if completed, `false` otherwise
163
+ */
164
+ protected async _completeJob({ jobId, output }: CompleteJobOptions) {
165
+ const result = await this.db
166
+ .update(this.tables.jobsTable)
167
+ .set({
168
+ status: JOB_STATUS_COMPLETED,
169
+ output,
170
+ finished_at: sql`now()`,
171
+ })
172
+ .where(
173
+ and(
174
+ eq(this.tables.jobsTable.id, jobId),
175
+ eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
176
+ eq(this.tables.jobsTable.client_id, this.id),
177
+ gt(this.tables.jobsTable.expires_at, sql`now()`),
178
+ ),
179
+ )
180
+ .returning({ id: this.tables.jobsTable.id })
181
+
182
+ return result.length > 0
183
+ }
184
+
185
+ /**
186
+ * Internal method to mark a job as failed.
187
+ *
188
+ * @returns Promise resolving to `true` if failed, `false` otherwise
189
+ */
190
+ protected async _failJob({ jobId, error }: FailJobOptions) {
191
+ const result = await this.db
192
+ .update(this.tables.jobsTable)
193
+ .set({
194
+ status: JOB_STATUS_FAILED,
195
+ error,
196
+ finished_at: sql`now()`,
197
+ })
198
+ .where(
199
+ and(
200
+ eq(this.tables.jobsTable.id, jobId),
201
+ eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
202
+ eq(this.tables.jobsTable.client_id, this.id),
203
+ ),
204
+ )
205
+ .returning({ id: this.tables.jobsTable.id })
206
+
207
+ return result.length > 0
208
+ }
209
+
210
+ /**
211
+ * Internal method to cancel a job.
212
+ *
213
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
214
+ */
215
+ protected async _cancelJob({ jobId }: CancelJobOptions) {
216
+ const result = await this.db
217
+ .update(this.tables.jobsTable)
218
+ .set({
219
+ status: JOB_STATUS_CANCELLED,
220
+ finished_at: sql`now()`,
221
+ })
222
+ .where(
223
+ and(
224
+ eq(this.tables.jobsTable.id, jobId),
225
+ or(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), eq(this.tables.jobsTable.status, JOB_STATUS_CREATED)),
226
+ ),
227
+ )
228
+ .returning({ id: this.tables.jobsTable.id })
229
+
230
+ return result.length > 0
231
+ }
232
+
233
+ /**
234
+ * Internal method to retry a completed, cancelled, or failed job by creating a copy of it with status 'created' and cleared output/error.
235
+ * Uses SELECT FOR UPDATE to prevent concurrent retries from creating duplicate jobs.
236
+ *
237
+ * @returns Promise resolving to the job ID, or `null` if creation failed
238
+ */
239
+ protected async _retryJob({ jobId }: RetryJobOptions): Promise<string | null> {
240
+ // Use a single atomic query with FOR UPDATE lock to prevent race conditions
241
+ const result = this._map(
242
+ await this.db.execute<{ id: string }>(sql`
243
+ WITH locked_source AS (
244
+ -- Lock the source job row to prevent concurrent retries
245
+ SELECT
246
+ j.action_name,
247
+ j.group_key,
248
+ j.checksum,
249
+ j.input,
250
+ j.timeout_ms,
251
+ j.created_at,
252
+ j.concurrency_limit
253
+ FROM ${this.tables.jobsTable} j
254
+ WHERE j.id = ${jobId}
255
+ AND j.status IN (${JOB_STATUS_COMPLETED}, ${JOB_STATUS_CANCELLED}, ${JOB_STATUS_FAILED})
256
+ FOR UPDATE OF j SKIP LOCKED
257
+ ),
258
+ existing_retry AS (
259
+ -- Check if a retry already exists (a newer job with same checksum, group_key, and input)
260
+ SELECT j.id
261
+ FROM ${this.tables.jobsTable} j
262
+ INNER JOIN locked_source ls
263
+ ON j.action_name = ls.action_name
264
+ AND j.group_key = ls.group_key
265
+ AND j.checksum = ls.checksum
266
+ AND j.input = ls.input
267
+ AND j.created_at > ls.created_at
268
+ WHERE j.status IN (${JOB_STATUS_CREATED}, ${JOB_STATUS_ACTIVE})
269
+ LIMIT 1
270
+ ),
271
+ inserted_retry AS (
272
+ -- Insert the retry only if no existing retry was found
273
+ -- Get concurrency_limit from the latest job at insertion time to avoid stale values
274
+ INSERT INTO ${this.tables.jobsTable} (
275
+ action_name,
276
+ group_key,
277
+ checksum,
278
+ input,
279
+ status,
280
+ timeout_ms,
281
+ concurrency_limit
282
+ )
283
+ SELECT
284
+ ls.action_name,
285
+ ls.group_key,
286
+ ls.checksum,
287
+ ls.input,
288
+ ${JOB_STATUS_CREATED},
289
+ ls.timeout_ms,
290
+ COALESCE(
291
+ (
292
+ SELECT j.concurrency_limit
293
+ FROM ${this.tables.jobsTable} j
294
+ WHERE j.action_name = ls.action_name
295
+ AND j.group_key = ls.group_key
296
+ AND (j.expires_at IS NULL OR j.expires_at > now())
297
+ ORDER BY j.created_at DESC, j.id DESC
298
+ LIMIT 1
299
+ ),
300
+ ls.concurrency_limit
301
+ )
302
+ FROM locked_source ls
303
+ WHERE NOT EXISTS (SELECT 1 FROM existing_retry)
304
+ RETURNING id
305
+ )
306
+ -- Return only the newly inserted retry ID (not existing retries)
307
+ SELECT id FROM inserted_retry
308
+ LIMIT 1
309
+ `),
310
+ )
311
+
312
+ if (result.length === 0) {
313
+ return null
314
+ }
315
+
316
+ return result[0]!.id
317
+ }
318
+
319
+ /**
320
+ * Internal method to delete a job by its ID.
321
+ * Active jobs cannot be deleted.
322
+ *
323
+ * @returns Promise resolving to `true` if deleted, `false` otherwise
324
+ */
325
+ protected async _deleteJob({ jobId }: DeleteJobOptions): Promise<boolean> {
326
+ const result = await this.db
327
+ .delete(this.tables.jobsTable)
328
+ .where(and(eq(this.tables.jobsTable.id, jobId), ne(this.tables.jobsTable.status, JOB_STATUS_ACTIVE)))
329
+ .returning({ id: this.tables.jobsTable.id })
330
+
331
+ // Also delete associated steps
332
+ if (result.length > 0) {
333
+ await this.db.delete(this.tables.jobStepsTable).where(eq(this.tables.jobStepsTable.job_id, jobId))
334
+ }
335
+
336
+ return result.length > 0
337
+ }
338
+
339
+ /**
340
+ * Internal method to delete multiple jobs using the same filters as getJobs.
341
+ * Active jobs cannot be deleted and will be excluded from deletion.
342
+ *
343
+ * @returns Promise resolving to the number of jobs deleted
344
+ */
345
+ protected async _deleteJobs(options?: DeleteJobsOptions): Promise<number> {
346
+ const jobsTable = this.tables.jobsTable
347
+ const filters = options?.filters ?? {}
348
+
349
+ const where = this._buildJobsWhereClause(filters)
350
+
351
+ const result = await this.db.delete(jobsTable).where(where).returning({ id: jobsTable.id })
352
+
353
+ return result.length
354
+ }
355
+
356
+ /**
357
+ * Internal method to fetch jobs from the database respecting concurrency limits per group.
358
+ * Uses the concurrency limit from the latest job created for each groupKey.
359
+ * Uses advisory locks to ensure thread-safe job fetching.
360
+ *
361
+ * @returns Promise resolving to an array of fetched jobs
362
+ */
363
+ protected async _fetch({ batch }: FetchOptions) {
364
+ const result = this._map(
365
+ await this.db.execute<Job>(sql`
366
+ WITH group_concurrency AS (
367
+ -- Get the concurrency limit from the latest job for each group
368
+ SELECT DISTINCT ON (j.group_key, j.action_name)
369
+ j.group_key as group_key,
370
+ j.action_name as action_name,
371
+ j.concurrency_limit as concurrency_limit
372
+ FROM ${this.tables.jobsTable} j
373
+ WHERE j.group_key IS NOT NULL
374
+ AND (j.expires_at IS NULL OR j.expires_at > now())
375
+ ORDER BY j.group_key, j.action_name, j.created_at DESC, j.id DESC
376
+ ),
377
+ eligible_groups AS (
378
+ -- Find all groups with their active counts that are below their concurrency limit
379
+ SELECT
380
+ gc.group_key,
381
+ gc.action_name,
382
+ gc.concurrency_limit,
383
+ COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) as active_count
384
+ FROM group_concurrency gc
385
+ LEFT JOIN ${this.tables.jobsTable} j
386
+ ON j.group_key = gc.group_key
387
+ AND j.action_name = gc.action_name
388
+ AND (j.expires_at IS NULL OR j.expires_at > now())
389
+ GROUP BY gc.group_key, gc.action_name, gc.concurrency_limit
390
+ HAVING COUNT(*) FILTER (WHERE j.status = ${JOB_STATUS_ACTIVE}) < gc.concurrency_limit
391
+ ),
392
+ candidate_jobs AS (
393
+ -- Lock candidate jobs first (before applying window functions)
394
+ SELECT
395
+ j.id,
396
+ j.action_name,
397
+ j.group_key as job_group_key,
398
+ j.created_at
399
+ FROM ${this.tables.jobsTable} j
400
+ INNER JOIN eligible_groups eg
401
+ ON j.group_key = eg.group_key
402
+ AND j.action_name = eg.action_name
403
+ WHERE j.status = ${JOB_STATUS_CREATED}
404
+ FOR UPDATE OF j SKIP LOCKED
405
+ ),
406
+ ranked_jobs AS (
407
+ -- Rank jobs within each group after locking
408
+ SELECT
409
+ cj.id,
410
+ cj.action_name,
411
+ cj.job_group_key,
412
+ cj.created_at,
413
+ ROW_NUMBER() OVER (
414
+ PARTITION BY cj.job_group_key, cj.action_name
415
+ ORDER BY cj.created_at ASC, cj.id ASC
416
+ ) as job_rank
417
+ FROM candidate_jobs cj
418
+ ),
419
+ next_job AS (
420
+ -- Select only jobs that fit within the concurrency limit per group
421
+ -- Ordered globally by created_at to respect job creation order
422
+ SELECT rj.id, rj.action_name, rj.job_group_key
423
+ FROM ranked_jobs rj
424
+ INNER JOIN eligible_groups eg
425
+ ON rj.job_group_key = eg.group_key
426
+ AND rj.action_name = eg.action_name
427
+ WHERE rj.job_rank <= (eg.concurrency_limit - eg.active_count)
428
+ ORDER BY rj.created_at ASC, rj.id ASC
429
+ LIMIT ${batch}
430
+ ),
431
+ verify_concurrency AS (
432
+ -- Double-check concurrency limit after acquiring lock
433
+ SELECT
434
+ nj.id,
435
+ nj.action_name,
436
+ nj.job_group_key,
437
+ eg.concurrency_limit,
438
+ (SELECT COUNT(*)
439
+ FROM ${this.tables.jobsTable}
440
+ WHERE action_name = nj.action_name
441
+ AND group_key = nj.job_group_key
442
+ AND status = ${JOB_STATUS_ACTIVE}) as current_active
443
+ FROM next_job nj
444
+ INNER JOIN eligible_groups eg
445
+ ON nj.job_group_key = eg.group_key
446
+ AND nj.action_name = eg.action_name
447
+ )
448
+ UPDATE ${this.tables.jobsTable} j
449
+ SET status = ${JOB_STATUS_ACTIVE},
450
+ started_at = now(),
451
+ expires_at = now() + (timeout_ms || ' milliseconds')::interval,
452
+ client_id = ${this.id}
453
+ FROM verify_concurrency vc
454
+ WHERE j.id = vc.id
455
+ AND vc.current_active < vc.concurrency_limit -- Final concurrency check using job's concurrency limit
456
+ RETURNING
457
+ j.id,
458
+ j.action_name as "actionName",
459
+ j.group_key as "groupKey",
460
+ j.input,
461
+ j.output,
462
+ j.error,
463
+ j.status,
464
+ j.timeout_ms as "timeoutMs",
465
+ j.expires_at as "expiresAt",
466
+ j.started_at as "startedAt",
467
+ j.finished_at as "finishedAt",
468
+ j.created_at as "createdAt",
469
+ j.updated_at as "updatedAt",
470
+ j.concurrency_limit as "concurrencyLimit"
471
+ `),
472
+ )
473
+
474
+ return result
475
+ }
476
+
477
+ /**
478
+ * Internal method to recover stuck jobs (jobs that were active but the process that owned them is no longer running).
479
+ * In multi-process mode, pings other processes to check if they're alive before recovering their jobs.
480
+ *
481
+ * @returns Promise resolving to the number of jobs recovered
482
+ */
483
+ protected async _recoverJobs(options: RecoverJobsOptions): Promise<number> {
484
+ const { checksums, multiProcessMode = false, processTimeout = 5_000 } = options
485
+
486
+ const unresponsiveClientIds: string[] = [this.id]
487
+
488
+ if (multiProcessMode) {
489
+ const result = (await this.db
490
+ .selectDistinct({
491
+ clientId: this.tables.jobsTable.client_id,
492
+ })
493
+ .from(this.tables.jobsTable)
494
+ .where(
495
+ and(eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE), ne(this.tables.jobsTable.client_id, this.id)),
496
+ )) as unknown as { clientId: string }[]
497
+
498
+ if (result.length > 0) {
499
+ const pongCount = new Set<string>()
500
+ const { unlisten } = await this._listen(`pong-${this.id}`, (payload: string) => {
501
+ const toClientId = JSON.parse(payload).toClientId
502
+ pongCount.add(toClientId)
503
+ if (pongCount.size >= result.length) {
504
+ unlisten()
505
+ }
506
+ })
507
+
508
+ await Promise.all(result.map((row) => this._notify(`ping-${row.clientId}`, { fromClientId: this.id })))
509
+
510
+ let waitForSeconds = processTimeout / 1_000
511
+ while (pongCount.size < result.length && waitForSeconds > 0) {
512
+ await new Promise((resolve) => setTimeout(resolve, 1000).unref?.())
513
+ waitForSeconds--
514
+ }
515
+
516
+ unresponsiveClientIds.push(...result.filter((row) => !pongCount.has(row.clientId)).map((row) => row.clientId))
517
+ }
518
+ }
519
+
520
+ if (unresponsiveClientIds.length > 0) {
521
+ const result = this._map(
522
+ await this.db.execute<{ id: string }>(sql`
523
+ WITH locked_jobs AS (
524
+ SELECT j.id
525
+ FROM ${this.tables.jobsTable} j
526
+ WHERE j.status = ${JOB_STATUS_ACTIVE}
527
+ AND j.client_id IN ${unresponsiveClientIds}
528
+ FOR UPDATE OF j SKIP LOCKED
529
+ ),
530
+ updated_jobs AS (
531
+ UPDATE ${this.tables.jobsTable} j
532
+ SET status = ${JOB_STATUS_CREATED},
533
+ started_at = NULL,
534
+ expires_at = NULL,
535
+ finished_at = NULL,
536
+ output = NULL,
537
+ error = NULL
538
+ WHERE EXISTS (SELECT 1 FROM locked_jobs lj WHERE lj.id = j.id)
539
+ RETURNING id, checksum
540
+ ),
541
+ deleted_steps AS (
542
+ DELETE FROM ${this.tables.jobStepsTable} s
543
+ WHERE EXISTS (
544
+ SELECT 1 FROM updated_jobs uj
545
+ WHERE uj.id = s.job_id
546
+ AND uj.checksum NOT IN ${checksums}
547
+ )
548
+ )
549
+ SELECT id FROM updated_jobs
550
+ `),
551
+ )
552
+
553
+ return result.length
554
+ }
555
+
556
+ return 0
557
+ }
558
+
559
+ // ============================================================================
560
+ // Step Methods
561
+ // ============================================================================
562
+
563
+ /**
564
+ * Internal method to create or recover a job step by creating or resetting a step record in the database.
565
+ *
566
+ * @returns Promise resolving to the step, or `null` if creation failed
567
+ */
568
+ protected async _createOrRecoverJobStep({
569
+ jobId,
570
+ name,
571
+ timeoutMs,
572
+ retriesLimit,
573
+ }: CreateOrRecoverJobStepOptions): Promise<CreateOrRecoverJobStepResult | null> {
574
+ type StepResult = CreateOrRecoverJobStepResult
575
+
576
+ const [result] = this._map(
577
+ await this.db.execute<StepResult>(sql`
578
+ WITH job_check AS (
579
+ SELECT j.id
580
+ FROM ${this.tables.jobsTable} j
581
+ WHERE j.id = ${jobId}
582
+ AND j.status = ${JOB_STATUS_ACTIVE}
583
+ AND (j.expires_at IS NULL OR j.expires_at > now())
584
+ ),
585
+ step_existed AS (
586
+ SELECT EXISTS(
587
+ SELECT 1 FROM ${this.tables.jobStepsTable} s
588
+ WHERE s.job_id = ${jobId} AND s.name = ${name}
589
+ ) AS existed
590
+ ),
591
+ upserted_step AS (
592
+ INSERT INTO ${this.tables.jobStepsTable} (
593
+ job_id,
594
+ name,
595
+ timeout_ms,
596
+ retries_limit,
597
+ status,
598
+ started_at,
599
+ expires_at,
600
+ retries_count,
601
+ delayed_ms
602
+ )
603
+ SELECT
604
+ ${jobId},
605
+ ${name},
606
+ ${timeoutMs},
607
+ ${retriesLimit},
608
+ ${STEP_STATUS_ACTIVE},
609
+ now(),
610
+ now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
611
+ 0,
612
+ NULL
613
+ WHERE EXISTS (SELECT 1 FROM job_check)
614
+ ON CONFLICT (job_id, name) DO UPDATE
615
+ SET
616
+ timeout_ms = ${timeoutMs},
617
+ expires_at = now() + interval '${sql.raw(timeoutMs.toString())} milliseconds',
618
+ retries_count = 0,
619
+ retries_limit = ${retriesLimit},
620
+ delayed_ms = NULL,
621
+ started_at = now(),
622
+ history_failed_attempts = '{}'::jsonb
623
+ WHERE ${this.tables.jobStepsTable}.status = ${STEP_STATUS_ACTIVE}
624
+ RETURNING
625
+ id,
626
+ status,
627
+ retries_limit AS "retriesLimit",
628
+ retries_count AS "retriesCount",
629
+ timeout_ms AS "timeoutMs",
630
+ error,
631
+ output
632
+ ),
633
+ final_upserted AS (
634
+ SELECT
635
+ us.*,
636
+ CASE WHEN se.existed THEN false ELSE true END AS "isNew"
637
+ FROM upserted_step us
638
+ CROSS JOIN step_existed se
639
+ ),
640
+ existing_step AS (
641
+ SELECT
642
+ s.id,
643
+ s.status,
644
+ s.retries_limit AS "retriesLimit",
645
+ s.retries_count AS "retriesCount",
646
+ s.timeout_ms AS "timeoutMs",
647
+ s.error,
648
+ s.output,
649
+ false AS "isNew"
650
+ FROM ${this.tables.jobStepsTable} s
651
+ INNER JOIN job_check jc ON s.job_id = jc.id
652
+ WHERE s.job_id = ${jobId}
653
+ AND s.name = ${name}
654
+ AND NOT EXISTS (SELECT 1 FROM final_upserted)
655
+ )
656
+ SELECT * FROM final_upserted
657
+ UNION ALL
658
+ SELECT * FROM existing_step
659
+ `),
660
+ )
661
+
662
+ if (!result) {
663
+ this.logger?.error({ jobId }, `[PostgresAdapter] Job ${jobId} is not active or has expired`)
664
+ return null
665
+ }
666
+
667
+ return result
668
+ }
669
+
670
+ /**
671
+ * Internal method to mark a job step as completed.
672
+ *
673
+ * @returns Promise resolving to `true` if completed, `false` otherwise
674
+ */
675
+ protected async _completeJobStep({ stepId, output }: CompleteJobStepOptions) {
676
+ const result = await this.db
677
+ .update(this.tables.jobStepsTable)
678
+ .set({
679
+ status: STEP_STATUS_COMPLETED,
680
+ output,
681
+ finished_at: sql`now()`,
682
+ })
683
+ .from(this.tables.jobsTable)
684
+ .where(
685
+ and(
686
+ eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
687
+ eq(this.tables.jobStepsTable.id, stepId),
688
+ eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
689
+ eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
690
+ or(isNull(this.tables.jobsTable.expires_at), gt(this.tables.jobsTable.expires_at, sql`now()`)),
691
+ ),
692
+ )
693
+ .returning({ id: this.tables.jobStepsTable.id })
694
+
695
+ return result.length > 0
696
+ }
697
+
698
+ /**
699
+ * Internal method to mark a job step as failed.
700
+ *
701
+ * @returns Promise resolving to `true` if failed, `false` otherwise
702
+ */
703
+ protected async _failJobStep({ stepId, error }: FailJobStepOptions) {
704
+ const result = await this.db
705
+ .update(this.tables.jobStepsTable)
706
+ .set({
707
+ status: STEP_STATUS_FAILED,
708
+ error,
709
+ finished_at: sql`now()`,
710
+ })
711
+ .from(this.tables.jobsTable)
712
+ .where(
713
+ and(
714
+ eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
715
+ eq(this.tables.jobStepsTable.id, stepId),
716
+ eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
717
+ eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
718
+ ),
719
+ )
720
+ .returning({ id: this.tables.jobStepsTable.id })
721
+
722
+ return result.length > 0
723
+ }
724
+
725
+ /**
726
+ * Internal method to delay a job step.
727
+ *
728
+ * @returns Promise resolving to `true` if delayed, `false` otherwise
729
+ */
730
+ protected async _delayJobStep({ stepId, delayMs, error }: DelayJobStepOptions) {
731
+ const jobStepsTable = this.tables.jobStepsTable
732
+ const jobsTable = this.tables.jobsTable
733
+
734
+ const result = await this.db
735
+ .update(jobStepsTable)
736
+ .set({
737
+ delayed_ms: delayMs,
738
+ retries_count: sql`${jobStepsTable.retries_count} + 1`,
739
+ expires_at: sql`now() + (${jobStepsTable.timeout_ms} || ' milliseconds')::interval + (${delayMs} || ' milliseconds')::interval`,
740
+ history_failed_attempts: sql`COALESCE(${jobStepsTable.history_failed_attempts}, '{}'::jsonb) || jsonb_build_object(
741
+ extract(epoch from now())::text,
742
+ jsonb_build_object(
743
+ 'failedAt', now(),
744
+ 'error', ${JSON.stringify(error)}::jsonb,
745
+ 'delayedMs', ${delayMs}::integer
746
+ )
747
+ )`,
748
+ })
749
+ .from(jobsTable)
750
+ .where(
751
+ and(
752
+ eq(jobStepsTable.job_id, jobsTable.id),
753
+ eq(jobStepsTable.id, stepId),
754
+ eq(jobStepsTable.status, STEP_STATUS_ACTIVE),
755
+ eq(jobsTable.status, JOB_STATUS_ACTIVE),
756
+ ),
757
+ )
758
+ .returning({ id: jobStepsTable.id })
759
+
760
+ return result.length > 0
761
+ }
762
+
763
+ /**
764
+ * Internal method to cancel a job step.
765
+ *
766
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
767
+ */
768
+ protected async _cancelJobStep({ stepId }: CancelJobStepOptions) {
769
+ const result = await this.db
770
+ .update(this.tables.jobStepsTable)
771
+ .set({
772
+ status: STEP_STATUS_CANCELLED,
773
+ finished_at: sql`now()`,
774
+ })
775
+ .from(this.tables.jobsTable)
776
+ .where(
777
+ and(
778
+ eq(this.tables.jobStepsTable.job_id, this.tables.jobsTable.id),
779
+ eq(this.tables.jobStepsTable.id, stepId),
780
+ eq(this.tables.jobStepsTable.status, STEP_STATUS_ACTIVE),
781
+ or(
782
+ eq(this.tables.jobsTable.status, JOB_STATUS_ACTIVE),
783
+ eq(this.tables.jobsTable.status, JOB_STATUS_CANCELLED),
784
+ ),
785
+ ),
786
+ )
787
+ .returning({ id: this.tables.jobStepsTable.id })
788
+
789
+ return result.length > 0
790
+ }
791
+
792
+ // ============================================================================
793
+ // Query Methods
794
+ // ============================================================================
795
+
796
+ /**
797
+ * Internal method to get a job by its ID. Does not include step information.
798
+ */
799
+ protected async _getJobById(jobId: string): Promise<Job | null> {
800
+ const [job] = await this.db
801
+ .select({
802
+ id: this.tables.jobsTable.id,
803
+ actionName: this.tables.jobsTable.action_name,
804
+ groupKey: this.tables.jobsTable.group_key,
805
+ input: this.tables.jobsTable.input,
806
+ output: this.tables.jobsTable.output,
807
+ error: this.tables.jobsTable.error,
808
+ status: this.tables.jobsTable.status,
809
+ timeoutMs: this.tables.jobsTable.timeout_ms,
810
+ expiresAt: this.tables.jobsTable.expires_at,
811
+ startedAt: this.tables.jobsTable.started_at,
812
+ finishedAt: this.tables.jobsTable.finished_at,
813
+ createdAt: this.tables.jobsTable.created_at,
814
+ updatedAt: this.tables.jobsTable.updated_at,
815
+ concurrencyLimit: this.tables.jobsTable.concurrency_limit,
816
+ clientId: this.tables.jobsTable.client_id,
817
+ })
818
+ .from(this.tables.jobsTable)
819
+ .where(eq(this.tables.jobsTable.id, jobId))
820
+ .limit(1)
821
+
822
+ return job ?? null
823
+ }
824
+
825
+ /**
826
+ * Internal method to get steps for a job with pagination and fuzzy search.
827
+ * Steps are always ordered by created_at ASC.
828
+ * Steps do not include output data.
829
+ */
830
+ protected async _getJobSteps(options: GetJobStepsOptions): Promise<GetJobStepsResult> {
831
+ const { jobId, page = 1, pageSize = 10, search } = options
832
+
833
+ const jobStepsTable = this.tables.jobStepsTable
834
+
835
+ const fuzzySearch = search?.trim()
836
+
837
+ const where = and(
838
+ eq(jobStepsTable.job_id, jobId),
839
+ fuzzySearch && fuzzySearch.length > 0
840
+ ? or(
841
+ ilike(jobStepsTable.name, `%${fuzzySearch}%`),
842
+ sql`to_tsvector('english', ${jobStepsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
843
+ )
844
+ : undefined,
845
+ options.updatedAfter
846
+ ? sql`date_trunc('milliseconds', ${jobStepsTable.updated_at}) > ${options.updatedAfter.toISOString()}::timestamptz`
847
+ : undefined,
848
+ )
849
+
850
+ // Get total count
851
+ const total = await this.db.$count(jobStepsTable, where)
852
+
853
+ if (!total) {
854
+ return {
855
+ steps: [],
856
+ total: 0,
857
+ page,
858
+ pageSize,
859
+ }
860
+ }
861
+
862
+ const steps = await this.db
863
+ .select({
864
+ id: jobStepsTable.id,
865
+ jobId: jobStepsTable.job_id,
866
+ name: jobStepsTable.name,
867
+ status: jobStepsTable.status,
868
+ error: jobStepsTable.error,
869
+ startedAt: jobStepsTable.started_at,
870
+ finishedAt: jobStepsTable.finished_at,
871
+ timeoutMs: jobStepsTable.timeout_ms,
872
+ expiresAt: jobStepsTable.expires_at,
873
+ retriesLimit: jobStepsTable.retries_limit,
874
+ retriesCount: jobStepsTable.retries_count,
875
+ delayedMs: jobStepsTable.delayed_ms,
876
+ historyFailedAttempts: jobStepsTable.history_failed_attempts,
877
+ createdAt: jobStepsTable.created_at,
878
+ updatedAt: jobStepsTable.updated_at,
879
+ })
880
+ .from(jobStepsTable)
881
+ .where(where)
882
+ .orderBy(asc(jobStepsTable.created_at))
883
+ .limit(pageSize)
884
+ .offset((page - 1) * pageSize)
885
+
886
+ return {
887
+ steps,
888
+ total,
889
+ page,
890
+ pageSize,
891
+ }
892
+ }
893
+
894
+ protected _buildJobsWhereClause(filters: GetJobsOptions['filters']) {
895
+ if (!filters) {
896
+ return undefined
897
+ }
898
+
899
+ const jobsTable = this.tables.jobsTable
900
+
901
+ const fuzzySearch = filters.search?.trim()
902
+
903
+ // Build WHERE clause parts using postgres template literals
904
+ return and(
905
+ filters.status
906
+ ? inArray(jobsTable.status, Array.isArray(filters.status) ? filters.status : [filters.status])
907
+ : undefined,
908
+ filters.actionName
909
+ ? inArray(jobsTable.action_name, Array.isArray(filters.actionName) ? filters.actionName : [filters.actionName])
910
+ : undefined,
911
+ filters.groupKey && Array.isArray(filters.groupKey)
912
+ ? sql`j.group_key LIKE ANY(ARRAY[${sql.raw(filters.groupKey.map((key) => `'${key}'`).join(','))}]::text[])`
913
+ : undefined,
914
+ filters.groupKey && !Array.isArray(filters.groupKey)
915
+ ? ilike(jobsTable.group_key, `%${filters.groupKey}%`)
916
+ : undefined,
917
+ filters.clientId
918
+ ? inArray(jobsTable.client_id, Array.isArray(filters.clientId) ? filters.clientId : [filters.clientId])
919
+ : undefined,
920
+ filters.createdAt && Array.isArray(filters.createdAt)
921
+ ? between(
922
+ sql`date_trunc('second', ${jobsTable.created_at})`,
923
+ filters.createdAt[0]!.toISOString(),
924
+ filters.createdAt[1]!.toISOString(),
925
+ )
926
+ : undefined,
927
+ filters.createdAt && !Array.isArray(filters.createdAt)
928
+ ? gte(sql`date_trunc('second', ${jobsTable.created_at})`, filters.createdAt.toISOString())
929
+ : undefined,
930
+ filters.startedAt && Array.isArray(filters.startedAt)
931
+ ? between(
932
+ sql`date_trunc('second', ${jobsTable.started_at})`,
933
+ filters.startedAt[0]!.toISOString(),
934
+ filters.startedAt[1]!.toISOString(),
935
+ )
936
+ : undefined,
937
+ filters.startedAt && !Array.isArray(filters.startedAt)
938
+ ? gte(sql`date_trunc('second', ${jobsTable.started_at})`, filters.startedAt.toISOString())
939
+ : undefined,
940
+ filters.finishedAt && Array.isArray(filters.finishedAt)
941
+ ? between(
942
+ sql`date_trunc('second', ${jobsTable.finished_at})`,
943
+ filters.finishedAt[0]!.toISOString(),
944
+ filters.finishedAt[1]!.toISOString(),
945
+ )
946
+ : undefined,
947
+ filters.finishedAt && !Array.isArray(filters.finishedAt)
948
+ ? gte(sql`date_trunc('second', ${jobsTable.finished_at})`, filters.finishedAt.toISOString())
949
+ : undefined,
950
+ filters.updatedAfter
951
+ ? sql`date_trunc('milliseconds', ${jobsTable.updated_at}) > ${filters.updatedAfter.toISOString()}::timestamptz`
952
+ : undefined,
953
+ fuzzySearch && fuzzySearch.length > 0
954
+ ? or(
955
+ ilike(jobsTable.action_name, `%${fuzzySearch}%`),
956
+ ilike(jobsTable.group_key, `%${fuzzySearch}%`),
957
+ ilike(jobsTable.client_id, `%${fuzzySearch}%`),
958
+ sql`${jobsTable.id}::text ilike ${`%${fuzzySearch}%`}`,
959
+ sql`to_tsvector('english', ${jobsTable.input}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
960
+ sql`to_tsvector('english', ${jobsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`,
961
+ )
962
+ : undefined,
963
+ ...(filters.inputFilter && Object.keys(filters.inputFilter).length > 0
964
+ ? this.#buildJsonbWhereConditions(filters.inputFilter, jobsTable.input)
965
+ : []),
966
+ ...(filters.outputFilter && Object.keys(filters.outputFilter).length > 0
967
+ ? this.#buildJsonbWhereConditions(filters.outputFilter, jobsTable.output)
968
+ : []),
969
+ )
970
+ }
971
+ /**
972
+ * Internal method to get jobs with pagination, filtering, and sorting.
973
+ * Does not include step information or job output.
974
+ */
975
+ protected async _getJobs(options?: GetJobsOptions): Promise<GetJobsResult> {
976
+ const jobsTable = this.tables.jobsTable
977
+ const page = options?.page ?? 1
978
+ const pageSize = options?.pageSize ?? 10
979
+ const filters = options?.filters ?? {}
980
+
981
+ const sortInput = options?.sort ?? { field: 'startedAt', order: 'desc' }
982
+ const sorts = Array.isArray(sortInput) ? sortInput : [sortInput]
983
+
984
+ const where = this._buildJobsWhereClause(filters)
985
+
986
+ // Get total count
987
+ const total = await this.db.$count(jobsTable, where)
988
+ if (!total) {
989
+ return {
990
+ jobs: [],
991
+ total: 0,
992
+ page,
993
+ pageSize,
994
+ }
995
+ }
996
+
997
+ const sortFieldMap: Record<JobSort['field'], any> = {
998
+ createdAt: jobsTable.created_at,
999
+ startedAt: jobsTable.started_at,
1000
+ finishedAt: jobsTable.finished_at,
1001
+ status: jobsTable.status,
1002
+ actionName: jobsTable.action_name,
1003
+ expiresAt: jobsTable.expires_at,
1004
+ }
1005
+
1006
+ const jobs = await this.db
1007
+ .select({
1008
+ id: jobsTable.id,
1009
+ actionName: jobsTable.action_name,
1010
+ groupKey: jobsTable.group_key,
1011
+ input: jobsTable.input,
1012
+ output: jobsTable.output,
1013
+ error: jobsTable.error,
1014
+ status: jobsTable.status,
1015
+ timeoutMs: jobsTable.timeout_ms,
1016
+ expiresAt: jobsTable.expires_at,
1017
+ startedAt: jobsTable.started_at,
1018
+ finishedAt: jobsTable.finished_at,
1019
+ createdAt: jobsTable.created_at,
1020
+ updatedAt: jobsTable.updated_at,
1021
+ concurrencyLimit: jobsTable.concurrency_limit,
1022
+ clientId: jobsTable.client_id,
1023
+ })
1024
+ .from(jobsTable)
1025
+ .where(where)
1026
+ .orderBy(
1027
+ ...sorts
1028
+ .filter((sortItem) => sortItem.field in sortFieldMap)
1029
+ .map((sortItem) => {
1030
+ const sortField = sortFieldMap[sortItem.field]
1031
+ if (sortItem.order.toUpperCase() === 'ASC') {
1032
+ return asc(sortField)
1033
+ } else {
1034
+ return desc(sortField)
1035
+ }
1036
+ }),
1037
+ )
1038
+ .limit(pageSize)
1039
+ .offset((page - 1) * pageSize)
1040
+
1041
+ return {
1042
+ jobs,
1043
+ total,
1044
+ page,
1045
+ pageSize,
1046
+ }
1047
+ }
1048
+
1049
+ /**
1050
+ * Internal method to get a step by its ID with all information.
1051
+ */
1052
+ protected async _getJobStepById(stepId: string): Promise<JobStep | null> {
1053
+ const [step] = await this.db
1054
+ .select({
1055
+ id: this.tables.jobStepsTable.id,
1056
+ jobId: this.tables.jobStepsTable.job_id,
1057
+ name: this.tables.jobStepsTable.name,
1058
+ output: this.tables.jobStepsTable.output,
1059
+ status: this.tables.jobStepsTable.status,
1060
+ error: this.tables.jobStepsTable.error,
1061
+ startedAt: this.tables.jobStepsTable.started_at,
1062
+ finishedAt: this.tables.jobStepsTable.finished_at,
1063
+ timeoutMs: this.tables.jobStepsTable.timeout_ms,
1064
+ expiresAt: this.tables.jobStepsTable.expires_at,
1065
+ retriesLimit: this.tables.jobStepsTable.retries_limit,
1066
+ retriesCount: this.tables.jobStepsTable.retries_count,
1067
+ delayedMs: this.tables.jobStepsTable.delayed_ms,
1068
+ historyFailedAttempts: this.tables.jobStepsTable.history_failed_attempts,
1069
+ createdAt: this.tables.jobStepsTable.created_at,
1070
+ updatedAt: this.tables.jobStepsTable.updated_at,
1071
+ })
1072
+ .from(this.tables.jobStepsTable)
1073
+ .where(eq(this.tables.jobStepsTable.id, stepId))
1074
+ .limit(1)
1075
+
1076
+ return step ?? null
1077
+ }
1078
+
1079
+ /**
1080
+ * Internal method to get job status and updatedAt timestamp.
1081
+ */
1082
+ protected async _getJobStatus(jobId: string): Promise<JobStatusResult | null> {
1083
+ const [job] = await this.db
1084
+ .select({
1085
+ status: this.tables.jobsTable.status,
1086
+ updatedAt: this.tables.jobsTable.updated_at,
1087
+ })
1088
+ .from(this.tables.jobsTable)
1089
+ .where(eq(this.tables.jobsTable.id, jobId))
1090
+ .limit(1)
1091
+
1092
+ return job ?? null
1093
+ }
1094
+
1095
+ /**
1096
+ * Internal method to get job step status and updatedAt timestamp.
1097
+ */
1098
+ protected async _getJobStepStatus(stepId: string): Promise<JobStepStatusResult | null> {
1099
+ const [step] = await this.db
1100
+ .select({
1101
+ status: this.tables.jobStepsTable.status,
1102
+ updatedAt: this.tables.jobStepsTable.updated_at,
1103
+ })
1104
+ .from(this.tables.jobStepsTable)
1105
+ .where(eq(this.tables.jobStepsTable.id, stepId))
1106
+ .limit(1)
1107
+
1108
+ return step ?? null
1109
+ }
1110
+
1111
+ /**
1112
+ * Internal method to get action statistics including counts and last job created date.
1113
+ */
1114
+ protected async _getActions(): Promise<GetActionsResult> {
1115
+ const actionStats = this.db.$with('action_stats').as(
1116
+ this.db
1117
+ .select({
1118
+ name: this.tables.jobsTable.action_name,
1119
+ last_job_created: sql<Date | null>`MAX(${this.tables.jobsTable.created_at})`.as('last_job_created'),
1120
+ active: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_ACTIVE})`.as(
1121
+ 'active',
1122
+ ),
1123
+ completed: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_COMPLETED})`.as(
1124
+ 'completed',
1125
+ ),
1126
+ failed: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_FAILED})`.as(
1127
+ 'failed',
1128
+ ),
1129
+ cancelled: sql<number>`COUNT(*) FILTER (WHERE ${this.tables.jobsTable.status} = ${JOB_STATUS_CANCELLED})`.as(
1130
+ 'cancelled',
1131
+ ),
1132
+ })
1133
+ .from(this.tables.jobsTable)
1134
+ .groupBy(this.tables.jobsTable.action_name),
1135
+ )
1136
+
1137
+ const actions = await this.db
1138
+ .with(actionStats)
1139
+ .select({
1140
+ name: actionStats.name,
1141
+ lastJobCreated: actionStats.last_job_created,
1142
+ active: sql<number>`${actionStats.active}::int`,
1143
+ completed: sql<number>`${actionStats.completed}::int`,
1144
+ failed: sql<number>`${actionStats.failed}::int`,
1145
+ cancelled: sql<number>`${actionStats.cancelled}::int`,
1146
+ })
1147
+ .from(actionStats)
1148
+ .orderBy(actionStats.name)
1149
+
1150
+ return {
1151
+ actions: actions.map((action) => ({
1152
+ ...action,
1153
+ lastJobCreated: action.lastJobCreated ?? null,
1154
+ })),
1155
+ }
1156
+ }
1157
+
1158
+ // ============================================================================
1159
+ // Private Methods
1160
+ // ============================================================================
1161
+
1162
+ /**
1163
+ * Build WHERE conditions for JSONB filter using individual property checks.
1164
+ * Each property becomes a separate condition using ->> operator and ILIKE for case-insensitive matching.
1165
+ * Supports nested properties via dot notation and arrays.
1166
+ *
1167
+ * Example:
1168
+ * { "email": "tincho@gmail", "address.name": "nicolas", "products": ["chicle"] }
1169
+ * Generates:
1170
+ * input ->> 'email' ILIKE '%tincho@gmail%'
1171
+ * AND input ->> 'address' ->> 'name' ILIKE '%nicolas%'
1172
+ * AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(input -> 'products') AS elem WHERE LOWER(elem) ILIKE LOWER('%chicle%'))
1173
+ *
1174
+ * @param filter - Flat record with dot-notation keys (e.g., { "email": "test", "address.name": "value", "products": ["chicle"] })
1175
+ * @param jsonbColumn - The JSONB column name
1176
+ * @returns Array of SQL conditions
1177
+ */
1178
+ #buildJsonbWhereConditions(filter: Record<string, any>, jsonbColumn: PgColumn): any[] {
1179
+ const conditions: any[] = []
1180
+
1181
+ for (const [key, value] of Object.entries(filter)) {
1182
+ const parts = key.split('.').filter((p) => p.length > 0)
1183
+ if (parts.length === 0) {
1184
+ continue
1185
+ }
1186
+
1187
+ // Build the JSONB path expression step by step
1188
+ // For "address.name": input -> 'address' ->> 'name' (-> for intermediate, ->> for final)
1189
+ // For "email": input ->> 'email' (->> for single level)
1190
+ let jsonbPath = sql`${jsonbColumn}`
1191
+ if (parts.length === 1) {
1192
+ // Single level: use ->> directly
1193
+ jsonbPath = sql`${jsonbPath} ->> ${parts[0]!}`
1194
+ } else {
1195
+ // Nested: use -> for intermediate steps, ->> for final step
1196
+ for (let i = 0; i < parts.length - 1; i++) {
1197
+ const part = parts[i]
1198
+ if (part) {
1199
+ jsonbPath = sql`${jsonbPath} -> ${part}`
1200
+ }
1201
+ }
1202
+ const lastPart = parts[parts.length - 1]
1203
+ if (lastPart) {
1204
+ jsonbPath = sql`${jsonbPath} ->> ${lastPart}`
1205
+ }
1206
+ }
1207
+
1208
+ // Handle array values - check if JSONB array contains at least one of the values
1209
+ if (Array.isArray(value)) {
1210
+ // Build condition: check if any element in the JSONB array matches any value in the filter array
1211
+ const arrayValueConditions = value.map((arrayValue) => {
1212
+ const arrayValueStr = String(arrayValue)
1213
+ // Get the array from JSONB: input -> 'products'
1214
+ let arrayPath = sql`${jsonbColumn}`
1215
+ for (let i = 0; i < parts.length - 1; i++) {
1216
+ const part = parts[i]
1217
+ if (part) {
1218
+ arrayPath = sql`${arrayPath} -> ${part}`
1219
+ }
1220
+ }
1221
+ const lastPart = parts[parts.length - 1]
1222
+ if (lastPart) {
1223
+ arrayPath = sql`${arrayPath} -> ${lastPart}`
1224
+ }
1225
+
1226
+ // Check if the JSONB array contains the value (case-insensitive for strings)
1227
+ if (typeof arrayValue === 'string') {
1228
+ return sql`EXISTS (
1229
+ SELECT 1
1230
+ FROM jsonb_array_elements_text(${arrayPath}) AS elem
1231
+ WHERE LOWER(elem) ILIKE LOWER(${`%${arrayValueStr}%`})
1232
+ )`
1233
+ } else {
1234
+ // For non-string values, use exact containment
1235
+ return sql`${arrayPath} @> ${sql.raw(JSON.stringify([arrayValue]))}::jsonb`
1236
+ }
1237
+ })
1238
+
1239
+ // Combine array conditions with OR (at least one must match)
1240
+ if (arrayValueConditions.length > 0) {
1241
+ conditions.push(
1242
+ arrayValueConditions.reduce((acc, condition, idx) => (idx === 0 ? condition : sql`${acc} OR ${condition}`)),
1243
+ )
1244
+ }
1245
+ } else if (typeof value === 'string') {
1246
+ // String values: use ILIKE for case-insensitive partial matching
1247
+ conditions.push(sql`COALESCE(${jsonbPath}, '') ILIKE ${`%${value}%`}`)
1248
+ } else {
1249
+ // Non-string, non-array values: use exact match
1250
+ // Convert JSONB value to text for comparison
1251
+ conditions.push(sql`${jsonbPath}::text = ${String(value)}`)
1252
+ }
1253
+ }
1254
+
1255
+ return conditions
1256
+ }
1257
+
1258
+ // ============================================================================
1259
+ // Protected Methods
1260
+ // ============================================================================
1261
+
1262
+ /**
1263
+ * Send a PostgreSQL notification.
1264
+ *
1265
+ * @param event - The event name
1266
+ * @param data - The data to send
1267
+ * @returns Promise resolving to `void`
1268
+ */
1269
+ protected async _notify(_event: string, _data: any): Promise<void> {
1270
+ // do nothing
1271
+ }
1272
+
1273
+ /**
1274
+ * Listen for PostgreSQL notifications.
1275
+ *
1276
+ * @param event - The event name to listen for
1277
+ * @param callback - Callback function to handle notifications
1278
+ * @returns Promise resolving to an object with an `unlisten` function
1279
+ */
1280
+ protected async _listen(_event: string, _callback: (payload: string) => void): Promise<{ unlisten: () => void }> {
1281
+ // do nothing
1282
+ return {
1283
+ unlisten: () => {
1284
+ // do nothing
1285
+ },
1286
+ }
1287
+ }
1288
+
1289
+ /**
1290
+ * Map database query results to the expected format.
1291
+ * Can be overridden by subclasses to handle different result formats.
1292
+ *
1293
+ * @param result - The raw database query result
1294
+ * @returns The mapped result
1295
+ */
1296
+ protected _map(result: any) {
1297
+ return result
1298
+ }
1299
+ }