@coji/durably 0.12.0 → 0.14.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 { M as withLogPersistence } from '../index-hM7-oiyj.js';
1
+ export { V as withLogPersistence } from '../index-CDCdrLgw.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,16 +32,39 @@ 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
- pollingInterval: 1000, // Job polling interval (ms)
36
- heartbeatInterval: 5000, // Heartbeat update interval (ms)
37
- staleThreshold: 30000, // When to consider a job abandoned (ms)
38
- cleanupSteps: true, // Delete step output data on terminal state (default: true)
63
+ pollingIntervalMs: 1000, // Job polling interval (ms)
64
+ leaseRenewIntervalMs: 5000, // Lease renewal interval (ms)
65
+ leaseMs: 30000, // Lease duration (ms); expired leases are reclaimed
66
+ preserveSteps: false, // Set to true to keep step output data after terminal state (default: false = cleanup)
67
+ retainRuns: '30d', // Auto-delete terminal runs older than 30 days (runs during worker polling; supports 'd', 'h', 'm' units)
39
68
  // Optional: type-safe labels with Zod schema
40
69
  // labels: z.object({ organizationId: z.string(), env: z.string() }),
41
70
  jobs: {
@@ -210,7 +239,9 @@ const typedRuns = await durably.getRuns<MyRun>({ jobName: 'my-job' })
210
239
  ### Retrigger Failed Runs
211
240
 
212
241
  ```ts
213
- // Creates a fresh run (new ID) with the same input/options
242
+ // Creates a fresh run (new ID) with the same input and labels
243
+ // Input is validated against the current job schema — throws if incompatible
244
+ // Note: idempotencyKey is not carried forward
214
245
  const newRun = await durably.retrigger(runId)
215
246
  console.log(newRun.id) // new run ID
216
247
  ```
@@ -227,14 +258,29 @@ await durably.cancel(runId)
227
258
  await durably.deleteRun(runId)
228
259
  ```
229
260
 
261
+ ### Purge Old Runs
262
+
263
+ Batch-delete terminal runs (completed, failed, cancelled) older than a cutoff date.
264
+ Pending and leased runs are never deleted.
265
+
266
+ ```ts
267
+ // Delete terminal runs older than 30 days
268
+ const deleted = await durably.purgeRuns({
269
+ olderThan: new Date(Date.now() - 30 * 24 * 60 * 60 * 1000),
270
+ limit: 500, // optional batch size (default: 500)
271
+ })
272
+ ```
273
+
274
+ For automatic cleanup, use the `retainRuns` option (see Core Concepts). Cleanup runs during idle worker polling cycles, at most once per minute, in batches of 100.
275
+
230
276
  ## Events
231
277
 
232
- Subscribe to job execution events:
278
+ 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.
233
279
 
234
280
  ```ts
235
281
  // Run lifecycle events
236
282
  durably.on('run:trigger', (e) => console.log('Triggered:', e.runId))
237
- durably.on('run:start', (e) => console.log('Started:', e.runId))
283
+ durably.on('run:leased', (e) => console.log('Leased:', e.runId))
238
284
  durably.on('run:complete', (e) => console.log('Done:', e.output))
239
285
  durably.on('run:fail', (e) => console.error('Failed:', e.error))
240
286
  durably.on('run:cancel', (e) => console.log('Cancelled:', e.runId))
@@ -279,8 +325,8 @@ while (true) {
279
325
  if (done) break
280
326
 
281
327
  switch (value.type) {
282
- case 'run:start':
283
- console.log('Started')
328
+ case 'run:leased':
329
+ console.log('Leased')
284
330
  break
285
331
  case 'run:complete':
286
332
  console.log('Completed:', value.output)
@@ -367,7 +413,7 @@ GET /runs?label.organizationId=org_123
367
413
  GET /runs/subscribe?label.organizationId=org_123&label.env=prod
368
414
  ```
369
415
 
370
- **Response Shape:** The `/runs` and `/run` endpoints return `ClientRun` objects (internal fields like `heartbeatAt`, `idempotencyKey`, `concurrencyKey`, `updatedAt` are stripped). Use `toClientRun()` to apply the same projection in custom code:
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:
371
417
 
372
418
  ```ts
373
419
  import { toClientRun } from '@coji/durably'
@@ -460,9 +506,9 @@ const { dialect } = new SQLocalKysely('app.sqlite3')
460
506
 
461
507
  const durably = createDurably({
462
508
  dialect,
463
- pollingInterval: 100,
464
- heartbeatInterval: 500,
465
- staleThreshold: 3000,
509
+ pollingIntervalMs: 100,
510
+ leaseRenewIntervalMs: 500,
511
+ leaseMs: 3000,
466
512
  jobs: {
467
513
  myJob: defineJob({
468
514
  name: 'my-job',
@@ -481,13 +527,13 @@ await durably.init()
481
527
  ## Run Lifecycle
482
528
 
483
529
  ```text
484
- trigger() → pending → running → completed
485
-
530
+ trigger() → pending → leased → completed
531
+
486
532
  → failed
487
533
  ```
488
534
 
489
535
  - **pending**: Waiting for worker to pick up
490
- - **running**: Worker is executing steps
536
+ - **leased**: Worker has acquired a lease and is executing steps
491
537
  - **completed**: All steps finished successfully
492
538
  - **failed**: A step threw an error
493
539
  - **cancelled**: Manually cancelled via `cancel()`
@@ -528,7 +574,7 @@ interface StepContext {
528
574
  interface Run<TLabels extends Record<string, string> = Record<string, string>> {
529
575
  id: string
530
576
  jobName: string
531
- status: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
577
+ status: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
532
578
  input: unknown
533
579
  labels: TLabels
534
580
  output: unknown | null
@@ -603,7 +649,7 @@ interface LogData {
603
649
  interface RunFilter<
604
650
  TLabels extends Record<string, string> = Record<string, string>,
605
651
  > {
606
- status?: 'pending' | 'running' | 'completed' | 'failed' | 'cancelled'
652
+ status?: 'pending' | 'leased' | 'completed' | 'failed' | 'cancelled'
607
653
  jobName?: string | string[]
608
654
  labels?: Partial<TLabels>
609
655
  limit?: number
@@ -611,6 +657,23 @@ interface RunFilter<
611
657
  }
612
658
  ```
613
659
 
660
+ ## Error Classes
661
+
662
+ Durably exports typed error classes for programmatic error handling:
663
+
664
+ ```ts
665
+ import {
666
+ DurablyError, // Base class with statusCode (extends Error)
667
+ NotFoundError, // 404 — resource not found
668
+ ValidationError, // 400 — invalid input or request
669
+ ConflictError, // 409 — operation conflicts with current state
670
+ CancelledError, // Run was cancelled during execution
671
+ LeaseLostError, // Worker lost lease ownership
672
+ } from '@coji/durably'
673
+ ```
674
+
675
+ `DurablyError` subclasses (`NotFoundError`, `ValidationError`, `ConflictError`) carry a `statusCode` property and are used by the HTTP handler to return appropriate responses.
676
+
614
677
  ## License
615
678
 
616
679
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@coji/durably",
3
- "version": "0.12.0",
3
+ "version": "0.14.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",
@@ -20,6 +20,20 @@
20
20
  "docs",
21
21
  "README.md"
22
22
  ],
23
+ "scripts": {
24
+ "build": "tsup",
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",
29
+ "test:react": "vitest run --config vitest.react.config.ts",
30
+ "test:browser": "vitest run --config vitest.browser.config.ts",
31
+ "typecheck": "tsc --noEmit",
32
+ "lint": "biome lint .",
33
+ "lint:fix": "biome lint --write .",
34
+ "format": "prettier --experimental-cli --check .",
35
+ "format:fix": "prettier --experimental-cli --write ."
36
+ },
23
37
  "keywords": [
24
38
  "batch",
25
39
  "job",
@@ -45,20 +59,24 @@
45
59
  "zod": "^4.0.0"
46
60
  },
47
61
  "dependencies": {
62
+ "better-sqlite3": "12.6.2",
48
63
  "ulidx": "^2.4.1"
49
64
  },
50
65
  "devDependencies": {
51
- "@biomejs/biome": "^2.4.6",
66
+ "@biomejs/biome": "^2.4.7",
52
67
  "@libsql/client": "^0.17.0",
53
68
  "@libsql/kysely-libsql": "^0.4.1",
54
69
  "@testing-library/react": "^16.3.2",
70
+ "@types/better-sqlite3": "^7.6.13",
71
+ "@types/pg": "^8.15.6",
55
72
  "@types/react": "^19.2.14",
56
73
  "@types/react-dom": "^19.2.3",
57
- "@vitejs/plugin-react": "^5.1.4",
58
- "@vitest/browser": "^4.0.18",
59
- "@vitest/browser-playwright": "4.0.18",
60
- "jsdom": "^28.1.0",
61
- "kysely": "^0.28.11",
74
+ "@vitejs/plugin-react": "^6.0.1",
75
+ "@vitest/browser": "^4.1.0",
76
+ "@vitest/browser-playwright": "4.1.0",
77
+ "jsdom": "^29.0.0",
78
+ "kysely": "^0.28.12",
79
+ "pg": "^8.16.3",
62
80
  "playwright": "^1.58.2",
63
81
  "prettier": "^3.8.1",
64
82
  "prettier-plugin-organize-imports": "^4.3.0",
@@ -67,19 +85,7 @@
67
85
  "sqlocal": "^0.17.0",
68
86
  "tsup": "^8.5.1",
69
87
  "typescript": "^5.9.3",
70
- "vitest": "^4.0.18",
88
+ "vitest": "^4.1.0",
71
89
  "zod": "^4.3.6"
72
- },
73
- "scripts": {
74
- "build": "tsup",
75
- "test": "pnpm test:node && pnpm test:react && pnpm test:browser",
76
- "test:node": "vitest run --config vitest.config.ts",
77
- "test:react": "vitest run --config vitest.react.config.ts",
78
- "test:browser": "vitest run --config vitest.browser.config.ts",
79
- "typecheck": "tsc --noEmit",
80
- "lint": "biome lint .",
81
- "lint:fix": "biome lint --write .",
82
- "format": "prettier --experimental-cli --check .",
83
- "format:fix": "prettier --experimental-cli --write ."
84
90
  }
85
- }
91
+ }
package/LICENSE DELETED
@@ -1,21 +0,0 @@
1
- MIT License
2
-
3
- Copyright (c) 2025 coji
4
-
5
- Permission is hereby granted, free of charge, to any person obtaining a copy
6
- of this software and associated documentation files (the "Software"), to deal
7
- in the Software without restriction, including without limitation the rights
8
- to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
- copies of the Software, and to permit persons to whom the Software is
10
- furnished to do so, subject to the following conditions:
11
-
12
- The above copyright notice and this permission notice shall be included in all
13
- copies or substantial portions of the Software.
14
-
15
- THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
- IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
- FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
- AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
- LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
- OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
- SOFTWARE.
@@ -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":[]}