diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8c577ea..f27a0b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,40 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
## [Unreleased]
+## [0.15.0] - 2026-03-29
+
+### Breaking Changes
+
+#### @coji/durably
+
+- **`trigger()` return type changed to `TriggerResult`**: Returns `{ run, disposition }` where `disposition` is `'created' | 'idempotent' | 'coalesced'`. Add `coalesce: 'skip'` option to reuse an existing pending run instead of throwing. `concurrencyKey` now enforces max 1 pending run per key (#148)
+
+#### @coji/durably-react
+
+- **`useRunActions()` no longer returns `isLoading` / `error`**: Use React 19 `useTransition` for per-button pending UI and `try/catch` for error handling. Peer dependency raised to React 19+ (#179)
+
+### Added
+
+#### @coji/durably
+
+- **`waitForRun(runId, options?)`**: Wait for an existing run to reach a terminal state, with `timeout`, `onProgress`, and `onLog` callbacks. Uses event-first resolution with storage polling fallback for cross-process observation (#151, #169)
+- **`maxConcurrentRuns` option**: Enable parallel run processing in the worker. Defaults to `1` (sequential) (#173)
+- **Event classification**: Events classified as Domain or Operational with `isDomainEvent()` type guard (#169)
+- **Automatic WAL checkpoint**: Periodic `PRAGMA wal_checkpoint(TRUNCATE)` during idle maintenance for local SQLite backends, preventing unbounded WAL file growth. Probed at `migrate()` time; skipped for Turso, PostgreSQL, and browser environments (#181)
+
+#### @coji/durably-react
+
+- **`isTerminal` / `isActive` on run objects and hooks**: Derived status booleans replace manual status enumeration (#179)
+- **`useRuns` status array filter**: `status` option accepts `RunStatus | RunStatus[]` to filter by multiple statuses (#154)
+- **`createJobHooks().useRun()` lifecycle callbacks**: `onStart`, `onComplete`, and `onFail` options for per-run lifecycle handling (#155)
+- **`createJobHooks()` options forwarding**: All hook options forwarded transparently via `Omit` (#179)
+
+### Fixed
+
+#### @coji/durably
+
+- **`TypedRun` / `TypedClientRun` output type**: Default `output` type no longer includes `undefined`, removing unnecessary undefined checks (#145)
+
## [0.14.0] - 2026-03-16
### Added
diff --git a/CLAUDE.md b/CLAUDE.md
index bc83060..8112a84 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -37,8 +37,9 @@ Regenerate `llms.txt` after editing any `llms.md`. Regenerate the OG image whene
- **Job**: Defined via `defineJob()` and registered via `jobs` option (or `.register()`), receives a step context and payload
- **Step**: Created via `step.run()`, each step's success state and return value is persisted (cleaned up on terminal state by default, see `preserveSteps`)
-- **Run**: A job execution instance, created via `trigger()`, always persisted as `pending` before execution
-- **Worker**: Polls for pending runs and executes them sequentially
+- **Run**: A job execution instance, created via `trigger()` (returns `TriggerResult` with `disposition`: `'created' | 'idempotent' | 'coalesced'`), always persisted as `pending` before execution. Use `coalesce: 'skip'` to reuse an existing pending run with the same `concurrencyKey`
+- **Worker**: Polls for pending runs and executes them (sequentially by default, or concurrently via `maxConcurrentRuns`)
+- **waitForRun**: `durably.waitForRun(runId, options?)` waits for a run to reach terminal state, with `timeout`, `onProgress`, `onLog` callbacks. Uses events with storage polling fallback
## Key Design Decisions
@@ -61,6 +62,7 @@ Five tables: `durably_runs`, `durably_run_labels`, `durably_steps`, `durably_log
- `leaseRenewIntervalMs`: 5000ms
- `leaseMs`: 30000ms (lease duration; expired leases are reclaimed)
- `preserveSteps`: false (deletes step output data when runs reach terminal state)
+- `maxConcurrentRuns`: 1 (concurrent runs per worker; increase for I/O-bound jobs)
- `retainRuns`: undefined (no automatic cleanup; set e.g. `'30d'` to auto-delete terminal runs)
## Browser Constraints (by design)
diff --git a/packages/durably-react/docs/llms.md b/packages/durably-react/docs/llms.md
index 4bf02b9..a81cdac 100644
--- a/packages/durably-react/docs/llms.md
+++ b/packages/durably-react/docs/llms.md
@@ -166,6 +166,9 @@ function Component({ runId }: { runId: string }) {
}>({
api: '/api/durably',
runId,
+ onStart: () => console.log('Run started'),
+ onComplete: () => console.log('Run completed'),
+ onFail: () => console.log('Run failed'),
})
return
Status: {status}
diff --git a/packages/durably-react/package.json b/packages/durably-react/package.json
index 46b5260..bda4c62 100644
--- a/packages/durably-react/package.json
+++ b/packages/durably-react/package.json
@@ -1,6 +1,6 @@
{
"name": "@coji/durably-react",
- "version": "0.14.0",
+ "version": "0.15.0",
"description": "React bindings for Durably - step-oriented resumable batch execution",
"type": "module",
"main": "./dist/index.js",
diff --git a/packages/durably/docs/llms.md b/packages/durably/docs/llms.md
index eea118f..762b2d0 100644
--- a/packages/durably/docs/llms.md
+++ b/packages/durably/docs/llms.md
@@ -562,6 +562,10 @@ import { withLogPersistence } from '@coji/durably'
durably.use(withLogPersistence())
```
+## SQLite WAL Maintenance
+
+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.
+
## Browser Usage
```ts
diff --git a/packages/durably/package.json b/packages/durably/package.json
index 7ba88eb..76e4597 100644
--- a/packages/durably/package.json
+++ b/packages/durably/package.json
@@ -1,6 +1,6 @@
{
"name": "@coji/durably",
- "version": "0.14.0",
+ "version": "0.15.0",
"description": "Step-oriented resumable batch execution for Node.js and browsers using SQLite",
"type": "module",
"main": "./dist/index.js",
diff --git a/website/api/durably-react/fullstack.md b/website/api/durably-react/fullstack.md
index 9e08872..10de7a2 100644
--- a/website/api/durably-react/fullstack.md
+++ b/website/api/durably-react/fullstack.md
@@ -249,6 +249,9 @@ function Component({ runId }: { runId: string }) {
}>({
api: '/api/durably',
runId,
+ onStart: () => console.log('Run started'),
+ onComplete: () => console.log('Run completed'),
+ onFail: () => console.log('Run failed'),
})
return Status: {status}
@@ -257,10 +260,13 @@ function Component({ runId }: { runId: string }) {
### Options
-| Option | Type | Description |
-| ------- | -------- | -------------------------- |
-| `api` | `string` | API base path |
-| `runId` | `string` | The run ID to subscribe to |
+| Option | Type | Description |
+| ------------ | ------------ | ------------------------------------------------- |
+| `api` | `string` | API base path |
+| `runId` | `string` | The run ID to subscribe to |
+| `onStart` | `() => void` | Called when the run transitions to pending/leased |
+| `onComplete` | `() => void` | Called when the run completes |
+| `onFail` | `() => void` | Called when the run fails |
---
diff --git a/website/guide/concepts.md b/website/guide/concepts.md
index 39412b6..73e7c47 100644
--- a/website/guide/concepts.md
+++ b/website/guide/concepts.md
@@ -165,6 +165,7 @@ createDurably({
dialect,
leaseRenewIntervalMs: 5000, // Renew lease every 5s (default)
leaseMs: 30000, // Lease duration — stale after 30s without renewal (default)
+ maxConcurrentRuns: 1, // Concurrent runs per worker (default: 1, increase for I/O-bound jobs)
})
```
diff --git a/website/guide/databases.md b/website/guide/databases.md
index ef213aa..a90ce1a 100644
--- a/website/guide/databases.md
+++ b/website/guide/databases.md
@@ -97,6 +97,12 @@ const dialect = new SqliteDialect({
- Good for one-off scripts and CLI tools
- No remote support (use libSQL if you might need Turso later)
+## SQLite WAL Maintenance
+
+For local SQLite backends using WAL (Write-Ahead Logging) mode, Durably automatically runs `PRAGMA wal_checkpoint(TRUNCATE)` during idle maintenance to prevent unbounded WAL file growth. This is throttled to at most once per 60 seconds and only runs when the worker is idle.
+
+At `migrate()` time, Durably probes whether WAL checkpointing is supported. Checkpointing is enabled only for local file-backed SQLite — it is automatically skipped for Turso (remote libSQL), PostgreSQL, and browser (OPFS) backends.
+
## PostgreSQL
**For multi-worker production deployments.** The recommended backend for running multiple workers concurrently, with advisory locks and `FOR UPDATE SKIP LOCKED` for strong concurrency guarantees.
diff --git a/website/public/llms.txt b/website/public/llms.txt
index 1ac0a18..cc82715 100644
--- a/website/public/llms.txt
+++ b/website/public/llms.txt
@@ -562,6 +562,10 @@ import { withLogPersistence } from '@coji/durably'
durably.use(withLogPersistence())
```
+## SQLite WAL Maintenance
+
+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.
+
## Browser Usage
```ts
@@ -932,6 +936,9 @@ function Component({ runId }: { runId: string }) {
}>({
api: '/api/durably',
runId,
+ onStart: () => console.log('Run started'),
+ onComplete: () => console.log('Run completed'),
+ onFail: () => console.log('Run failed'),
})
return Status: {status}