@coji/durably 0.0.1 → 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 coji
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,54 +1,74 @@
1
- # durably
1
+ # @coji/durably
2
2
 
3
3
  Step-oriented resumable batch execution for Node.js and browsers using SQLite.
4
4
 
5
- > **Note**: This package is under development. The API is not yet implemented.
5
+ **[Documentation](https://coji.github.io/durably/)** | **[GitHub](https://github.com/coji/durably)** | **[Live Demo](https://durably-demo.vercel.app)**
6
6
 
7
- ## Features (Planned)
7
+ ## Features
8
8
 
9
9
  - Resumable batch processing with step-level persistence
10
10
  - Works in both Node.js and browsers
11
- - Uses SQLite for state management (better-sqlite3, libsql, or WASM)
12
- - Minimal dependencies - just Kysely as a peer dependency
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
13
  - Event system for monitoring and extensibility
14
- - Plugin architecture for optional features
14
+ - Type-safe input/output with Zod schemas
15
15
 
16
16
  ## Installation
17
17
 
18
18
  ```bash
19
- npm install durably kysely better-sqlite3
19
+ # Node.js with better-sqlite3
20
+ 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
20
27
  ```
21
28
 
22
- ## Usage (Preview)
29
+ ## Usage
23
30
 
24
31
  ```ts
25
- import { createClient, defineJob } from 'durably'
26
- import Database from 'better-sqlite3'
27
- import { BetterSqlite3Dialect } from 'kysely'
32
+ import { createDurably } from '@coji/durably'
33
+ import SQLite from 'better-sqlite3'
34
+ import { SqliteDialect } from 'kysely'
35
+ import { z } from 'zod'
28
36
 
29
- const dialect = new BetterSqlite3Dialect({
30
- database: new Database('app.db'),
37
+ const dialect = new SqliteDialect({
38
+ database: new SQLite('local.db'),
31
39
  })
32
40
 
33
- const client = createClient({ dialect })
41
+ const durably = createDurably({ dialect })
34
42
 
35
- const syncUsers = defineJob('sync-users', async (ctx, payload: { orgId: string }) => {
36
- const users = await ctx.run('fetch-users', async () => {
37
- return api.fetchUsers(payload.orgId)
38
- })
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 (context, payload) => {
50
+ const users = await context.run('fetch-users', async () => {
51
+ return api.fetchUsers(payload.orgId)
52
+ })
39
53
 
40
- await ctx.run('save-to-db', async () => {
41
- await db.upsertUsers(users)
42
- })
43
- })
54
+ await context.run('save-to-db', async () => {
55
+ await db.upsertUsers(users)
56
+ })
44
57
 
45
- client.register(syncUsers)
46
- await client.migrate()
47
- client.start()
58
+ return { syncedCount: users.length }
59
+ },
60
+ )
61
+
62
+ await durably.migrate()
63
+ durably.start()
48
64
 
49
65
  await syncUsers.trigger({ orgId: 'org_123' })
50
66
  ```
51
67
 
68
+ ## Documentation
69
+
70
+ For full documentation, visit [coji.github.io/durably](https://coji.github.io/durably/).
71
+
52
72
  ## License
53
73
 
54
74
  MIT
package/dist/index.d.ts CHANGED
@@ -1,55 +1,509 @@
1
+ import { Dialect, Kysely } from 'kysely';
2
+ import { z } from 'zod';
3
+
1
4
  /**
2
- * durably - Step-oriented resumable batch execution for Node.js and browsers
3
- *
4
- * This package is under development. See https://github.com/coji/durably for updates.
5
+ * Base event interface
5
6
  */
6
- interface ClientOptions {
7
- dialect: unknown;
8
- pollingInterval?: number;
9
- heartbeatInterval?: number;
10
- staleThreshold?: number;
7
+ interface BaseEvent {
8
+ type: string;
9
+ timestamp: string;
10
+ sequence: number;
11
+ }
12
+ /**
13
+ * Run start event
14
+ */
15
+ interface RunStartEvent extends BaseEvent {
16
+ type: 'run:start';
17
+ runId: string;
18
+ jobName: string;
19
+ payload: unknown;
20
+ }
21
+ /**
22
+ * Run complete event
23
+ */
24
+ interface RunCompleteEvent extends BaseEvent {
25
+ type: 'run:complete';
26
+ runId: string;
27
+ jobName: string;
28
+ output: unknown;
29
+ duration: number;
30
+ }
31
+ /**
32
+ * Run fail event
33
+ */
34
+ interface RunFailEvent extends BaseEvent {
35
+ type: 'run:fail';
36
+ runId: string;
37
+ jobName: string;
38
+ error: string;
39
+ failedStepName: string;
40
+ }
41
+ /**
42
+ * Step start event
43
+ */
44
+ interface StepStartEvent extends BaseEvent {
45
+ type: 'step:start';
46
+ runId: string;
47
+ jobName: string;
48
+ stepName: string;
49
+ stepIndex: number;
50
+ }
51
+ /**
52
+ * Step complete event
53
+ */
54
+ interface StepCompleteEvent extends BaseEvent {
55
+ type: 'step:complete';
56
+ runId: string;
57
+ jobName: string;
58
+ stepName: string;
59
+ stepIndex: number;
60
+ output: unknown;
61
+ duration: number;
62
+ }
63
+ /**
64
+ * Step fail event
65
+ */
66
+ interface StepFailEvent extends BaseEvent {
67
+ type: 'step:fail';
68
+ runId: string;
69
+ jobName: string;
70
+ stepName: string;
71
+ stepIndex: number;
72
+ error: string;
73
+ }
74
+ /**
75
+ * Log write event
76
+ */
77
+ interface LogWriteEvent extends BaseEvent {
78
+ type: 'log:write';
79
+ runId: string;
80
+ stepName: string | null;
81
+ level: 'info' | 'warn' | 'error';
82
+ message: string;
83
+ data: unknown;
84
+ }
85
+ /**
86
+ * Worker error event (internal errors like heartbeat failures)
87
+ */
88
+ interface WorkerErrorEvent extends BaseEvent {
89
+ type: 'worker:error';
90
+ error: string;
91
+ context: string;
92
+ runId?: string;
93
+ }
94
+ /**
95
+ * All event types as discriminated union
96
+ */
97
+ type DurablyEvent = RunStartEvent | RunCompleteEvent | RunFailEvent | StepStartEvent | StepCompleteEvent | StepFailEvent | LogWriteEvent | WorkerErrorEvent;
98
+ /**
99
+ * Event types for type-safe event names
100
+ */
101
+ type EventType = DurablyEvent['type'];
102
+ /**
103
+ * Extract event by type
104
+ */
105
+ type EventByType<T extends EventType> = Extract<DurablyEvent, {
106
+ type: T;
107
+ }>;
108
+ /**
109
+ * Event input (without auto-generated fields)
110
+ */
111
+ type EventInput<T extends EventType> = Omit<EventByType<T>, 'timestamp' | 'sequence'>;
112
+ /**
113
+ * All possible event inputs as a union (properly distributed)
114
+ */
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'>;
116
+ /**
117
+ * Event listener function
118
+ */
119
+ type EventListener<T extends EventType> = (event: EventByType<T>) => void;
120
+ /**
121
+ * Unsubscribe function returned by on()
122
+ */
123
+ type Unsubscribe = () => void;
124
+ /**
125
+ * Error handler function for listener exceptions
126
+ */
127
+ type ErrorHandler = (error: Error, event: DurablyEvent) => void;
128
+
129
+ /**
130
+ * Database schema types for Durably
131
+ */
132
+ interface RunsTable {
133
+ id: string;
134
+ job_name: string;
135
+ payload: string;
136
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
137
+ idempotency_key: string | null;
138
+ concurrency_key: string | null;
139
+ current_step_index: number;
140
+ progress: string | null;
141
+ output: string | null;
142
+ error: string | null;
143
+ heartbeat_at: string;
144
+ created_at: string;
145
+ updated_at: string;
146
+ }
147
+ interface StepsTable {
148
+ id: string;
149
+ run_id: string;
150
+ name: string;
151
+ index: number;
152
+ status: 'completed' | 'failed';
153
+ output: string | null;
154
+ error: string | null;
155
+ started_at: string;
156
+ completed_at: string | null;
157
+ }
158
+ interface LogsTable {
159
+ id: string;
160
+ run_id: string;
161
+ step_name: string | null;
162
+ level: 'info' | 'warn' | 'error';
163
+ message: string;
164
+ data: string | null;
165
+ created_at: string;
166
+ }
167
+ interface SchemaVersionsTable {
168
+ version: number;
169
+ applied_at: string;
170
+ }
171
+ interface Database {
172
+ durably_runs: RunsTable;
173
+ durably_steps: StepsTable;
174
+ durably_logs: LogsTable;
175
+ durably_schema_versions: SchemaVersionsTable;
176
+ }
177
+
178
+ /**
179
+ * Run data for creating a new run
180
+ */
181
+ interface CreateRunInput {
182
+ jobName: string;
183
+ payload: unknown;
184
+ idempotencyKey?: string;
185
+ concurrencyKey?: string;
186
+ }
187
+ /**
188
+ * Run data returned from storage
189
+ */
190
+ interface Run {
191
+ id: string;
192
+ jobName: string;
193
+ payload: unknown;
194
+ status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
195
+ idempotencyKey: string | null;
196
+ concurrencyKey: string | null;
197
+ currentStepIndex: number;
198
+ progress: {
199
+ current: number;
200
+ total?: number;
201
+ message?: string;
202
+ } | null;
203
+ output: unknown | null;
204
+ error: string | null;
205
+ heartbeatAt: string;
206
+ createdAt: string;
207
+ updatedAt: string;
208
+ }
209
+ /**
210
+ * Run update data
211
+ */
212
+ interface UpdateRunInput {
213
+ status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
214
+ currentStepIndex?: number;
215
+ progress?: {
216
+ current: number;
217
+ total?: number;
218
+ message?: string;
219
+ } | null;
220
+ output?: unknown;
221
+ error?: string | null;
222
+ heartbeatAt?: string;
223
+ }
224
+ /**
225
+ * Run filter options
226
+ */
227
+ interface RunFilter$1 {
228
+ status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled';
229
+ jobName?: string;
230
+ /** Maximum number of runs to return */
231
+ limit?: number;
232
+ /** Number of runs to skip (for pagination) */
233
+ offset?: number;
11
234
  }
12
- interface JobContext<TPayload> {
13
- run<T>(name: string, fn: () => Promise<T>): Promise<T>;
235
+ /**
236
+ * Step data for creating a new step
237
+ */
238
+ interface CreateStepInput {
239
+ runId: string;
240
+ name: string;
241
+ index: number;
242
+ status: 'completed' | 'failed';
243
+ output?: unknown;
244
+ error?: string;
245
+ startedAt: string;
246
+ }
247
+ /**
248
+ * Step data returned from storage
249
+ */
250
+ interface Step {
251
+ id: string;
252
+ runId: string;
253
+ name: string;
254
+ index: number;
255
+ status: 'completed' | 'failed';
256
+ output: unknown | null;
257
+ error: string | null;
258
+ startedAt: string;
259
+ completedAt: string | null;
260
+ }
261
+ /**
262
+ * Log data for creating a new log
263
+ */
264
+ interface CreateLogInput {
265
+ runId: string;
266
+ stepName: string | null;
267
+ level: 'info' | 'warn' | 'error';
268
+ message: string;
269
+ data?: unknown;
270
+ }
271
+ /**
272
+ * Log data returned from storage
273
+ */
274
+ interface Log {
275
+ id: string;
276
+ runId: string;
277
+ stepName: string | null;
278
+ level: 'info' | 'warn' | 'error';
279
+ message: string;
280
+ data: unknown | null;
281
+ createdAt: string;
282
+ }
283
+ /**
284
+ * Storage interface for database operations
285
+ */
286
+ interface Storage {
287
+ createRun(input: CreateRunInput): Promise<Run>;
288
+ batchCreateRuns(inputs: CreateRunInput[]): Promise<Run[]>;
289
+ updateRun(runId: string, data: UpdateRunInput): Promise<void>;
290
+ deleteRun(runId: string): Promise<void>;
291
+ getRun(runId: string): Promise<Run | null>;
292
+ getRuns(filter?: RunFilter$1): Promise<Run[]>;
293
+ getNextPendingRun(excludeConcurrencyKeys: string[]): Promise<Run | null>;
294
+ createStep(input: CreateStepInput): Promise<Step>;
295
+ getSteps(runId: string): Promise<Step[]>;
296
+ getCompletedStep(runId: string, name: string): Promise<Step | null>;
297
+ createLog(input: CreateLogInput): Promise<Log>;
298
+ getLogs(runId: string): Promise<Log[]>;
299
+ }
300
+
301
+ /**
302
+ * Job context passed to the job function
303
+ */
304
+ interface JobContext {
305
+ /**
306
+ * The ID of the current run
307
+ */
308
+ readonly runId: string;
309
+ /**
310
+ * Execute a step with automatic persistence and replay
311
+ */
312
+ run<T>(name: string, fn: () => T | Promise<T>): Promise<T>;
313
+ /**
314
+ * Report progress for the current run
315
+ */
316
+ progress(current: number, total?: number, message?: string): void;
317
+ /**
318
+ * Log a message
319
+ */
14
320
  log: {
15
- info(message: string, data?: Record<string, unknown>): void;
16
- warn(message: string, data?: Record<string, unknown>): void;
17
- error(message: string, data?: Record<string, unknown>): void;
321
+ info(message: string, data?: unknown): void;
322
+ warn(message: string, data?: unknown): void;
323
+ error(message: string, data?: unknown): void;
18
324
  };
19
325
  }
20
- interface Job<TPayload> {
326
+ /**
327
+ * Job function type
328
+ */
329
+ type JobFunction<TInput, TOutput> = (context: JobContext, 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
+ /**
339
+ * Trigger options
340
+ */
341
+ interface TriggerOptions {
342
+ idempotencyKey?: string;
343
+ concurrencyKey?: string;
344
+ /** Timeout in milliseconds for triggerAndWait() */
345
+ timeout?: number;
346
+ }
347
+ /**
348
+ * Run filter options
349
+ */
350
+ interface RunFilter {
351
+ status?: 'pending' | 'running' | 'completed' | 'failed';
352
+ jobName?: string;
353
+ }
354
+ /**
355
+ * Typed run with output type
356
+ */
357
+ interface TypedRun<TOutput> extends Omit<Run, 'output'> {
358
+ output: TOutput | null;
359
+ }
360
+ /**
361
+ * Batch trigger input - either just the input or input with options
362
+ */
363
+ type BatchTriggerInput<TInput> = TInput | {
364
+ input: TInput;
365
+ options?: TriggerOptions;
366
+ };
367
+ /**
368
+ * Result of triggerAndWait
369
+ */
370
+ interface TriggerAndWaitResult<TOutput> {
371
+ id: string;
372
+ output: TOutput;
373
+ }
374
+ /**
375
+ * Job handle returned by defineJob
376
+ */
377
+ interface JobHandle<TName extends string, TInput, TOutput> {
378
+ readonly name: TName;
379
+ /**
380
+ * Trigger a new run
381
+ */
382
+ trigger(input: TInput, options?: TriggerOptions): Promise<TypedRun<TOutput>>;
383
+ /**
384
+ * Trigger a new run and wait for completion
385
+ * Returns the output directly, throws if the run fails
386
+ */
387
+ triggerAndWait(input: TInput, options?: TriggerOptions): Promise<TriggerAndWaitResult<TOutput>>;
388
+ /**
389
+ * Trigger multiple runs in a batch
390
+ * All inputs are validated before any runs are created
391
+ */
392
+ batchTrigger(inputs: BatchTriggerInput<TInput>[]): Promise<TypedRun<TOutput>[]>;
393
+ /**
394
+ * Get a run by ID
395
+ */
396
+ getRun(id: string): Promise<TypedRun<TOutput> | null>;
397
+ /**
398
+ * Get runs with optional filter
399
+ */
400
+ getRuns(filter?: Omit<RunFilter, 'jobName'>): Promise<TypedRun<TOutput>[]>;
401
+ }
402
+
403
+ /**
404
+ * Options for creating a Durably instance
405
+ */
406
+ interface DurablyOptions {
407
+ dialect: Dialect;
408
+ pollingInterval?: number;
409
+ heartbeatInterval?: number;
410
+ staleThreshold?: number;
411
+ }
412
+ /**
413
+ * Plugin interface for extending Durably
414
+ */
415
+ interface DurablyPlugin {
21
416
  name: string;
22
- trigger(payload: TPayload, options?: {
23
- idempotencyKey?: string;
24
- concurrencyKey?: string;
25
- }): Promise<{
26
- runId: string;
27
- }>;
28
- batchTrigger(items: Array<{
29
- payload: TPayload;
30
- options?: {
31
- idempotencyKey?: string;
32
- concurrencyKey?: string;
33
- };
34
- }>): Promise<Array<{
35
- runId: string;
36
- }>>;
37
- }
38
- interface Client {
39
- register<TPayload>(job: Job<TPayload>): void;
417
+ install(durably: Durably): void;
418
+ }
419
+ /**
420
+ * Durably instance
421
+ */
422
+ interface Durably {
423
+ /**
424
+ * Run database migrations
425
+ * This is idempotent and safe to call multiple times
426
+ */
40
427
  migrate(): Promise<void>;
428
+ /**
429
+ * Get the underlying Kysely database instance
430
+ * Useful for testing and advanced use cases
431
+ */
432
+ readonly db: Kysely<Database>;
433
+ /**
434
+ * Storage layer for database operations
435
+ */
436
+ readonly storage: Storage;
437
+ /**
438
+ * Register an event listener
439
+ * @returns Unsubscribe function
440
+ */
441
+ on<T extends EventType>(type: T, listener: EventListener<T>): Unsubscribe;
442
+ /**
443
+ * Emit an event (auto-assigns timestamp and sequence)
444
+ */
445
+ emit(event: AnyEventInput): void;
446
+ /**
447
+ * Register an error handler for listener exceptions
448
+ */
449
+ onError(handler: ErrorHandler): void;
450
+ /**
451
+ * Define a job
452
+ */
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>;
454
+ /**
455
+ * Start the worker polling loop
456
+ */
41
457
  start(): void;
458
+ /**
459
+ * Stop the worker after current run completes
460
+ */
42
461
  stop(): Promise<void>;
462
+ /**
463
+ * Retry a failed run by resetting it to pending
464
+ * @throws Error if run is not in failed status
465
+ */
43
466
  retry(runId: string): Promise<void>;
44
- getRuns(filter?: {
45
- status?: string;
46
- }): Promise<Array<{
47
- id: string;
48
- }>>;
49
- on(event: string, handler: (event: unknown) => void): void;
50
- use(plugin: unknown): void;
51
- }
52
- declare function createClient(_options: ClientOptions): Client;
53
- declare function defineJob<TPayload>(_name: string, _handler: (ctx: JobContext<TPayload>, payload: TPayload) => Promise<void>): Job<TPayload>;
54
-
55
- export { type Client, type ClientOptions, type Job, type JobContext, createClient, defineJob };
467
+ /**
468
+ * Cancel a pending or running run
469
+ * @throws Error if run is already completed, failed, or cancelled
470
+ */
471
+ cancel(runId: string): Promise<void>;
472
+ /**
473
+ * Delete a completed, failed, or cancelled run and its associated steps and logs
474
+ * @throws Error if run is pending or running, or does not exist
475
+ */
476
+ deleteRun(runId: string): Promise<void>;
477
+ /**
478
+ * Get a run by ID (returns unknown output type)
479
+ */
480
+ getRun(runId: string): Promise<Run | null>;
481
+ /**
482
+ * Get runs with optional filtering
483
+ */
484
+ getRuns(filter?: RunFilter$1): Promise<Run[]>;
485
+ /**
486
+ * Register a plugin
487
+ */
488
+ use(plugin: DurablyPlugin): void;
489
+ }
490
+ /**
491
+ * Create a Durably instance
492
+ */
493
+ declare function createDurably(options: DurablyOptions): Durably;
494
+
495
+ /**
496
+ * Plugin that persists log events to the database
497
+ */
498
+ declare function withLogPersistence(): DurablyPlugin;
499
+
500
+ /**
501
+ * Error thrown when a run is cancelled during execution.
502
+ * The worker catches this error and treats it specially - it does not
503
+ * mark the run as failed, as the run status is already 'cancelled'.
504
+ */
505
+ declare class CancelledError extends Error {
506
+ constructor(runId: string);
507
+ }
508
+
509
+ export { CancelledError, type Database, type Durably, type DurablyEvent, type DurablyOptions, type DurablyPlugin, type ErrorHandler, type EventType, type JobContext, 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 StepFailEvent, type StepStartEvent, type StepsTable, type TriggerAndWaitResult, type WorkerErrorEvent, createDurably, withLogPersistence };