duron 0.3.0-beta.8 → 0.3.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.
Files changed (91) hide show
  1. package/dist/action-job.d.ts +33 -2
  2. package/dist/action-job.d.ts.map +1 -1
  3. package/dist/action-job.js +93 -26
  4. package/dist/action-manager.d.ts +44 -2
  5. package/dist/action-manager.d.ts.map +1 -1
  6. package/dist/action-manager.js +64 -3
  7. package/dist/action.d.ts +388 -7
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/action.js +44 -23
  10. package/dist/adapters/adapter.d.ts +365 -8
  11. package/dist/adapters/adapter.d.ts.map +1 -1
  12. package/dist/adapters/adapter.js +221 -15
  13. package/dist/adapters/postgres/base.d.ts +184 -6
  14. package/dist/adapters/postgres/base.d.ts.map +1 -1
  15. package/dist/adapters/postgres/base.js +436 -75
  16. package/dist/adapters/postgres/pglite.d.ts +37 -0
  17. package/dist/adapters/postgres/pglite.d.ts.map +1 -1
  18. package/dist/adapters/postgres/pglite.js +38 -0
  19. package/dist/adapters/postgres/postgres.d.ts +35 -0
  20. package/dist/adapters/postgres/postgres.d.ts.map +1 -1
  21. package/dist/adapters/postgres/postgres.js +42 -0
  22. package/dist/adapters/postgres/schema.d.ts +150 -37
  23. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  24. package/dist/adapters/postgres/schema.default.d.ts +151 -38
  25. package/dist/adapters/postgres/schema.default.d.ts.map +1 -1
  26. package/dist/adapters/postgres/schema.default.js +2 -2
  27. package/dist/adapters/postgres/schema.js +60 -23
  28. package/dist/adapters/schemas.d.ts +124 -80
  29. package/dist/adapters/schemas.d.ts.map +1 -1
  30. package/dist/adapters/schemas.js +139 -26
  31. package/dist/client.d.ts +426 -22
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +370 -20
  34. package/dist/constants.js +6 -0
  35. package/dist/errors.d.ts +166 -9
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/errors.js +189 -19
  38. package/dist/index.d.ts +2 -1
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/server.d.ts +99 -37
  41. package/dist/server.d.ts.map +1 -1
  42. package/dist/server.js +84 -25
  43. package/dist/step-manager.d.ts +111 -4
  44. package/dist/step-manager.d.ts.map +1 -1
  45. package/dist/step-manager.js +411 -75
  46. package/dist/telemetry/index.d.ts +1 -4
  47. package/dist/telemetry/index.d.ts.map +1 -1
  48. package/dist/telemetry/index.js +2 -4
  49. package/dist/telemetry/local-span-exporter.d.ts +56 -0
  50. package/dist/telemetry/local-span-exporter.d.ts.map +1 -0
  51. package/dist/telemetry/local-span-exporter.js +118 -0
  52. package/dist/utils/p-retry.d.ts +5 -0
  53. package/dist/utils/p-retry.d.ts.map +1 -1
  54. package/dist/utils/p-retry.js +8 -0
  55. package/dist/utils/wait-for-abort.d.ts +1 -0
  56. package/dist/utils/wait-for-abort.d.ts.map +1 -1
  57. package/dist/utils/wait-for-abort.js +1 -0
  58. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260121160012_normal_bloodstrike}/migration.sql +32 -20
  59. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260121160012_normal_bloodstrike}/snapshot.json +241 -66
  60. package/package.json +42 -26
  61. package/src/action-job.ts +43 -32
  62. package/src/action-manager.ts +5 -5
  63. package/src/action.ts +317 -149
  64. package/src/adapters/adapter.ts +54 -54
  65. package/src/adapters/postgres/base.ts +266 -86
  66. package/src/adapters/postgres/schema.default.ts +2 -2
  67. package/src/adapters/postgres/schema.ts +52 -24
  68. package/src/adapters/schemas.ts +91 -36
  69. package/src/client.ts +322 -68
  70. package/src/errors.ts +141 -30
  71. package/src/index.ts +2 -0
  72. package/src/server.ts +39 -37
  73. package/src/step-manager.ts +254 -91
  74. package/src/telemetry/index.ts +2 -20
  75. package/src/telemetry/local-span-exporter.ts +148 -0
  76. package/dist/telemetry/adapter.d.ts +0 -107
  77. package/dist/telemetry/adapter.d.ts.map +0 -1
  78. package/dist/telemetry/adapter.js +0 -134
  79. package/dist/telemetry/local.d.ts +0 -22
  80. package/dist/telemetry/local.d.ts.map +0 -1
  81. package/dist/telemetry/local.js +0 -243
  82. package/dist/telemetry/noop.d.ts +0 -17
  83. package/dist/telemetry/noop.d.ts.map +0 -1
  84. package/dist/telemetry/noop.js +0 -66
  85. package/dist/telemetry/opentelemetry.d.ts +0 -25
  86. package/dist/telemetry/opentelemetry.d.ts.map +0 -1
  87. package/dist/telemetry/opentelemetry.js +0 -312
  88. package/src/telemetry/adapter.ts +0 -642
  89. package/src/telemetry/local.ts +0 -429
  90. package/src/telemetry/noop.ts +0 -141
  91. package/src/telemetry/opentelemetry.ts +0 -453
@@ -8,6 +8,14 @@ export class PostgresBaseAdapter extends Adapter {
8
8
  tables;
9
9
  schema = 'duron';
10
10
  migrateOnStart = true;
11
+ // ============================================================================
12
+ // Constructor
13
+ // ============================================================================
14
+ /**
15
+ * Create a new PostgresAdapter instance.
16
+ *
17
+ * @param options - Configuration options for the PostgreSQL adapter
18
+ */
11
19
  constructor(options) {
12
20
  super();
13
21
  this.connection = options.connection;
@@ -16,9 +24,21 @@ export class PostgresBaseAdapter extends Adapter {
16
24
  this.tables = createSchema(this.schema);
17
25
  this._initDb();
18
26
  }
27
+ /**
28
+ * Initialize the database connection and Drizzle instance.
29
+ */
19
30
  _initDb() {
20
31
  throw new Error('Not implemented');
21
32
  }
33
+ // ============================================================================
34
+ // Lifecycle Methods
35
+ // ============================================================================
36
+ /**
37
+ * Start the adapter.
38
+ * Runs migrations if enabled and sets up database listeners.
39
+ *
40
+ * @returns Promise resolving to `true` if started successfully, `false` otherwise
41
+ */
22
42
  async _start() {
23
43
  await this._listen(`ping-${this.id}`, async (payload) => {
24
44
  const fromClientId = JSON.parse(payload).fromClientId;
@@ -38,18 +58,29 @@ export class PostgresBaseAdapter extends Adapter {
38
58
  });
39
59
  }
40
60
  async _stop() {
61
+ // do nothing
41
62
  }
42
- async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit }) {
63
+ // ============================================================================
64
+ // Job Methods
65
+ // ============================================================================
66
+ /**
67
+ * Internal method to create a new job in the database.
68
+ *
69
+ * @returns Promise resolving to the job ID, or `null` if creation failed
70
+ */
71
+ async _createJob({ queue, groupKey, input, timeoutMs, checksum, concurrencyLimit, concurrencyStepLimit, description, }) {
43
72
  const [result] = await this.db
44
73
  .insert(this.tables.jobsTable)
45
74
  .values({
46
75
  action_name: queue,
47
76
  group_key: groupKey,
77
+ description: description ?? null,
48
78
  checksum,
49
79
  input,
50
80
  status: JOB_STATUS_CREATED,
51
81
  timeout_ms: timeoutMs,
52
82
  concurrency_limit: concurrencyLimit,
83
+ concurrency_step_limit: concurrencyStepLimit,
53
84
  })
54
85
  .returning({ id: this.tables.jobsTable.id });
55
86
  if (!result) {
@@ -57,6 +88,11 @@ export class PostgresBaseAdapter extends Adapter {
57
88
  }
58
89
  return result.id;
59
90
  }
91
+ /**
92
+ * Internal method to mark a job as completed.
93
+ *
94
+ * @returns Promise resolving to `true` if completed, `false` otherwise
95
+ */
60
96
  async _completeJob({ jobId, output }) {
61
97
  const result = await this.db
62
98
  .update(this.tables.jobsTable)
@@ -70,6 +106,11 @@ export class PostgresBaseAdapter extends Adapter {
70
106
  .returning({ id: this.tables.jobsTable.id });
71
107
  return result.length > 0;
72
108
  }
109
+ /**
110
+ * Internal method to mark a job as failed.
111
+ *
112
+ * @returns Promise resolving to `true` if failed, `false` otherwise
113
+ */
73
114
  async _failJob({ jobId, error }) {
74
115
  const result = await this.db
75
116
  .update(this.tables.jobsTable)
@@ -83,6 +124,11 @@ export class PostgresBaseAdapter extends Adapter {
83
124
  .returning({ id: this.tables.jobsTable.id });
84
125
  return result.length > 0;
85
126
  }
127
+ /**
128
+ * Internal method to cancel a job.
129
+ *
130
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
131
+ */
86
132
  async _cancelJob({ jobId }) {
87
133
  const result = await this.db
88
134
  .update(this.tables.jobsTable)
@@ -95,18 +141,27 @@ export class PostgresBaseAdapter extends Adapter {
95
141
  .returning({ id: this.tables.jobsTable.id });
96
142
  return result.length > 0;
97
143
  }
144
+ /**
145
+ * Internal method to retry a completed, cancelled, or failed job by creating a copy of it with status 'created' and cleared output/error.
146
+ * Uses SELECT FOR UPDATE to prevent concurrent retries from creating duplicate jobs.
147
+ *
148
+ * @returns Promise resolving to the job ID, or `null` if creation failed
149
+ */
98
150
  async _retryJob({ jobId }) {
151
+ // Use a single atomic query with FOR UPDATE lock to prevent race conditions
99
152
  const result = this._map(await this.db.execute(sql `
100
153
  WITH locked_source AS (
101
154
  -- Lock the source job row to prevent concurrent retries
102
155
  SELECT
103
156
  j.action_name,
104
157
  j.group_key,
158
+ j.description,
105
159
  j.checksum,
106
160
  j.input,
107
161
  j.timeout_ms,
108
162
  j.created_at,
109
- j.concurrency_limit
163
+ j.concurrency_limit,
164
+ j.concurrency_step_limit
110
165
  FROM ${this.tables.jobsTable} j
111
166
  WHERE j.id = ${jobId}
112
167
  AND j.status IN (${JOB_STATUS_COMPLETED}, ${JOB_STATUS_CANCELLED}, ${JOB_STATUS_FAILED})
@@ -131,15 +186,18 @@ export class PostgresBaseAdapter extends Adapter {
131
186
  INSERT INTO ${this.tables.jobsTable} (
132
187
  action_name,
133
188
  group_key,
189
+ description,
134
190
  checksum,
135
191
  input,
136
192
  status,
137
193
  timeout_ms,
138
- concurrency_limit
194
+ concurrency_limit,
195
+ concurrency_step_limit
139
196
  )
140
197
  SELECT
141
198
  ls.action_name,
142
199
  ls.group_key,
200
+ ls.description,
143
201
  ls.checksum,
144
202
  ls.input,
145
203
  ${JOB_STATUS_CREATED},
@@ -155,7 +213,8 @@ export class PostgresBaseAdapter extends Adapter {
155
213
  LIMIT 1
156
214
  ),
157
215
  ls.concurrency_limit
158
- )
216
+ ),
217
+ ls.concurrency_step_limit
159
218
  FROM locked_source ls
160
219
  WHERE NOT EXISTS (SELECT 1 FROM existing_retry)
161
220
  RETURNING id
@@ -169,6 +228,25 @@ export class PostgresBaseAdapter extends Adapter {
169
228
  }
170
229
  return result[0].id;
171
230
  }
231
+ /**
232
+ * Internal method to time travel a job to restart from a specific step.
233
+ * The job must be in completed, failed, or cancelled status.
234
+ * Resets the job and ancestor steps to active status, deletes subsequent steps,
235
+ * and preserves completed parallel siblings.
236
+ *
237
+ * Algorithm:
238
+ * 1. Validate job is in terminal state (completed/failed/cancelled)
239
+ * 2. Find the target step and all its ancestors (using parent_step_id)
240
+ * 3. Determine which steps to keep:
241
+ * - Steps completed BEFORE the target step (by created_at)
242
+ * - Branch siblings that are completed (independent)
243
+ * 4. Delete steps that should not be kept
244
+ * 5. Reset ancestor steps to active status (they need to re-run)
245
+ * 6. Reset the target step to active status
246
+ * 7. Reset job to created status
247
+ *
248
+ * @returns Promise resolving to `true` if time travel succeeded, `false` otherwise
249
+ */
172
250
  async _timeTravelJob({ jobId, stepId }) {
173
251
  const result = this._map(await this.db.execute(sql `
174
252
  WITH RECURSIVE
@@ -336,16 +414,29 @@ export class PostgresBaseAdapter extends Adapter {
336
414
  `));
337
415
  return result.length > 0 && result[0].success === true;
338
416
  }
417
+ /**
418
+ * Internal method to delete a job by its ID.
419
+ * Active jobs cannot be deleted.
420
+ *
421
+ * @returns Promise resolving to `true` if deleted, `false` otherwise
422
+ */
339
423
  async _deleteJob({ jobId }) {
340
424
  const result = await this.db
341
425
  .delete(this.tables.jobsTable)
342
426
  .where(and(eq(this.tables.jobsTable.id, jobId), ne(this.tables.jobsTable.status, JOB_STATUS_ACTIVE)))
343
427
  .returning({ id: this.tables.jobsTable.id });
428
+ // Also delete associated steps
344
429
  if (result.length > 0) {
345
430
  await this.db.delete(this.tables.jobStepsTable).where(eq(this.tables.jobStepsTable.job_id, jobId));
346
431
  }
347
432
  return result.length > 0;
348
433
  }
434
+ /**
435
+ * Internal method to delete multiple jobs using the same filters as getJobs.
436
+ * Active jobs cannot be deleted and will be excluded from deletion.
437
+ *
438
+ * @returns Promise resolving to the number of jobs deleted
439
+ */
349
440
  async _deleteJobs(options) {
350
441
  const jobsTable = this.tables.jobsTable;
351
442
  const filters = options?.filters ?? {};
@@ -353,6 +444,13 @@ export class PostgresBaseAdapter extends Adapter {
353
444
  const result = await this.db.delete(jobsTable).where(where).returning({ id: jobsTable.id });
354
445
  return result.length;
355
446
  }
447
+ /**
448
+ * Internal method to fetch jobs from the database respecting concurrency limits per group.
449
+ * Uses the concurrency limit from the latest job created for each groupKey.
450
+ * Uses advisory locks to ensure thread-safe job fetching.
451
+ *
452
+ * @returns Promise resolving to an array of fetched jobs
453
+ */
356
454
  async _fetch({ batch }) {
357
455
  const result = this._map(await this.db.execute(sql `
358
456
  WITH group_concurrency AS (
@@ -450,6 +548,7 @@ export class PostgresBaseAdapter extends Adapter {
450
548
  j.id,
451
549
  j.action_name as "actionName",
452
550
  j.group_key as "groupKey",
551
+ j.description,
453
552
  j.input,
454
553
  j.output,
455
554
  j.error,
@@ -460,10 +559,17 @@ export class PostgresBaseAdapter extends Adapter {
460
559
  j.finished_at as "finishedAt",
461
560
  j.created_at as "createdAt",
462
561
  j.updated_at as "updatedAt",
463
- j.concurrency_limit as "concurrencyLimit"
562
+ j.concurrency_limit as "concurrencyLimit",
563
+ j.concurrency_step_limit as "concurrencyStepLimit"
464
564
  `));
465
565
  return result;
466
566
  }
567
+ /**
568
+ * Internal method to recover stuck jobs (jobs that were active but the process that owned them is no longer running).
569
+ * In multi-process mode, pings other processes to check if they're alive before recovering their jobs.
570
+ *
571
+ * @returns Promise resolving to the number of jobs recovered
572
+ */
467
573
  async _recoverJobs(options) {
468
574
  const { checksums, multiProcessMode = false, processTimeout = 5_000 } = options;
469
575
  const unresponsiveClientIds = [this.id];
@@ -527,6 +633,14 @@ export class PostgresBaseAdapter extends Adapter {
527
633
  }
528
634
  return 0;
529
635
  }
636
+ // ============================================================================
637
+ // Step Methods
638
+ // ============================================================================
639
+ /**
640
+ * Internal method to create or recover a job step by creating or resetting a step record in the database.
641
+ *
642
+ * @returns Promise resolving to the step, or `null` if creation failed
643
+ */
530
644
  async _createOrRecoverJobStep({ jobId, name, timeoutMs, retriesLimit, parentStepId, parallel = false, }) {
531
645
  const [result] = this._map(await this.db.execute(sql `
532
646
  WITH job_check AS (
@@ -539,7 +653,7 @@ export class PostgresBaseAdapter extends Adapter {
539
653
  step_existed AS (
540
654
  SELECT EXISTS(
541
655
  SELECT 1 FROM ${this.tables.jobStepsTable} s
542
- WHERE s.job_id = ${jobId}
656
+ WHERE s.job_id = ${jobId}
543
657
  AND s.name = ${name}
544
658
  AND s.parent_step_id IS NOT DISTINCT FROM ${parentStepId}
545
659
  ) AS existed
@@ -624,6 +738,11 @@ export class PostgresBaseAdapter extends Adapter {
624
738
  }
625
739
  return result;
626
740
  }
741
+ /**
742
+ * Internal method to mark a job step as completed.
743
+ *
744
+ * @returns Promise resolving to `true` if completed, `false` otherwise
745
+ */
627
746
  async _completeJobStep({ stepId, output }) {
628
747
  const result = await this.db
629
748
  .update(this.tables.jobStepsTable)
@@ -638,6 +757,11 @@ export class PostgresBaseAdapter extends Adapter {
638
757
  .returning({ id: this.tables.jobStepsTable.id });
639
758
  return result.length > 0;
640
759
  }
760
+ /**
761
+ * Internal method to mark a job step as failed.
762
+ *
763
+ * @returns Promise resolving to `true` if failed, `false` otherwise
764
+ */
641
765
  async _failJobStep({ stepId, error }) {
642
766
  const result = await this.db
643
767
  .update(this.tables.jobStepsTable)
@@ -652,6 +776,11 @@ export class PostgresBaseAdapter extends Adapter {
652
776
  .returning({ id: this.tables.jobStepsTable.id });
653
777
  return result.length > 0;
654
778
  }
779
+ /**
780
+ * Internal method to delay a job step.
781
+ *
782
+ * @returns Promise resolving to `true` if delayed, `false` otherwise
783
+ */
655
784
  async _delayJobStep({ stepId, delayMs, error }) {
656
785
  const jobStepsTable = this.tables.jobStepsTable;
657
786
  const jobsTable = this.tables.jobsTable;
@@ -676,6 +805,11 @@ export class PostgresBaseAdapter extends Adapter {
676
805
  .returning({ id: jobStepsTable.id });
677
806
  return result.length > 0;
678
807
  }
808
+ /**
809
+ * Internal method to cancel a job step.
810
+ *
811
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
812
+ */
679
813
  async _cancelJobStep({ stepId }) {
680
814
  const result = await this.db
681
815
  .update(this.tables.jobStepsTable)
@@ -689,30 +823,53 @@ export class PostgresBaseAdapter extends Adapter {
689
823
  .returning({ id: this.tables.jobStepsTable.id });
690
824
  return result.length > 0;
691
825
  }
826
+ // ============================================================================
827
+ // Query Methods
828
+ // ============================================================================
829
+ /**
830
+ * Internal method to get a job by its ID. Does not include step information.
831
+ */
692
832
  async _getJobById(jobId) {
833
+ const jobsTable = this.tables.jobsTable;
834
+ // Calculate duration as a SQL expression (finishedAt - startedAt in milliseconds)
835
+ const durationMs = sql `
836
+ CASE
837
+ WHEN ${jobsTable.started_at} IS NOT NULL AND ${jobsTable.finished_at} IS NOT NULL
838
+ THEN EXTRACT(EPOCH FROM (${jobsTable.finished_at} - ${jobsTable.started_at})) * 1000
839
+ ELSE NULL
840
+ END
841
+ `.as('duration_ms');
693
842
  const [job] = await this.db
694
843
  .select({
695
- id: this.tables.jobsTable.id,
696
- actionName: this.tables.jobsTable.action_name,
697
- groupKey: this.tables.jobsTable.group_key,
698
- input: this.tables.jobsTable.input,
699
- output: this.tables.jobsTable.output,
700
- error: this.tables.jobsTable.error,
701
- status: this.tables.jobsTable.status,
702
- timeoutMs: this.tables.jobsTable.timeout_ms,
703
- expiresAt: this.tables.jobsTable.expires_at,
704
- startedAt: this.tables.jobsTable.started_at,
705
- finishedAt: this.tables.jobsTable.finished_at,
706
- createdAt: this.tables.jobsTable.created_at,
707
- updatedAt: this.tables.jobsTable.updated_at,
708
- concurrencyLimit: this.tables.jobsTable.concurrency_limit,
709
- clientId: this.tables.jobsTable.client_id,
844
+ id: jobsTable.id,
845
+ actionName: jobsTable.action_name,
846
+ groupKey: jobsTable.group_key,
847
+ description: jobsTable.description,
848
+ input: jobsTable.input,
849
+ output: jobsTable.output,
850
+ error: jobsTable.error,
851
+ status: jobsTable.status,
852
+ timeoutMs: jobsTable.timeout_ms,
853
+ expiresAt: jobsTable.expires_at,
854
+ startedAt: jobsTable.started_at,
855
+ finishedAt: jobsTable.finished_at,
856
+ createdAt: jobsTable.created_at,
857
+ updatedAt: jobsTable.updated_at,
858
+ concurrencyLimit: jobsTable.concurrency_limit,
859
+ concurrencyStepLimit: jobsTable.concurrency_step_limit,
860
+ clientId: jobsTable.client_id,
861
+ durationMs,
710
862
  })
711
- .from(this.tables.jobsTable)
712
- .where(eq(this.tables.jobsTable.id, jobId))
863
+ .from(jobsTable)
864
+ .where(eq(jobsTable.id, jobId))
713
865
  .limit(1);
714
866
  return job ?? null;
715
867
  }
868
+ /**
869
+ * Internal method to get all steps for a job with optional fuzzy search.
870
+ * Steps are always ordered by created_at ASC.
871
+ * Steps do not include output data.
872
+ */
716
873
  async _getJobSteps(options) {
717
874
  const { jobId, search } = options;
718
875
  const jobStepsTable = this.tables.jobStepsTable;
@@ -756,6 +913,7 @@ export class PostgresBaseAdapter extends Adapter {
756
913
  }
757
914
  const jobsTable = this.tables.jobsTable;
758
915
  const fuzzySearch = filters.search?.trim();
916
+ // Build WHERE clause parts using postgres template literals
759
917
  return and(filters.status
760
918
  ? inArray(jobsTable.status, Array.isArray(filters.status) ? filters.status : [filters.status])
761
919
  : undefined, filters.actionName
@@ -766,7 +924,7 @@ export class PostgresBaseAdapter extends Adapter {
766
924
  ? ilike(jobsTable.group_key, `%${filters.groupKey}%`)
767
925
  : undefined, filters.clientId
768
926
  ? inArray(jobsTable.client_id, Array.isArray(filters.clientId) ? filters.clientId : [filters.clientId])
769
- : undefined, filters.createdAt && Array.isArray(filters.createdAt)
927
+ : undefined, filters.description ? ilike(jobsTable.description, `%${filters.description}%`) : undefined, filters.createdAt && Array.isArray(filters.createdAt)
770
928
  ? between(sql `date_trunc('second', ${jobsTable.created_at})`, filters.createdAt[0].toISOString(), filters.createdAt[1].toISOString())
771
929
  : undefined, filters.createdAt && !Array.isArray(filters.createdAt)
772
930
  ? gte(sql `date_trunc('second', ${jobsTable.created_at})`, filters.createdAt.toISOString())
@@ -781,13 +939,17 @@ export class PostgresBaseAdapter extends Adapter {
781
939
  : undefined, filters.updatedAfter
782
940
  ? sql `date_trunc('milliseconds', ${jobsTable.updated_at}) > ${filters.updatedAfter.toISOString()}::timestamptz`
783
941
  : undefined, fuzzySearch && fuzzySearch.length > 0
784
- ? or(ilike(jobsTable.action_name, `%${fuzzySearch}%`), ilike(jobsTable.group_key, `%${fuzzySearch}%`), ilike(jobsTable.client_id, `%${fuzzySearch}%`), sql `${jobsTable.id}::text ilike ${`%${fuzzySearch}%`}`, sql `to_tsvector('english', ${jobsTable.input}::text) @@ plainto_tsquery('english', ${fuzzySearch})`, sql `to_tsvector('english', ${jobsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`)
942
+ ? or(ilike(jobsTable.action_name, `%${fuzzySearch}%`), ilike(jobsTable.group_key, `%${fuzzySearch}%`), ilike(jobsTable.description, `%${fuzzySearch}%`), ilike(jobsTable.client_id, `%${fuzzySearch}%`), sql `${jobsTable.id}::text ilike ${`%${fuzzySearch}%`}`, sql `to_tsvector('english', ${jobsTable.input}::text) @@ plainto_tsquery('english', ${fuzzySearch})`, sql `to_tsvector('english', ${jobsTable.output}::text) @@ plainto_tsquery('english', ${fuzzySearch})`)
785
943
  : undefined, ...(filters.inputFilter && Object.keys(filters.inputFilter).length > 0
786
944
  ? this.#buildJsonbWhereConditions(filters.inputFilter, jobsTable.input)
787
945
  : []), ...(filters.outputFilter && Object.keys(filters.outputFilter).length > 0
788
946
  ? this.#buildJsonbWhereConditions(filters.outputFilter, jobsTable.output)
789
947
  : []));
790
948
  }
949
+ /**
950
+ * Internal method to get jobs with pagination, filtering, and sorting.
951
+ * Does not include step information or job output.
952
+ */
791
953
  async _getJobs(options) {
792
954
  const jobsTable = this.tables.jobsTable;
793
955
  const page = options?.page ?? 1;
@@ -796,6 +958,7 @@ export class PostgresBaseAdapter extends Adapter {
796
958
  const sortInput = options?.sort ?? { field: 'startedAt', order: 'desc' };
797
959
  const sorts = Array.isArray(sortInput) ? sortInput : [sortInput];
798
960
  const where = this._buildJobsWhereClause(filters);
961
+ // Get total count
799
962
  const total = await this.db.$count(jobsTable, where);
800
963
  if (!total) {
801
964
  return {
@@ -805,6 +968,14 @@ export class PostgresBaseAdapter extends Adapter {
805
968
  pageSize,
806
969
  };
807
970
  }
971
+ // Calculate duration as a SQL expression (finishedAt - startedAt in milliseconds)
972
+ const durationMs = sql `
973
+ CASE
974
+ WHEN ${jobsTable.started_at} IS NOT NULL AND ${jobsTable.finished_at} IS NOT NULL
975
+ THEN EXTRACT(EPOCH FROM (${jobsTable.finished_at} - ${jobsTable.started_at})) * 1000
976
+ ELSE NULL
977
+ END
978
+ `.as('duration_ms');
808
979
  const sortFieldMap = {
809
980
  createdAt: jobsTable.created_at,
810
981
  startedAt: jobsTable.started_at,
@@ -812,12 +983,15 @@ export class PostgresBaseAdapter extends Adapter {
812
983
  status: jobsTable.status,
813
984
  actionName: jobsTable.action_name,
814
985
  expiresAt: jobsTable.expires_at,
986
+ duration: durationMs,
987
+ description: jobsTable.description,
815
988
  };
816
989
  const jobs = await this.db
817
990
  .select({
818
991
  id: jobsTable.id,
819
992
  actionName: jobsTable.action_name,
820
993
  groupKey: jobsTable.group_key,
994
+ description: jobsTable.description,
821
995
  input: jobsTable.input,
822
996
  output: jobsTable.output,
823
997
  error: jobsTable.error,
@@ -829,7 +1003,9 @@ export class PostgresBaseAdapter extends Adapter {
829
1003
  createdAt: jobsTable.created_at,
830
1004
  updatedAt: jobsTable.updated_at,
831
1005
  concurrencyLimit: jobsTable.concurrency_limit,
1006
+ concurrencyStepLimit: jobsTable.concurrency_step_limit,
832
1007
  clientId: jobsTable.client_id,
1008
+ durationMs,
833
1009
  })
834
1010
  .from(jobsTable)
835
1011
  .where(where)
@@ -853,6 +1029,9 @@ export class PostgresBaseAdapter extends Adapter {
853
1029
  pageSize,
854
1030
  };
855
1031
  }
1032
+ /**
1033
+ * Internal method to get a step by its ID with all information.
1034
+ */
856
1035
  async _getJobStepById(stepId) {
857
1036
  const [step] = await this.db
858
1037
  .select({
@@ -880,6 +1059,9 @@ export class PostgresBaseAdapter extends Adapter {
880
1059
  .limit(1);
881
1060
  return step ?? null;
882
1061
  }
1062
+ /**
1063
+ * Internal method to get job status and updatedAt timestamp.
1064
+ */
883
1065
  async _getJobStatus(jobId) {
884
1066
  const [job] = await this.db
885
1067
  .select({
@@ -891,6 +1073,9 @@ export class PostgresBaseAdapter extends Adapter {
891
1073
  .limit(1);
892
1074
  return job ?? null;
893
1075
  }
1076
+ /**
1077
+ * Internal method to get job step status and updatedAt timestamp.
1078
+ */
894
1079
  async _getJobStepStatus(stepId) {
895
1080
  const [step] = await this.db
896
1081
  .select({
@@ -902,6 +1087,9 @@ export class PostgresBaseAdapter extends Adapter {
902
1087
  .limit(1);
903
1088
  return step ?? null;
904
1089
  }
1090
+ /**
1091
+ * Internal method to get action statistics including counts and last job created date.
1092
+ */
905
1093
  async _getActions() {
906
1094
  const actionStats = this.db.$with('action_stats').as(this.db
907
1095
  .select({
@@ -933,85 +1121,217 @@ export class PostgresBaseAdapter extends Adapter {
933
1121
  })),
934
1122
  };
935
1123
  }
936
- async _insertMetrics(metrics) {
937
- if (metrics.length === 0) {
1124
+ // ============================================================================
1125
+ // Metrics Methods
1126
+ // ============================================================================
1127
+ /**
1128
+ * Internal method to insert multiple span records in a single batch.
1129
+ */
1130
+ async _insertSpans(spans) {
1131
+ if (spans.length === 0) {
938
1132
  return 0;
939
1133
  }
940
- const values = metrics.map((m) => ({
941
- job_id: m.jobId,
942
- step_id: m.stepId ?? null,
943
- name: m.name,
944
- value: m.value,
945
- attributes: m.attributes ?? {},
946
- type: m.type,
1134
+ const values = spans.map((s) => ({
1135
+ trace_id: s.traceId,
1136
+ span_id: s.spanId,
1137
+ parent_span_id: s.parentSpanId,
1138
+ job_id: s.jobId,
1139
+ step_id: s.stepId,
1140
+ name: s.name,
1141
+ kind: s.kind,
1142
+ start_time_unix_nano: s.startTimeUnixNano,
1143
+ end_time_unix_nano: s.endTimeUnixNano,
1144
+ status_code: s.statusCode,
1145
+ status_message: s.statusMessage,
1146
+ attributes: s.attributes ?? {},
1147
+ events: s.events ?? [],
947
1148
  }));
948
1149
  const result = await this.db
949
- .insert(this.tables.metricsTable)
1150
+ .insert(this.tables.spansTable)
950
1151
  .values(values)
951
- .returning({ id: this.tables.metricsTable.id });
1152
+ .returning({ id: this.tables.spansTable.id });
952
1153
  return result.length;
953
1154
  }
954
- async _getMetrics(options) {
955
- const metricsTable = this.tables.metricsTable;
1155
+ /**
1156
+ * Internal method to get spans for a job or step.
1157
+ * For step queries, uses a recursive CTE to find all descendant spans.
1158
+ */
1159
+ async _getSpans(options) {
1160
+ const spansTable = this.tables.spansTable;
956
1161
  const filters = options.filters ?? {};
957
- const where = this._buildMetricsWhereClause(options.jobId, options.stepId, filters);
958
- const sortInput = options.sort ?? { field: 'timestamp', order: 'desc' };
1162
+ // Build sort
1163
+ const sortInput = options.sort ?? { field: 'startTimeUnixNano', order: 'asc' };
959
1164
  const sortFieldMap = {
960
- name: metricsTable.name,
961
- value: metricsTable.value,
962
- timestamp: metricsTable.timestamp,
963
- createdAt: metricsTable.created_at,
1165
+ name: 'name',
1166
+ startTimeUnixNano: 'start_time_unix_nano',
1167
+ endTimeUnixNano: 'end_time_unix_nano',
964
1168
  };
965
- const total = await this.db.$count(metricsTable, where);
1169
+ const sortField = sortFieldMap[sortInput.field];
1170
+ const sortOrder = sortInput.order === 'asc' ? 'ASC' : 'DESC';
1171
+ // For step queries, use a recursive CTE to get descendant spans
1172
+ if (options.stepId) {
1173
+ return this._getStepSpansRecursive(options.stepId, sortField, sortOrder, filters);
1174
+ }
1175
+ // Build WHERE clause for job queries
1176
+ const where = this._buildSpansWhereClause(options.jobId, undefined, filters);
1177
+ // Get total count
1178
+ const total = await this.db.$count(spansTable, where);
966
1179
  if (!total) {
967
1180
  return {
968
- metrics: [],
1181
+ spans: [],
969
1182
  total: 0,
970
1183
  };
971
1184
  }
972
- const sortField = sortFieldMap[sortInput.field];
973
- const orderByClause = sortInput.order === 'asc' ? asc(sortField) : desc(sortField);
974
- const metrics = await this.db
1185
+ const sortFieldColumn = sortFieldMap[sortInput.field];
1186
+ const orderByClause = sortInput.order === 'asc'
1187
+ ? asc(spansTable[sortFieldColumn])
1188
+ : desc(spansTable[sortFieldColumn]);
1189
+ const rows = await this.db
975
1190
  .select({
976
- id: metricsTable.id,
977
- jobId: metricsTable.job_id,
978
- stepId: metricsTable.step_id,
979
- name: metricsTable.name,
980
- value: metricsTable.value,
981
- attributes: metricsTable.attributes,
982
- type: metricsTable.type,
983
- timestamp: metricsTable.timestamp,
984
- createdAt: metricsTable.created_at,
1191
+ id: spansTable.id,
1192
+ traceId: spansTable.trace_id,
1193
+ spanId: spansTable.span_id,
1194
+ parentSpanId: spansTable.parent_span_id,
1195
+ jobId: spansTable.job_id,
1196
+ stepId: spansTable.step_id,
1197
+ name: spansTable.name,
1198
+ kind: spansTable.kind,
1199
+ startTimeUnixNano: spansTable.start_time_unix_nano,
1200
+ endTimeUnixNano: spansTable.end_time_unix_nano,
1201
+ statusCode: spansTable.status_code,
1202
+ statusMessage: spansTable.status_message,
1203
+ attributes: spansTable.attributes,
1204
+ events: spansTable.events,
985
1205
  })
986
- .from(metricsTable)
1206
+ .from(spansTable)
987
1207
  .where(where)
988
1208
  .orderBy(orderByClause);
1209
+ // Cast kind and statusCode to proper types, convert BigInt to string for JSON serialization
1210
+ const spans = rows.map((row) => ({
1211
+ ...row,
1212
+ kind: row.kind,
1213
+ statusCode: row.statusCode,
1214
+ // Convert BigInt to string for JSON serialization
1215
+ startTimeUnixNano: row.startTimeUnixNano?.toString() ?? null,
1216
+ endTimeUnixNano: row.endTimeUnixNano?.toString() ?? null,
1217
+ }));
989
1218
  return {
990
- metrics,
1219
+ spans,
991
1220
  total,
992
1221
  };
993
1222
  }
994
- async _deleteMetrics(options) {
1223
+ /**
1224
+ * Get spans for a step using a recursive CTE to traverse the span hierarchy.
1225
+ * This returns the step's span and all its descendant spans (children, grandchildren, etc.)
1226
+ */
1227
+ async _getStepSpansRecursive(stepId, sortField, sortOrder, _filters) {
1228
+ const schemaName = this.schema;
1229
+ // Use a recursive CTE to find all descendant spans
1230
+ // 1. Base case: find the span with step_id = stepId
1231
+ // 2. Recursive case: find all spans where parent_span_id = span_id of a span we've already found
1232
+ const query = sql `
1233
+ WITH RECURSIVE span_tree AS (
1234
+ -- Base case: the span(s) for the step
1235
+ SELECT * FROM ${sql.identifier(schemaName)}.spans WHERE step_id = ${stepId}::uuid
1236
+ UNION ALL
1237
+ -- Recursive case: children of spans we've found
1238
+ SELECT s.* FROM ${sql.identifier(schemaName)}.spans s
1239
+ INNER JOIN span_tree st ON s.parent_span_id = st.span_id
1240
+ )
1241
+ SELECT
1242
+ id,
1243
+ trace_id as "traceId",
1244
+ span_id as "spanId",
1245
+ parent_span_id as "parentSpanId",
1246
+ job_id as "jobId",
1247
+ step_id as "stepId",
1248
+ name,
1249
+ kind,
1250
+ start_time_unix_nano as "startTimeUnixNano",
1251
+ end_time_unix_nano as "endTimeUnixNano",
1252
+ status_code as "statusCode",
1253
+ status_message as "statusMessage",
1254
+ attributes,
1255
+ events
1256
+ FROM span_tree
1257
+ ORDER BY ${sql.identifier(sortField)} ${sql.raw(sortOrder)}
1258
+ `;
1259
+ // Raw SQL returns numeric types as strings, so we type them as such
1260
+ const rows = (await this.db.execute(query));
1261
+ // Convert types: raw SQL returns numeric types as strings
1262
+ const spans = rows.map((row) => ({
1263
+ ...row,
1264
+ // Convert id to number (bigserial comes as string from raw SQL)
1265
+ id: typeof row.id === 'string' ? Number.parseInt(row.id, 10) : row.id,
1266
+ // Convert kind and statusCode to proper types
1267
+ kind: (typeof row.kind === 'string' ? Number.parseInt(row.kind, 10) : row.kind),
1268
+ statusCode: (typeof row.statusCode === 'string' ? Number.parseInt(row.statusCode, 10) : row.statusCode),
1269
+ // Convert BigInt to string for JSON serialization
1270
+ startTimeUnixNano: row.startTimeUnixNano?.toString() ?? null,
1271
+ endTimeUnixNano: row.endTimeUnixNano?.toString() ?? null,
1272
+ }));
1273
+ return {
1274
+ spans,
1275
+ total: spans.length,
1276
+ };
1277
+ }
1278
+ /**
1279
+ * Internal method to delete all spans for a job.
1280
+ */
1281
+ async _deleteSpans(options) {
995
1282
  const result = await this.db
996
- .delete(this.tables.metricsTable)
997
- .where(eq(this.tables.metricsTable.job_id, options.jobId))
998
- .returning({ id: this.tables.metricsTable.id });
1283
+ .delete(this.tables.spansTable)
1284
+ .where(eq(this.tables.spansTable.job_id, options.jobId))
1285
+ .returning({ id: this.tables.spansTable.id });
999
1286
  return result.length;
1000
1287
  }
1001
- _buildMetricsWhereClause(jobId, stepId, filters) {
1002
- const metricsTable = this.tables.metricsTable;
1003
- return and(jobId ? eq(metricsTable.job_id, jobId) : undefined, stepId ? eq(metricsTable.step_id, stepId) : undefined, filters?.name
1288
+ /**
1289
+ * Build WHERE clause for spans queries (used for job queries only).
1290
+ * When querying by jobId, we find all spans that share the same trace_id
1291
+ * as spans with that job. This includes spans from external libraries that
1292
+ * don't have the duron.job.id attribute but are part of the same trace.
1293
+ *
1294
+ * Note: Step queries are handled separately by _getStepSpansRecursive using
1295
+ * a recursive CTE to traverse the span hierarchy.
1296
+ */
1297
+ _buildSpansWhereClause(jobId, _stepId, filters) {
1298
+ const spansTable = this.tables.spansTable;
1299
+ // Build condition for finding spans by trace_id (includes external spans)
1300
+ let traceCondition;
1301
+ if (jobId) {
1302
+ // Find all spans that share a trace_id with any span that has this job_id
1303
+ // This includes external spans (like from AI SDK) that don't have duron.job.id
1304
+ traceCondition = inArray(spansTable.trace_id, this.db.select({ traceId: spansTable.trace_id }).from(spansTable).where(eq(spansTable.job_id, jobId)));
1305
+ }
1306
+ return and(traceCondition, filters?.name
1004
1307
  ? Array.isArray(filters.name)
1005
- ? or(...filters.name.map((n) => ilike(metricsTable.name, `%${n}%`)))
1006
- : ilike(metricsTable.name, `%${filters.name}%`)
1007
- : undefined, filters?.type
1008
- ? inArray(metricsTable.type, Array.isArray(filters.type) ? filters.type : [filters.type])
1009
- : undefined, filters?.timestampRange && filters.timestampRange.length === 2
1010
- ? between(metricsTable.timestamp, filters.timestampRange[0], filters.timestampRange[1])
1011
- : undefined, ...(filters?.attributesFilter && Object.keys(filters.attributesFilter).length > 0
1012
- ? this.#buildJsonbWhereConditions(filters.attributesFilter, metricsTable.attributes)
1308
+ ? or(...filters.name.map((n) => ilike(spansTable.name, `%${n}%`)))
1309
+ : ilike(spansTable.name, `%${filters.name}%`)
1310
+ : undefined, filters?.kind ? inArray(spansTable.kind, Array.isArray(filters.kind) ? filters.kind : [filters.kind]) : undefined, filters?.statusCode
1311
+ ? inArray(spansTable.status_code, Array.isArray(filters.statusCode) ? filters.statusCode : [filters.statusCode])
1312
+ : undefined, filters?.traceId ? eq(spansTable.trace_id, filters.traceId) : undefined, ...(filters?.attributesFilter && Object.keys(filters.attributesFilter).length > 0
1313
+ ? this.#buildJsonbWhereConditions(filters.attributesFilter, spansTable.attributes)
1013
1314
  : []));
1014
1315
  }
1316
+ // ============================================================================
1317
+ // Private Methods
1318
+ // ============================================================================
1319
+ /**
1320
+ * Build WHERE conditions for JSONB filter using individual property checks.
1321
+ * Each property becomes a separate condition using ->> operator and ILIKE for case-insensitive matching.
1322
+ * Supports nested properties via dot notation and arrays.
1323
+ *
1324
+ * Example:
1325
+ * { "email": "tincho@gmail", "address.name": "nicolas", "products": ["chicle"] }
1326
+ * Generates:
1327
+ * input ->> 'email' ILIKE '%tincho@gmail%'
1328
+ * AND input ->> 'address' ->> 'name' ILIKE '%nicolas%'
1329
+ * AND EXISTS (SELECT 1 FROM jsonb_array_elements_text(input -> 'products') AS elem WHERE LOWER(elem) ILIKE LOWER('%chicle%'))
1330
+ *
1331
+ * @param filter - Flat record with dot-notation keys (e.g., { "email": "test", "address.name": "value", "products": ["chicle"] })
1332
+ * @param jsonbColumn - The JSONB column name
1333
+ * @returns Array of SQL conditions
1334
+ */
1015
1335
  #buildJsonbWhereConditions(filter, jsonbColumn) {
1016
1336
  const conditions = [];
1017
1337
  for (const [key, value] of Object.entries(filter)) {
@@ -1019,11 +1339,16 @@ export class PostgresBaseAdapter extends Adapter {
1019
1339
  if (parts.length === 0) {
1020
1340
  continue;
1021
1341
  }
1342
+ // Build the JSONB path expression step by step
1343
+ // For "address.name": input -> 'address' ->> 'name' (-> for intermediate, ->> for final)
1344
+ // For "email": input ->> 'email' (->> for single level)
1022
1345
  let jsonbPath = sql `${jsonbColumn}`;
1023
1346
  if (parts.length === 1) {
1347
+ // Single level: use ->> directly
1024
1348
  jsonbPath = sql `${jsonbPath} ->> ${parts[0]}`;
1025
1349
  }
1026
1350
  else {
1351
+ // Nested: use -> for intermediate steps, ->> for final step
1027
1352
  for (let i = 0; i < parts.length - 1; i++) {
1028
1353
  const part = parts[i];
1029
1354
  if (part) {
@@ -1035,9 +1360,12 @@ export class PostgresBaseAdapter extends Adapter {
1035
1360
  jsonbPath = sql `${jsonbPath} ->> ${lastPart}`;
1036
1361
  }
1037
1362
  }
1363
+ // Handle array values - check if JSONB array contains at least one of the values
1038
1364
  if (Array.isArray(value)) {
1365
+ // Build condition: check if any element in the JSONB array matches any value in the filter array
1039
1366
  const arrayValueConditions = value.map((arrayValue) => {
1040
1367
  const arrayValueStr = String(arrayValue);
1368
+ // Get the array from JSONB: input -> 'products'
1041
1369
  let arrayPath = sql `${jsonbColumn}`;
1042
1370
  for (let i = 0; i < parts.length - 1; i++) {
1043
1371
  const part = parts[i];
@@ -1049,6 +1377,7 @@ export class PostgresBaseAdapter extends Adapter {
1049
1377
  if (lastPart) {
1050
1378
  arrayPath = sql `${arrayPath} -> ${lastPart}`;
1051
1379
  }
1380
+ // Check if the JSONB array contains the value (case-insensitive for strings)
1052
1381
  if (typeof arrayValue === 'string') {
1053
1382
  return sql `EXISTS (
1054
1383
  SELECT 1
@@ -1057,30 +1386,62 @@ export class PostgresBaseAdapter extends Adapter {
1057
1386
  )`;
1058
1387
  }
1059
1388
  else {
1389
+ // For non-string values, use exact containment
1060
1390
  return sql `${arrayPath} @> ${sql.raw(JSON.stringify([arrayValue]))}::jsonb`;
1061
1391
  }
1062
1392
  });
1393
+ // Combine array conditions with OR (at least one must match)
1063
1394
  if (arrayValueConditions.length > 0) {
1064
1395
  conditions.push(arrayValueConditions.reduce((acc, condition, idx) => (idx === 0 ? condition : sql `${acc} OR ${condition}`)));
1065
1396
  }
1066
1397
  }
1067
1398
  else if (typeof value === 'string') {
1399
+ // String values: use ILIKE for case-insensitive partial matching
1068
1400
  conditions.push(sql `COALESCE(${jsonbPath}, '') ILIKE ${`%${value}%`}`);
1069
1401
  }
1070
1402
  else {
1403
+ // Non-string, non-array values: use exact match
1404
+ // Convert JSONB value to text for comparison
1071
1405
  conditions.push(sql `${jsonbPath}::text = ${String(value)}`);
1072
1406
  }
1073
1407
  }
1074
1408
  return conditions;
1075
1409
  }
1410
+ // ============================================================================
1411
+ // Protected Methods
1412
+ // ============================================================================
1413
+ /**
1414
+ * Send a PostgreSQL notification.
1415
+ *
1416
+ * @param event - The event name
1417
+ * @param data - The data to send
1418
+ * @returns Promise resolving to `void`
1419
+ */
1076
1420
  async _notify(_event, _data) {
1421
+ // do nothing
1077
1422
  }
1423
+ /**
1424
+ * Listen for PostgreSQL notifications.
1425
+ *
1426
+ * @param event - The event name to listen for
1427
+ * @param callback - Callback function to handle notifications
1428
+ * @returns Promise resolving to an object with an `unlisten` function
1429
+ */
1078
1430
  async _listen(_event, _callback) {
1431
+ // do nothing
1079
1432
  return {
1080
1433
  unlisten: () => {
1434
+ // do nothing
1081
1435
  },
1082
1436
  };
1083
1437
  }
1438
+ /**
1439
+ * Map database query results to the expected format.
1440
+ * Can be overridden by subclasses to handle different result formats.
1441
+ *
1442
+ * @param result - The raw database query result
1443
+ * @returns The mapped result
1444
+ */
1084
1445
  _map(result) {
1085
1446
  return result;
1086
1447
  }