duron 0.1.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 (82) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +140 -0
  3. package/dist/action-job.d.ts +24 -0
  4. package/dist/action-job.d.ts.map +1 -0
  5. package/dist/action-job.js +108 -0
  6. package/dist/action-manager.d.ts +21 -0
  7. package/dist/action-manager.d.ts.map +1 -0
  8. package/dist/action-manager.js +78 -0
  9. package/dist/action.d.ts +129 -0
  10. package/dist/action.d.ts.map +1 -0
  11. package/dist/action.js +87 -0
  12. package/dist/adapters/adapter.d.ts +92 -0
  13. package/dist/adapters/adapter.d.ts.map +1 -0
  14. package/dist/adapters/adapter.js +424 -0
  15. package/dist/adapters/postgres/drizzle.config.d.ts +3 -0
  16. package/dist/adapters/postgres/drizzle.config.d.ts.map +1 -0
  17. package/dist/adapters/postgres/drizzle.config.js +10 -0
  18. package/dist/adapters/postgres/pglite.d.ts +13 -0
  19. package/dist/adapters/postgres/pglite.d.ts.map +1 -0
  20. package/dist/adapters/postgres/pglite.js +36 -0
  21. package/dist/adapters/postgres/postgres.d.ts +51 -0
  22. package/dist/adapters/postgres/postgres.d.ts.map +1 -0
  23. package/dist/adapters/postgres/postgres.js +867 -0
  24. package/dist/adapters/postgres/schema.d.ts +581 -0
  25. package/dist/adapters/postgres/schema.d.ts.map +1 -0
  26. package/dist/adapters/postgres/schema.default.d.ts +577 -0
  27. package/dist/adapters/postgres/schema.default.d.ts.map +1 -0
  28. package/dist/adapters/postgres/schema.default.js +3 -0
  29. package/dist/adapters/postgres/schema.js +87 -0
  30. package/dist/adapters/schemas.d.ts +516 -0
  31. package/dist/adapters/schemas.d.ts.map +1 -0
  32. package/dist/adapters/schemas.js +184 -0
  33. package/dist/client.d.ts +85 -0
  34. package/dist/client.d.ts.map +1 -0
  35. package/dist/client.js +416 -0
  36. package/dist/constants.d.ts +14 -0
  37. package/dist/constants.d.ts.map +1 -0
  38. package/dist/constants.js +22 -0
  39. package/dist/errors.d.ts +43 -0
  40. package/dist/errors.d.ts.map +1 -0
  41. package/dist/errors.js +75 -0
  42. package/dist/index.d.ts +8 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +6 -0
  45. package/dist/server.d.ts +1193 -0
  46. package/dist/server.d.ts.map +1 -0
  47. package/dist/server.js +516 -0
  48. package/dist/step-manager.d.ts +46 -0
  49. package/dist/step-manager.d.ts.map +1 -0
  50. package/dist/step-manager.js +216 -0
  51. package/dist/utils/checksum.d.ts +2 -0
  52. package/dist/utils/checksum.d.ts.map +1 -0
  53. package/dist/utils/checksum.js +6 -0
  54. package/dist/utils/p-retry.d.ts +19 -0
  55. package/dist/utils/p-retry.d.ts.map +1 -0
  56. package/dist/utils/p-retry.js +130 -0
  57. package/dist/utils/wait-for-abort.d.ts +5 -0
  58. package/dist/utils/wait-for-abort.d.ts.map +1 -0
  59. package/dist/utils/wait-for-abort.js +32 -0
  60. package/migrations/postgres/0000_lethal_speed_demon.sql +64 -0
  61. package/migrations/postgres/meta/0000_snapshot.json +606 -0
  62. package/migrations/postgres/meta/_journal.json +13 -0
  63. package/package.json +88 -0
  64. package/src/action-job.ts +201 -0
  65. package/src/action-manager.ts +166 -0
  66. package/src/action.ts +247 -0
  67. package/src/adapters/adapter.ts +969 -0
  68. package/src/adapters/postgres/drizzle.config.ts +11 -0
  69. package/src/adapters/postgres/pglite.ts +86 -0
  70. package/src/adapters/postgres/postgres.ts +1346 -0
  71. package/src/adapters/postgres/schema.default.ts +5 -0
  72. package/src/adapters/postgres/schema.ts +119 -0
  73. package/src/adapters/schemas.ts +320 -0
  74. package/src/client.ts +859 -0
  75. package/src/constants.ts +37 -0
  76. package/src/errors.ts +205 -0
  77. package/src/index.ts +14 -0
  78. package/src/server.ts +718 -0
  79. package/src/step-manager.ts +471 -0
  80. package/src/utils/checksum.ts +7 -0
  81. package/src/utils/p-retry.ts +213 -0
  82. package/src/utils/wait-for-abort.ts +40 -0
package/src/client.ts ADDED
@@ -0,0 +1,859 @@
1
+ import pino, { type Logger } from 'pino'
2
+ import { zocker } from 'zocker'
3
+ import * as z from 'zod'
4
+
5
+ import type { Action, ConcurrencyHandlerContext } from './action.js'
6
+ import { ActionManager } from './action-manager.js'
7
+ import type {
8
+ Adapter,
9
+ GetActionsResult,
10
+ GetJobStepsOptions,
11
+ GetJobStepsResult,
12
+ GetJobsOptions,
13
+ GetJobsResult,
14
+ Job,
15
+ JobStep,
16
+ } from './adapters/adapter.js'
17
+ import type { JobStatusResult, JobStepStatusResult } from './adapters/schemas.js'
18
+ import { JOB_STATUS_CANCELLED, JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, type JobStatus } from './constants.js'
19
+
20
+ const BaseOptionsSchema = z.object({
21
+ /**
22
+ * Unique identifier for this Duron instance.
23
+ * Used for multi-process coordination and job ownership.
24
+ * Defaults to a random UUID if not provided.
25
+ */
26
+ id: z.string().optional(),
27
+
28
+ /**
29
+ * Synchronization pattern for fetching jobs.
30
+ * - `'pull'`: Periodically poll the database for new jobs
31
+ * - `'push'`: Listen for database notifications when jobs are available
32
+ * - `'hybrid'`: Use both pull and push patterns (recommended)
33
+ * - `false`: Disable automatic job fetching (manual fetching only)
34
+ *
35
+ * @default 'hybrid'
36
+ */
37
+ syncPattern: z.union([z.literal('pull'), z.literal('push'), z.literal('hybrid'), z.literal(false)]).default('hybrid'),
38
+
39
+ /**
40
+ * Interval in milliseconds between pull operations when using pull or hybrid sync pattern.
41
+ *
42
+ * @default 5000
43
+ */
44
+ pullInterval: z.number().default(5_000),
45
+
46
+ /**
47
+ * Maximum number of jobs to fetch in a single batch.
48
+ *
49
+ * @default 10
50
+ */
51
+ batchSize: z.number().default(10),
52
+
53
+ /**
54
+ * Maximum number of jobs that can run concurrently per action.
55
+ * This controls the concurrency limit for the action's fastq queue.
56
+ *
57
+ * @default 100
58
+ */
59
+ actionConcurrencyLimit: z.number().default(100),
60
+
61
+ /**
62
+ * Maximum number of jobs that can run concurrently per group key.
63
+ * Jobs with the same group key will respect this limit.
64
+ * This can be overridden using action -> groups -> concurrency.
65
+ *
66
+ * @default 10
67
+ */
68
+ groupConcurrencyLimit: z.number().default(10),
69
+
70
+ /**
71
+ * Whether to run database migrations on startup.
72
+ * When enabled, Duron will automatically apply pending migrations when the adapter starts.
73
+ *
74
+ * @default true
75
+ */
76
+ migrateOnStart: z.boolean().default(true),
77
+
78
+ /**
79
+ * Whether to recover stuck jobs on startup.
80
+ * Stuck jobs are jobs that were marked as active but the process that owned them
81
+ * is no longer running.
82
+ *
83
+ * @default true
84
+ */
85
+ recoverJobsOnStart: z.boolean().default(true),
86
+
87
+ /**
88
+ * Enable multi-process mode for job recovery.
89
+ * When enabled, Duron will ping other processes to check if they're alive
90
+ * before recovering their jobs.
91
+ *
92
+ * @default false
93
+ */
94
+ multiProcessMode: z.boolean().default(false),
95
+
96
+ /**
97
+ * Timeout in milliseconds to wait for process ping responses in multi-process mode.
98
+ * Processes that don't respond within this timeout will have their jobs recovered.
99
+ *
100
+ * @default 300000 (5 minutes)
101
+ */
102
+ processTimeout: z.number().default(5 * 60 * 1000),
103
+ })
104
+
105
+ /**
106
+ * Options for configuring a Duron instance.
107
+ *
108
+ * @template TActions - Record of action definitions keyed by action name
109
+ * @template TVariables - Type of variables available to actions
110
+ */
111
+ export interface ClientOptions<
112
+ TActions extends Record<string, Action<any, any, TVariables>>,
113
+ TVariables = Record<string, unknown>,
114
+ > extends z.input<typeof BaseOptionsSchema> {
115
+ /**
116
+ * The database adapter to use for storing jobs and steps.
117
+ * Required.
118
+ */
119
+ database: Adapter
120
+
121
+ /**
122
+ * A record of action definitions, where each key is the action name.
123
+ * Required.
124
+ */
125
+ actions?: TActions
126
+
127
+ /**
128
+ * Logger instance or log level for logging events and errors.
129
+ * Can be a pino Logger instance or a log level string ('fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent').
130
+ * If not provided, defaults to 'error' level.
131
+ */
132
+ logger?: Logger | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'
133
+
134
+ /**
135
+ * Variables available to all actions via the context.
136
+ * These can be accessed in action handlers using `ctx.var`.
137
+ */
138
+ variables?: TVariables
139
+ }
140
+
141
+ interface FetchOptions {
142
+ batchSize?: number
143
+ }
144
+
145
+ /**
146
+ * Client is the main entry point for Duron.
147
+ * Manages job execution, action handling, and database operations.
148
+ *
149
+ * @template TActions - Record of action definitions keyed by action name
150
+ * @template TVariables - Type of variables available to actions
151
+ */
152
+ export class Client<
153
+ TActions extends Record<string, Action<any, any, TVariables>>,
154
+ TVariables = Record<string, unknown>,
155
+ > {
156
+ #options: z.infer<typeof BaseOptionsSchema>
157
+ #id: string
158
+ #actions: TActions | null
159
+ #database: Adapter
160
+ #variables: Record<string, unknown>
161
+ #logger: Logger
162
+ #started: boolean = false
163
+ #stopped: boolean = false
164
+ #starting: Promise<boolean> | null = null
165
+ #stopping: Promise<boolean> | null = null
166
+ #pullInterval: NodeJS.Timeout | null = null
167
+ #actionManagers = new Map<string, ActionManager<Action<any, any, any>>>()
168
+ #mockInputSchemas = new Map<string, any>()
169
+ #pendingJobWaits = new Map<
170
+ string,
171
+ Set<{
172
+ resolve: (job: Job | null) => void
173
+ timeoutId?: NodeJS.Timeout
174
+ signal?: AbortSignal
175
+ abortHandler?: () => void
176
+ }>
177
+ >()
178
+ #jobStatusListenerSetup = false
179
+
180
+ // ============================================================================
181
+ // Constructor
182
+ // ============================================================================
183
+
184
+ /**
185
+ * Create a new Duron Client instance.
186
+ *
187
+ * @param options - Configuration options for the client
188
+ */
189
+ constructor(options: ClientOptions<TActions, TVariables>) {
190
+ this.#options = BaseOptionsSchema.parse(options)
191
+ this.#id = options.id ?? globalThis.crypto.randomUUID()
192
+ this.#database = options.database
193
+ this.#actions = options.actions ?? null
194
+ this.#variables = options?.variables ?? {}
195
+ this.#logger = this.#normalizeLogger(options?.logger)
196
+ this.#database.setId(this.#id)
197
+ this.#database.setLogger(this.#logger)
198
+ }
199
+
200
+ #normalizeLogger(logger?: Logger | 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' | 'silent'): Logger {
201
+ let pinoInstance: Logger | null = null
202
+ if (!logger) {
203
+ pinoInstance = pino({ level: 'error' })
204
+ } else if (typeof logger === 'string') {
205
+ pinoInstance = pino({ level: logger })
206
+ } else {
207
+ pinoInstance = logger
208
+ }
209
+ return pinoInstance.child({ duron: this.#id })
210
+ }
211
+
212
+ // ============================================================================
213
+ // Public API Methods
214
+ // ============================================================================
215
+
216
+ get logger() {
217
+ return this.#logger
218
+ }
219
+
220
+ /**
221
+ * Get the current configuration of this Duron instance.
222
+ *
223
+ * @returns Configuration object including options, actions, and variables
224
+ */
225
+ getConfig() {
226
+ return {
227
+ ...this.#options,
228
+ actions: this.#actions,
229
+ variables: this.#variables,
230
+ }
231
+ }
232
+
233
+ /**
234
+ * Run an action by creating a new job.
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
+ * @returns Promise resolving to the created job ID
239
+ * @throws Error if action is not found or job creation fails
240
+ */
241
+ async runAction<TActionName extends keyof TActions>(
242
+ actionName: TActionName,
243
+ input?: NonNullable<TActions[TActionName]['input']> extends z.ZodObject
244
+ ? z.input<NonNullable<TActions[TActionName]['input']>>
245
+ : never,
246
+ ): Promise<string> {
247
+ await this.start()
248
+
249
+ const action = this.#actions?.[actionName]
250
+ if (!action) {
251
+ throw new Error(`Action ${String(actionName)} not found`)
252
+ }
253
+
254
+ // Validate input if schema is provided
255
+ let validatedInput: any = input ?? {}
256
+ if (action.input) {
257
+ validatedInput = action.input.parse(validatedInput, {
258
+ error: () => 'Error parsing action input',
259
+ reportInput: true,
260
+ })
261
+ }
262
+
263
+ // Determine groupKey and concurrency limit using concurrency handler or defaults
264
+ const concurrencyCtx: ConcurrencyHandlerContext<typeof action.input, TVariables> = {
265
+ input: validatedInput,
266
+ var: this.#variables as TVariables,
267
+ }
268
+
269
+ let groupKey = '@default'
270
+ if (action.groups?.groupKey) {
271
+ groupKey = await action.groups.groupKey(concurrencyCtx)
272
+ }
273
+
274
+ let concurrencyLimit = this.#options.groupConcurrencyLimit
275
+ if (action.groups?.concurrency) {
276
+ concurrencyLimit = await action.groups.concurrency(concurrencyCtx)
277
+ }
278
+
279
+ // Create job in database
280
+ const jobId = await this.#database.createJob({
281
+ queue: action.name,
282
+ groupKey,
283
+ input: validatedInput,
284
+ timeoutMs: action.expire,
285
+ checksum: action.checksum,
286
+ concurrencyLimit,
287
+ })
288
+
289
+ if (!jobId) {
290
+ throw new Error(`Failed to create job for action ${String(actionName)}`)
291
+ }
292
+
293
+ this.#logger.debug({ jobId, actionName: String(actionName), groupKey }, '[Duron] Action sent/created')
294
+
295
+ return jobId
296
+ }
297
+
298
+ /**
299
+ * Fetch and process jobs from the database.
300
+ * Concurrency limits are determined from the latest job created for each groupKey.
301
+ *
302
+ * @param options - Fetch options including batch size
303
+ * @returns Promise resolving to the array of fetched jobs
304
+ */
305
+ async fetch(options: FetchOptions) {
306
+ await this.start()
307
+
308
+ if (!this.#actions) {
309
+ return []
310
+ }
311
+
312
+ // Fetch jobs from each action's queue
313
+ // Concurrency limits are determined from the latest job created for each groupKey
314
+ const jobs = await this.#database.fetch({
315
+ batch: options.batchSize ?? this.#options.batchSize,
316
+ })
317
+
318
+ // Process fetched jobs
319
+ for (const job of jobs) {
320
+ this.#executeJob(job)
321
+ }
322
+
323
+ return jobs
324
+ }
325
+
326
+ /**
327
+ * Cancel a job by its ID.
328
+ * If the job is currently being processed, it will be cancelled immediately.
329
+ * Otherwise, it will be cancelled in the database.
330
+ *
331
+ * @param jobId - The ID of the job to cancel
332
+ * @returns Promise resolving to `true` if cancelled, `false` otherwise
333
+ */
334
+ async cancelJob(jobId: string) {
335
+ await this.start()
336
+
337
+ let cancelled = false
338
+ for (const manager of this.#actionManagers.values()) {
339
+ cancelled = manager.cancelJob(jobId)
340
+ if (cancelled) {
341
+ break
342
+ }
343
+ }
344
+
345
+ if (!cancelled) {
346
+ // If the job is not being processed, cancel it in the database
347
+ await this.#database.cancelJob({ jobId })
348
+ }
349
+
350
+ return cancelled
351
+ }
352
+
353
+ /**
354
+ * Retry a failed job by creating a copy of it with status 'created' and cleared output/error.
355
+ *
356
+ * @param jobId - The ID of the job to retry
357
+ * @returns Promise resolving to the new job ID, or `null` if retry failed
358
+ */
359
+ async retryJob(jobId: string): Promise<string | null> {
360
+ await this.start()
361
+ return this.#database.retryJob({ jobId })
362
+ }
363
+
364
+ /**
365
+ * Delete a job by its ID.
366
+ * Active jobs cannot be deleted.
367
+ *
368
+ * @param jobId - The ID of the job to delete
369
+ * @returns Promise resolving to `true` if deleted, `false` otherwise
370
+ */
371
+ async deleteJob(jobId: string): Promise<boolean> {
372
+ await this.start()
373
+ return this.#database.deleteJob({ jobId })
374
+ }
375
+
376
+ /**
377
+ * Delete multiple jobs using the same filters as getJobs.
378
+ * Active jobs cannot be deleted and will be excluded from deletion.
379
+ *
380
+ * @param options - Query options including filters (same as getJobs)
381
+ * @returns Promise resolving to the number of jobs deleted
382
+ */
383
+ async deleteJobs(options?: GetJobsOptions): Promise<number> {
384
+ await this.start()
385
+ return this.#database.deleteJobs(options)
386
+ }
387
+
388
+ // ============================================================================
389
+ // Query Methods
390
+ // ============================================================================
391
+
392
+ /**
393
+ * Get a job by its ID. Does not include step information.
394
+ *
395
+ * @param jobId - The ID of the job to retrieve
396
+ * @returns Promise resolving to the job, or `null` if not found
397
+ */
398
+ async getJobById(jobId: string): Promise<Job | null> {
399
+ await this.start()
400
+ return this.#database.getJobById(jobId)
401
+ }
402
+
403
+ /**
404
+ * Get steps for a job with pagination and fuzzy search.
405
+ * Steps are always ordered by created_at ASC.
406
+ * Steps do not include output data.
407
+ *
408
+ * @param options - Query options including jobId, pagination, and search
409
+ * @returns Promise resolving to steps result with pagination info
410
+ */
411
+ async getJobSteps(options: GetJobStepsOptions): Promise<GetJobStepsResult> {
412
+ await this.start()
413
+ return this.#database.getJobSteps(options)
414
+ }
415
+
416
+ /**
417
+ * Get jobs with pagination, filtering, and sorting.
418
+ * Does not include step information or job output.
419
+ *
420
+ * @param options - Query options including pagination, filters, and sort
421
+ * @returns Promise resolving to jobs result with pagination info
422
+ */
423
+ async getJobs(options?: GetJobsOptions): Promise<GetJobsResult> {
424
+ await this.start()
425
+ return this.#database.getJobs(options)
426
+ }
427
+
428
+ /**
429
+ * Get a step by its ID with all information.
430
+ *
431
+ * @param stepId - The ID of the step to retrieve
432
+ * @returns Promise resolving to the step, or `null` if not found
433
+ */
434
+ async getJobStepById(stepId: string): Promise<JobStep | null> {
435
+ await this.start()
436
+ return this.#database.getJobStepById(stepId)
437
+ }
438
+
439
+ /**
440
+ * Get job status and updatedAt timestamp.
441
+ *
442
+ * @param jobId - The ID of the job
443
+ * @returns Promise resolving to job status result, or `null` if not found
444
+ */
445
+ async getJobStatus(jobId: string): Promise<JobStatusResult | null> {
446
+ await this.start()
447
+ return this.#database.getJobStatus(jobId)
448
+ }
449
+
450
+ /**
451
+ * Get job step status and updatedAt timestamp.
452
+ *
453
+ * @param stepId - The ID of the step
454
+ * @returns Promise resolving to step status result, or `null` if not found
455
+ */
456
+ async getJobStepStatus(stepId: string): Promise<JobStepStatusResult | null> {
457
+ await this.start()
458
+ return this.#database.getJobStepStatus(stepId)
459
+ }
460
+
461
+ /**
462
+ * Wait for a job to change status by subscribing to job-status-changed events.
463
+ * When the job status changes, the job is fetched and returned.
464
+ *
465
+ * @param jobId - The ID of the job to wait for
466
+ * @param options - Optional configuration including timeout
467
+ * @returns Promise resolving to the job when its status changes, or `null` if timeout
468
+ */
469
+ async waitForJob(
470
+ jobId: string,
471
+ options?: {
472
+ /**
473
+ * Timeout in milliseconds. If the job status doesn't change within this time, the promise resolves to `null`.
474
+ * Defaults to no timeout (waits indefinitely).
475
+ */
476
+ timeout?: number
477
+ /**
478
+ * AbortSignal to cancel waiting. If aborted, the promise resolves to `null`.
479
+ */
480
+ signal?: AbortSignal
481
+ },
482
+ ): Promise<Job | null> {
483
+ await this.start()
484
+
485
+ // First, check if the job already exists and is in a terminal state
486
+ const existingJobStatus = await this.getJobStatus(jobId)
487
+ if (existingJobStatus) {
488
+ const terminalStatuses: JobStatus[] = [JOB_STATUS_COMPLETED, JOB_STATUS_FAILED, JOB_STATUS_CANCELLED]
489
+ if (terminalStatuses.includes(existingJobStatus.status)) {
490
+ const job = await this.getJobById(jobId)
491
+ if (!job) {
492
+ return null
493
+ }
494
+ return job
495
+ }
496
+ }
497
+
498
+ // Set up the shared event listener if not already set up
499
+ this.#setupJobStatusListener()
500
+
501
+ return new Promise<Job | null>((resolve) => {
502
+ // Check if already aborted before setting up wait
503
+ if (options?.signal?.aborted) {
504
+ resolve(null)
505
+ return
506
+ }
507
+
508
+ let timeoutId: NodeJS.Timeout | undefined
509
+ let abortHandler: (() => void) | undefined
510
+
511
+ // Set up timeout if provided
512
+ if (options?.timeout) {
513
+ timeoutId = setTimeout(() => {
514
+ this.#removeJobWait(jobId, resolve)
515
+ resolve(null)
516
+ }, options.timeout)
517
+ }
518
+
519
+ // Set up abort signal if provided
520
+ if (options?.signal) {
521
+ abortHandler = () => {
522
+ this.#removeJobWait(jobId, resolve)
523
+ resolve(null)
524
+ }
525
+ options.signal.addEventListener('abort', abortHandler)
526
+ }
527
+
528
+ // Add this wait request to the pending waits
529
+ if (!this.#pendingJobWaits.has(jobId)) {
530
+ this.#pendingJobWaits.set(jobId, new Set())
531
+ }
532
+ this.#pendingJobWaits.get(jobId)!.add({
533
+ resolve,
534
+ timeoutId,
535
+ signal: options?.signal,
536
+ abortHandler,
537
+ })
538
+ })
539
+ }
540
+
541
+ /**
542
+ * Get action statistics including counts and last job created date.
543
+ *
544
+ * @returns Promise resolving to action statistics
545
+ */
546
+ async getActions(): Promise<GetActionsResult> {
547
+ await this.start()
548
+ return this.#database.getActions()
549
+ }
550
+
551
+ /**
552
+ * Get action metadata including input schemas and mock data.
553
+ * This is useful for generating UI forms or mock data.
554
+ *
555
+ * @returns Promise resolving to action metadata
556
+ */
557
+ async getActionsMetadata(): Promise<Array<{ name: string; mockInput: any }>> {
558
+ await this.start()
559
+
560
+ if (!this.#actions) {
561
+ return []
562
+ }
563
+
564
+ return Object.values(this.#actions).map((action) => {
565
+ let mockInput = {}
566
+ if (action.input) {
567
+ if (!this.#mockInputSchemas.has(action.name)) {
568
+ this.#mockInputSchemas.set(
569
+ action.name,
570
+ zocker(action.input as z.ZodObject)
571
+ .override(z.ZodString, 'string')
572
+ .generate(),
573
+ )
574
+ }
575
+ mockInput = this.#mockInputSchemas.get(action.name)
576
+ }
577
+ return {
578
+ name: action.name,
579
+ mockInput,
580
+ }
581
+ })
582
+ }
583
+
584
+ // ============================================================================
585
+ // Lifecycle Methods
586
+ // ============================================================================
587
+
588
+ /**
589
+ * Start the Duron instance.
590
+ * Initializes the database, recovers stuck jobs, and sets up sync patterns.
591
+ *
592
+ * @returns Promise resolving to `true` if started successfully, `false` otherwise
593
+ */
594
+ async start() {
595
+ if (this.#stopping || this.#stopped) {
596
+ return false
597
+ }
598
+
599
+ if (this.#started) {
600
+ return true
601
+ }
602
+
603
+ if (this.#starting) {
604
+ return this.#starting
605
+ }
606
+
607
+ this.#starting = (async () => {
608
+ const dbStarted = await this.#database.start()
609
+ if (!dbStarted) {
610
+ return false
611
+ }
612
+
613
+ if (this.#actions) {
614
+ if (this.#options.recoverJobsOnStart) {
615
+ await this.#database.recoverJobs({
616
+ checksums: Object.values(this.#actions).map((action) => action.checksum),
617
+ multiProcessMode: this.#options.multiProcessMode,
618
+ processTimeout: this.#options.processTimeout,
619
+ })
620
+ }
621
+
622
+ // Setup sync pattern
623
+ if (this.#options.syncPattern === 'pull' || this.#options.syncPattern === 'hybrid') {
624
+ this.#startPullLoop()
625
+ }
626
+
627
+ if (this.#options.syncPattern === 'push' || this.#options.syncPattern === 'hybrid') {
628
+ this.#setupPushListener()
629
+ }
630
+ }
631
+
632
+ this.#started = true
633
+ this.#starting = null
634
+ return true
635
+ })()
636
+
637
+ return this.#starting
638
+ }
639
+
640
+ /**
641
+ * Stop the Duron instance.
642
+ * Stops the pull loop, aborts all running jobs, waits for queues to drain, and stops the database.
643
+ *
644
+ * @returns Promise resolving to `true` if stopped successfully, `false` otherwise
645
+ */
646
+ async stop() {
647
+ if (this.#stopped) {
648
+ return true
649
+ }
650
+
651
+ if (this.#stopping) {
652
+ return this.#stopping
653
+ }
654
+
655
+ this.#stopping = (async () => {
656
+ // Stop pull loop
657
+ if (this.#pullInterval) {
658
+ clearTimeout(this.#pullInterval)
659
+ this.#pullInterval = null
660
+ }
661
+
662
+ // Clean up all pending job waits
663
+ for (const waits of this.#pendingJobWaits.values()) {
664
+ for (const wait of waits) {
665
+ if (wait.timeoutId) {
666
+ clearTimeout(wait.timeoutId)
667
+ }
668
+ if (wait.signal && wait.abortHandler) {
669
+ wait.signal.removeEventListener('abort', wait.abortHandler)
670
+ }
671
+ wait.resolve(null)
672
+ }
673
+ }
674
+ this.#pendingJobWaits.clear()
675
+
676
+ // Wait for action managers to drain
677
+ await Promise.all(
678
+ Array.from(this.#actionManagers.values()).map(async (manager) => {
679
+ await manager.stop()
680
+ }),
681
+ )
682
+
683
+ const dbStopped = await this.#database.stop()
684
+ if (!dbStopped) {
685
+ return false
686
+ }
687
+
688
+ this.#stopped = true
689
+ this.#stopping = null
690
+ return true
691
+ })()
692
+
693
+ return this.#stopping
694
+ }
695
+
696
+ // ============================================================================
697
+ // Private Methods
698
+ // ============================================================================
699
+
700
+ /**
701
+ * Set up the shared event listener for job-status-changed events.
702
+ * This listener is shared across all waitForJob calls to avoid multiple listeners.
703
+ */
704
+ #setupJobStatusListener() {
705
+ if (this.#jobStatusListenerSetup) {
706
+ return
707
+ }
708
+
709
+ this.#jobStatusListenerSetup = true
710
+
711
+ this.#database.on(
712
+ 'job-status-changed',
713
+ async (event: { jobId: string; status: JobStatus | 'retried'; ownerId: string }) => {
714
+ const pendingWaits = this.#pendingJobWaits.get(event.jobId)
715
+ if (!pendingWaits || pendingWaits.size === 0) {
716
+ return
717
+ }
718
+
719
+ // Fetch the job once for all pending waits
720
+ const job = await this.getJobById(event.jobId)
721
+
722
+ // Resolve all pending waits for this job
723
+ const waitsToResolve = Array.from(pendingWaits)
724
+ this.#pendingJobWaits.delete(event.jobId)
725
+
726
+ for (const wait of waitsToResolve) {
727
+ // Clean up timeout and abort signal
728
+ if (wait.timeoutId) {
729
+ clearTimeout(wait.timeoutId)
730
+ }
731
+ if (wait.signal && wait.abortHandler) {
732
+ wait.signal.removeEventListener('abort', wait.abortHandler)
733
+ }
734
+ wait.resolve(job)
735
+ }
736
+ },
737
+ )
738
+ }
739
+
740
+ /**
741
+ * Remove a specific wait request from the pending waits.
742
+ *
743
+ * @param jobId - The job ID
744
+ * @param resolve - The resolve function to remove
745
+ */
746
+ #removeJobWait(jobId: string, resolve: (job: Job | null) => void) {
747
+ const pendingWaits = this.#pendingJobWaits.get(jobId)
748
+ if (!pendingWaits) {
749
+ return
750
+ }
751
+
752
+ // Find and remove the specific wait request
753
+ for (const wait of pendingWaits) {
754
+ if (wait.resolve === resolve) {
755
+ if (wait.timeoutId) {
756
+ clearTimeout(wait.timeoutId)
757
+ }
758
+ if (wait.signal && wait.abortHandler) {
759
+ wait.signal.removeEventListener('abort', wait.abortHandler)
760
+ }
761
+ pendingWaits.delete(wait)
762
+ break
763
+ }
764
+ }
765
+
766
+ // Clean up empty sets
767
+ if (pendingWaits.size === 0) {
768
+ this.#pendingJobWaits.delete(jobId)
769
+ }
770
+ }
771
+
772
+ /**
773
+ * Execute a job by finding its action and queuing it with the appropriate ActionManager.
774
+ *
775
+ * @param job - The job to execute
776
+ */
777
+ #executeJob(job: Job) {
778
+ if (!this.#actions) {
779
+ return
780
+ }
781
+
782
+ const action = Object.values(this.#actions).find((a) => a.name === job.actionName)
783
+ if (!action) {
784
+ const error = { name: 'ActionNotFoundError', message: `Action "${job.actionName}" not found for job ${job.id}` }
785
+ this.#logger.warn({ jobId: job.id, actionName: job.actionName }, `[Duron] Action not found for job ${job.id}`)
786
+ this.#database.failJob({ jobId: job.id, error }).catch((dbError) => {
787
+ this.#logger.error({ error: dbError, jobId: job.id }, `[Duron] Error failing job ${job.id}`)
788
+ })
789
+ return
790
+ }
791
+
792
+ // Get or create ActionManager for this action
793
+ let actionManager = this.#actionManagers.get(action.name)
794
+ if (!actionManager) {
795
+ actionManager = new ActionManager({
796
+ action,
797
+ database: this.#database,
798
+ variables: this.#variables,
799
+ logger: this.#logger,
800
+ concurrencyLimit: this.#options.actionConcurrencyLimit,
801
+ })
802
+ this.#actionManagers.set(action.name, actionManager)
803
+ }
804
+
805
+ // Queue job execution
806
+ actionManager.push(job).catch((err) => {
807
+ // Only log unexpected errors (not cancellation/timeout which are handled elsewhere)
808
+ this.#logger.error(
809
+ { err, jobId: job.id, actionName: action.name },
810
+ `[Duron] Error executing job ${job.id} for action ${action.name}`,
811
+ )
812
+ })
813
+ }
814
+
815
+ /**
816
+ * Start the pull loop for periodically fetching jobs.
817
+ * Only starts if not already running.
818
+ */
819
+ #startPullLoop() {
820
+ if (this.#pullInterval) {
821
+ return
822
+ }
823
+
824
+ const pull = async () => {
825
+ if (this.#stopped) {
826
+ return
827
+ }
828
+
829
+ try {
830
+ await this.fetch({
831
+ batchSize: this.#options.batchSize,
832
+ })
833
+ } catch (error) {
834
+ this.#logger.error({ error }, '[Duron] [PullLoop] Error in pull loop')
835
+ }
836
+
837
+ if (!this.#stopped) {
838
+ this.#pullInterval = setTimeout(pull, this.#options.pullInterval)
839
+ }
840
+ }
841
+
842
+ // Start immediately
843
+ pull()
844
+ }
845
+
846
+ /**
847
+ * Setup the push listener for database notifications.
848
+ * Listens for 'job-available' events and fetches jobs when notified.
849
+ */
850
+ #setupPushListener() {
851
+ this.#database.on('job-available', async () => {
852
+ this.fetch({
853
+ batchSize: 1,
854
+ }).catch((error) => {
855
+ this.#logger.error({ error }, '[Duron] [PushListener] Error fetching job')
856
+ })
857
+ })
858
+ }
859
+ }