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