@coji/durably 0.14.0 → 0.15.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 { V as withLogPersistence } from '../index-CDCdrLgw.js';
1
+ export { a5 as withLogPersistence } from '../index-CXH4ozmK.js';
2
2
  import 'kysely';
3
3
  import 'zod';
package/docs/llms.md CHANGED
@@ -60,7 +60,8 @@ const dialect = new LibsqlDialect({ client })
60
60
  // Option 1: With jobs (1-step initialization, returns typed instance)
61
61
  const durably = createDurably({
62
62
  dialect,
63
- pollingIntervalMs: 1000, // Job polling interval (ms)
63
+ pollingIntervalMs: 1000, // Delay before polling again when idle (ms)
64
+ maxConcurrentRuns: 1, // Concurrent runs processed by the worker (default: 1)
64
65
  leaseRenewIntervalMs: 5000, // Lease renewal interval (ms)
65
66
  leaseMs: 30000, // Lease duration (ms); expired leases are reclaimed
66
67
  preserveSteps: false, // Set to true to keep step output data after terminal state (default: false = cleanup)
@@ -123,6 +124,7 @@ await durably.init()
123
124
  // Basic trigger (fire and forget)
124
125
  const run = await syncUsers.trigger({ orgId: 'org_123' })
125
126
  console.log(run.id, run.status) // "pending"
127
+ console.log(run.disposition) // "created"
126
128
 
127
129
  // Wait for completion
128
130
  const result = await syncUsers.triggerAndWait(
@@ -130,15 +132,27 @@ const result = await syncUsers.triggerAndWait(
130
132
  { timeout: 5000 },
131
133
  )
132
134
  console.log(result.output.syncedCount)
135
+ console.log(result.disposition) // "created"
133
136
 
134
137
  // With idempotency key (prevents duplicate jobs)
135
- await syncUsers.trigger(
138
+ const idempotentRun = await syncUsers.trigger(
136
139
  { orgId: 'org_123' },
137
140
  { idempotencyKey: 'webhook-event-456' },
138
141
  )
142
+ console.log(idempotentRun.disposition) // "created" or "idempotent"
139
143
 
140
- // With concurrency key (serializes execution)
144
+ // With concurrency key (serializes execution, max 1 pending per key)
141
145
  await syncUsers.trigger({ orgId: 'org_123' }, { concurrencyKey: 'org_123' })
146
+ // Second trigger with same key throws ConflictError if a pending run exists
147
+
148
+ // With coalesce (skip duplicate pending runs gracefully)
149
+ const coalesced = await syncUsers.trigger(
150
+ { orgId: 'org_123' },
151
+ { concurrencyKey: 'org_123', coalesce: 'skip' },
152
+ )
153
+ if (coalesced.disposition === 'coalesced') {
154
+ console.log('Reused existing pending run:', coalesced.id)
155
+ }
142
156
 
143
157
  // With labels (for filtering)
144
158
  await syncUsers.trigger({ orgId: 'org_123' }, { labels: { source: 'browser' } })
@@ -184,6 +198,31 @@ step.log.error('Failed to connect', { error: err.message })
184
198
 
185
199
  ## Run Management
186
200
 
201
+ ### Wait for Existing Run
202
+
203
+ ```ts
204
+ // Wait for an existing run to complete (no new run created)
205
+ // Useful when Job A triggers Job B and returns B's run ID
206
+ const completedRun = await durably.waitForRun(runId)
207
+ console.log(completedRun.output) // available when completed
208
+
209
+ // With timeout and callbacks
210
+ const run = await durably.waitForRun(runId, {
211
+ timeout: 10000,
212
+ onProgress: (p) => console.log(`${p.current}/${p.total}`),
213
+ onLog: (l) => console.log(l.message),
214
+ })
215
+
216
+ // Same-process listeners settle waits immediately; if another runtime completes the run
217
+ // against the same storage, the wait falls back to storage polling. Optional
218
+ // `pollingIntervalMs` overrides the instance `createDurably({ pollingIntervalMs })` for this call only.
219
+ await durably.waitForRun(runId, { pollingIntervalMs: 2000 })
220
+
221
+ // Throws NotFoundError if run doesn't exist
222
+ // Throws CancelledError if run is cancelled
223
+ // Throws Error if run fails
224
+ ```
225
+
187
226
  ### Get Run Status
188
227
 
189
228
  ```ts
@@ -210,6 +249,9 @@ const typedRun = await durably.getRun<MyRun>(runId)
210
249
  // Get failed runs
211
250
  const failedRuns = await durably.getRuns({ status: 'failed' })
212
251
 
252
+ // Get active runs (multiple statuses)
253
+ const activeRuns = await durably.getRuns({ status: ['pending', 'leased'] })
254
+
213
255
  // Filter by job name with pagination
214
256
  const runs = await durably.getRuns({
215
257
  jobName: 'sync-users', // also accepts string[] for multiple jobs
@@ -279,7 +321,11 @@ Subscribe to job execution events. **Listeners run synchronously** in the worker
279
321
 
280
322
  ```ts
281
323
  // Run lifecycle events
324
+ // Note: run:trigger is NOT emitted on idempotent hits (disposition: 'idempotent')
282
325
  durably.on('run:trigger', (e) => console.log('Triggered:', e.runId))
326
+ durably.on('run:coalesced', (e) =>
327
+ console.log('Coalesced:', e.runId, 'skipped input:', e.skippedInput),
328
+ )
283
329
  durably.on('run:leased', (e) => console.log('Leased:', e.runId))
284
330
  durably.on('run:complete', (e) => console.log('Done:', e.output))
285
331
  durably.on('run:fail', (e) => console.error('Failed:', e.error))
@@ -299,6 +345,25 @@ durably.on('step:cancel', (e) => console.log('Step cancelled:', e.stepName))
299
345
  durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message))
300
346
  ```
301
347
 
348
+ ### Core event categories
349
+
350
+ The `DurablyEvent` union is grouped for callers who want lifecycle facts vs operational detail:
351
+
352
+ - **Domain** (`DomainEvent` / `DomainEventType`): `run:trigger`, `run:coalesced`, `run:complete`, `run:fail`, `run:cancel`, `run:delete`
353
+ - **Operational** (`OperationalEvent` / `OperationalEventType`): `run:leased`, `run:lease-renewed`, `run:progress`, `step:*`, `log:write`, `worker:error`
354
+
355
+ Use `isDomainEvent(event)` (checks `event.type` only) as a type guard.
356
+
357
+ ```ts
358
+ import { isDomainEvent, type DurablyEvent } from '@coji/durably'
359
+
360
+ function handleEvent(event: DurablyEvent) {
361
+ if (isDomainEvent(event)) {
362
+ console.log('State change:', event.type, event.runId)
363
+ }
364
+ }
365
+ ```
366
+
302
367
  ## Advanced APIs
303
368
 
304
369
  ### getJob
@@ -413,13 +478,13 @@ GET /runs?label.organizationId=org_123
413
478
  GET /runs/subscribe?label.organizationId=org_123&label.env=prod
414
479
  ```
415
480
 
416
- **Response Shape:** The `/runs` and `/run` endpoints return `ClientRun` objects (internal fields like `leaseOwner`, `leaseExpiresAt`, `idempotencyKey`, `concurrencyKey`, `updatedAt` are stripped). Use `toClientRun()` to apply the same projection in custom code:
481
+ **Response Shape:** The `/runs` and `/run` endpoints return `ClientRun` objects (internal fields like `leaseOwner`, `leaseExpiresAt`, `idempotencyKey`, `concurrencyKey`, `leaseGeneration`, `updatedAt` are stripped). Each response includes derived `isTerminal` and `isActive` booleans from `status` (terminal: completed, failed, or cancelled; active: pending or leased). Use `toClientRun()` to apply the same projection in custom code:
417
482
 
418
483
  ```ts
419
484
  import { toClientRun } from '@coji/durably'
420
485
 
421
486
  const run = await durably.getRun(runId)
422
- const clientRun = toClientRun(run) // strips internal fields
487
+ const clientRun = toClientRun(run) // strips internal fields; adds isTerminal / isActive
423
488
  ```
424
489
 
425
490
  **Handler Interface:**
@@ -477,11 +542,13 @@ interface TriggerRequest<TLabels> {
477
542
  input: unknown
478
543
  idempotencyKey?: string
479
544
  concurrencyKey?: string
545
+ coalesce?: 'skip'
480
546
  labels?: TLabels
481
547
  }
482
548
 
483
549
  interface TriggerResponse {
484
550
  runId: string
551
+ disposition: Disposition
485
552
  }
486
553
  ```
487
554
 
@@ -495,6 +562,10 @@ import { withLogPersistence } from '@coji/durably'
495
562
  durably.use(withLogPersistence())
496
563
  ```
497
564
 
565
+ ## SQLite WAL Maintenance
566
+
567
+ For local SQLite backends using WAL mode, Durably automatically runs periodic WAL checkpoints (`PRAGMA wal_checkpoint(TRUNCATE)`) during idle maintenance to prevent unbounded WAL file growth. This is probed at `migrate()` time and only enabled when the backend supports it — automatically skipped for Turso (remote libSQL), PostgreSQL, and browser (OPFS) backends.
568
+
498
569
  ## Browser Usage
499
570
 
500
571
  ```ts
@@ -586,6 +657,8 @@ interface Run<TLabels extends Record<string, string> = Record<string, string>> {
586
657
  updatedAt: string
587
658
  }
588
659
 
660
+ type Disposition = 'created' | 'idempotent' | 'coalesced'
661
+
589
662
  interface TypedRun<
590
663
  TOutput,
591
664
  TLabels extends Record<string, string> = Record<string, string>,
@@ -593,6 +666,10 @@ interface TypedRun<
593
666
  output: TOutput | null
594
667
  }
595
668
 
669
+ type TriggerResult<TOutput, TLabels> = TypedRun<TOutput, TLabels> & {
670
+ disposition: Disposition
671
+ }
672
+
596
673
  interface JobHandle<
597
674
  TName extends string,
598
675
  TInput,
@@ -603,14 +680,14 @@ interface JobHandle<
603
680
  trigger(
604
681
  input: TInput,
605
682
  options?: TriggerOptions<TLabels>,
606
- ): Promise<TypedRun<TOutput, TLabels>>
683
+ ): Promise<TriggerResult<TOutput, TLabels>>
607
684
  triggerAndWait(
608
685
  input: TInput,
609
686
  options?: TriggerAndWaitOptions<TLabels>,
610
- ): Promise<{ id: string; output: TOutput }>
687
+ ): Promise<TriggerAndWaitResult<TOutput>>
611
688
  batchTrigger(
612
689
  inputs: BatchTriggerInput<TInput, TLabels>[],
613
- ): Promise<TypedRun<TOutput, TLabels>[]>
690
+ ): Promise<TriggerResult<TOutput, TLabels>[]>
614
691
  getRun(id: string): Promise<TypedRun<TOutput, TLabels> | null>
615
692
  getRuns(
616
693
  filter?: Omit<RunFilter<TLabels>, 'jobName'>,
@@ -622,17 +699,27 @@ interface TriggerOptions<
622
699
  > {
623
700
  idempotencyKey?: string
624
701
  concurrencyKey?: string
702
+ coalesce?: 'skip'
625
703
  labels?: TLabels
626
704
  }
627
705
 
628
- interface TriggerAndWaitOptions<
629
- TLabels extends Record<string, string> = Record<string, string>,
630
- > extends TriggerOptions<TLabels> {
706
+ interface TriggerAndWaitResult<TOutput> {
707
+ id: string
708
+ output: TOutput
709
+ disposition: Disposition
710
+ }
711
+
712
+ interface WaitForRunOptions {
631
713
  timeout?: number
632
714
  onProgress?: (progress: ProgressData) => void | Promise<void>
633
715
  onLog?: (log: LogData) => void | Promise<void>
634
716
  }
635
717
 
718
+ interface TriggerAndWaitOptions<
719
+ TLabels extends Record<string, string> = Record<string, string>,
720
+ >
721
+ extends TriggerOptions<TLabels>, WaitForRunOptions {}
722
+
636
723
  interface ProgressData {
637
724
  current: number
638
725
  total?: number
@@ -649,7 +736,7 @@ interface LogData {
649
736
  interface RunFilter<
650
737
  TLabels extends Record<string, string> = Record<string, string>,
651
738
  > {
652
- status?: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
739
+ status?: RunStatus | RunStatus[]
653
740
  jobName?: string | string[]
654
741
  labels?: Partial<TLabels>
655
742
  limit?: number
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -23,13 +23,11 @@
23
23
  "scripts": {
24
24
  "build": "tsup",
25
25
  "test": "pnpm test:node && pnpm test:react && pnpm test:browser",
26
- "test:node": "vitest run --config vitest.config.ts --exclude 'tests/node/**/*.postgres.test.ts'",
27
- "test:node:postgres": "vitest run --config vitest.config.ts postgres",
28
- "test:node:all": "vitest run --config vitest.config.ts",
26
+ "test:node": "vitest run --config vitest.config.ts",
29
27
  "test:react": "vitest run --config vitest.react.config.ts",
30
28
  "test:browser": "vitest run --config vitest.browser.config.ts",
31
29
  "typecheck": "tsc --noEmit",
32
- "lint": "biome lint .",
30
+ "lint": "biome lint --error-on-warnings .",
33
31
  "lint:fix": "biome lint --write .",
34
32
  "format": "prettier --experimental-cli --check .",
35
33
  "format:fix": "prettier --experimental-cli --write ."
@@ -66,6 +64,7 @@
66
64
  "@biomejs/biome": "^2.4.7",
67
65
  "@libsql/client": "^0.17.0",
68
66
  "@libsql/kysely-libsql": "^0.4.1",
67
+ "@testcontainers/postgresql": "11.13.0",
69
68
  "@testing-library/react": "^16.3.2",
70
69
  "@types/better-sqlite3": "^7.6.13",
71
70
  "@types/pg": "^8.15.6",
@@ -83,6 +82,7 @@
83
82
  "react": "^19.2.4",
84
83
  "react-dom": "^19.2.4",
85
84
  "sqlocal": "^0.17.0",
85
+ "testcontainers": "11.13.0",
86
86
  "tsup": "^8.5.1",
87
87
  "typescript": "^5.9.3",
88
88
  "vitest": "^4.1.0",