@coji/durably 0.13.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-DWsJlgyh.js';
1
+ export { a5 as withLogPersistence } from '../index-CXH4ozmK.js';
2
2
  import 'kysely';
3
3
  import 'zod';
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  withLogPersistence
3
- } from "../chunk-UCUP6NMJ.js";
3
+ } from "../chunk-L42OCQEV.js";
4
4
  export {
5
5
  withLogPersistence
6
6
  };
package/docs/llms.md CHANGED
@@ -1,18 +1,24 @@
1
1
  # Durably - LLM Documentation
2
2
 
3
- > Step-oriented resumable batch execution for Node.js and browsers using SQLite.
3
+ > Step-oriented resumable batch execution for Node.js and browsers using SQLite or PostgreSQL.
4
4
 
5
5
  ## Overview
6
6
 
7
- Durably is a minimal workflow engine that persists step results to SQLite. If a job is interrupted (server restart, browser tab close, crash), it automatically resumes from the last successful step.
7
+ Durably is a minimal workflow engine that persists step results to SQLite or PostgreSQL. If a job is interrupted (server restart, browser tab close, crash), it automatically resumes from the last successful step. Supports libSQL/Turso (single-server or serverless), PostgreSQL (recommended for multi-worker), and SQLocal (browser/OPFS).
8
8
 
9
9
  ## Installation
10
10
 
11
11
  ```bash
12
- # Node.js with libsql (recommended)
12
+ # Node.js with libSQL (recommended for single-server / Turso)
13
13
  pnpm add @coji/durably kysely zod @libsql/client @libsql/kysely-libsql
14
14
 
15
- # Browser with SQLocal
15
+ # Node.js with better-sqlite3 (lightweight local alternative)
16
+ pnpm add @coji/durably kysely zod better-sqlite3
17
+
18
+ # Node.js with PostgreSQL (recommended for multi-worker)
19
+ pnpm add @coji/durably kysely zod pg
20
+
21
+ # Browser with SQLocal (OPFS-backed)
16
22
  pnpm add @coji/durably kysely zod sqlocal
17
23
  ```
18
24
 
@@ -26,13 +32,36 @@ import { LibsqlDialect } from '@libsql/kysely-libsql'
26
32
  import { createClient } from '@libsql/client'
27
33
  import { z } from 'zod'
28
34
 
35
+ // --- libSQL local (single-server) ---
29
36
  const client = createClient({ url: 'file:local.db' })
30
37
  const dialect = new LibsqlDialect({ client })
31
38
 
39
+ // --- Turso remote (serverless/edge) ---
40
+ // const client = createClient({
41
+ // url: process.env.TURSO_DATABASE_URL!,
42
+ // authToken: process.env.TURSO_AUTH_TOKEN!,
43
+ // })
44
+ // const dialect = new LibsqlDialect({ client })
45
+
46
+ // --- better-sqlite3 (lightweight local) ---
47
+ // import Database from 'better-sqlite3'
48
+ // import { SqliteDialect } from 'kysely'
49
+ // const dialect = new SqliteDialect({
50
+ // database: new Database('local.db'),
51
+ // })
52
+
53
+ // --- PostgreSQL (multi-worker) ---
54
+ // import pg from 'pg'
55
+ // import { PostgresDialect } from 'kysely'
56
+ // const dialect = new PostgresDialect({
57
+ // pool: new pg.Pool({ connectionString: process.env.DATABASE_URL }),
58
+ // })
59
+
32
60
  // Option 1: With jobs (1-step initialization, returns typed instance)
33
61
  const durably = createDurably({
34
62
  dialect,
35
- 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)
36
65
  leaseRenewIntervalMs: 5000, // Lease renewal interval (ms)
37
66
  leaseMs: 30000, // Lease duration (ms); expired leases are reclaimed
38
67
  preserveSteps: false, // Set to true to keep step output data after terminal state (default: false = cleanup)
@@ -95,6 +124,7 @@ await durably.init()
95
124
  // Basic trigger (fire and forget)
96
125
  const run = await syncUsers.trigger({ orgId: 'org_123' })
97
126
  console.log(run.id, run.status) // "pending"
127
+ console.log(run.disposition) // "created"
98
128
 
99
129
  // Wait for completion
100
130
  const result = await syncUsers.triggerAndWait(
@@ -102,15 +132,27 @@ const result = await syncUsers.triggerAndWait(
102
132
  { timeout: 5000 },
103
133
  )
104
134
  console.log(result.output.syncedCount)
135
+ console.log(result.disposition) // "created"
105
136
 
106
137
  // With idempotency key (prevents duplicate jobs)
107
- await syncUsers.trigger(
138
+ const idempotentRun = await syncUsers.trigger(
108
139
  { orgId: 'org_123' },
109
140
  { idempotencyKey: 'webhook-event-456' },
110
141
  )
142
+ console.log(idempotentRun.disposition) // "created" or "idempotent"
111
143
 
112
- // With concurrency key (serializes execution)
144
+ // With concurrency key (serializes execution, max 1 pending per key)
113
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
+ }
114
156
 
115
157
  // With labels (for filtering)
116
158
  await syncUsers.trigger({ orgId: 'org_123' }, { labels: { source: 'browser' } })
@@ -156,6 +198,31 @@ step.log.error('Failed to connect', { error: err.message })
156
198
 
157
199
  ## Run Management
158
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
+
159
226
  ### Get Run Status
160
227
 
161
228
  ```ts
@@ -182,6 +249,9 @@ const typedRun = await durably.getRun<MyRun>(runId)
182
249
  // Get failed runs
183
250
  const failedRuns = await durably.getRuns({ status: 'failed' })
184
251
 
252
+ // Get active runs (multiple statuses)
253
+ const activeRuns = await durably.getRuns({ status: ['pending', 'leased'] })
254
+
185
255
  // Filter by job name with pagination
186
256
  const runs = await durably.getRuns({
187
257
  jobName: 'sync-users', // also accepts string[] for multiple jobs
@@ -247,11 +317,15 @@ For automatic cleanup, use the `retainRuns` option (see Core Concepts). Cleanup
247
317
 
248
318
  ## Events
249
319
 
250
- Subscribe to job execution events:
320
+ Subscribe to job execution events. **Listeners run synchronously** in the worker's hot path — keep them fast and non-blocking. Use fire-and-forget (`void asyncFn()`) for expensive work.
251
321
 
252
322
  ```ts
253
323
  // Run lifecycle events
324
+ // Note: run:trigger is NOT emitted on idempotent hits (disposition: 'idempotent')
254
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
+ )
255
329
  durably.on('run:leased', (e) => console.log('Leased:', e.runId))
256
330
  durably.on('run:complete', (e) => console.log('Done:', e.output))
257
331
  durably.on('run:fail', (e) => console.error('Failed:', e.error))
@@ -271,6 +345,25 @@ durably.on('step:cancel', (e) => console.log('Step cancelled:', e.stepName))
271
345
  durably.on('log:write', (e) => console.log(`[${e.level}]`, e.message))
272
346
  ```
273
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
+
274
367
  ## Advanced APIs
275
368
 
276
369
  ### getJob
@@ -385,13 +478,13 @@ GET /runs?label.organizationId=org_123
385
478
  GET /runs/subscribe?label.organizationId=org_123&label.env=prod
386
479
  ```
387
480
 
388
- **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:
389
482
 
390
483
  ```ts
391
484
  import { toClientRun } from '@coji/durably'
392
485
 
393
486
  const run = await durably.getRun(runId)
394
- const clientRun = toClientRun(run) // strips internal fields
487
+ const clientRun = toClientRun(run) // strips internal fields; adds isTerminal / isActive
395
488
  ```
396
489
 
397
490
  **Handler Interface:**
@@ -449,11 +542,13 @@ interface TriggerRequest<TLabels> {
449
542
  input: unknown
450
543
  idempotencyKey?: string
451
544
  concurrencyKey?: string
545
+ coalesce?: 'skip'
452
546
  labels?: TLabels
453
547
  }
454
548
 
455
549
  interface TriggerResponse {
456
550
  runId: string
551
+ disposition: Disposition
457
552
  }
458
553
  ```
459
554
 
@@ -467,6 +562,10 @@ import { withLogPersistence } from '@coji/durably'
467
562
  durably.use(withLogPersistence())
468
563
  ```
469
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
+
470
569
  ## Browser Usage
471
570
 
472
571
  ```ts
@@ -558,6 +657,8 @@ interface Run<TLabels extends Record<string, string> = Record<string, string>> {
558
657
  updatedAt: string
559
658
  }
560
659
 
660
+ type Disposition = 'created' | 'idempotent' | 'coalesced'
661
+
561
662
  interface TypedRun<
562
663
  TOutput,
563
664
  TLabels extends Record<string, string> = Record<string, string>,
@@ -565,6 +666,10 @@ interface TypedRun<
565
666
  output: TOutput | null
566
667
  }
567
668
 
669
+ type TriggerResult<TOutput, TLabels> = TypedRun<TOutput, TLabels> & {
670
+ disposition: Disposition
671
+ }
672
+
568
673
  interface JobHandle<
569
674
  TName extends string,
570
675
  TInput,
@@ -575,14 +680,14 @@ interface JobHandle<
575
680
  trigger(
576
681
  input: TInput,
577
682
  options?: TriggerOptions<TLabels>,
578
- ): Promise<TypedRun<TOutput, TLabels>>
683
+ ): Promise<TriggerResult<TOutput, TLabels>>
579
684
  triggerAndWait(
580
685
  input: TInput,
581
686
  options?: TriggerAndWaitOptions<TLabels>,
582
- ): Promise<{ id: string; output: TOutput }>
687
+ ): Promise<TriggerAndWaitResult<TOutput>>
583
688
  batchTrigger(
584
689
  inputs: BatchTriggerInput<TInput, TLabels>[],
585
- ): Promise<TypedRun<TOutput, TLabels>[]>
690
+ ): Promise<TriggerResult<TOutput, TLabels>[]>
586
691
  getRun(id: string): Promise<TypedRun<TOutput, TLabels> | null>
587
692
  getRuns(
588
693
  filter?: Omit<RunFilter<TLabels>, 'jobName'>,
@@ -594,17 +699,27 @@ interface TriggerOptions<
594
699
  > {
595
700
  idempotencyKey?: string
596
701
  concurrencyKey?: string
702
+ coalesce?: 'skip'
597
703
  labels?: TLabels
598
704
  }
599
705
 
600
- interface TriggerAndWaitOptions<
601
- TLabels extends Record<string, string> = Record<string, string>,
602
- > extends TriggerOptions<TLabels> {
706
+ interface TriggerAndWaitResult<TOutput> {
707
+ id: string
708
+ output: TOutput
709
+ disposition: Disposition
710
+ }
711
+
712
+ interface WaitForRunOptions {
603
713
  timeout?: number
604
714
  onProgress?: (progress: ProgressData) => void | Promise<void>
605
715
  onLog?: (log: LogData) => void | Promise<void>
606
716
  }
607
717
 
718
+ interface TriggerAndWaitOptions<
719
+ TLabels extends Record<string, string> = Record<string, string>,
720
+ >
721
+ extends TriggerOptions<TLabels>, WaitForRunOptions {}
722
+
608
723
  interface ProgressData {
609
724
  current: number
610
725
  total?: number
@@ -621,7 +736,7 @@ interface LogData {
621
736
  interface RunFilter<
622
737
  TLabels extends Record<string, string> = Record<string, string>,
623
738
  > {
624
- status?: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
739
+ status?: RunStatus | RunStatus[]
625
740
  jobName?: string | string[]
626
741
  labels?: Partial<TLabels>
627
742
  limit?: number
@@ -629,6 +744,23 @@ interface RunFilter<
629
744
  }
630
745
  ```
631
746
 
747
+ ## Error Classes
748
+
749
+ Durably exports typed error classes for programmatic error handling:
750
+
751
+ ```ts
752
+ import {
753
+ DurablyError, // Base class with statusCode (extends Error)
754
+ NotFoundError, // 404 — resource not found
755
+ ValidationError, // 400 — invalid input or request
756
+ ConflictError, // 409 — operation conflicts with current state
757
+ CancelledError, // Run was cancelled during execution
758
+ LeaseLostError, // Worker lost lease ownership
759
+ } from '@coji/durably'
760
+ ```
761
+
762
+ `DurablyError` subclasses (`NotFoundError`, `ValidationError`, `ConflictError`) carry a `statusCode` property and are used by the HTTP handler to return appropriate responses.
763
+
632
764
  ## License
633
765
 
634
766
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.13.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",
@@ -1 +0,0 @@
1
- {"version":3,"sources":["../src/plugins/log-persistence.ts"],"sourcesContent":["import type { DurablyPlugin } from '../durably'\n\n/**\n * Plugin that persists log events to the database\n */\nexport function withLogPersistence(): DurablyPlugin {\n return {\n name: 'log-persistence',\n install(durably) {\n durably.on('log:write', async (event) => {\n await durably.storage.createLog({\n runId: event.runId,\n stepName: event.stepName,\n level: event.level,\n message: event.message,\n data: event.data,\n })\n })\n },\n }\n}\n"],"mappings":";AAKO,SAAS,qBAAoC;AAClD,SAAO;AAAA,IACL,MAAM;AAAA,IACN,QAAQ,SAAS;AACf,cAAQ,GAAG,aAAa,OAAO,UAAU;AACvC,cAAM,QAAQ,QAAQ,UAAU;AAAA,UAC9B,OAAO,MAAM;AAAA,UACb,UAAU,MAAM;AAAA,UAChB,OAAO,MAAM;AAAA,UACb,SAAS,MAAM;AAAA,UACf,MAAM,MAAM;AAAA,QACd,CAAC;AAAA,MACH,CAAC;AAAA,IACH;AAAA,EACF;AACF;","names":[]}