duron 0.1.1 → 0.2.0

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