duron 0.3.0-beta.10 → 0.3.0-beta.12

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 (90) 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 +87 -22
  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 +146 -3
  8. package/dist/action.d.ts.map +1 -1
  9. package/dist/action.js +131 -0
  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 +174 -5
  14. package/dist/adapters/postgres/base.d.ts.map +1 -1
  15. package/dist/adapters/postgres/base.js +349 -66
  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 +118 -35
  23. package/dist/adapters/postgres/schema.d.ts.map +1 -1
  24. package/dist/adapters/postgres/schema.default.d.ts +119 -36
  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 +55 -22
  28. package/dist/adapters/schemas.d.ts +107 -80
  29. package/dist/adapters/schemas.d.ts.map +1 -1
  30. package/dist/adapters/schemas.js +131 -26
  31. package/dist/client.d.ts +315 -9
  32. package/dist/client.d.ts.map +1 -1
  33. package/dist/client.js +391 -21
  34. package/dist/constants.js +6 -0
  35. package/dist/errors.d.ts +119 -0
  36. package/dist/errors.d.ts.map +1 -1
  37. package/dist/errors.js +111 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/server.d.ts +91 -37
  41. package/dist/server.d.ts.map +1 -1
  42. package/dist/server.js +81 -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 +340 -69
  46. package/dist/telemetry/adapter.d.ts +322 -0
  47. package/dist/telemetry/adapter.d.ts.map +1 -1
  48. package/dist/telemetry/adapter.js +145 -0
  49. package/dist/telemetry/index.d.ts +1 -4
  50. package/dist/telemetry/index.d.ts.map +1 -1
  51. package/dist/telemetry/index.js +2 -4
  52. package/dist/telemetry/local-span-exporter.d.ts +56 -0
  53. package/dist/telemetry/local-span-exporter.d.ts.map +1 -0
  54. package/dist/telemetry/local-span-exporter.js +118 -0
  55. package/dist/telemetry/local.d.ts +48 -0
  56. package/dist/telemetry/local.d.ts.map +1 -1
  57. package/dist/telemetry/local.js +102 -0
  58. package/dist/telemetry/noop.d.ts +10 -0
  59. package/dist/telemetry/noop.d.ts.map +1 -1
  60. package/dist/telemetry/noop.js +43 -0
  61. package/dist/telemetry/opentelemetry.d.ts +23 -0
  62. package/dist/telemetry/opentelemetry.d.ts.map +1 -1
  63. package/dist/telemetry/opentelemetry.js +39 -0
  64. package/dist/utils/p-retry.d.ts +5 -0
  65. package/dist/utils/p-retry.d.ts.map +1 -1
  66. package/dist/utils/p-retry.js +8 -0
  67. package/dist/utils/wait-for-abort.d.ts +1 -0
  68. package/dist/utils/wait-for-abort.d.ts.map +1 -1
  69. package/dist/utils/wait-for-abort.js +1 -0
  70. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/migration.sql +27 -19
  71. package/migrations/postgres/{20260119153838_flimsy_thor_girl → 20260120154151_mean_magdalene}/snapshot.json +172 -65
  72. package/package.json +7 -2
  73. package/src/action-job.ts +32 -28
  74. package/src/action-manager.ts +5 -5
  75. package/src/action.ts +7 -7
  76. package/src/adapters/adapter.ts +54 -54
  77. package/src/adapters/postgres/base.ts +140 -77
  78. package/src/adapters/postgres/schema.default.ts +2 -2
  79. package/src/adapters/postgres/schema.ts +47 -23
  80. package/src/adapters/schemas.ts +83 -36
  81. package/src/client.ts +195 -42
  82. package/src/index.ts +1 -0
  83. package/src/server.ts +37 -37
  84. package/src/step-manager.ts +170 -86
  85. package/src/telemetry/index.ts +2 -20
  86. package/src/telemetry/local-span-exporter.ts +148 -0
  87. package/src/telemetry/adapter.ts +0 -642
  88. package/src/telemetry/local.ts +0 -429
  89. package/src/telemetry/noop.ts +0 -141
  90. package/src/telemetry/opentelemetry.ts +0 -453
package/dist/client.js CHANGED
@@ -1,27 +1,105 @@
1
+ import { trace } from '@opentelemetry/api';
2
+ import { resourceFromAttributes } from '@opentelemetry/resources';
3
+ import { BatchSpanProcessor } from '@opentelemetry/sdk-trace-base';
4
+ import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node';
5
+ import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions';
1
6
  import pino, {} from 'pino';
2
7
  import { zocker } from 'zocker';
3
8
  import * as z from 'zod';
4
9
  import { ActionManager } from './action-manager.js';
5
10
  import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED } from './constants.js';
6
- import { LocalTelemetryAdapter, noopTelemetryAdapter } from './telemetry/index.js';
11
+ import { LocalSpanExporter } from './telemetry/local-span-exporter.js';
7
12
  const BaseOptionsSchema = z.object({
13
+ /**
14
+ * Unique identifier for this Duron instance.
15
+ * Used for multi-process coordination and job ownership.
16
+ * Defaults to a random UUID if not provided.
17
+ */
8
18
  id: z.string().optional(),
19
+ /**
20
+ * Synchronization pattern for fetching jobs.
21
+ * - `'pull'`: Periodically poll the database for new jobs
22
+ * - `'push'`: Listen for database notifications when jobs are available
23
+ * - `'hybrid'`: Use both pull and push patterns (recommended)
24
+ * - `false`: Disable automatic job fetching (manual fetching only)
25
+ *
26
+ * @default 'hybrid'
27
+ */
9
28
  syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
29
+ /**
30
+ * Interval in milliseconds between pull operations when using pull or hybrid sync pattern.
31
+ *
32
+ * @default 5000
33
+ */
10
34
  pullInterval: z.number().default(5_000),
35
+ /**
36
+ * Maximum number of jobs to fetch in a single batch.
37
+ *
38
+ * @default 10
39
+ */
11
40
  batchSize: z.number().default(10),
41
+ /**
42
+ * Maximum number of jobs that can run concurrently per action.
43
+ * This controls the concurrency limit for the action's fastq queue.
44
+ *
45
+ * @default 100
46
+ */
12
47
  actionConcurrencyLimit: z.number().default(100),
48
+ /**
49
+ * Maximum number of jobs that can run concurrently per group key.
50
+ * Jobs with the same group key will respect this limit.
51
+ * This can be overridden using action -> groups -> concurrency.
52
+ *
53
+ * @default 10
54
+ */
13
55
  groupConcurrencyLimit: z.number().default(10),
56
+ /**
57
+ * Whether to run database migrations on startup.
58
+ * When enabled, Duron will automatically apply pending migrations when the adapter starts.
59
+ *
60
+ * @default true
61
+ */
14
62
  migrateOnStart: z.boolean().default(true),
63
+ /**
64
+ * Whether to recover stuck jobs on startup.
65
+ * Stuck jobs are jobs that were marked as active but the process that owned them
66
+ * is no longer running.
67
+ *
68
+ * @default true
69
+ */
15
70
  recoverJobsOnStart: z.boolean().default(true),
71
+ /**
72
+ * Enable multi-process mode for job recovery.
73
+ * When enabled, Duron will ping other processes to check if they're alive
74
+ * before recovering their jobs.
75
+ *
76
+ * @default false
77
+ */
16
78
  multiProcessMode: z.boolean().default(false),
17
- processTimeout: z.number().default(5 * 1000),
79
+ /**
80
+ * Timeout in milliseconds to wait for process ping responses in multi-process mode.
81
+ * Processes that don't respond within this timeout will have their jobs recovered.
82
+ *
83
+ * @default 5000 (5 seconds)
84
+ */
85
+ processTimeout: z.number().default(5 * 1000), // 5 seconds
18
86
  });
87
+ /**
88
+ * Client is the main entry point for Duron.
89
+ * Manages job execution, action handling, and database operations.
90
+ *
91
+ * @template TActions - Record of action definitions keyed by action name
92
+ * @template TVariables - Type of variables available to actions
93
+ */
19
94
  export class Client {
20
95
  #options;
21
96
  #id;
22
97
  #actions;
23
98
  #database;
24
- #telemetry;
99
+ #tracerProvider = null;
100
+ #tracer;
101
+ #telemetryOptions = null;
102
+ #localSpansEnabled = false;
25
103
  #variables;
26
104
  #logger;
27
105
  #started = false;
@@ -33,18 +111,68 @@ export class Client {
33
111
  #mockInputSchemas = new Map();
34
112
  #pendingJobWaits = new Map();
35
113
  #jobStatusListenerSetup = false;
114
+ // ============================================================================
115
+ // Constructor
116
+ // ============================================================================
117
+ /**
118
+ * Create a new Duron Client instance.
119
+ *
120
+ * @param options - Configuration options for the client
121
+ */
36
122
  constructor(options) {
37
123
  this.#options = BaseOptionsSchema.parse(options);
38
124
  this.#id = options.id ?? globalThis.crypto.randomUUID();
39
125
  this.#database = options.database;
40
- this.#telemetry = options.telemetry ?? noopTelemetryAdapter();
126
+ this.#telemetryOptions = options.telemetry ?? null;
41
127
  this.#actions = options.actions ?? null;
42
128
  this.#variables = options?.variables ?? {};
43
129
  this.#logger = this.#normalizeLogger(options?.logger);
44
130
  this.#database.setId(this.#id);
45
131
  this.#database.setLogger(this.#logger);
46
- this.#telemetry.setLogger(this.#logger);
47
- this.#telemetry.setClient(this);
132
+ // Initialize OpenTelemetry TracerProvider if telemetry options are provided
133
+ // When no options are provided, the tracer will be a no-op (from OpenTelemetry API)
134
+ if (this.#telemetryOptions) {
135
+ this.#initTelemetry(this.#telemetryOptions);
136
+ }
137
+ // Get tracer from our provider if configured, otherwise use global no-op tracer
138
+ // This keeps telemetry scoped to this client instance rather than globally registered
139
+ this.#tracer = this.#tracerProvider?.getTracer('duron') ?? trace.getTracer('duron');
140
+ }
141
+ /**
142
+ * Initialize OpenTelemetry TracerProvider with configured processors.
143
+ */
144
+ #initTelemetry(options) {
145
+ const serviceName = options.serviceName ?? 'duron';
146
+ const processors = [];
147
+ // Add local span exporter if enabled
148
+ if (options.local) {
149
+ const localOptions = typeof options.local === 'boolean' ? {} : options.local;
150
+ const flushDelayMs = localOptions.flushDelayMs ?? 5000;
151
+ const localExporter = new LocalSpanExporter({ adapter: this.#database });
152
+ processors.push(new BatchSpanProcessor(localExporter, {
153
+ scheduledDelayMillis: flushDelayMs,
154
+ }));
155
+ this.#localSpansEnabled = true;
156
+ }
157
+ // Add custom span processors
158
+ if (options.spanProcessors) {
159
+ processors.push(...options.spanProcessors);
160
+ }
161
+ // Add custom trace exporter wrapped in BatchSpanProcessor
162
+ if (options.traceExporter) {
163
+ processors.push(new BatchSpanProcessor(options.traceExporter));
164
+ }
165
+ // Only create TracerProvider if we have processors
166
+ if (processors.length > 0) {
167
+ this.#tracerProvider = new NodeTracerProvider({
168
+ resource: resourceFromAttributes({
169
+ [ATTR_SERVICE_NAME]: serviceName,
170
+ }),
171
+ spanProcessors: processors,
172
+ });
173
+ // Note: We do NOT call .register() here to avoid global state pollution
174
+ // The tracer is obtained directly from this provider instance
175
+ }
48
176
  }
49
177
  #normalizeLogger(logger) {
50
178
  let pinoInstance = null;
@@ -59,18 +187,46 @@ export class Client {
59
187
  }
60
188
  return pinoInstance.child({ duron: this.#id });
61
189
  }
190
+ // ============================================================================
191
+ // Public API Methods
192
+ // ============================================================================
62
193
  get logger() {
63
194
  return this.#logger;
64
195
  }
65
- get telemetry() {
66
- return this.#telemetry;
196
+ /**
197
+ * Get the OpenTelemetry tracer for creating custom spans.
198
+ * Always returns a tracer - it's a no-op tracer when no SDK is configured.
199
+ */
200
+ get tracer() {
201
+ return this.#tracer;
67
202
  }
203
+ /**
204
+ * Get the database adapter instance.
205
+ */
68
206
  get database() {
69
207
  return this.#database;
70
208
  }
71
- get metricsEnabled() {
72
- return this.#telemetry instanceof LocalTelemetryAdapter;
73
- }
209
+ /**
210
+ * Check if local span storage is enabled.
211
+ * Returns true if telemetry.local is enabled.
212
+ */
213
+ get spansEnabled() {
214
+ return this.#localSpansEnabled;
215
+ }
216
+ /**
217
+ * Force flush any pending telemetry data.
218
+ * Useful in tests or when you need to ensure spans are exported before querying.
219
+ */
220
+ async flushTelemetry() {
221
+ if (this.#tracerProvider) {
222
+ await this.#tracerProvider.forceFlush();
223
+ }
224
+ }
225
+ /**
226
+ * Get the current configuration of this Duron instance.
227
+ *
228
+ * @returns Configuration object including options, actions, and variables
229
+ */
74
230
  getConfig() {
75
231
  return {
76
232
  ...this.#options,
@@ -78,12 +234,21 @@ export class Client {
78
234
  variables: this.#variables,
79
235
  };
80
236
  }
237
+ /**
238
+ * Run an action by creating a new job.
239
+ *
240
+ * @param actionName - Name of the action to run
241
+ * @param input - Input data for the action (validated against action's input schema if provided)
242
+ * @returns Promise resolving to the created job ID
243
+ * @throws Error if action is not found or job creation fails
244
+ */
81
245
  async runAction(actionName, input) {
82
246
  await this.start();
83
247
  const action = this.#actions?.[actionName];
84
248
  if (!action) {
85
249
  throw new Error(`Action ${String(actionName)} not found`);
86
250
  }
251
+ // Validate input if schema is provided
87
252
  let validatedInput = input ?? {};
88
253
  if (action.input) {
89
254
  validatedInput = action.input.parse(validatedInput, {
@@ -91,6 +256,7 @@ export class Client {
91
256
  reportInput: true,
92
257
  });
93
258
  }
259
+ // Determine groupKey and concurrency limit using concurrency handler or defaults
94
260
  const concurrencyCtx = {
95
261
  input: validatedInput,
96
262
  var: this.#variables,
@@ -103,6 +269,7 @@ export class Client {
103
269
  if (action.groups?.concurrency) {
104
270
  concurrencyLimit = await action.groups.concurrency(concurrencyCtx);
105
271
  }
272
+ // Create job in database
106
273
  const jobId = await this.#database.createJob({
107
274
  queue: action.name,
108
275
  groupKey,
@@ -117,11 +284,24 @@ export class Client {
117
284
  this.#logger.debug({ jobId, actionName: String(actionName), groupKey }, '[Duron] Action sent/created');
118
285
  return jobId;
119
286
  }
287
+ /**
288
+ * Run an action and wait for its completion.
289
+ * This is a convenience method that combines `runAction` and `waitForJob`.
290
+ *
291
+ * @param actionName - Name of the action to run
292
+ * @param input - Input data for the action (validated against action's input schema if provided)
293
+ * @param options - Options including abort signal and timeout
294
+ * @returns Promise resolving to the job result with typed input and output
295
+ * @throws Error if action is not found, job creation fails, job is cancelled, or operation is aborted
296
+ */
120
297
  async runActionAndWait(actionName, input, options) {
298
+ // Check if already aborted before starting
121
299
  if (options?.signal?.aborted) {
122
300
  throw new Error('Operation was aborted');
123
301
  }
302
+ // Create the job
124
303
  const jobId = await this.runAction(actionName, input);
304
+ // Set up abort handler to cancel the job if signal is aborted
125
305
  let abortHandler;
126
306
  if (options?.signal) {
127
307
  abortHandler = () => {
@@ -131,6 +311,7 @@ export class Client {
131
311
  };
132
312
  options.signal.addEventListener('abort', abortHandler, { once: true });
133
313
  }
314
+ // Set up timeout handler to cancel the job if timeout is reached
134
315
  let timeoutId;
135
316
  let timeoutAbortController;
136
317
  if (options?.timeout) {
@@ -143,6 +324,7 @@ export class Client {
143
324
  }, options.timeout);
144
325
  }
145
326
  try {
327
+ // Combine signals if both are provided
146
328
  let waitSignal;
147
329
  if (options?.signal && timeoutAbortController) {
148
330
  waitSignal = AbortSignal.any([options.signal, timeoutAbortController.signal]);
@@ -153,13 +335,16 @@ export class Client {
153
335
  else if (timeoutAbortController) {
154
336
  waitSignal = timeoutAbortController.signal;
155
337
  }
338
+ // Wait for the job to complete
156
339
  const job = await this.waitForJob(jobId, { signal: waitSignal });
340
+ // Clean up
157
341
  if (timeoutId) {
158
342
  clearTimeout(timeoutId);
159
343
  }
160
344
  if (options?.signal && abortHandler) {
161
345
  options.signal.removeEventListener('abort', abortHandler);
162
346
  }
347
+ // Handle null result (aborted or timed out)
163
348
  if (!job) {
164
349
  if (options?.signal?.aborted) {
165
350
  throw new Error('Operation was aborted');
@@ -169,6 +354,7 @@ export class Client {
169
354
  }
170
355
  throw new Error('Job not found');
171
356
  }
357
+ // Handle cancelled job
172
358
  if (job.status === JOB_STATUS_CANCELLED) {
173
359
  if (options?.signal?.aborted) {
174
360
  throw new Error('Operation was aborted');
@@ -178,6 +364,7 @@ export class Client {
178
364
  }
179
365
  throw new Error('Job was cancelled');
180
366
  }
367
+ // Handle failed job
181
368
  if (job.status === JOB_STATUS_FAILED) {
182
369
  const errorMessage = job.error?.message ?? 'Job failed';
183
370
  const error = new Error(errorMessage);
@@ -186,9 +373,11 @@ export class Client {
186
373
  }
187
374
  throw error;
188
375
  }
376
+ // Return the job result with typed input/output
189
377
  return job;
190
378
  }
191
379
  catch (err) {
380
+ // Clean up on error
192
381
  if (timeoutId) {
193
382
  clearTimeout(timeoutId);
194
383
  }
@@ -198,19 +387,37 @@ export class Client {
198
387
  throw err;
199
388
  }
200
389
  }
201
- async fetch(options) {
390
+ /**
391
+ * Fetch and process jobs from the database.
392
+ * Concurrency limits are determined from the latest job created for each groupKey.
393
+ *
394
+ * @param [options.batchSize] - Maximum number of jobs to fetch in this batch (defaults to `batchSize` from client options)
395
+ * @returns Promise resolving to the array of fetched jobs
396
+ */
397
+ async fetch(options = {}) {
202
398
  await this.start();
203
399
  if (!this.#actions) {
204
400
  return [];
205
401
  }
402
+ // Fetch jobs from each action's queue
403
+ // Concurrency limits are determined from the latest job created for each groupKey
206
404
  const jobs = await this.#database.fetch({
207
405
  batch: options.batchSize ?? this.#options.batchSize,
208
406
  });
407
+ // Process fetched jobs
209
408
  for (const job of jobs) {
210
409
  this.#executeJob(job);
211
410
  }
212
411
  return jobs;
213
412
  }
413
+ /**
414
+ * Cancel a job by its ID.
415
+ * If the job is currently being processed, it will be cancelled immediately.
416
+ * Otherwise, it will be cancelled in the database.
417
+ *
418
+ * @param jobId - The ID of the job to cancel
419
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
420
+ */
214
421
  async cancelJob(jobId) {
215
422
  await this.start();
216
423
  let cancelled = false;
@@ -221,52 +428,134 @@ export class Client {
221
428
  }
222
429
  }
223
430
  if (!cancelled) {
431
+ // If the job is not being processed, cancel it in the database
224
432
  await this.#database.cancelJob({ jobId });
225
433
  }
226
434
  return cancelled;
227
435
  }
436
+ /**
437
+ * Retry a failed job by creating a copy of it with status 'created' and cleared output/error.
438
+ *
439
+ * @param jobId - The ID of the job to retry
440
+ * @returns Promise resolving to the new job ID, or `null` if retry failed
441
+ */
228
442
  async retryJob(jobId) {
229
443
  await this.start();
230
444
  return this.#database.retryJob({ jobId });
231
445
  }
446
+ /**
447
+ * Time travel a job to restart from a specific step.
448
+ * The job must be in completed, failed, or cancelled status.
449
+ * Resets the job and ancestor steps to active status, deletes subsequent steps,
450
+ * and preserves completed parallel siblings.
451
+ *
452
+ * @param jobId - The ID of the job to time travel
453
+ * @param stepId - The ID of the step to restart from
454
+ * @returns Promise resolving to `true` if time travel succeeded, `false` otherwise
455
+ */
232
456
  async timeTravelJob(jobId, stepId) {
233
457
  await this.start();
234
458
  return this.#database.timeTravelJob({ jobId, stepId });
235
459
  }
460
+ /**
461
+ * Delete a job by its ID.
462
+ * Active jobs cannot be deleted.
463
+ *
464
+ * @param jobId - The ID of the job to delete
465
+ * @returns Promise resolving to `true` if deleted, `false` otherwise
466
+ */
236
467
  async deleteJob(jobId) {
237
468
  await this.start();
238
469
  return this.#database.deleteJob({ jobId });
239
470
  }
471
+ /**
472
+ * Delete multiple jobs using the same filters as getJobs.
473
+ * Active jobs cannot be deleted and will be excluded from deletion.
474
+ *
475
+ * @param options - Query options including filters (same as getJobs)
476
+ * @returns Promise resolving to the number of jobs deleted
477
+ */
240
478
  async deleteJobs(options) {
241
479
  await this.start();
242
480
  return this.#database.deleteJobs(options);
243
481
  }
482
+ // ============================================================================
483
+ // Query Methods
484
+ // ============================================================================
485
+ /**
486
+ * Get a job by its ID. Does not include step information.
487
+ *
488
+ * @param jobId - The ID of the job to retrieve
489
+ * @returns Promise resolving to the job, or `null` if not found
490
+ */
244
491
  async getJobById(jobId) {
245
492
  await this.start();
246
493
  return this.#database.getJobById(jobId);
247
494
  }
495
+ /**
496
+ * Get steps for a job with pagination and fuzzy search.
497
+ * Steps are always ordered by created_at ASC.
498
+ * Steps do not include output data.
499
+ *
500
+ * @param options - Query options including jobId, pagination, and search
501
+ * @returns Promise resolving to steps result with pagination info
502
+ */
248
503
  async getJobSteps(options) {
249
504
  await this.start();
250
505
  return this.#database.getJobSteps(options);
251
506
  }
507
+ /**
508
+ * Get jobs with pagination, filtering, and sorting.
509
+ * Does not include step information or job output.
510
+ *
511
+ * @param options - Query options including pagination, filters, and sort
512
+ * @returns Promise resolving to jobs result with pagination info
513
+ */
252
514
  async getJobs(options) {
253
515
  await this.start();
254
516
  return this.#database.getJobs(options);
255
517
  }
518
+ /**
519
+ * Get a step by its ID with all information.
520
+ *
521
+ * @param stepId - The ID of the step to retrieve
522
+ * @returns Promise resolving to the step, or `null` if not found
523
+ */
256
524
  async getJobStepById(stepId) {
257
525
  await this.start();
258
526
  return this.#database.getJobStepById(stepId);
259
527
  }
528
+ /**
529
+ * Get job status and updatedAt timestamp.
530
+ *
531
+ * @param jobId - The ID of the job
532
+ * @returns Promise resolving to job status result, or `null` if not found
533
+ */
260
534
  async getJobStatus(jobId) {
261
535
  await this.start();
262
536
  return this.#database.getJobStatus(jobId);
263
537
  }
538
+ /**
539
+ * Get job step status and updatedAt timestamp.
540
+ *
541
+ * @param stepId - The ID of the step
542
+ * @returns Promise resolving to step status result, or `null` if not found
543
+ */
264
544
  async getJobStepStatus(stepId) {
265
545
  await this.start();
266
546
  return this.#database.getJobStepStatus(stepId);
267
547
  }
548
+ /**
549
+ * Wait for a job to change status by subscribing to job-status-changed events.
550
+ * When the job status changes, the job result is returned.
551
+ *
552
+ * @param jobId - The ID of the job to wait for
553
+ * @param options - Optional configuration including timeout
554
+ * @returns Promise resolving to the job result when its status changes, or `null` if timeout
555
+ */
268
556
  async waitForJob(jobId, options) {
269
557
  await this.start();
558
+ // First, check if the job already exists and is in a terminal state
270
559
  const existingJobStatus = await this.getJobStatus(jobId);
271
560
  if (existingJobStatus) {
272
561
  const terminalStatuses = [JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED];
@@ -276,7 +565,7 @@ export class Client {
276
565
  return null;
277
566
  }
278
567
  return {
279
- jobId: job.id,
568
+ id: job.id,
280
569
  actionName: job.actionName,
281
570
  status: job.status,
282
571
  groupKey: job.groupKey,
@@ -286,20 +575,24 @@ export class Client {
286
575
  };
287
576
  }
288
577
  }
578
+ // Set up the shared event listener if not already set up
289
579
  this.#setupJobStatusListener();
290
580
  return new Promise((resolve) => {
581
+ // Check if already aborted before setting up wait
291
582
  if (options?.signal?.aborted) {
292
583
  resolve(null);
293
584
  return;
294
585
  }
295
586
  let timeoutId;
296
587
  let abortHandler;
588
+ // Set up timeout if provided
297
589
  if (options?.timeout) {
298
590
  timeoutId = setTimeout(() => {
299
591
  this.#removeJobWait(jobId, resolve);
300
592
  resolve(null);
301
593
  }, options.timeout);
302
594
  }
595
+ // Set up abort signal if provided
303
596
  if (options?.signal) {
304
597
  abortHandler = () => {
305
598
  this.#removeJobWait(jobId, resolve);
@@ -307,6 +600,7 @@ export class Client {
307
600
  };
308
601
  options.signal.addEventListener('abort', abortHandler);
309
602
  }
603
+ // Add this wait request to the pending waits
310
604
  if (!this.#pendingJobWaits.has(jobId)) {
311
605
  this.#pendingJobWaits.set(jobId, new Set());
312
606
  }
@@ -318,17 +612,36 @@ export class Client {
318
612
  });
319
613
  });
320
614
  }
615
+ /**
616
+ * Get action statistics including counts and last job created date.
617
+ *
618
+ * @returns Promise resolving to action statistics
619
+ */
321
620
  async getActions() {
322
621
  await this.start();
323
622
  return this.#database.getActions();
324
623
  }
325
- async getMetrics(options) {
624
+ /**
625
+ * Get spans for a job or step.
626
+ * Only available when telemetry.local is enabled.
627
+ *
628
+ * @param options - Query options including jobId/stepId, filters, and sort
629
+ * @returns Promise resolving to spans result
630
+ * @throws Error if local telemetry is not enabled
631
+ */
632
+ async getSpans(options) {
326
633
  await this.start();
327
- if (!this.metricsEnabled) {
328
- throw new Error('Metrics are only available when using LocalTelemetryAdapter');
634
+ if (!this.spansEnabled) {
635
+ throw new Error('Spans are only available when telemetry.local is enabled');
329
636
  }
330
- return this.#database.getMetrics(options);
637
+ return this.#database.getSpans(options);
331
638
  }
639
+ /**
640
+ * Get action metadata including input schemas and mock data.
641
+ * This is useful for generating UI forms or mock data.
642
+ *
643
+ * @returns Promise resolving to action metadata
644
+ */
332
645
  async getActionsMetadata() {
333
646
  await this.start();
334
647
  if (!this.#actions) {
@@ -350,6 +663,15 @@ export class Client {
350
663
  };
351
664
  });
352
665
  }
666
+ // ============================================================================
667
+ // Lifecycle Methods
668
+ // ============================================================================
669
+ /**
670
+ * Start the Duron instance.
671
+ * Initializes the database, recovers stuck jobs, and sets up sync patterns.
672
+ *
673
+ * @returns Promise resolving to `true` if started successfully, `false` otherwise
674
+ */
353
675
  async start() {
354
676
  if (this.#stopping || this.#stopped) {
355
677
  return false;
@@ -365,7 +687,6 @@ export class Client {
365
687
  if (!dbStarted) {
366
688
  return false;
367
689
  }
368
- await this.#telemetry.start();
369
690
  if (this.#actions) {
370
691
  if (this.#options.recoverJobsOnStart) {
371
692
  await this.#database.recoverJobs({
@@ -374,6 +695,7 @@ export class Client {
374
695
  processTimeout: this.#options.processTimeout,
375
696
  });
376
697
  }
698
+ // Setup sync pattern
377
699
  if (this.#options.syncPattern === 'pull' || this.#options.syncPattern === 'hybrid') {
378
700
  this.#startPullLoop();
379
701
  }
@@ -387,6 +709,12 @@ export class Client {
387
709
  })();
388
710
  return this.#starting;
389
711
  }
712
+ /**
713
+ * Stop the Duron instance.
714
+ * Stops the pull loop, aborts all running jobs, waits for queues to drain, and stops the database.
715
+ *
716
+ * @returns Promise resolving to `true` if stopped successfully, `false` otherwise
717
+ */
390
718
  async stop() {
391
719
  if (this.#stopped) {
392
720
  return true;
@@ -395,10 +723,12 @@ export class Client {
395
723
  return this.#stopping;
396
724
  }
397
725
  this.#stopping = (async () => {
726
+ // Stop pull loop
398
727
  if (this.#pullInterval) {
399
728
  clearTimeout(this.#pullInterval);
400
729
  this.#pullInterval = null;
401
730
  }
731
+ // Clean up all pending job waits
402
732
  for (const waits of this.#pendingJobWaits.values()) {
403
733
  for (const wait of waits) {
404
734
  if (wait.timeoutId) {
@@ -411,10 +741,14 @@ export class Client {
411
741
  }
412
742
  }
413
743
  this.#pendingJobWaits.clear();
744
+ // Wait for action managers to drain
414
745
  await Promise.all(Array.from(this.#actionManagers.values()).map(async (manager) => {
415
746
  await manager.stop();
416
747
  }));
417
- await this.#telemetry.stop();
748
+ // Shutdown TracerProvider if configured
749
+ if (this.#tracerProvider) {
750
+ await this.#tracerProvider.shutdown();
751
+ }
418
752
  const dbStopped = await this.#database.stop();
419
753
  if (!dbStopped) {
420
754
  return false;
@@ -425,6 +759,13 @@ export class Client {
425
759
  })();
426
760
  return this.#stopping;
427
761
  }
762
+ // ============================================================================
763
+ // Private Methods
764
+ // ============================================================================
765
+ /**
766
+ * Set up the shared event listener for job-status-changed events.
767
+ * This listener is shared across all waitForJob calls to avoid multiple listeners.
768
+ */
428
769
  #setupJobStatusListener() {
429
770
  if (this.#jobStatusListenerSetup) {
430
771
  return;
@@ -435,10 +776,12 @@ export class Client {
435
776
  if (!pendingWaits || pendingWaits.size === 0) {
436
777
  return;
437
778
  }
779
+ // Fetch the job once for all pending waits
438
780
  const job = await this.getJobById(event.jobId);
781
+ // Transform to JobResult
439
782
  const result = job
440
783
  ? {
441
- jobId: job.id,
784
+ id: job.id,
442
785
  actionName: job.actionName,
443
786
  status: job.status,
444
787
  groupKey: job.groupKey,
@@ -447,9 +790,11 @@ export class Client {
447
790
  error: job.error,
448
791
  }
449
792
  : null;
793
+ // Resolve all pending waits for this job
450
794
  const waitsToResolve = Array.from(pendingWaits);
451
795
  this.#pendingJobWaits.delete(event.jobId);
452
796
  for (const wait of waitsToResolve) {
797
+ // Clean up timeout and abort signal
453
798
  if (wait.timeoutId) {
454
799
  clearTimeout(wait.timeoutId);
455
800
  }
@@ -460,11 +805,18 @@ export class Client {
460
805
  }
461
806
  });
462
807
  }
808
+ /**
809
+ * Remove a specific wait request from the pending waits.
810
+ *
811
+ * @param jobId - The job ID
812
+ * @param resolve - The resolve function to remove
813
+ */
463
814
  #removeJobWait(jobId, resolve) {
464
815
  const pendingWaits = this.#pendingJobWaits.get(jobId);
465
816
  if (!pendingWaits) {
466
817
  return;
467
818
  }
819
+ // Find and remove the specific wait request
468
820
  for (const wait of pendingWaits) {
469
821
  if (wait.resolve === resolve) {
470
822
  if (wait.timeoutId) {
@@ -477,10 +829,16 @@ export class Client {
477
829
  break;
478
830
  }
479
831
  }
832
+ // Clean up empty sets
480
833
  if (pendingWaits.size === 0) {
481
834
  this.#pendingJobWaits.delete(jobId);
482
835
  }
483
836
  }
837
+ /**
838
+ * Execute a job by finding its action and queuing it with the appropriate ActionManager.
839
+ *
840
+ * @param job - The job to execute
841
+ */
484
842
  #executeJob(job) {
485
843
  if (!this.#actions) {
486
844
  return;
@@ -494,22 +852,29 @@ export class Client {
494
852
  });
495
853
  return;
496
854
  }
855
+ // Get or create ActionManager for this action
497
856
  let actionManager = this.#actionManagers.get(action.name);
498
857
  if (!actionManager) {
499
858
  actionManager = new ActionManager({
500
859
  action,
501
860
  database: this.#database,
502
- telemetry: this.#telemetry,
861
+ tracer: this.#tracer,
503
862
  variables: this.#variables,
504
863
  logger: this.#logger,
505
864
  concurrencyLimit: this.#options.actionConcurrencyLimit,
506
865
  });
507
866
  this.#actionManagers.set(action.name, actionManager);
508
867
  }
868
+ // Queue job execution
509
869
  actionManager.push(job).catch((err) => {
870
+ // Only log unexpected errors (not cancellation/timeout which are handled elsewhere)
510
871
  this.#logger.error({ err, jobId: job.id, actionName: action.name }, `[Duron] Error executing job ${job.id} for action ${action.name}`);
511
872
  });
512
873
  }
874
+ /**
875
+ * Start the pull loop for periodically fetching jobs.
876
+ * Only starts if not already running.
877
+ */
513
878
  #startPullLoop() {
514
879
  if (this.#pullInterval) {
515
880
  return;
@@ -530,8 +895,13 @@ export class Client {
530
895
  this.#pullInterval = setTimeout(pull, this.#options.pullInterval);
531
896
  }
532
897
  };
898
+ // Start immediately
533
899
  pull();
534
900
  }
901
+ /**
902
+ * Setup the push listener for database notifications.
903
+ * Listens for 'job-available' events and fetches jobs when notified.
904
+ */
535
905
  #setupPushListener() {
536
906
  this.#database.on('job-available', async () => {
537
907
  this.fetch({