@coji/durably 0.3.0 → 0.6.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.
package/README.md CHANGED
@@ -4,65 +4,36 @@ Step-oriented resumable batch execution for Node.js and browsers using SQLite.
4
4
 
5
5
  **[Documentation](https://coji.github.io/durably/)** | **[GitHub](https://github.com/coji/durably)** | **[Live Demo](https://durably-demo.vercel.app)**
6
6
 
7
- ## Features
8
-
9
- - Resumable batch processing with step-level persistence
10
- - Works in both Node.js and browsers
11
- - Uses SQLite for state management (better-sqlite3/libsql for Node.js, SQLite WASM for browsers)
12
- - Minimal dependencies - just Kysely and Zod as peer dependencies
13
- - Event system for monitoring and extensibility
14
- - Type-safe input/output with Zod schemas
7
+ > **Note:** This package is ESM-only. CommonJS is not supported.
15
8
 
16
9
  ## Installation
17
10
 
18
11
  ```bash
19
- # Node.js with better-sqlite3
20
12
  npm install @coji/durably kysely zod better-sqlite3
21
-
22
- # Node.js with libsql
23
- npm install @coji/durably kysely zod @libsql/client @libsql/kysely-libsql
24
-
25
- # Browser with SQLocal
26
- npm install @coji/durably kysely zod sqlocal
27
13
  ```
28
14
 
29
- ## Usage
15
+ See the [Getting Started Guide](https://coji.github.io/durably/guide/getting-started) for other SQLite backends (libsql, SQLocal for browsers).
16
+
17
+ ## Quick Start
30
18
 
31
19
  ```ts
32
- import { createDurably } from '@coji/durably'
33
- import SQLite from 'better-sqlite3'
34
- import { SqliteDialect } from 'kysely'
20
+ import { createDurably, defineJob } from '@coji/durably'
35
21
  import { z } from 'zod'
36
22
 
37
- const dialect = new SqliteDialect({
38
- database: new SQLite('local.db'),
39
- })
40
-
41
- const durably = createDurably({ dialect })
42
-
43
- const syncUsers = durably.defineJob(
44
- {
45
- name: 'sync-users',
46
- input: z.object({ orgId: z.string() }),
47
- output: z.object({ syncedCount: z.number() }),
48
- },
49
- async (step, payload) => {
50
- const users = await step.run('fetch-users', async () => {
51
- return api.fetchUsers(payload.orgId)
52
- })
53
-
54
- await step.run('save-to-db', async () => {
55
- await db.upsertUsers(users)
23
+ const myJob = defineJob({
24
+ name: 'my-job',
25
+ input: z.object({ id: z.string() }),
26
+ run: async (step, payload) => {
27
+ await step.run('step-1', async () => {
28
+ /* ... */
56
29
  })
57
-
58
- return { syncedCount: users.length }
59
30
  },
60
- )
31
+ })
61
32
 
62
- await durably.migrate()
63
- durably.start()
33
+ const durably = createDurably({ dialect }).register({ myJob })
64
34
 
65
- await syncUsers.trigger({ orgId: 'org_123' })
35
+ await durably.init() // migrate + start
36
+ await durably.jobs.myJob.trigger({ id: '123' })
66
37
  ```
67
38
 
68
39
  ## Documentation
package/dist/index.d.ts CHANGED
@@ -9,6 +9,15 @@ interface BaseEvent {
9
9
  timestamp: string;
10
10
  sequence: number;
11
11
  }
12
+ /**
13
+ * Run trigger event (emitted when a job is triggered, before worker picks it up)
14
+ */
15
+ interface RunTriggerEvent extends BaseEvent {
16
+ type: 'run:trigger';
17
+ runId: string;
18
+ jobName: string;
19
+ payload: unknown;
20
+ }
12
21
  /**
13
22
  * Run start event
14
23
  */
@@ -38,6 +47,35 @@ interface RunFailEvent extends BaseEvent {
38
47
  error: string;
39
48
  failedStepName: string;
40
49
  }
50
+ /**
51
+ * Run cancel event
52
+ */
53
+ interface RunCancelEvent extends BaseEvent {
54
+ type: 'run:cancel';
55
+ runId: string;
56
+ jobName: string;
57
+ }
58
+ /**
59
+ * Run retry event (emitted when a failed run is retried)
60
+ */
61
+ interface RunRetryEvent extends BaseEvent {
62
+ type: 'run:retry';
63
+ runId: string;
64
+ jobName: string;
65
+ }
66
+ /**
67
+ * Run progress event
68
+ */
69
+ interface RunProgressEvent extends BaseEvent {
70
+ type: 'run:progress';
71
+ runId: string;
72
+ jobName: string;
73
+ progress: {
74
+ current: number;
75
+ total?: number;
76
+ message?: string;
77
+ };
78
+ }
41
79
  /**
42
80
  * Step start event
43
81
  */
@@ -94,7 +132,7 @@ interface WorkerErrorEvent extends BaseEvent {
94
132
  /**
95
133
  * All event types as discriminated union
96
134
  */
97
- type DurablyEvent = RunStartEvent | RunCompleteEvent | RunFailEvent | StepStartEvent | StepCompleteEvent | StepFailEvent | LogWriteEvent | WorkerErrorEvent;
135
+ type DurablyEvent = RunTriggerEvent | RunStartEvent | RunCompleteEvent | RunFailEvent | RunCancelEvent | RunRetryEvent | RunProgressEvent | StepStartEvent | StepCompleteEvent | StepFailEvent | LogWriteEvent | WorkerErrorEvent;
98
136
  /**
99
137
  * Event types for type-safe event names
100
138
  */
@@ -112,7 +150,7 @@ type EventInput<T extends EventType> = Omit<EventByType<T>, 'timestamp' | 'seque
112
150
  /**
113
151
  * All possible event inputs as a union (properly distributed)
114
152
  */
115
- type AnyEventInput = EventInput<'run:start'> | EventInput<'run:complete'> | EventInput<'run:fail'> | EventInput<'step:start'> | EventInput<'step:complete'> | EventInput<'step:fail'> | EventInput<'log:write'> | EventInput<'worker:error'>;
153
+ type AnyEventInput = EventInput<'run:trigger'> | EventInput<'run:start'> | EventInput<'run:complete'> | EventInput<'run:fail'> | EventInput<'run:cancel'> | EventInput<'run:retry'> | EventInput<'run:progress'> | EventInput<'step:start'> | EventInput<'step:complete'> | EventInput<'step:fail'> | EventInput<'log:write'> | EventInput<'worker:error'>;
116
154
  /**
117
155
  * Event listener function
118
156
  */
@@ -195,6 +233,7 @@ interface Run {
195
233
  idempotencyKey: string | null;
196
234
  concurrencyKey: string | null;
197
235
  currentStepIndex: number;
236
+ stepCount: number;
198
237
  progress: {
199
238
  current: number;
200
239
  total?: number;
@@ -323,18 +362,6 @@ interface StepContext {
323
362
  error(message: string, data?: unknown): void;
324
363
  };
325
364
  }
326
- /**
327
- * Job function type
328
- */
329
- type JobFunction<TInput, TOutput> = (step: StepContext, payload: TInput) => Promise<TOutput>;
330
- /**
331
- * Job definition options
332
- */
333
- interface JobDefinition<TName extends string, TInputSchema extends z.ZodType, TOutputSchema extends z.ZodType | undefined> {
334
- name: TName;
335
- input: TInputSchema;
336
- output?: TOutputSchema;
337
- }
338
365
  /**
339
366
  * Trigger options
340
367
  */
@@ -348,8 +375,12 @@ interface TriggerOptions {
348
375
  * Run filter options
349
376
  */
350
377
  interface RunFilter {
351
- status?: 'pending' | 'running' | 'completed' | 'failed';
378
+ status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
352
379
  jobName?: string;
380
+ /** Maximum number of runs to return */
381
+ limit?: number;
382
+ /** Number of runs to skip (for pagination) */
383
+ offset?: number;
353
384
  }
354
385
  /**
355
386
  * Typed run with output type
@@ -400,6 +431,66 @@ interface JobHandle<TName extends string, TInput, TOutput> {
400
431
  getRuns(filter?: Omit<RunFilter, 'jobName'>): Promise<TypedRun<TOutput>[]>;
401
432
  }
402
433
 
434
+ /**
435
+ * Job run function type
436
+ */
437
+ type JobRunFunction<TInput, TOutput> = (step: StepContext, payload: TInput) => Promise<TOutput>;
438
+ /**
439
+ * Job definition - a standalone description of a job
440
+ * This is the result of calling defineJob() and can be passed to durably.register()
441
+ */
442
+ interface JobDefinition<TName extends string, TInput, TOutput> {
443
+ readonly name: TName;
444
+ readonly input: z.ZodType<TInput>;
445
+ readonly output: z.ZodType<TOutput> | undefined;
446
+ readonly run: JobRunFunction<TInput, TOutput>;
447
+ }
448
+ /**
449
+ * Extract input type from a JobDefinition
450
+ * @example
451
+ * ```ts
452
+ * type Input = JobInput<typeof myJob> // { userId: string }
453
+ * ```
454
+ */
455
+ type JobInput<T> = T extends JobDefinition<string, infer TInput, unknown> ? TInput : never;
456
+ /**
457
+ * Extract output type from a JobDefinition
458
+ * @example
459
+ * ```ts
460
+ * type Output = JobOutput<typeof myJob> // { count: number }
461
+ * ```
462
+ */
463
+ type JobOutput<T> = T extends JobDefinition<string, unknown, infer TOutput> ? TOutput : never;
464
+ /**
465
+ * Configuration for defining a job
466
+ */
467
+ interface DefineJobConfig<TName extends string, TInputSchema extends z.ZodType, TOutputSchema extends z.ZodType | undefined> {
468
+ name: TName;
469
+ input: TInputSchema;
470
+ output?: TOutputSchema;
471
+ run: JobRunFunction<z.infer<TInputSchema>, TOutputSchema extends z.ZodType ? z.infer<TOutputSchema> : void>;
472
+ }
473
+ /**
474
+ * Define a job - creates a JobDefinition that can be registered with durably.register()
475
+ *
476
+ * @example
477
+ * ```ts
478
+ * import { defineJob } from '@coji/durably'
479
+ * import { z } from 'zod'
480
+ *
481
+ * export const syncUsers = defineJob({
482
+ * name: 'sync-users',
483
+ * input: z.object({ orgId: z.string() }),
484
+ * output: z.object({ syncedCount: z.number() }),
485
+ * run: async (step, payload) => {
486
+ * const users = await step.run('fetch-users', () => fetchUsers(payload.orgId))
487
+ * return { syncedCount: users.length }
488
+ * },
489
+ * })
490
+ * ```
491
+ */
492
+ declare function defineJob<TName extends string, TInputSchema extends z.ZodType, TOutputSchema extends z.ZodType | undefined = undefined>(config: DefineJobConfig<TName, TInputSchema, TOutputSchema>): JobDefinition<TName, z.infer<TInputSchema>, TOutputSchema extends z.ZodType ? z.infer<TOutputSchema> : void>;
493
+
403
494
  /**
404
495
  * Options for creating a Durably instance
405
496
  */
@@ -414,12 +505,33 @@ interface DurablyOptions {
414
505
  */
415
506
  interface DurablyPlugin {
416
507
  name: string;
417
- install(durably: Durably): void;
508
+ install(durably: Durably<any>): void;
418
509
  }
419
510
  /**
420
- * Durably instance
511
+ * Helper type to transform JobDefinition record to JobHandle record
512
+ */
513
+ type TransformToHandles<TJobs extends Record<string, JobDefinition<string, unknown, unknown>>> = {
514
+ [K in keyof TJobs]: TJobs[K] extends JobDefinition<infer TName, infer TInput, infer TOutput> ? JobHandle<TName & string, TInput, TOutput> : never;
515
+ };
516
+ /**
517
+ * Durably instance with type-safe jobs
421
518
  */
422
- interface Durably {
519
+ interface Durably<TJobs extends Record<string, JobHandle<string, unknown, unknown>> = Record<string, never>> {
520
+ /**
521
+ * Registered job handles (type-safe)
522
+ */
523
+ readonly jobs: TJobs;
524
+ /**
525
+ * Initialize Durably: run migrations and start the worker
526
+ * This is the recommended way to start Durably.
527
+ * Equivalent to calling migrate() then start().
528
+ * @example
529
+ * ```ts
530
+ * const durably = createDurably({ dialect }).register({ ... })
531
+ * await durably.init()
532
+ * ```
533
+ */
534
+ init(): Promise<void>;
423
535
  /**
424
536
  * Run database migrations
425
537
  * This is idempotent and safe to call multiple times
@@ -448,9 +560,19 @@ interface Durably {
448
560
  */
449
561
  onError(handler: ErrorHandler): void;
450
562
  /**
451
- * Define a job
563
+ * Register job definitions and return a new Durably instance with type-safe jobs
564
+ * @example
565
+ * ```ts
566
+ * const durably = createDurably({ dialect })
567
+ * .register({
568
+ * importCsv: importCsvJob,
569
+ * syncUsers: syncUsersJob,
570
+ * })
571
+ * await durably.migrate()
572
+ * // Usage: durably.jobs.importCsv.trigger({ rows: [...] })
573
+ * ```
452
574
  */
453
- defineJob<TName extends string, TInputSchema extends z.ZodType, TOutputSchema extends z.ZodType | undefined = undefined>(definition: JobDefinition<TName, TInputSchema, TOutputSchema>, fn: JobFunction<z.infer<TInputSchema>, TOutputSchema extends z.ZodType ? z.infer<TOutputSchema> : void>): JobHandle<TName, z.infer<TInputSchema>, TOutputSchema extends z.ZodType ? z.infer<TOutputSchema> : void>;
575
+ register<TNewJobs extends Record<string, JobDefinition<string, any, any>>>(jobDefs: TNewJobs): Durably<TJobs & TransformToHandles<TNewJobs>>;
454
576
  /**
455
577
  * Start the worker polling loop
456
578
  */
@@ -486,11 +608,21 @@ interface Durably {
486
608
  * Register a plugin
487
609
  */
488
610
  use(plugin: DurablyPlugin): void;
611
+ /**
612
+ * Get a registered job handle by name
613
+ * Returns undefined if job is not registered
614
+ */
615
+ getJob<TName extends string = string>(name: TName): JobHandle<TName, Record<string, unknown>, unknown> | undefined;
616
+ /**
617
+ * Subscribe to events for a specific run
618
+ * Returns a ReadableStream that can be used for SSE
619
+ */
620
+ subscribe(runId: string): ReadableStream<DurablyEvent>;
489
621
  }
490
622
  /**
491
623
  * Create a Durably instance
492
624
  */
493
- declare function createDurably(options: DurablyOptions): Durably;
625
+ declare function createDurably(options: DurablyOptions): Durably<Record<string, never>>;
494
626
 
495
627
  /**
496
628
  * Plugin that persists log events to the database
@@ -506,4 +638,131 @@ declare class CancelledError extends Error {
506
638
  constructor(runId: string);
507
639
  }
508
640
 
509
- export { CancelledError, type Database, type Durably, type DurablyEvent, type DurablyOptions, type DurablyPlugin, type ErrorHandler, type EventType, type JobHandle, type Log, type LogWriteEvent, type LogsTable, type Run, type RunCompleteEvent, type RunFailEvent, type RunFilter$1 as RunFilter, type RunStartEvent, type RunsTable, type SchemaVersionsTable, type Step, type StepCompleteEvent, type StepContext, type StepFailEvent, type StepStartEvent, type StepsTable, type TriggerAndWaitResult, type WorkerErrorEvent, createDurably, withLogPersistence };
641
+ /**
642
+ * Request body for triggering a job
643
+ */
644
+ interface TriggerRequest {
645
+ jobName: string;
646
+ input: Record<string, unknown>;
647
+ idempotencyKey?: string;
648
+ concurrencyKey?: string;
649
+ }
650
+ /**
651
+ * Response for trigger endpoint
652
+ */
653
+ interface TriggerResponse {
654
+ runId: string;
655
+ }
656
+ /**
657
+ * Handler interface for HTTP endpoints
658
+ */
659
+ interface DurablyHandler {
660
+ /**
661
+ * Handle all Durably HTTP requests with automatic routing
662
+ *
663
+ * Routes:
664
+ * - GET {basePath}/subscribe?runId=xxx - SSE stream
665
+ * - GET {basePath}/runs - List runs
666
+ * - GET {basePath}/run?runId=xxx - Get single run
667
+ * - POST {basePath}/trigger - Trigger a job
668
+ * - POST {basePath}/retry?runId=xxx - Retry a failed run
669
+ * - POST {basePath}/cancel?runId=xxx - Cancel a run
670
+ *
671
+ * @param request - The incoming HTTP request
672
+ * @param basePath - The base path to strip from the URL (e.g., '/api/durably')
673
+ * @returns Response or null if route not matched
674
+ *
675
+ * @example
676
+ * ```ts
677
+ * // React Router / Remix
678
+ * export async function loader({ request }) {
679
+ * return durablyHandler.handle(request, '/api/durably')
680
+ * }
681
+ * export async function action({ request }) {
682
+ * return durablyHandler.handle(request, '/api/durably')
683
+ * }
684
+ * ```
685
+ */
686
+ handle(request: Request, basePath: string): Promise<Response>;
687
+ /**
688
+ * Handle job trigger request
689
+ * Expects POST with JSON body: { jobName, input, idempotencyKey?, concurrencyKey? }
690
+ * Returns JSON: { runId }
691
+ */
692
+ trigger(request: Request): Promise<Response>;
693
+ /**
694
+ * Handle subscription request
695
+ * Expects GET with query param: runId
696
+ * Returns SSE stream of events
697
+ */
698
+ subscribe(request: Request): Response;
699
+ /**
700
+ * Handle runs list request
701
+ * Expects GET with optional query params: jobName, status, limit, offset
702
+ * Returns JSON array of runs
703
+ */
704
+ runs(request: Request): Promise<Response>;
705
+ /**
706
+ * Handle single run request
707
+ * Expects GET with query param: runId
708
+ * Returns JSON run object or 404
709
+ */
710
+ run(request: Request): Promise<Response>;
711
+ /**
712
+ * Handle retry request
713
+ * Expects POST with query param: runId
714
+ * Returns JSON: { success: true }
715
+ */
716
+ retry(request: Request): Promise<Response>;
717
+ /**
718
+ * Handle cancel request
719
+ * Expects POST with query param: runId
720
+ * Returns JSON: { success: true }
721
+ */
722
+ cancel(request: Request): Promise<Response>;
723
+ /**
724
+ * Handle delete request
725
+ * Expects DELETE with query param: runId
726
+ * Returns JSON: { success: true }
727
+ */
728
+ delete(request: Request): Promise<Response>;
729
+ /**
730
+ * Handle steps request
731
+ * Expects GET with query param: runId
732
+ * Returns JSON array of steps
733
+ */
734
+ steps(request: Request): Promise<Response>;
735
+ /**
736
+ * Handle runs subscription request
737
+ * Expects GET with optional query param: jobName
738
+ * Returns SSE stream of run update notifications
739
+ */
740
+ runsSubscribe(request: Request): Response;
741
+ }
742
+ /**
743
+ * Options for createDurablyHandler
744
+ */
745
+ interface CreateDurablyHandlerOptions {
746
+ /**
747
+ * Called before handling each request.
748
+ * Use this to initialize Durably (migrate, start worker, etc.)
749
+ *
750
+ * @example
751
+ * ```ts
752
+ * const durablyHandler = createDurablyHandler(durably, {
753
+ * onRequest: async () => {
754
+ * await durably.migrate()
755
+ * durably.start()
756
+ * }
757
+ * })
758
+ * ```
759
+ */
760
+ onRequest?: () => Promise<void> | void;
761
+ }
762
+ /**
763
+ * Create HTTP handlers for Durably
764
+ * Uses Web Standard Request/Response for framework-agnostic usage
765
+ */
766
+ declare function createDurablyHandler(durably: Durably, options?: CreateDurablyHandlerOptions): DurablyHandler;
767
+
768
+ export { CancelledError, type Database, type Durably, type DurablyEvent, type DurablyHandler, type DurablyOptions, type DurablyPlugin, type ErrorHandler, type EventType, type JobDefinition, type JobHandle, type JobInput, type JobOutput, type Log, type LogWriteEvent, type LogsTable, type Run, type RunCompleteEvent, type RunFailEvent, type RunFilter$1 as RunFilter, type RunProgressEvent, type RunStartEvent, type RunsTable, type SchemaVersionsTable, type Step, type StepCompleteEvent, type StepContext, type StepFailEvent, type StepStartEvent, type StepsTable, type TriggerAndWaitResult, type TriggerRequest, type TriggerResponse, type WorkerErrorEvent, createDurably, createDurablyHandler, defineJob, withLogPersistence };