@coji/durably 0.9.0 → 0.11.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 { N as withLogPersistence } from '../index-fppJjkF-.js';
2
2
  import 'kysely';
3
3
  import 'zod';
package/docs/llms.md CHANGED
@@ -24,15 +24,29 @@ 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
+ // Optional: type-safe labels with Zod schema
39
+ // labels: z.object({ organizationId: z.string(), env: z.string() }),
40
+ jobs: {
41
+ syncUsers: syncUsersJob,
42
+ },
43
+ })
44
+ // durably.jobs.syncUsers is immediately available and type-safe
45
+
46
+ // Option 2: Without jobs (register later)
47
+ const durably = createDurably({ dialect })
48
+ const { syncUsers } = durably.register({
49
+ syncUsers: syncUsersJob,
36
50
  })
37
51
  ```
38
52
 
@@ -60,11 +74,6 @@ const syncUsersJob = defineJob({
60
74
  return { syncedCount: users.length }
61
75
  },
62
76
  })
63
-
64
- // Register jobs with durably instance
65
- const { syncUsers } = durably.register({
66
- syncUsers: syncUsersJob,
67
- })
68
77
  ```
69
78
 
70
79
  ### 3. Starting the Worker
@@ -127,7 +136,7 @@ const result = await step.run('step-name', async (signal) => {
127
136
 
128
137
  ### step.progress(current, total?, message?)
129
138
 
130
- Updates progress information for the run.
139
+ Updates progress information for the run. Call freely in loops — SSE delivery is throttled by `sseThrottleMs` (default 100ms) so clients receive smooth updates without flooding.
131
140
 
132
141
  ```ts
133
142
  step.progress(50, 100, 'Processing items...')
@@ -173,7 +182,7 @@ const failedRuns = await durably.getRuns({ status: 'failed' })
173
182
 
174
183
  // Filter by job name with pagination
175
184
  const runs = await durably.getRuns({
176
- jobName: 'sync-users',
185
+ jobName: 'sync-users', // also accepts string[] for multiple jobs
177
186
  status: 'completed',
178
187
  limit: 10,
179
188
  offset: 0,
@@ -294,23 +303,59 @@ Create HTTP handlers for client/server architecture using Web Standard Request/R
294
303
  ```ts
295
304
  import { createDurablyHandler } from '@coji/durably'
296
305
 
297
- const handler = createDurablyHandler(durably)
306
+ const handler = createDurablyHandler(durably, {
307
+ sseThrottleMs: 100, // default: throttle progress SSE events (0 to disable)
308
+ onRequest: async () => {
309
+ // Called before each request (after auth) — useful for lazy init
310
+ await durably.init()
311
+ },
312
+ })
298
313
 
299
- // Use the unified handle() method with automatic routing
314
+ // Use the handle() method with automatic routing
300
315
  app.all('/api/durably/*', async (req) => {
301
316
  return await handler.handle(req, '/api/durably')
302
317
  })
318
+ ```
319
+
320
+ **With auth middleware (multi-tenant):**
321
+
322
+ ```ts
323
+ const handler = createDurablyHandler(durably, {
324
+ auth: {
325
+ // Required: authenticate every request. Throw Response to reject.
326
+ authenticate: async (request) => {
327
+ const session = await requireUser(request)
328
+ const orgId = await resolveCurrentOrgId(request, session.user.id)
329
+ return { orgId }
330
+ },
331
+
332
+ // Guard before trigger (called after body validation + job resolution)
333
+ onTrigger: async (ctx, { jobName, input, labels }) => {
334
+ if (labels?.organizationId !== ctx.orgId) {
335
+ throw new Response('Forbidden', { status: 403 })
336
+ }
337
+ },
338
+
339
+ // Guard before run-level operations (read, subscribe, steps, retry, cancel, delete)
340
+ onRunAccess: async (ctx, run, { operation }) => {
341
+ if (run.labels.organizationId !== ctx.orgId) {
342
+ throw new Response('Forbidden', { status: 403 })
343
+ }
344
+ },
303
345
 
304
- // Or use individual endpoints
305
- app.post('/api/durably/trigger', (req) => handler.trigger(req))
306
- app.get('/api/durably/subscribe', (req) => handler.subscribe(req))
307
- app.get('/api/durably/runs', (req) => handler.runs(req))
308
- app.get('/api/durably/run', (req) => handler.run(req))
309
- app.get('/api/durably/steps', (req) => handler.steps(req))
310
- app.get('/api/durably/runs/subscribe', (req) => handler.runsSubscribe(req))
311
- app.post('/api/durably/retry', (req) => handler.retry(req))
312
- app.post('/api/durably/cancel', (req) => handler.cancel(req))
313
- app.delete('/api/durably/run', (req) => handler.delete(req))
346
+ // Scope runs list queries (GET /runs)
347
+ scopeRuns: async (ctx, filter) => ({
348
+ ...filter,
349
+ labels: { ...filter.labels, organizationId: ctx.orgId },
350
+ }),
351
+
352
+ // Scope runs subscribe stream (GET /runs/subscribe). Falls back to scopeRuns if not set.
353
+ scopeRunsSubscribe: async (ctx, filter) => ({
354
+ ...filter,
355
+ labels: { ...filter.labels, organizationId: ctx.orgId },
356
+ }),
357
+ },
358
+ })
314
359
  ```
315
360
 
316
361
  **Label filtering via query params:**
@@ -333,27 +378,58 @@ const clientRun = toClientRun(run) // strips internal fields
333
378
 
334
379
  ```ts
335
380
  interface DurablyHandler {
336
- // Unified routing handler
337
381
  handle(request: Request, basePath: string): Promise<Response>
382
+ }
338
383
 
339
- // Individual endpoints
340
- trigger(request: Request): Promise<Response> // POST /trigger
341
- subscribe(request: Request): Response // GET /subscribe?runId=xxx (SSE)
342
- runs(request: Request): Promise<Response> // GET /runs
343
- run(request: Request): Promise<Response> // GET /run?runId=xxx
344
- steps(request: Request): Promise<Response> // GET /steps?runId=xxx
345
- runsSubscribe(request: Request): Response // GET /runs/subscribe (SSE)
346
- retry(request: Request): Promise<Response> // POST /retry?runId=xxx
347
- cancel(request: Request): Promise<Response> // POST /cancel?runId=xxx
348
- delete(request: Request): Promise<Response> // DELETE /run?runId=xxx
384
+ interface CreateDurablyHandlerOptions<
385
+ TContext = undefined,
386
+ TLabels extends Record<string, string> = Record<string, string>,
387
+ > {
388
+ onRequest?: () => Promise<void> | void
389
+ sseThrottleMs?: number // default: 100
390
+ auth?: AuthConfig<TContext, TLabels>
349
391
  }
350
392
 
351
- interface TriggerRequest {
393
+ interface AuthConfig<
394
+ TContext,
395
+ TLabels extends Record<string, string> = Record<string, string>,
396
+ > {
397
+ authenticate: (request: Request) => Promise<TContext> | TContext
398
+ onTrigger?: (
399
+ ctx: TContext,
400
+ trigger: TriggerRequest<TLabels>,
401
+ ) => Promise<void> | void
402
+ onRunAccess?: (
403
+ ctx: TContext,
404
+ run: Run<TLabels>,
405
+ info: { operation: RunOperation },
406
+ ) => Promise<void> | void
407
+ scopeRuns?: (
408
+ ctx: TContext,
409
+ filter: RunFilter<TLabels>,
410
+ ) => RunFilter<TLabels> | Promise<RunFilter<TLabels>>
411
+ scopeRunsSubscribe?: (
412
+ ctx: TContext,
413
+ filter: RunsSubscribeFilter<TLabels>,
414
+ ) => RunsSubscribeFilter<TLabels> | Promise<RunsSubscribeFilter<TLabels>>
415
+ }
416
+
417
+ type RunOperation =
418
+ | 'read'
419
+ | 'subscribe'
420
+ | 'steps'
421
+ | 'retry'
422
+ | 'cancel'
423
+ | 'delete'
424
+
425
+ // RunsSubscribeFilter is Pick<RunFilter, 'jobName' | 'labels'>
426
+
427
+ interface TriggerRequest<TLabels> {
352
428
  jobName: string
353
- input: Record<string, unknown>
429
+ input: unknown
354
430
  idempotencyKey?: string
355
431
  concurrencyKey?: string
356
- labels?: Record<string, string>
432
+ labels?: TLabels
357
433
  }
358
434
 
359
435
  interface TriggerResponse {
@@ -385,17 +461,15 @@ const durably = createDurably({
385
461
  pollingInterval: 100,
386
462
  heartbeatInterval: 500,
387
463
  staleThreshold: 3000,
388
- })
389
-
390
- // Same API as Node.js
391
- const { myJob } = durably.register({
392
- myJob: defineJob({
393
- name: 'my-job',
394
- input: z.object({}),
395
- run: async (step) => {
396
- /* ... */
397
- },
398
- }),
464
+ jobs: {
465
+ myJob: defineJob({
466
+ name: 'my-job',
467
+ input: z.object({}),
468
+ run: async (step) => {
469
+ /* ... */
470
+ },
471
+ }),
472
+ },
399
473
  })
400
474
 
401
475
  // Initialize (same as Node.js)
@@ -448,44 +522,88 @@ interface StepContext {
448
522
  }
449
523
  }
450
524
 
451
- interface Run<TOutput = unknown> {
525
+ // TLabels defaults to Record<string, string> when no labels schema is provided
526
+ interface Run<TLabels extends Record<string, string> = Record<string, string>> {
452
527
  id: string
453
528
  jobName: string
454
529
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
455
530
  input: unknown
456
- labels: Record<string, string>
457
- output?: TOutput
458
- error?: string
459
- progress?: { current: number; total?: number; message?: string }
531
+ labels: TLabels
532
+ output: unknown | null
533
+ error: string | null
534
+ progress: { current: number; total?: number; message?: string } | null
460
535
  startedAt: string | null
461
536
  completedAt: string | null
462
537
  createdAt: string
463
538
  updatedAt: string
464
539
  }
465
540
 
466
- interface JobHandle<TName, TInput, TOutput> {
541
+ interface TypedRun<
542
+ TOutput,
543
+ TLabels extends Record<string, string> = Record<string, string>,
544
+ > extends Omit<Run<TLabels>, 'output'> {
545
+ output: TOutput | null
546
+ }
547
+
548
+ interface JobHandle<
549
+ TName extends string,
550
+ TInput,
551
+ TOutput,
552
+ TLabels extends Record<string, string> = Record<string, string>,
553
+ > {
467
554
  name: TName
468
- trigger(input: TInput, options?: TriggerOptions): Promise<Run<TOutput>>
555
+ trigger(
556
+ input: TInput,
557
+ options?: TriggerOptions<TLabels>,
558
+ ): Promise<TypedRun<TOutput, TLabels>>
469
559
  triggerAndWait(
470
560
  input: TInput,
471
- options?: TriggerOptions,
561
+ options?: TriggerAndWaitOptions<TLabels>,
472
562
  ): Promise<{ id: string; output: TOutput }>
473
- batchTrigger(inputs: BatchTriggerInput<TInput>[]): Promise<Run<TOutput>[]>
474
- getRun(id: string): Promise<Run<TOutput> | null>
475
- getRuns(filter?: RunFilter): Promise<Run<TOutput>[]>
563
+ batchTrigger(
564
+ inputs: BatchTriggerInput<TInput, TLabels>[],
565
+ ): Promise<TypedRun<TOutput, TLabels>[]>
566
+ getRun(id: string): Promise<TypedRun<TOutput, TLabels> | null>
567
+ getRuns(
568
+ filter?: Omit<RunFilter<TLabels>, 'jobName'>,
569
+ ): Promise<TypedRun<TOutput, TLabels>[]>
476
570
  }
477
571
 
478
- interface TriggerOptions {
572
+ interface TriggerOptions<
573
+ TLabels extends Record<string, string> = Record<string, string>,
574
+ > {
479
575
  idempotencyKey?: string
480
576
  concurrencyKey?: string
481
- labels?: Record<string, string>
577
+ labels?: TLabels
578
+ }
579
+
580
+ interface TriggerAndWaitOptions<
581
+ TLabels extends Record<string, string> = Record<string, string>,
582
+ > extends TriggerOptions<TLabels> {
482
583
  timeout?: number
584
+ onProgress?: (progress: ProgressData) => void | Promise<void>
585
+ onLog?: (log: LogData) => void | Promise<void>
586
+ }
587
+
588
+ interface ProgressData {
589
+ current: number
590
+ total?: number
591
+ message?: string
592
+ }
593
+
594
+ interface LogData {
595
+ level: 'info' | 'warn' | 'error'
596
+ message: string
597
+ data?: unknown
598
+ stepName?: string | null
483
599
  }
484
600
 
485
- interface RunFilter {
601
+ interface RunFilter<
602
+ TLabels extends Record<string, string> = Record<string, string>,
603
+ > {
486
604
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
487
- jobName?: string
488
- labels?: Record<string, string>
605
+ jobName?: string | string[]
606
+ labels?: Partial<TLabels>
489
607
  limit?: number
490
608
  offset?: number
491
609
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.9.0",
3
+ "version": "0.11.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",