duron 0.3.0-beta.5 → 0.3.0-beta.7

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.
@@ -5,6 +5,7 @@ export class OpenTelemetryAdapter extends TelemetryAdapter {
5
5
  #traceDatabaseQueries;
6
6
  #tracer = null;
7
7
  #spanMap = new Map();
8
+ #tracerCache = new Map();
8
9
  constructor(options = {}) {
9
10
  super();
10
11
  this.#serviceName = options.serviceName ?? 'duron';
@@ -198,5 +199,114 @@ export class OpenTelemetryAdapter extends TelemetryAdapter {
198
199
  extSpan.otelSpan.setAttribute(options.key, options.value);
199
200
  }
200
201
  }
202
+ _getTracer(name) {
203
+ const cached = this.#tracerCache.get(name);
204
+ if (cached) {
205
+ return cached;
206
+ }
207
+ const adapter = this;
208
+ const tracer = {
209
+ name,
210
+ startSpan(spanName, options) {
211
+ let otelSpan = null;
212
+ let api = null;
213
+ let ended = false;
214
+ const initPromise = (async () => {
215
+ api = await import('@opentelemetry/api');
216
+ let otelTracer;
217
+ if (adapter.#tracerProvider) {
218
+ otelTracer = adapter.#tracerProvider.getTracer(name);
219
+ }
220
+ else {
221
+ otelTracer = api.trace.getTracer(name);
222
+ }
223
+ let spanKind = api.SpanKind.INTERNAL;
224
+ if (options?.kind === 'client')
225
+ spanKind = api.SpanKind.CLIENT;
226
+ else if (options?.kind === 'server')
227
+ spanKind = api.SpanKind.SERVER;
228
+ else if (options?.kind === 'producer')
229
+ spanKind = api.SpanKind.PRODUCER;
230
+ else if (options?.kind === 'consumer')
231
+ spanKind = api.SpanKind.CONSUMER;
232
+ let parentContext = api.context.active();
233
+ if (options?.parentSpan) {
234
+ const parentOtelSpan = options.parentSpan._otelSpan;
235
+ if (parentOtelSpan) {
236
+ parentContext = api.trace.setSpan(api.context.active(), parentOtelSpan);
237
+ }
238
+ }
239
+ otelSpan = otelTracer.startSpan(spanName, {
240
+ kind: spanKind,
241
+ attributes: options?.attributes,
242
+ }, parentContext);
243
+ })();
244
+ const tracerSpan = {
245
+ setAttribute(key, value) {
246
+ initPromise.then(() => {
247
+ if (otelSpan && !ended) {
248
+ otelSpan.setAttribute(key, value);
249
+ }
250
+ });
251
+ },
252
+ setAttributes(attributes) {
253
+ initPromise.then(() => {
254
+ if (otelSpan && !ended) {
255
+ otelSpan.setAttributes(attributes);
256
+ }
257
+ });
258
+ },
259
+ addEvent(eventName, attributes) {
260
+ initPromise.then(() => {
261
+ if (otelSpan && !ended) {
262
+ otelSpan.addEvent(eventName, attributes);
263
+ }
264
+ });
265
+ },
266
+ recordException(error) {
267
+ initPromise.then(() => {
268
+ if (otelSpan && !ended) {
269
+ otelSpan.recordException(error);
270
+ }
271
+ });
272
+ },
273
+ setStatusOk() {
274
+ initPromise.then(() => {
275
+ if (otelSpan && api && !ended) {
276
+ otelSpan.setStatus({ code: api.SpanStatusCode.OK });
277
+ }
278
+ });
279
+ },
280
+ setStatusError(message) {
281
+ initPromise.then(() => {
282
+ if (otelSpan && api && !ended) {
283
+ otelSpan.setStatus({ code: api.SpanStatusCode.ERROR, message });
284
+ }
285
+ });
286
+ },
287
+ end() {
288
+ initPromise.then(() => {
289
+ if (otelSpan && !ended) {
290
+ ended = true;
291
+ otelSpan.end();
292
+ }
293
+ });
294
+ },
295
+ isRecording() {
296
+ return otelSpan?.isRecording() ?? false;
297
+ },
298
+ };
299
+ initPromise.then(() => {
300
+ if (otelSpan) {
301
+ ;
302
+ tracerSpan._otelSpan = otelSpan;
303
+ }
304
+ });
305
+ return tracerSpan;
306
+ },
307
+ };
308
+ this.#tracerCache.set(name, tracer);
309
+ return tracer;
310
+ }
201
311
  }
202
312
  export const openTelemetryAdapter = (options) => new OpenTelemetryAdapter(options);
@@ -2,9 +2,7 @@
2
2
  "version": "8",
3
3
  "dialect": "postgres",
4
4
  "id": "4e93c0bf-b135-40b4-b130-0cf0968bffe3",
5
- "prevIds": [
6
- "00000000-0000-0000-0000-000000000000"
7
- ],
5
+ "prevIds": ["00000000-0000-0000-0000-000000000000"],
8
6
  "ddl": [
9
7
  {
10
8
  "name": "duron",
@@ -1268,14 +1266,10 @@
1268
1266
  },
1269
1267
  {
1270
1268
  "nameExplicit": false,
1271
- "columns": [
1272
- "job_id"
1273
- ],
1269
+ "columns": ["job_id"],
1274
1270
  "schemaTo": "duron",
1275
1271
  "tableTo": "jobs",
1276
- "columnsTo": [
1277
- "id"
1278
- ],
1272
+ "columnsTo": ["id"],
1279
1273
  "onUpdate": "NO ACTION",
1280
1274
  "onDelete": "CASCADE",
1281
1275
  "name": "job_steps_job_id_jobs_id_fkey",
@@ -1285,14 +1279,10 @@
1285
1279
  },
1286
1280
  {
1287
1281
  "nameExplicit": false,
1288
- "columns": [
1289
- "job_id"
1290
- ],
1282
+ "columns": ["job_id"],
1291
1283
  "schemaTo": "duron",
1292
1284
  "tableTo": "jobs",
1293
- "columnsTo": [
1294
- "id"
1295
- ],
1285
+ "columnsTo": ["id"],
1296
1286
  "onUpdate": "NO ACTION",
1297
1287
  "onDelete": "CASCADE",
1298
1288
  "name": "metrics_job_id_jobs_id_fkey",
@@ -1302,14 +1292,10 @@
1302
1292
  },
1303
1293
  {
1304
1294
  "nameExplicit": false,
1305
- "columns": [
1306
- "step_id"
1307
- ],
1295
+ "columns": ["step_id"],
1308
1296
  "schemaTo": "duron",
1309
1297
  "tableTo": "job_steps",
1310
- "columnsTo": [
1311
- "id"
1312
- ],
1298
+ "columnsTo": ["id"],
1313
1299
  "onUpdate": "NO ACTION",
1314
1300
  "onDelete": "CASCADE",
1315
1301
  "name": "metrics_step_id_job_steps_id_fkey",
@@ -1318,9 +1304,7 @@
1318
1304
  "table": "metrics"
1319
1305
  },
1320
1306
  {
1321
- "columns": [
1322
- "id"
1323
- ],
1307
+ "columns": ["id"],
1324
1308
  "nameExplicit": false,
1325
1309
  "name": "job_steps_pkey",
1326
1310
  "schema": "duron",
@@ -1328,9 +1312,7 @@
1328
1312
  "entityType": "pks"
1329
1313
  },
1330
1314
  {
1331
- "columns": [
1332
- "id"
1333
- ],
1315
+ "columns": ["id"],
1334
1316
  "nameExplicit": false,
1335
1317
  "name": "jobs_pkey",
1336
1318
  "schema": "duron",
@@ -1338,9 +1320,7 @@
1338
1320
  "entityType": "pks"
1339
1321
  },
1340
1322
  {
1341
- "columns": [
1342
- "id"
1343
- ],
1323
+ "columns": ["id"],
1344
1324
  "nameExplicit": false,
1345
1325
  "name": "metrics_pkey",
1346
1326
  "schema": "duron",
@@ -1349,11 +1329,7 @@
1349
1329
  },
1350
1330
  {
1351
1331
  "nameExplicit": true,
1352
- "columns": [
1353
- "job_id",
1354
- "name",
1355
- "parent_step_id"
1356
- ],
1332
+ "columns": ["job_id", "name", "parent_step_id"],
1357
1333
  "nullsNotDistinct": true,
1358
1334
  "name": "unique_job_step_name_parent",
1359
1335
  "entityType": "uniques",
@@ -1383,4 +1359,4 @@
1383
1359
  }
1384
1360
  ],
1385
1361
  "renames": []
1386
- }
1362
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "duron",
3
- "version": "0.3.0-beta.5",
3
+ "version": "0.3.0-beta.7",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "module": "./dist/index.js",
package/src/action.ts CHANGED
@@ -133,18 +133,13 @@ export interface StepDefinitionHandlerContext<TInput extends z.ZodObject, TVaria
133
133
  * The job ID this step belongs to.
134
134
  */
135
135
  jobId: string
136
-
137
136
  }
138
137
 
139
138
  /**
140
139
  * A reusable step definition created with createStep().
141
140
  * Can be executed within an action handler using ctx.run().
142
141
  */
143
- export interface StepDefinition<
144
- TInput extends z.ZodObject,
145
- TResult,
146
- TVariables = Record<string, unknown>,
147
- > {
142
+ export interface StepDefinition<TInput extends z.ZodObject, TResult, TVariables = Record<string, unknown>> {
148
143
  /**
149
144
  * The name of the step.
150
145
  * Can be a static string or a function that generates the name from the input.
@@ -417,11 +412,10 @@ export const defineAction = <TVariables = Record<string, unknown>>() => {
417
412
  /**
418
413
  * Input type for createStep() - the definition object before transformation.
419
414
  */
420
- export type StepDefinitionInput<
421
- TInput extends z.ZodObject,
422
- TResult,
423
- TVariables = Record<string, unknown>,
424
- > = Omit<StepDefinition<TInput, TResult, TVariables>, '__stepDefinition'>
415
+ export type StepDefinitionInput<TInput extends z.ZodObject, TResult, TVariables = Record<string, unknown>> = Omit<
416
+ StepDefinition<TInput, TResult, TVariables>,
417
+ '__stepDefinition'
418
+ >
425
419
 
426
420
  /**
427
421
  * Creates a reusable step definition that can be executed within action handlers.
@@ -119,7 +119,9 @@ export default function createSchema(schemaName: string) {
119
119
  index('idx_job_steps_output_fts').using('gin', sql`to_tsvector('english', ${table.output}::text)`),
120
120
  // Unique constraint - step name is unique within a parent (name + parentStepId)
121
121
  // nullsNotDistinct ensures NULL parent_step_id values are treated as equal for uniqueness
122
- unique('unique_job_step_name_parent').on(table.job_id, table.name, table.parent_step_id).nullsNotDistinct(),
122
+ unique('unique_job_step_name_parent')
123
+ .on(table.job_id, table.name, table.parent_step_id)
124
+ .nullsNotDistinct(),
123
125
  check(
124
126
  'job_steps_status_check',
125
127
  sql`${table.status} IN ${sql.raw(`(${STEP_STATUSES.map((s) => `'${s}'`).join(',')})`)}`,
package/src/client.ts CHANGED
@@ -20,6 +20,38 @@ import type { JobStatusResult, JobStepStatusResult } from './adapters/schemas.js
20
20
  import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, type JobStatus } from './constants.js'
21
21
  import { LocalTelemetryAdapter, noopTelemetryAdapter, type TelemetryAdapter } from './telemetry/index.js'
22
22
 
23
+ /**
24
+ * Extracts the inferred type from an action's input/output schema.
25
+ * Handles the case where the schema might be undefined.
26
+ */
27
+ type InferActionSchema<T> = T extends z.ZodTypeAny ? z.infer<T> : Record<string, unknown>
28
+
29
+ /**
30
+ * Result returned from waitForJob with untyped input and output.
31
+ */
32
+ export interface JobResult {
33
+ jobId: string
34
+ actionName: string
35
+ status: JobStatus
36
+ groupKey: string
37
+ input: unknown
38
+ output: unknown
39
+ error: Job['error']
40
+ }
41
+
42
+ /**
43
+ * Result returned from runActionAndWait with typed input and output based on the action's Zod schemas.
44
+ */
45
+ export interface TypedJobResult<TAction extends Action<any, any, any>> {
46
+ jobId: string
47
+ actionName: string
48
+ status: JobStatus
49
+ groupKey: string
50
+ input: InferActionSchema<NonNullable<TAction['input']>>
51
+ output: InferActionSchema<NonNullable<TAction['output']>>
52
+ error: Job['error']
53
+ }
54
+
23
55
  const BaseOptionsSchema = z.object({
24
56
  /**
25
57
  * Unique identifier for this Duron instance.
@@ -184,7 +216,7 @@ export class Client<
184
216
  #pendingJobWaits = new Map<
185
217
  string,
186
218
  Set<{
187
- resolve: (job: Job | null) => void
219
+ resolve: (result: JobResult | null) => void
188
220
  timeoutId?: NodeJS.Timeout
189
221
  signal?: AbortSignal
190
222
  abortHandler?: () => void
@@ -335,6 +367,132 @@ export class Client<
335
367
  return jobId
336
368
  }
337
369
 
370
+ /**
371
+ * Run an action and wait for its completion.
372
+ * This is a convenience method that combines `runAction` and `waitForJob`.
373
+ *
374
+ * @param actionName - Name of the action to run
375
+ * @param input - Input data for the action (validated against action's input schema if provided)
376
+ * @param options - Options including abort signal and timeout
377
+ * @returns Promise resolving to the job result with typed input and output
378
+ * @throws Error if action is not found, job creation fails, job is cancelled, or operation is aborted
379
+ */
380
+ async runActionAndWait<TActionName extends keyof TActions>(
381
+ actionName: TActionName,
382
+ input?: NonNullable<TActions[TActionName]['input']> extends z.ZodObject
383
+ ? z.input<NonNullable<TActions[TActionName]['input']>>
384
+ : never,
385
+ options?: {
386
+ /**
387
+ * AbortSignal to cancel the operation. If aborted, the job will be cancelled and the promise will reject.
388
+ */
389
+ signal?: AbortSignal
390
+ /**
391
+ * Timeout in milliseconds. If the job doesn't complete within this time, the job will be cancelled and the promise will reject.
392
+ */
393
+ timeout?: number
394
+ },
395
+ ): Promise<TypedJobResult<TActions[TActionName]>> {
396
+ // Check if already aborted before starting
397
+ if (options?.signal?.aborted) {
398
+ throw new Error('Operation was aborted')
399
+ }
400
+
401
+ // Create the job
402
+ const jobId = await this.runAction(actionName, input)
403
+
404
+ // Set up abort handler to cancel the job if signal is aborted
405
+ let abortHandler: (() => void) | undefined
406
+ if (options?.signal) {
407
+ abortHandler = () => {
408
+ this.cancelJob(jobId).catch((err) => {
409
+ this.#logger.error({ err, jobId }, '[Duron] Error cancelling job on abort')
410
+ })
411
+ }
412
+ options.signal.addEventListener('abort', abortHandler, { once: true })
413
+ }
414
+
415
+ // Set up timeout handler to cancel the job if timeout is reached
416
+ let timeoutId: NodeJS.Timeout | undefined
417
+ let timeoutAbortController: AbortController | undefined
418
+ if (options?.timeout) {
419
+ timeoutAbortController = new AbortController()
420
+ timeoutId = setTimeout(() => {
421
+ timeoutAbortController!.abort()
422
+ this.cancelJob(jobId).catch((err) => {
423
+ this.#logger.error({ err, jobId }, '[Duron] Error cancelling job on timeout')
424
+ })
425
+ }, options.timeout)
426
+ }
427
+
428
+ try {
429
+ // Combine signals if both are provided
430
+ let waitSignal: AbortSignal | undefined
431
+ if (options?.signal && timeoutAbortController) {
432
+ waitSignal = AbortSignal.any([options.signal, timeoutAbortController.signal])
433
+ } else if (options?.signal) {
434
+ waitSignal = options.signal
435
+ } else if (timeoutAbortController) {
436
+ waitSignal = timeoutAbortController.signal
437
+ }
438
+
439
+ // Wait for the job to complete
440
+ const job = await this.waitForJob(jobId, { signal: waitSignal })
441
+
442
+ // Clean up
443
+ if (timeoutId) {
444
+ clearTimeout(timeoutId)
445
+ }
446
+ if (options?.signal && abortHandler) {
447
+ options.signal.removeEventListener('abort', abortHandler)
448
+ }
449
+
450
+ // Handle null result (aborted or timed out)
451
+ if (!job) {
452
+ if (options?.signal?.aborted) {
453
+ throw new Error('Operation was aborted')
454
+ }
455
+ if (timeoutAbortController?.signal.aborted) {
456
+ throw new Error('Operation timed out')
457
+ }
458
+ throw new Error('Job not found')
459
+ }
460
+
461
+ // Handle cancelled job
462
+ if (job.status === JOB_STATUS_CANCELLED) {
463
+ if (options?.signal?.aborted) {
464
+ throw new Error('Operation was aborted')
465
+ }
466
+ if (timeoutAbortController?.signal.aborted) {
467
+ throw new Error('Operation timed out')
468
+ }
469
+ throw new Error('Job was cancelled')
470
+ }
471
+
472
+ // Handle failed job
473
+ if (job.status === JOB_STATUS_FAILED) {
474
+ const errorMessage = job.error?.message ?? 'Job failed'
475
+ const error = new Error(errorMessage)
476
+ if (job.error?.stack) {
477
+ error.stack = job.error.stack
478
+ }
479
+ throw error
480
+ }
481
+
482
+ // Return the job result with typed input/output
483
+ return job as TypedJobResult<TActions[TActionName]>
484
+ } catch (err) {
485
+ // Clean up on error
486
+ if (timeoutId) {
487
+ clearTimeout(timeoutId)
488
+ }
489
+ if (options?.signal && abortHandler) {
490
+ options.signal.removeEventListener('abort', abortHandler)
491
+ }
492
+ throw err
493
+ }
494
+ }
495
+
338
496
  /**
339
497
  * Fetch and process jobs from the database.
340
498
  * Concurrency limits are determined from the latest job created for each groupKey.
@@ -515,11 +673,11 @@ export class Client<
515
673
 
516
674
  /**
517
675
  * Wait for a job to change status by subscribing to job-status-changed events.
518
- * When the job status changes, the job is fetched and returned.
676
+ * When the job status changes, the job result is returned.
519
677
  *
520
678
  * @param jobId - The ID of the job to wait for
521
679
  * @param options - Optional configuration including timeout
522
- * @returns Promise resolving to the job when its status changes, or `null` if timeout
680
+ * @returns Promise resolving to the job result when its status changes, or `null` if timeout
523
681
  */
524
682
  async waitForJob(
525
683
  jobId: string,
@@ -534,7 +692,7 @@ export class Client<
534
692
  */
535
693
  signal?: AbortSignal
536
694
  },
537
- ): Promise<Job | null> {
695
+ ): Promise<JobResult | null> {
538
696
  await this.start()
539
697
 
540
698
  // First, check if the job already exists and is in a terminal state
@@ -546,14 +704,22 @@ export class Client<
546
704
  if (!job) {
547
705
  return null
548
706
  }
549
- return job
707
+ return {
708
+ jobId: job.id,
709
+ actionName: job.actionName,
710
+ status: job.status,
711
+ groupKey: job.groupKey,
712
+ input: job.input,
713
+ output: job.output,
714
+ error: job.error,
715
+ }
550
716
  }
551
717
  }
552
718
 
553
719
  // Set up the shared event listener if not already set up
554
720
  this.#setupJobStatusListener()
555
721
 
556
- return new Promise<Job | null>((resolve) => {
722
+ return new Promise<JobResult | null>((resolve) => {
557
723
  // Check if already aborted before setting up wait
558
724
  if (options?.signal?.aborted) {
559
725
  resolve(null)
@@ -796,6 +962,19 @@ export class Client<
796
962
  // Fetch the job once for all pending waits
797
963
  const job = await this.getJobById(event.jobId)
798
964
 
965
+ // Transform to JobResult
966
+ const result: JobResult | null = job
967
+ ? {
968
+ jobId: job.id,
969
+ actionName: job.actionName,
970
+ status: job.status,
971
+ groupKey: job.groupKey,
972
+ input: job.input,
973
+ output: job.output,
974
+ error: job.error,
975
+ }
976
+ : null
977
+
799
978
  // Resolve all pending waits for this job
800
979
  const waitsToResolve = Array.from(pendingWaits)
801
980
  this.#pendingJobWaits.delete(event.jobId)
@@ -808,7 +987,7 @@ export class Client<
808
987
  if (wait.signal && wait.abortHandler) {
809
988
  wait.signal.removeEventListener('abort', wait.abortHandler)
810
989
  }
811
- wait.resolve(job)
990
+ wait.resolve(result)
812
991
  }
813
992
  },
814
993
  )
@@ -820,7 +999,7 @@ export class Client<
820
999
  * @param jobId - The job ID
821
1000
  * @param resolve - The resolve function to remove
822
1001
  */
823
- #removeJobWait(jobId: string, resolve: (job: Job | null) => void) {
1002
+ #removeJobWait(jobId: string, resolve: (result: JobResult | null) => void) {
824
1003
  const pendingWaits = this.#pendingJobWaits.get(jobId)
825
1004
  if (!pendingWaits) {
826
1005
  return
@@ -23,7 +23,46 @@ import {
23
23
  serializeError,
24
24
  UnhandledChildStepsError,
25
25
  } from './errors.js'
26
- import type { ObserveContext, Span, TelemetryAdapter } from './telemetry/adapter.js'
26
+ import type { ObserveContext, Span, TelemetryAdapter, Tracer, TracerSpan } from './telemetry/adapter.js'
27
+
28
+ // ============================================================================
29
+ // Noop Tracer (for fallback observe context)
30
+ // ============================================================================
31
+
32
+ const noopTracerSpan: TracerSpan = {
33
+ setAttribute() {
34
+ // No-op
35
+ },
36
+ setAttributes() {
37
+ // No-op
38
+ },
39
+ addEvent() {
40
+ // No-op
41
+ },
42
+ recordException() {
43
+ // No-op
44
+ },
45
+ setStatusOk() {
46
+ // No-op
47
+ },
48
+ setStatusError() {
49
+ // No-op
50
+ },
51
+ end() {
52
+ // No-op
53
+ },
54
+ isRecording() {
55
+ return false
56
+ },
57
+ }
58
+
59
+ const createNoopTracer = (name: string): Tracer => ({
60
+ name,
61
+ startSpan(): TracerSpan {
62
+ return noopTracerSpan
63
+ },
64
+ })
65
+
27
66
  import pRetry from './utils/p-retry.js'
28
67
  import waitForAbort from './utils/wait-for-abort.js'
29
68
 
@@ -251,6 +290,9 @@ export class StepManager {
251
290
  addSpanEvent: () => {
252
291
  // No-op
253
292
  },
293
+ getTracer: (name: string) => {
294
+ return createNoopTracer(name)
295
+ },
254
296
  }
255
297
  }
256
298