@coji/durably 0.10.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
@@ -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,
@@ -296,23 +305,57 @@ import { createDurablyHandler } from '@coji/durably'
296
305
 
297
306
  const handler = createDurablyHandler(durably, {
298
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
+ },
299
312
  })
300
313
 
301
- // Use the unified handle() method with automatic routing
314
+ // Use the handle() method with automatic routing
302
315
  app.all('/api/durably/*', async (req) => {
303
316
  return await handler.handle(req, '/api/durably')
304
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
+ },
305
345
 
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))
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
+ })
316
359
  ```
317
360
 
318
361
  **Label filtering via query params:**
@@ -335,27 +378,58 @@ const clientRun = toClientRun(run) // strips internal fields
335
378
 
336
379
  ```ts
337
380
  interface DurablyHandler {
338
- // Unified routing handler
339
381
  handle(request: Request, basePath: string): Promise<Response>
382
+ }
340
383
 
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
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>
351
391
  }
352
392
 
353
- 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> {
354
428
  jobName: string
355
- input: Record<string, unknown>
429
+ input: unknown
356
430
  idempotencyKey?: string
357
431
  concurrencyKey?: string
358
- labels?: Record<string, string>
432
+ labels?: TLabels
359
433
  }
360
434
 
361
435
  interface TriggerResponse {
@@ -387,17 +461,15 @@ const durably = createDurably({
387
461
  pollingInterval: 100,
388
462
  heartbeatInterval: 500,
389
463
  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
- }),
464
+ jobs: {
465
+ myJob: defineJob({
466
+ name: 'my-job',
467
+ input: z.object({}),
468
+ run: async (step) => {
469
+ /* ... */
470
+ },
471
+ }),
472
+ },
401
473
  })
402
474
 
403
475
  // Initialize (same as Node.js)
@@ -450,44 +522,88 @@ interface StepContext {
450
522
  }
451
523
  }
452
524
 
453
- 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>> {
454
527
  id: string
455
528
  jobName: string
456
529
  status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
457
530
  input: unknown
458
- labels: Record<string, string>
459
- output?: TOutput
460
- error?: string
461
- 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
462
535
  startedAt: string | null
463
536
  completedAt: string | null
464
537
  createdAt: string
465
538
  updatedAt: string
466
539
  }
467
540
 
468
- 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
+ > {
469
554
  name: TName
470
- trigger(input: TInput, options?: TriggerOptions): Promise<Run<TOutput>>
555
+ trigger(
556
+ input: TInput,
557
+ options?: TriggerOptions<TLabels>,
558
+ ): Promise<TypedRun<TOutput, TLabels>>
471
559
  triggerAndWait(
472
560
  input: TInput,
473
- options?: TriggerOptions,
561
+ options?: TriggerAndWaitOptions<TLabels>,
474
562
  ): 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>[]>
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>[]>
478
570
  }
479
571
 
480
- interface TriggerOptions {
572
+ interface TriggerOptions<
573
+ TLabels extends Record<string, string> = Record<string, string>,
574
+ > {
481
575
  idempotencyKey?: string
482
576
  concurrencyKey?: string
483
- 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> {
484
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
485
599
  }
486
600
 
487
- interface RunFilter {
601
+ interface RunFilter<
602
+ TLabels extends Record<string, string> = Record<string, string>,
603
+ > {
488
604
  status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
489
- jobName?: string
490
- labels?: Record<string, string>
605
+ jobName?: string | string[]
606
+ labels?: Partial<TLabels>
491
607
  limit?: number
492
608
  offset?: number
493
609
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.10.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",