@coji/durably 0.4.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;
@@ -336,8 +375,12 @@ interface TriggerOptions {
336
375
  * Run filter options
337
376
  */
338
377
  interface RunFilter {
339
- status?: 'pending' | 'running' | 'completed' | 'failed';
378
+ status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
340
379
  jobName?: string;
380
+ /** Maximum number of runs to return */
381
+ limit?: number;
382
+ /** Number of runs to skip (for pagination) */
383
+ offset?: number;
341
384
  }
342
385
  /**
343
386
  * Typed run with output type
@@ -402,6 +445,22 @@ interface JobDefinition<TName extends string, TInput, TOutput> {
402
445
  readonly output: z.ZodType<TOutput> | undefined;
403
446
  readonly run: JobRunFunction<TInput, TOutput>;
404
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;
405
464
  /**
406
465
  * Configuration for defining a job
407
466
  */
@@ -446,12 +505,33 @@ interface DurablyOptions {
446
505
  */
447
506
  interface DurablyPlugin {
448
507
  name: string;
449
- install(durably: Durably): void;
508
+ install(durably: Durably<any>): void;
450
509
  }
451
510
  /**
452
- * Durably instance
511
+ * Helper type to transform JobDefinition record to JobHandle record
453
512
  */
454
- interface Durably {
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
518
+ */
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>;
455
535
  /**
456
536
  * Run database migrations
457
537
  * This is idempotent and safe to call multiple times
@@ -480,11 +560,19 @@ interface Durably {
480
560
  */
481
561
  onError(handler: ErrorHandler): void;
482
562
  /**
483
- * Register a job definition and return a job handle
484
- * Same JobDefinition can be registered multiple times (idempotent)
485
- * Different JobDefinitions with the same name will throw an error
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
+ * ```
486
574
  */
487
- register<TName extends string, TInput, TOutput>(jobDef: JobDefinition<TName, TInput, TOutput>): JobHandle<TName, TInput, TOutput>;
575
+ register<TNewJobs extends Record<string, JobDefinition<string, any, any>>>(jobDefs: TNewJobs): Durably<TJobs & TransformToHandles<TNewJobs>>;
488
576
  /**
489
577
  * Start the worker polling loop
490
578
  */
@@ -520,11 +608,21 @@ interface Durably {
520
608
  * Register a plugin
521
609
  */
522
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>;
523
621
  }
524
622
  /**
525
623
  * Create a Durably instance
526
624
  */
527
- declare function createDurably(options: DurablyOptions): Durably;
625
+ declare function createDurably(options: DurablyOptions): Durably<Record<string, never>>;
528
626
 
529
627
  /**
530
628
  * Plugin that persists log events to the database
@@ -540,4 +638,131 @@ declare class CancelledError extends Error {
540
638
  constructor(runId: string);
541
639
  }
542
640
 
543
- export { CancelledError, type Database, type Durably, type DurablyEvent, type DurablyOptions, type DurablyPlugin, type ErrorHandler, type EventType, type JobDefinition, 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, defineJob, 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 };