@coji/durably 0.10.0 → 0.12.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.
@@ -1,3 +1,3 @@
1
- export { H as withLogPersistence } from '../index-BjlCb0gP.js';
1
+ export { M as withLogPersistence } from '../index-hM7-oiyj.js';
2
2
  import 'kysely';
3
3
  import 'zod';
package/docs/llms.md CHANGED
@@ -24,15 +24,30 @@ pnpm add @coji/durably kysely zod sqlocal
24
24
  import { createDurably } from '@coji/durably'
25
25
  import { LibsqlDialect } from '@libsql/kysely-libsql'
26
26
  import { createClient } from '@libsql/client'
27
+ import { z } from 'zod'
27
28
 
28
29
  const client = createClient({ url: 'file:local.db' })
29
30
  const dialect = new LibsqlDialect({ client })
30
31
 
32
+ // Option 1: With jobs (1-step initialization, returns typed instance)
31
33
  const durably = createDurably({
32
34
  dialect,
33
35
  pollingInterval: 1000, // Job polling interval (ms)
34
36
  heartbeatInterval: 5000, // Heartbeat update interval (ms)
35
37
  staleThreshold: 30000, // When to consider a job abandoned (ms)
38
+ cleanupSteps: true, // Delete step output data on terminal state (default: true)
39
+ // Optional: type-safe labels with Zod schema
40
+ // labels: z.object({ organizationId: z.string(), env: z.string() }),
41
+ jobs: {
42
+ syncUsers: syncUsersJob,
43
+ },
44
+ })
45
+ // durably.jobs.syncUsers is immediately available and type-safe
46
+
47
+ // Option 2: Without jobs (register later)
48
+ const durably = createDurably({ dialect })
49
+ const { syncUsers } = durably.register({
50
+ syncUsers: syncUsersJob,
36
51
  })
37
52
  ```
38
53
 
@@ -60,11 +75,6 @@ const syncUsersJob = defineJob({
60
75
  return { syncedCount: users.length }
61
76
  },
62
77
  })
63
-
64
- // Register jobs with durably instance
65
- const { syncUsers } = durably.register({
66
- syncUsers: syncUsersJob,
67
- })
68
78
  ```
69
79
 
70
80
  ### 3. Starting the Worker
@@ -173,7 +183,7 @@ const failedRuns = await durably.getRuns({ status: 'failed' })
173
183
 
174
184
  // Filter by job name with pagination
175
185
  const runs = await durably.getRuns({
176
- jobName: 'sync-users',
186
+ jobName: 'sync-users', // also accepts string[] for multiple jobs
177
187
  status: 'completed',
178
188
  limit: 10,
179
189
  offset: 0,
@@ -197,10 +207,12 @@ type MyRun = Run & {
197
207
  const typedRuns = await durably.getRuns<MyRun>({ jobName: 'my-job' })
198
208
  ```
199
209
 
200
- ### Retry Failed Runs
210
+ ### Retrigger Failed Runs
201
211
 
202
212
  ```ts
203
- await durably.retry(runId)
213
+ // Creates a fresh run (new ID) with the same input/options
214
+ const newRun = await durably.retrigger(runId)
215
+ console.log(newRun.id) // new run ID
204
216
  ```
205
217
 
206
218
  ### Cancel Runs
@@ -227,7 +239,6 @@ durably.on('run:complete', (e) => console.log('Done:', e.output))
227
239
  durably.on('run:fail', (e) => console.error('Failed:', e.error))
228
240
  durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId))
229
241
  durably.on('run:delete', (e) => console.log('Deleted:', e.runId))
230
- durably.on('run:retry', (e) => console.log('Retried:', e.runId))
231
242
  durably.on('run:progress', (e) =>
232
243
  console.log('Progress:', e.progress.current, '/', e.progress.total),
233
244
  )
@@ -296,23 +307,57 @@ import { createDurablyHandler } from '@coji/durably'
296
307
 
297
308
  const handler = createDurablyHandler(durably, {
298
309
  sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable)
310
+ onRequest: async () => {
311
+ // Called before each request (after auth) — useful for lazy init
312
+ await durably.init()
313
+ },
299
314
  })
300
315
 
301
- // Use the unified handle() method with automatic routing
316
+ // Use the handle() method with automatic routing
302
317
  app.all('/api/durably/*', async (req) => {
303
318
  return await handler.handle(req, '/api/durably')
304
319
  })
320
+ ```
321
+
322
+ **With auth middleware (multi-tenant):**
323
+
324
+ ```ts
325
+ const handler = createDurablyHandler(durably, {
326
+ auth: {
327
+ // Required: authenticate every request. Throw Response to reject.
328
+ authenticate: async (request) => {
329
+ const session = await requireUser(request)
330
+ const orgId = await resolveCurrentOrgId(request, session.user.id)
331
+ return { orgId }
332
+ },
333
+
334
+ // Guard before trigger (called after body validation + job resolution)
335
+ onTrigger: async (ctx, { jobName, input, labels }) => {
336
+ if (labels?.organizationId !== ctx.orgId) {
337
+ throw new Response('Forbidden', { status: 403 })
338
+ }
339
+ },
340
+
341
+ // Guard before run-level operations (read, subscribe, steps, retrigger, cancel, delete)
342
+ onRunAccess: async (ctx, run, { operation }) => {
343
+ if (run.labels.organizationId !== ctx.orgId) {
344
+ throw new Response('Forbidden', { status: 403 })
345
+ }
346
+ },
305
347
 
306
- // Or use individual endpoints
307
- app.post('/api/durably/trigger', (req) => handler.trigger(req))
308
- app.get('/api/durably/subscribe', (req) => handler.subscribe(req))
309
- app.get('/api/durably/runs', (req) => handler.runs(req))
310
- app.get('/api/durably/run', (req) => handler.run(req))
311
- app.get('/api/durably/steps', (req) => handler.steps(req))
312
- app.get('/api/durably/runs/subscribe', (req) => handler.runsSubscribe(req))
313
- app.post('/api/durably/retry', (req) => handler.retry(req))
314
- app.post('/api/durably/cancel', (req) => handler.cancel(req))
315
- app.delete('/api/durably/run', (req) => handler.delete(req))
348
+ // Scope runs list queries (GET /runs)
349
+ scopeRuns: async (ctx, filter) => ({
350
+ ...filter,
351
+ labels: { ...filter.labels, organizationId: ctx.orgId },
352
+ }),
353
+
354
+ // Scope runs subscribe stream (GET /runs/subscribe). Falls back to scopeRuns if not set.
355
+ scopeRunsSubscribe: async (ctx, filter) => ({
356
+ ...filter,
357
+ labels: { ...filter.labels, organizationId: ctx.orgId },
358
+ }),
359
+ },
360
+ })
316
361
  ```
317
362
 
318
363
  **Label filtering via query params:**
@@ -335,27 +380,58 @@ const clientRun = toClientRun(run) // strips internal fields
335
380
 
336
381
  ```ts
337
382
  interface DurablyHandler {
338
- // Unified routing handler
339
383
  handle(request: Request, basePath: string): Promise<Response>
384
+ }
340
385
 
341
- // Individual endpoints
342
- trigger(request: Request): Promise<Response> // POST /trigger
343
- subscribe(request: Request): Response // GET /subscribe?runId=xxx (SSE)
344
- runs(request: Request): Promise<Response> // GET /runs
345
- run(request: Request): Promise<Response> // GET /run?runId=xxx
346
- steps(request: Request): Promise<Response> // GET /steps?runId=xxx
347
- runsSubscribe(request: Request): Response // GET /runs/subscribe (SSE)
348
- retry(request: Request): Promise<Response> // POST /retry?runId=xxx
349
- cancel(request: Request): Promise<Response> // POST /cancel?runId=xxx
350
- delete(request: Request): Promise<Response> // DELETE /run?runId=xxx
386
+ interface CreateDurablyHandlerOptions<
387
+ TContext = undefined,
388
+ TLabels extends Record<string, string> = Record<string, string>,
389
+ > {
390
+ onRequest?: () => Promise<void> | void
391
+ sseThrottleMs?: number // default: 100
392
+ auth?: AuthConfig<TContext, TLabels>
351
393
  }
352
394
 
353
- interface TriggerRequest {
395
+ interface AuthConfig<
396
+ TContext,
397
+ TLabels extends Record<string, string> = Record<string, string>,
398
+ > {
399
+ authenticate: (request: Request) => Promise<TContext> | TContext
400
+ onTrigger?: (
401
+ ctx: TContext,
402
+ trigger: TriggerRequest<TLabels>,
403
+ ) => Promise<void> | void
404
+ onRunAccess?: (
405
+ ctx: TContext,
406
+ run: Run<TLabels>,
407
+ info: { operation: RunOperation },
408
+ ) => Promise<void> | void
409
+ scopeRuns?: (
410
+ ctx: TContext,
411
+ filter: RunFilter<TLabels>,
412
+ ) => RunFilter<TLabels> | Promise<RunFilter<TLabels>>
413
+ scopeRunsSubscribe?: (
414
+ ctx: TContext,
415
+ filter: RunsSubscribeFilter<TLabels>,
416
+ ) => RunsSubscribeFilter<TLabels> | Promise<RunsSubscribeFilter<TLabels>>
417
+ }
418
+
419
+ type RunOperation =
420
+ | 'read'
421
+ | 'subscribe'
422
+ | 'steps'
423
+ | 'retrigger'
424
+ | 'cancel'
425
+ | 'delete'
426
+
427
+ // RunsSubscribeFilter is Pick<RunFilter, 'jobName' | 'labels'>
428
+
429
+ interface TriggerRequest<TLabels> {
354
430
  jobName: string
355
- input: Record<string, unknown>
431
+ input: unknown
356
432
  idempotencyKey?: string
357
433
  concurrencyKey?: string
358
- labels?: Record<string, string>
434
+ labels?: TLabels
359
435
  }
360
436
 
361
437
  interface TriggerResponse {
@@ -387,17 +463,15 @@ const durably = createDurably({
387
463
  pollingInterval: 100,
388
464
  heartbeatInterval: 500,
389
465
  staleThreshold: 3000,
390
- })
391
-
392
- // Same API as Node.js
393
- const { myJob } = durably.register({
394
- myJob: defineJob({
395
- name: 'my-job',
396
- input: z.object({}),
397
- run: async (step) => {
398
- /* ... */
399
- },
400
- }),
466
+ jobs: {
467
+ myJob: defineJob({
468
+ name: 'my-job',
469
+ input: z.object({}),
470
+ run: async (step) => {
471
+ /* ... */
472
+ },
473
+ }),
474
+ },
401
475
  })
402
476
 
403
477
  // Initialize (same as Node.js)
@@ -450,44 +524,88 @@ interface StepContext {
450
524
  }
451
525
  }
452
526
 
453
- interface Run<TOutput = unknown> {
527
+ // TLabels defaults to Record<string, string> when no labels schema is provided
528
+ interface Run<TLabels extends Record<string, string> = Record<string, string>> {
454
529
  id: string
455
530
  jobName: string
456
531
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
457
532
  input: unknown
458
- labels: Record<string, string>
459
- output?: TOutput
460
- error?: string
461
- progress?: { current: number; total?: number; message?: string }
533
+ labels: TLabels
534
+ output: unknown | null
535
+ error: string | null
536
+ progress: { current: number; total?: number; message?: string } | null
462
537
  startedAt: string | null
463
538
  completedAt: string | null
464
539
  createdAt: string
465
540
  updatedAt: string
466
541
  }
467
542
 
468
- interface JobHandle<TName, TInput, TOutput> {
543
+ interface TypedRun<
544
+ TOutput,
545
+ TLabels extends Record<string, string> = Record<string, string>,
546
+ > extends Omit<Run<TLabels>, 'output'> {
547
+ output: TOutput | null
548
+ }
549
+
550
+ interface JobHandle<
551
+ TName extends string,
552
+ TInput,
553
+ TOutput,
554
+ TLabels extends Record<string, string> = Record<string, string>,
555
+ > {
469
556
  name: TName
470
- trigger(input: TInput, options?: TriggerOptions): Promise<Run<TOutput>>
557
+ trigger(
558
+ input: TInput,
559
+ options?: TriggerOptions<TLabels>,
560
+ ): Promise<TypedRun<TOutput, TLabels>>
471
561
  triggerAndWait(
472
562
  input: TInput,
473
- options?: TriggerOptions,
563
+ options?: TriggerAndWaitOptions<TLabels>,
474
564
  ): Promise<{ id: string; output: TOutput }>
475
- batchTrigger(inputs: BatchTriggerInput<TInput>[]): Promise<Run<TOutput>[]>
476
- getRun(id: string): Promise<Run<TOutput> | null>
477
- getRuns(filter?: RunFilter): Promise<Run<TOutput>[]>
565
+ batchTrigger(
566
+ inputs: BatchTriggerInput<TInput, TLabels>[],
567
+ ): Promise<TypedRun<TOutput, TLabels>[]>
568
+ getRun(id: string): Promise<TypedRun<TOutput, TLabels> | null>
569
+ getRuns(
570
+ filter?: Omit<RunFilter<TLabels>, 'jobName'>,
571
+ ): Promise<TypedRun<TOutput, TLabels>[]>
478
572
  }
479
573
 
480
- interface TriggerOptions {
574
+ interface TriggerOptions<
575
+ TLabels extends Record<string, string> = Record<string, string>,
576
+ > {
481
577
  idempotencyKey?: string
482
578
  concurrencyKey?: string
483
- labels?: Record<string, string>
579
+ labels?: TLabels
580
+ }
581
+
582
+ interface TriggerAndWaitOptions<
583
+ TLabels extends Record<string, string> = Record<string, string>,
584
+ > extends TriggerOptions<TLabels> {
484
585
  timeout?: number
586
+ onProgress?: (progress: ProgressData) => void | Promise<void>
587
+ onLog?: (log: LogData) => void | Promise<void>
588
+ }
589
+
590
+ interface ProgressData {
591
+ current: number
592
+ total?: number
593
+ message?: string
594
+ }
595
+
596
+ interface LogData {
597
+ level: 'info' | 'warn' | 'error'
598
+ message: string
599
+ data?: unknown
600
+ stepName?: string | null
485
601
  }
486
602
 
487
- interface RunFilter {
603
+ interface RunFilter<
604
+ TLabels extends Record<string, string> = Record<string, string>,
605
+ > {
488
606
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
489
- jobName?: string
490
- labels?: Record<string, string>
607
+ jobName?: string | string[]
608
+ labels?: Partial<TLabels>
491
609
  limit?: number
492
610
  offset?: number
493
611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Step-oriented resumable batch execution for Node.js and browsers using SQLite",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -48,7 +48,7 @@
48
48
  "ulidx": "^2.4.1"
49
49
  },
50
50
  "devDependencies": {
51
- "@biomejs/biome": "^2.4.5",
51
+ "@biomejs/biome": "^2.4.6",
52
52
  "@libsql/client": "^0.17.0",
53
53
  "@libsql/kysely-libsql": "^0.4.1",
54
54
  "@testing-library/react": "^16.3.2",