diff --git a/.github/actions/setup-js/action.yaml b/.github/actions/setup-js/action.yaml
new file mode 100644
index 000000000..48f96b4f1
--- /dev/null
+++ b/.github/actions/setup-js/action.yaml
@@ -0,0 +1,36 @@
+name: Set up JavaScript workspace
+description: >-
+ Install pnpm and an official Node.js 26 build, prove that it has native
+ Temporal, and install the locked dependencies of the workspace in `js/`.
+
+inputs:
+ node-version:
+ default: ""
+ description: >-
+ Node.js version to use instead of the one in `js/.node-version`.
+
+runs:
+ using: composite
+ steps:
+ - uses: pnpm/action-setup@v6
+ with:
+ package_json_file: js/package.json
+
+ - uses: actions/setup-node@v6
+ with:
+ cache: pnpm
+ cache-dependency-path: js/pnpm-lock.yaml
+ node-version: ${{ inputs.node-version }}
+ node-version-file: ${{ inputs.node-version == '' && 'js/.node-version' || '' }}
+
+ # Official Node.js 26 binaries enable Temporal; some builds from source do
+ # not, and River refuses to run without it.
+ - name: Require native Temporal
+ shell: bash
+ run: |
+ node --version
+ test "$(node -p 'typeof Temporal')" = object
+
+ - run: pnpm install --frozen-lockfile
+ shell: bash
+ working-directory: js
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 387f924b9..3999e4631 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -17,7 +17,10 @@ updates:
schedule:
interval: "weekly"
- package-ecosystem: "github-actions"
- directory: "/"
+ # The JavaScript workflows' composite actions live under .github/actions.
+ directories:
+ - "/"
+ - "/.github/actions/*"
schedule:
interval: "weekly"
- package-ecosystem: "gomod"
@@ -30,3 +33,31 @@ updates:
- "patch"
schedule:
interval: "weekly"
+ # Routine minor and patch updates to the JavaScript workspace arrive as one
+ # grouped pull request per dependency type; major updates stay separate so
+ # each can be reviewed on its own. Exact pins in package.json (Prisma, the
+ # TypeScript-next preview, pnpm overrides) stay exact because `increase`
+ # rewrites the pinned version rather than widening it.
+ - package-ecosystem: "npm"
+ directory: "/js"
+ cooldown:
+ default-days: 7
+ groups:
+ development-dependencies:
+ dependency-type: "development"
+ patterns:
+ - "*"
+ update-types:
+ - "minor"
+ - "patch"
+ production-dependencies:
+ dependency-type: "production"
+ patterns:
+ - "*"
+ update-types:
+ - "minor"
+ - "patch"
+ open-pull-requests-limit: 10
+ schedule:
+ interval: "monthly"
+ versioning-strategy: increase
diff --git a/.github/workflows/js.yaml b/.github/workflows/js.yaml
new file mode 100644
index 000000000..2a4959522
--- /dev/null
+++ b/.github/workflows/js.yaml
@@ -0,0 +1,231 @@
+name: JavaScript
+
+on:
+ # Filter the whole workflow so unrelated changes don't create skipped
+ # matrix jobs. Keep both events' paths in sync. Release tags always run it.
+ push:
+ branches:
+ - master
+ paths:
+ - ".github/actions/setup-js/**"
+ - ".github/workflows/js.yaml"
+ - "Makefile"
+ - "js/**"
+ - "riverdriver/**/*.sql"
+ tags: ["v*"]
+ pull_request:
+ paths:
+ - ".github/actions/setup-js/**"
+ - ".github/workflows/js.yaml"
+ - "Makefile"
+ - "js/**"
+ - "riverdriver/**/*.sql"
+
+concurrency:
+ cancel-in-progress: ${{ github.event_name == 'pull_request' }}
+ group: ${{ github.workflow }}-${{ github.ref }}
+
+permissions:
+ contents: read
+
+# The full runtime deliberately requires native Temporal from Node 26. Every
+# job installs the official build selected by js/.node-version (or the matrix)
+# and asserts `typeof Temporal` before doing anything else.
+
+defaults:
+ run:
+ working-directory: js
+
+jobs:
+ build:
+ name: Build
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+
+ - run: pnpm run build:all
+
+ - run: pnpm run typecheck:tests
+
+ - run: pnpm run typecheck:next
+
+ # Compares the generated mirror with River's canonical migration sources.
+ - run: pnpm run verify:migrations
+
+ - run: pnpm run api:check
+
+ - run: pnpm run docs:api
+
+ - run: pnpm run docs:snippets
+
+ - run: pnpm run migration:legacy
+
+ lint:
+ name: Lint
+ runs-on: ubuntu-latest
+ timeout-minutes: 10
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+
+ # Type-aware lint reads `riverqueue` through its built declarations.
+ - run: pnpm run build
+
+ - run: pnpm run lint
+
+ - run: pnpm run fmt:check
+
+ - run: pnpm run license:check
+
+ - run: pnpm audit
+
+ packages:
+ name: Package archives
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ # The packed `node:test` suite runs its PostgreSQL tests against this
+ # database, and RIVER_REQUIRE_POSTGRES makes them fail instead of skip
+ # when DATABASE_URL is missing.
+ services:
+ postgres:
+ image: postgres:18
+ env:
+ POSTGRES_DB: river_test
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ env:
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable
+ RIVER_REQUIRE_POSTGRES: "1"
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+
+ - run: pnpm run build:all
+
+ # The packed examples that need a database use its default schema.
+ - run: node cli/dist/bin.js migrate-up --database-url "$DATABASE_URL"
+
+ - run: pnpm run package:check
+
+ examples:
+ name: Examples
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ services:
+ postgres:
+ image: postgres:18
+ env:
+ POSTGRES_DB: river_test
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ env:
+ DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+
+ - run: pnpm run build:all
+
+ - run: node cli/dist/bin.js migrate-up --database-url "$DATABASE_URL"
+
+ - run: pnpm run package:examples
+
+ test:
+ name: Test (Node ${{ matrix.node-version || 'from .node-version' }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 15
+
+ strategy:
+ fail-fast: false
+ matrix:
+ # The minimum supported release and the current Node 26 release.
+ node-version: ["26.0.0", ""]
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+ with:
+ node-version: ${{ matrix.node-version }}
+
+ # The unit tests import the other workspace packages through their
+ # build output, and worker threads run compiled handler modules.
+ - run: pnpm run build:all
+
+ - run: pnpm run test
+
+ test_integration:
+ name: Test (integration, PostgreSQL ${{ matrix.postgres-version }})
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ strategy:
+ fail-fast: false
+ matrix:
+ postgres-version: [14, 15, 16, 17, 18]
+
+ services:
+ postgres:
+ image: postgres:${{ matrix.postgres-version }}
+ env:
+ POSTGRES_DB: river_test
+ POSTGRES_PASSWORD: postgres
+ options: >-
+ --health-cmd pg_isready
+ --health-interval 10s
+ --health-timeout 5s
+ --health-retries 5
+ ports:
+ - 5432:5432
+
+ env:
+ TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/river_test?sslmode=disable
+
+ steps:
+ - uses: actions/checkout@v6
+ with:
+ persist-credentials: false
+
+ - uses: ./.github/actions/setup-js
+
+ - run: pnpm run build:all
+
+ - run: node cli/dist/bin.js migrate-up --database-url "$TEST_DATABASE_URL"
+
+ - run: pnpm run test:integration
diff --git a/Makefile b/Makefile
index a55a88ee5..4a77f3d6b 100644
--- a/Makefile
+++ b/Makefile
@@ -19,10 +19,15 @@ db/reset/test: ## Drop, create, and migrate test databases
.PHONY: generate
generate: ## Generate generated artifacts
+generate: generate/js-migrations
generate: generate/migrations
generate: generate/rust-migrations
generate: generate/sqlc
+.PHONY: generate/js-migrations
+generate/js-migrations: ## Sync database migrations to JavaScript
+ pnpm -C js run generate:migrations
+
.PHONY: generate/migrations
generate/migrations: ## Sync changes of pgxv5 migrations to database/sql
rsync -au --delete "riverdriver/riverpgxv5/migration/" "riverdriver/riverdatabasesql/migration/"
@@ -91,6 +96,21 @@ lint/rust: ## Run Rust formatting and clippy checks, including single-backend bu
cd rust && cargo clippy -p riverqueue -p riverqueue-migrate -p riverqueue-cli -p riverqueue-test --no-default-features --features sqlite --all-targets --locked -- -D warnings
cd rust && $(RUST_POSTGRES_TESTS_ENV) cargo clippy -p riverqueue -p riverqueue-migrate --all-targets --all-features --locked -- -D warnings
+# JavaScript targets, like the Rust ones, are separate from `lint` and `test`
+# and need Node.js 26 and pnpm; they delegate to the workspace's own scripts.
+# Run `pnpm -C js install` first.
+.PHONY: build/js
+build/js: ## Build every JavaScript package
+ pnpm -C js run build:all
+
+.PHONY: lint/js
+lint/js: ## Run JavaScript lint, formatting, and type checks with both compilers
+lint/js: build/js
+ pnpm -C js run lint
+ pnpm -C js run fmt:check
+ pnpm -C js run typecheck:tests
+ pnpm -C js run typecheck:next
+
.PHONY: test
test:: ## Run tests (TEST_DATABASE=all, postgres, or sqlite)
define test-target
@@ -116,6 +136,19 @@ RUST_POSTGRES_TESTS_ENV = RUSTFLAGS="$$RUSTFLAGS --cfg river_postgres_tests" \
# PostgreSQL integration tests need RIVER_RUST_DATABASE_URL. Without it
# test/rust still runs unit, doc, and SQLite integration tests, and fails in CI
# so a missing URL cannot turn the PostgreSQL suite into a silent pass.
+.PHONY: test/js
+test/js: ## Run JavaScript unit tests
+test/js: build/js
+ pnpm -C js run test
+
+# Integration tests use TEST_DATABASE_URL (default
+# postgres://localhost:5432/river_test), migrated with
+# `node js/cli/dist/bin.js migrate-up`.
+.PHONY: test/js/integration
+test/js/integration: ## Run JavaScript integration tests against PostgreSQL
+test/js/integration: build/js
+ pnpm -C js run test:integration
+
.PHONY: test/rust
test/rust: ## Run Rust unit and SQLite tests, plus PostgreSQL tests when RIVER_RUST_DATABASE_URL is set
@if [ -n "$$RIVER_RUST_DATABASE_URL" ]; then \
@@ -136,6 +169,13 @@ test/rust/postgres: ## Run all Rust tests, including PostgreSQL integration test
test/rust/sqlite: ## Run Rust unit, doc, and SQLite integration tests without a PostgreSQL database
cd rust && cargo test --workspace --features riverqueue/sqlite,riverqueue-migrate/sqlite --locked
+.PHONY: doc/js
+doc/js: ## Check JavaScript API reports, TypeDoc, and README snippets
+doc/js: build/js
+ pnpm -C js run api:check
+ pnpm -C js run docs:api
+ pnpm -C js run docs:snippets
+
.PHONY: doc/rust
doc/rust: ## Build Rust API documentation, compiled examples, and doctests for each backend feature set
cd rust && RUSTDOCFLAGS="-D warnings" cargo doc --workspace --all-features --no-deps --locked
@@ -148,6 +188,20 @@ doc/rust: ## Build Rust API documentation, compiled examples, and doctests for e
doc/rust/docsrs: ## Build Rust API documentation as docs.rs does (nightly toolchain, `--cfg docsrs`)
cd rust && RUSTDOCFLAGS="--cfg docsrs -D warnings" CARGO_TARGET_DIR="$${CARGO_TARGET_DIR:-target}/docsrs" cargo +nightly doc -p riverqueue -p riverqueue-migrate -p riverqueue-test --all-features --no-deps --locked
+.PHONY: check/js/dependencies
+check/js/dependencies: ## Audit JavaScript advisories and production dependency licenses
+ pnpm -C js audit
+ pnpm -C js run license:check
+
+# Packs every published package and checks the archives in clean consumers,
+# including the 0.1 upgrade fixture. Its PostgreSQL tests run when
+# DATABASE_URL is set.
+.PHONY: check/js/package
+check/js/package: ## Build and verify publishable npm archives without publishing
+check/js/package: build/js
+ pnpm -C js run migration:legacy
+ pnpm -C js run package:check
+
.PHONY: check/rust/dependencies
check/rust/dependencies: ## Audit Rust advisories, licenses, bans, and sources
cd rust && cargo deny check
@@ -212,10 +266,15 @@ update-mod-version: ## Update River packages in all submodules to $VERSION
.PHONY: verify
verify: ## Verify generated artifacts
+verify: verify/js-migrations
verify: verify/migrations
verify: verify/rust-migrations
verify: verify/sqlc
+.PHONY: verify/js-migrations
+verify/js-migrations: ## Verify JavaScript migrations match the canonical migrations
+ pnpm -C js run verify:migrations
+
.PHONY: verify/migrations
verify/migrations: ## Verify synced migrations
diff -qr riverdriver/riverpgxv5/migration riverdriver/riverdatabasesql/migration
diff --git a/js/.gitignore b/js/.gitignore
new file mode 100644
index 000000000..d04dbb7f2
--- /dev/null
+++ b/js/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+coverage/
+dist/
+docs/api/
+temp/
+*/temp/
+examples/prisma/src/generated/prisma/
+*.tsbuildinfo
+.DS_Store
+# Package LICENSE copies generated from the root LICENSE at build time.
+/cli/LICENSE
+/driver/*/LICENSE
+/migrate/LICENSE
+/test/LICENSE
+/worker-threads/LICENSE
diff --git a/js/.node-version b/js/.node-version
new file mode 100644
index 000000000..6f4247a62
--- /dev/null
+++ b/js/.node-version
@@ -0,0 +1 @@
+26
diff --git a/js/.prettierrc b/js/.prettierrc
new file mode 100644
index 000000000..63c660cd2
--- /dev/null
+++ b/js/.prettierrc
@@ -0,0 +1,5 @@
+{
+ "semi": true,
+ "singleQuote": false,
+ "trailingComma": "es5"
+}
diff --git a/js/CHANGELOG.md b/js/CHANGELOG.md
new file mode 100644
index 000000000..e47217a3c
--- /dev/null
+++ b/js/CHANGELOG.md
@@ -0,0 +1,71 @@
+# Changelog
+
+All notable changes to this project will be documented in this file.
+
+The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
+and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
+
+## [Unreleased]
+
+This release turns the insert-only 0.1 client into a complete River
+implementation that interoperates with River for Go and Rust in the same
+database. It requires Node.js 26 with native `Temporal`. See
+[migrating from 0.1](./docs/migrating-from-0.1.md); `riverqueue codemod-0.1`
+rewrites the mechanical changes.
+
+### Added
+
+- Workers and a supervised runtime: `Workers`, `client.start()` returning a `RunHandle` (`stop`, `completed`, `await using`), per-queue concurrency, dynamic queues, cooperative cancellation through `AbortSignal`, job timeouts, stuck-job detection with `jobStuckThreshold` (10 seconds by default, like River for Go's `JobStuckThreshold`), graceful and cancelling shutdown, and resilience to transient database failures.
+- Like River for Go, a stopping leader resigns and ends maintenance as the stop begins, while its queues drain, and a failed runtime shuts down the same way before `run.completed` rejects. A removed queue's name stays reserved until its jobs finish.
+- A client-wide `fetchCooldown` (100 ms by default, like River for Go's `FetchCooldown`) is the claim cooldown of queues that don't set their own, and limits insert notifications like Go's client: on every driver, a client notifies a queue of new jobs at most once per cooldown, whether it inserts them directly, as periodic jobs, or by scheduling them. Retrying a job sends no insert notification.
+- `fetchOnlyKnownKinds: true` limits a client's claims to the kinds it has workers for when it starts, like River for Go's `Config.FetchOnlyKnownKinds`, so clients with different workers can share a queue while jobs of other kinds stay available without using an attempt.
+- Like River for Go, a client without notifications, whether `pollOnly: true` or on a server without them, checks its running jobs for cancellation requests every `queueControlPollInterval` (two seconds by default), including while a stop drains them, so another client's cancellation reaches it.
+- YugabyteDB is supported as a PostgreSQL server, like River for Go: `PgDriver` and `PrismaDriver` detect it, mark unique insertions with a `river:unique_nonce` metadata value in place of `xmax`, and, unless `yb_enable_listen_notify` is on, send no notifications while clients poll as if `pollOnly` were set. On PostgreSQL 18 and later, unique insertions read the conflicting row through `RETURNING OLD`.
+- Like River for Go, when a new leader's periodic job enqueuer fails to start three times, the leader resigns that term itself instead of continuing to retry.
+- Client options, including `maintenance` and `eventLoopDelay`, queue configuration, in `queues` and in `addQueue` and `updateQueue`, and stop options reject keys River doesn't know, such as a misspelled `pollInterval`, with a `ValidationError`.
+- Job definitions with `defineJob`, validated by any Standard Schema library or an explicit decoder on insert and again before work. A definition's `kindAliases` lets its worker also work jobs stored under other kinds, like River for Go's `JobArgsWithKindAliases`, so a kind can be renamed safely.
+- Work outcomes `complete`, `snooze`, `discard`, and `cancel`; retry policies per client or, like River for Go's `Worker.NextRetry`, per worker; an error handler, resumable steps, output recording, and transactional completion with `ctx.completeTx`.
+- Like River for Go, a handler's `signal` aborts with a `JobAttemptFinishedError` once its attempt finished, so work the handler left running stops.
+- Leader election and maintenance matching Go: scheduler, rescuer, job and queue cleaners, PostgreSQL reindexer, and periodic jobs (`periodicJob`) with fixed intervals or custom schedules such as cron. Like Go, maintenance works in batches of 10,000 rows with a timeout on each batch and a random 50 ms to 1 s pause between batches, and a service switches to batches of 1,000 after three timed-out batches in a row.
+- `leaderElectionDisabled: true` keeps a client out of leader election, like River for Go's `Config.LeaderElectionDisabled`: it works jobs from its queues while other eligible clients in the same database and schema handle scheduling, retries, periodic jobs, rescue, and cleanup. Such a client rejects `periodicJobs` and changes to `client.periodicJobs` with a `ConfigurationError`.
+- `cron()`, a periodic schedule that parses cron expressions exactly like River for Go (robfig/cron's `ParseStandard`, including descriptors, `@every`, and `CRON_TZ=` prefixes) and fires at the same times. Expressions without a `CRON_TZ=` prefix or `timeZone` option are evaluated in the process's local time zone, as in Go; pin a zone in mixed-language fleets.
+- Job and queue queries and controls under `client.jobs` and `client.queues`, with opaque keyset pagination. Job list cursors use River for Go's `JobListCursor` text format, so page tokens pass freely between River for Go, Rust, and JavaScript. Like Go, ordering by `time` sorts every listed job by the first listed state's time field (`scheduledAt` without a state filter), with null times last ascending and first descending, and cursors carry that field's value.
+- Hooks, work and insert middleware, plugins, bounded event subscriptions, `diagnostics_channel` publishing, and pino-compatible logging.
+- `Workers.add` also takes a `WorkHandlerFactory`, whose `createWorkHandler` builds the handler from the definition it's registered with, so an integration's worker is registered as `workers.add(definition, integrationWorker(options))`.
+- `@riverqueue/driver-sqlite`, a complete runtime on Node's built-in `node:sqlite`. Like River for Go, River runs on a private connection to the application's database file, in WAL mode, so application statements never join River's transactions.
+ - Pass an application handle with a transaction open as `{ tx }` to insert jobs with application rows. `transaction(database, callback)` begins one with `BEGIN IMMEDIATE`, retrying asynchronously while another connection holds the write lock.
+ - `SqliteDriver.memory()` and `driver.connect()` share an in-memory database, and `driver.close()` closes River's connection.
+ - Once River's own transaction holds the write lock, a River call without `{ tx }` from inside it, such as from insert middleware after `next()`, fails at once with a `TransactionScopeError` instead of waiting for it forever.
+ - On SQLite, insert middleware and hooks must not await I/O after `next()`, while River holds the write lock. When River's transaction is still open at the event loop's next turn, River rolls it back and fails the insertion with a `TransactionScopeError` whose `reason` is `"event_loop_turn"`. The check finds most mistakes, including all slow I/O and every insertion started from an I/O callback such as an HTTP handler, but it isn't deterministic for fast local I/O awaited by an insertion started from a timer or `setImmediate` callback.
+- `@riverqueue/migrate` with River's canonical PostgreSQL and SQLite migrations, and `@riverqueue/cli` with migration commands, `bench`, and the 0.1 codemod.
+- River's migration 8: on SQLite it rebuilds `river_job` with an `AUTOINCREMENT` key so a deleted job's ID is never reused, and, like River for Go, refuses to run while an extension's `river_job_sequence`, `river_job_workflow_scheduling`, or `river_workflow` schema exists; on PostgreSQL it changes nothing.
+- `@riverqueue/worker-threads` for CPU-bound handlers, and `@riverqueue/test` with test clients, insertion assertions, and `workOnce`. A worker-thread handler that still ignores its abort after the client's `jobStuckThreshold` has its thread terminated; when the abort came from a stopping client, it fails with a `JobAbortedError`, so its attempt counts and a job that hangs on every stop doesn't retry forever.
+
+### Changed
+
+- Job IDs are `bigint` and timestamps are `Temporal.Instant`, so no database value is rounded. Job args and metadata use an exact JSON domain that preserves numbers JavaScript cannot represent.
+- Argument classes (`JobArgs`, `JobArgsObject`) and `InsertManyParams` are replaced by job definitions and plain `{ job, args, options }` batch items; insert results report `status: "inserted" | "duplicate"`.
+- Insert options use `unique` (was `uniqueOpts`) with duration values such as `byPeriod: { seconds: 60 }`, add `delay`, and accept `Date` or `Temporal.Instant` for `scheduledAt`. Every duration option takes a `Temporal.Duration` or duration-like object.
+- Like River for Go, `insertMany` rejects a batch in which a unique key appears more than once among jobs whose state the key covers with a `ValidationError`, before writing any of it. 0.1 inserted the first such job and reported the rest as duplicates of it.
+- Like River for Go, `unique.excludeKind` requires `byArgs`, `byQueue`, or `byPeriod` and is otherwise rejected with a `ValidationError`, since the unique key would be the same for every job.
+- `ClientOpts`, `InsertOpts`, and `UniqueOpts` are renamed `ClientOptions`, `InsertOptions`, and `UniqueOptions`; `riverqueue codemod-0.1` imports them under their 0.1 names.
+- The PostgreSQL schema is configured on `PgDriver`, which accepts any `node-postgres` client as a transaction. `@riverqueue/driver-prisma` clients are typed as insert-only.
+- Errors River throws all extend `RiverError` with a stable `code`.
+- River for Go's `InsertManyFast` has no counterpart yet; use `insertMany`, which returns one result per row, reports a unique conflict as `status: "duplicate"` rather than skipping the row (SQLite) or failing the batch (PostgreSQL), and runs insert middleware and hooks.
+- Like River for Go, a job inserted without a schedule takes its creation and scheduled times from the database's clock, so a producer never waits out a difference between the application's clock and the database's.
+- The packages ship as ESM only; `require()` works through Node 26's `require(esm)`.
+- Like River for Go, an operation given a caller's transaction as `{ tx }` runs directly in it and opens no savepoint or nested transaction, on every driver, including transactional completion with `completeTx`. When the operation fails after writing, such as from insert middleware or a hook that throws after the write, its writes stay in the caller's transaction, which the caller rolls back; on PostgreSQL a database error aborts that transaction. Validation still fails before anything is written. An application that needs to recover and continue the transaction can wrap the call in a savepoint of its own.
+- Like River for Go, `insert` and `insertMany` without `{ tx }` run argument validation, insert middleware, `beforeInsert` and `afterInsert` hooks, and the write in one transaction River owns, so an error thrown by middleware after `next()` returns or by an `afterInsert` hook rolls the jobs back.
+- A `PgDriver` constructed from a single `node-postgres` client, not a pool, rejects insertions without `{ tx }` with a `ConfigurationError`, like River for Go's drivers without a pool: River needs a connection of its own to begin a transaction. Pass `{ tx }` or construct the driver with a `Pool`. `PrismaDriver` runs such insertions in a Prisma interactive transaction, so its client must be a root `PrismaClient` with `$transaction`; its `transactionOptions` sets that transaction's `maxWait` and `timeout`.
+- Drivers are opaque: `PgDriver`, `SqliteDriver`, and `PrismaDriver` expose only their construction, plus `SqliteDriver.memory()`, `connect()`, and `close()`. River's operations, such as 0.1's `jobInsert` and `jobInsertMany`, aren't callable on them, and `PgDriver` no longer exposes its `pool` or `schema`, nor `SqliteDriver` its `database`: keep your own reference to the connection. `createMigrator(driver)` still migrates the driver's connection and schema. `new Client()` accepts only a River driver, not any object with insertion methods; test clients come from `@riverqueue/test`.
+- Updated the Prisma example and `@riverqueue/driver-prisma` usage documentation for Prisma 7. The example now uses `@prisma/adapter-pg`, `prisma.config.ts`, and an explicitly generated ESM TypeScript client; its build automatically runs `prisma generate` before compilation. Prisma 7 requires Node.js `^20.19`, `^22.12`, or `>=24`. [PR #31](https://github.com/riverqueue/riverqueue-js/pull/31).
+
+### Fixed
+
+- A unique insertion with `excludeKind` that's skipped as a duplicate of a job of another kind no longer changes that job's kind to its own, which made workers of the other kind run it with the first job's arguments. The existing job keeps its kind, like River for Go.
+
+## [0.1.0] - 2026-06-01
+
+### Added
+
+- Initial release of the River TypeScript client with insert-only support, matching the semantics of the Go River client. Includes a core `riverqueue` package with `Client`, `JobArgsObject`, `InsertManyParams`, unique job support, and configurable schema. Driver packages `@riverqueue/driver-pg` (node-postgres) and `@riverqueue/driver-prisma` (Prisma) are provided as separate workspace packages to keep transitive dependencies minimal. [PR #1](https://github.com/riverqueue/riverqueue-js/pull/1).
diff --git a/js/LICENSE b/js/LICENSE
new file mode 100644
index 000000000..0a041280b
--- /dev/null
+++ b/js/LICENSE
@@ -0,0 +1,165 @@
+ GNU LESSER GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+
+ This version of the GNU Lesser General Public License incorporates
+the terms and conditions of version 3 of the GNU General Public
+License, supplemented by the additional permissions listed below.
+
+ 0. Additional Definitions.
+
+ As used herein, "this License" refers to version 3 of the GNU Lesser
+General Public License, and the "GNU GPL" refers to version 3 of the GNU
+General Public License.
+
+ "The Library" refers to a covered work governed by this License,
+other than an Application or a Combined Work as defined below.
+
+ An "Application" is any work that makes use of an interface provided
+by the Library, but which is not otherwise based on the Library.
+Defining a subclass of a class defined by the Library is deemed a mode
+of using an interface provided by the Library.
+
+ A "Combined Work" is a work produced by combining or linking an
+Application with the Library. The particular version of the Library
+with which the Combined Work was made is also called the "Linked
+Version".
+
+ The "Minimal Corresponding Source" for a Combined Work means the
+Corresponding Source for the Combined Work, excluding any source code
+for portions of the Combined Work that, considered in isolation, are
+based on the Application, and not on the Linked Version.
+
+ The "Corresponding Application Code" for a Combined Work means the
+object code and/or source code for the Application, including any data
+and utility programs needed for reproducing the Combined Work from the
+Application, but excluding the System Libraries of the Combined Work.
+
+ 1. Exception to Section 3 of the GNU GPL.
+
+ You may convey a covered work under sections 3 and 4 of this License
+without being bound by section 3 of the GNU GPL.
+
+ 2. Conveying Modified Versions.
+
+ If you modify a copy of the Library, and, in your modifications, a
+facility refers to a function or data to be supplied by an Application
+that uses the facility (other than as an argument passed when the
+facility is invoked), then you may convey a copy of the modified
+version:
+
+ a) under this License, provided that you make a good faith effort to
+ ensure that, in the event an Application does not supply the
+ function or data, the facility still operates, and performs
+ whatever part of its purpose remains meaningful, or
+
+ b) under the GNU GPL, with none of the additional permissions of
+ this License applicable to that copy.
+
+ 3. Object Code Incorporating Material from Library Header Files.
+
+ The object code form of an Application may incorporate material from
+a header file that is part of the Library. You may convey such object
+code under terms of your choice, provided that, if the incorporated
+material is not limited to numerical parameters, data structure
+layouts and accessors, or small macros, inline functions and templates
+(ten or fewer lines in length), you do both of the following:
+
+ a) Give prominent notice with each copy of the object code that the
+ Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the object code with a copy of the GNU GPL and this license
+ document.
+
+ 4. Combined Works.
+
+ You may convey a Combined Work under terms of your choice that,
+taken together, effectively do not restrict modification of the
+portions of the Library contained in the Combined Work and reverse
+engineering for debugging such modifications, if you also do each of
+the following:
+
+ a) Give prominent notice with each copy of the Combined Work that
+ the Library is used in it and that the Library and its use are
+ covered by this License.
+
+ b) Accompany the Combined Work with a copy of the GNU GPL and this license
+ document.
+
+ c) For a Combined Work that displays copyright notices during
+ execution, include the copyright notice for the Library among
+ these notices, as well as a reference directing the user to the
+ copies of the GNU GPL and this license document.
+
+ d) Do one of the following:
+
+ 0) Convey the Minimal Corresponding Source under the terms of this
+ License, and the Corresponding Application Code in a form
+ suitable for, and under terms that permit, the user to
+ recombine or relink the Application with a modified version of
+ the Linked Version to produce a modified Combined Work, in the
+ manner specified by section 6 of the GNU GPL for conveying
+ Corresponding Source.
+
+ 1) Use a suitable shared library mechanism for linking with the
+ Library. A suitable mechanism is one that (a) uses at run time
+ a copy of the Library already present on the user's computer
+ system, and (b) will operate properly with a modified version
+ of the Library that is interface-compatible with the Linked
+ Version.
+
+ e) Provide Installation Information, but only if you would otherwise
+ be required to provide such information under section 6 of the
+ GNU GPL, and only to the extent that such information is
+ necessary to install and execute a modified version of the
+ Combined Work produced by recombining or relinking the
+ Application with a modified version of the Linked Version. (If
+ you use option 4d0, the Installation Information must accompany
+ the Minimal Corresponding Source and Corresponding Application
+ Code. If you use option 4d1, you must provide the Installation
+ Information in the manner specified by section 6 of the GNU GPL
+ for conveying Corresponding Source.)
+
+ 5. Combined Libraries.
+
+ You may place library facilities that are a work based on the
+Library side by side in a single library together with other library
+facilities that are not Applications and are not covered by this
+License, and convey such a combined library under terms of your
+choice, if you do both of the following:
+
+ a) Accompany the combined library with a copy of the same work based
+ on the Library, uncombined with any other library facilities,
+ conveyed under the terms of this License.
+
+ b) Give prominent notice with the combined library that part of it
+ is a work based on the Library, and explaining where to find the
+ accompanying uncombined form of the same work.
+
+ 6. Revised Versions of the GNU Lesser General Public License.
+
+ The Free Software Foundation may publish revised and/or new versions
+of the GNU Lesser General Public License from time to time. Such new
+versions will be similar in spirit to the present version, but may
+differ in detail to address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Library as you received it specifies that a certain numbered version
+of the GNU Lesser General Public License "or any later version"
+applies to it, you have the option of following the terms and
+conditions either of that published version or of any later version
+published by the Free Software Foundation. If the Library as you
+received it does not specify a version number of the GNU Lesser
+General Public License, you may choose any version of the GNU Lesser
+General Public License ever published by the Free Software Foundation.
+
+ If the Library as you received it specifies that a proxy can decide
+whether future versions of the GNU Lesser General Public License shall
+apply, that proxy's public statement of acceptance of any version is
+permanent authorization for you to choose that version for the
+Library.
diff --git a/js/README.md b/js/README.md
new file mode 100644
index 000000000..9f0aebe72
--- /dev/null
+++ b/js/README.md
@@ -0,0 +1,130 @@
+# River for JavaScript and TypeScript
+
+River is a fast, reliable background job system backed by PostgreSQL or SQLite.
+This implementation runs on Node.js and shares River's database protocol with
+River for Go and Rust, so services in all three languages can insert and work
+the same jobs in the same database.
+
+It is currently an alpha and is not published from this branch.
+
+## Requirements
+
+- **Node.js 26 with native `Temporal`.** River uses `Temporal.Instant` for
+ timestamps and `bigint` for IDs, so no value read from the database is lost.
+ Official Node.js 26 builds (nodejs.org, `actions/setup-node`, the official
+ Docker images) enable Temporal. Some distribution and Homebrew builds do
+ not; check with:
+
+ ```sh
+ node -p "typeof Temporal" # must print "object"
+ ```
+
+- **TypeScript 6.0 or newer**, if you use TypeScript. Install `@types/node`
+ 26 or newer (and `@types/pg` with `@riverqueue/driver-pg`) and list `"node"`
+ in `compilerOptions.types`. JavaScript users need neither.
+- **ESM.** The packages ship one ES module build. CommonJS code can
+ `require()` them on Node 26, which returns the same module instance as
+ `import`; TypeScript CommonJS projects need `module: "node20"` or
+ `"nodenext"`.
+
+Keep every `@riverqueue/*` package on the same version as `riverqueue`; the
+packages declare that as an exact peer dependency.
+
+## Quickstart with PostgreSQL
+
+Install the core package, the PostgreSQL driver, migrations, and a validator
+(any [Standard Schema](https://standardschema.dev) library works; this uses
+Zod):
+
+```sh
+npm install riverqueue @riverqueue/driver-pg @riverqueue/migrate pg zod
+```
+
+Define a job, migrate the database, insert a job, and work it:
+
+```ts
+import { createMigrator } from "@riverqueue/migrate";
+import { PgDriver } from "@riverqueue/driver-pg";
+import { Pool } from "pg";
+import { Client, Workers, defineJob } from "riverqueue";
+import { z } from "zod";
+
+// A job definition is a plain value that producers and workers both import.
+const sendWelcomeEmail = defineJob({
+ kind: "send_welcome_email",
+ schema: z.object({ to: z.email() }),
+});
+
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+const driver = new PgDriver(pool);
+
+// Migrations are an explicit step, typically run at deploy time.
+await createMigrator(driver).migrateUp();
+
+const workers = new Workers().add(
+ sendWelcomeEmail,
+ async ({ job, logger, signal }) => {
+ signal.throwIfAborted();
+ logger.info({ to: job.args.to }, "sending welcome email");
+ }
+);
+
+const client = new Client(driver, {
+ queues: { default: { maxWorkers: 50 } },
+ workers,
+});
+
+const inserted = await client.insert(sendWelcomeEmail, {
+ to: "person@example.com",
+});
+console.log(`inserted job ${inserted.job.id}`); // a bigint
+
+const run = await client.start();
+process.once("SIGTERM", () => {
+ void run.stop({ timeout: { seconds: 30 } });
+});
+await run.completed; // settles after a stop, or rejects on a fatal error
+await pool.end();
+```
+
+River never closes the pool you give it and never migrates on its own. Web
+servers that only insert jobs construct the same `Client` without `queues` or
+`workers` and never call `start()`.
+
+For SQLite, install `@riverqueue/driver-sqlite` instead of the PostgreSQL
+packages; it uses Node's built-in `node:sqlite`. See the
+[SQLite driver](./driver/sqlite/README.md).
+
+## Packages
+
+| Package | Purpose |
+| ---------------------------- | ------------------------------------------------------------------- |
+| `riverqueue` | Job definitions, insertion, workers, runtime, queries, and events |
+| `@riverqueue/driver-pg` | PostgreSQL through `node-postgres` |
+| `@riverqueue/driver-prisma` | Insert jobs inside Prisma transactions |
+| `@riverqueue/driver-sqlite` | SQLite through Node's built-in `node:sqlite` |
+| `@riverqueue/migrate` | PostgreSQL and SQLite migrations |
+| `@riverqueue/worker-threads` | Run CPU-bound handlers on worker threads |
+| `@riverqueue/test` | Test helpers for producers and workers |
+| `@riverqueue/cli` | The `riverqueue` command: migrations, benchmarks, and a 0.1 codemod |
+
+## Documentation
+
+Start with the [guide](./docs/README.md), then the topic guides:
+
+- [Errors, retries, timeouts, and cancellation](./docs/errors-and-retries.md)
+- [Periodic jobs](./docs/periodic-jobs.md)
+- [Resumable jobs](./docs/resumable-jobs.md)
+- [Testing](./docs/testing.md)
+- [Runtime, concurrency, and the event loop](./docs/runtime.md)
+- [Databases, pools, and migrations](./docs/databases.md)
+- [Logging, events, and metrics](./docs/observability.md)
+- [Running alongside Go and Rust](./docs/deployment.md)
+- [Migrating from `riverqueue` 0.1](./docs/migrating-from-0.1.md)
+
+API reference documentation is generated with `pnpm run docs:api`.
+
+## License
+
+River for JavaScript and TypeScript is licensed under the GNU Lesser General
+Public License v3.0 or later. See [LICENSE](./LICENSE).
diff --git a/js/cli/README.md b/js/cli/README.md
new file mode 100644
index 000000000..0107d10c8
--- /dev/null
+++ b/js/cli/README.md
@@ -0,0 +1,154 @@
+# `@riverqueue/cli`
+
+The `riverqueue` command runs River's database migrations and a throughput
+benchmark, and upgrades code written for `riverqueue@0.1`. It requires
+Node.js 26 or newer and mirrors the commands of River's Go CLI.
+
+```sh
+npm install --save-dev @riverqueue/cli
+npx riverqueue migrate-up --database-url postgres://localhost/myapp
+```
+
+Run `npx riverqueue --help` for every command, and
+`npx riverqueue --help` for a command's flags.
+
+## Migrations
+
+| Command | What it does |
+| -------------- | -------------------------------------------------------- |
+| `migrate-up` | Apply missing migrations |
+| `migrate-down` | Revert the newest migration, or more with a target |
+| `migrate-list` | List migrations and mark the newest applied one with `*` |
+| `validate` | Exit with status 1 if any migration is missing |
+| `migrate-get` | Print migration SQL for use with another migration tool |
+| `version` | Print the CLI and Node.js versions (also `--version`) |
+
+`--database-url` selects the database:
+
+- `postgres://…` or `postgresql://…` for PostgreSQL. Without
+ `--database-url`, PostgreSQL commands use the standard `PG*` environment
+ variables when `PGDATABASE` is set.
+- `sqlite://PATH` for SQLite, for example `sqlite:///var/lib/app/river.db`
+ for an absolute path or `sqlite://river.db` for a relative one.
+
+Other common flags:
+
+- `--schema NAME`: the PostgreSQL schema holding River's tables. It must match
+ the `schema` given to `PgDriver`.
+- `--target-version N`: the version to end at. With `migrate-down`,
+ `--target-version 0` reverts every migration and drops River's tables.
+- `--max-steps N`: run at most N migrations.
+- `--dry-run` and `--show-sql`: print what would run, with its SQL.
+- `--statement-timeout DURATION`: PostgreSQL's `statement_timeout`, such as
+ `30s` or `5m`. It defaults to a `statement_timeout` parameter in the URL,
+ and otherwise to 10 seconds, as in River's Go CLI.
+
+```sh
+# Check in CI that a database is migrated.
+npx riverqueue validate --database-url "$DATABASE_URL"
+
+# Preview and apply migrations in a custom schema.
+npx riverqueue migrate-up --database-url "$DATABASE_URL" --schema river \
+ --dry-run --show-sql
+npx riverqueue migrate-up --database-url "$DATABASE_URL" --schema river
+
+# Hand River's SQL to another migration tool, without its tracking table.
+npx riverqueue migrate-get --all --exclude-version 1 --up > river.up.sql
+```
+
+Applications can also migrate from code with `@riverqueue/migrate`.
+
+## Upgrading from riverqueue 0.1
+
+`riverqueue codemod-0.1` rewrites code written for the `riverqueue@0.1`
+client to the current API and marks what it cannot rewrite with
+`TODO(riverqueue-0.1)` comments. It needs the `typescript` package, version 5
+or 6, in the project:
+
+```sh
+npx riverqueue codemod-0.1 --write src
+```
+
+Without `--write` it reports what would change; `--check` exits with status 1
+if any file would change. [Migrating from riverqueue 0.1][migrating]
+describes what it rewrites and what it leaves for review.
+
+[migrating]: https://github.com/riverqueue/river/blob/master/js/docs/migrating-from-0.1.md
+
+## Benchmark
+
+`riverqueue bench` inserts and works no-op jobs and reports throughput. It is
+destructive: it empties River's `river_job`, `river_leader`, `river_queue`,
+and `river_notification` tables and runs `VACUUM FULL` on `river_job`, so
+use it only on a disposable PostgreSQL database. It requires an explicit
+`--database-url` (it never reads `PG*` variables or `DATABASE_URL`), and
+`--yes` unless it can ask for confirmation in an interactive terminal.
+
+```sh
+npx riverqueue bench --database-url postgres://localhost/river_bench --yes \
+ --duration 30s
+```
+
+Without `--duration` it runs until Ctrl-C; press Ctrl-C again to stop without
+waiting for running jobs. `--num-total-jobs N` (`-n`) inserts N jobs up front
+and stops when all of them are worked.
+
+Every two seconds the benchmark prints the jobs worked and inserted and jobs
+per second. The summary adds the overall rate, the 95th percentile time from
+insert to completion, the run time, and peak resource use: running jobs,
+pending completions, completion queries, pool connections, event-loop delay,
+heap, and RSS. The 95th percentile includes time spent waiting in the
+backlog, so it reflects queue depth as much as per-job latency. The command
+fails if running jobs or pending completions exceed their configured limits,
+or if the pool exceeds `--max-connections`.
+
+The default workload matches River's Go and Rust benchmarks so results are
+comparable: a 75,000-job backlog topped up in batches of 5,000 and 2,000
+concurrent workers. The pool defaults to 50 connections. `--backlog`,
+`--batch-size`, `--max-workers`, `--max-connections`, and `--skip-vacuum`
+change the workload for controlled comparisons.
+
+## Running from code
+
+`run(argv)` runs the same commands in-process and resolves to an exit code:
+
+```ts
+import { run } from "@riverqueue/cli";
+
+process.exitCode = await run([
+ "migrate-up",
+ "--database-url",
+ "postgres://localhost/myapp",
+]);
+```
+
+Extension packages can embed this CLI and add migration lines, as with
+River's Go CLI. The migration commands select an added line with
+`--line NAME`, while River's `main` line stays the default:
+
+```ts
+import type { Migration, MigrationBackend } from "@riverqueue/migrate";
+import { run } from "@riverqueue/cli";
+
+declare function extensionMigrations(
+ backend: MigrationBackend
+): readonly Migration[];
+
+process.exitCode = await run(process.argv.slice(2), {
+ migrationLines: { extension: extensionMigrations },
+ program: "extension",
+});
+```
+
+## Requirements
+
+Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must
+print `object`. Official Node.js binaries include it; some builds compiled from
+source, including some distribution and Homebrew packages, do not.
+
+The CLI installs its own matching `riverqueue` and database packages.
+
+TypeScript users need TypeScript 6.0 or newer and `@types/node` and `@types/pg`,
+with `"node"` listed in `compilerOptions.types`. See [River's
+requirements](https://github.com/riverqueue/river/tree/master/js#requirements) for
+details.
diff --git a/js/cli/etc/cli.api.md b/js/cli/etc/cli.api.md
new file mode 100644
index 000000000..5bee98224
--- /dev/null
+++ b/js/cli/etc/cli.api.md
@@ -0,0 +1,77 @@
+# API report: `@riverqueue/cli`
+
+
+
+This report contains declarations and TSDoc for names exported by the
+package entry point. Private members, unexported implementation
+declarations, and file layout are omitted.
+
+## `run`
+
+````ts
+/**
+ * Run the `riverqueue` command line with `argv`, the arguments after the
+ * program name, and resolve to the process exit code.
+ *
+ * Errors are reported on `stderr` rather than thrown. `bench` handles
+ * `SIGINT` and `SIGTERM` while it runs so it can stop cleanly and print its
+ * summary, and removes its handlers when it finishes.
+ *
+ * ```ts
+ * process.exitCode = await run(process.argv.slice(2));
+ * ```
+ */
+export declare function run(
+ argv: readonly string[],
+ options?: RunOptions
+): Promise;
+````
+
+## `RunOptions`
+
+````ts
+/**
+ * Options for {@link run}. Each stream defaults to the process's own.
+ *
+ * A package that ships its own migration line can embed this command line
+ * with its lines added, as River's Go CLI allows:
+ *
+ * ```ts
+ * import { run } from "@riverqueue/cli";
+ *
+ * process.exitCode = await run(process.argv.slice(2), {
+ * migrationLines: { extension: (backend) => extensionMigrations(backend) },
+ * program: "extension",
+ * });
+ * ```
+ */
+export interface RunOptions {
+ /**
+ * Additional migration lines, by name, that the migration commands select
+ * with `--line`. Each returns the line's migrations for a backend,
+ * versioned from 1. River's bundled `main` line is always available and
+ * cannot be replaced.
+ */
+ readonly migrationLines?: Readonly<
+ Record readonly Migration[]>
+ >;
+ /**
+ * Program name shown in help, version output, and error messages.
+ * Defaults to `riverqueue`.
+ */
+ readonly program?: string;
+ /** Receives errors, warnings, and prompts. */
+ readonly stderr?: {
+ readonly isTTY?: boolean;
+ write(chunk: string): unknown;
+ };
+ /** Answers `bench`'s confirmation prompt when it is a terminal. */
+ readonly stdin?: NodeJS.ReadableStream & {
+ readonly isTTY?: boolean;
+ };
+ /** Receives command output. */
+ readonly stdout?: {
+ write(chunk: string): unknown;
+ };
+}
+````
diff --git a/js/cli/package.json b/js/cli/package.json
new file mode 100644
index 000000000..3bef4d28e
--- /dev/null
+++ b/js/cli/package.json
@@ -0,0 +1,85 @@
+{
+ "name": "@riverqueue/cli",
+ "version": "0.50.0-alpha.1",
+ "description": "Command-line migrations, benchmarks, and upgrade codemods for River TypeScript.",
+ "type": "module",
+ "sideEffects": [
+ "./dist/bin.js"
+ ],
+ "engines": {
+ "node": ">=26"
+ },
+ "bin": {
+ "riverqueue": "./dist/bin.js"
+ },
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "files": [
+ "dist",
+ "src",
+ "!src/**/*.test.ts",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "node ../node_modules/typescript/bin/tsc && chmod +x dist/bin.js && node ../scripts/copy-license.mjs",
+ "clean": "rm -rf dist",
+ "prepack": "pnpm run clean && pnpm run build",
+ "test": "vitest run src"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/riverqueue/river.git",
+ "directory": "js/cli"
+ },
+ "contributors": [
+ "Brandur Leach",
+ "Blake Gentry"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "dependencies": {
+ "@riverqueue/driver-pg": "workspace:0.50.0-alpha.1",
+ "@riverqueue/migrate": "workspace:0.50.0-alpha.1",
+ "pg": "^8.22.0",
+ "riverqueue": "workspace:0.50.0-alpha.1"
+ },
+ "peerDependencies": {
+ "@types/node": ">=26",
+ "@types/pg": ">=8",
+ "typescript": "^5.0.0 || ^6.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/node": {
+ "optional": true
+ },
+ "@types/pg": {
+ "optional": true
+ },
+ "typescript": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/node": "^26.1.1",
+ "@types/pg": "^8.20.0",
+ "typescript": "^6.0.3"
+ },
+ "keywords": [
+ "river",
+ "job-queue",
+ "cli",
+ "migrations",
+ "benchmark"
+ ]
+}
diff --git a/js/cli/src/bench.ts b/js/cli/src/bench.ts
new file mode 100644
index 000000000..8bec82e49
--- /dev/null
+++ b/js/cli/src/bench.ts
@@ -0,0 +1,580 @@
+import { performance } from "node:perf_hooks";
+import { setTimeout as sleep } from "node:timers/promises";
+import { createInterface } from "node:readline/promises";
+
+import { PgDriver } from "@riverqueue/driver-pg";
+import { createMigrator } from "@riverqueue/migrate";
+import type pg from "pg";
+import { Client, defineJob, Workers } from "riverqueue";
+import type { EventSubscription, RunHandle } from "riverqueue";
+
+import { BenchmarkResourceMonitor } from "./benchmark-metrics.js";
+import { writeLine, type Command, type CommandContext } from "./command.js";
+import {
+ describePostgresTarget,
+ openPostgresPool,
+ parseDatabaseUrl,
+ STATEMENT_TIMEOUT_OPTION,
+ statementTimeoutValue,
+} from "./database.js";
+import {
+ booleanValue,
+ integerValue,
+ parseDuration,
+ stringValue,
+ UsageError,
+ type OptionValues,
+} from "./options.js";
+
+const DEFAULT_BACKLOG = 75_000;
+const DEFAULT_BATCH_SIZE = 5_000;
+const DEFAULT_MAX_CONNECTIONS = 50;
+const DEFAULT_MAX_WORKERS = 2_000;
+const ITERATION_MS = 2_000;
+const PRODUCER_CHECK_MS = 250;
+
+// The tables River's Go benchmark truncates for the current main line.
+const RESET_TABLES = [
+ "river_job",
+ "river_leader",
+ "river_queue",
+ "river_notification",
+] as const;
+
+export const benchCommand: Command = {
+ description: `
+Measure River's throughput by inserting and working no-op jobs.
+
+WARNING: bench deletes every row from River's job, leader, queue, and
+notification tables and then runs VACUUM FULL on the job table. Use it only
+on a disposable database. It needs an explicit --database-url, and --yes
+unless you confirm at an interactive prompt.
+
+By default the benchmark keeps a backlog of jobs and runs until interrupted
+with Ctrl-C (press it twice to stop without waiting for running jobs).
+--duration stops after a time such as 30s or 5m. --num-total-jobs inserts a
+fixed number of jobs first and stops once all of them are worked.
+
+Every two seconds it prints the jobs worked and inserted and jobs per second.
+The summary adds the overall rate, the 95th percentile time from insert to
+completion, and peak resource use. The workload matches River's Go and Rust
+benchmarks so results are comparable.`,
+ name: "bench",
+ options: {
+ backlog: {
+ description: `Jobs to keep queued while running without --num-total-jobs (default: ${DEFAULT_BACKLOG})`,
+ type: "string",
+ valueName: "N",
+ },
+ "batch-size": {
+ description: `Jobs inserted per batch (default: ${DEFAULT_BATCH_SIZE})`,
+ type: "string",
+ valueName: "N",
+ },
+ "database-url": {
+ description:
+ "PostgreSQL database to benchmark (required; its River tables are emptied)",
+ type: "string",
+ valueName: "URL",
+ },
+ duration: {
+ description: "Stop after this long, such as 30s, 5m, or 1h30m",
+ type: "string",
+ valueName: "DURATION",
+ },
+ "max-connections": {
+ description: `PostgreSQL pool size (default: ${DEFAULT_MAX_CONNECTIONS})`,
+ type: "string",
+ valueName: "N",
+ },
+ "max-workers": {
+ description: `Jobs worked at once (default: ${DEFAULT_MAX_WORKERS})`,
+ type: "string",
+ valueName: "N",
+ },
+ "num-total-jobs": {
+ description: "Insert N jobs up front, then stop when all are worked",
+ short: "n",
+ type: "string",
+ valueName: "N",
+ },
+ schema: {
+ description:
+ "PostgreSQL schema containing River's tables (default: the search_path)",
+ type: "string",
+ valueName: "NAME",
+ },
+ "skip-vacuum": {
+ description: "Don't run VACUUM FULL after emptying the tables",
+ type: "boolean",
+ },
+ "statement-timeout": STATEMENT_TIMEOUT_OPTION,
+ yes: {
+ description: "Empty River's tables without asking for confirmation",
+ short: "y",
+ type: "boolean",
+ },
+ },
+ summary: "Benchmark River's job throughput (empties River's tables)",
+ run: async (values, context) => runBench(parseBenchOptions(values), context),
+};
+
+interface BenchOptions {
+ readonly backlog: number;
+ readonly batchSize: number;
+ readonly databaseUrl: string;
+ readonly durationMs: number | undefined;
+ readonly maxConnections: number;
+ readonly maxWorkers: number;
+ readonly numTotalJobs: number | undefined;
+ readonly schema: string | undefined;
+ readonly skipVacuum: boolean;
+ readonly statementTimeoutMs: number | undefined;
+ readonly yes: boolean;
+}
+
+interface BenchmarkCounters {
+ failed: number;
+ inserted: number;
+ lastWorkedAt: number;
+ worked: number;
+}
+
+// Same kind and args as River's Go benchmark job.
+const benchmarkJob = defineJob({
+ decode(value) {
+ const num = value.num;
+ if (typeof num !== "number" || !Number.isSafeInteger(num)) {
+ throw new TypeError("benchmark job num must be an integer");
+ }
+ return { num };
+ },
+ kind: "benchmark",
+});
+
+function parseBenchOptions(values: OptionValues): BenchOptions {
+ const command = "bench";
+ const databaseUrl = stringValue(values, "database-url");
+ if (databaseUrl === undefined) {
+ throw new UsageError(
+ "--database-url is required because bench empties River's tables",
+ command
+ );
+ }
+ const duration = stringValue(values, "duration");
+ const durationMs =
+ duration === undefined
+ ? undefined
+ : parseDuration(command, "--duration", duration);
+ const numTotalJobs = integerValue(command, values, "num-total-jobs", 1);
+ if (durationMs !== undefined && numTotalJobs !== undefined) {
+ throw new UsageError(
+ "pass at most one of --duration and --num-total-jobs",
+ command
+ );
+ }
+ return {
+ backlog: integerValue(command, values, "backlog", 1) ?? DEFAULT_BACKLOG,
+ batchSize:
+ integerValue(command, values, "batch-size", 1) ?? DEFAULT_BATCH_SIZE,
+ databaseUrl,
+ durationMs,
+ maxConnections:
+ integerValue(command, values, "max-connections", 1) ??
+ DEFAULT_MAX_CONNECTIONS,
+ maxWorkers:
+ integerValue(command, values, "max-workers", 1) ?? DEFAULT_MAX_WORKERS,
+ numTotalJobs,
+ schema: stringValue(values, "schema"),
+ skipVacuum: booleanValue(values, "skip-vacuum"),
+ statementTimeoutMs: statementTimeoutValue(command, values),
+ yes: booleanValue(values, "yes"),
+ };
+}
+
+async function runBench(
+ options: BenchOptions,
+ context: CommandContext
+): Promise {
+ const location = parseDatabaseUrl("bench", options.databaseUrl, context.env);
+ if (location.backend !== "postgres") {
+ throw new UsageError(
+ "only PostgreSQL databases can be benchmarked",
+ "bench"
+ );
+ }
+ const interactive =
+ context.stdin.isTTY === true && context.stderr.isTTY === true;
+ if (!options.yes && !interactive) {
+ throw new UsageError(
+ "pass --yes to confirm emptying River's tables, or run bench in an " +
+ "interactive terminal to be asked",
+ "bench"
+ );
+ }
+ const pool = openPostgresPool(location, {
+ max: options.maxConnections,
+ statementTimeoutMs: options.statementTimeoutMs,
+ });
+ try {
+ const driver = new PgDriver(
+ pool,
+ options.schema === undefined ? {} : { schema: options.schema }
+ );
+ const validation = await createMigrator(driver).validate();
+ if (!validation.ok) {
+ throw new Error(
+ `the database is not fully migrated (${validation.messages.join("; ")}); ` +
+ `run ${context.program} migrate-up first`
+ );
+ }
+
+ const tables = RESET_TABLES.map((table) =>
+ qualifiedTable(options.schema, table)
+ );
+ const target = describePostgresTarget(options.databaseUrl);
+ if (!options.yes && !(await confirmReset(context, tables, target))) {
+ writeLine(context.stderr, "bench: cancelled; no tables were changed");
+ return 1;
+ }
+ writeLine(context.stderr, `bench: emptying ${tables.join(", ")}`);
+ await pool.query(`TRUNCATE TABLE ${tables.join(", ")}`);
+ if (!options.skipVacuum) {
+ await pool.query(
+ `VACUUM FULL ${qualifiedTable(options.schema, "river_job")}`
+ );
+ }
+
+ return await runWorkload(options, context, pool, driver);
+ } finally {
+ await pool.end();
+ }
+}
+
+async function confirmReset(
+ context: CommandContext,
+ tables: readonly string[],
+ target: string
+): Promise {
+ context.stderr.write(
+ `bench will delete every row from ${tables.join(", ")} in ${target}.\n` +
+ "Continue? [y/N] "
+ );
+ const prompt = createInterface({ input: context.stdin });
+ try {
+ const answer = await prompt.question("");
+ return /^y(es)?$/i.test(answer.trim());
+ } finally {
+ prompt.close();
+ }
+}
+
+async function runWorkload(
+ options: BenchOptions,
+ context: CommandContext,
+ pool: pg.Pool,
+ driver: PgDriver
+): Promise {
+ const allWorked = new AbortController();
+ const forceShutdown = new AbortController();
+ const shutdown = new AbortController();
+ let countEvents: Promise | undefined;
+ let producer: Promise | undefined;
+ let producerFailure: { readonly error: unknown } | undefined;
+ let run: RunHandle | undefined;
+ let resourceSampling: NodeJS.Timeout | undefined;
+ let subscription: EventSubscription | undefined;
+ let signals = 0;
+ // The first signal stops gracefully so completions are recorded and the
+ // summary still prints; a second one cancels running jobs.
+ const onSignal = () => {
+ signals++;
+ if (signals === 1) shutdown.abort(new Error("benchmark interrupted"));
+ else forceShutdown.abort(new Error("benchmark force-stopped"));
+ };
+ process.on("SIGINT", onSignal);
+ process.on("SIGTERM", onSignal);
+
+ try {
+ const workers = new Workers().add(benchmarkJob, () => undefined);
+ const client = new Client(driver, {
+ clientId: `riverqueue-js-benchmark-${process.pid}`,
+ // Go's benchmark fetch settings, which also limit insert
+ // notifications.
+ fetchCooldown: { milliseconds: 2 },
+ queues: {
+ default: {
+ maxWorkers: options.maxWorkers,
+ pollInterval: { milliseconds: 20 },
+ },
+ },
+ workers,
+ });
+ subscription = client.subscribe({
+ capacity: Math.max(options.backlog, options.batchSize),
+ kinds: [
+ "job_cancelled",
+ "job_completed",
+ "job_failed",
+ "subscription_lag",
+ ],
+ });
+ const counters: BenchmarkCounters = {
+ failed: 0,
+ inserted: 0,
+ lastWorkedAt: 0,
+ worked: 0,
+ };
+ const events = subscription;
+ countEvents = (async () => {
+ for await (const event of events) {
+ if (event.kind === "job_completed") {
+ counters.worked++;
+ counters.lastWorkedAt = performance.now();
+ if (counters.worked === options.numTotalJobs) allWorked.abort();
+ continue;
+ }
+ counters.failed++;
+ shutdown.abort(
+ "error" in event && event.error instanceof Error
+ ? event.error
+ : new Error(`unexpected benchmark event ${event.kind}`)
+ );
+ }
+ })();
+
+ let nextNum = 0;
+ const insert = async (count: number) => {
+ let remaining = count;
+ while (remaining > 0 && !shutdown.signal.aborted) {
+ const size = Math.min(remaining, options.batchSize);
+ const items = Array.from({ length: size }, () => ({
+ args: { num: ++nextNum },
+ job: benchmarkJob,
+ }));
+ const results = await client.insertMany(items);
+ counters.inserted += results.length;
+ remaining -= results.length;
+ }
+ };
+
+ await insert(options.numTotalJobs ?? options.backlog);
+ run = await client.start();
+ const running = run;
+ const resources = new BenchmarkResourceMonitor({
+ maxConnections: options.maxConnections,
+ maxPendingCompletions: running.diagnostics.completionCapacity,
+ maxWorkers: options.maxWorkers,
+ });
+ const sampleResources = () => {
+ resources.sample(running.diagnostics, pool);
+ };
+ sampleResources();
+ resourceSampling = setInterval(sampleResources, 10);
+ resourceSampling.unref();
+ const startedAt = performance.now();
+ producer = (
+ options.numTotalJobs === undefined
+ ? produceContinuously(
+ insert,
+ counters,
+ options.backlog,
+ shutdown.signal
+ )
+ : Promise.resolve()
+ ).catch((error: unknown) => {
+ producerFailure = { error };
+ shutdown.abort(error);
+ });
+
+ let previousAt = startedAt;
+ let previousInserted = 0;
+ let previousWorked = 0;
+ // Report every two seconds, ending exactly at --duration, or as soon as
+ // every --num-total-jobs job has been worked.
+ for (let iteration = 1; ; iteration++) {
+ const reportAt = Math.min(
+ iteration * ITERATION_MS,
+ options.durationMs ?? Number.POSITIVE_INFINITY
+ );
+ await interruptibleSleep(
+ Math.max(startedAt + reportAt - performance.now(), 0),
+ AbortSignal.any([allWorked.signal, shutdown.signal])
+ );
+ if (shutdown.signal.aborted) break;
+ const now = performance.now();
+ const inserted = counters.inserted - previousInserted;
+ const worked = counters.worked - previousWorked;
+ writeLine(
+ context.stdout,
+ `bench: jobs worked [ ${formatInteger(worked)} ], inserted [ ${formatInteger(inserted)} ], ` +
+ `job/sec [ ${formatRate((worked * 1_000) / Math.max(now - previousAt, 1))} ] ` +
+ `[${formatSeconds(now - startedAt)}]`
+ );
+ previousAt = now;
+ previousInserted = counters.inserted;
+ previousWorked = counters.worked;
+ if (allWorked.signal.aborted || reportAt === options.durationMs) break;
+ }
+
+ shutdown.abort(new Error("benchmark complete"));
+ await producer;
+ if (producerFailure !== undefined) throw producerFailure.error;
+ // A graceful stop waits for running jobs and flushes their completions,
+ // so the statistics below include every worked job.
+ await run.stop({
+ mode: forceShutdown.signal.aborted ? "cancel" : "graceful",
+ signal: forceShutdown.signal,
+ });
+ sampleResources();
+ clearInterval(resourceSampling);
+ resourceSampling = undefined;
+ resources.assertBounded();
+ subscription.close();
+ await countEvents;
+
+ const statistics = await benchmarkStatistics(
+ pool,
+ qualifiedTable(options.schema, "river_job")
+ );
+ if (counters.failed > 0 || statistics.failed > 0) {
+ throw new Error(
+ `${Math.max(counters.failed, statistics.failed)} benchmark jobs failed`
+ );
+ }
+ const finishedAt =
+ counters.lastWorkedAt === 0 ? performance.now() : counters.lastWorkedAt;
+ const elapsedMs = Math.max(finishedAt - startedAt, 0);
+ writeLine(
+ context.stdout,
+ `bench: total jobs worked [ ${formatInteger(statistics.worked)} ], ` +
+ `total jobs inserted [ ${formatInteger(counters.inserted)} ], ` +
+ `overall job/sec [ ${formatRate(
+ elapsedMs === 0 ? 0 : (statistics.worked * 1_000) / elapsedMs
+ )} ], p95 [ ${formatP95(statistics.p95Seconds)} ], ` +
+ `running ${formatSeconds(elapsedMs)}`
+ );
+ const peaks = resources.peaks;
+ writeLine(
+ context.stdout,
+ `bench: peak running jobs ${peaks.activeAttempts}, ` +
+ `completion backlog ${peaks.pendingCompletions}, ` +
+ `completion queries ${peaks.completionQueries}, ` +
+ `pool active/total/waiting ${peaks.poolActiveConnections}/` +
+ `${peaks.poolTotalConnections}/${peaks.poolWaitingRequests}, ` +
+ `event-loop delay p99/max ${formatMilliseconds(peaks.eventLoopDelayP99Ms)}/` +
+ `${formatMilliseconds(peaks.eventLoopDelayMaxMs)}, ` +
+ `heap/RSS ${formatBytes(peaks.heapUsedBytes)}/${formatBytes(peaks.rssBytes)}`
+ );
+ return 0;
+ } finally {
+ if (resourceSampling !== undefined) clearInterval(resourceSampling);
+ shutdown.abort(new Error("benchmark cleanup"));
+ await producer?.catch(() => undefined);
+ if (run !== undefined && run.state !== "stopped") {
+ await run
+ .stop({ mode: "cancel", timeout: { milliseconds: 5_000 } })
+ .catch(() => undefined);
+ }
+ subscription?.close();
+ await countEvents?.catch(() => undefined);
+ process.off("SIGINT", onSignal);
+ process.off("SIGTERM", onSignal);
+ }
+}
+
+// Top the backlog back up whenever workers have drained part of it, the way
+// River's Go benchmark does.
+async function produceContinuously(
+ insert: (count: number) => Promise,
+ counters: BenchmarkCounters,
+ backlog: number,
+ signal: AbortSignal
+): Promise {
+ while (!signal.aborted) {
+ const jobsLeft = Math.max(counters.inserted - counters.worked, 0);
+ if (jobsLeft < backlog) await insert(backlog - jobsLeft);
+ await interruptibleSleep(PRODUCER_CHECK_MS, signal);
+ }
+}
+
+/** Wait without keeping the process alive, ending early once `signal` aborts. */
+function interruptibleSleep(
+ milliseconds: number,
+ signal: AbortSignal
+): Promise {
+ return sleep(milliseconds, undefined, { ref: false, signal }).catch(
+ () => undefined
+ );
+}
+
+// p95 measures insert-to-completion time, the same way as River's Rust
+// benchmark. It includes time spent waiting in the backlog.
+async function benchmarkStatistics(
+ pool: pg.Pool,
+ table: string
+): Promise<{ failed: number; p95Seconds: number | null; worked: number }> {
+ const result = await pool.query<{
+ failed: string;
+ p95_seconds: number | null;
+ worked: string;
+ }>(`
+ SELECT
+ count(*) FILTER (WHERE state IN ('cancelled', 'discarded'))::bigint AS failed,
+ percentile_cont(0.95) WITHIN GROUP (
+ ORDER BY extract(epoch FROM (finalized_at - created_at))::double precision
+ ) FILTER (WHERE state = 'completed') AS p95_seconds,
+ count(*) FILTER (WHERE state = 'completed')::bigint AS worked
+ FROM ${table}
+ `);
+ const row = result.rows[0];
+ if (row === undefined) {
+ throw new Error("benchmark statistics returned no row");
+ }
+ return {
+ failed: exactCount(row.failed, "failed"),
+ p95Seconds: row.p95_seconds,
+ worked: exactCount(row.worked, "worked"),
+ };
+}
+
+function exactCount(value: string, name: string): number {
+ const count = Number(value);
+ if (!Number.isSafeInteger(count) || count < 0) {
+ throw new Error(`benchmark ${name} count exceeds JavaScript's safe range`);
+ }
+ return count;
+}
+
+function formatBytes(value: number): string {
+ return `${(value / (1024 * 1024)).toFixed(1)}MiB`;
+}
+
+function formatInteger(value: number): string {
+ return Math.trunc(value).toString(10).padStart(10);
+}
+
+function formatMilliseconds(value: number): string {
+ return `${value.toFixed(1)}ms`;
+}
+
+function formatP95(value: number | null): string {
+ return value === null
+ ? "n/a".padStart(10)
+ : `${value.toFixed(3)}s`.padStart(10);
+}
+
+function formatRate(value: number): string {
+ return value.toFixed(1).padStart(10);
+}
+
+function formatSeconds(milliseconds: number): string {
+ return `${(milliseconds / 1_000).toFixed(1)}s`;
+}
+
+function qualifiedTable(schema: string | undefined, table: string): string {
+ const quoted = `"${table}"`;
+ return schema === undefined
+ ? quoted
+ : `"${schema.replaceAll('"', '""')}".${quoted}`;
+}
diff --git a/js/cli/src/benchmark-metrics.test.ts b/js/cli/src/benchmark-metrics.test.ts
new file mode 100644
index 000000000..809c36015
--- /dev/null
+++ b/js/cli/src/benchmark-metrics.test.ts
@@ -0,0 +1,130 @@
+import { describe, expect, it } from "vitest";
+
+import { BenchmarkResourceMonitor } from "./benchmark-metrics.js";
+import type { RunDiagnostics } from "riverqueue";
+
+describe("BenchmarkResourceMonitor", () => {
+ it("retains high-water marks and accepts River's resource bounds", () => {
+ const monitor = new BenchmarkResourceMonitor({
+ maxConnections: 4,
+ maxPendingCompletions: 20,
+ maxWorkers: 10,
+ });
+ monitor.sample(
+ diagnostics({
+ activeAttempts: 6,
+ completionQueries: 2,
+ pendingCompletions: 5,
+ }),
+ { idleCount: 1, totalCount: 4, waitingCount: 2 },
+ memory(100, 200)
+ );
+ monitor.sample(
+ diagnostics({
+ activeAttempts: 2,
+ completionQueries: 1,
+ pendingCompletions: 3,
+ }),
+ { idleCount: 2, totalCount: 3, waitingCount: 0 },
+ memory(90, 180)
+ );
+
+ expect(monitor.peaks).toMatchObject({
+ activeAttempts: 6,
+ completionQueries: 2,
+ heapUsedBytes: 100,
+ pendingCompletions: 5,
+ poolActiveConnections: 3,
+ poolTotalConnections: 4,
+ poolWaitingRequests: 2,
+ rssBytes: 200,
+ samples: 2,
+ });
+ expect(() => monitor.assertBounded()).not.toThrow();
+ });
+
+ it.each([
+ [{ activeAttempts: 11 }, "active attempts exceeded worker bound"],
+ [
+ { pendingCompletions: 21 },
+ "completion backlog exceeded configured capacity",
+ ],
+ [{ completionQueries: 3 }, "completion query concurrency exceeded"],
+ ] as const)(
+ "rejects an architectural bound violation: %s",
+ (values, message) => {
+ const monitor = new BenchmarkResourceMonitor({
+ maxConnections: 4,
+ maxPendingCompletions: 20,
+ maxWorkers: 10,
+ });
+ monitor.sample(
+ diagnostics(values),
+ {
+ idleCount: 0,
+ totalCount: 4,
+ waitingCount: 0,
+ },
+ memory(1, 1)
+ );
+ expect(() => monitor.assertBounded()).toThrow(message);
+ }
+ );
+
+ it("rejects pool growth beyond its configured maximum", () => {
+ const monitor = new BenchmarkResourceMonitor({
+ maxConnections: 4,
+ maxPendingCompletions: 20,
+ maxWorkers: 10,
+ });
+ monitor.sample(
+ diagnostics({}),
+ {
+ idleCount: 0,
+ totalCount: 5,
+ waitingCount: 0,
+ },
+ memory(1, 1)
+ );
+ expect(() => monitor.assertBounded()).toThrow(
+ "pool exceeded configured connection bound"
+ );
+ });
+});
+
+function diagnostics(
+ values: Partial<
+ Pick<
+ RunDiagnostics,
+ "activeAttempts" | "completionQueries" | "pendingCompletions"
+ >
+ >
+): RunDiagnostics {
+ return {
+ activeAttempts: values.activeAttempts ?? 0,
+ clientId: "benchmark",
+ completionCapacity: 20,
+ completionQueries: values.completionQueries ?? 0,
+ eventLoopDelay: {
+ exceededThreshold: false,
+ max: Temporal.Duration.from({ milliseconds: 12 }),
+ mean: Temporal.Duration.from({ milliseconds: 3 }),
+ p99: Temporal.Duration.from({ milliseconds: 8 }),
+ },
+ executors: {},
+ maintenance: null,
+ pendingCompletions: values.pendingCompletions ?? 0,
+ queues: {},
+ state: "running",
+ };
+}
+
+function memory(heapUsed: number, rss: number): NodeJS.MemoryUsage {
+ return {
+ arrayBuffers: 0,
+ external: 0,
+ heapTotal: heapUsed,
+ heapUsed,
+ rss,
+ };
+}
diff --git a/js/cli/src/benchmark-metrics.ts b/js/cli/src/benchmark-metrics.ts
new file mode 100644
index 000000000..a12c78dc6
--- /dev/null
+++ b/js/cli/src/benchmark-metrics.ts
@@ -0,0 +1,132 @@
+import type { Pool } from "pg";
+import type { RunDiagnostics } from "riverqueue";
+
+export interface BenchmarkResourceBounds {
+ readonly maxConnections: number;
+ readonly maxPendingCompletions: number;
+ readonly maxWorkers: number;
+}
+
+export interface BenchmarkResourcePeaks {
+ readonly activeAttempts: number;
+ readonly completionQueries: number;
+ readonly eventLoopDelayMaxMs: number;
+ readonly eventLoopDelayP99Ms: number;
+ readonly heapUsedBytes: number;
+ readonly pendingCompletions: number;
+ readonly poolActiveConnections: number;
+ readonly poolTotalConnections: number;
+ readonly poolWaitingRequests: number;
+ readonly rssBytes: number;
+ readonly samples: number;
+}
+
+/** Collect high-water marks without retaining per-sample benchmark data. */
+export class BenchmarkResourceMonitor {
+ readonly #bounds: BenchmarkResourceBounds;
+ #peaks: BenchmarkResourcePeaks = emptyPeaks();
+
+ constructor(bounds: BenchmarkResourceBounds) {
+ requirePositiveInteger(bounds.maxConnections, "maxConnections");
+ requirePositiveInteger(
+ bounds.maxPendingCompletions,
+ "maxPendingCompletions"
+ );
+ requirePositiveInteger(bounds.maxWorkers, "maxWorkers");
+ this.#bounds = bounds;
+ }
+
+ get peaks(): BenchmarkResourcePeaks {
+ return this.#peaks;
+ }
+
+ assertBounded(): void {
+ if (this.#peaks.activeAttempts > this.#bounds.maxWorkers) {
+ throw new Error(
+ `benchmark active attempts exceeded worker bound: ${this.#peaks.activeAttempts} > ${this.#bounds.maxWorkers}`
+ );
+ }
+ if (this.#peaks.pendingCompletions > this.#bounds.maxPendingCompletions) {
+ throw new Error(
+ `benchmark completion backlog exceeded configured capacity: ${this.#peaks.pendingCompletions} > ${this.#bounds.maxPendingCompletions}`
+ );
+ }
+ if (this.#peaks.completionQueries > 2) {
+ throw new Error(
+ `benchmark completion query concurrency exceeded River's two-way bound: ${this.#peaks.completionQueries}`
+ );
+ }
+ if (this.#peaks.poolTotalConnections > this.#bounds.maxConnections) {
+ throw new Error(
+ `benchmark pool exceeded configured connection bound: ${this.#peaks.poolTotalConnections} > ${this.#bounds.maxConnections}`
+ );
+ }
+ }
+
+ sample(
+ diagnostics: RunDiagnostics,
+ pool: Pick,
+ memory = process.memoryUsage()
+ ): void {
+ const delay = diagnostics.eventLoopDelay;
+ this.#peaks = {
+ activeAttempts: Math.max(
+ this.#peaks.activeAttempts,
+ diagnostics.activeAttempts
+ ),
+ completionQueries: Math.max(
+ this.#peaks.completionQueries,
+ diagnostics.completionQueries
+ ),
+ eventLoopDelayMaxMs: Math.max(
+ this.#peaks.eventLoopDelayMaxMs,
+ delay?.max.total("milliseconds") ?? 0
+ ),
+ eventLoopDelayP99Ms: Math.max(
+ this.#peaks.eventLoopDelayP99Ms,
+ delay?.p99.total("milliseconds") ?? 0
+ ),
+ heapUsedBytes: Math.max(this.#peaks.heapUsedBytes, memory.heapUsed),
+ pendingCompletions: Math.max(
+ this.#peaks.pendingCompletions,
+ diagnostics.pendingCompletions
+ ),
+ poolActiveConnections: Math.max(
+ this.#peaks.poolActiveConnections,
+ pool.totalCount - pool.idleCount
+ ),
+ poolTotalConnections: Math.max(
+ this.#peaks.poolTotalConnections,
+ pool.totalCount
+ ),
+ poolWaitingRequests: Math.max(
+ this.#peaks.poolWaitingRequests,
+ pool.waitingCount
+ ),
+ rssBytes: Math.max(this.#peaks.rssBytes, memory.rss),
+ samples: this.#peaks.samples + 1,
+ };
+ }
+}
+
+function emptyPeaks(): BenchmarkResourcePeaks {
+ return {
+ activeAttempts: 0,
+ completionQueries: 0,
+ eventLoopDelayMaxMs: 0,
+ eventLoopDelayP99Ms: 0,
+ heapUsedBytes: 0,
+ pendingCompletions: 0,
+ poolActiveConnections: 0,
+ poolTotalConnections: 0,
+ poolWaitingRequests: 0,
+ rssBytes: 0,
+ samples: 0,
+ };
+}
+
+function requirePositiveInteger(value: number, name: string): void {
+ if (!Number.isSafeInteger(value) || value < 1) {
+ throw new RangeError(`${name} must be a positive safe integer`);
+ }
+}
diff --git a/js/cli/src/bin.ts b/js/cli/src/bin.ts
new file mode 100644
index 000000000..ccfede91a
--- /dev/null
+++ b/js/cli/src/bin.ts
@@ -0,0 +1,7 @@
+#!/usr/bin/env node
+
+import process from "node:process";
+
+import { run } from "./run.js";
+
+process.exitCode = await run(process.argv.slice(2));
diff --git a/js/cli/src/codemod-command.test.ts b/js/cli/src/codemod-command.test.ts
new file mode 100644
index 000000000..38928d378
--- /dev/null
+++ b/js/cli/src/codemod-command.test.ts
@@ -0,0 +1,225 @@
+import {
+ mkdirSync,
+ mkdtempSync,
+ readFileSync,
+ rmSync,
+ writeFileSync,
+} from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { PassThrough } from "node:stream";
+
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { loadTypeScript } from "./codemod-command.js";
+import { run } from "./run.js";
+
+const LEGACY = `import { Client, type JobArgs } from "riverqueue";
+
+declare const client: Client;
+
+class SortArgs implements JobArgs {
+ kind = "sort";
+ constructor(public strings: string[]) {}
+}
+
+const result = await client.insert(new SortArgs(["b", "a"]));
+console.log(result.job.id + 1);
+`;
+
+const MIGRATED = `import { Client, defineJob } from "riverqueue";
+
+declare const client: Client;
+
+const sort = defineJob<{ strings: string[] }>()({
+ kind: "sort",
+});
+
+const result = await client.insert(sort, { strings: ["b", "a"] });
+// TODO(riverqueue-0.1): \`JobRow.id\` is now a \`bigint\`; review number annotations, arithmetic, and JSON serialization
+console.log(result.job.id + 1);
+`;
+
+async function invoke(argv: readonly string[]): Promise<{
+ exitCode: number;
+ stderr: string;
+ stdout: string;
+}> {
+ let stderr = "";
+ let stdout = "";
+ const exitCode = await run(argv, {
+ stderr: { write: (chunk: string) => (stderr += chunk) },
+ stdin: Object.assign(new PassThrough(), { isTTY: false }),
+ stdout: { write: (chunk: string) => (stdout += chunk) },
+ });
+ return { exitCode, stderr, stdout };
+}
+
+describe("codemod-0.1", () => {
+ let directory: string;
+
+ beforeEach(() => {
+ directory = mkdtempSync(join(tmpdir(), "riverqueue-codemod-"));
+ });
+
+ afterEach(() => {
+ rmSync(directory, { force: true, recursive: true });
+ });
+
+ function file(name: string, contents: string): string {
+ const path = join(directory, name);
+ mkdirSync(join(path, ".."), { recursive: true });
+ writeFileSync(path, contents);
+ return path;
+ }
+
+ it("describes itself in help", async () => {
+ const program = await invoke(["--help"]);
+ const command = await invoke(["codemod-0.1", "--help"]);
+
+ expect(program.stdout).toMatch(
+ /codemod-0\.1\s+Migrate source code from riverqueue 0\.1/
+ );
+ expect(command.exitCode).toBe(0);
+ expect(command.stdout).toContain(
+ "riverqueue codemod-0.1 [flags] ..."
+ );
+ expect(command.stdout).toMatch(/--check\s+Exit with status 1/);
+ expect(command.stdout).toMatch(/--write\s+Write the migrated files/);
+ });
+
+ it("reports changes without writing by default", async () => {
+ const path = file("app.ts", LEGACY);
+
+ const result = await invoke(["codemod-0.1", path]);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("would rewrite ");
+ expect(result.stdout).toContain("1 file to rewrite, 0 unchanged.");
+ expect(result.stdout).toContain("(lines as rewritten):");
+ expect(result.stdout).toMatch(/app\.ts:11: `JobRow\.id` is now a `bigint`/);
+ expect(readFileSync(path, "utf8")).toBe(LEGACY);
+ });
+
+ it("writes changes and then passes --check", async () => {
+ const path = file("app.ts", LEGACY);
+
+ const check = await invoke(["codemod-0.1", "--check", path]);
+ const write = await invoke(["codemod-0.1", "--write", path]);
+ const recheck = await invoke(["codemod-0.1", "--check", path]);
+
+ expect(check.exitCode).toBe(1);
+ expect(write).toMatchObject({ exitCode: 0, stderr: "" });
+ expect(write.stdout).toContain("rewrote ");
+ expect(write.stdout).toContain("1 file rewritten, 0 unchanged.");
+ expect(readFileSync(path, "utf8")).toBe(MIGRATED);
+ expect(recheck.exitCode).toBe(0);
+ expect(recheck.stdout).toContain("0 files to rewrite, 1 unchanged.");
+ // Remaining sites are still listed for review.
+ expect(recheck.stdout).toMatch(/app\.ts:11: `JobRow\.id`/);
+ });
+
+ it("expands directories and globs, skipping node_modules and declarations", async () => {
+ const app = file("src/app.ts", LEGACY);
+ const script = file("src/legacy.mjs", "export const answer = 42;\n");
+ file("src/types.d.ts", LEGACY);
+ file("src/node_modules/dep/index.ts", LEGACY);
+ file("src/notes.md", "new SortArgs()\n");
+
+ const fromDirectory = await invoke(["codemod-0.1", join(directory, "src")]);
+ const fromGlob = await invoke([
+ "codemod-0.1",
+ join(directory, "src/**/*.ts"),
+ ]);
+
+ expect(fromDirectory.stdout).toContain("1 file to rewrite, 1 unchanged.");
+ expect(fromGlob.stdout).toContain("1 file to rewrite, 0 unchanged.");
+ for (const result of [fromDirectory, fromGlob]) {
+ expect(result.stdout).toContain(join("src", "app.ts"));
+ expect(result.stdout).not.toContain("node_modules");
+ }
+ expect(readFileSync(app, "utf8")).toBe(LEGACY);
+ expect(readFileSync(script, "utf8")).toBe("export const answer = 42;\n");
+ });
+
+ it.each([
+ [[], "pass at least one file, directory, or glob"],
+ [
+ ["--check", "--write", "app.ts"],
+ "--check and --write cannot be combined",
+ ],
+ [["missing.ts"], 'no such file or directory: "missing.ts"'],
+ [["README.md"], '"README.md" is not a .ts'],
+ [["nothing/**/*.ts"], 'no source files match "nothing/**/*.ts"'],
+ ])("rejects %j", async (argv, message) => {
+ file("README.md", "# readme\n");
+ const relativeArgv = argv.map((arg) =>
+ arg.startsWith("-") ? arg : join(directory, arg)
+ );
+
+ const result = await invoke(["codemod-0.1", ...relativeArgv]);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ message.replace(/"(.*)"/, (_match, name: string) =>
+ JSON.stringify(join(directory, name))
+ )
+ );
+ expect(result.stderr).toContain('Run "riverqueue codemod-0.1 --help"');
+ });
+
+ it("keeps rejecting positional arguments for other commands", async () => {
+ const result = await invoke(["migrate-list", "extra"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain('unexpected argument: "extra"');
+ });
+});
+
+describe("loadTypeScript", () => {
+ it("loads the compiler API", () => {
+ const ts = loadTypeScript([import.meta.dirname]);
+
+ expect(typeof ts.createSourceFile).toBe("function");
+ });
+
+ it("explains how to install TypeScript when it is missing", () => {
+ const directory = mkdtempSync(join(tmpdir(), "riverqueue-no-ts-"));
+ try {
+ expect(() => loadTypeScript([directory])).toThrow(
+ /needs the typescript package, version 5 or 6.*npm install --save-dev typescript@6/
+ );
+ } finally {
+ rmSync(directory, { force: true, recursive: true });
+ }
+ });
+
+ it("skips a TypeScript without the compiler API", () => {
+ const directory = mkdtempSync(join(tmpdir(), "riverqueue-native-ts-"));
+ try {
+ const packageDirectory = join(directory, "node_modules", "typescript");
+ mkdirSync(packageDirectory, { recursive: true });
+ writeFileSync(
+ join(packageDirectory, "package.json"),
+ JSON.stringify({
+ main: "version.cjs",
+ name: "typescript",
+ version: "7.0.0",
+ })
+ );
+ writeFileSync(
+ join(packageDirectory, "version.cjs"),
+ 'module.exports = { version: "7.0.0" };\n'
+ );
+
+ expect(() => loadTypeScript([directory])).toThrow(
+ /found typescript@7\.0\.0 without the compiler API/
+ );
+ expect(
+ typeof loadTypeScript([directory, import.meta.dirname]).createSourceFile
+ ).toBe("function");
+ } finally {
+ rmSync(directory, { force: true, recursive: true });
+ }
+ });
+});
diff --git a/js/cli/src/codemod-command.ts b/js/cli/src/codemod-command.ts
new file mode 100644
index 000000000..037e3ee5e
--- /dev/null
+++ b/js/cli/src/codemod-command.ts
@@ -0,0 +1,281 @@
+import { glob, readFile, stat, writeFile } from "node:fs/promises";
+import { createRequire } from "node:module";
+import {
+ dirname,
+ extname,
+ isAbsolute,
+ join,
+ relative,
+ resolve,
+} from "node:path";
+import { fileURLToPath } from "node:url";
+
+import {
+ migrateSources,
+ SOURCE_EXTENSIONS,
+ TODO_MARKER,
+ type CodemodFileResult,
+ type TypeScriptApi,
+} from "./codemod.js";
+import { writeLine, type Command, type CommandContext } from "./command.js";
+import { booleanValue, UsageError, type OptionValues } from "./options.js";
+
+const COMMAND = "codemod-0.1";
+const SKIPPED_DIRECTORIES = new Set([".git", "node_modules"]);
+
+/** `riverqueue codemod-0.1`: rewrite sources written for `riverqueue@0.1`. */
+export const codemodCommand: Command = {
+ description: `
+Migrate TypeScript and JavaScript sources written for riverqueue 0.1 to the
+current API. Pass files, directories, or glob patterns; directories are
+searched for .ts, .tsx, .mts, .cts, .js, .jsx, .mjs, and .cjs files outside
+node_modules. Without --write the command only reports what it would change.
+
+It converts JobArgs classes whose args are exactly their constructor
+parameter properties into defineJob definitions and rewrites their
+construction in insert and insertMany calls, along with JobArgsObject,
+InsertManyParams, uniqueOpts (a byPeriod in seconds becomes { seconds: N }),
+uniqueSkippedAsDuplicated, and the JOB_STATE_* constants. Anything that
+needs judgment, such as bigint job IDs and Temporal timestamps, gets a
+"${TODO_MARKER}" comment and is listed at the end.
+
+Requires the typescript package (5.x or 6.x) installed in the project. Run
+your formatter and type checker afterwards.`,
+ name: COMMAND,
+ options: {
+ check: {
+ description:
+ "Exit with status 1 if any file would change, without writing",
+ type: "boolean",
+ },
+ write: {
+ description: "Write the migrated files in place",
+ type: "boolean",
+ },
+ },
+ positionals: "...",
+ summary: "Migrate source code from riverqueue 0.1",
+ run: runCodemod,
+};
+
+async function runCodemod(
+ values: OptionValues,
+ context: CommandContext,
+ positionals: readonly string[]
+): Promise {
+ const check = booleanValue(values, "check");
+ const write = booleanValue(values, "write");
+ if (check && write) {
+ throw new UsageError("--check and --write cannot be combined", COMMAND);
+ }
+ if (positionals.length === 0) {
+ throw new UsageError("pass at least one file, directory, or glob", COMMAND);
+ }
+
+ const cwd = process.cwd();
+ const paths = await expandPaths(cwd, positionals);
+ const ts = loadTypeScript([cwd, dirname(fileURLToPath(import.meta.url))]);
+ const sources = await Promise.all(
+ paths.map(async (path) => ({ path, text: await readFile(path, "utf8") }))
+ );
+ const results = migrateSources(ts, sources);
+
+ const display = (path: string): string => relative(cwd, path) || path;
+ let changed = 0;
+ const failed = new Set();
+ for (const result of results) {
+ if (result.error !== undefined) {
+ failed.add(result);
+ writeLine(
+ context.stderr,
+ `${context.program} ${COMMAND}: skipped ${display(result.path)}: ${result.error}`
+ );
+ continue;
+ }
+ if (!result.changed) continue;
+ const original = sources.find(({ path }) => path === result.path);
+ if (
+ original !== undefined &&
+ syntaxErrorCount(ts, result.path, result.output) >
+ syntaxErrorCount(ts, result.path, original.text)
+ ) {
+ failed.add(result);
+ writeLine(
+ context.stderr,
+ `${context.program} ${COMMAND}: skipped ${display(result.path)}: the rewrite would not parse; please report this file`
+ );
+ continue;
+ }
+ changed++;
+ if (write) await writeFile(result.path, result.output);
+ writeLine(
+ context.stdout,
+ `${write ? "rewrote" : "would rewrite"} ${display(result.path)}`
+ );
+ }
+
+ const unchanged = results.length - changed - failed.size;
+ writeLine(
+ context.stdout,
+ `${plural(changed, "file")} ${write ? "rewritten" : "to rewrite"}, ${unchanged} unchanged.`
+ );
+ printSites(
+ context,
+ results.filter((result) => !failed.has(result)),
+ display,
+ write
+ );
+ if (failed.size > 0) return 1;
+ return check && changed > 0 ? 1 : 0;
+}
+
+function printSites(
+ context: CommandContext,
+ results: readonly CodemodFileResult[],
+ display: (path: string) => string,
+ written: boolean
+): void {
+ const sites = results.flatMap((result) =>
+ result.sites.map((site) => ({ ...site, path: display(result.path) }))
+ );
+ if (sites.length === 0) return;
+ writeLine(context.stdout);
+ writeLine(
+ context.stdout,
+ `${plural(sites.length, "site")} to review, marked "${TODO_MARKER}"` +
+ (written ? ":" : " (lines as rewritten):")
+ );
+ for (const site of sites) {
+ writeLine(context.stdout, ` ${site.path}:${site.line}: ${site.message}`);
+ }
+}
+
+function plural(count: number, noun: string): string {
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
+}
+
+/** Resolve file, directory, and glob arguments to sorted absolute paths. */
+async function expandPaths(
+ cwd: string,
+ patterns: readonly string[]
+): Promise {
+ const paths = new Set();
+ for (const pattern of patterns) {
+ if (/[*?[\]{}]/.test(pattern)) {
+ let matched = false;
+ for await (const match of glob(pattern, { cwd, exclude: isSkipped })) {
+ const path = resolve(cwd, match);
+ if (isSourceFile(path) && (await stat(path)).isFile()) {
+ paths.add(path);
+ matched = true;
+ }
+ }
+ if (!matched) {
+ throw new UsageError(
+ `no source files match ${JSON.stringify(pattern)}`,
+ COMMAND
+ );
+ }
+ continue;
+ }
+ const path = isAbsolute(pattern) ? pattern : resolve(cwd, pattern);
+ const stats = await stat(path).catch(() => {
+ throw new UsageError(
+ `no such file or directory: ${JSON.stringify(pattern)}`,
+ COMMAND
+ );
+ });
+ if (stats.isDirectory()) {
+ for await (const match of glob(`**/*{${SOURCE_EXTENSIONS.join(",")}}`, {
+ cwd: path,
+ exclude: isSkipped,
+ })) {
+ const file = join(path, match);
+ if (isSourceFile(file)) paths.add(file);
+ }
+ } else if (isSourceFile(path)) {
+ paths.add(path);
+ } else {
+ throw new UsageError(
+ `${JSON.stringify(pattern)} is not a ${SOURCE_EXTENSIONS.join(", ")} file`,
+ COMMAND
+ );
+ }
+ }
+ return [...paths].sort();
+}
+
+function isSkipped(path: string): boolean {
+ return path.split(/[\\/]/).some((part) => SKIPPED_DIRECTORIES.has(part));
+}
+
+function isSourceFile(path: string): boolean {
+ return (
+ SOURCE_EXTENSIONS.includes(extname(path).toLowerCase()) &&
+ !/\.d\.[cm]?ts$/i.test(path)
+ );
+}
+
+/**
+ * Load the `typescript` compiler API from the first of `directories` that
+ * resolves a version providing it. The project's own TypeScript comes first
+ * so the codemod parses with the version the project compiles with.
+ */
+export function loadTypeScript(directories: readonly string[]): TypeScriptApi {
+ const found: string[] = [];
+ for (const directory of directories) {
+ let loaded: unknown;
+ try {
+ loaded = createRequire(join(directory, "package.json"))("typescript");
+ } catch {
+ continue;
+ }
+ if (isTypeScriptApi(loaded)) return loaded;
+ const version =
+ typeof loaded === "object" &&
+ loaded !== null &&
+ "version" in loaded &&
+ typeof loaded.version === "string"
+ ? loaded.version
+ : "unknown";
+ found.push(`typescript@${version} without the compiler API`);
+ }
+ const detail = found.length === 0 ? "" : ` (found ${found.join(", ")})`;
+ throw new Error(
+ `${COMMAND} needs the typescript package, version 5 or 6, for its ` +
+ `compiler API${detail}; install it in the project being migrated, ` +
+ `for example with "npm install --save-dev typescript@6"`
+ );
+}
+
+function isTypeScriptApi(value: unknown): value is TypeScriptApi {
+ if (typeof value !== "object" || value === null) return false;
+ const api = value as Partial>;
+ const major = Number.parseInt(String(api.version), 10);
+ return (
+ major >= 5 &&
+ typeof api.createSourceFile === "function" &&
+ typeof api.getModifiers === "function" &&
+ typeof api.isSatisfiesExpression === "function" &&
+ typeof api.transpileModule === "function"
+ );
+}
+
+/** Count syntax errors the TypeScript parser reports for `text`. */
+function syntaxErrorCount(
+ ts: TypeScriptApi,
+ path: string,
+ text: string
+): number {
+ const { diagnostics = [] } = ts.transpileModule(text, {
+ compilerOptions: {
+ allowJs: true,
+ jsx: ts.JsxEmit.Preserve,
+ module: ts.ModuleKind.ESNext,
+ target: ts.ScriptTarget.ESNext,
+ },
+ fileName: path,
+ reportDiagnostics: true,
+ });
+ return diagnostics.length;
+}
diff --git a/js/cli/src/codemod-edits.ts b/js/cli/src/codemod-edits.ts
new file mode 100644
index 000000000..74a0e0901
--- /dev/null
+++ b/js/cli/src/codemod-edits.ts
@@ -0,0 +1,112 @@
+/**
+ * Minimal text edits over an unchanged original source.
+ *
+ * The codemod never reprints a syntax tree. It records replacements of
+ * original ranges and applies them once, so formatting and comments outside
+ * the rewritten expressions survive byte for byte.
+ */
+
+interface Edit {
+ readonly end: number;
+ /** Tie-breaker keeping zero-width insertions at one position in order. */
+ readonly order: number;
+ readonly start: number;
+ readonly text: string;
+}
+
+/** Non-overlapping replacements of ranges in one original source text. */
+export class SourceEdits {
+ readonly #source: string;
+ #edits: Edit[] = [];
+ #order = 0;
+
+ constructor(source: string) {
+ this.#source = source;
+ }
+
+ /** Apply every edit and return the rewritten text. */
+ apply(): string {
+ return this.render(0, this.#source.length);
+ }
+
+ /**
+ * The replacement whose range strictly contains `position`, so that text
+ * inserted there would land inside rewritten code.
+ */
+ containing(
+ position: number
+ ): { readonly end: number; readonly start: number } | undefined {
+ return this.#edits.find(
+ (edit) => edit.start < position && position < edit.end
+ );
+ }
+
+ /** Insert `text` at `position` without replacing anything. */
+ insert(position: number, text: string): void {
+ this.replace(position, position, text);
+ }
+
+ /** Whether `[start, end)` overlaps or touches the inside of an edit. */
+ intersects(start: number, end: number): boolean {
+ return this.#edits.some(
+ (edit) => edit.start !== edit.end && edit.start < end && start < edit.end
+ );
+ }
+
+ /**
+ * The original text of `[start, end)` with the edits inside it applied.
+ * Callers build a replacement for an outer node from this, which is why
+ * {@link SourceEdits.replace} may then drop those inner edits.
+ */
+ render(start: number, end: number): string {
+ let output = "";
+ let cursor = start;
+ for (const edit of this.#sorted()) {
+ if (edit.start < start || edit.end > end) continue;
+ if (edit.start === edit.end && edit.start === end && start !== end) {
+ // An insertion at the end boundary belongs to the following text.
+ continue;
+ }
+ output += this.#source.slice(cursor, edit.start) + edit.text;
+ cursor = edit.end;
+ }
+ return output + this.#source.slice(cursor, end);
+ }
+
+ /**
+ * Replace `[start, end)` with `text`. Edits strictly inside the range are
+ * superseded, because the caller rendered them into `text`; a partial
+ * overlap is a bug in the caller.
+ */
+ replace(start: number, end: number, text: string): void {
+ const kept: Edit[] = [];
+ for (const edit of this.#edits) {
+ const inside =
+ start !== end &&
+ edit.start >= start &&
+ edit.end <= end &&
+ !(edit.start === edit.end && edit.start === end);
+ if (inside) continue;
+ const overlaps = edit.start < end && start < edit.end && start !== end;
+ const splits = start === end && edit.start < start && start < edit.end;
+ if (overlaps || splits) {
+ throw new Error(
+ `internal codemod error: overlapping edits at ${start}-${end} and ${edit.start}-${edit.end}`
+ );
+ }
+ kept.push(edit);
+ }
+ kept.push({ end, order: this.#order++, start, text });
+ this.#edits = kept;
+ }
+
+ #sorted(): Edit[] {
+ return [...this.#edits].sort(
+ (left, right) =>
+ left.start - right.start ||
+ // Insertions come before a replacement that starts at the same place.
+ left.end - left.start - (right.end - right.start) ||
+ left.order - right.order
+ );
+ }
+}
diff --git a/js/cli/src/codemod.test.ts b/js/cli/src/codemod.test.ts
new file mode 100644
index 000000000..c36e80b7c
--- /dev/null
+++ b/js/cli/src/codemod.test.ts
@@ -0,0 +1,806 @@
+import { readFileSync } from "node:fs";
+
+import { describe, expect, it } from "vitest";
+
+import { migrateSources, type CodemodFileResult } from "./codemod.js";
+import { loadTypeScript } from "./codemod-command.js";
+
+const ts = loadTypeScript([import.meta.dirname]);
+
+const ROOT = "/project/src";
+
+/** Strip the indentation common to every non-blank line of a template. */
+function code(strings: TemplateStringsArray, ...values: unknown[]): string {
+ const text = String.raw({ raw: strings }, ...values).replace(/^\n/, "");
+ const indents = text
+ .split("\n")
+ .filter((line) => line.trim() !== "")
+ .map((line) => /^ */.exec(line)?.[0].length ?? 0);
+ const indent = Math.min(...indents);
+ return text
+ .split("\n")
+ .map((line) => line.slice(indent))
+ .join("\n")
+ .replace(/[ ]+$/, "");
+}
+
+/**
+ * Migrate files named relative to a fake project root, and check that a
+ * second run over the output changes nothing.
+ */
+function migrate(
+ files: Record
+): Map {
+ const results = migrateSources(
+ ts,
+ Object.entries(files).map(([name, text]) => ({
+ path: `${ROOT}/${name}`,
+ text,
+ }))
+ );
+ const again = migrateSources(
+ ts,
+ results.map(({ output, path }) => ({ path, text: output }))
+ );
+ for (const [index, result] of again.entries()) {
+ expect(result.output, `second run over ${result.path}`).toBe(
+ results[index]?.output
+ );
+ }
+ return new Map(
+ results.map((result) => [result.path.slice(ROOT.length + 1), result])
+ );
+}
+
+function migrateOne(text: string, name = "app.ts"): string {
+ const result = migrate({ [name]: text }).get(name);
+ if (result === undefined) throw new Error(`no result for ${name}`);
+ return result.output;
+}
+
+describe("migrateSources", () => {
+ describe("argument classes", () => {
+ it("converts a parameter-property class and its insert sites", () => {
+ expect(
+ migrateOne(code`
+ import { Client, type JobArgs } from "riverqueue";
+
+ declare const client: Client;
+
+ /** Sorts strings. */
+ export class SortArgs implements JobArgs {
+ kind = "sort";
+ // Sorting has its own queue.
+ insertOpts = { queue: "sorting", uniqueOpts: { byPeriod: 60 } };
+
+ constructor(readonly strings: string[], public reverse?: boolean) {}
+ }
+
+ export async function enqueue(strings: string[]) {
+ await client.insert(new SortArgs(strings, true), { priority: 2 });
+ await client.insert(new SortArgs(["a"]));
+ await client.insertMany([new SortArgs(strings)]);
+ }
+ `)
+ ).toBe(code`
+ import { Client, defineJob } from "riverqueue";
+
+ declare const client: Client;
+
+ /** Sorts strings. */
+ export const sort = defineJob<{ strings: string[]; reverse?: boolean }>()({
+ kind: "sort",
+ // Sorting has its own queue.
+ defaults: { queue: "sorting", unique: { byPeriod: { seconds: 60 } } },
+ });
+
+ export async function enqueue(strings: string[]) {
+ await client.insert(sort, { strings, reverse: true }, { priority: 2 });
+ await client.insert(sort, { strings: ["a"] });
+ await client.insertMany([{ job: sort, args: { strings } }]);
+ }
+ `);
+ });
+
+ it("accepts a toJSON that returns exactly the parameters", () => {
+ expect(
+ migrateOne(code`
+ import type { JobArgs } from "riverqueue";
+
+ class SortArgs implements JobArgs {
+ readonly kind = 'sort' as const;
+
+ constructor(
+ /** Strings to sort. */
+ public strings: string[],
+ ) {}
+
+ toJSON() {
+ return { strings: this.strings };
+ }
+ }
+ `)
+ ).toBe(code`
+ import { defineJob } from "riverqueue";
+
+ const sort = defineJob<{
+ /** Strings to sort. */
+ strings: string[];
+ }>()({
+ kind: 'sort',
+ });
+ `);
+ });
+
+ it("defines a class without parameters as an unchecked job", () => {
+ expect(
+ migrateOne(code`
+ import { Client, JobArgs } from "riverqueue";
+
+ declare const client: Client;
+
+ class PingArgs implements JobArgs {
+ kind = "ping";
+ }
+
+ await client.insert(new PingArgs());
+ `)
+ ).toBe(code`
+ import { Client, defineJob } from "riverqueue";
+
+ declare const client: Client;
+
+ const ping = defineJob({
+ kind: "ping",
+ });
+
+ await client.insert(ping, {});
+ `);
+ });
+
+ it("avoids names that are already taken", () => {
+ expect(
+ migrateOne(code`
+ import type { JobArgs } from "riverqueue";
+
+ const sort = (values: string[]) => values.sort();
+
+ class SortArgs implements JobArgs {
+ kind = "sort";
+ constructor(public strings: string[]) {}
+ }
+
+ class DeleteArgs implements JobArgs {
+ kind = "delete";
+ constructor(public id: string) {}
+ }
+ `)
+ ).toBe(code`
+ import { defineJob } from "riverqueue";
+
+ const sort = (values: string[]) => values.sort();
+
+ const sortJob = defineJob<{ strings: string[] }>()({
+ kind: "sort",
+ });
+
+ const deleteJob = defineJob<{ id: string }>()({
+ kind: "delete",
+ });
+ `);
+ });
+
+ it.each([
+ [
+ "a method",
+ "describe() { return this.id; }",
+ "it has members other than `kind`, `insertOpts`, and constructor parameter properties",
+ ],
+ [
+ "a computed kind",
+ "",
+ "its `kind` is not a string literal",
+ 'kind = ["re", "port"].join("");',
+ ],
+ [
+ "a constructor body",
+ "",
+ "its constructor has a body",
+ 'kind = "report";',
+ "constructor(public id: string) { console.log(id); }",
+ ],
+ [
+ "a plain constructor parameter",
+ "",
+ "a constructor parameter is not a parameter property",
+ 'kind = "report";',
+ "constructor(id: string) {}",
+ ],
+ [
+ "a default parameter",
+ "",
+ "a constructor parameter is a rest, default, or decorated parameter",
+ 'kind = "report";',
+ 'constructor(public id = "x") {}',
+ ],
+ [
+ "a toJSON that renames fields",
+ "toJSON() { return { report_id: this.id }; }",
+ "its `toJSON` does not return exactly its parameters",
+ ],
+ ])(
+ "flags a class with %s",
+ (
+ _case,
+ extra,
+ reason,
+ kind = 'kind = "report";',
+ constructor = "constructor(public id: string) {}"
+ ) => {
+ const input = code`
+ import { Client, type JobArgs } from "riverqueue";
+
+ declare const client: Client;
+
+ class ReportArgs implements JobArgs {
+ ${kind}
+ ${constructor}
+ ${extra}
+ }
+
+ await client.insert(new ReportArgs("r1"));
+ `;
+ const output = migrateOne(input);
+
+ expect(output).toContain(
+ `// TODO(riverqueue-0.1): convert \`ReportArgs\` to \`defineJob\` by hand: ${reason}\nclass ReportArgs implements JobArgs {`
+ );
+ expect(output).toContain(
+ "// TODO(riverqueue-0.1): `ReportArgs` could not be converted automatically; insert a job definition with a plain args object\n" +
+ 'await client.insert(new ReportArgs("r1"));'
+ );
+ expect(output).toContain(
+ 'import { Client, type JobArgs } from "riverqueue";'
+ );
+ }
+ );
+
+ it("flags a construction outside insert calls and other references", () => {
+ expect(
+ migrateOne(code`
+ import { Client, type JobArgs } from "riverqueue";
+
+ declare const client: Client;
+
+ class SortArgs implements JobArgs {
+ kind = "sort";
+ constructor(public strings: string[]) {}
+ }
+
+ const args = new SortArgs(["b", "a"]);
+ export function describe(value: SortArgs) {
+ return value.strings;
+ }
+ await client.insertMany([new SortArgs(...[["a"]])]);
+ `)
+ ).toBe(code`
+ import { Client, defineJob } from "riverqueue";
+
+ declare const client: Client;
+
+ const sort = defineJob<{ strings: string[] }>()({
+ kind: "sort",
+ });
+
+ // TODO(riverqueue-0.1): pass \`sort\` and the args object \`{ strings: ["b", "a"] }\` to insert or insertMany
+ const args = new SortArgs(["b", "a"]);
+ // TODO(riverqueue-0.1): \`SortArgs\` is now the \`sort\` job definition; pass it with a plain args object
+ export function describe(value: SortArgs) {
+ return value.strings;
+ }
+ // TODO(riverqueue-0.1): spell out the \`SortArgs\` arguments as a plain args object for \`sort\`
+ await client.insertMany([new SortArgs(...[["a"]])]);
+ `);
+ });
+
+ it("rewrites imports, aliases, and re-exports across files", () => {
+ const results = migrate({
+ "app.ts": code`
+ import type { Client } from "riverqueue";
+ import { SendEmailArgs, Mail, SortArgs as Sort } from "./jobs/index.js";
+
+ declare const client: Client;
+ const sendEmail = "taken";
+
+ await client.insert(new SendEmailArgs("a@example.com"));
+ await client.insert(new Mail("b@example.com"));
+ await client.insert(new Sort(["b"]));
+ `,
+ "jobs/email.ts": code`
+ import type { JobArgs } from "riverqueue";
+
+ export class SendEmailArgs implements JobArgs {
+ kind = "send_email";
+ constructor(public to: string) {}
+ }
+
+ class SortArgs implements JobArgs {
+ kind = "sort";
+ constructor(public strings: string[]) {}
+ }
+
+ export { SortArgs };
+ `,
+ "jobs/index.ts": code`
+ export * from "./email.js";
+ export { SendEmailArgs as Mail } from "./email.js";
+ `,
+ });
+
+ expect(results.get("app.ts")?.output).toBe(code`
+ import type { Client } from "riverqueue";
+ import { sendEmail as sendEmailJob, Mail, sort as Sort } from "./jobs/index.js";
+
+ declare const client: Client;
+ const sendEmail = "taken";
+
+ await client.insert(sendEmailJob, { to: "a@example.com" });
+ await client.insert(Mail, { to: "b@example.com" });
+ await client.insert(Sort, { strings: ["b"] });
+ `);
+ expect(results.get("jobs/email.ts")?.output).toBe(code`
+ import { defineJob } from "riverqueue";
+
+ export const sendEmail = defineJob<{ to: string }>()({
+ kind: "send_email",
+ });
+
+ const sort = defineJob<{ strings: string[] }>()({
+ kind: "sort",
+ });
+
+ export { sort };
+ `);
+ expect(results.get("jobs/index.ts")?.output).toBe(code`
+ export * from "./email.js";
+ export { sendEmail as Mail } from "./email.js";
+ `);
+ });
+
+ it("matches a path-alias import by its unique exported class name", () => {
+ const results = migrate({
+ "app.ts": code`
+ import type { Client } from "riverqueue";
+ import { SortArgs } from "@/jobs";
+
+ declare const client: Client;
+ await client.insert(new SortArgs(["b"]));
+ `,
+ "jobs.ts": code`
+ import type { JobArgs } from "riverqueue";
+
+ export class SortArgs implements JobArgs {
+ kind = "sort";
+ constructor(public strings: string[]) {}
+ }
+ `,
+ });
+
+ expect(results.get("app.ts")?.output).toContain(
+ 'import { sort } from "@/jobs";'
+ );
+ expect(results.get("app.ts")?.output).toContain(
+ 'await client.insert(sort, { strings: ["b"] });'
+ );
+ });
+
+ it("flags an unknown class constructed in an insert call", () => {
+ expect(
+ migrateOne(code`
+ import type { Client } from "riverqueue";
+ import { SortArgs } from "./elsewhere.js";
+
+ declare const client: Client;
+ await client.insert(new SortArgs(["b"]));
+ `)
+ ).toContain(
+ "// TODO(riverqueue-0.1): `SortArgs` was not converted; insert a job definition with a plain args object\n" +
+ "await client.insert(new SortArgs"
+ );
+ });
+ });
+
+ describe("batch items", () => {
+ it("converts InsertManyParams and JobArgsObject", () => {
+ expect(
+ migrateOne(code`
+ import {
+ Client,
+ InsertManyParams,
+ JobArgsObject,
+ } from "riverqueue";
+
+ declare const client: Client;
+ declare const tx: unknown;
+
+ await client.insert(new JobArgsObject("send_email", { to: "a" }), { tx });
+ await client.insertMany(
+ [
+ new InsertManyParams(new JobArgsObject("send_email", { to: "b" }), {
+ priority: 2,
+ }),
+ new InsertManyParams(new JobArgsObject("email.digest", {})),
+ new JobArgsObject("email.digest", { weekly: true }),
+ ],
+ { tx }
+ );
+ `)
+ ).toBe(code`
+ import { Client, defineJob } from "riverqueue";
+
+ const sendEmailJob = defineJob({ kind: "send_email" });
+ const emailDigestJob = defineJob({ kind: "email.digest" });
+
+ declare const client: Client;
+ declare const tx: unknown;
+
+ await client.insert(sendEmailJob, { to: "a" }, { tx });
+ await client.insertMany(
+ [
+ { job: sendEmailJob, args: { to: "b" }, options: {
+ priority: 2,
+ } },
+ { job: emailDigestJob, args: {} },
+ { job: emailDigestJob, args: { weekly: true } },
+ ],
+ { tx }
+ );
+ `);
+ });
+
+ it("moves a TODO out of a rewritten batch item", () => {
+ expect(
+ migrateOne(code`
+ import { Client, InsertManyParams, JobArgsObject } from "riverqueue";
+
+ declare const client: Client;
+ declare const period: number;
+
+ await client.insertMany([
+ new InsertManyParams(new JobArgsObject("sort", {}), {
+ uniqueOpts: { byPeriod: period },
+ }),
+ ]);
+ `)
+ ).toBe(code`
+ import { Client, defineJob } from "riverqueue";
+
+ const sortJob = defineJob({ kind: "sort" });
+
+ declare const client: Client;
+ declare const period: number;
+
+ await client.insertMany([
+ // TODO(riverqueue-0.1): \`byPeriod\` was a number of seconds and is now a duration; check the value
+ { job: sortJob, args: {}, options: {
+ unique: { byPeriod: { seconds: period } },
+ } },
+ ]);
+ `);
+ });
+
+ it("flags forms it cannot convert", () => {
+ expect(
+ migrateOne(code`
+ import { Client, InsertManyParams, JobArgsObject } from "riverqueue";
+
+ declare const client: Client;
+ declare const kind: string;
+ declare const legacy: InsertManyParams[];
+
+ const args = new JobArgsObject("sort", { strings: [] });
+ await client.insert(new JobArgsObject(kind, {}));
+ await client.insertMany([new InsertManyParams(args, { priority: 2 })]);
+ `)
+ ).toBe(code`
+ import { Client, InsertManyParams, JobArgsObject } from "riverqueue";
+
+ declare const client: Client;
+ declare const kind: string;
+ // TODO(riverqueue-0.1): \`InsertManyParams\` was removed; use a job definition with a plain args object
+ declare const legacy: InsertManyParams[];
+
+ // TODO(riverqueue-0.1): pass \`defineJob({ kind: "sort" })\` and the args object \`{ strings: [] }\` to insert or insertMany
+ const args = new JobArgsObject("sort", { strings: [] });
+ // TODO(riverqueue-0.1): define this \`JobArgsObject\` kind with \`defineJob({ kind })\` and insert a plain args object
+ await client.insert(new JobArgsObject(kind, {}));
+ // TODO(riverqueue-0.1): replace \`InsertManyParams\` with a \`{ job, args, options }\` item built from a job definition
+ await client.insertMany([new InsertManyParams(args, { priority: 2 })]);
+ `);
+ });
+
+ it("uses a namespace import", () => {
+ expect(
+ migrateOne(code`
+ import * as river from "riverqueue";
+
+ declare const client: river.Client;
+
+ await client.insertMany([
+ new river.InsertManyParams(new river.JobArgsObject("sort", {})),
+ ]);
+ const state = river.JOB_STATE_AVAILABLE;
+ `)
+ ).toBe(code`
+ import * as river from "riverqueue";
+
+ const sortJob = river.defineJob({ kind: "sort" });
+
+ declare const client: river.Client;
+
+ await client.insertMany([
+ { job: sortJob, args: {} },
+ ]);
+ const state = river.JOB_STATE.available;
+ `);
+ });
+ });
+
+ describe("insert options", () => {
+ it("imports renamed option types under their 0.1 names", () => {
+ expect(
+ migrateOne(code`
+ import { Client, type ClientOpts } from "riverqueue";
+
+ declare const options: ClientOpts;
+ void Client;
+ `)
+ ).toBe(code`
+ import { Client, type ClientOptions as ClientOpts } from "riverqueue";
+
+ declare const options: ClientOpts;
+ void Client;
+ `);
+ });
+
+ it("renames uniqueOpts and converts byPeriod seconds", () => {
+ expect(
+ migrateOne(code`
+ import type { Client, InsertOpts, UniqueOpts } from "riverqueue";
+
+ declare const client: Client;
+ declare const args: never;
+ declare function period(): number;
+ declare const shared: UniqueOpts;
+
+ const defaults: InsertOpts = {
+ uniqueOpts: { byArgs: ["id"], byPeriod: 15 * 60, byQueue: true },
+ };
+ const unique = { byPeriod: 60 } satisfies UniqueOpts;
+ await client.insert(args, { uniqueOpts: { byPeriod: period() } });
+ await client.insert(args, { uniqueOpts: shared });
+ await client.insert(args, {
+ uniqueOpts: { byArgs: false, byPeriod: Temporal.Duration.from({ hours: 1 }) },
+ });
+ const unrelated = { uniqueOpts: true };
+ `)
+ ).toBe(code`
+ import type {
+ Client,
+ InsertOptions as InsertOpts,
+ UniqueOptions as UniqueOpts,
+ } from "riverqueue";
+
+ declare const client: Client;
+ declare const args: never;
+ declare function period(): number;
+ declare const shared: UniqueOpts;
+
+ const defaults: InsertOpts = {
+ unique: { byArgs: ["id"], byPeriod: { seconds: 15 * 60 }, byQueue: true },
+ };
+ const unique = { byPeriod: { seconds: 60 } } satisfies UniqueOpts;
+ // TODO(riverqueue-0.1): \`byPeriod\` was a number of seconds and is now a duration; check the value
+ await client.insert(args, { unique: { byPeriod: { seconds: period() } } });
+ // TODO(riverqueue-0.1): \`unique.byPeriod\` is now a duration such as \`{ seconds: 60 }\`, not a number of seconds
+ await client.insert(args, { unique: shared });
+ await client.insert(args, {
+ // TODO(riverqueue-0.1): \`byArgs\` takes \`true\` or a list of fields; omit it instead of passing false
+ unique: { byArgs: false, byPeriod: Temporal.Duration.from({ hours: 1 }) },
+ });
+ // TODO(riverqueue-0.1): if these are River insert options, rename \`uniqueOpts\` to \`unique\` (\`byPeriod\` is now a duration)
+ const unrelated = { uniqueOpts: true };
+ `);
+ });
+
+ it("moves the client schema option to PgDriver", () => {
+ expect(
+ migrateOne(code`
+ import { Client } from "riverqueue";
+ import { PgDriver } from "@riverqueue/driver-pg";
+
+ declare const pool: never;
+ declare const options: { schema: string };
+
+ export const client = new Client(new PgDriver(pool), { schema: "river" });
+ export const other = new Client(new PgDriver(pool), options);
+ `)
+ ).toBe(code`
+ import { Client } from "riverqueue";
+ import { PgDriver } from "@riverqueue/driver-pg";
+
+ declare const pool: never;
+ declare const options: { schema: string };
+
+ export const client = new Client(new PgDriver(pool, { schema: "river" }));
+ // TODO(riverqueue-0.1): the \`schema\` client option moved to the driver: \`new PgDriver(pool, { schema })\`
+ export const other = new Client(new PgDriver(pool), options);
+ `);
+ });
+ });
+
+ describe("results and rows", () => {
+ it("replaces uniqueSkippedAsDuplicated with the status", () => {
+ expect(
+ migrateOne(code`
+ import type { InsertResult } from "riverqueue";
+
+ declare const result: InsertResult;
+ declare const maybe: InsertResult | undefined;
+
+ if (result.uniqueSkippedAsDuplicated) console.log("duplicate");
+ if (!result.uniqueSkippedAsDuplicated) console.log("inserted");
+ if (!maybe?.uniqueSkippedAsDuplicated) console.log("maybe inserted");
+ const same = result.uniqueSkippedAsDuplicated === true;
+ const flags = [result.uniqueSkippedAsDuplicated && true];
+ const { uniqueSkippedAsDuplicated } = result;
+ `)
+ ).toBe(code`
+ import type { InsertResult } from "riverqueue";
+
+ declare const result: InsertResult;
+ declare const maybe: InsertResult | undefined;
+
+ if (result.status === "duplicate") console.log("duplicate");
+ if (result.status === "inserted") console.log("inserted");
+ if (maybe?.status !== "duplicate") console.log("maybe inserted");
+ const same = (result.status === "duplicate") === true;
+ const flags = [result.status === "duplicate" && true];
+ // TODO(riverqueue-0.1): replace \`uniqueSkippedAsDuplicated\` with \`status === "duplicate"\`
+ const { uniqueSkippedAsDuplicated } = result;
+ `);
+ });
+
+ it("flags job IDs and timestamps without changing them", () => {
+ expect(
+ migrateOne(code`
+ import type { InsertResult } from "riverqueue";
+
+ declare const result: InsertResult;
+
+ const id: number = result.job.id;
+ console.log(\`job \${result.job.id}\`, result.job.id.toString());
+ const age = Date.now() - result.job.createdAt.getTime();
+ const at = \`
+ \${result.job.scheduledAt}
+ \`;
+ `)
+ ).toBe(code`
+ import type { InsertResult } from "riverqueue";
+
+ declare const result: InsertResult;
+
+ // TODO(riverqueue-0.1): \`JobRow.id\` is now a \`bigint\`; review number annotations, arithmetic, and JSON serialization
+ const id: number = result.job.id;
+ console.log(\`job \${result.job.id}\`, result.job.id.toString());
+ // TODO(riverqueue-0.1): \`JobRow.createdAt\` is now a \`Temporal.Instant\`, not a \`Date\`
+ const age = Date.now() - result.job.createdAt.getTime();
+ // TODO(riverqueue-0.1): \`JobRow.scheduledAt\` is now a \`Temporal.Instant\`, not a \`Date\`
+ const at = \`
+ \${result.job.scheduledAt}
+ \`;
+ `);
+ });
+
+ it("leaves files that do not use riverqueue alone", () => {
+ const input = code`
+ const job = { id: 1, createdAt: new Date() };
+ console.log(job.id, { uniqueOpts: 1 });
+ `;
+ const results = migrate({ "other.ts": input });
+
+ expect(results.get("other.ts")).toMatchObject({
+ changed: false,
+ output: input,
+ sites: [],
+ });
+ });
+ });
+
+ describe("imports", () => {
+ it("replaces JOB_STATE constants", () => {
+ expect(
+ migrateOne(code`
+ import { JOB_STATE_AVAILABLE, JOB_STATE_RUNNING, type JobState } from "riverqueue";
+
+ const states: JobState[] = [JOB_STATE_AVAILABLE, JOB_STATE_RUNNING];
+ const labels = { JOB_STATE_AVAILABLE };
+ `)
+ ).toBe(code`
+ import { JOB_STATE, type JobState } from "riverqueue";
+
+ const states: JobState[] = [JOB_STATE.available, JOB_STATE.running];
+ const labels = { JOB_STATE_AVAILABLE: JOB_STATE.available };
+ `);
+ });
+
+ it("keeps a still-referenced import and flags removed exports", () => {
+ expect(
+ migrateOne(code`
+ import {
+ Client,
+ type Driver,
+ type JobArgs,
+ uniqueBitmaskFromStates,
+ } from "riverqueue";
+
+ export function enqueue(client: Client, args: JobArgs, driver: Driver) {
+ return uniqueBitmaskFromStates(["available"]);
+ }
+ `)
+ ).toBe(code`
+ // TODO(riverqueue-0.1): \`Driver\` is no longer exported: drivers now implement \`riverqueue/unstable-driver\`
+ // TODO(riverqueue-0.1): \`uniqueBitmaskFromStates\` is no longer exported: it moved to \`riverqueue/unstable-driver\`
+ import {
+ Client,
+ type Driver,
+ type JobArgs,
+ uniqueBitmaskFromStates,
+ } from "riverqueue";
+
+ // TODO(riverqueue-0.1): \`JobArgs\` was removed; accept a \`JobDefinition\` and its args, or an \`InsertManyItem\`
+ export function enqueue(client: Client, args: JobArgs, driver: Driver) {
+ return uniqueBitmaskFromStates(["available"]);
+ }
+ `);
+ });
+
+ it("preserves CRLF line endings", () => {
+ const input = code`
+ import type { JobArgs } from "riverqueue";
+
+ class PingArgs implements JobArgs {
+ kind = "ping";
+ }
+ `.replaceAll("\n", "\r\n");
+
+ expect(migrateOne(input)).toBe(
+ code`
+ import { defineJob } from "riverqueue";
+
+ const ping = defineJob({
+ kind: "ping",
+ });
+ `.replaceAll("\n", "\r\n")
+ );
+ });
+ });
+
+ it("migrates the riverqueue 0.1 fixture to the recorded output", () => {
+ const fixture = new URL("../../fixtures/migration-0.1/", import.meta.url);
+ const results = migrate({
+ "consumer.ts": readFileSync(new URL("before.ts.txt", fixture), "utf8"),
+ });
+
+ expect(results.get("consumer.ts")?.output).toBe(
+ readFileSync(new URL("codemod.ts.txt", fixture), "utf8")
+ );
+ expect(results.get("consumer.ts")?.sites).toEqual([
+ {
+ line: 19,
+ message:
+ "`JobRow.id` is now a `bigint`; review number annotations, arithmetic, and JSON serialization",
+ },
+ ]);
+ });
+});
diff --git a/js/cli/src/codemod.ts b/js/cli/src/codemod.ts
new file mode 100644
index 000000000..7f14f365d
--- /dev/null
+++ b/js/cli/src/codemod.ts
@@ -0,0 +1,2114 @@
+/**
+ * Source-to-source migration from the `riverqueue@0.1.0` insert-only client
+ * to the current job-definition API.
+ *
+ * The codemod rewrites only patterns with one unambiguous meaning: argument
+ * classes whose serialized shape is exactly their constructor parameter
+ * properties, `JobArgsObject`, `InsertManyParams`, renamed option types,
+ * the `uniqueOpts` rename, `byPeriod` seconds, `uniqueSkippedAsDuplicated`,
+ * and the `JOB_STATE_*` constants. Everything else gets a `TODO(riverqueue-0.1):` comment so the
+ * remaining sites line up with the compiler errors they produce. It never
+ * guesses at ID arithmetic, timestamp handling, or string formatting.
+ *
+ * The TypeScript compiler API is passed in rather than imported so the CLI
+ * can use whichever compatible `typescript` the project being migrated has
+ * installed. Syntax kinds differ between TypeScript versions, so this module
+ * reads every kind from that instance.
+ */
+
+import { dirname, extname, resolve } from "node:path";
+
+import type * as TS from "typescript";
+
+import { SourceEdits } from "./codemod-edits.js";
+
+/** The `typescript` module namespace. */
+export type TypeScriptApi = typeof TS;
+
+/** Prefix of every comment the codemod leaves at a site it cannot rewrite. */
+export const TODO_MARKER = "TODO(riverqueue-0.1):";
+
+/** One file to migrate. */
+export interface CodemodSource {
+ /** Absolute path, used for parsing mode and relative import resolution. */
+ readonly path: string;
+ /** Current contents. */
+ readonly text: string;
+}
+
+/** A site left for manual review, marked with {@link TODO_MARKER}. */
+interface CodemodSite {
+ /** One-based line in the migrated text of the code the comment marks. */
+ readonly line: number;
+ /** The comment's text after the marker. */
+ readonly message: string;
+}
+
+export interface CodemodFileResult {
+ /** Whether {@link CodemodFileResult.output} differs from the input. */
+ readonly changed: boolean;
+ /** Migrated contents. */
+ readonly output: string;
+ /**
+ * Why the file could not be migrated, if it could not; `output` is then
+ * the unchanged input.
+ */
+ readonly error?: string;
+ /** Absolute path of the file. */
+ readonly path: string;
+ /** Every marked site in the migrated text, including earlier runs' marks. */
+ readonly sites: readonly CodemodSite[];
+}
+
+const RIVER_MODULE = "riverqueue";
+const JOB_ROW_TIMESTAMPS = new Set([
+ "attemptedAt",
+ "createdAt",
+ "finalizedAt",
+ "scheduledAt",
+]);
+const JOB_STATE_PREFIX = "JOB_STATE_";
+const CLIENT_SCHEMA_MESSAGE =
+ "the `schema` client option moved to the driver: `new PgDriver(pool, { schema })`";
+const BY_PERIOD_MESSAGE =
+ "`byPeriod` was a number of seconds and is now a duration; check the value";
+const UNIQUE_OPTIONS_MESSAGE =
+ "`unique.byPeriod` is now a duration such as `{ seconds: 60 }`, not a number of seconds";
+/** 0.1 exports replaced by a rewrite; the import is dropped once unused. */
+const REPLACED_EXPORTS = new Set([
+ "InsertManyParams",
+ "JobArgs",
+ "JobArgsObject",
+]);
+/** 0.1 exports with no direct replacement, and what to do instead. */
+const REMOVED_EXPORTS: ReadonlyMap = new Map([
+ ["Driver", "drivers now implement `riverqueue/unstable-driver`"],
+ ["DriverOptions", "drivers now implement `riverqueue/unstable-driver`"],
+ ["JobInsertParams", "drivers now implement `riverqueue/unstable-driver`"],
+ ["uniqueBitmaskFromStates", "it moved to `riverqueue/unstable-driver`"],
+ ["uniqueBitmaskToStates", "it moved to `riverqueue/unstable-driver`"],
+]);
+/**
+ * 0.1 exports under a new name. Imports keep their local name, so the
+ * code using them needs no change.
+ */
+const RENAMED_EXPORTS: ReadonlyMap = new Map([
+ ["ClientOpts", "ClientOptions"],
+ ["InsertOpts", "InsertOptions"],
+ ["UniqueOpts", "UniqueOptions"],
+]);
+const RESERVED_WORDS = new Set(
+ (
+ "arguments await break case catch class const continue debugger default " +
+ "delete do else enum eval export extends false finally for function if " +
+ "implements import in instanceof interface let new null package private " +
+ "protected public return static super switch this throw true try typeof " +
+ "undefined var void while with yield"
+ ).split(" ")
+);
+/** Extensions of the files the codemod parses. */
+export const SOURCE_EXTENSIONS: readonly string[] = [
+ ".ts",
+ ".tsx",
+ ".mts",
+ ".cts",
+ ".js",
+ ".jsx",
+ ".mjs",
+ ".cjs",
+];
+
+/**
+ * Migrate a set of files together. Argument classes are resolved across the
+ * set, so `new SortArgs(...)` in one file is rewritten against the class
+ * declared in another when both are processed.
+ */
+export function migrateSources(
+ ts: TypeScriptApi,
+ sources: readonly CodemodSource[]
+): CodemodFileResult[] {
+ const files = sources.map((source) => parseFile(ts, source));
+ const byPath = new Map(files.map((file) => [file.path, file]));
+ const project = new Project(ts, files, byPath);
+ return files.map((file) => {
+ try {
+ return new FileMigration(ts, project, file).run();
+ } catch (error: unknown) {
+ return {
+ changed: false,
+ error: error instanceof Error ? error.message : String(error),
+ output: file.text,
+ path: file.path,
+ sites: [],
+ };
+ }
+ });
+}
+
+interface ParsedFile {
+ /** Job argument classes declared at the top level, by class name. */
+ readonly classes: Map;
+ readonly isTypeScript: boolean;
+ readonly newline: string;
+ readonly path: string;
+ /** Local names bound by named imports from `riverqueue` to imported names. */
+ readonly riverImports: Map;
+ /** Local name of `import * as name from "riverqueue"`, if any. */
+ readonly riverNamespace: string | undefined;
+ readonly sourceFile: TS.SourceFile;
+ /** Identifier texts that name bindings or references in the file. */
+ readonly taken: Set;
+ readonly text: string;
+}
+
+interface JobParam {
+ readonly comments: readonly string[];
+ readonly name: string;
+ readonly optional: boolean;
+ readonly type: string | undefined;
+}
+
+interface ConvertibleJobClass {
+ readonly className: string;
+ readonly convertible: true;
+ readonly declaration: TS.ClassDeclaration;
+ readonly defaults: TS.Expression | undefined;
+ readonly defaultsComments: readonly string[];
+ readonly file: ParsedFile;
+ /** Source text of the `kind` literal, keeping its quote style. */
+ readonly kind: string;
+ readonly kindComments: readonly string[];
+ readonly memberIndent: string;
+ readonly params: readonly JobParam[];
+}
+
+interface UnconvertibleJobClass {
+ readonly className: string;
+ readonly convertible: false;
+ readonly declaration: TS.ClassDeclaration;
+ readonly file: ParsedFile;
+ readonly reason: string;
+}
+
+type JobClass = ConvertibleJobClass | UnconvertibleJobClass;
+
+/** How an exported name reaches a job class, and whether it changes. */
+interface ResolvedExport {
+ readonly jobClass: JobClass;
+ /** Whether the exported name follows the class name to its definition name. */
+ readonly renamed: boolean;
+}
+
+function parseFile(ts: TypeScriptApi, source: CodemodSource): ParsedFile {
+ const extension = extname(source.path).toLowerCase();
+ const scriptKind =
+ extension === ".tsx"
+ ? ts.ScriptKind.TSX
+ : extension === ".jsx"
+ ? ts.ScriptKind.JSX
+ : [".js", ".mjs", ".cjs"].includes(extension)
+ ? ts.ScriptKind.JS
+ : ts.ScriptKind.TS;
+ const sourceFile = ts.createSourceFile(
+ source.path,
+ source.text,
+ ts.ScriptTarget.Latest,
+ true,
+ scriptKind
+ );
+ const riverImports = new Map();
+ let riverNamespace: string | undefined;
+ for (const statement of sourceFile.statements) {
+ if (!isRiverImport(ts, statement)) continue;
+ const bindings = statement.importClause?.namedBindings;
+ if (bindings === undefined) continue;
+ if (ts.isNamespaceImport(bindings)) {
+ riverNamespace = bindings.name.text;
+ } else {
+ for (const element of bindings.elements) {
+ riverImports.set(
+ element.name.text,
+ (element.propertyName ?? element.name).text
+ );
+ }
+ }
+ }
+ const taken = new Set();
+ const collect = (node: TS.Node): void => {
+ if (ts.isIdentifier(node) && !isPropertyName(ts, node)) {
+ taken.add(node.text);
+ }
+ ts.forEachChild(node, collect);
+ };
+ collect(sourceFile);
+
+ const file: ParsedFile = {
+ classes: new Map(),
+ isTypeScript:
+ scriptKind === ts.ScriptKind.TS || scriptKind === ts.ScriptKind.TSX,
+ newline: source.text.includes("\r\n") ? "\r\n" : "\n",
+ path: source.path,
+ riverImports,
+ riverNamespace,
+ sourceFile,
+ taken,
+ text: source.text,
+ };
+ for (const statement of sourceFile.statements) {
+ if (
+ ts.isClassDeclaration(statement) &&
+ statement.name !== undefined &&
+ implementsJobArgs(ts, file, statement)
+ ) {
+ file.classes.set(
+ statement.name.text,
+ analyzeClass(ts, file, statement, statement.name.text)
+ );
+ }
+ }
+ return file;
+}
+
+function isRiverImport(
+ ts: TypeScriptApi,
+ node: TS.Node
+): node is TS.ImportDeclaration {
+ return (
+ ts.isImportDeclaration(node) &&
+ ts.isStringLiteral(node.moduleSpecifier) &&
+ node.moduleSpecifier.text === RIVER_MODULE
+ );
+}
+
+/** Whether `identifier` names a property rather than a binding or reference. */
+function isPropertyName(ts: TypeScriptApi, identifier: TS.Identifier): boolean {
+ const parent = identifier.parent;
+ return (
+ ((ts.isPropertyAccessExpression(parent) ||
+ ts.isPropertyAssignment(parent) ||
+ ts.isPropertyDeclaration(parent) ||
+ ts.isPropertySignature(parent) ||
+ ts.isMethodDeclaration(parent) ||
+ ts.isMethodSignature(parent) ||
+ ts.isGetAccessorDeclaration(parent) ||
+ ts.isSetAccessorDeclaration(parent) ||
+ ts.isEnumMember(parent)) &&
+ parent.name === identifier) ||
+ (ts.isQualifiedName(parent) && parent.right === identifier) ||
+ ((ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent)) &&
+ parent.propertyName === identifier) ||
+ (ts.isBindingElement(parent) && parent.propertyName === identifier)
+ );
+}
+
+/** Resolve `expression` to the name it imports from `riverqueue`, if any. */
+function riverName(
+ ts: TypeScriptApi,
+ file: ParsedFile,
+ expression: TS.Node
+): string | undefined {
+ if (ts.isIdentifier(expression)) {
+ return file.riverImports.get(expression.text);
+ }
+ if (
+ ts.isPropertyAccessExpression(expression) &&
+ ts.isIdentifier(expression.expression) &&
+ expression.expression.text === file.riverNamespace
+ ) {
+ return expression.name.text;
+ }
+ return undefined;
+}
+
+function implementsJobArgs(
+ ts: TypeScriptApi,
+ file: ParsedFile,
+ declaration: TS.ClassDeclaration
+): boolean {
+ return (declaration.heritageClauses ?? []).some(
+ (clause) =>
+ clause.token === ts.SyntaxKind.ImplementsKeyword &&
+ clause.types.some(
+ (type) => riverName(ts, file, type.expression) === "JobArgs"
+ )
+ );
+}
+
+function analyzeClass(
+ ts: TypeScriptApi,
+ file: ParsedFile,
+ declaration: TS.ClassDeclaration,
+ className: string
+): JobClass {
+ const unconvertible = (reason: string): UnconvertibleJobClass => ({
+ className,
+ convertible: false,
+ declaration,
+ file,
+ reason,
+ });
+ const modifiers = ts.getModifiers(declaration) ?? [];
+ if (
+ modifiers.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword)
+ ) {
+ return unconvertible("it is a default export");
+ }
+ if (
+ modifiers.some(
+ (modifier) =>
+ modifier.kind === ts.SyntaxKind.AbstractKeyword ||
+ modifier.kind === ts.SyntaxKind.DeclareKeyword
+ ) ||
+ (ts.getDecorators(declaration) ?? []).length > 0
+ ) {
+ return unconvertible("it is abstract, declared, or decorated");
+ }
+ if (declaration.typeParameters !== undefined) {
+ return unconvertible("it has type parameters");
+ }
+ const heritage = declaration.heritageClauses ?? [];
+ if (
+ heritage.some(
+ (clause) =>
+ clause.token === ts.SyntaxKind.ExtendsKeyword ||
+ clause.types.length !== 1
+ )
+ ) {
+ return unconvertible("it extends a class or implements other interfaces");
+ }
+
+ let kind: string | undefined;
+ let kindComments: readonly string[] = [];
+ let memberIndent: string | undefined;
+ let defaults: TS.Expression | undefined;
+ let defaultsComments: readonly string[] = [];
+ let params: JobParam[] = [];
+ let toJson: TS.MethodDeclaration | undefined;
+ let sawConstructor = false;
+ for (const member of declaration.members) {
+ const name =
+ member.name !== undefined && ts.isIdentifier(member.name)
+ ? member.name.text
+ : undefined;
+ if (
+ ts.isPropertyDeclaration(member) &&
+ (name === "kind" || name === "insertOpts") &&
+ !(ts.getModifiers(member) ?? []).some(
+ (modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword
+ ) &&
+ (ts.getDecorators(member) ?? []).length === 0
+ ) {
+ if (name === "kind") {
+ const literal = stringLiteral(ts, member.initializer);
+ if (literal === undefined) {
+ return unconvertible("its `kind` is not a string literal");
+ }
+ kind = literal.getText();
+ kindComments = leadingComments(ts, file.text, member);
+ memberIndent = lineIndent(file.text, member.getStart());
+ } else if (member.initializer !== undefined) {
+ defaults = member.initializer;
+ defaultsComments = leadingComments(ts, file.text, member);
+ }
+ continue;
+ }
+ if (ts.isConstructorDeclaration(member) && !sawConstructor) {
+ sawConstructor = true;
+ if (member.body === undefined || member.body.statements.length > 0) {
+ return unconvertible("its constructor has a body");
+ }
+ const parsed = parseParameters(ts, file, member.parameters);
+ if (typeof parsed === "string") return unconvertible(parsed);
+ params = parsed;
+ continue;
+ }
+ if (
+ ts.isMethodDeclaration(member) &&
+ name === "toJSON" &&
+ toJson === undefined
+ ) {
+ toJson = member;
+ continue;
+ }
+ if (ts.isSemicolonClassElement(member)) continue;
+ return unconvertible(
+ "it has members other than `kind`, `insertOpts`, and constructor parameter properties"
+ );
+ }
+ if (kind === undefined) {
+ return unconvertible("it has no `kind` property initialized to a string");
+ }
+ if (toJson !== undefined && !isIdentityToJson(ts, toJson, params)) {
+ return unconvertible("its `toJSON` does not return exactly its parameters");
+ }
+ return {
+ className,
+ convertible: true,
+ declaration,
+ defaults,
+ defaultsComments,
+ file,
+ kind,
+ kindComments,
+ memberIndent: memberIndent ?? " ",
+ params,
+ };
+}
+
+function parseParameters(
+ ts: TypeScriptApi,
+ file: ParsedFile,
+ parameters: TS.NodeArray
+): JobParam[] | string {
+ const params: JobParam[] = [];
+ for (const parameter of parameters) {
+ const modifiers = ts.getModifiers(parameter) ?? [];
+ const isProperty = modifiers.some((modifier) =>
+ [
+ ts.SyntaxKind.PublicKeyword,
+ ts.SyntaxKind.PrivateKeyword,
+ ts.SyntaxKind.ProtectedKeyword,
+ ts.SyntaxKind.ReadonlyKeyword,
+ ].includes(modifier.kind)
+ );
+ if (!isProperty || !ts.isIdentifier(parameter.name)) {
+ return "a constructor parameter is not a parameter property";
+ }
+ if (
+ parameter.dotDotDotToken !== undefined ||
+ parameter.initializer !== undefined ||
+ (ts.getDecorators(parameter) ?? []).length > 0
+ ) {
+ return "a constructor parameter is a rest, default, or decorated parameter";
+ }
+ if (parameter.type === undefined && file.isTypeScript) {
+ return `constructor parameter \`${parameter.name.text}\` has no type annotation`;
+ }
+ params.push({
+ comments: leadingComments(ts, file.text, parameter),
+ name: parameter.name.text,
+ optional: parameter.questionToken !== undefined,
+ type: parameter.type?.getText(),
+ });
+ }
+ return params;
+}
+
+/** Whether `toJSON` returns `{ a: this.a, ... }` for exactly `params`. */
+function isIdentityToJson(
+ ts: TypeScriptApi,
+ method: TS.MethodDeclaration,
+ params: readonly JobParam[]
+): boolean {
+ const statements = method.body?.statements ?? [];
+ const statement = statements[0];
+ if (
+ method.parameters.length > 0 ||
+ statements.length !== 1 ||
+ statement === undefined ||
+ !ts.isReturnStatement(statement) ||
+ statement.expression === undefined
+ ) {
+ return false;
+ }
+ const expression = skipParentheses(ts, statement.expression);
+ if (!ts.isObjectLiteralExpression(expression)) return false;
+ const names = new Set();
+ for (const property of expression.properties) {
+ if (
+ !ts.isPropertyAssignment(property) ||
+ !ts.isIdentifier(property.name) ||
+ !ts.isPropertyAccessExpression(property.initializer) ||
+ property.initializer.expression.kind !== ts.SyntaxKind.ThisKeyword ||
+ property.initializer.name.text !== property.name.text
+ ) {
+ return false;
+ }
+ names.add(property.name.text);
+ }
+ return (
+ names.size === params.length && params.every(({ name }) => names.has(name))
+ );
+}
+
+/** The string literal `expression` is, ignoring `as const` and `satisfies`. */
+function stringLiteral(
+ ts: TypeScriptApi,
+ expression: TS.Expression | undefined
+): TS.NoSubstitutionTemplateLiteral | TS.StringLiteral | undefined {
+ if (expression === undefined) return undefined;
+ const current = skipOuterExpressions(ts, expression);
+ return ts.isStringLiteral(current) ||
+ ts.isNoSubstitutionTemplateLiteral(current)
+ ? current
+ : undefined;
+}
+
+function skipParentheses(
+ ts: TypeScriptApi,
+ expression: TS.Expression
+): TS.Expression {
+ let current = expression;
+ while (ts.isParenthesizedExpression(current)) current = current.expression;
+ return current;
+}
+
+/** Skip parentheses, `as const`, and `satisfies` around `expression`. */
+function skipOuterExpressions(
+ ts: TypeScriptApi,
+ expression: TS.Expression
+): TS.Expression {
+ let current = skipParentheses(ts, expression);
+ while (
+ (ts.isAsExpression(current) && current.type.getText() === "const") ||
+ ts.isSatisfiesExpression(current)
+ ) {
+ current = skipParentheses(ts, current.expression);
+ }
+ return current;
+}
+
+/** Full text of the comments immediately before `node`. */
+function leadingComments(
+ ts: TypeScriptApi,
+ text: string,
+ node: TS.Node
+): string[] {
+ return (ts.getLeadingCommentRanges(text, node.getFullStart()) ?? []).map(
+ (range) => text.slice(range.pos, range.end)
+ );
+}
+
+function lineStart(text: string, position: number): number {
+ return text.lastIndexOf("\n", position - 1) + 1;
+}
+
+function lineIndent(text: string, position: number): string {
+ const start = lineStart(text, position);
+ return /^[ \t]*/.exec(text.slice(start))?.[0] ?? "";
+}
+
+/** Cross-file knowledge: job classes, their definition names, and exports. */
+class Project {
+ readonly #byPath: ReadonlyMap;
+ readonly #definitionNames = new Map();
+ readonly #files: readonly ParsedFile[];
+ readonly #ts: TypeScriptApi;
+
+ constructor(
+ ts: TypeScriptApi,
+ files: readonly ParsedFile[],
+ byPath: ReadonlyMap
+ ) {
+ this.#ts = ts;
+ this.#files = files;
+ this.#byPath = byPath;
+ for (const file of files) {
+ for (const jobClass of file.classes.values()) {
+ if (!jobClass.convertible) continue;
+ const base =
+ definitionBaseName(jobClass.className) ||
+ `${camelCase(jobClass.kind.slice(1, -1))}Job`;
+ const name = claimName(file.taken, base, [`${base}Job`]);
+ this.#definitionNames.set(jobClass, name);
+ }
+ }
+ }
+
+ /** The module-level name of a converted class's definition. */
+ definitionName(jobClass: ConvertibleJobClass): string {
+ const name = this.#definitionNames.get(jobClass);
+ if (name === undefined) {
+ throw new Error(
+ `internal codemod error: no name for ${jobClass.className}`
+ );
+ }
+ return name;
+ }
+
+ /** Resolve an import or re-export of `name` from `specifier` in `from`. */
+ resolveImport(
+ from: ParsedFile,
+ specifier: string,
+ name: string
+ ): ResolvedExport | undefined {
+ const target = this.#resolveModule(from.path, specifier);
+ if (target !== undefined) return this.#resolveExport(target, name, 0);
+ if (specifier.startsWith(".") || specifier === RIVER_MODULE) {
+ return undefined;
+ }
+ // A path alias or package name this codemod cannot resolve: match an
+ // exported class of the same name when exactly one processed file has one.
+ const matches = this.#files.flatMap((file) => {
+ const jobClass = file.classes.get(name);
+ return jobClass !== undefined &&
+ hasModifier(
+ this.#ts,
+ jobClass.declaration,
+ this.#ts.SyntaxKind.ExportKeyword
+ )
+ ? [jobClass]
+ : [];
+ });
+ const [only] = matches;
+ return matches.length === 1 && only !== undefined
+ ? { jobClass: only, renamed: true }
+ : undefined;
+ }
+
+ #resolveExport(
+ file: ParsedFile,
+ name: string,
+ depth: number
+ ): ResolvedExport | undefined {
+ const ts = this.#ts;
+ if (depth > 16) return undefined;
+ const declared = file.classes.get(name);
+ if (
+ declared !== undefined &&
+ hasModifier(ts, declared.declaration, ts.SyntaxKind.ExportKeyword)
+ ) {
+ return { jobClass: declared, renamed: true };
+ }
+ for (const statement of file.sourceFile.statements) {
+ if (!ts.isExportDeclaration(statement) || statement.isTypeOnly) continue;
+ const module =
+ statement.moduleSpecifier !== undefined &&
+ ts.isStringLiteral(statement.moduleSpecifier)
+ ? statement.moduleSpecifier.text
+ : undefined;
+ const clause = statement.exportClause;
+ if (clause === undefined) {
+ if (module === undefined) continue;
+ const target = this.#resolveModule(file.path, module);
+ const resolved =
+ target === undefined
+ ? undefined
+ : this.#resolveExport(target, name, depth + 1);
+ if (resolved !== undefined) return resolved;
+ continue;
+ }
+ if (!ts.isNamedExports(clause)) continue;
+ for (const element of clause.elements) {
+ if (element.name.text !== name || element.isTypeOnly) continue;
+ const local = (element.propertyName ?? element.name).text;
+ const aliased = element.propertyName !== undefined;
+ let resolved: ResolvedExport | undefined;
+ if (module === undefined) {
+ const jobClass = file.classes.get(local);
+ resolved =
+ jobClass === undefined ? undefined : { jobClass, renamed: true };
+ } else {
+ const target = this.#resolveModule(file.path, module);
+ resolved =
+ target === undefined
+ ? undefined
+ : this.#resolveExport(target, local, depth + 1);
+ }
+ if (resolved !== undefined) {
+ return {
+ jobClass: resolved.jobClass,
+ renamed: resolved.renamed && !aliased,
+ };
+ }
+ }
+ }
+ return undefined;
+ }
+
+ #resolveModule(from: string, specifier: string): ParsedFile | undefined {
+ if (!specifier.startsWith(".")) return undefined;
+ const base = resolve(dirname(from), specifier);
+ const extension = extname(base);
+ const stem = base.slice(0, base.length - extension.length);
+ const candidates = [
+ base,
+ ...(
+ {
+ ".cjs": [".cts"],
+ ".js": [".ts", ".tsx"],
+ ".jsx": [".tsx"],
+ ".mjs": [".mts"],
+ }[extension] ?? []
+ ).map((replacement) => stem + replacement),
+ ...SOURCE_EXTENSIONS.map((candidate) => base + candidate),
+ ...SOURCE_EXTENSIONS.map((candidate) =>
+ resolve(base, `index${candidate}`)
+ ),
+ ];
+ for (const candidate of candidates) {
+ const file = this.#byPath.get(candidate);
+ if (file !== undefined) return file;
+ }
+ return undefined;
+ }
+}
+
+function hasModifier(
+ ts: TypeScriptApi,
+ node: TS.HasModifiers,
+ kind: TS.SyntaxKind
+): boolean {
+ return (ts.getModifiers(node) ?? []).some(
+ (modifier) => modifier.kind === kind
+ );
+}
+
+/** `SortArgs` becomes `sort`, `URLFetchArgs` becomes `urlFetch`. */
+function definitionBaseName(className: string): string {
+ const base = className.replace(/Args$/, "");
+ const leading = /^[A-Z]+(?=[A-Z][a-z]|\d|$)/.exec(base)?.[0];
+ if (leading !== undefined) {
+ return leading.toLowerCase() + base.slice(leading.length);
+ }
+ return base.charAt(0).toLowerCase() + base.slice(1);
+}
+
+/** `send_email` becomes `sendEmail`. */
+function camelCase(kind: string): string {
+ const words = kind.split(/[^A-Za-z0-9]+/).filter((word) => word !== "");
+ const joined = words
+ .map((word, index) =>
+ index === 0
+ ? word.charAt(0).toLowerCase() + word.slice(1)
+ : word.charAt(0).toUpperCase() + word.slice(1)
+ )
+ .join("");
+ return joined === "" || /^\d/.test(joined) ? `job${joined}` : joined;
+}
+
+/** Take the first free name among `base`, `fallbacks`, and numbered forms. */
+function claimName(
+ taken: Set,
+ base: string,
+ fallbacks: readonly string[] = []
+): string {
+ const free = (name: string): boolean =>
+ !taken.has(name) && !RESERVED_WORDS.has(name);
+ let name = [base, ...fallbacks].find(free);
+ const last = fallbacks.at(-1) ?? base;
+ for (let suffix = 2; name === undefined; suffix++) {
+ if (free(`${last}${suffix}`)) name = `${last}${suffix}`;
+ }
+ taken.add(name);
+ return name;
+}
+
+/** Where a `new` expression sits relative to insertion calls. */
+type InsertPosition = "element" | "insert" | "other" | "params";
+
+/** The pieces of a legacy job argument expression. */
+interface JobArgsParts {
+ readonly args: string;
+ readonly definition: string;
+}
+
+interface NamedImportDeclaration {
+ readonly bindings: TS.NamedImports;
+ readonly clause: TS.ImportClause;
+ readonly statement: TS.ImportDeclaration;
+}
+
+interface PendingTodo {
+ readonly message: string;
+ readonly node: TS.Node;
+ /**
+ * Skip the comment when an existing marker comment above the line already
+ * mentions this text, for messages whose wording depends on which files a
+ * run processed.
+ */
+ readonly unlessMentioned: string | undefined;
+}
+
+/** Rewrites one file against the shared {@link Project}. */
+class FileMigration {
+ readonly #edits: SourceEdits;
+ readonly #file: ParsedFile;
+ /** Hoisted unchecked definitions for `JobArgsObject` kinds. */
+ readonly #hoisted = new Map<
+ string,
+ { readonly literal: string; readonly name: string }
+ >();
+ /** Local names bound to job classes, and their definition names here. */
+ readonly #localClasses = new Map<
+ string,
+ { readonly definition: string | undefined; readonly jobClass: JobClass }
+ >();
+ /** Object literals known to be insert options. */
+ readonly #optionObjects = new Set();
+ readonly #project: Project;
+ /** Import and export specifiers to rename, with their new text. */
+ readonly #specifierRenames = new Map();
+ readonly #todos: PendingTodo[] = [];
+ readonly #ts: TypeScriptApi;
+ /** Object literals known to be unique options. */
+ readonly #uniqueObjects = new Set();
+ #usesDefineJob = false;
+ #usesJobState = false;
+
+ constructor(ts: TypeScriptApi, project: Project, file: ParsedFile) {
+ this.#ts = ts;
+ this.#project = project;
+ this.#file = file;
+ this.#edits = new SourceEdits(file.text);
+ }
+
+ get #isRiverFile(): boolean {
+ return (
+ this.#file.riverImports.size > 0 ||
+ this.#file.riverNamespace !== undefined ||
+ this.#localClasses.size > 0
+ );
+ }
+
+ run(): CodemodFileResult {
+ this.#bindClasses();
+ this.#collectOptionObjects(this.#file.sourceFile);
+ this.#visit(this.#file.sourceFile);
+ this.#flagRemainingReferences();
+ const keptImports = this.#rewriteImports();
+ this.#insertHoisted(keptImports);
+ this.#insertTodos();
+ const output = this.#edits.apply();
+ return {
+ changed: output !== this.#file.text,
+ output,
+ path: this.#file.path,
+ sites: collectSites(output),
+ };
+ }
+
+ /** Bind local and imported class names, and plan specifier renames. */
+ #bindClasses(): void {
+ const ts = this.#ts;
+ const file = this.#file;
+ for (const [name, jobClass] of file.classes) {
+ this.#localClasses.set(name, {
+ definition: jobClass.convertible
+ ? this.#project.definitionName(jobClass)
+ : undefined,
+ jobClass,
+ });
+ }
+ for (const statement of file.sourceFile.statements) {
+ if (
+ ts.isImportDeclaration(statement) &&
+ ts.isStringLiteral(statement.moduleSpecifier) &&
+ statement.importClause?.namedBindings !== undefined &&
+ ts.isNamedImports(statement.importClause.namedBindings)
+ ) {
+ for (const element of statement.importClause.namedBindings.elements) {
+ const imported = (element.propertyName ?? element.name).text;
+ const resolved = this.#project.resolveImport(
+ file,
+ statement.moduleSpecifier.text,
+ imported
+ );
+ if (resolved === undefined) continue;
+ const { jobClass } = resolved;
+ if (!jobClass.convertible) {
+ this.#localClasses.set(element.name.text, {
+ definition: undefined,
+ jobClass,
+ });
+ continue;
+ }
+ const definition = this.#project.definitionName(jobClass);
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports
+ if (element.isTypeOnly || statement.importClause.isTypeOnly) {
+ // A type-only import cannot construct the class; its remaining
+ // type references are flagged.
+ this.#localClasses.set(element.name.text, { definition, jobClass });
+ continue;
+ }
+ const exported = resolved.renamed ? definition : imported;
+ let local = element.name.text;
+ if (element.propertyName === undefined && resolved.renamed) {
+ file.taken.delete(local);
+ local = claimName(file.taken, definition, [`${definition}Job`]);
+ }
+ this.#localClasses.set(element.name.text, {
+ definition: local,
+ jobClass,
+ });
+ const text = exported === local ? local : `${exported} as ${local}`;
+ if (text !== element.getText()) {
+ this.#specifierRenames.set(element, text);
+ }
+ }
+ }
+ if (
+ ts.isExportDeclaration(statement) &&
+ !statement.isTypeOnly &&
+ statement.exportClause !== undefined &&
+ ts.isNamedExports(statement.exportClause)
+ ) {
+ const module =
+ statement.moduleSpecifier !== undefined &&
+ ts.isStringLiteral(statement.moduleSpecifier)
+ ? statement.moduleSpecifier.text
+ : undefined;
+ for (const element of statement.exportClause.elements) {
+ if (element.isTypeOnly) continue;
+ const local = (element.propertyName ?? element.name).text;
+ let jobClass: JobClass | undefined;
+ let renamed = true;
+ if (module === undefined) {
+ jobClass = file.classes.get(local);
+ } else {
+ const resolved = this.#project.resolveImport(file, module, local);
+ jobClass = resolved?.jobClass;
+ renamed = resolved?.renamed ?? false;
+ }
+ if (jobClass === undefined || !jobClass.convertible) continue;
+ const definition = this.#project.definitionName(jobClass);
+ const source = module === undefined || renamed ? definition : local;
+ const exported =
+ element.propertyName === undefined ? source : element.name.text;
+ const text =
+ source === exported ? source : `${source} as ${exported}`;
+ if (text !== element.getText()) {
+ this.#specifierRenames.set(element, text);
+ }
+ }
+ }
+ }
+ }
+
+ #collectOptionObjects(node: TS.Node): void {
+ const ts = this.#ts;
+ const addOptions = (expression: TS.Expression | undefined): void => {
+ if (expression === undefined) return;
+ const object = skipParentheses(ts, expression);
+ if (ts.isObjectLiteralExpression(object)) this.#optionObjects.add(object);
+ };
+ if (ts.isCallExpression(node) && calleeName(ts, node) === "insert") {
+ const [, second, third] = node.arguments;
+ addOptions(node.arguments.length === 2 ? second : third);
+ }
+ if (
+ ts.isNewExpression(node) &&
+ riverName(ts, this.#file, node.expression) === "InsertManyParams"
+ ) {
+ addOptions(node.arguments?.[1]);
+ }
+ if (
+ ts.isPropertyDeclaration(node) &&
+ ts.isIdentifier(node.name) &&
+ node.name.text === "insertOpts" &&
+ ts.isClassDeclaration(node.parent) &&
+ node.parent.name !== undefined &&
+ this.#file.classes.has(node.parent.name.text)
+ ) {
+ addOptions(node.initializer);
+ }
+ const typed = this.#declaredRiverType(node);
+ if (typed?.type === "InsertOpts") addOptions(typed.expression);
+ if (typed?.type === "UniqueOpts") {
+ const object = skipParentheses(ts, typed.expression);
+ if (ts.isObjectLiteralExpression(object)) this.#uniqueObjects.add(object);
+ }
+ ts.forEachChild(node, (child) => this.#collectOptionObjects(child));
+ }
+
+ /** `const x: InsertOpts = {...}` and `{...} satisfies InsertOpts`. */
+ #declaredRiverType(
+ node: TS.Node
+ ): { readonly expression: TS.Expression; readonly type: string } | undefined {
+ const ts = this.#ts;
+ let typeNode: TS.TypeNode | undefined;
+ let expression: TS.Expression | undefined;
+ if (ts.isVariableDeclaration(node)) {
+ typeNode = node.type;
+ expression = node.initializer;
+ } else if (ts.isSatisfiesExpression(node) || ts.isAsExpression(node)) {
+ typeNode = node.type;
+ expression = node.expression;
+ }
+ if (
+ typeNode === undefined ||
+ expression === undefined ||
+ !ts.isTypeReferenceNode(typeNode)
+ ) {
+ return undefined;
+ }
+ const typeName = typeNode.typeName;
+ const name = ts.isIdentifier(typeName)
+ ? this.#file.riverImports.get(typeName.text)
+ : ts.isIdentifier(typeName.left) &&
+ typeName.left.text === this.#file.riverNamespace
+ ? typeName.right.text
+ : undefined;
+ return name === undefined ? undefined : { expression, type: name };
+ }
+
+ /** Post-order traversal, so inner rewrites exist before outer ones. */
+ #visit(node: TS.Node): void {
+ const ts = this.#ts;
+ ts.forEachChild(node, (child) => this.#visit(child));
+ if (ts.isClassDeclaration(node)) this.#visitClass(node);
+ else if (ts.isNewExpression(node)) this.#visitNew(node);
+ else if (ts.isPropertyAccessExpression(node))
+ this.#visitPropertyAccess(node);
+ else if (
+ ts.isPrefixUnaryExpression(node) &&
+ node.operator === ts.SyntaxKind.ExclamationToken
+ ) {
+ this.#visitNegation(node);
+ } else if (
+ ts.isPropertyAssignment(node) ||
+ ts.isShorthandPropertyAssignment(node)
+ ) {
+ this.#visitProperty(node);
+ } else if (ts.isIdentifier(node)) this.#visitIdentifier(node);
+ else if (
+ ts.isBindingElement(node) &&
+ (node.propertyName ?? node.name).getText() === "uniqueSkippedAsDuplicated"
+ ) {
+ this.#todo(
+ node,
+ 'replace `uniqueSkippedAsDuplicated` with `status === "duplicate"`'
+ );
+ }
+ }
+
+ #visitClass(node: TS.ClassDeclaration): void {
+ if (node.name === undefined) return;
+ const jobClass = this.#file.classes.get(node.name.text);
+ if (jobClass?.declaration !== node) return;
+ if (!jobClass.convertible) {
+ this.#todo(
+ node,
+ `convert \`${jobClass.className}\` to \`defineJob\` by hand: ${jobClass.reason}`
+ );
+ return;
+ }
+ const definition = this.#project.definitionName(jobClass);
+ this.#edits.replace(
+ node.getStart(),
+ node.getEnd(),
+ this.#definitionText(jobClass, definition)
+ );
+ }
+
+ #definitionText(jobClass: ConvertibleJobClass, name: string): string {
+ const ts = this.#ts;
+ const newline = this.#file.newline;
+ const indent = lineIndent(this.#file.text, jobClass.declaration.getStart());
+ const member = jobClass.memberIndent;
+ const exported = hasModifier(
+ ts,
+ jobClass.declaration,
+ ts.SyntaxKind.ExportKeyword
+ )
+ ? "export "
+ : "";
+ const head = `${exported}const ${name} = ${this.#defineJobReference()}`;
+ let call: string;
+ if (jobClass.params.length === 0) {
+ call = `${head}({`;
+ } else {
+ const property = (param: JobParam): string =>
+ `${param.name}${param.optional ? "?" : ""}: ${param.type ?? "unknown"}`;
+ const inline = `${head}<{ ${jobClass.params.map(property).join("; ")} }>()({`;
+ const multiline =
+ jobClass.params.some((param) => param.comments.length > 0) ||
+ inline.includes("\n") ||
+ indent.length + inline.length > 80;
+ call = multiline
+ ? [
+ `${head}<{`,
+ ...jobClass.params.flatMap((param) => [
+ ...param.comments.map((comment) => member + comment),
+ `${member}${property(param)};`,
+ ]),
+ `${indent}}>()({`,
+ ].join(newline)
+ : inline;
+ }
+ const lines = [
+ call,
+ ...jobClass.kindComments.map((comment) => member + comment),
+ `${member}kind: ${jobClass.kind},`,
+ ];
+ if (jobClass.defaults !== undefined) {
+ lines.push(
+ ...jobClass.defaultsComments.map((comment) => member + comment),
+ `${member}defaults: ${this.#render(jobClass.defaults)},`
+ );
+ }
+ lines.push(`${indent}});`);
+ return lines.join(newline);
+ }
+
+ #defineJobReference(): string {
+ for (const [local, imported] of this.#file.riverImports) {
+ if (imported === "defineJob") return local;
+ }
+ if (
+ this.#file.riverNamespace !== undefined &&
+ !this.#hasNamedRiverImport()
+ ) {
+ return `${this.#file.riverNamespace}.defineJob`;
+ }
+ this.#usesDefineJob = true;
+ return "defineJob";
+ }
+
+ #jobStateReference(): string {
+ for (const [local, imported] of this.#file.riverImports) {
+ if (imported === "JOB_STATE") return local;
+ }
+ if (
+ this.#file.riverNamespace !== undefined &&
+ !this.#hasNamedRiverImport()
+ ) {
+ return `${this.#file.riverNamespace}.JOB_STATE`;
+ }
+ this.#usesJobState = true;
+ return "JOB_STATE";
+ }
+
+ #hasNamedRiverImport(): boolean {
+ const ts = this.#ts;
+ return this.#file.sourceFile.statements.some(
+ (statement) =>
+ isRiverImport(ts, statement) &&
+ statement.importClause?.namedBindings !== undefined &&
+ ts.isNamedImports(statement.importClause.namedBindings)
+ );
+ }
+
+ #visitNew(node: TS.NewExpression): void {
+ const ts = this.#ts;
+ const position = this.#insertPosition(node);
+ const river = riverName(ts, this.#file, node.expression);
+ if (river === "InsertManyParams") {
+ this.#rewriteInsertManyParams(node);
+ return;
+ }
+ if (river === "Client") {
+ this.#rewriteClientSchema(node);
+ return;
+ }
+ if (position === "params") return; // The InsertManyParams rewrite uses it.
+ const parts = this.#jobArgsParts(node, position !== "other");
+ if (parts === undefined) {
+ if (
+ position !== "other" &&
+ this.#isRiverFile &&
+ ts.isIdentifier(node.expression)
+ ) {
+ const name = `\`${node.expression.text}\``;
+ this.#todo(
+ node,
+ `${name} was not converted; insert a job definition with a plain args object`,
+ name
+ );
+ }
+ return;
+ }
+ if (typeof parts === "string") {
+ this.#todo(node, parts);
+ return;
+ }
+ switch (position) {
+ case "element":
+ this.#replace(
+ node,
+ `{ job: ${parts.definition}, args: ${parts.args} }`
+ );
+ return;
+ case "insert":
+ this.#replace(node, `${parts.definition}, ${parts.args}`);
+ return;
+ case "other":
+ this.#todo(
+ node,
+ `pass \`${parts.definition}\` and the args object \`${abbreviate(parts.args)}\` to insert or insertMany`
+ );
+ return;
+ }
+ }
+
+ /**
+ * The definition and args object a legacy job expression becomes, a TODO
+ * message when it is legacy but cannot be converted, or undefined when it
+ * is not a legacy job expression. Without `hoist`, a `JobArgsObject`
+ * kind is described rather than given a module-level definition.
+ */
+ #jobArgsParts(
+ node: TS.Expression,
+ hoist = true
+ ): JobArgsParts | string | undefined {
+ const ts = this.#ts;
+ if (!ts.isNewExpression(node)) return undefined;
+ const args = node.arguments ?? [];
+ if (riverName(ts, this.#file, node.expression) === "JobArgsObject") {
+ const [kindArgument, objectArgument] = args;
+ const kind = stringLiteral(ts, kindArgument);
+ if (kind === undefined || args.some((arg) => ts.isSpreadElement(arg))) {
+ return "define this `JobArgsObject` kind with `defineJob({ kind })` and insert a plain args object";
+ }
+ return {
+ args:
+ objectArgument === undefined ? "{}" : this.#render(objectArgument),
+ definition: hoist
+ ? this.#hoist(kind)
+ : `defineJob({ kind: ${kind.getText()} })`,
+ };
+ }
+ if (!ts.isIdentifier(node.expression)) return undefined;
+ const bound = this.#localClasses.get(node.expression.text);
+ if (bound === undefined) return undefined;
+ const { jobClass } = bound;
+ if (!jobClass.convertible || bound.definition === undefined) {
+ return `\`${jobClass.className}\` could not be converted automatically; insert a job definition with a plain args object`;
+ }
+ if (args.some((arg) => ts.isSpreadElement(arg))) {
+ return `spell out the \`${jobClass.className}\` arguments as a plain args object for \`${bound.definition}\``;
+ }
+ const properties = jobClass.params.flatMap((param, index) => {
+ const arg = args[index];
+ if (arg === undefined) return [];
+ const text = this.#render(arg);
+ return [text === param.name ? text : `${param.name}: ${text}`];
+ });
+ return {
+ args: properties.length === 0 ? "{}" : `{ ${properties.join(", ")} }`,
+ definition: bound.definition,
+ };
+ }
+
+ #insertPosition(node: TS.NewExpression): InsertPosition {
+ const ts = this.#ts;
+ const parent = node.parent;
+ if (
+ ts.isCallExpression(parent) &&
+ parent.arguments[0] === node &&
+ calleeName(ts, parent) === "insert"
+ ) {
+ return "insert";
+ }
+ if (
+ ts.isArrayLiteralExpression(parent) &&
+ ts.isCallExpression(parent.parent) &&
+ parent.parent.arguments[0] === parent &&
+ calleeName(ts, parent.parent) === "insertMany"
+ ) {
+ return "element";
+ }
+ if (
+ ts.isNewExpression(parent) &&
+ parent.arguments?.[0] === node &&
+ riverName(ts, this.#file, parent.expression) === "InsertManyParams"
+ ) {
+ return "params";
+ }
+ return "other";
+ }
+
+ #rewriteInsertManyParams(node: TS.NewExpression): void {
+ const [argsNode, optionsNode] = node.arguments ?? [];
+ const parts =
+ argsNode === undefined ? undefined : this.#jobArgsParts(argsNode);
+ if (parts === undefined || typeof parts === "string") {
+ this.#todo(
+ node,
+ "replace `InsertManyParams` with a `{ job, args, options }` item built from a job definition"
+ );
+ return;
+ }
+ const options =
+ optionsNode === undefined
+ ? ""
+ : `, options: ${this.#render(optionsNode)}`;
+ this.#replace(
+ node,
+ `{ job: ${parts.definition}, args: ${parts.args}${options} }`
+ );
+ }
+
+ /** `new Client(new PgDriver(pool), { schema })` moves the schema. */
+ #rewriteClientSchema(node: TS.NewExpression): void {
+ const ts = this.#ts;
+ const [driver, options] = node.arguments ?? [];
+ if (options === undefined) return;
+ const object = skipParentheses(ts, options);
+ if (!ts.isObjectLiteralExpression(object)) {
+ this.#todo(node, CLIENT_SCHEMA_MESSAGE);
+ return;
+ }
+ const names = object.properties.map((property) =>
+ property.name !== undefined && ts.isIdentifier(property.name)
+ ? property.name.text
+ : undefined
+ );
+ if (!names.includes("schema")) return;
+ const pool =
+ driver !== undefined &&
+ ts.isNewExpression(driver) &&
+ ts.isIdentifier(driver.expression) &&
+ driver.expression.text === "PgDriver" &&
+ driver.arguments?.length === 1
+ ? driver.arguments[0]
+ : undefined;
+ const onlySchema =
+ names.length === 1 &&
+ object.properties.every(
+ (property) =>
+ ts.isPropertyAssignment(property) ||
+ ts.isShorthandPropertyAssignment(property)
+ );
+ if (driver === undefined || pool === undefined || !onlySchema) {
+ this.#todo(node, CLIENT_SCHEMA_MESSAGE);
+ return;
+ }
+ this.#edits.replace(
+ driver.getStart(),
+ options.getEnd(),
+ `new PgDriver(${this.#render(pool)}, ${this.#render(options)})`
+ );
+ }
+
+ #visitPropertyAccess(node: TS.PropertyAccessExpression): void {
+ const ts = this.#ts;
+ const name = node.name.text;
+ if (name === "uniqueSkippedAsDuplicated") {
+ if (
+ ts.isPrefixUnaryExpression(node.parent) &&
+ node.parent.operator === ts.SyntaxKind.ExclamationToken
+ ) {
+ return;
+ }
+ this.#replaceStatus(node, node, "===", "duplicate");
+ return;
+ }
+ if (name.startsWith(JOB_STATE_PREFIX)) {
+ const state = name.slice(JOB_STATE_PREFIX.length).toLowerCase();
+ if (riverName(ts, this.#file, node) === name) {
+ this.#replace(node, `${this.#file.riverNamespace}.JOB_STATE.${state}`);
+ }
+ return;
+ }
+ if (
+ !this.#isRiverFile ||
+ !ts.isPropertyAccessExpression(node.expression) ||
+ node.expression.name.text !== "job"
+ ) {
+ return;
+ }
+ if (name === "id" && !isStringConversion(ts, node)) {
+ this.#todo(
+ node,
+ "`JobRow.id` is now a `bigint`; review number annotations, arithmetic, and JSON serialization"
+ );
+ } else if (JOB_ROW_TIMESTAMPS.has(name)) {
+ this.#todo(
+ node,
+ `\`JobRow.${name}\` is now a \`Temporal.Instant\`, not a \`Date\``
+ );
+ }
+ }
+
+ #visitNegation(node: TS.PrefixUnaryExpression): void {
+ const ts = this.#ts;
+ const operand = node.operand;
+ if (
+ ts.isPropertyAccessExpression(operand) &&
+ operand.name.text === "uniqueSkippedAsDuplicated"
+ ) {
+ // `!undefined` is true, so an optional chain must not compare equal.
+ if (operand.questionDotToken === undefined) {
+ this.#replaceStatus(node, operand, "===", "inserted");
+ } else {
+ this.#replaceStatus(node, operand, "!==", "duplicate");
+ }
+ }
+ }
+
+ #replaceStatus(
+ node: TS.Expression,
+ access: TS.PropertyAccessExpression,
+ operator: "!==" | "===",
+ status: "duplicate" | "inserted"
+ ): void {
+ const receiver = this.#render(access.expression);
+ const dot = access.questionDotToken === undefined ? "." : "?.";
+ const comparison = `${receiver}${dot}status ${operator} "${status}"`;
+ this.#replace(
+ node,
+ needsParentheses(this.#ts, node) ? `(${comparison})` : comparison
+ );
+ }
+
+ #visitProperty(
+ node: TS.PropertyAssignment | TS.ShorthandPropertyAssignment
+ ): void {
+ const ts = this.#ts;
+ if (!ts.isIdentifier(node.name)) return;
+ const name = node.name.text;
+ const object = node.parent;
+ if (name === "byPeriod" && this.#uniqueObjects.has(object)) {
+ this.#rewriteByPeriod(node);
+ return;
+ }
+ if (name === "byArgs" && this.#uniqueObjects.has(object)) {
+ const value = ts.isPropertyAssignment(node)
+ ? node.initializer
+ : undefined;
+ if (
+ value === undefined ||
+ !(
+ value.kind === ts.SyntaxKind.TrueKeyword ||
+ ts.isArrayLiteralExpression(value)
+ )
+ ) {
+ this.#todo(
+ node,
+ "`byArgs` takes `true` or a list of fields; omit it instead of passing false"
+ );
+ }
+ return;
+ }
+ if (name !== "uniqueOpts") return;
+ if (!this.#optionObjects.has(object)) {
+ if (this.#isRiverFile) {
+ this.#todo(
+ node,
+ "if these are River insert options, rename `uniqueOpts` to `unique` (`byPeriod` is now a duration)"
+ );
+ }
+ return;
+ }
+ if (ts.isShorthandPropertyAssignment(node)) {
+ this.#replace(node, "unique: uniqueOpts");
+ this.#todo(node, UNIQUE_OPTIONS_MESSAGE);
+ return;
+ }
+ const value = skipOuterExpressions(ts, node.initializer);
+ if (ts.isObjectLiteralExpression(value)) {
+ // The post-order walk visited this object's properties before it was
+ // known to hold unique options, so rewrite them now.
+ if (!this.#uniqueObjects.has(value)) {
+ this.#uniqueObjects.add(value);
+ for (const property of value.properties) {
+ if (
+ ts.isPropertyAssignment(property) ||
+ ts.isShorthandPropertyAssignment(property)
+ ) {
+ this.#visitProperty(property);
+ }
+ }
+ }
+ } else {
+ this.#todo(node, UNIQUE_OPTIONS_MESSAGE);
+ }
+ this.#replace(node.name, "unique");
+ }
+
+ #rewriteByPeriod(
+ node: TS.PropertyAssignment | TS.ShorthandPropertyAssignment
+ ): void {
+ const ts = this.#ts;
+ if (ts.isShorthandPropertyAssignment(node)) {
+ this.#replace(node, "byPeriod: { seconds: byPeriod }");
+ this.#todo(node, BY_PERIOD_MESSAGE);
+ return;
+ }
+ const value = skipParentheses(ts, node.initializer);
+ if (isNumericConstant(ts, value)) {
+ this.#replace(
+ node.initializer,
+ `{ seconds: ${this.#render(node.initializer)} }`
+ );
+ return;
+ }
+ if (
+ ts.isObjectLiteralExpression(value) ||
+ isTemporalExpression(ts, value)
+ ) {
+ return;
+ }
+ this.#replace(
+ node.initializer,
+ `{ seconds: ${this.#render(node.initializer)} }`
+ );
+ this.#todo(node, BY_PERIOD_MESSAGE);
+ }
+
+ #visitIdentifier(node: TS.Identifier): void {
+ const ts = this.#ts;
+ const imported = this.#file.riverImports.get(node.text);
+ if (
+ imported === undefined ||
+ !imported.startsWith(JOB_STATE_PREFIX) ||
+ isPropertyName(ts, node) ||
+ ts.isImportSpecifier(node.parent) ||
+ ts.isExportSpecifier(node.parent)
+ ) {
+ return;
+ }
+ const state = imported.slice(JOB_STATE_PREFIX.length).toLowerCase();
+ if (ts.isShorthandPropertyAssignment(node.parent)) {
+ this.#replace(
+ node.parent,
+ `${node.text}: ${this.#jobStateReference()}.${state}`
+ );
+ } else {
+ this.#replace(node, `${this.#jobStateReference()}.${state}`);
+ }
+ }
+
+ /** Flag references to removed APIs that no rewrite consumed. */
+ #flagRemainingReferences(): void {
+ const ts = this.#ts;
+ for (const reference of this.#remainingReferences()) {
+ const { node, local } = reference;
+ if (ts.isNewExpression(node.parent) && node.parent.expression === node) {
+ continue; // #visitNew already rewrote or flagged it.
+ }
+ if (ts.isExpressionWithTypeArguments(node.parent)) {
+ continue; // The unconvertible class itself is flagged.
+ }
+ const imported = this.#file.riverImports.get(local);
+ if (imported !== undefined) {
+ this.#todo(node, removedReferenceMessage(imported));
+ continue;
+ }
+ const bound = this.#localClasses.get(local);
+ if (bound?.definition !== undefined) {
+ this.#todo(
+ node,
+ `\`${bound.jobClass.className}\` is now the \`${bound.definition}\` job definition; pass it with a plain args object`
+ );
+ }
+ }
+ }
+
+ /** Identifier references to replaced APIs outside every rewritten range. */
+ #remainingReferences(): {
+ readonly local: string;
+ readonly node: TS.Identifier;
+ }[] {
+ const watched = new Set();
+ for (const [local, imported] of this.#file.riverImports) {
+ if (
+ REPLACED_EXPORTS.has(imported) ||
+ imported.startsWith(JOB_STATE_PREFIX)
+ ) {
+ watched.add(local);
+ }
+ }
+ for (const [local, bound] of this.#localClasses) {
+ if (bound.jobClass.convertible) watched.add(local);
+ }
+ return this.#references(watched, true).filter(
+ ({ node }) => !this.#ts.isExportSpecifier(node.parent)
+ );
+ }
+
+ /**
+ * References to `names` as bindings or values, other than their import
+ * specifiers, optionally skipping those inside rewritten ranges.
+ */
+ #references(
+ names: ReadonlySet,
+ afterEdits: boolean
+ ): { local: string; node: TS.Identifier }[] {
+ const ts = this.#ts;
+ const references: { local: string; node: TS.Identifier }[] = [];
+ const visit = (node: TS.Node): void => {
+ if (
+ ts.isIdentifier(node) &&
+ names.has(node.text) &&
+ !isPropertyName(ts, node) &&
+ !ts.isImportSpecifier(node.parent) &&
+ !(
+ ts.isExportSpecifier(node.parent) &&
+ node.parent.parent.parent.moduleSpecifier !== undefined
+ ) &&
+ !(ts.isClassDeclaration(node.parent) && node.parent.name === node) &&
+ !(afterEdits && this.#edits.intersects(node.getStart(), node.getEnd()))
+ ) {
+ references.push({ local: node.text, node });
+ }
+ ts.forEachChild(node, visit);
+ };
+ visit(this.#file.sourceFile);
+ return references;
+ }
+
+ /**
+ * Rewrite `riverqueue` import lists and job-class specifiers. Returns the
+ * import declarations that remain, for placing hoisted definitions.
+ */
+ #rewriteImports(): TS.ImportDeclaration[] {
+ const ts = this.#ts;
+ const kept: TS.ImportDeclaration[] = [];
+ const riverImports: NamedImportDeclaration[] = [];
+ for (const statement of this.#file.sourceFile.statements) {
+ if (ts.isImportDeclaration(statement)) {
+ const clause = statement.importClause;
+ const bindings = clause?.namedBindings;
+ if (
+ isRiverImport(ts, statement) &&
+ clause !== undefined &&
+ bindings !== undefined &&
+ ts.isNamedImports(bindings)
+ ) {
+ riverImports.push({ bindings, clause, statement });
+ continue;
+ }
+ kept.push(statement);
+ if (bindings !== undefined && ts.isNamedImports(bindings)) {
+ this.#renameSpecifiers(bindings.elements);
+ }
+ } else if (
+ ts.isExportDeclaration(statement) &&
+ statement.exportClause !== undefined &&
+ ts.isNamedExports(statement.exportClause)
+ ) {
+ this.#renameSpecifiers(statement.exportClause.elements);
+ }
+ }
+
+ const hasValueImport = (name: string): boolean =>
+ riverImports.some(
+ ({ bindings, clause }) =>
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports
+ !clause.isTypeOnly &&
+ bindings.elements.some(
+ (element) =>
+ !element.isTypeOnly &&
+ (element.propertyName ?? element.name).text === name
+ )
+ );
+ const additions: string[] = [];
+ if (this.#usesDefineJob && !hasValueImport("defineJob")) {
+ additions.push("defineJob");
+ }
+ if (this.#usesJobState && !hasValueImport("JOB_STATE")) {
+ additions.push("JOB_STATE");
+ }
+ const target =
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports
+ riverImports.find(({ clause }) => !clause.isTypeOnly) ?? riverImports[0];
+ const locals = new Set(this.#file.riverImports.keys());
+ const countReferences = (afterEdits: boolean): Map => {
+ const counts = new Map();
+ for (const { local } of this.#references(locals, afterEdits)) {
+ counts.set(local, (counts.get(local) ?? 0) + 1);
+ }
+ return counts;
+ };
+ const referencedBefore = countReferences(false);
+ const referencedAfter = countReferences(true);
+
+ for (const declaration of riverImports) {
+ const { bindings, clause, statement } = declaration;
+ let specifiers: string[] = [];
+ let changed = false;
+ for (const element of bindings.elements) {
+ const imported = (element.propertyName ?? element.name).text;
+ const removed = REMOVED_EXPORTS.get(imported);
+ if (removed !== undefined) {
+ this.#todo(
+ statement,
+ `\`${imported}\` is no longer exported: ${removed}`
+ );
+ }
+ // Drop replaced APIs, and anything whose every use was rewritten.
+ const local = element.name.text;
+ if (
+ (referencedAfter.get(local) ?? 0) === 0 &&
+ (REPLACED_EXPORTS.has(imported) ||
+ imported.startsWith(JOB_STATE_PREFIX) ||
+ (referencedBefore.get(local) ?? 0) > 0)
+ ) {
+ changed = true;
+ continue;
+ }
+ const renamed = RENAMED_EXPORTS.get(imported);
+ if (renamed !== undefined) {
+ const type = element.isTypeOnly ? "type " : "";
+ specifiers.push(`${type}${renamed} as ${local}`);
+ changed = true;
+ continue;
+ }
+ specifiers.push(element.getText());
+ }
+ const toValue =
+ // eslint-disable-next-line @typescript-eslint/no-deprecated -- phaseModifier is unavailable in TypeScript 5, which the codemod supports
+ declaration === target && clause.isTypeOnly && additions.length > 0;
+ if (declaration === target && additions.length > 0) {
+ if (toValue) {
+ specifiers = specifiers.map((specifier) =>
+ /^type\s/.test(specifier) ? specifier : `type ${specifier}`
+ );
+ }
+ specifiers = insertSorted(specifiers, additions);
+ changed = true;
+ }
+ if (!changed) {
+ kept.push(statement);
+ } else if (specifiers.length === 0 && clause.name === undefined) {
+ this.#removeLine(statement);
+ } else {
+ kept.push(statement);
+ this.#replaceImportList(declaration, specifiers, toValue);
+ }
+ }
+
+ if (
+ target === undefined &&
+ additions.length > 0 &&
+ this.#file.riverNamespace === undefined
+ ) {
+ const newline = this.#file.newline;
+ const statement = `import { ${additions.join(", ")} } from "${RIVER_MODULE}";`;
+ const last = kept.at(-1);
+ if (last === undefined) {
+ this.#edits.insert(0, statement + newline);
+ } else {
+ this.#edits.insert(last.getEnd(), newline + statement);
+ }
+ }
+ return kept;
+ }
+
+ #renameSpecifiers(
+ elements: readonly (TS.ExportSpecifier | TS.ImportSpecifier)[]
+ ): void {
+ for (const element of elements) {
+ const text = this.#specifierRenames.get(element);
+ if (text !== undefined) this.#replace(element, text);
+ }
+ }
+
+ /** Replace an import's `{ ... }` list, as a value import when `toValue`. */
+ #replaceImportList(
+ { bindings, clause, statement }: NamedImportDeclaration,
+ specifiers: readonly string[],
+ toValue: boolean
+ ): void {
+ const text = this.#file.text;
+ const newline = this.#file.newline;
+ const start = toValue ? clause.getStart() : bindings.getStart();
+ const prefix =
+ toValue && clause.name !== undefined ? `${clause.name.text}, ` : "";
+ const inline =
+ specifiers.length === 0 ? "{}" : `{ ${specifiers.join(", ")} }`;
+ const line =
+ text.slice(lineStart(text, statement.getStart()), start) +
+ prefix +
+ inline +
+ text.slice(bindings.getEnd(), statement.getEnd());
+ const indent = lineIndent(text, statement.getStart());
+ const list =
+ line.length <= 80
+ ? inline
+ : [
+ "{",
+ ...specifiers.map((specifier) => `${indent} ${specifier},`),
+ `${indent}}`,
+ ].join(newline);
+ this.#edits.replace(start, bindings.getEnd(), prefix + list);
+ }
+
+ #removeLine(node: TS.Node): void {
+ const text = this.#file.text;
+ const end = text.indexOf("\n", node.getEnd());
+ this.#edits.replace(
+ lineStart(text, node.getStart()),
+ end === -1 ? node.getEnd() : end + 1,
+ ""
+ );
+ }
+
+ #hoist(kind: TS.NoSubstitutionTemplateLiteral | TS.StringLiteral): string {
+ const existing = this.#hoisted.get(kind.text);
+ if (existing !== undefined) return existing.name;
+ const name = claimName(this.#file.taken, `${camelCase(kind.text)}Job`);
+ this.#hoisted.set(kind.text, { literal: kind.getText(), name });
+ this.#defineJobReference();
+ return name;
+ }
+
+ #insertHoisted(keptImports: readonly TS.ImportDeclaration[]): void {
+ if (this.#hoisted.size === 0) return;
+ const newline = this.#file.newline;
+ const reference = this.#defineJobReference();
+ const definitions = [...this.#hoisted.values()].map(
+ ({ literal, name }) =>
+ `const ${name} = ${reference}({ kind: ${literal} });`
+ );
+ const last = [...keptImports]
+ .sort((left, right) => left.getEnd() - right.getEnd())
+ .at(-1);
+ if (last === undefined) {
+ this.#edits.insert(0, definitions.join(newline) + newline + newline);
+ } else {
+ this.#edits.insert(
+ last.getEnd(),
+ newline + newline + definitions.join(newline)
+ );
+ }
+ }
+
+ #todo(node: TS.Node, message: string, unlessMentioned?: string): void {
+ this.#todos.push({ message, node, unlessMentioned });
+ }
+
+ /** Insert each pending TODO above its line, skipping existing copies. */
+ #insertTodos(): void {
+ const text = this.#file.text;
+ const newline = this.#file.newline;
+ const byPosition = new Map();
+ for (const todo of this.#todos) {
+ const position = this.#commentPosition(todo.node);
+ const todos = byPosition.get(position) ?? [];
+ if (!todos.some(({ message }) => message === todo.message)) {
+ todos.push(todo);
+ }
+ byPosition.set(position, todos);
+ }
+ for (const [position, todos] of byPosition) {
+ const existing = commentBlockAbove(text, position).filter((line) =>
+ line.startsWith(`// ${TODO_MARKER} `)
+ );
+ const indent = lineIndent(text, position);
+ const fresh = todos
+ .filter(
+ ({ message, unlessMentioned }) =>
+ !existing.includes(`// ${TODO_MARKER} ${message}`) &&
+ (unlessMentioned === undefined ||
+ !existing.some((line) => line.includes(unlessMentioned)))
+ )
+ .map(({ message }) => message);
+ if (fresh.length === 0) continue;
+ this.#edits.insert(
+ position,
+ fresh
+ .map((message) => `${indent}// ${TODO_MARKER} ${message}${newline}`)
+ .join("")
+ );
+ }
+ }
+
+ /**
+ * The start of the line to put a comment above `node`: its own line,
+ * unless that would land inside a rewritten range, a template literal, or
+ * JSX text, in which case an enclosing line.
+ */
+ #commentPosition(node: TS.Node): number {
+ const ts = this.#ts;
+ const text = this.#file.text;
+ let position = lineStart(text, node.getStart());
+ for (;;) {
+ const edit = this.#edits.containing(position);
+ if (edit !== undefined) {
+ position = lineStart(text, edit.start);
+ continue;
+ }
+ let enclosing: TS.Node | undefined;
+ for (
+ let current: TS.Node | undefined = node;
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- a source file's parent is undefined at runtime
+ current !== undefined;
+ current = current.parent
+ ) {
+ if (
+ (ts.isTemplateExpression(current) ||
+ ts.isNoSubstitutionTemplateLiteral(current) ||
+ ts.isJsxElement(current) ||
+ ts.isJsxFragment(current) ||
+ ts.isJsxSelfClosingElement(current)) &&
+ current.getStart() < position &&
+ position < current.getEnd()
+ ) {
+ enclosing = current;
+ }
+ }
+ if (enclosing === undefined) return position;
+ position = lineStart(text, enclosing.getStart());
+ }
+ }
+
+ #render(node: TS.Node): string {
+ return this.#edits.render(node.getStart(), node.getEnd());
+ }
+
+ #replace(node: TS.Node, text: string): void {
+ this.#edits.replace(node.getStart(), node.getEnd(), text);
+ }
+}
+
+function calleeName(
+ ts: TypeScriptApi,
+ call: TS.CallExpression
+): string | undefined {
+ return ts.isPropertyAccessExpression(call.expression)
+ ? call.expression.name.text
+ : undefined;
+}
+
+/** `id.toString()`, `String(id)`, and `${id}` read a bigint like a number. */
+function isStringConversion(ts: TypeScriptApi, node: TS.Expression): boolean {
+ const parent = node.parent;
+ return (
+ ts.isTemplateSpan(parent) ||
+ (ts.isPropertyAccessExpression(parent) &&
+ parent.name.text === "toString" &&
+ ts.isCallExpression(parent.parent)) ||
+ (ts.isCallExpression(parent) &&
+ ts.isIdentifier(parent.expression) &&
+ parent.expression.text === "String")
+ );
+}
+
+/** A numeric literal, or integer arithmetic (`+`, `-`, `*`) on them. */
+function isNumericConstant(ts: TypeScriptApi, node: TS.Expression): boolean {
+ const expression = skipParentheses(ts, node);
+ if (ts.isNumericLiteral(expression)) return true;
+ return (
+ ts.isBinaryExpression(expression) &&
+ [
+ ts.SyntaxKind.AsteriskToken,
+ ts.SyntaxKind.MinusToken,
+ ts.SyntaxKind.PlusToken,
+ ].includes(expression.operatorToken.kind) &&
+ isNumericConstant(ts, expression.left) &&
+ isNumericConstant(ts, expression.right)
+ );
+}
+
+function isTemporalExpression(ts: TypeScriptApi, node: TS.Expression): boolean {
+ let current: TS.Expression = node;
+ while (
+ ts.isCallExpression(current) ||
+ ts.isPropertyAccessExpression(current)
+ ) {
+ current = current.expression;
+ }
+ return ts.isIdentifier(current) && current.text === "Temporal";
+}
+
+/** Whether a comparison replacing `node` needs parentheses in its context. */
+function needsParentheses(ts: TypeScriptApi, node: TS.Node): boolean {
+ const parent = node.parent;
+ if (ts.isCallExpression(parent) || ts.isNewExpression(parent)) {
+ return parent.expression === node;
+ }
+ if (
+ ts.isParenthesizedExpression(parent) ||
+ ts.isIfStatement(parent) ||
+ ts.isWhileStatement(parent) ||
+ ts.isDoStatement(parent) ||
+ ts.isForStatement(parent) ||
+ ts.isReturnStatement(parent) ||
+ ts.isExpressionStatement(parent) ||
+ ts.isVariableDeclaration(parent) ||
+ ts.isPropertyAssignment(parent) ||
+ ts.isArrowFunction(parent) ||
+ ts.isArrayLiteralExpression(parent) ||
+ ts.isTemplateSpan(parent) ||
+ ts.isJsxExpression(parent) ||
+ // `===` binds tighter than `?:`, so every operand position is safe.
+ ts.isConditionalExpression(parent)
+ ) {
+ return false;
+ }
+ if (ts.isBinaryExpression(parent)) {
+ return ![
+ ts.SyntaxKind.AmpersandAmpersandToken,
+ ts.SyntaxKind.BarBarToken,
+ ts.SyntaxKind.QuestionQuestionToken,
+ ts.SyntaxKind.CommaToken,
+ ts.SyntaxKind.EqualsToken,
+ ].includes(parent.operatorToken.kind);
+ }
+ return true;
+}
+
+/** Add `additions` to a specifier list, alphabetically when it is sorted. */
+function insertSorted(
+ specifiers: readonly string[],
+ additions: readonly string[]
+): string[] {
+ const key = (specifier: string): string =>
+ specifier.replace(/^type\s+/, "").toLowerCase();
+ const sorted = specifiers.every(
+ (specifier, index) =>
+ index === 0 || key(specifiers[index - 1] ?? "") <= key(specifier)
+ );
+ const result = [...specifiers];
+ for (const addition of additions) {
+ if (!sorted) {
+ result.push(addition);
+ continue;
+ }
+ const index = result.findIndex(
+ (specifier) => key(specifier) > key(addition)
+ );
+ result.splice(index === -1 ? result.length : index, 0, addition);
+ }
+ return result;
+}
+
+/** The comment lines directly above the line starting at `position`. */
+function commentBlockAbove(text: string, position: number): string[] {
+ const lines: string[] = [];
+ let end = position - 1;
+ while (end > 0) {
+ const start = lineStart(text, end);
+ const line = text.slice(start, end).replace(/\r$/, "").trim();
+ if (!line.startsWith("//")) break;
+ lines.push(line);
+ end = start - 1;
+ }
+ return lines;
+}
+
+/** Every marker comment in `text`, with the line of the code it marks. */
+function collectSites(text: string): CodemodSite[] {
+ const lines = text.split("\n");
+ const sites: CodemodSite[] = [];
+ const pattern = `// ${TODO_MARKER} `;
+ lines.forEach((line, index) => {
+ const at = line.indexOf(pattern);
+ if (at === -1 || line.slice(0, at).trim() !== "") return;
+ let target = index + 1;
+ while (
+ target < lines.length &&
+ (lines[target] ?? "").trim().startsWith("//")
+ ) {
+ target++;
+ }
+ sites.push({
+ line: target + 1,
+ message: line
+ .slice(at + pattern.length)
+ .replace(/\r$/, "")
+ .trim(),
+ });
+ });
+ return sites;
+}
+
+function removedReferenceMessage(imported: string): string {
+ if (imported === "JobArgs") {
+ return "`JobArgs` was removed; accept a `JobDefinition` and its args, or an `InsertManyItem`";
+ }
+ if (imported.startsWith(JOB_STATE_PREFIX)) {
+ const state = imported.slice(JOB_STATE_PREFIX.length).toLowerCase();
+ return `\`${imported}\` was removed; use \`JOB_STATE.${state}\``;
+ }
+ return `\`${imported}\` was removed; use a job definition with a plain args object`;
+}
+
+/** A single-line, bounded rendering of code for a message. */
+function abbreviate(code: string): string {
+ const flat = code.replaceAll(/\s+/g, " ");
+ return flat.length <= 60 ? flat : `${flat.slice(0, 57)}...`;
+}
diff --git a/js/cli/src/command.ts b/js/cli/src/command.ts
new file mode 100644
index 000000000..97e8e647f
--- /dev/null
+++ b/js/cli/src/command.ts
@@ -0,0 +1,56 @@
+import type { Migration, MigrationBackend } from "@riverqueue/migrate";
+
+import type { OptionSpecs, OptionValues } from "./options.js";
+
+/** A destination for command output, such as `process.stdout`. */
+export interface OutputStream {
+ write(chunk: string): unknown;
+}
+
+/** A source of interactive input, such as `process.stdin`. */
+type InputStream = NodeJS.ReadableStream & { readonly isTTY?: boolean };
+
+/** Returns an additional migration line's migrations for a backend. */
+type MigrationLineProvider = (
+ backend: MigrationBackend
+) => readonly Migration[];
+
+/** Process facilities a command runs with. */
+export interface CommandContext {
+ readonly env: NodeJS.ProcessEnv;
+ /** Migration lines beyond River's bundled main line, by name. */
+ readonly migrationLines: Readonly>;
+ /** Program name shown in help and messages, such as `riverqueue`. */
+ readonly program: string;
+ readonly stderr: OutputStream & { readonly isTTY?: boolean };
+ readonly stdin: InputStream;
+ readonly stdout: OutputStream;
+}
+
+/** One `riverqueue` subcommand. */
+export interface Command {
+ /**
+ * Longer help text shown by `riverqueue --help`, in which
+ * `{program}` stands for the program name.
+ */
+ readonly description: string;
+ readonly name: string;
+ readonly options: OptionSpecs;
+ /**
+ * Usage placeholder for positional arguments, such as `...`. Commands
+ * without it reject positional arguments.
+ */
+ readonly positionals?: string;
+ /** One-line summary shown in the command list. */
+ readonly summary: string;
+ /** Run the command and resolve to the process exit code. */
+ run(
+ values: OptionValues,
+ context: CommandContext,
+ positionals: readonly string[]
+ ): Promise;
+}
+
+export function writeLine(stream: OutputStream, line = ""): void {
+ stream.write(`${line}\n`);
+}
diff --git a/js/cli/src/database.ts b/js/cli/src/database.ts
new file mode 100644
index 000000000..76158f5f4
--- /dev/null
+++ b/js/cli/src/database.ts
@@ -0,0 +1,150 @@
+import { DatabaseSync } from "node:sqlite";
+
+import pg from "pg";
+
+import {
+ parseDuration,
+ stringValue,
+ UsageError,
+ type OptionSpec,
+ type OptionValues,
+} from "./options.js";
+
+/** Where a command's database lives, as given by `--database-url`. */
+export type DatabaseLocation =
+ | {
+ readonly backend: "postgres";
+ readonly connectionString: string | undefined;
+ }
+ | { readonly backend: "sqlite"; readonly path: string | URL };
+
+// Match River's Go CLI: bound statements so a stuck lock can't hang a
+// deploy. Settings in the database URL take precedence over these defaults,
+// and --statement-timeout takes precedence over both.
+const POSTGRES_DEFAULTS = {
+ application_name: "riverqueue CLI",
+ idle_in_transaction_session_timeout: 11_000,
+ statement_timeout: 10_000,
+} as const;
+
+/** `--statement-timeout`, accepted by commands that use PostgreSQL. */
+export const STATEMENT_TIMEOUT_OPTION: OptionSpec = {
+ description:
+ "PostgreSQL statement_timeout, such as 30s or 5m (default: a " +
+ "statement_timeout parameter in --database-url, otherwise 10s)",
+ type: "string",
+ valueName: "DURATION",
+};
+
+const SQLITE_BUSY_TIMEOUT_MS = 5_000;
+
+/**
+ * Interpret `--database-url`.
+ *
+ * `postgres://` and `postgresql://` URLs select PostgreSQL. `sqlite://PATH`
+ * selects SQLite, where `PATH` is a file path (`sqlite:///abs/river.db` or
+ * `sqlite://relative/river.db`), `:memory:`, or a `file:` URL. Without a URL,
+ * PostgreSQL is configured from `PG*` environment variables when
+ * `PGDATABASE` is set, as node-postgres and River's Go CLI do.
+ */
+export function parseDatabaseUrl(
+ command: string,
+ url: string | undefined,
+ env: NodeJS.ProcessEnv
+): DatabaseLocation {
+ if (url === undefined) {
+ if (env.PGDATABASE !== undefined && env.PGDATABASE !== "") {
+ return { backend: "postgres", connectionString: undefined };
+ }
+ throw new UsageError(
+ "--database-url is required unless PGDATABASE and other PG* " +
+ "environment variables configure PostgreSQL",
+ command
+ );
+ }
+
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase();
+ switch (scheme) {
+ case "postgres":
+ case "postgresql":
+ return { backend: "postgres", connectionString: url };
+ case "sqlite": {
+ const path = url.slice("sqlite://".length);
+ if (path.length === 0) {
+ throw new UsageError(
+ "a SQLite --database-url needs a path, such as sqlite:///var/lib/river.db",
+ command
+ );
+ }
+ return {
+ backend: "sqlite",
+ path: path.startsWith("file:") ? new URL(path) : path,
+ };
+ }
+ default:
+ throw new UsageError(
+ "--database-url must start with postgres://, postgresql://, or sqlite://",
+ command
+ );
+ }
+}
+
+/** Read `--statement-timeout` in milliseconds. */
+export function statementTimeoutValue(
+ command: string,
+ values: OptionValues
+): number | undefined {
+ const value = stringValue(values, "statement-timeout");
+ return value === undefined
+ ? undefined
+ : parseDuration(command, "--statement-timeout", value);
+}
+
+/** Open a PostgreSQL pool for a CLI command. */
+export function openPostgresPool(
+ location: Extract,
+ options: {
+ readonly max?: number | undefined;
+ readonly statementTimeoutMs?: number | undefined;
+ } = {}
+): pg.Pool {
+ const { statementTimeoutMs } = options;
+ let connectionString = location.connectionString;
+ // node-postgres lets URL parameters override pool options, so an explicit
+ // timeout has to replace any in the URL.
+ if (connectionString !== undefined && statementTimeoutMs !== undefined) {
+ const url = new URL(connectionString);
+ url.searchParams.set("statement_timeout", String(statementTimeoutMs));
+ connectionString = url.toString();
+ }
+ return new pg.Pool({
+ ...POSTGRES_DEFAULTS,
+ ...(connectionString === undefined ? {} : { connectionString }),
+ ...(options.max === undefined ? {} : { max: options.max }),
+ ...(statementTimeoutMs === undefined
+ ? {}
+ : { statement_timeout: statementTimeoutMs }),
+ });
+}
+
+export function openSqliteDatabase(
+ location: Extract
+): DatabaseSync {
+ const database = new DatabaseSync(location.path);
+ database.exec(`PRAGMA busy_timeout = ${SQLITE_BUSY_TIMEOUT_MS}`);
+ return database;
+}
+
+/**
+ * Describe a PostgreSQL target for confirmation prompts without exposing a
+ * password.
+ */
+export function describePostgresTarget(connectionString: string): string {
+ try {
+ const url = new URL(connectionString);
+ if (url.password !== "") url.password = "****";
+ return url.toString();
+ } catch {
+ return "the database in --database-url";
+ }
+}
diff --git a/js/cli/src/index.ts b/js/cli/src/index.ts
new file mode 100644
index 000000000..255d878cf
--- /dev/null
+++ b/js/cli/src/index.ts
@@ -0,0 +1,11 @@
+/**
+ * The `riverqueue` command line for River migrations, benchmarks, and the
+ * `riverqueue@0.1` upgrade codemod.
+ *
+ * Most users run the `riverqueue` executable. {@link run} runs the same
+ * commands in-process, for example from a deployment script.
+ *
+ * @packageDocumentation
+ */
+export { run } from "./run.js";
+export type { RunOptions } from "./run.js";
diff --git a/js/cli/src/migrate-commands.ts b/js/cli/src/migrate-commands.ts
new file mode 100644
index 000000000..42a48c5e4
--- /dev/null
+++ b/js/cli/src/migrate-commands.ts
@@ -0,0 +1,467 @@
+import { quoteIdentifier } from "riverqueue/unstable-driver";
+import {
+ createMigrator,
+ loadMigrations,
+ MIGRATION_LINE_MAIN,
+ type Migration,
+ type MigrationBackend,
+ type MigrationDirection,
+ type MigrateResult,
+ type Migrator,
+} from "@riverqueue/migrate";
+
+import { writeLine, type Command, type CommandContext } from "./command.js";
+import {
+ openPostgresPool,
+ openSqliteDatabase,
+ parseDatabaseUrl,
+ STATEMENT_TIMEOUT_OPTION,
+ statementTimeoutValue,
+} from "./database.js";
+import {
+ booleanValue,
+ integerValue,
+ parseInteger,
+ stringValue,
+ stringValues,
+ UsageError,
+ type OptionSpec,
+ type OptionValues,
+} from "./options.js";
+
+const TEMPLATE_SCHEMA = "/* TEMPLATE: schema */";
+
+const DATABASE_URL_OPTION: OptionSpec = {
+ description:
+ "Database to use: postgres://..., postgresql://..., or sqlite://PATH " +
+ "(defaults to PG* environment variables when PGDATABASE is set)",
+ type: "string",
+ valueName: "URL",
+};
+
+const LINE_OPTION: OptionSpec = {
+ description: `Migration line to use (default: ${MIGRATION_LINE_MAIN})`,
+ type: "string",
+ valueName: "NAME",
+};
+
+const SCHEMA_OPTION: OptionSpec = {
+ description:
+ "PostgreSQL schema containing River's tables (default: the search_path)",
+ type: "string",
+ valueName: "NAME",
+};
+
+const MIGRATE_OPTIONS = {
+ "database-url": DATABASE_URL_OPTION,
+ "dry-run": {
+ description: "Print the migrations that would run without running them",
+ type: "boolean",
+ },
+ line: LINE_OPTION,
+ "max-steps": {
+ description: "Run at most N migrations",
+ type: "string",
+ valueName: "N",
+ },
+ schema: SCHEMA_OPTION,
+ "show-sql": {
+ description: "Print the SQL of each migration",
+ type: "boolean",
+ },
+ "statement-timeout": STATEMENT_TIMEOUT_OPTION,
+ "target-version": {
+ description:
+ "Version to end at; migrate-down reverts every version above it, and 0 reverts all of them",
+ type: "string",
+ valueName: "VERSION",
+ },
+} as const satisfies Record;
+
+export const migrateDownCommand: Command = {
+ description: `
+Revert River migrations, newest first.
+
+Reverts one migration by default. Use --max-steps or --target-version to
+revert more; --target-version 0 reverts every migration and drops River's
+tables and their data. Combine --dry-run and --show-sql to print the SQL
+without running it.`,
+ name: "migrate-down",
+ options: MIGRATE_OPTIONS,
+ summary: "Revert River migrations",
+ run: async (values, context) => runMigrate("down", values, context),
+};
+
+export const migrateUpCommand: Command = {
+ description: `
+Apply River migrations that aren't applied yet, oldest first.
+
+Applies every missing migration by default. Use --max-steps or
+--target-version to apply fewer. Combine --dry-run and --show-sql to print
+the SQL without running it.`,
+ name: "migrate-up",
+ options: MIGRATE_OPTIONS,
+ summary: "Apply River migrations",
+ run: async (values, context) => runMigrate("up", values, context),
+};
+
+export const migrateGetCommand: Command = {
+ description: `
+Print the SQL of River migrations for use with another migration tool.
+
+Choose versions with --version (comma-separated or repeated) or --all, and
+a direction with --up or --down. With --all, down migrations print newest
+first. --exclude-version 1 skips the tables River uses to track its own
+migrations. No database connection is made: --database-url only selects
+PostgreSQL (the default) or SQLite SQL.
+
+ {program} migrate-get --version 3 --up > river_3.up.sql
+ {program} migrate-get --all --exclude-version 1 --up > river.up.sql
+ {program} migrate-get --all --down --database-url sqlite:// > river.down.sql`,
+ name: "migrate-get",
+ options: {
+ all: { description: "Print every migration", type: "boolean" },
+ "database-url": {
+ description:
+ "Selects the SQL dialect: postgres:// (default) or sqlite://",
+ type: "string",
+ valueName: "URL",
+ },
+ down: { description: "Print down migrations", type: "boolean" },
+ "exclude-version": {
+ description: "Leave out these versions (comma-separated or repeated)",
+ multiple: true,
+ type: "string",
+ valueName: "VERSIONS",
+ },
+ line: LINE_OPTION,
+ schema: SCHEMA_OPTION,
+ up: { description: "Print up migrations", type: "boolean" },
+ version: {
+ description: "Versions to print (comma-separated or repeated)",
+ multiple: true,
+ type: "string",
+ valueName: "VERSIONS",
+ },
+ },
+ summary: "Print the SQL of River migrations",
+ run: async (values, context) => runMigrateGet(values, context),
+};
+
+export const migrateListCommand: Command = {
+ description: `
+List River migrations, marking the newest one applied to the database with *.`,
+ name: "migrate-list",
+ options: {
+ "database-url": DATABASE_URL_OPTION,
+ line: LINE_OPTION,
+ schema: SCHEMA_OPTION,
+ "statement-timeout": STATEMENT_TIMEOUT_OPTION,
+ },
+ summary: "List River migrations and show which is applied",
+ run: async (values, context) =>
+ withMigrator("migrate-list", values, context, async (migrator) => {
+ const existing = await migrator.existingVersions();
+ const known = new Set(migrator.migrations.map(({ version }) => version));
+ const current = Math.max(
+ 0,
+ ...existing.filter((version) => known.has(version))
+ );
+ for (const { name, version } of migrator.migrations) {
+ const prefix = version === current ? "* " : current > 0 ? " " : "";
+ writeLine(context.stdout, `${prefix}${formatVersion(version)} ${name}`);
+ }
+ const unknown = existing.filter((version) => !known.has(version));
+ if (unknown.length > 0) {
+ writeLine(
+ context.stderr,
+ `the database also has migration versions this riverqueue ` +
+ `release doesn't know: ${unknown.join(", ")}`
+ );
+ }
+ return 0;
+ }),
+};
+
+export const validateCommand: Command = {
+ description: `
+Check that every River migration is applied. Exits with status 1 and lists the
+missing versions if any are not.
+
+Pair it with migrate-up --dry-run --show-sql to see the SQL that would fix it.`,
+ name: "validate",
+ options: {
+ "database-url": DATABASE_URL_OPTION,
+ line: LINE_OPTION,
+ schema: SCHEMA_OPTION,
+ "statement-timeout": STATEMENT_TIMEOUT_OPTION,
+ "target-version": {
+ description: "Only require versions up to and including VERSION",
+ type: "string",
+ valueName: "VERSION",
+ },
+ },
+ summary: "Check that River migrations are applied",
+ run: async (values, context) =>
+ withMigrator("validate", values, context, async (migrator) => {
+ const targetVersion = integerValue(
+ "validate",
+ values,
+ "target-version",
+ 1
+ );
+ const result = await migrator.validate({ targetVersion });
+ for (const message of result.messages) {
+ writeLine(context.stderr, message);
+ }
+ return result.ok ? 0 : 1;
+ }),
+};
+
+async function runMigrate(
+ direction: MigrationDirection,
+ values: OptionValues,
+ context: CommandContext
+): Promise {
+ const command = `migrate-${direction}`;
+ const dryRun = booleanValue(values, "dry-run");
+ const maxSteps = integerValue(command, values, "max-steps", 0);
+ const showSql = booleanValue(values, "show-sql");
+ const targetVersion = integerValue(command, values, "target-version", 0);
+ return withMigrator(command, values, context, async (migrator) => {
+ const options = { dryRun, maxSteps, targetVersion };
+ const result =
+ direction === "up"
+ ? await migrator.migrateUp(options)
+ : await migrator.migrateDown(options);
+ printResult(context, migrator.line, result, { dryRun, maxSteps, showSql });
+ return 0;
+ });
+}
+
+function printResult(
+ context: CommandContext,
+ line: string,
+ result: MigrateResult,
+ options: {
+ readonly dryRun: boolean;
+ readonly maxSteps: number | undefined;
+ readonly showSql: boolean;
+ }
+): void {
+ const { direction, versions } = result;
+ if (versions.length === 0) {
+ writeLine(context.stdout, "no migrations to apply");
+ return;
+ }
+ const nameWidth = Math.max(...versions.map(({ name }) => name.length));
+ for (const { duration, name, sql, version } of versions) {
+ const prefix = `${formatVersion(version)} [${direction}] ${name.padEnd(nameWidth)}`;
+ writeLine(
+ context.stdout,
+ options.dryRun
+ ? `migration ${prefix} [dry run]`
+ : `applied migration ${prefix} [${formatDuration(duration.total("milliseconds"))}]`
+ );
+ if (options.showSql) {
+ writeLine(context.stdout, "-".repeat(80));
+ writeLine(context.stdout, migrationComment(line, version, direction));
+ writeLine(context.stdout, sql.trim());
+ writeLine(context.stdout);
+ }
+ }
+ if (options.maxSteps !== undefined && versions.length < options.maxSteps) {
+ writeLine(context.stdout, "no more migrations to apply");
+ }
+}
+
+async function runMigrateGet(
+ values: OptionValues,
+ context: CommandContext
+): Promise {
+ const command = "migrate-get";
+ const all = booleanValue(values, "all");
+ const down = booleanValue(values, "down");
+ const up = booleanValue(values, "up");
+ const requested = parseVersionList(command, "--version", values, "version");
+ const excluded = new Set(
+ parseVersionList(command, "--exclude-version", values, "exclude-version")
+ );
+ if (all === requested.length > 0) {
+ throw new UsageError("pass exactly one of --all or --version", command);
+ }
+ if (down === up) {
+ throw new UsageError("pass exactly one of --up or --down", command);
+ }
+ const line = resolveLine(command, context, stringValue(values, "line"));
+ const backend = sqlDialect(command, stringValue(values, "database-url"));
+ const schema = stringValue(values, "schema");
+ if (backend === "sqlite" && schema !== undefined) {
+ throw new UsageError("--schema only applies to PostgreSQL", command);
+ }
+
+ const migrations =
+ lineMigrations(context, line, backend) ?? loadMigrations(backend);
+ const selected: Migration[] = all
+ ? down
+ ? [...migrations].reverse()
+ : [...migrations]
+ : requested.map((version) => {
+ const migration = migrations.find((m) => m.version === version);
+ if (migration === undefined) {
+ throw new UsageError(
+ `migration ${version} does not exist (available versions: ` +
+ `${migrations.map((m) => m.version).join(", ")})`,
+ command
+ );
+ }
+ return migration;
+ });
+
+ const direction = down ? "down" : "up";
+ const prefix = schema === undefined ? "" : `${quoteIdentifier(schema)}.`;
+ let printed = false;
+ for (const migration of selected) {
+ if (excluded.has(migration.version)) continue;
+ if (printed) writeLine(context.stdout);
+ printed = true;
+ const sql = down ? migration.downSql : migration.upSql;
+ writeLine(
+ context.stdout,
+ migrationComment(line, migration.version, direction)
+ );
+ writeLine(context.stdout, sql.replaceAll(TEMPLATE_SCHEMA, prefix).trim());
+ }
+ return 0;
+}
+
+async function withMigrator(
+ command: string,
+ values: OptionValues,
+ context: CommandContext,
+ run: (migrator: Migrator) => Promise
+): Promise {
+ const line = resolveLine(command, context, stringValue(values, "line"));
+ const schema = stringValue(values, "schema");
+ const location = parseDatabaseUrl(
+ command,
+ stringValue(values, "database-url"),
+ context.env
+ );
+
+ const statementTimeoutMs = statementTimeoutValue(command, values);
+
+ if (location.backend === "sqlite") {
+ for (const [flag, value] of [
+ ["--schema", schema],
+ ["--statement-timeout", statementTimeoutMs],
+ ] as const) {
+ if (value !== undefined) {
+ throw new UsageError(`${flag} only applies to PostgreSQL`, command);
+ }
+ }
+ const database = openSqliteDatabase(location);
+ try {
+ return await run(
+ createMigrator(
+ { database },
+ { line, migrations: lineMigrations(context, line, "sqlite") }
+ )
+ );
+ } finally {
+ database.close();
+ }
+ }
+
+ const pool = openPostgresPool(location, { max: 2, statementTimeoutMs });
+ try {
+ return await run(
+ createMigrator(
+ { pool, schema },
+ { line, migrations: lineMigrations(context, line, "postgres") }
+ )
+ );
+ } finally {
+ await pool.end();
+ }
+}
+
+function formatDuration(milliseconds: number): string {
+ return milliseconds < 1_000
+ ? `${milliseconds.toFixed(2)}ms`
+ : `${(milliseconds / 1_000).toFixed(2)}s`;
+}
+
+function formatVersion(version: number): string {
+ return version.toString().padStart(3, "0");
+}
+
+function migrationComment(
+ line: string,
+ version: number,
+ direction: MigrationDirection
+): string {
+ return `-- River ${line} migration ${formatVersion(version)} [${direction}]`;
+}
+
+function parseVersionList(
+ command: string,
+ flag: string,
+ values: OptionValues,
+ name: string
+): number[] {
+ return stringValues(values, name).flatMap((value) =>
+ value.split(",").map((part) => parseInteger(command, flag, part, 1))
+ );
+}
+
+/**
+ * The migrations of an additional line, or undefined for River's bundled
+ * main line.
+ */
+function lineMigrations(
+ context: CommandContext,
+ line: string,
+ backend: MigrationBackend
+): readonly Migration[] | undefined {
+ return line === MIGRATION_LINE_MAIN
+ ? undefined
+ : context.migrationLines[line]?.(backend);
+}
+
+/** Check `--line` against the main line and any additional lines. */
+function resolveLine(
+ command: string,
+ context: CommandContext,
+ line: string | undefined
+): string {
+ if (
+ line === undefined ||
+ line === MIGRATION_LINE_MAIN ||
+ Object.hasOwn(context.migrationLines, line)
+ ) {
+ return line ?? MIGRATION_LINE_MAIN;
+ }
+ const available = [
+ MIGRATION_LINE_MAIN,
+ ...Object.keys(context.migrationLines).sort(),
+ ];
+ throw new UsageError(
+ `migration line does not exist: ${line} (available lines: ${available.join(", ")})`,
+ command
+ );
+}
+
+function sqlDialect(
+ command: string,
+ url: string | undefined
+): MigrationBackend {
+ if (url === undefined) return "postgres";
+ const scheme = /^([a-z][a-z0-9+.-]*):\/\//i.exec(url)?.[1]?.toLowerCase();
+ if (scheme === "postgres" || scheme === "postgresql") return "postgres";
+ if (scheme === "sqlite") return "sqlite";
+ throw new UsageError(
+ "--database-url must start with postgres://, postgresql://, or sqlite://",
+ command
+ );
+}
diff --git a/js/cli/src/options.ts b/js/cli/src/options.ts
new file mode 100644
index 000000000..5418bcb8e
--- /dev/null
+++ b/js/cli/src/options.ts
@@ -0,0 +1,278 @@
+import { parseArgs } from "node:util";
+
+/** One command-line flag accepted by a command. */
+export interface OptionSpec {
+ /** One-line help text. */
+ readonly description: string;
+ /** Whether the flag may repeat. Repeated values are collected in order. */
+ readonly multiple?: boolean;
+ /** Single-letter alias, such as `n` for `-n`. */
+ readonly short?: string;
+ /** `boolean` flags take no value; `string` flags require one. */
+ readonly type: "boolean" | "string";
+ /** Placeholder shown in help for the value, such as `URL`. */
+ readonly valueName?: string;
+}
+
+/** Flags keyed by long name, without the leading `--`. */
+export type OptionSpecs = Readonly>;
+
+/** Parsed flag values keyed by long name. */
+export type OptionValues = Readonly<
+ Record
+>;
+
+/** A mistake in how a command was invoked, reported with a usage hint. */
+export class UsageError extends Error {
+ /** Command whose help the error should point to, if any. */
+ readonly command: string | undefined;
+
+ constructor(message: string, command?: string) {
+ super(message);
+ this.name = "UsageError";
+ this.command = command;
+ }
+}
+
+const HELP_OPTION: OptionSpec = {
+ description: "Show help",
+ short: "h",
+ type: "boolean",
+};
+
+/**
+ * Parse `argv` against a command's flags with `node:util` `parseArgs` in
+ * strict mode. Every command also accepts `-h` and `--help`. Positional
+ * arguments are rejected unless `allowPositionals` is set.
+ */
+export function parseOptions(
+ command: string,
+ specs: OptionSpecs,
+ argv: readonly string[],
+ allowPositionals = false
+): {
+ help: boolean;
+ positionals: readonly string[];
+ values: OptionValues;
+} {
+ const options: Record<
+ string,
+ { multiple?: boolean; short?: string; type: "boolean" | "string" }
+ > = { help: toParseArgsOption(HELP_OPTION) };
+ for (const [name, spec] of Object.entries(specs)) {
+ options[name] = toParseArgsOption(spec);
+ }
+
+ let parsed: ReturnType;
+ try {
+ parsed = parseArgs({
+ allowPositionals,
+ args: [...argv],
+ options,
+ strict: true,
+ });
+ } catch (error: unknown) {
+ throw new UsageError(parseErrorMessage(error), command);
+ }
+ const { help, ...values } = parsed.values;
+ return { help: help === true, positionals: parsed.positionals, values };
+}
+
+const HELP_WIDTH = 80;
+
+/**
+ * Render help text: the description, a usage line, and the flags with
+ * descriptions wrapped to 80 columns.
+ */
+export function formatHelp(sections: {
+ readonly description: string;
+ readonly options?: OptionSpecs;
+ readonly usage: string;
+}): string {
+ const lines = [
+ sections.description.trim(),
+ "",
+ "Usage:",
+ ` ${sections.usage}`,
+ ];
+ if (sections.options !== undefined) {
+ const rows = [
+ ...Object.entries(sections.options),
+ ["help", HELP_OPTION] as const,
+ ]
+ .sort(([left], [right]) => left.localeCompare(right))
+ .map(([name, spec]) => {
+ const short = spec.short === undefined ? " " : `-${spec.short}, `;
+ const value =
+ spec.type === "string" ? ` ${spec.valueName ?? "VALUE"}` : "";
+ return [` ${short}--${name}${value}`, spec.description] as const;
+ });
+ const width = Math.max(...rows.map(([flag]) => flag.length)) + 2;
+ lines.push("", "Flags:");
+ for (const [flag, description] of rows) {
+ const wrapped = wrap(description, HELP_WIDTH - width);
+ lines.push(`${flag.padEnd(width)}${wrapped[0] ?? ""}`);
+ for (const continuation of wrapped.slice(1)) {
+ lines.push(`${" ".repeat(width)}${continuation}`);
+ }
+ }
+ }
+ return `${lines.join("\n")}\n`;
+}
+
+function wrap(text: string, width: number): string[] {
+ const lines: string[] = [];
+ let current = "";
+ for (const word of text.split(/\s+/)) {
+ if (current !== "" && current.length + 1 + word.length > width) {
+ lines.push(current);
+ current = word;
+ } else {
+ current = current === "" ? word : `${current} ${word}`;
+ }
+ }
+ if (current !== "") lines.push(current);
+ return lines;
+}
+
+/** Read an optional string flag. */
+export function stringValue(
+ values: OptionValues,
+ name: string
+): string | undefined {
+ const value = values[name];
+ return typeof value === "string" ? value : undefined;
+}
+
+/** Read a boolean flag, defaulting to `false`. */
+export function booleanValue(values: OptionValues, name: string): boolean {
+ return values[name] === true;
+}
+
+/** Read every value of a repeatable string flag. */
+export function stringValues(
+ values: OptionValues,
+ name: string
+): readonly string[] {
+ const value = values[name];
+ return Array.isArray(value)
+ ? value.filter((item): item is string => typeof item === "string")
+ : [];
+}
+
+/** Parse an optional flag as a non-negative (0) or positive (1) integer. */
+export function integerValue(
+ command: string,
+ values: OptionValues,
+ name: string,
+ minimum: 0 | 1
+): number | undefined {
+ const value = stringValue(values, name);
+ return value === undefined
+ ? undefined
+ : parseInteger(command, `--${name}`, value, minimum);
+}
+
+/** Parse `value` as a non-negative (0) or positive (1) decimal integer. */
+export function parseInteger(
+ command: string,
+ flag: string,
+ value: string,
+ minimum: 0 | 1
+): number {
+ const parsed = /^\s*-?\d+\s*$/.test(value) ? Number(value) : Number.NaN;
+ if (!Number.isSafeInteger(parsed) || parsed < minimum) {
+ const kind = minimum === 0 ? "a non-negative" : "a positive";
+ throw new UsageError(
+ `${flag} must be ${kind} integer; received ${JSON.stringify(value)}`,
+ command
+ );
+ }
+ return parsed;
+}
+
+const DURATION_UNITS_MS: Readonly> = {
+ h: 3_600_000,
+ m: 60_000,
+ ms: 1,
+ s: 1_000,
+};
+
+/**
+ * Parse a duration such as `30s`, `5m`, `1h30m`, or `1.5s` into whole
+ * milliseconds.
+ */
+export function parseDuration(
+ command: string,
+ flag: string,
+ value: string
+): number {
+ const segment = /(\d+(?:\.\d+)?)(ms|h|m|s)/y;
+ let total = 0;
+ let offset = 0;
+ while (offset < value.length) {
+ segment.lastIndex = offset;
+ const match = segment.exec(value);
+ const amount = match?.[1];
+ const unit = match?.[2];
+ if (amount === undefined || unit === undefined) break;
+ total += Number(amount) * (DURATION_UNITS_MS[unit] ?? Number.NaN);
+ offset = segment.lastIndex;
+ }
+ const milliseconds = Math.round(total);
+ if (
+ value.length === 0 ||
+ offset !== value.length ||
+ !Number.isSafeInteger(milliseconds) ||
+ milliseconds < 1
+ ) {
+ throw new UsageError(
+ `${flag} must be a duration such as 500ms, 30s, 5m, or 1h30m; ` +
+ `received ${JSON.stringify(value)}`,
+ command
+ );
+ }
+ return milliseconds;
+}
+
+function parseErrorMessage(error: unknown): string {
+ if (!(error instanceof Error)) return String(error);
+ const code = (error as { code?: unknown }).code;
+ const option = /'(-[^' ]+)/.exec(error.message)?.[1];
+ switch (code) {
+ case "ERR_PARSE_ARGS_UNKNOWN_OPTION":
+ return option === undefined ? error.message : `unknown flag: ${option}`;
+ case "ERR_PARSE_ARGS_INVALID_OPTION_VALUE":
+ if (option === undefined) return error.message;
+ if (error.message.includes("does not take")) {
+ return `flag ${option} does not take a value`;
+ }
+ if (error.message.includes("ambiguous")) {
+ return (
+ `flag ${option} requires a value; ` +
+ `write ${option}=VALUE to pass a value that starts with "-"`
+ );
+ }
+ return `flag ${option} requires a value`;
+ case "ERR_PARSE_ARGS_UNEXPECTED_POSITIONAL": {
+ const argument = /'([^']*)'/.exec(error.message)?.[1];
+ return argument === undefined
+ ? error.message
+ : `unexpected argument: ${JSON.stringify(argument)}`;
+ }
+ default:
+ return error.message;
+ }
+}
+
+function toParseArgsOption(spec: OptionSpec): {
+ multiple?: boolean;
+ short?: string;
+ type: "boolean" | "string";
+} {
+ return {
+ type: spec.type,
+ ...(spec.multiple === true ? { multiple: true } : {}),
+ ...(spec.short === undefined ? {} : { short: spec.short }),
+ };
+}
diff --git a/js/cli/src/run.integration.test.ts b/js/cli/src/run.integration.test.ts
new file mode 100644
index 000000000..b97382fb9
--- /dev/null
+++ b/js/cli/src/run.integration.test.ts
@@ -0,0 +1,184 @@
+import { randomUUID } from "node:crypto";
+import { PassThrough } from "node:stream";
+
+import type { Migration } from "@riverqueue/migrate";
+import pg from "pg";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { run, type RunOptions } from "./run.js";
+
+const TEST_DATABASE_URL =
+ process.env.TEST_DATABASE_URL ??
+ "postgres://localhost:5432/river_test?sslmode=disable";
+
+async function invoke(
+ argv: readonly string[],
+ options: Pick = {}
+) {
+ let stderr = "";
+ let stdout = "";
+ const exitCode = await run(argv, {
+ ...options,
+ stderr: { write: (chunk: string) => (stderr += chunk) },
+ stdin: Object.assign(new PassThrough(), { isTTY: false }),
+ stdout: { write: (chunk: string) => (stdout += chunk) },
+ });
+ return { exitCode, stderr, stdout };
+}
+
+describe("riverqueue with PostgreSQL", () => {
+ let pool: pg.Pool;
+ let schema: string;
+
+ beforeEach(async () => {
+ pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ schema = `river_cli_${randomUUID().replaceAll("-", "")}`;
+ await pool.query(`CREATE SCHEMA "${schema}"`);
+ });
+
+ afterEach(async () => {
+ await pool.query(`DROP SCHEMA IF EXISTS "${schema}" CASCADE`);
+ await pool.end();
+ });
+
+ function database(...argv: string[]): string[] {
+ return [...argv, "--database-url", TEST_DATABASE_URL, "--schema", schema];
+ }
+
+ it("migrates a schema up and down", async () => {
+ await expect(invoke(database("validate"))).resolves.toEqual({
+ exitCode: 1,
+ stderr: "unapplied migrations: 1, 2, 3, 4, 5, 6, 7, 8\n",
+ stdout: "",
+ });
+
+ const up = await invoke(
+ database("migrate-up", "--statement-timeout", "1m")
+ );
+ expect(up).toMatchObject({ exitCode: 0, stderr: "" });
+ expect(up.stdout).toMatch(
+ /^applied migration 001 \[up\] create_river_migration +\[[\d.]+m?s\]\n/
+ );
+ await expect(invoke(database("validate"))).resolves.toEqual({
+ exitCode: 0,
+ stderr: "",
+ stdout: "",
+ });
+ const list = await invoke(database("migrate-list"));
+ expect(list.stdout).toMatch(/\n\* 008 \S+\n$/);
+ const tables = await pool.query(
+ "SELECT 1 FROM information_schema.tables " +
+ "WHERE table_schema = $1 AND table_name = 'river_job'",
+ [schema]
+ );
+ expect(tables.rows).toHaveLength(1);
+
+ const down = await invoke(
+ database("migrate-down", "--target-version", "0", "--show-sql")
+ );
+ expect(down.exitCode).toBe(0);
+ expect(down.stdout).toContain(`DROP TABLE "${schema}".river_migration`);
+ const remaining = await pool.query(
+ "SELECT 1 FROM information_schema.tables WHERE table_schema = $1",
+ [schema]
+ );
+ expect(remaining.rows).toEqual([]);
+ });
+
+ it("migrates an additional line", async () => {
+ const migrations: readonly Migration[] = [
+ {
+ downSql: "DROP TABLE /* TEMPLATE: schema */extra_widget;",
+ name: "create_widget",
+ upSql: "CREATE TABLE /* TEMPLATE: schema */extra_widget (id bigint);",
+ version: 1,
+ },
+ ];
+ const migrationLines = {
+ extra: (backend: string) => (backend === "postgres" ? migrations : []),
+ };
+ const extra = (...argv: string[]) =>
+ invoke(database(...argv, "--line", "extra"), { migrationLines });
+ await invoke(database("migrate-up"));
+
+ await expect(extra("migrate-up")).resolves.toMatchObject({
+ exitCode: 0,
+ stderr: "",
+ });
+ await expect(extra("migrate-list")).resolves.toMatchObject({
+ stdout: "* 001 create_widget\n",
+ });
+ const rows = await pool.query(
+ `SELECT line, version::text FROM "${schema}".river_migration WHERE line = 'extra'`
+ );
+ expect(rows.rows).toEqual([{ line: "extra", version: "1" }]);
+
+ await expect(extra("migrate-down")).resolves.toMatchObject({
+ exitCode: 0,
+ });
+ const after = await pool.query(
+ `SELECT 1 FROM "${schema}".river_migration WHERE line = 'extra'`
+ );
+ expect(after.rows).toEqual([]);
+ await expect(invoke(database("validate"))).resolves.toMatchObject({
+ exitCode: 0,
+ });
+ });
+
+ it("benchmarks a migrated schema after emptying River's tables", async () => {
+ expect((await invoke(database("migrate-up"))).exitCode).toBe(0);
+ await pool.query(
+ `INSERT INTO "${schema}".river_queue (name, updated_at) ` +
+ "VALUES ('bench_sentinel', now())"
+ );
+
+ const result = await invoke(
+ database(
+ "bench",
+ "--yes",
+ "--num-total-jobs",
+ "300",
+ "--max-workers",
+ "50",
+ "--max-connections",
+ "10",
+ "--statement-timeout",
+ "1m"
+ )
+ );
+
+ expect(result.stderr).toBe(
+ `bench: emptying "${schema}"."river_job", "${schema}"."river_leader", ` +
+ `"${schema}"."river_queue", "${schema}"."river_notification"\n`
+ );
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toMatch(
+ /^bench: total jobs worked \[ +300 \], total jobs inserted \[ +300 \], overall job\/sec \[ +[\d.]+ \], p95 \[ +[\d.]+s \], running [\d.]+s$/m
+ );
+ const queues = await pool.query<{ name: string }>(
+ `SELECT name FROM "${schema}".river_queue ORDER BY name`
+ );
+ expect(queues.rows.map(({ name }) => name)).not.toContain("bench_sentinel");
+ const jobs = await pool.query<{
+ args: unknown;
+ kind: string;
+ state: string;
+ }>(
+ `SELECT args, kind, state FROM "${schema}".river_job ORDER BY id LIMIT 2`
+ );
+ expect(jobs.rows).toEqual([
+ { args: { num: 1 }, kind: "benchmark", state: "completed" },
+ { args: { num: 2 }, kind: "benchmark", state: "completed" },
+ ]);
+ expect(process.listenerCount("SIGINT")).toBe(0);
+ });
+
+ it("refuses to benchmark an unmigrated schema", async () => {
+ const result = await invoke(database("bench", "--yes", "-n", "1"));
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "the database is not fully migrated (unapplied migrations: 1, 2, 3, 4, 5, 6, 7, 8); run riverqueue migrate-up first"
+ );
+ });
+});
diff --git a/js/cli/src/run.test.ts b/js/cli/src/run.test.ts
new file mode 100644
index 000000000..b4fdd0daa
--- /dev/null
+++ b/js/cli/src/run.test.ts
@@ -0,0 +1,681 @@
+import { mkdtempSync, rmSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { PassThrough } from "node:stream";
+
+import type { Migration, MigrationBackend } from "@riverqueue/migrate";
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+
+import { parseDuration } from "./options.js";
+import { run, type RunOptions } from "./run.js";
+
+interface Invocation {
+ exitCode: number;
+ stderr: string;
+ stdout: string;
+}
+
+async function invoke(
+ argv: readonly string[],
+ options: {
+ interactive?: boolean;
+ migrationLines?: RunOptions["migrationLines"];
+ program?: string;
+ } = {}
+): Promise {
+ let stderr = "";
+ let stdout = "";
+ const stdin = Object.assign(new PassThrough(), {
+ isTTY: options.interactive ?? false,
+ });
+ const exitCode = await run(argv, {
+ ...(options.migrationLines === undefined
+ ? {}
+ : { migrationLines: options.migrationLines }),
+ ...(options.program === undefined ? {} : { program: options.program }),
+ stderr: {
+ isTTY: options.interactive ?? false,
+ write: (chunk: string) => (stderr += chunk),
+ },
+ stdin,
+ stdout: { write: (chunk: string) => (stdout += chunk) },
+ });
+ return { exitCode, stderr, stdout };
+}
+
+describe("help and version", () => {
+ it.each([[[]], [["--help"]], [["-h"]], [["help"]]])(
+ "prints program help for %j",
+ async (argv) => {
+ const result = await invoke(argv);
+
+ expect(result).toMatchObject({ exitCode: 0, stderr: "" });
+ for (const command of [
+ "bench",
+ "migrate-down",
+ "migrate-get",
+ "migrate-list",
+ "migrate-up",
+ "validate",
+ "version",
+ ]) {
+ expect(result.stdout).toContain(` ${command} `);
+ }
+ expect(result.stdout).toContain("riverqueue [flags]");
+ }
+ );
+
+ it.each([
+ [["migrate-up", "--help"]],
+ [["migrate-up", "-h"]],
+ [["help", "migrate-up"]],
+ ])("prints command help for %j", async (argv) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toContain("riverqueue migrate-up [flags]");
+ expect(result.stdout).toMatch(
+ /--target-version VERSION\s+Version to end at/
+ );
+ expect(result.stdout).not.toContain("--num-total-jobs");
+ });
+
+ it("describes durations without Go jargon", async () => {
+ const result = await invoke(["bench", "--help"]);
+
+ expect(result.stdout.replaceAll(/\s+/g, " ")).toContain(
+ "such as 30s, 5m, or 1h30m"
+ );
+ expect(result.stdout).not.toMatch(/go-style/i);
+ for (const line of result.stdout.split("\n")) {
+ expect(line.length).toBeLessThanOrEqual(80);
+ }
+ });
+
+ it.each([[["--version"]], [["version"]]])(
+ "prints versions for %j",
+ async (argv) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toMatch(
+ /^riverqueue version \d+\.\d+\.\d+\S*\nNode\.js v\d+/
+ );
+ }
+ );
+});
+
+describe("argument errors", () => {
+ it.each([
+ [
+ ["migrate-up", "--bogus-flag"],
+ "unknown flag: --bogus-flag",
+ "migrate-up",
+ ],
+ [["migrate-up", "-x"], "unknown flag: -x", "migrate-up"],
+ [
+ ["migrate-up", "--num-total-jobs", "10"],
+ "unknown flag: --num-total-jobs",
+ "migrate-up",
+ ],
+ [
+ ["migrate-up", "--max-steps"],
+ "flag --max-steps requires a value",
+ "migrate-up",
+ ],
+ [
+ ["migrate-up", "--max-steps", "--dry-run"],
+ "flag --max-steps requires a value; " +
+ 'write --max-steps=VALUE to pass a value that starts with "-"',
+ "migrate-up",
+ ],
+ [
+ ["migrate-up", "--dry-run=yes"],
+ "flag --dry-run does not take a value",
+ "migrate-up",
+ ],
+ [["migrate-up", "extra"], 'unexpected argument: "extra"', "migrate-up"],
+ [
+ ["migrate-up", "--max-steps", "two"],
+ '--max-steps must be a non-negative integer; received "two"',
+ "migrate-up",
+ ],
+ [
+ ["migrate-down", "--target-version=-1"],
+ '--target-version must be a non-negative integer; received "-1"',
+ "migrate-down",
+ ],
+ [["unknown"], 'unknown command: "unknown"', undefined],
+ [["--bogus"], "unknown flag: --bogus", undefined],
+ [["help", "nope"], 'unknown command: "nope"', undefined],
+ ])("rejects %j", async (argv, message, command) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stdout).toBe("");
+ const prefix =
+ command === undefined ? "riverqueue" : `riverqueue ${command}`;
+ expect(result.stderr).toBe(
+ `${prefix}: ${message}\n` +
+ `Run "riverqueue${command === undefined ? "" : ` ${command}`} --help" for usage.\n`
+ );
+ });
+
+ it.each([
+ [
+ ["migrate-up", "--database-url", "mysql://localhost/river"],
+ "--database-url must start with postgres://",
+ ],
+ [
+ ["migrate-up", "--database-url", "sqlite://"],
+ "a SQLite --database-url needs a path",
+ ],
+ [
+ ["migrate-up", "--database-url", "sqlite::memory:"],
+ "--database-url must start with",
+ ],
+ [
+ [
+ "migrate-up",
+ "--database-url",
+ "sqlite://:memory:",
+ "--schema",
+ "river",
+ ],
+ "--schema only applies to PostgreSQL",
+ ],
+ [
+ [
+ "migrate-up",
+ "--database-url",
+ "sqlite://:memory:",
+ "--statement-timeout",
+ "5s",
+ ],
+ "--statement-timeout only applies to PostgreSQL",
+ ],
+ [
+ ["migrate-up", "--database-url", "sqlite://:memory:", "--line", "mian"],
+ "migration line does not exist: mian (available lines: main)",
+ ],
+ [
+ [
+ "migrate-up",
+ "--database-url",
+ "postgres://localhost/river",
+ "--statement-timeout",
+ "10",
+ ],
+ "--statement-timeout must be a duration",
+ ],
+ ])("rejects database arguments %j", async (argv, message) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(message);
+ });
+
+ describe("without --database-url", () => {
+ let pgDatabase: string | undefined;
+
+ beforeEach(() => {
+ pgDatabase = process.env.PGDATABASE;
+ delete process.env.PGDATABASE;
+ });
+
+ afterEach(() => {
+ if (pgDatabase !== undefined) process.env.PGDATABASE = pgDatabase;
+ });
+
+ it("requires a URL unless PGDATABASE is set", async () => {
+ const result = await invoke(["migrate-list"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "--database-url is required unless PGDATABASE"
+ );
+ });
+ });
+});
+
+describe("bench guards", () => {
+ it.each([
+ [
+ ["bench"],
+ "--database-url is required because bench empties River's tables",
+ ],
+ [
+ ["bench", "--database-url", "postgres://localhost/river"],
+ "pass --yes to confirm emptying River's tables",
+ ],
+ [
+ ["bench", "--database-url", "sqlite:///tmp/river.db", "--yes"],
+ "only PostgreSQL databases can be benchmarked",
+ ],
+ [
+ [
+ "bench",
+ "--database-url",
+ "postgres://localhost/river",
+ "--duration",
+ "1s",
+ "-n",
+ "5",
+ ],
+ "pass at most one of --duration and --num-total-jobs",
+ ],
+ [
+ [
+ "bench",
+ "--database-url",
+ "postgres://localhost/river",
+ "--duration",
+ "90",
+ ],
+ '--duration must be a duration such as 500ms, 30s, 5m, or 1h30m; received "90"',
+ ],
+ [
+ ["bench", "--database-url", "postgres://localhost/river", "--dry-run"],
+ "unknown flag: --dry-run",
+ ],
+ ])("rejects %j before touching a database", async (argv, message) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(message);
+ expect(result.stdout).toBe("");
+ });
+
+ it("never reads DATABASE_URL", async () => {
+ const previous = process.env.DATABASE_URL;
+ process.env.DATABASE_URL = "postgres://localhost/river";
+ try {
+ const result = await invoke(["bench", "--yes"]);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain("--database-url is required");
+ } finally {
+ if (previous === undefined) delete process.env.DATABASE_URL;
+ else process.env.DATABASE_URL = previous;
+ }
+ });
+});
+
+describe("parseDuration", () => {
+ it.each([
+ ["500ms", 500],
+ ["30s", 30_000],
+ ["1.5s", 1_500],
+ ["5m", 300_000],
+ ["1h30m", 5_400_000],
+ ["2m0.5s", 120_500],
+ ])("parses %s", (value, expected) => {
+ expect(parseDuration("bench", "--duration", value)).toBe(expected);
+ });
+
+ it.each(["", "30", "0s", "1d", "s", "1s ", "-1s", "0.1ms"])(
+ "rejects %j",
+ (value) => {
+ expect(() => parseDuration("bench", "--duration", value)).toThrow(
+ "--duration must be a duration such as"
+ );
+ }
+ );
+});
+
+describe("migrate-get", () => {
+ it("prints selected versions with schema-qualified SQL", async () => {
+ const result = await invoke([
+ "migrate-get",
+ "--version",
+ "1,2",
+ "--up",
+ "--schema",
+ 'my"schema',
+ ]);
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stdout).toMatch(/^-- River main migration 001 \[up\]\n/);
+ expect(result.stdout).toContain("\n\n-- River main migration 002 [up]\n");
+ expect(result.stdout).toContain(
+ 'CREATE TABLE "my""schema".river_migration'
+ );
+ expect(result.stdout).not.toContain("TEMPLATE");
+ });
+
+ it("prints every down migration newest first, excluding versions", async () => {
+ const result = await invoke([
+ "migrate-get",
+ "--all",
+ "--down",
+ "--exclude-version",
+ "1",
+ "--exclude-version=8",
+ ]);
+
+ const headers = [
+ ...result.stdout.matchAll(/^-- River main migration (\d+)/gm),
+ ].map((match) => match[1]);
+ expect(headers).toEqual(["007", "006", "005", "004", "003", "002"]);
+ });
+
+ it("prints SQLite SQL when the URL selects SQLite", async () => {
+ const postgres = await invoke(["migrate-get", "--version", "2", "--up"]);
+ const sqlite = await invoke([
+ "migrate-get",
+ "--version",
+ "2",
+ "--up",
+ "--database-url",
+ "sqlite://",
+ ]);
+
+ expect(sqlite.exitCode).toBe(0);
+ expect(sqlite.stdout).not.toBe(postgres.stdout);
+ expect(postgres.stdout).toContain("CREATE TYPE");
+ expect(sqlite.stdout).not.toContain("CREATE TYPE");
+ });
+
+ it.each([
+ [["migrate-get", "--up"], "pass exactly one of --all or --version"],
+ [
+ ["migrate-get", "--all", "--version", "1", "--up"],
+ "pass exactly one of --all or --version",
+ ],
+ [["migrate-get", "--all"], "pass exactly one of --up or --down"],
+ [
+ ["migrate-get", "--all", "--up", "--down"],
+ "pass exactly one of --up or --down",
+ ],
+ [
+ ["migrate-get", "--version", "9", "--up"],
+ "migration 9 does not exist (available versions: 1, 2, 3, 4, 5, 6, 7, 8)",
+ ],
+ [
+ ["migrate-get", "--version", "1,x", "--up"],
+ '--version must be a positive integer; received "x"',
+ ],
+ [
+ [
+ "migrate-get",
+ "--all",
+ "--up",
+ "--database-url",
+ "sqlite://",
+ "--schema",
+ "s",
+ ],
+ "--schema only applies to PostgreSQL",
+ ],
+ ])("rejects %j", async (argv, message) => {
+ const result = await invoke(argv);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(message);
+ });
+});
+
+describe("SQLite migrations", () => {
+ let directory: string;
+ let url: string;
+
+ beforeEach(() => {
+ directory = mkdtempSync(join(tmpdir(), "riverqueue-cli-"));
+ url = `sqlite://${join(directory, "river.db")}`;
+ });
+
+ afterEach(() => {
+ rmSync(directory, { force: true, recursive: true });
+ });
+
+ it("migrates up, lists, validates, and migrates down", async () => {
+ const missing = await invoke(["validate", "--database-url", url]);
+ expect(missing).toEqual({
+ exitCode: 1,
+ stderr: "unapplied migrations: 1, 2, 3, 4, 5, 6, 7, 8\n",
+ stdout: "",
+ });
+
+ const dryRun = await invoke([
+ "migrate-up",
+ "--database-url",
+ url,
+ "--dry-run",
+ "--show-sql",
+ "--max-steps",
+ "1",
+ ]);
+ expect(dryRun.exitCode).toBe(0);
+ expect(dryRun.stdout).toContain(
+ "migration 001 [up] create_river_migration [dry run]\n" +
+ "-".repeat(80) +
+ "\n-- River main migration 001 [up]\nCREATE TABLE river_migration"
+ );
+
+ const up = await invoke(["migrate-up", "--database-url", url]);
+ expect(up.exitCode).toBe(0);
+ expect(up.stdout.trim().split("\n")).toHaveLength(8);
+ expect(up.stdout).toMatch(
+ /^applied migration 001 \[up\] create_river_migration\s+\[\d+\.\d+m?s\]$/m
+ );
+
+ const list = await invoke(["migrate-list", "--database-url", url]);
+ expect(list.stdout).toContain(
+ " 007 notification_outbox_sqlite_jsonb_and_sql_cleanup\n* 008 "
+ );
+ await expect(invoke(["validate", "--database-url", url])).resolves.toEqual({
+ exitCode: 0,
+ stderr: "",
+ stdout: "",
+ });
+ await expect(
+ invoke(["migrate-up", "--database-url", url])
+ ).resolves.toMatchObject({
+ exitCode: 0,
+ stdout: "no migrations to apply\n",
+ });
+
+ const down = await invoke(["migrate-down", "--database-url", url]);
+ expect(down.stdout).toMatch(/^applied migration 008 \[down\] /);
+ const toFive = await invoke([
+ "migrate-down",
+ "--database-url",
+ url,
+ "--target-version",
+ "5",
+ "--max-steps",
+ "3",
+ ]);
+ expect(toFive.stdout).toMatch(
+ /^applied migration 007 \[down\] .*\napplied migration 006 \[down\] bulk_unique .*\nno more migrations to apply\n$/
+ );
+ const all = await invoke([
+ "migrate-down",
+ "--database-url",
+ url,
+ "--target-version",
+ "0",
+ ]);
+ expect(all.stdout.trim().split("\n")).toHaveLength(5);
+ const empty = await invoke(["migrate-list", "--database-url", url]);
+ expect(empty.stdout).toMatch(/^001 create_river_migration\n/);
+ });
+
+ it("reports migration failures with their cause", async () => {
+ const result = await invoke([
+ "migrate-down",
+ "--database-url",
+ url,
+ "--target-version",
+ "3",
+ ]);
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toBe(
+ "riverqueue migrate-down: cannot migrate down to version 3 because it is not applied\n"
+ );
+ });
+});
+
+describe("embedding", () => {
+ const EXTRA_MIGRATIONS: readonly Migration[] = [
+ {
+ downSql: "DROP TABLE /* TEMPLATE: schema */extra_widget;",
+ name: "create_widget",
+ upSql:
+ "CREATE TABLE /* TEMPLATE: schema */extra_widget (id INTEGER PRIMARY KEY);",
+ version: 1,
+ },
+ {
+ downSql:
+ "ALTER TABLE /* TEMPLATE: schema */extra_widget DROP COLUMN name;",
+ name: "add_widget_name",
+ upSql:
+ "ALTER TABLE /* TEMPLATE: schema */extra_widget ADD COLUMN name TEXT;",
+ version: 2,
+ },
+ ];
+
+ let backends: MigrationBackend[];
+ let directory: string;
+ let url: string;
+ let migrationLines: RunOptions["migrationLines"];
+
+ beforeEach(() => {
+ backends = [];
+ directory = mkdtempSync(join(tmpdir(), "riverqueue-cli-lines-"));
+ url = `sqlite://${join(directory, "river.db")}`;
+ migrationLines = {
+ extra: (backend) => {
+ backends.push(backend);
+ return EXTRA_MIGRATIONS;
+ },
+ other: () => EXTRA_MIGRATIONS,
+ };
+ });
+
+ afterEach(() => {
+ rmSync(directory, { force: true, recursive: true });
+ });
+
+ it("migrates an additional line selected with --line", async () => {
+ const extra = (...argv: string[]) =>
+ invoke([...argv, "--database-url", url, "--line", "extra"], {
+ migrationLines,
+ });
+ await invoke(["migrate-up", "--database-url", url]);
+
+ const up = await extra("migrate-up");
+ expect(up).toMatchObject({ exitCode: 0, stderr: "" });
+ expect(up.stdout).toMatch(
+ /^applied migration 001 \[up\] create_widget +\[.*\]\napplied migration 002 \[up\] add_widget_name +\[.*\]\n$/
+ );
+ await expect(extra("migrate-list")).resolves.toMatchObject({
+ exitCode: 0,
+ stdout: " 001 create_widget\n* 002 add_widget_name\n",
+ });
+ await expect(extra("validate")).resolves.toMatchObject({ exitCode: 0 });
+ const down = await extra("migrate-down", "--target-version", "0");
+ expect(down.stdout).toMatch(
+ /^applied migration 002 \[down\] add_widget_name .*\napplied migration 001 \[down\] create_widget /
+ );
+ await expect(extra("validate")).resolves.toMatchObject({
+ exitCode: 1,
+ stderr: "unapplied migrations: 1, 2\n",
+ });
+ // The main line is untouched.
+ await expect(
+ invoke(["validate", "--database-url", url], { migrationLines })
+ ).resolves.toMatchObject({ exitCode: 0 });
+ expect(new Set(backends)).toEqual(new Set(["sqlite"]));
+ });
+
+ it("prints an additional line's SQL for each dialect", async () => {
+ const postgres = await invoke(
+ [
+ "migrate-get",
+ "--line",
+ "extra",
+ "--version",
+ "1",
+ "--up",
+ "--schema",
+ "s",
+ ],
+ { migrationLines }
+ );
+ const sqlite = await invoke(
+ [
+ "migrate-get",
+ "--line",
+ "extra",
+ "--all",
+ "--down",
+ "--database-url",
+ "sqlite://",
+ ],
+ { migrationLines }
+ );
+
+ expect(postgres.stdout).toBe(
+ "-- River extra migration 001 [up]\n" +
+ 'CREATE TABLE "s".extra_widget (id INTEGER PRIMARY KEY);\n'
+ );
+ expect(sqlite.stdout).toBe(
+ "-- River extra migration 002 [down]\n" +
+ "ALTER TABLE extra_widget DROP COLUMN name;\n\n" +
+ "-- River extra migration 001 [down]\n" +
+ "DROP TABLE extra_widget;\n"
+ );
+ expect(backends).toEqual(["postgres", "sqlite"]);
+ });
+
+ it("rejects an unknown line, listing the known ones", async () => {
+ const result = await invoke(
+ ["migrate-list", "--database-url", url, "--line", "missing"],
+ { migrationLines }
+ );
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toContain(
+ "migration line does not exist: missing (available lines: main, extra, other)"
+ );
+ });
+
+ it("keeps River's main line", async () => {
+ const result = await invoke(["version"], {
+ migrationLines: { main: () => EXTRA_MIGRATIONS },
+ });
+
+ expect(result.exitCode).toBe(1);
+ expect(result.stderr).toBe(
+ "riverqueue: migrationLines cannot replace River's main line\n"
+ );
+ });
+
+ it("uses the program name in help, version, and errors", async () => {
+ const program = "extension";
+
+ const help = await invoke(["--help"], { program });
+ const commandHelp = await invoke(["migrate-get", "--help"], { program });
+ const version = await invoke(["--version"], { program });
+ const unknown = await invoke(["nope"], { program });
+ const usage = await invoke(["migrate-up", "--bogus"], { program });
+
+ expect(help.stdout).toContain("extension [flags]");
+ expect(help.stdout).toContain('Run "extension --help"');
+ expect(commandHelp.stdout).toContain("extension migrate-get [flags]");
+ expect(commandHelp.stdout).toContain(
+ " extension migrate-get --version 3 --up > river_3.up.sql"
+ );
+ expect(commandHelp.stdout).not.toContain("riverqueue");
+ expect(version.stdout).toMatch(/^extension version /);
+ expect(unknown.stderr).toBe(
+ 'extension: unknown command: "nope"\nRun "extension --help" for usage.\n'
+ );
+ expect(usage.stderr).toBe(
+ "extension migrate-up: unknown flag: --bogus\n" +
+ 'Run "extension migrate-up --help" for usage.\n'
+ );
+ });
+});
diff --git a/js/cli/src/run.ts b/js/cli/src/run.ts
new file mode 100644
index 000000000..be18c34cb
--- /dev/null
+++ b/js/cli/src/run.ts
@@ -0,0 +1,267 @@
+import { readFileSync } from "node:fs";
+
+import {
+ MIGRATION_LINE_MAIN,
+ type Migration,
+ type MigrationBackend,
+} from "@riverqueue/migrate";
+
+import { benchCommand } from "./bench.js";
+import { codemodCommand } from "./codemod-command.js";
+import { writeLine, type Command, type CommandContext } from "./command.js";
+import {
+ migrateDownCommand,
+ migrateGetCommand,
+ migrateListCommand,
+ migrateUpCommand,
+ validateCommand,
+} from "./migrate-commands.js";
+import { formatHelp, parseOptions, UsageError } from "./options.js";
+
+/**
+ * Options for {@link run}. Each stream defaults to the process's own.
+ *
+ * A package that ships its own migration line can embed this command line
+ * with its lines added, as River's Go CLI allows:
+ *
+ * ```ts
+ * import { run } from "@riverqueue/cli";
+ *
+ * process.exitCode = await run(process.argv.slice(2), {
+ * migrationLines: { extension: (backend) => extensionMigrations(backend) },
+ * program: "extension",
+ * });
+ * ```
+ */
+export interface RunOptions {
+ /**
+ * Additional migration lines, by name, that the migration commands select
+ * with `--line`. Each returns the line's migrations for a backend,
+ * versioned from 1. River's bundled `main` line is always available and
+ * cannot be replaced.
+ */
+ readonly migrationLines?: Readonly<
+ Record readonly Migration[]>
+ >;
+ /**
+ * Program name shown in help, version output, and error messages.
+ * Defaults to `riverqueue`.
+ */
+ readonly program?: string;
+ /** Receives errors, warnings, and prompts. */
+ readonly stderr?: {
+ readonly isTTY?: boolean;
+ write(chunk: string): unknown;
+ };
+ /** Answers `bench`'s confirmation prompt when it is a terminal. */
+ readonly stdin?: NodeJS.ReadableStream & { readonly isTTY?: boolean };
+ /** Receives command output. */
+ readonly stdout?: { write(chunk: string): unknown };
+}
+
+const PROGRAM = "riverqueue";
+
+const versionCommand: Command = {
+ description: "Print the CLI and Node.js versions.",
+ name: "version",
+ options: {},
+ summary: "Print version information",
+ run: async (_values, context) => {
+ printVersion(context);
+ return 0;
+ },
+};
+
+const COMMANDS: ReadonlyMap = new Map(
+ [
+ benchCommand,
+ codemodCommand,
+ migrateDownCommand,
+ migrateGetCommand,
+ migrateListCommand,
+ migrateUpCommand,
+ validateCommand,
+ versionCommand,
+ ].map((command) => [command.name, command])
+);
+
+/**
+ * Run the `riverqueue` command line with `argv`, the arguments after the
+ * program name, and resolve to the process exit code.
+ *
+ * Errors are reported on `stderr` rather than thrown. `bench` handles
+ * `SIGINT` and `SIGTERM` while it runs so it can stop cleanly and print its
+ * summary, and removes its handlers when it finishes.
+ *
+ * ```ts
+ * process.exitCode = await run(process.argv.slice(2));
+ * ```
+ */
+export async function run(
+ argv: readonly string[],
+ options: RunOptions = {}
+): Promise {
+ const context: CommandContext = {
+ env: process.env,
+ migrationLines: options.migrationLines ?? {},
+ program: options.program ?? PROGRAM,
+ stderr: options.stderr ?? process.stderr,
+ stdin: options.stdin ?? process.stdin,
+ stdout: options.stdout ?? process.stdout,
+ };
+ const [name, ...rest] = argv;
+ let command: Command | undefined;
+ try {
+ if (Object.hasOwn(context.migrationLines, MIGRATION_LINE_MAIN)) {
+ throw new Error(
+ `migrationLines cannot replace River's ${MIGRATION_LINE_MAIN} line`
+ );
+ }
+ if (name === undefined || name === "--help" || name === "-h") {
+ context.stdout.write(programHelp(context.program));
+ return 0;
+ }
+ if (name === "--version") {
+ printVersion(context);
+ return 0;
+ }
+ if (name === "help") {
+ return printCommandHelp(context, rest);
+ }
+ command = COMMANDS.get(name);
+ if (command === undefined) {
+ throw new UsageError(
+ name.startsWith("-")
+ ? `unknown flag: ${name}`
+ : `unknown command: ${JSON.stringify(name)}`
+ );
+ }
+
+ const { help, positionals, values } = parseOptions(
+ command.name,
+ command.options,
+ rest,
+ command.positionals !== undefined
+ );
+ if (help) {
+ context.stdout.write(commandHelp(context.program, command));
+ return 0;
+ }
+ return await command.run(values, context, positionals);
+ } catch (error: unknown) {
+ reportError(context, error, command?.name);
+ return 1;
+ }
+}
+
+function commandHelp(program: string, command: Command): string {
+ return formatHelp({
+ description: command.description.replaceAll("{program}", program),
+ options: command.options,
+ usage: `${program} ${command.name} [flags]${
+ command.positionals === undefined ? "" : ` ${command.positionals}`
+ }`,
+ });
+}
+
+function describeError(error: unknown): string {
+ const messages: string[] = [];
+ let current: unknown = error;
+ while (current !== undefined && messages.length < 5) {
+ if (current instanceof Error) {
+ const message = current.message;
+ if (message !== "" && !messages.includes(message)) messages.push(message);
+ current = current.cause;
+ } else {
+ // eslint-disable-next-line @typescript-eslint/no-base-to-string -- reports any thrown value
+ messages.push(String(current));
+ break;
+ }
+ }
+ return messages.join(": ");
+}
+
+function printCommandHelp(
+ context: CommandContext,
+ names: readonly string[]
+): number {
+ const [name, ...extra] = names;
+ if (name === undefined) {
+ context.stdout.write(programHelp(context.program));
+ return 0;
+ }
+ const command = COMMANDS.get(name);
+ if (command === undefined || extra.length > 0) {
+ throw new UsageError(
+ command === undefined
+ ? `unknown command: ${JSON.stringify(name)}`
+ : `unexpected argument: ${JSON.stringify(extra[0])}`
+ );
+ }
+ context.stdout.write(commandHelp(context.program, command));
+ return 0;
+}
+
+function printVersion(context: CommandContext): void {
+ writeLine(context.stdout, `${context.program} version ${packageVersion()}`);
+ writeLine(context.stdout, `Node.js ${process.version}`);
+}
+
+function packageVersion(): string {
+ try {
+ const manifest: unknown = JSON.parse(
+ readFileSync(new URL("../package.json", import.meta.url), "utf8")
+ );
+ if (
+ typeof manifest === "object" &&
+ manifest !== null &&
+ "version" in manifest &&
+ typeof manifest.version === "string"
+ ) {
+ return manifest.version;
+ }
+ } catch {
+ // Fall through to an unknown version.
+ }
+ return "(unknown)";
+}
+
+function programHelp(program: string): string {
+ const commands = [...COMMANDS.values()].sort((left, right) =>
+ left.name.localeCompare(right.name)
+ );
+ const width = Math.max(...commands.map(({ name }) => name.length)) + 2;
+ return formatHelp({
+ description: `
+Command-line tools for River, the job queue for PostgreSQL and SQLite.
+
+Commands:
+${commands.map(({ name, summary }) => ` ${name.padEnd(width)}${summary}`).join("\n")}
+
+Commands that use a database take --database-url. PostgreSQL commands other
+than bench also read the standard PG* environment variables when PGDATABASE
+is set. Run "${program} --help" for a command's flags, and
+"${program} --version" for version information.`,
+ usage: `${program} [flags]`,
+ });
+}
+
+function reportError(
+ context: CommandContext,
+ error: unknown,
+ commandName: string | undefined
+): void {
+ const { program } = context;
+ const prefix =
+ commandName === undefined ? program : `${program} ${commandName}`;
+ writeLine(context.stderr, `${prefix}: ${describeError(error)}`);
+ if (error instanceof UsageError) {
+ const helpCommand = error.command ?? commandName;
+ writeLine(
+ context.stderr,
+ helpCommand === undefined
+ ? `Run "${program} --help" for usage.`
+ : `Run "${program} ${helpCommand} --help" for usage.`
+ );
+ }
+}
diff --git a/js/cli/tsconfig.json b/js/cli/tsconfig.json
new file mode 100644
index 000000000..015e9bcd6
--- /dev/null
+++ b/js/cli/tsconfig.json
@@ -0,0 +1,9 @@
+{
+ "extends": "../tsconfig.base.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "exclude": ["src/**/*.integration.test.ts", "src/**/*.test.ts"],
+ "include": ["src"]
+}
diff --git a/js/docs/README.md b/js/docs/README.md
new file mode 100644
index 000000000..ad0e609d8
--- /dev/null
+++ b/js/docs/README.md
@@ -0,0 +1,502 @@
+# River for JavaScript and TypeScript: guide
+
+This guide covers defining, inserting, and working jobs. The
+[README](../README.md) has requirements and a runnable quickstart; the topic
+guides linked at the end go deeper.
+
+## Define jobs
+
+A job definition is an immutable value naming a job `kind` and describing its
+arguments. Producers and workers both import it. It holds no client, pool, or
+handler, so a web server can import it without worker dependencies.
+
+Validate arguments with any [Standard Schema](https://standardschema.dev)
+library (Zod, Valibot, ArkType, ...):
+
+```ts
+import { defineJob } from "riverqueue";
+import { z } from "zod";
+
+export const sendEmail = defineJob({
+ kind: "send_email",
+ schema: z.object({
+ messageId: z.string(),
+ to: z.email(),
+ }),
+ defaults: { maxAttempts: 5, queue: "email" },
+});
+```
+
+River runs the schema when a job is inserted and again before it is worked,
+because another producer (an older deploy, a Go or Rust service, or a SQL
+script) may have inserted it. Producers pass the schema's input type; workers
+receive its output type, so schema defaults and transforms apply to what the
+worker sees. River persists the producer's input exactly as given, so
+uniqueness hashes and other languages see the same JSON.
+
+The schema's input must be JSON. A schema with a `Date` or `bigint` input is
+rejected when you call `defineJob`; validate the JSON shape (for example an
+ISO string) and convert it in the schema's output or in the worker.
+
+Without a validation library, write a decoder. It receives the persisted JSON
+object and returns the worker's arguments, or throws to reject them:
+
+```ts
+import { defineJob } from "riverqueue";
+
+export const resizeImage = defineJob({
+ kind: "resize_image",
+ decode(value) {
+ const { url, width } = value;
+ if (typeof url !== "string" || typeof width !== "number") {
+ throw new TypeError("expected { url: string, width: number }");
+ }
+ return { url, width };
+ },
+});
+```
+
+Producers insert the decoder's return type. When the decoder returns
+something that is not JSON, declare the producer type separately with
+`defineJob()`:
+
+```ts
+import { defineJob } from "riverqueue";
+
+interface ReportInput {
+ reportId: string;
+ since: string; // ISO 8601
+}
+
+export const buildReport = defineJob()({
+ kind: "build_report",
+ decode(value) {
+ if (typeof value.reportId !== "string" || typeof value.since !== "string") {
+ throw new TypeError("expected { reportId, since }");
+ }
+ return {
+ reportId: value.reportId,
+ since: Temporal.Instant.from(value.since),
+ };
+ },
+});
+```
+
+`defineJob()({ kind })` without a decoder types producers only: its
+workers receive an unvalidated `JsonObject`, because a type annotation cannot
+check what another producer stored. `defineJob({ kind })` accepts any JSON
+object on both sides.
+
+Kinds are persisted and shared with other languages, so treat them as part of
+your data model. A kind starts with a letter, digit, or underscore, and kinds
+starting with `river_internal_` are reserved.
+
+To rename a kind without orphaning jobs already stored under the old name,
+make the new name the `kind` and list the old one in `kindAliases`, like River
+for Go's `JobArgsWithKindAliases`. New jobs are inserted under the new kind,
+and the definition's worker also works jobs stored under the alias. Remove the
+alias once those jobs have finished, including their retries:
+
+```ts
+import { defineJob } from "riverqueue";
+
+export const sendInvoice = defineJob({
+ kind: "send_invoice",
+ kindAliases: ["email_invoice"],
+});
+```
+
+## Insert jobs
+
+
+
+```ts
+const result = await client.insert(
+ sendEmail,
+ { messageId: "msg_123", to: "person@example.com" },
+ { priority: 1, queue: "email", tags: ["welcome"] }
+);
+
+if (result.status === "inserted") {
+ console.log(result.job.id); // bigint
+}
+```
+
+Insertion options resolve per option, most specific first: the call's options,
+then the definition's `defaults`, then the client's `defaultInsertOptions`,
+then River's defaults (queue `default`, priority 1, 25 attempts).
+
+| Option | Meaning |
+| ------------- | ------------------------------------------------------------------- |
+| `queue` | Queue the job is worked from |
+| `priority` | 1 (highest) through 4 |
+| `maxAttempts` | Attempts before the job is discarded, including the first |
+| `scheduledAt` | When the job becomes available, as a `Temporal.Instant` or a `Date` |
+| `delay` | Or, how long from now, such as `{ minutes: 5 }` |
+| `tags` | Labels for querying |
+| `metadata` | Application JSON stored with the job |
+| `pending` | Insert as `pending`, for another process to make available |
+| `unique` | Deduplicate against existing jobs; see below |
+
+### Unique jobs
+
+`unique` rejects a job that duplicates an existing one instead of inserting
+it; the result has `status: "duplicate"` and the existing job:
+
+
+
+```ts
+const result = await client.insert(
+ syncAccount,
+ { accountId: "acct_1" },
+ { unique: { byArgs: true, byPeriod: { hours: 1 } } }
+);
+if (result.status === "duplicate") {
+ console.log(`already queued as ${result.job.id}`);
+}
+```
+
+Uniqueness is always by kind (unless `excludeKind`), and optionally by all
+args or selected `byArgs` paths (such as `["account.id"]`), by `byQueue`, and
+within fixed `byPeriod` windows. Like River for Go, `excludeKind` requires
+`byArgs`, `byQueue`, or `byPeriod`. `byState` chooses which job states count as
+duplicates; the default is every state except `cancelled` and `discarded`.
+Unique keys are computed exactly as River for Go computes them, so uniqueness
+holds across languages.
+
+By-args uniqueness hashes the JSON River writes. Top-level keys are sorted, but
+nested objects are hashed in their own key order, so jobs from different
+languages deduplicate only when they encode nested objects identically. Go
+writes struct fields in declaration order and map keys sorted; JavaScript writes
+object keys in insertion order, except that keys that look like array indices
+(`"2"`, `"10"`) always come first in ascending numeric order. Keep nested keys
+in the same order in every producer, and avoid integer-like keys in nested
+objects that must deduplicate against another language.
+
+With `byArgs: true`, every top-level key is hashed literally, including empty
+keys and keys containing JSON path punctuation. A selected `byArgs` path uses
+an unescaped dot to reach a nested field: `"account.id"` selects `id` within
+`account`. Escape a dot to select a literal key containing one:
+`"account\\.id"` selects the top-level key `account.id`. Use `"\\\\"` for
+a literal backslash in a key. Empty path segments and a trailing backslash
+are invalid. So is a segment that is an unsigned integer or `-1`, escaped or
+not: River for Go reads it as an array index and builds a JSON array instead
+of an object when assembling the selected fields, so the keys wouldn't
+match.
+
+### Batches
+
+`insertMany` inserts a batch atomically and returns results in input order;
+an empty batch returns immediately. Items may use different definitions, and
+each item's `args` is checked against its own definition:
+
+
+
+```ts
+const results = await client.insertMany([
+ { args: { to: "a@example.com" }, job: sendEmail },
+ { args: { accountId: "acct_1" }, job: syncAccount, options: { priority: 2 } },
+]);
+console.log(results.map(({ status }) => status));
+```
+
+### Transactions
+
+Insert jobs in the same transaction as the application writes that caused
+them, so both commit or neither does. River never begins, commits, or rolls
+back your transaction. With `node-postgres`, pass any client that ran `BEGIN`
+(a `PoolClient` or a `pg.Client`):
+
+
+
+```ts
+const tx = await pool.connect();
+try {
+ await tx.query("BEGIN");
+ await tx.query("INSERT INTO accounts (id) VALUES ($1)", ["acct_1"]);
+ await client.insert(syncAccount, { accountId: "acct_1" }, { tx });
+ await tx.query("COMMIT");
+} catch (error) {
+ await tx.query("ROLLBACK");
+ throw error;
+} finally {
+ tx.release();
+}
+```
+
+Like River for Go, River runs its statements directly in your transaction and
+opens no savepoint or nested transaction in it, which on PostgreSQL would cost
+a subtransaction for every call. When a call fails after River wrote to the
+database, for example because insert middleware or an `afterInsert` hook threw
+after the job was inserted, the write stays in your transaction, so roll it
+back as above. On PostgreSQL a database error also aborts the transaction. To
+recover from a failed call and continue the transaction, wrap the call in a
+savepoint of your own. Without `{ tx }`, River's own transaction rolls the
+whole call back.
+
+The job's insert notification is delivered when the transaction commits, so
+workers never see a job whose transaction rolled back. Prisma and SQLite use
+the same `{ tx }` option; see the [Prisma](../driver/prisma/README.md) and
+[SQLite](../driver/sqlite/README.md) drivers.
+
+## Work jobs
+
+Register a handler per definition. Its context carries the validated `job`
+(`job.args`, plus the persisted JSON in `job.rawArgs`), an `AbortSignal`, a
+`logger` bound to the job, and the `client` for inserting follow-up jobs:
+
+
+
+```ts
+import { Workers, complete, snooze } from "riverqueue";
+
+const workers = new Workers().add(
+ sendEmail,
+ async ({ job, logger, signal }) => {
+ const response = await mailer.send(job.args, { signal });
+ if (response.rateLimited) return snooze({ seconds: 30 });
+
+ logger.info({ providerId: response.id }, "sent");
+ return complete({ output: { providerId: response.id } });
+ },
+ { timeout: { minutes: 2 } }
+);
+```
+
+Resolving completes the job; throwing or rejecting fails the attempt and
+schedules a retry. Handlers may instead return `complete({ output })`,
+`snooze(duration)` (run again later without using an attempt),
+`discard({ reason })`, or `cancel({ reason })`. See
+[errors, retries, and cancellation](./errors-and-retries.md).
+
+Pass the `signal` to every cancel-aware operation. It aborts when the job's
+timeout expires, when the job is cancelled from anywhere in the fleet, and
+when the client stops with `mode: "cancel"`.
+
+`ctx.recordOutput` stores JSON output even when the attempt fails, and
+`ctx.setMetadata` merges into the job's metadata.
+
+`ctx.completeTx(tx)` completes the job inside your own transaction, so the
+job's completion commits atomically with the handler's writes:
+
+
+
+```ts
+import type { PoolClient } from "pg";
+import { Workers } from "riverqueue";
+
+const workers = new Workers().add(
+ chargeCard,
+ async ({ completeTx, job }) => {
+ const tx = await pool.connect();
+ try {
+ await tx.query("BEGIN");
+ await tx.query("INSERT INTO charges (amount_cents) VALUES ($1)", [
+ job.args.amountCents,
+ ]);
+ await completeTx(tx);
+ await tx.query("COMMIT");
+ } catch (error) {
+ await tx.query("ROLLBACK");
+ throw error;
+ } finally {
+ tx.release();
+ }
+ }
+);
+```
+
+If the transaction rolls back, so does the completion, and River records the
+attempt from the handler's result as usual: a thrown error fails it and a
+normal return completes it. `new Workers()` types `completeTx`
+(and `ctx.client`) for node-postgres. Without it, `Workers` accepts the
+transaction of any installed River driver, so with both the PostgreSQL and
+SQLite drivers installed, passing a SQLite transaction to a PostgreSQL client's
+handler would compile and then fail at runtime.
+
+## Run workers
+
+Configure queues and start the client. `maxWorkers` bounds concurrent
+handlers per queue in this process:
+
+
+
+```ts
+const client = new Client(new PgDriver(pool), {
+ queues: {
+ default: { maxWorkers: 100 },
+ email: { maxWorkers: 20, pollInterval: { seconds: 2 } },
+ },
+ workers,
+});
+
+await using run = await client.start();
+await run.addQueue("reports", { maxWorkers: 5 });
+console.log(run.diagnostics.queues);
+```
+
+`client.start()` returns a `RunHandle`. `run.completed` settles when the
+runtime stops and rejects if it fails; `run.stop()` stops gracefully (running
+jobs finish; pass `timeout` to bound the wait and `mode: "cancel"` to abort
+them), and `await using` stops it when the scope ends. A client starts at most
+once. See [the runtime guide](./runtime.md) for shutdown, concurrency, and
+the event loop.
+
+## Query and control jobs
+
+Job operations live on `client.jobs` and queue operations on `client.queues`.
+IDs are `bigint`, missing rows are `null`, and list pagination uses an opaque
+cursor. Each accepts `{ tx }`:
+
+
+
+```ts
+const page = await client.jobs.list({
+ kinds: ["send_email"],
+ limit: 100,
+ states: ["available", "retryable"],
+});
+for (const job of page.jobs) console.log(job.id, job.state);
+
+if (page.nextCursor !== null) {
+ await client.jobs.list({ after: page.nextCursor, limit: 100 });
+}
+
+const job = await client.jobs.get(9_007_199_254_740_993n);
+if (job !== null) {
+ await client.jobs.retry(job.id);
+ await client.jobs.cancel(job.id); // also aborts a running attempt
+}
+
+await client.queues.pause("email"); // or "*" for every queue
+await client.queues.resume("email");
+```
+
+A job list cursor is the same text River for Go's `JobListCursor` and River
+for Rust's `JobListCursor` produce, so a service in one language can hand a
+page token to a service in another to continue the listing. River accepts
+cursors in either base64 alphabet, with or without padding. Queue list
+cursors are specific to JavaScript.
+
+Like River for Go, `orderBy: "time"` sorts every listed job by the time
+field of the first listed state (`scheduledAt` for `available`, `pending`,
+`retryable`, and `scheduled`, `attemptedAt` for `running`, `finalizedAt`
+for finalized states), and by `scheduledAt` when `states` is empty. Jobs
+where that field is null, like `finalizedAt` for an unfinished job, sort
+after all others ascending and before all others descending.
+
+## Exact values and JSON
+
+River never exposes a lossy database value:
+
+- job IDs and other 64-bit integers are `bigint`;
+- timestamps are `Temporal.Instant`, with PostgreSQL's microseconds; and
+- job args and metadata are JSON, typed as `JsonValue`/`JsonObject`.
+
+At every JSON boundary River rejects `bigint`, non-finite numbers, integers
+beyond `Number.MAX_SAFE_INTEGER`, cycles, sparse arrays, accessors, class
+instances, and invalid Unicode instead of silently coercing them. Like
+`JSON.stringify`, it omits object properties whose value is `undefined`.
+
+A number another producer stored that JavaScript cannot represent exactly
+(such as a Go `int64` above 2^53) arrives as an `ExactJsonNumber`, so reading
+and re-inserting it never changes it. Validated args only contain one if your
+schema accepts it. For untyped `JsonObject` args, use `isJsonNumber` and
+`jsonNumberToBigInt`:
+
+```ts
+import { isJsonNumber, jsonNumberToBigInt, parseJsonObject } from "riverqueue";
+
+const args = parseJsonObject('{"userId":9223372036854775807}');
+if (isJsonNumber(args.userId)) {
+ console.log(jsonNumberToBigInt(args.userId)); // 9223372036854775807n
+}
+```
+
+`JSON.stringify` cannot encode `bigint`. Use `jobToJsonValue(job)` for a
+JSON-safe copy of a job (IDs as decimal strings, instants as ISO strings) and
+`jobFromJsonValue` to restore it. River never patches
+`BigInt.prototype.toJSON`.
+
+## More guides
+
+- [Errors, retries, timeouts, and cancellation](./errors-and-retries.md)
+- [Periodic jobs](./periodic-jobs.md)
+- [Resumable jobs](./resumable-jobs.md)
+- [Testing](./testing.md)
+- [Runtime, concurrency, and the event loop](./runtime.md)
+- [Databases, pools, and migrations](./databases.md)
+- [Logging, events, and metrics](./observability.md)
+- [Running alongside Go and Rust](./deployment.md)
+- [Migrating from `riverqueue` 0.1](./migrating-from-0.1.md)
+
+Runnable examples:
+
+- [PostgreSQL worker](../examples/pg-worker/README.md)
+- [node-postgres insertion and transactions](../examples/node-postgres/README.md)
+- [Prisma insertion and transactions](../examples/prisma/README.md)
+- [SQLite worker](../examples/sqlite-worker/README.md)
+- [Graceful shutdown](../examples/graceful-shutdown/README.md)
+- [Hooks and metrics](../examples/hooks-metrics/README.md)
+- [CPU work on worker threads](../examples/worker-thread-cpu/README.md)
+- [A payload shared with Go](../examples/mixed-language/README.md)
diff --git a/js/docs/databases.md b/js/docs/databases.md
new file mode 100644
index 000000000..9bc3da5f6
--- /dev/null
+++ b/js/docs/databases.md
@@ -0,0 +1,109 @@
+# Databases, pools, and migrations
+
+Each database lives in its own driver package, so an application installs only
+the database it uses. The core API never exposes one database's types as
+universal: a client's transaction type comes from its driver.
+
+## PostgreSQL
+
+`@riverqueue/driver-pg` is the complete `node-postgres` runtime. The
+application owns the pool; River never ends it. River reads timestamps in
+PostgreSQL's default ISO `DateStyle` and rejects any other with a
+`ConfigurationError`; a server configured otherwise needs `DateStyle=ISO` for
+River's connections, such as through the pool's
+`options: "-c DateStyle=ISO"`.
+
+### Pool sizing
+
+River doesn't hold a connection per running job: handlers use your pool for
+their own queries, and River borrows connections briefly to claim and complete
+jobs. It holds a few connections for longer:
+
+- one for `LISTEN` notifications (unless `pollOnly: true`);
+- one for maintenance while this client is the leader;
+- one more while the leader rebuilds indexes;
+- at most two completion queries at a time, each borrowing briefly.
+
+`client.start()` refuses a pool whose `max` is below what the enabled services
+need. A practical size for a worker process is
+
+```text
+max = (connections your handlers use concurrently) + 5
+```
+
+For example, 50 workers whose handlers each run one query at a time need
+about 55 connections, not counting anything else the process does with the
+same pool. Watch pool wait time (`pool.waitingCount`) before raising
+`maxWorkers`; a starved pool slows claims and completions for every queue.
+Producer-only processes need no extra connections for River.
+
+For PgBouncer transaction mode, ordinary queries may use the pooled endpoint,
+but session-scoped notifications and leadership need a direct or session-mode
+connection. A custom schema must be configured consistently in the driver,
+migrator, and CLI.
+
+### YugabyteDB
+
+River's PostgreSQL drivers detect YugabyteDB from the server's `version()`
+the first time they need to, and cache what they find for the driver's
+lifetime, like River for Go. YugabyteDB has no `xmax`, so a unique insertion
+marks each row with a `river:unique_nonce` metadata value instead, as on
+SQLite. Without `LISTEN`/`NOTIFY`, which needs YugabyteDB 2025.2.3 or later
+with `ysql_yb_enable_listen_notify=true` on both masters and tservers, River
+sends no notifications and a client polls even without `pollOnly: true`: it
+claims new jobs every queue's `pollInterval`, rereads queue pauses, resumes,
+and metadata, and checks its running jobs for cancellations every
+`queueControlPollInterval` (two seconds by default), including while a stop
+drains them. After enabling notifications, construct a new driver, for
+example by restarting, so River detects them. On PostgreSQL 18 and later,
+unique insertions read the conflicting row through `RETURNING OLD`.
+
+On PostgreSQL, `@riverqueue/migrate` serializes concurrent migrators with a
+transaction-scoped advisory lock. YugabyteDB has advisory locks only behind a
+preview flag, so there, like River for Go's migrator everywhere, it takes no
+lock; run one migrator at a time.
+
+`@riverqueue/driver-prisma` is deliberately producer-only. It lets application
+writes and job insertion share a Prisma transaction, but it does not claim or
+work jobs. Use the PostgreSQL runtime in worker services.
+
+## SQLite
+
+`@riverqueue/driver-sqlite` uses Node's synchronous `DatabaseSync`. Each
+statement briefly blocks the event loop, so run heavily loaded SQLite workers
+in a process separate from latency-sensitive HTTP handling.
+
+Like River for Go, River runs on a private connection to the application's
+database file, in WAL mode, so application statements never join River's
+transactions. Pass an application handle with an open transaction as `{ tx }`
+to insert jobs with application rows; the driver's `transaction` helper begins
+one with `BEGIN IMMEDIATE`. When another connection or process (for example a
+Go River client, or an application transaction) holds SQLite's write lock,
+River waits with an asynchronous backoff instead of blocking the event loop,
+and fails with a retryable error after `busyTimeout`. For in-memory databases,
+use `SqliteDriver.memory()`.
+
+An insertion without `{ tx }` holds SQLite's write lock from its first
+statement until it commits, and its insert middleware and hooks run inside
+that transaction. On SQLite they must not await I/O after `next()`. See the
+[driver's README](../driver/sqlite/README.md) for the transaction model.
+
+River's SQLite tables live in the database's main schema. On SQLite, River
+for Go's `Config.Schema` can place them in an attached database instead, but
+`SqliteDriver` has no schema option, so Go clients sharing queues with
+JavaScript on SQLite must leave `Schema` unset, as with River for Rust.
+
+SQLite timestamps have millisecond precision. IDs remain `bigint`, and public
+instants remain `Temporal.Instant`. Backend-specific capability differences are
+reported explicitly instead of being silently emulated.
+
+## Migrations
+
+Run `@riverqueue/migrate` or `@riverqueue/cli` as an explicit deployment step.
+Ordinary client construction and startup never migrate. The JavaScript bundle
+is a generated mirror of River's canonical migrations and is checked for drift;
+do not maintain a second hand-edited migration history.
+
+Pin runtime and migration packages to the same River version line. In a
+mixed-language deployment, apply only migrations supported by every still-live
+binary and preserve the normal expand/deploy/contract rollout order.
diff --git a/js/docs/deployment.md b/js/docs/deployment.md
new file mode 100644
index 000000000..6d4e27ec0
--- /dev/null
+++ b/js/docs/deployment.md
@@ -0,0 +1,81 @@
+# Running alongside Go and Rust
+
+JavaScript, Go, and Rust River services share the database protocol, job states,
+uniqueness rules, exact integer/timestamp representation, and migration line.
+They do not need identical source-level API names. Treat persisted job kinds and
+JSON payload schemas as service contracts.
+
+## Unique arguments and resumable work
+
+Uniqueness hashes depend on the selected argument paths and serialized JSON.
+River sorts top-level keys but preserves nested object order, matching Go.
+When selecting a whole nested object, match its key order to the Go producer's
+struct or map serialization. Select scalar leaf paths (for example,
+`byArgs: ["account.id", "account.region"]`) when nested object order should not
+be part of identity. Missing selected fields and explicit `null` are distinct.
+
+Resumable step names and cursor shapes must also match between implementations.
+Keep names stable and await steps sequentially; nested steps are supported,
+but concurrent steps do not define a checkpoint order. Catching a step error
+does not make the attempt successful: River retains the failure and checkpoint.
+The test helper returns updated metadata so a subsequent test attempt can
+resume with the same persisted state as a real worker.
+
+## Rollout
+
+Pin all River packages in a JavaScript application to one exact version line.
+Before a mixed rollout, verify that every live language implementation supports
+the target migration and capabilities. Apply compatible expand migrations,
+deploy producers that can still be read by old workers, deploy workers, and
+only then remove old payload shapes or database compatibility.
+
+Keep schemas and decoders tolerant during the transition and make new fields
+optional or versioned. A TypeScript rename does not migrate persisted
+payloads. Test both directions (a job inserted in one language and worked in
+the other) with the payloads your services actually produce.
+
+## Periodic jobs
+
+Only the elected leader inserts periodic jobs, and leadership moves between
+languages. Register the same periodic jobs in every language that may lead,
+or keep them in one language and set `leaderElectionDisabled: true` on the
+others; see
+[periodic jobs](./periodic-jobs.md#mixed-language-fleets).
+
+## Rollback and rescue
+
+Retain the previous compatible binaries until the new fleet has completed its
+mixed-version soak. A rollback must not cross a migration that the previous
+binary cannot read. Rescue abandoned attempts through River's normal attempt
+identity and state transitions rather than direct ad hoc table updates.
+
+## Maintenance in mixed fleets
+
+Leadership is shared across languages: whichever client is elected, Go, Rust,
+or JavaScript, runs the rescuer, cleaners, and scheduler for the whole
+database, and the rescuer handles every stuck job regardless of which language
+was working it. As in Go, a stuck job whose kind the leading client has no
+worker for is discarded instead of retried.
+
+When job kinds are specific to one language, keep each language's kinds in
+queues that only that language's clients work, and register the other
+language's kinds on every client that may lead. A client claims only from its
+own queues, so such a placeholder handler never runs; it only lets the rescuer
+apply the normal retry policy. Give it the same timeout as the real worker so
+the rescuer waits just as long before treating the job as stuck. This applies
+to both PostgreSQL and SQLite.
+
+JavaScript uses `bigint` and `Temporal.Instant` so it does not silently truncate
+values produced by Go or Rust. Serialize with River's JSON-safe helpers at HTTP,
+logging, or RPC boundaries; ordinary `JSON.stringify` cannot encode `bigint`.
+
+## Production checklist
+
+- run explicit migrations before workers start;
+- bound worker, pool, completion, subscription, and worker-thread concurrency;
+- observe fatal runtime completion and graceful-shutdown deadlines;
+- compare single-process and multi-process benchmark results on the intended
+ database and workload;
+- verify no unbounded heap, listener, locked-job, or connection growth in a
+ sustained soak; and
+- exercise a real rollback and mixed-language rescue before relying on it.
diff --git a/js/docs/development.md b/js/docs/development.md
new file mode 100644
index 000000000..31580b72c
--- /dev/null
+++ b/js/docs/development.md
@@ -0,0 +1,245 @@
+# River TypeScript development
+
+## Setup
+
+The JavaScript workspace lives in `js/` of the River repository, next to the
+Go and Rust implementations. Run the `pnpm` commands below from `js/`, or use
+River's top-level `make` targets (`make lint/js`, `make test/js`, and so on),
+which delegate to `pnpm -C js`.
+
+Use an official Node.js 26 build, which includes native Temporal. The
+repository's `.node-version` selects Node 26 for version managers such as fnm,
+nvm (`nvm use $(cat .node-version)`), and `actions/setup-node`. Some Node.js
+builds compiled from source, including some distribution and Homebrew
+packages, lack Temporal, and most unit tests then fail with
+`ReferenceError: Temporal is not defined`. Check before installing, then use
+the pnpm version pinned by `packageManager` in `package.json`:
+
+ node -p "typeof Temporal" # must print "object"
+ pnpm install
+
+TypeScript 6.0 is the minimum compiler for the published declarations, which
+reference the `esnext.temporal` library. The workspace builds with TypeScript 6
+and also typechecks with the TypeScript-next preview (`typecheck:next`).
+
+## Commands
+
+```sh
+pnpm run build # Build the core package
+pnpm run build:all # Build all public packages
+pnpm run clean:all # Clean all build output
+pnpm run fmt # Format code with Prettier
+pnpm run fmt:check # Check formatting (for CI)
+pnpm run lint # Run ESLint
+pnpm run lint:fix # Run ESLint with auto-fix
+pnpm run test # Run unit tests
+pnpm run test:coverage # Run unit tests with line/branch coverage
+pnpm run test:integration # Run integration tests (requires database)
+pnpm run verify:migrations # Verify generated migration files and hashes
+pnpm run migration:legacy # Verify and compile the pinned 0.1.0 fixture
+pnpm run docs:snippets # Typecheck package README examples
+pnpm run api:report # Regenerate the etc/*.api.md declaration reports
+pnpm run package:check # Validate tarballs, consumers, and examples
+pnpm audit # Check all dependency advisories
+```
+
+ESLint's type-aware rules and `typecheck:tests` read `riverqueue` through its
+built declarations, as the satellite packages do, so run `pnpm run build`
+first after changing `src/`. The unit tests import the other workspace
+packages through their build output too, and `@riverqueue/worker-threads`
+runs compiled handler modules in its threads, so run `pnpm run build:all`
+before `pnpm run test`.
+
+`verify:migrations` compares every file of the generated migration mirror and
+its manifest with River's canonical Go migration sources in the surrounding
+repository. `generate:migrations` refreshes the mirror after a River migration
+changes.
+
+`package:check` creates real tarballs, validates them with publint and Are The
+Types Wrong, checks that every JavaScript and declaration map resolves to
+TypeScript source shipped in the same archive, and inspects licenses, package
+metadata, and archive contents. It installs the tarballs into a clean consumer
+without type packages, confirms that each library refuses to install beside a
+different `riverqueue` version, exercises ESM and Node's `require(esm)`,
+compiles strict TS6 and TS-next consumers, and builds every example against the
+packed artifacts. It then runs `scripts/packed-tests/*.test.mjs` in that
+consumer with Node's built-in `node --test`, so plain JavaScript exercises the
+installed tarballs with no transpiler or workspace alias in between: SQLite and
+worker-thread jobs end to end with exact int64 args, one shared `riverqueue`
+instance and error class hierarchy across packages, `require(esm)`, subpath
+exports, the CLI, and the test helpers. Its PostgreSQL tests run in a throwaway
+schema when `DATABASE_URL` is set. Database-free examples also run in this
+gate. `package:examples` runs the same packed examples with PostgreSQL when
+`DATABASE_URL` is set. Neither command publishes anything.
+
+`package:check` and `migration:legacy` reject archive paths that escape the
+package root and any packed path or text containing the local checkout or home
+directory. Set `RIVER_PACKAGE_DENYLIST` to a comma-separated list of extra
+case-insensitive substrings (for example, names of unpublished sibling
+checkouts) to reject them as well without committing those names.
+
+`api:report` regenerates the checked-in `etc/*.api.md` declaration reports
+from the built packages, and `api:check` (run in CI) fails when they are
+stale. Reports include TSDoc on every declaration and member, so
+documentation changes show up in review. Both commands also fail when an
+exported declaration refers to a type declared in the same package that the
+entry point does not export. Export such a type, or allow-list it with a
+reason in `scripts/api-reports.mjs`; allow-listed types appear in a separate
+section of the report.
+
+`docs:snippets` typechecks the TypeScript fences in every publishable package
+README. `migration:legacy` verifies the pinned 0.1.0 npm archive and tagged
+documentation snapshot, then compiles its old consumer on both compiler lanes.
+
+Prisma 7.9.1 currently pins `deepmerge-ts` 7.1.5 through its configuration
+toolchain. The root override to 8.0.1 carries the upstream fix for
+[GHSA-ggr8-5vv4-36mx](https://github.com/advisories/GHSA-ggr8-5vv4-36mx)
+until Prisma publishes a stable fixed dependency. Recheck the
+[upstream Prisma issue](https://github.com/prisma/orm/issues/30052) and remove
+the override only after `pnpm audit --prod` stays clean without it.
+
+Prisma 7.9.1 also pins `mysql2` 3.15.3 for its MySQL tooling, which this
+workspace never uses. The `mysql2` 3.24.4 override patches
+[GHSA-3f6p-5ww8-9rcr](https://github.com/advisories/GHSA-3f6p-5ww8-9rcr) and
+[GHSA-rgwj-5xj2-c3m3](https://github.com/advisories/GHSA-rgwj-5xj2-c3m3) in
+that development-only path; remove it once Prisma depends on a fixed release.
+
+The `nanoid` 3.3.18 override patches
+[GHSA-2v37-7h3g-55p8](https://github.com/advisories/GHSA-2v37-7h3g-55p8)
+in Vite's development-only PostCSS path. It remains within PostCSS's declared
+compatible range and can be removed once the ordinary lock resolution is at
+least 3.3.18.
+
+## Test guards
+
+Every Vitest configuration loads `scripts/vitest-setup.mjs`. It fails the
+running test when an `unhandledRejection` or `uncaughtException` escapes it,
+and fails a test file that finishes with more event-loop handles (timers,
+sockets, servers, child processes) than it started with. Close pools,
+listeners, and clients in `afterAll`, and `unref()` timers that intentionally
+outlive a test. The handle check waits up to two seconds for handles that are
+already closing, because node-postgres resolves `pool.end()` before its
+sockets report closed.
+
+Vitest's `--detectAsyncLeaks` is not used as a gate: it also reports
+promises that tests deliberately leave pending (for example a mocked query
+that never settles to exercise cancellation), and its async hooks slow the
+large batching tests past their timeouts.
+
+## Property tests
+
+Files named `*.property.test.ts` use [fast-check](https://fast-check.dev) to
+check invariants over generated inputs: the exact JSON codecs, unique-key
+hashing against a reference encoding of River's rules, opaque and portable
+job list cursors, SQLite keyset pagination across ties, and model-based
+command runs of the periodic job registry and the completion batcher. They
+run with the ordinary unit suite and use a fixed seed, so a run is
+reproducible. A failure prints its seed and shrink path; replay it, or
+explore new inputs, with `FAST_CHECK_SEED`:
+
+ FAST_CHECK_SEED=-1747166622 pnpm test src/json.property.test.ts
+ FAST_CHECK_SEED=random pnpm test
+
+## Line and branch coverage
+
+`pnpm run test:coverage` runs the unit suite with V8 coverage and writes a
+summary to the terminal and an HTML report to `coverage/`. Coverage is
+supplementary evidence only: it shows code no unit test executes, not that
+behavior matches River, which the JavaScript-native tests and River Go's
+recorded goldens establish. It has no threshold and is not a CI gate.
+
+## Integration tests
+
+Integration tests run against a real PostgreSQL database with River's schema.
+Create a disposable test database, build the workspace, and apply the exact
+generated migrations from this checkout:
+
+ createdb river_test
+ pnpm run build:all
+ node cli/dist/bin.js migrate-up \
+ --database-url "postgres://localhost/river_test"
+
+By default, tests connect to `postgres://localhost:5432/river_test`. Override with `TEST_DATABASE_URL`:
+
+ TEST_DATABASE_URL="postgres://user:pass@host:5432/mydb" pnpm run test:integration
+
+The integration suite includes a bounded multi-client stress test: three
+clients share one database while jobs are inserted from every client, one
+client is gracefully replaced mid-iteration, and seeded cancellations race
+claims, handlers, and completions. Each iteration asserts that every job is
+worked at most once, reaches a terminal state that matches its single
+observed terminal event, and is never lost. Scale it into a soak run, and
+replay a failure by its reported seed, with:
+
+ RIVER_STRESS_ITERATIONS=500 RIVER_STRESS_SEED=7 pnpm run test:integration \
+ driver/pg/src/stress.integration.test.ts
+
+## Running the CLI from a checkout
+
+After `pnpm run build:all`, run the workspace's `riverqueue` command with
+`node cli/dist/bin.js `. pnpm doesn't link a workspace package's
+own `bin` into its `node_modules/.bin`, so
+`pnpm --filter=@riverqueue/cli exec riverqueue` can't find it. For example,
+to benchmark against a disposable database:
+
+ node cli/dist/bin.js bench \
+ --database-url "postgres://localhost/river_bench" --yes --duration 30s
+
+## Continuous integration
+
+River's `.github/workflows/js.yaml` runs on pull requests and pushes to
+`master` that change `js/`, River's migrations or SQL, or the workflow itself,
+and on release tags. Each job installs the official Node.js build from
+`js/.node-version` and fails unless `typeof Temporal` is `object`. The jobs
+cover build, both type-check lanes, generated migrations (compared with
+River's sources), API reports, TypeDoc, README snippets, the 0.1 fixture, lint,
+formatting, licenses, `pnpm audit`, packed archives and examples, unit tests on
+Node 26.0.0 and the current Node 26 release, and integration tests on
+PostgreSQL 14 through 18.
+
+Unit tests compare cron schedules and snooze counting with goldens recorded
+from River Go in `src/testdata`, the same values the Rust port checks in its
+own fixtures.
+
+## Preparing a release
+
+The root, drivers, migration library, worker-thread integration, test helpers,
+and CLI are intended to be publishable eventually. Examples are private. Until
+the initial release is explicitly approved, do not reserve names, publish
+packages, create tags, or create releases.
+
+1. Fetch changes to the repo. Export `VERSION` by incrementing the last tag:
+
+ ```shell
+ git checkout master && git pull --rebase
+ export VERSION=0.x.y
+ git checkout -b $USER-$VERSION
+ ```
+
+2. Update every publishable `package.json` to the same version, including the
+ exact `workspace:` versions of first-party `dependencies`,
+ `peerDependencies`, and `devDependencies`, then regenerate `pnpm-lock.yaml`.
+ Libraries take `riverqueue` as an exact peer so a mismatched pair fails at
+ install time instead of loading two copies; only the self-contained CLI
+ depends on it directly. Do not change the private example package
+ versions.
+
+3. Update `CHANGELOG.md` by moving the release notes from `Unreleased` into a
+ heading for the new version.
+
+4. Verify generated migrations, declarations, package contents, and consumer
+ installs. Dry runs must not contact the publish endpoint.
+
+ ```shell
+ pnpm run verify:migrations
+ pnpm run build:all
+ pnpm run api:check
+ pnpm run docs:snippets
+ pnpm run migration:legacy
+ pnpm run package:check
+ pnpm run license:check
+ pnpm audit
+ ```
+
+5. Prepare a PR with the version and changelog changes. Publication remains a
+ separate, explicitly authorized operation after merge.
diff --git a/js/docs/errors-and-retries.md b/js/docs/errors-and-retries.md
new file mode 100644
index 000000000..bb007898a
--- /dev/null
+++ b/js/docs/errors-and-retries.md
@@ -0,0 +1,255 @@
+# Errors, retries, timeouts, and cancellation
+
+## Outcomes
+
+A handler's result decides what happens to its job:
+
+| Handler | Job becomes |
+| --------------------------------- | --------------------------------------------------------------- |
+| resolves, or returns `complete()` | `completed`; `complete({ output })` also records JSON output |
+| throws or rejects | `retryable` (retried later), or `discarded` after `maxAttempts` |
+| returns `snooze({ minutes: 5 })` | scheduled again without using an attempt |
+| returns `discard({ reason })` | `discarded` without further retries |
+| returns `cancel({ reason })` | `cancelled`, recording `JobCancelError: ` as its error |
+
+Outcomes are closed values built by those helpers; returning any other object
+is an error, so output is never recorded by accident. Use `snooze` for "try
+again later" conditions such as rate limits, `discard` when retrying can't
+help (a permanently invalid request), and `cancel` when the work should no
+longer happen at all. A snooze or retry due within the scheduler interval (5
+seconds by default) becomes available immediately, as in River for Go.
+
+Recorded errors hold the thrown message, bounded in size, and the time the
+attempt started. Like River for Go, which records stack traces only for
+panics, River records a stack only for JavaScript's runtime faults (a native
+`TypeError`, `RangeError`, `ReferenceError`, `SyntaxError`, `EvalError`, or
+`URIError`); deliberate errors record their message alone. Don't put secrets
+or full payloads in error messages: they are stored on the job.
+
+A job whose row River can't read (for example, metadata another tool rewrote
+as a JSON array) is never worked, and neither is a job whose kind has no
+worker. Its attempt fails before hooks and middleware run, with an error such
+as `job row couldn't be decoded: …`, so `errorHandler` sees it and the retry
+schedule applies as usual. `client.jobs.get` reports such a row as an error,
+but `client.jobs.list`, `cancel`, `retry`, and `delete` still act on it, as in
+River for Go, returning the job with the fields River couldn't read left empty
+(`{}` or `[]`). Like River for Go, a recorded error that another tool wrote in
+a shape River doesn't, such as an `at` that isn't an RFC 3339 timestamp, an
+`attempt` stored as a string, or an `error` that's an object, doesn't make
+the row unreadable: River keeps what it can of it, leaving such an `at` as the
+zero time `0001-01-01T00:00:00Z` and keeping other values as their JSON text.
+
+## Retry schedule
+
+By default the delay before attempt _n + 1_ is _n⁴_ seconds with ±10% jitter,
+where _n_ is the number of errors so far, the same curve as River for Go. Set
+`maxAttempts` per job, per definition (`defaults`), or per client
+(`defaultInsertOptions`). Replace the schedule with `retryPolicy`:
+
+
+
+```ts
+const client = new Client(new PgDriver(new Pool()), {
+ // Retry every 30 seconds, whatever the attempt.
+ retryPolicy: (_job, now) => now.add({ seconds: 30 }),
+ queues: { default: { maxWorkers: 10 } },
+ workers,
+});
+```
+
+A policy that throws, or returns a time that is not a `Temporal.Instant` or
+is in the past, falls back to the default schedule.
+
+A worker can set its own `retryPolicy`, like River for Go's
+`Worker.NextRetry`. It decides for that kind, both after a failed attempt and
+when the rescuer retries a stuck job, once the job's arguments decode; when it
+throws or returns something other than a `Temporal.Instant`, the client's
+policy decides:
+
+
+
+```ts
+const workers = new Workers().add(
+ sendEmail,
+ async () => {
+ // ...
+ },
+ // Retry email delivery every five minutes.
+ { retryPolicy: (_job, now) => now.add({ minutes: 5 }) }
+);
+```
+
+## Error handler
+
+`errorHandler` observes every failed attempt, after work middleware and hooks
+have run, and may cancel the job instead of retrying it. That includes an
+attempt stopped by its timeout, whose error is a `JobTimeoutError`. As in
+River for Go, it isn't called for an attempt interrupted by a client stop, or
+for an error thrown after the job was cancelled remotely, because the
+cancellation already decides the outcome (a runtime fault such as a
+`TypeError`, the JavaScript analog of a Go panic, still reaches it):
+
+
+
+```ts
+class PermanentError extends Error {}
+
+const client = new Client(new PgDriver(new Pool()), {
+ errorHandler: ({ job }, error) => {
+ errorTracker.capture(error, { attempt: job.attempt, kind: job.kind });
+ if (error instanceof PermanentError) return { cancel: true };
+ // Give up on a job that keeps timing out instead of retrying it.
+ if (error instanceof JobTimeoutError && job.attempt >= 3) {
+ return { cancel: true };
+ }
+ return undefined;
+ },
+ queues: { default: { maxWorkers: 10 } },
+ workers,
+});
+```
+
+A failing error handler is logged and ignored; it never changes the job's
+outcome.
+
+## Timeouts
+
+Every attempt has a cooperative timeout: `jobTimeout` on the client (1 minute
+by default) or `timeout` on the worker registration, which takes precedence.
+`null` disables it.
+
+
+
+```ts
+const workers = new Workers().add(
+ buildReport,
+ async ({ job, signal }) => {
+ await renderReport(job.args, { signal });
+ },
+ { timeout: { minutes: 30 } }
+);
+```
+
+When the timeout expires, the handler's `signal` aborts with a
+`JobTimeoutError`. Node cannot stop a running function, so the handler must
+pass the signal to the operations it awaits (or check
+`signal.throwIfAborted()` in loops). A handler stopped by the abort records the
+timeout as its error, reaches `errorHandler`, and is retried.
+
+A job whose timeout is disabled is never rescued: River cannot tell a
+long-running attempt from an abandoned one, so it trusts the attempt, as River
+for Go does. Otherwise the leader's rescuer retries jobs whose attempt started
+longer ago than `maintenance.rescueAfter` (1 hour by default), which recovers
+jobs from crashed processes.
+
+## Stuck jobs
+
+An attempt that ignores its signal and keeps running past its timeout is
+reported as stuck after the client's `jobStuckThreshold` (10 seconds by
+default, like River for Go's `JobStuckThreshold`; it must not be negative):
+River emits a `job_stuck` event carrying a
+`JobStuckError`, logs it, and calls `stuckHandler`. The handler may return
+`{ addWorkerSlot: true }` to let the queue start another job while the stuck
+one keeps its slot. The stuck attempt's late result is still guarded: it
+cannot overwrite a newer attempt.
+
+For CPU-bound handlers that can't observe a signal, use
+[`@riverqueue/worker-threads`](../worker-threads/README.md), which terminates
+a handler's thread once it has ignored its aborted signal for
+`jobStuckThreshold`.
+
+## Cancellation
+
+`client.jobs.cancel(id)` cancels a job from any client, in any language. A job
+that hasn't started never runs. A running attempt's `signal` aborts with a
+`JobCancelledError` on whichever process is running it; if the handler then
+throws, the job is `cancelled`, and if it completes anyway, the job is
+`completed`, matching River for Go.
+A job cancelled after a client claimed it but before its handler started
+still runs its handler, with its `signal` already aborted, as River for Go
+starts its worker with a cancelled context.
+
+When a client stops with `mode: "cancel"`, or a graceful stop's `timeout`
+passes, running handlers' signals abort. A handler that stops because of that
+abort puts its job back to `available` without using an attempt; a genuine
+error is recorded and retried as usual. A handler that still ignores the
+abort after `jobStuckThreshold`, such as a worker thread that
+`@riverqueue/worker-threads` then terminates, fails with a `JobAbortedError`: its
+attempt counts, and the retry policy and `maxAttempts` apply, so a job that
+hangs on every stop doesn't retry forever.
+
+Once an attempt finishes, its handler's `signal` aborts with a
+`JobAttemptFinishedError`, like River for Go cancelling a job's context when
+its executor returns, so work the handler started and left running stops
+instead of outliving the attempt.
+
+## Error classes
+
+Everything River throws on purpose is a `RiverError` with a stable `code`,
+and each subclass narrows `code`:
+
+| Class | `code` | When |
+| ---------------------------- | ------------------------ | ------------------------------------------------------ |
+| `ConfigurationError` | `configuration` | Invalid client, job, driver, or worker configuration |
+| `ValidationError` | `validation` | An invalid value passed to a River API |
+| `PayloadValidationError` | `payload_validation` | Args rejected by a schema or decoder (`phase`) |
+| `DatabaseOperationError` | `database` | A database operation failed (`retryable` if transient) |
+| `MigrationError` | `migration` | Planning, applying, or validating migrations failed |
+| `UnsupportedCapabilityError` | `unsupported_capability` | The driver can't do this (a Prisma client can't work) |
+| `BackendMismatchError` | `backend_mismatch` | A `tx` that belongs to another driver |
+| `LifecycleError` | `lifecycle` | Misuse of the runtime's lifecycle |
+| `JobAbortedError` | `job_aborted` | A handler ended by force after ignoring a stop's abort |
+| `JobAttemptFinishedError` | `job_attempt_finished` | A handler signal's reason once its attempt finished |
+| `JobCancelledError` | `job_cancelled` | A handler signal's reason after remote cancellation |
+| `JobTimeoutError` | `job_timeout` | A handler signal's reason after its timeout |
+| `JobStuckError` | `job_stuck` | Reported for stuck attempts |
+| `JobRunningError` | `job_running` | Deleting a running job |
+| `UnknownJobKindError` | `unknown_job_kind` | A claimed job has no registered worker |
+| `ExtensionError` | `extension` | A hook, middleware, or plugin failed |
+| `SubscriptionLagError` | `subscription_lag` | A subscription dropped events |
+
+
+
+```ts
+try {
+ await client.insert(sync, {});
+} catch (error) {
+ if (error instanceof DatabaseOperationError && error.retryable) {
+ // A transient failure (lost connection, lock timeout, ...): try again.
+ } else if (error instanceof RiverError) {
+ console.error(error.code, error.message);
+ }
+ throw error;
+}
+```
+
+Database errors in the runtime's own background work (claiming, completing,
+leadership) are never fatal: they are logged and retried with backoff. See
+[database failures](./runtime.md#database-failures).
diff --git a/js/docs/migrating-from-0.1.md b/js/docs/migrating-from-0.1.md
new file mode 100644
index 000000000..6ecb621b8
--- /dev/null
+++ b/js/docs/migrating-from-0.1.md
@@ -0,0 +1,222 @@
+# Migrating from `riverqueue` 0.1
+
+`riverqueue` 0.1 was an insert-only client. This release adds workers and the
+full runtime, and corrects types that could lose database values before 1.0
+locks them in. Most changes are mechanical, and the compiler flags every site
+that still needs attention.
+
+## Runtime requirements
+
+The package needs Node.js 26 with native `Temporal` and ships as ESM; see
+[requirements](../README.md#requirements). There is no second CommonJS build
+and no `Temporal` polyfill.
+
+## Codemod
+
+`@riverqueue/cli` includes a codemod for the mechanical part of this
+migration. It needs the `typescript` package, version 5 or 6, installed in
+the project it migrates:
+
+```sh
+npx riverqueue codemod-0.1 src # report what would change
+npx riverqueue codemod-0.1 --write src # rewrite files in place
+npx riverqueue codemod-0.1 --check src # exit 1 if anything would change
+```
+
+Arguments are files, directories, or glob patterns. Pass every file that
+declares or constructs argument classes in one run, so that call sites in
+other files are rewritten against their classes. The codemod edits only the
+expressions it rewrites and never reformats other code, so run your formatter
+afterwards. Running it again changes nothing.
+
+It rewrites the patterns that have one meaning:
+
+- A `JobArgs` class whose persisted args are exactly its constructor parameter
+ properties (with no `toJSON`, or one that returns exactly those properties)
+ becomes a `defineJob()({ kind, defaults })` definition, and its
+ `insertOpts` become `defaults`. `SortArgs` becomes `sort`, and imports and
+ re-exports of the class follow the new name. `new SortArgs(a)` in an
+ `insert` or `insertMany` call becomes the definition and a plain args
+ object.
+- `new JobArgsObject("kind", args)` in those calls uses a module-level
+ unchecked `defineJob({ kind: "kind" })`, one per kind. It stays separate from
+ a class definition of the same kind because the class's `insertOpts` never
+ applied to it.
+- `new InsertManyParams(args, options)` becomes `{ job, args, options }`.
+- The `uniqueOpts` insert option becomes `unique`, and a numeric `byPeriod` in
+ seconds becomes a duration such as `{ seconds: 60 }`.
+- The `ClientOpts`, `InsertOpts`, and `UniqueOpts` types, renamed
+ `ClientOptions`, `InsertOptions`, and `UniqueOptions`, are imported under
+ their 0.1 names.
+- `result.uniqueSkippedAsDuplicated` becomes `result.status === "duplicate"`,
+ and its negation `result.status === "inserted"`.
+- `JOB_STATE_AVAILABLE` and the other state constants become
+ `JOB_STATE.available` and so on.
+- `new Client(new PgDriver(pool), { schema })` moves the schema into
+ `new PgDriver(pool, { schema })`.
+- `riverqueue` imports drop what the rewrites replaced and add `defineJob`.
+
+Everything that needs judgment gets a `// TODO(riverqueue-0.1): ...` comment,
+and the command lists each marked site. That includes classes with methods,
+computed kinds, constructor bodies, or a custom `toJSON`; argument objects
+constructed outside insertion calls; non-literal `byPeriod` values; exports
+that no longer exist; and uses of `JobRow.id` and `JobRow` timestamps. The
+codemod deliberately leaves ID arithmetic, `number` annotations, `Date`
+handling, and string formatting alone: `JobRow.id` is now a `bigint` and
+timestamps are `Temporal.Instant`, as described below, and the compiler
+reports each site that depends on the old types. A `Date` passed as
+`scheduledAt` remains valid.
+
+Generated definitions declare only the producer type. Add a schema or
+`decode` callback before registering a worker for them, as described in the
+next section.
+
+## Job definitions replace argument classes
+
+0.1 described a job with a class implementing `JobArgs`:
+
+```ts ignore
+class SortArgs implements JobArgs {
+ kind = "sort";
+ insertOpts = { queue: "sorting" };
+ constructor(readonly strings: string[]) {}
+}
+await client.insert(new SortArgs(["b", "a"]));
+```
+
+Now a job is a definition value, and arguments are a plain JSON object:
+
+
+
+```ts
+const sort = defineJob({
+ defaults: { queue: "sorting" },
+ kind: "sort",
+ schema: z.object({ strings: z.array(z.string()) }),
+});
+await client.insert(sort, { strings: ["b", "a"] });
+```
+
+`JobArgsObject("kind", args)` becomes `defineJob({ kind: "kind" })`, which
+accepts any JSON object. Give a definition a schema or a `decode` function
+before registering a worker for it: a type annotation alone does not validate
+jobs another producer inserted. See [defining jobs](./README.md#define-jobs).
+
+## Batch items and results
+
+`new InsertManyParams(args, options)` becomes a plain object, and a
+definition identifies each item's kind:
+
+
+
+```ts
+const results = await client.insertMany([
+ { args: { strings: ["c"] }, job: sort, options: { priority: 2 } },
+ { args: { strings: ["d"] }, job: sort },
+]);
+
+if (results[0].status === "duplicate") {
+ // results[0].job is the existing job that conflicted
+}
+```
+
+`result.uniqueSkippedAsDuplicated` becomes `result.status === "duplicate"`.
+
+A batch may name a unique key only once. Like River for Go, `insertMany`
+rejects a batch that repeats one with a `ValidationError` and writes none of
+it, where 0.1 inserted the first job and reported the others as duplicates.
+
+## Insert options
+
+| 0.1 | Now |
+| ---------------------------------------- | ------------------------------------------ |
+| `uniqueOpts: { byPeriod: 60 }` (seconds) | `unique: { byPeriod: { seconds: 60 } }` |
+| `scheduledAt: new Date(...)` | unchanged; a `Temporal.Instant` also works |
+| compute `Date.now() + 60_000` yourself | `delay: { minutes: 1 }` |
+| `schema` on the client | `new PgDriver(pool, { schema })` |
+| `insertOpts` on an argument class | `defaults` on the definition |
+| `InsertOpts` and `UniqueOpts` types | `InsertOptions` and `UniqueOptions` |
+
+Options now resolve by presence rather than truthiness, so an invalid `0` or
+empty value is rejected instead of silently falling back to a default.
+
+## Drivers
+
+A driver is only passed to River: `PgDriver` no longer has `jobInsert` or
+`jobInsertMany`, nor exposes its pool. Insert through the client, and keep
+your own reference to the pool if you use it directly;
+`createMigrator(driver)` still migrates the driver's connection and schema.
+A hand-written driver or test double
+passed to `new Client()` is rejected; use `createTestClient` from
+`@riverqueue/test` in tests.
+
+## Exact IDs and timestamps
+
+`JobRow.id` is a `bigint`, not a `number`: IDs are 64-bit and larger values
+would lose precision. Use `id.toString()` for logs, URLs, and JSON. Review any
+ID arithmetic by hand rather than converting back to `number`.
+
+Every timestamp on a job row is a `Temporal.Instant`. It keeps PostgreSQL's
+microseconds and carries no local time zone, so review code that relied on
+`Date`'s local-time methods. Convert with `new Date(instant.epochMilliseconds)`
+where an API needs a `Date`.
+
+## JSON
+
+`JSON.stringify(job)` throws on `bigint`. Use `jobToJsonValue(job)` for a
+JSON-safe copy and `jobFromJsonValue` to restore it. Don't add a global
+`BigInt.prototype.toJSON`.
+
+Arguments must be JSON. River rejects class instances, `bigint`, and integers
+beyond `Number.MAX_SAFE_INTEGER` (which have already lost precision). Store
+large integers as strings, or use `exactJsonNumber("9223372036854775807")`
+when the wire format needs a JSON number. Numbers that other languages wrote
+and JavaScript cannot represent exactly arrive as `ExactJsonNumber`; see
+[exact values](./README.md#exact-values-and-json).
+
+## Inserting without a transaction
+
+Like River for Go, an insertion without `{ tx }` now runs in a transaction
+River begins itself, so its insert middleware and hooks commit or roll back
+with the jobs. River needs a connection of its own for that:
+
+- A `PgDriver` constructed from a single `pg.Client` or a checked-out
+ `PoolClient`, rather than a `Pool`, now rejects insertions without `{ tx }`
+ with a `ConfigurationError`. In 0.1 they ran directly on that client. The
+ codemod can't detect this. Construct the driver with a `Pool`, or begin a
+ transaction on the client and pass it as `{ tx }`.
+- A `PrismaDriver` runs insertions without `{ tx }` in an interactive
+ transaction on the root client's `$transaction`, so construct it with the
+ root `PrismaClient`, not a transaction client.
+
+On PostgreSQL, the transaction adds a `BEGIN` and a `COMMIT` round trip to
+each call that 0.1 ran as a single statement. To insert many jobs, pass them
+to one `insertMany` call.
+
+River for Go's fast batch insertion (`InsertManyFast`) has no JavaScript
+equivalent: it is held back until River for Go decides how it relates to
+extensions that act on every insertion. Code written against a development
+build's `insertManyFast` should insert large batches with one `insertMany`
+call instead, which differs in three ways: it resolves with one result per
+row rather than a count; a unique conflict is reported as that row's
+`status: "duplicate"` instead of being skipped (SQLite) or failing the whole
+batch (PostgreSQL); and insert middleware and hooks run for every row.
+
+## Prisma
+
+`@riverqueue/driver-prisma` still inserts jobs inside Prisma transactions. It
+does not run workers: `new Client(new PrismaDriver(prisma))` is typed as an
+insert-only client, so worker and query methods don't compile. Use
+`@riverqueue/driver-pg` in worker processes.
diff --git a/js/docs/observability.md b/js/docs/observability.md
new file mode 100644
index 000000000..4d584a2d3
--- /dev/null
+++ b/js/docs/observability.md
@@ -0,0 +1,182 @@
+# Logging, events, and metrics
+
+River reports what it does through four channels: a logger, hooks and
+middleware, event subscriptions, and Node's `diagnostics_channel`. None of
+them require a particular logging or telemetry library.
+
+## Logging
+
+Pass any logger with pino's argument order, `(attributes, message)`:
+
+
+
+```ts
+import pino from "pino";
+
+const client = new Client(new PgDriver(new Pool()), {
+ logger: pino({ name: "worker" }),
+ queues: { default: { maxWorkers: 50 } },
+ workers,
+});
+```
+
+River logs failures in its background work (database retries, dropped
+completions, stuck jobs, failing hooks) at `warn` and `error`. Without a
+`logger`, those two levels go to `console`; pass `logger: false` to silence
+them. Handlers get `ctx.logger`, the same logger with `jobId`, `jobKind`, and
+`attempt` attached, which accepts `logger.info("message")` or
+`logger.info({ key: value }, "message")`.
+
+## Hooks and middleware
+
+Middleware wraps work like Koa middleware and must call `next()` once. Hooks
+observe lifecycle points: `beforeInsert`/`afterInsert`,
+`beforeWork`/`afterWork`, `onEvent`, `onMetric`, and `onPeriodicJobsStart`.
+Both run in registration order, plugins first, then the client's own. As in
+River for Go, insert and work hooks run inside the innermost middleware, so a
+middleware span covers them. An `afterWork` hook that returns a result
+replaces the attempt's result, and an error thrown by `beforeWork` becomes the
+attempt's error without the worker running:
+
+
+
+```ts
+const client = new Client(new PgDriver(new Pool()), {
+ hooks: {
+ onEvent: (event) => {
+ if (event.kind === "job_failed") {
+ metrics.increment("river.job.failed", { kind: event.job.kind });
+ }
+ },
+ },
+ middleware: [
+ async ({ job }, next) => {
+ const started = performance.now();
+ try {
+ return await next();
+ } finally {
+ metrics.timing("river.job.duration", performance.now() - started, {
+ kind: job.kind,
+ });
+ }
+ },
+ ],
+ queues: { default: { maxWorkers: 50 } },
+ workers,
+});
+```
+
+Middleware and work hooks run inside the attempt, so their time is part of the
+job's. `onEvent` hooks receive events after the database change commits,
+through a bounded queue, so a slow hook doesn't hold up job processing; a
+stopping client delivers queued events before it finishes stopping. A hook
+that throws is logged and ignored.
+
+## Event subscriptions
+
+`client.subscribe()` returns an async iterable of events, emitted after their
+database transitions commit. Filtering by `kinds` narrows the event type:
+
+
+
+```ts
+using failures = client.subscribe({ capacity: 1_000, kinds: ["job_failed"] });
+for await (const event of failures) {
+ if (event.kind === "subscription_lag") {
+ console.warn(`dropped ${event.dropped} events`);
+ } else {
+ console.error(event.job.id, event.error);
+ }
+}
+```
+
+| Events | Carry |
+| ------------------------------------------------------------------------------------- | ----------------------------- |
+| `job_started`, `job_completed`, `job_snoozed`, `job_interrupted` | `job` |
+| `job_failed` | `job`, `error` |
+| `job_cancelled` | `job`, `error` if one |
+| `job_stuck` | `job`, a `JobStuckError` |
+| `job_race` (a finished attempt no longer owned its row) | `job` |
+| `queue_added`, `queue_paused`, `queue_resumed`, `queue_updated`, `queue_reconfigured` | `queue` |
+| `queue_removed` | `queueName` |
+| `leader_acquired`, `leader_lost` | `leader` |
+| `maintenance_succeeded`, `maintenance_failed` | `service`, `count` or `error` |
+| `runtime_event_loop_delay` | `eventLoopDelay` |
+| `subscription_lag` | `dropped`, `error` |
+
+A subscription buffers up to `capacity` events (256 by default). A consumer
+that falls behind loses the oldest events and receives one
+`subscription_lag` event saying how many. Subscriptions are for observing
+River, not a second queue: react to business events with jobs. Breaking out
+of `for await`, `using`, `close()`, or an aborted `signal` closes a
+subscription.
+
+## `diagnostics_channel`
+
+River publishes to three channels, which cost nothing without subscribers and
+suit tracing and APM integrations:
+
+| Channel | Message |
+| ------------------- | ---------------------------------------------------------------- |
+| `riverqueue:event` | Every `RiverEvent`, as delivered to subscriptions |
+| `riverqueue:metric` | Every `RiverMetric` |
+| `riverqueue:work` | `{ context, result }` after each attempt, before it is persisted |
+
+
+
+```ts
+import { subscribe } from "node:diagnostics_channel";
+import type { RiverMetric } from "riverqueue";
+
+subscribe("riverqueue:metric", (message) => {
+ const metric = message as RiverMetric;
+ if (metric.name === "job_get_available_duration") {
+ histogram.record(metric.duration.total("milliseconds"), {
+ queue: metric.queue,
+ });
+ }
+});
+```
+
+Metrics are `job_get_available_duration` and `job_get_available_count` (per
+claim query, by queue) and `job_completion_requeued`/`job_completion_dropped`
+(completion persistence failures). Hooks receive the same values through
+`onMetric`.
+
+River core has no OpenTelemetry dependency. To trace jobs, start a span in a
+work middleware (the middleware runs inside the attempt's async context) and
+record attributes from `ctx.job`; propagate trace context from the producer
+through job `metadata`.
+
+## Runtime diagnostics
+
+`run.diagnostics` is a snapshot of the running client: active attempts,
+pending completions, per-queue configuration and pause state, the latest
+event-loop delay, leadership, and maintenance runs. Worth alerting on:
+sustained event-loop delay, `job_stuck` events, `job_completion_dropped`,
+repeated `maintenance_failed`, `subscription_lag`, and growing pool wait in
+your `pg` instrumentation.
+
+Never put job arguments, database URLs, or credentials into metric labels,
+and redact diagnostics before sending them across a trust boundary.
diff --git a/js/docs/periodic-jobs.md b/js/docs/periodic-jobs.md
new file mode 100644
index 000000000..b6b4545d8
--- /dev/null
+++ b/js/docs/periodic-jobs.md
@@ -0,0 +1,198 @@
+# Periodic jobs
+
+A periodic job is inserted on a schedule by whichever client currently holds
+River's leadership. Define one with `periodicJob` and pass it to the client:
+
+
+
+```ts
+import { periodicJob } from "riverqueue";
+
+const pruneSessions = defineJob({
+ kind: "prune_sessions",
+ schema: z.object({ olderThanDays: z.number().int().positive() }),
+});
+
+const client = new Client(new PgDriver(new Pool()), {
+ periodicJobs: [
+ periodicJob({
+ args: { olderThanDays: 30 },
+ every: { hours: 1 },
+ id: "prune_sessions",
+ job: pruneSessions,
+ runOnStart: true,
+ }),
+ ],
+ queues: { default: { maxWorkers: 10 } },
+ workers,
+});
+```
+
+Each occurrence is an ordinary job: it is validated, inserted with
+`metadata.periodic: true` (and `river:periodic_job_id` when the periodic job
+has an `id`), and worked by any client with a worker for its kind.
+
+| Option | Meaning |
+| ------------ | -------------------------------------------------------------- |
+| `job` | The job definition to insert |
+| `args` | Arguments for every occurrence |
+| `construct` | Or, a function building each occurrence's `{ args, options }` |
+| `options` | Insert options for every occurrence (with `args`) |
+| `every` | A fixed interval such as `{ minutes: 15 }` (a day is 24 hours) |
+| `schedule` | Or, a schedule such as `cron("0 9 * * *")`; see below |
+| `runOnStart` | Also insert once each time a client becomes leader |
+| `id` | A stable identifier, unique per client; see below |
+
+`construct` may return `null` to skip an occurrence, which suits "only on
+weekdays" or "only if there is work" rules:
+
+
+
+```ts
+const weekdayPrune = periodicJob({
+ construct: () => {
+ const today = Temporal.Now.plainDateISO("UTC");
+ return today.dayOfWeek >= 6 ? null : { args: { olderThanDays: 30 } };
+ },
+ every: { days: 1 },
+ job: pruneSessions,
+});
+```
+
+## Cron schedules
+
+`cron` parses a standard cron expression with exactly the syntax and timing of
+River for Go (robfig/cron's `ParseStandard`), so a schedule fires at the same
+times whichever language holds leadership:
+
+
+
+```ts
+import { cron } from "riverqueue";
+
+const dailyDigest = periodicJob({
+ args: {},
+ id: "daily_digest",
+ job: sendDigest,
+ schedule: cron("CRON_TZ=America/Chicago 0 9 * * mon-fri"),
+});
+```
+
+It accepts five fields (minute, hour, day of month, month, and day of week
+numbered 0-6 from Sunday), lists, ranges, steps, `*` and `?`, month and
+weekday names, the descriptors `@yearly`, `@annually`, `@monthly`, `@weekly`,
+`@daily`, `@midnight`, and `@hourly`, and `@every` with a Go duration such as
+`@every 1h30m`. When both day of month and day of week are restricted, a day
+matching either fires. Anything River Go rejects, including a seconds field
+or `7` for Sunday, throws a `ConfigurationError`.
+
+### Time zones
+
+An expression is evaluated in the zone of its `CRON_TZ=` (or `TZ=`) prefix,
+else the `timeZone` option, else **the process's local time zone**, which is
+what River for Go and Rust use too. Occurrences follow wall-clock time in that
+zone: a time skipped by a daylight saving transition doesn't fire that day,
+and a repeated one can fire twice.
+
+Periodic jobs run on whichever client leads, and different machines often
+have different local zones (containers usually run in UTC). **Pin the zone
+explicitly in a mixed-language or multi-region fleet**, preferably in the
+expression itself so every language reads it from the same string. River for
+Rust bundles no time zone database and accepts only `UTC`, `Local`, and
+`Etc/GMT±N` prefixes, so `CRON_TZ=UTC` is the most portable choice:
+
+
+
+```ts
+cron("CRON_TZ=UTC 0 9 * * *");
+cron("0 9 * * *", { timeZone: "UTC" }); // Equivalent, but JavaScript-only.
+```
+
+### Custom schedules
+
+`schedule` accepts any `PeriodicSchedule`, an object whose
+`next(after: Temporal.Instant)` returns the next occurrence, so calendar
+rules cron can't express can be written directly.
+
+`next` must return an instant after `after`, or `null` to stop scheduling. A
+schedule that throws stops only its own job, and River logs the error.
+
+## Leadership and failures
+
+Only the leader inserts periodic jobs, so a fleet of clients inserts each
+occurrence once. When a client becomes leader it computes every job's next
+run from the current time and, with `runOnStart`, inserts one occurrence
+immediately. Occurrences due within 100 milliseconds are inserted together
+in one transaction.
+
+As in River for Go, a failed occurrence (a constructor that throws, or an
+insert that fails) is logged and skipped; the schedule moves on to the next
+occurrence rather than retrying, so a persistent failure can't wedge it. Make
+the job itself idempotent, or give it `unique` options, if duplicates after a
+leadership change would matter.
+
+## Adding and removing at runtime
+
+`client.periodicJobs` is the live registry. Changes take effect on the leader
+immediately, so make the same change on every client that may lead. A client
+created with `leaderElectionDisabled: true` never leads, and modifying its
+registry throws a `ConfigurationError`:
+
+
+
+```ts
+const handle = client.periodicJobs.add(
+ periodicJob({
+ args: {},
+ every: { minutes: 5 },
+ id: "cache",
+ job: refreshCache,
+ })
+);
+client.periodicJobs.remove(handle);
+client.periodicJobs.removeById("cache");
+```
+
+## Mixed-language fleets
+
+Leadership is shared across River for Go, Rust, and JavaScript. Periodic jobs
+run in whichever language currently leads, and only the periodic jobs that
+client registered run. If a Go service registers a periodic job that the
+JavaScript services don't (or the reverse), that job silently stops whenever
+leadership moves to the other language, which can happen on any deploy.
+
+Either register the same periodic jobs, with the same kinds, schedules, and
+IDs, in every language that may lead (with cron schedules pinned to one time
+zone, such as `CRON_TZ=UTC`), or keep periodic registration in one
+language and disable leader election (`leaderElectionDisabled: true`) on
+clients in the others so they never lead.
+
+## Durable schedules
+
+A periodic job with an `id` can have its next run persisted by an extension
+that provides a periodic job store, so a new leader continues the schedule
+instead of restarting it from the current time. River core ships no store;
+the `onPeriodicJobsStart` hook reports the durable records an installed
+store found when a leader starts inserting periodic jobs.
diff --git a/js/docs/resumable-jobs.md b/js/docs/resumable-jobs.md
new file mode 100644
index 000000000..337cd8c06
--- /dev/null
+++ b/js/docs/resumable-jobs.md
@@ -0,0 +1,78 @@
+# Resumable jobs
+
+A job that does several expensive steps can skip the steps an earlier attempt
+already finished. Wrap each step in `ctx.resumable.step`; when an attempt
+fails, River records the last completed step in the job's metadata, and the
+next attempt skips every step up to and including it:
+
+
+
+```ts
+const workers = new Workers().add(
+ onboardAccount,
+ async ({ job, resumable }) => {
+ const accountId = String(job.args.accountId);
+ await resumable.step("billing", () => billing.createCustomer(accountId));
+ await resumable.step("storage", () => storage.provision(accountId));
+ await resumable.step("welcome", () => mailer.sendWelcome(accountId));
+ }
+);
+```
+
+If `storage` fails, the retry skips `billing` and runs `storage` again. Steps
+must be awaited one at a time (nested steps are fine, concurrent ones are
+not), and names must stay stable across deploys because they are persisted.
+Catching a step's error doesn't make the attempt succeed: River still fails
+the attempt and keeps the checkpoint.
+
+## Cursors
+
+A step that works through a list can save a cursor so a retry resumes
+mid-step. `stepWithCursor` passes the cursor saved by the last failed attempt,
+or `null` the first time:
+
+
+
+```ts
+const workers = new Workers().add(exportRows, async ({ resumable }) => {
+ await resumable.stepWithCursor("export", async (cursor) => {
+ let after = typeof cursor === "number" ? cursor : null;
+ for (;;) {
+ const page = await rows.page(after);
+ if (page.last === null) return;
+ await exportPage(page.ids);
+ after = page.last;
+ resumable.setCursor(after);
+ }
+ });
+});
+```
+
+The cursor is any JSON value and is saved when the attempt ends. To save
+progress immediately, and atomically with the step's own writes, call
+`resumable.checkpoint({ cursor, tx })` inside the step with your database
+transaction.
+
+## Across languages
+
+Progress lives in the job's metadata under `river:resumable_step` and
+`river:resumable_cursor`, the same keys River for Go and Rust use, so a job
+can resume in a different language as long as step names and cursor shapes
+match. `@riverqueue/test`'s `workOnce` returns the updated metadata so a test
+can run a second attempt from the first one's checkpoint; see
+[testing](./testing.md).
diff --git a/js/docs/runtime.md b/js/docs/runtime.md
new file mode 100644
index 000000000..1a890c5d8
--- /dev/null
+++ b/js/docs/runtime.md
@@ -0,0 +1,224 @@
+# Runtime, concurrency, and the event loop
+
+River's Node.js runtime is asynchronous and supervised. `client.start()`
+returns a `RunHandle`; the application owns that handle until shutdown. Keep
+`run.completed` observed because it rejects if the runtime fails.
+
+
+
+```ts
+await using run = await client.start();
+
+process.once("SIGTERM", () => {
+ void run.stop({ mode: "graceful", timeout: { seconds: 30 } });
+});
+
+await run.completed;
+```
+
+Graceful shutdown stops fetching new work and lets active handlers finish, up
+to `timeout` if one is given. River never installs signal handlers itself;
+wire `SIGTERM` as above. Cancel shutdown also aborts active handler signals. A
+job timeout, remote cancellation, or shutdown abort is cooperative for an
+in-process handler; a late result is guarded by the attempt identity and
+cannot overwrite a newer attempt.
+
+Like River for Go, a stopping leader resigns and ends maintenance as soon as
+the stop begins, while its queues drain; another client can take over
+maintenance meanwhile. Stopping calls share one shutdown. When the runtime
+fails, it shuts down the same way, and `run.completed` rejects with the
+failure once that cleanup finished.
+
+## Concurrency and the event loop
+
+`maxWorkers` bounds active handlers per queue. This is I/O concurrency, not a
+pool of native threads. Async database and network handlers normally belong
+in-process, where Node can run many of them efficiently. Synchronous CPU work
+blocks job claiming, completion, cancellation, and application code in that
+process even when `maxWorkers` is high.
+
+Each queue also has a `fetchCooldown` minimum between claim queries and a
+`pollInterval` fallback when no insertion notification arrives. They default
+to the client's `fetchCooldown`, 100 milliseconds unless set, and 1 second,
+matching River's other runtimes. Like River for Go, each poll waits a random
+extra of up to a tenth of `pollInterval`, at least 10 milliseconds, so
+producers don't poll in lockstep. A notification wakes the poll wait but never
+bypasses the cooldown, so bursts coalesce instead of creating a database query
+per inserted job. Lower the cooldown deliberately for high-throughput queues
+and keep the poll interval at least as large as the cooldown.
+
+Like River for Go, a client sends at most one insert notification per queue
+per client `fetchCooldown`, on every driver, whether it inserts jobs directly,
+through periodic jobs, or by scheduling them as their time comes. A producer
+fetches at most once per cooldown anyway, and polls for a job whose
+notification was suppressed. Retrying a job sends no insert notification.
+
+River measures event-loop delay and exposes it in runtime diagnostics. Treat
+sustained delay as an operational fault: move CPU work to
+`@riverqueue/worker-threads`, a separate process, or a dedicated service rather
+than increasing queue concurrency.
+
+Worker threads are bounded and terminate a handler that ignores its abort
+signal for the client's `jobStuckThreshold`. The structured-clone boundary
+carries persisted JSON arguments, not decoder functions or arbitrary
+transformed class values. Worker modules should decode any richer local
+representation themselves.
+
+## Retries and errors
+
+A resolved handler completes the job. Throwing or rejecting records an attempt
+error and applies the configured retry policy. Return `snooze(...)` when the
+attempt should not count, `discard(...)` when no retry should occur, or
+`cancel({ reason })` to cancel the job permanently like Go's `river.JobCancel`,
+recording `JobCancelError: ` as the attempt error. River
+bounds recorded errors and stacks; do not place credentials or sensitive
+payloads in thrown messages.
+
+Each recorded error is stamped with the time its attempt started. Go records a
+stack trace only for panics, and River's JavaScript analog of a panic is a
+runtime fault raised as a native `TypeError`, `RangeError`, `ReferenceError`,
+`SyntaxError`, `EvalError`, or `URIError`. Only those record their stack; an
+error thrown deliberately, including a subclass of one of those classes,
+records its message with an empty trace.
+
+Like River's other runtimes, a retry or snooze due
+within one scheduler interval (5 seconds by default) is stored as `available`
+with its future `scheduled_at`, so it runs on time instead of waiting for the
+leader's next scheduler pass.
+
+Handlers receive one `AbortSignal`. Pass it into database, HTTP, and other
+cancel-aware operations, and catch an abort only to clean up. How an aborted
+attempt is recorded matches River's other runtimes:
+
+- After a remote cancellation, a handler that still resolves successfully
+ completes the job. Throwing (for example through `signal.throwIfAborted()`)
+ or returning `snooze(...)` or `discard(...)` cancels it.
+- After a job timeout, success completes the job, a handler stopped by the
+ abort records the timeout as its error, and any other error is recorded as
+ thrown.
+- During shutdown, only a handler stopped by the shutdown abort is made
+ available again without consuming an attempt. Success completes the job and
+ a genuine error is recorded and retried as usual.
+
+`job_cancelled` and `job_interrupted` events are emitted once, after the
+database transition commits. A late result from an attempt that no longer owns
+its row is ignored.
+
+## Database failures
+
+Database errors in background work are operational, not fatal. A lock held by
+another session, a `statement_timeout`, a failover, or a saturated pool is
+logged through the client's `logger` and retried; it never stops the runtime.
+`run.completed` rejects only for configuration errors and internal invariant
+violations.
+
+Claims, queue control polling, and notification streams retry after
+exponential backoff with jitter, from 250 milliseconds up to 30 seconds, and
+reset after a success. A notification stream that fails is resubscribed, and
+every successful resubscription polls all queues and their controls so work
+inserted while the listener was down is not left waiting for the next poll
+interval. It also reads the client's running jobs and cancels any that were
+cancelled while the listener was down. Like River for Go, a client whose
+notification stream can't connect and listen when it starts rejects
+`client.start()` with that error before claiming any job. Leader election
+retries sooner than its normal interval after a failure; maintenance services
+record the failure as a `maintenance_failed` event and try again on their next
+interval. Transient failures are logged as warnings and other failures as
+errors.
+
+Completions are persisted in batches. Each attempt is bounded to 10 seconds
+and a batch gets three attempts with 1, 2, and 4 second backoff, matching
+River's other runtimes. After that, a transient failure (a
+`DatabaseOperationError` whose `retryable` is true) requeues the batch, and
+workers wait for completion capacity until the database recovers. Any other
+failure drops the batch: its jobs stay `running` and the rescuer retries them
+after `maintenance.rescueAfter`. Both outcomes are logged and reported as the
+`job_completion_requeued` and `job_completion_dropped` metrics. During
+shutdown the first persistent failure abandons the remaining completions to
+the rescuer, so `stop()` finishes even while the database is unavailable.
+
+A completion changes a job only while the job still belongs to the attempt
+that produced it: the same attempt number, claimed by the same client. When
+that attempt's job has already left `running`, for example because the
+rescuer retried it or it was cancelled, the attempt's recorded output and
+metadata are still merged into the job, as in River for Go, and its state is
+left alone. Unlike River for Go, a late completion never changes a job that
+another client has since claimed again; it's reported as a `job_race` event.
+
+## Process scaling
+
+One process can efficiently run I/O-bound work. Add ordinary process replicas
+for CPU/control overhead, availability, or deployment isolation. River's
+database protocol coordinates claims and leadership across JavaScript, Go, and
+Rust processes; no JavaScript-specific process manager is hidden in the
+library.
+
+## Clients that never lead
+
+`leaderElectionDisabled: true` keeps a client out of leader election, like
+River for Go's `Config.LeaderElectionDisabled`. It works jobs from its
+configured queues as usual, but never runs the scheduler, rescuer, cleaners,
+reindexer, or periodic jobs. Use it for processes dedicated to particular job
+kinds that shouldn't take on anything else:
+
+
+
+```ts
+const videoClient = new Client(new PgDriver(new Pool()), {
+ leaderElectionDisabled: true,
+ queues: { video: { maxWorkers: 4 } },
+ workers: videoWorkers,
+});
+```
+
+At least one other started client on the same database and schema, in any
+language, must remain eligible to lead. Otherwise scheduled jobs, retries,
+periodic jobs, stuck-job rescue, and cleanup stop progressing: a client with
+leader election disabled never leads, even when no other client is running.
+Such a client can't configure `periodicJobs` or modify `client.periodicJobs`
+(both throw a `ConfigurationError`), though it works periodic jobs that the
+leader inserts into its queues. Its `maintenance` settings have no effect.
+
+## Sharing a queue with clients that know other kinds
+
+By default a client claims every job in its queues, and a job whose kind has
+no worker fails with an unknown job kind error. `fetchOnlyKnownKinds: true`
+limits claims to the kinds the client has workers for when it starts, like
+River for Go's `Config.FetchOnlyKnownKinds`. Jobs of other kinds stay
+available without using an attempt, so clients with different workers can
+share a queue, such as while job kinds move from one language to another:
+
+
+
+```ts
+const client = new Client(new PgDriver(new Pool()), {
+ fetchOnlyKnownKinds: true,
+ leaderElectionDisabled: true,
+ queues: { default: { maxWorkers: 10 } },
+ workers: migratedWorkers,
+});
+```
+
+The option affects only claiming. A leader's rescuer still handles stuck jobs
+in every queue and discards those whose kinds it doesn't know, so a client
+with only some of the kinds should also set `leaderElectionDisabled`, with
+another eligible client, in any language, that has workers for every kind.
diff --git a/js/docs/testing.md b/js/docs/testing.md
new file mode 100644
index 000000000..a8390a87b
--- /dev/null
+++ b/js/docs/testing.md
@@ -0,0 +1,147 @@
+# Testing
+
+River code has two sides to test: the code that inserts jobs, and the
+handlers that work them. `@riverqueue/test` covers both without a database,
+and a real client on an in-memory SQLite database covers everything else.
+
+## Producers
+
+Type producer code against `InsertClient`, which both the real client and the
+test client satisfy, and assert on what it inserted:
+
+
+
+```ts
+import { createTestClient, requireInserted } from "@riverqueue/test";
+
+export async function signUp(client: InsertClient, email: string) {
+ // ... create the account ...
+ await client.insert(sendWelcomeEmail, { to: email }, { queue: "email" });
+}
+
+describe("signUp", () => {
+ it("queues a welcome email", async () => {
+ const { client, insertions } = createTestClient();
+ await signUp(client, "person@example.com");
+
+ const job = requireInserted(insertions, sendWelcomeEmail, {
+ args: { to: "person@example.com" },
+ queue: "email",
+ });
+ console.assert(job.state === "available");
+ });
+});
+```
+
+`requireInserted` fails unless exactly one recorded job matches, and returns it
+with typed args; `requireNotInserted` and `requireManyInserted` (the full list,
+in order) complete the set, like Go's `rivertest`. Insertions run the
+definition's validation, so an invalid payload fails the test just as it
+would fail in production. `createTestClient()` records the
+`{ tx }` each insertion received for code that inserts inside transactions.
+Pass `hooks`, `insertMiddleware`, `plugins`, or `defaultInsertOptions` to
+`createTestClient` to record the jobs they produce.
+
+Against a real database, `requireInsertedInDatabase(client, definition,
+match, { tx })` and `requireNotInsertedInDatabase` make the same assertions
+on the jobs a client persisted, like Go's `rivertest.RequireInsertedTx`;
+pass `tx` to look inside a transaction that hasn't committed.
+
+## Workers
+
+`testJob` builds the job a handler would receive, validating producer input
+through the definition exactly as the runtime does, and `workOnce` runs a
+handler (or the one a `Workers` bundle registered for the job's kind):
+
+
+
+```ts
+import { testJob, workOnce } from "@riverqueue/test";
+
+it("snoozes when the payment provider is busy", async () => {
+ const job = await testJob(chargeCard, { amountCents: 500 }, { attempt: 2 });
+ const result = await workOnce(job, workers);
+
+ expect(result.status).toBe("succeeded");
+ if (result.status === "succeeded") {
+ expect(result.outcome).toMatchObject({ type: "snooze" });
+ }
+});
+```
+
+The result also carries recorded `output`, merged `metadata` (including
+[resumable](./resumable-jobs.md) progress), and every message the handler
+logged. `ctx.client` inside `workOnce` records insertions like
+`createTestClient`; pass `client` to use a real one.
+
+## The whole runtime
+
+`workOnce` runs one handler; it doesn't run middleware, hooks, retries,
+scheduling, or persistence. To test those, start a real client on an
+in-memory SQLite database. It needs no external service and uses the same
+runtime as production:
+
+
+
+```ts
+import { SqliteDriver } from "@riverqueue/driver-sqlite";
+import { createMigrator } from "@riverqueue/migrate";
+import { Client } from "riverqueue";
+
+it("works a job end to end", async () => {
+ using driver = SqliteDriver.memory();
+ await createMigrator(driver).migrateUp();
+ const client = new Client(driver, {
+ leaderElectionDisabled: true,
+ queues: {
+ default: {
+ fetchCooldown: { milliseconds: 20 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 50 },
+ },
+ },
+ workers,
+ });
+ const { job } = await client.insert(chargeCard, { amountCents: 500 });
+
+ using events = client.subscribe({ kinds: ["job_completed"] });
+ await using run = await client.start();
+ const { value: event } = await events.next();
+ expect(event?.kind === "job_completed" && event.job.id).toBe(job.id);
+ await run.stop();
+});
+```
+
+`leaderElectionDisabled: true` keeps leader election and maintenance services
+out of short tests. A queue's `pollInterval` can't be shorter than its
+`fetchCooldown` (the client's `fetchCooldown`, 100 ms by default), so a test
+that polls faster lowers both. Use the PostgreSQL driver against a disposable
+database when a test depends on PostgreSQL behavior (`LISTEN`/`NOTIFY`, custom
+schemas, or concurrent clients).
diff --git a/js/driver/pg/README.md b/js/driver/pg/README.md
new file mode 100644
index 000000000..61d8db304
--- /dev/null
+++ b/js/driver/pg/README.md
@@ -0,0 +1,112 @@
+# `@riverqueue/driver-pg`
+
+This package is River's complete PostgreSQL backend for Node.js 26 and newer.
+It accepts a caller-owned `pg.Pool`, performs insertion and runtime operations,
+and implements notifications, leadership, maintenance, and queue control.
+
+```sh
+npm install riverqueue @riverqueue/driver-pg pg
+```
+
+```ts
+import { Client, defineJob } from "riverqueue";
+import { z } from "zod";
+import { PgDriver } from "@riverqueue/driver-pg";
+import { Pool } from "pg";
+
+const pool = new Pool({ connectionString: process.env.DATABASE_URL });
+const client = new Client(new PgDriver(pool));
+const accountId = "acct_1";
+const syncAccount = defineJob({
+ kind: "sync_account",
+ schema: z.object({ accountId: z.string() }),
+});
+```
+
+The pool remains application-owned. Stopping River does not close it. Size the
+pool for River's worker concurrency plus application queries and migrations;
+observe pool wait and saturation through the application's `pg` instrumentation
+before increasing worker concurrency.
+
+## Transactions
+
+Pass the exact checked-out `PoolClient` as `tx`. River uses that connection but
+does not begin, commit, roll back, or release the transaction:
+
+```ts continued
+const tx = await pool.connect();
+try {
+ await tx.query("BEGIN");
+ await tx.query("INSERT INTO accounts (id) VALUES ($1)", [accountId]);
+ await client.insert(syncAccount, { accountId }, { tx });
+ await tx.query("COMMIT");
+} catch (error) {
+ await tx.query("ROLLBACK");
+ throw error;
+} finally {
+ tx.release();
+}
+```
+
+Without `{ tx }`, River begins a transaction of its own on a connection it
+leases from the pool, like River for Go: argument validation, insert
+middleware, hooks, and the write commit together, and an error thrown by any
+of them rolls the jobs back. A driver constructed from a single client, rather
+than a pool, has no connection to lease, so it rejects insertions without
+`{ tx }` with a `ConfigurationError`. The transaction costs two more round
+trips per call, for `BEGIN` and `COMMIT`, as it does in Go. To insert many
+jobs, pass them to one `insertMany` call rather than calling `insert` for
+each.
+
+Apply migrations explicitly with `@riverqueue/migrate` or
+`@riverqueue/cli` before starting workers.
+
+## Connection health and cancellation
+
+River pings its LISTEN connection after five idle seconds, by repeating an
+idempotent `LISTEN`, and replaces it when the ping fails or goes unanswered for
+five seconds, so a half-open socket cannot silently stop notifications. Every reconnection makes the runtime poll for work
+and queue changes it may have missed.
+
+When River abandons an in-flight statement, such as a completion query that
+exceeded its 10 second bound or a reindex interrupted by shutdown, it destroys
+that connection and asks PostgreSQL to cancel the statement with
+`pg_cancel_backend` from another pooled connection. The cancellation only
+targets a backend still running that River statement. A statement can still
+commit before the cancellation arrives; River's attempt-identity guard keeps
+that harmless, because a retried completion then observes the committed row
+and reports it. Leader maintenance passes are abandoned the same way when
+their leadership term ends or the client stops, which rolls them back.
+
+Like River for Go, a claim that has started runs to completion, and so do
+River's queue reports and leader elections, so on a connection whose socket
+went half-open they wait for the operating system to notice. Construct the
+`Pool` with `keepAlive: true`, and consider a `query_timeout`, so such sockets
+fail, and pass a `timeout` to `run.stop()` to bound how long a stop waits.
+
+## PgBouncer and schemas
+
+Transaction-mode PgBouncer is supported for ordinary operations. River's
+dedicated notification and leadership sessions must connect somewhere that
+preserves session state; use a direct connection or session-mode endpoint for
+those capabilities. Configure a custom River schema consistently in the
+driver, migrator, and CLI. Never interpolate an untrusted schema name.
+
+## Requirements
+
+Node.js 26 or newer with native `Temporal`: `node -p "typeof Temporal"` must
+print `object`. Official Node.js binaries include it; some builds compiled from
+source, including some distribution and Homebrew packages, do not.
+
+Install `riverqueue` at exactly this package's version. It is a peer dependency,
+so npm rejects a mismatched pair instead of loading two copies.
+
+River parses the values its queries return with its own parsers, so changes an
+application makes to node-postgres's global parsers (`pg.types.setTypeParser`)
+don't affect it. It reads timestamps in PostgreSQL's default `DateStyle` of
+`ISO`; a session with another `DateStyle` fails with a clear error.
+
+TypeScript users need TypeScript 6.0 or newer and `@types/node` and `@types/pg`,
+with `"node"` listed in `compilerOptions.types`. See [River's
+requirements](https://github.com/riverqueue/river/tree/master/js#requirements) for
+details.
diff --git a/js/driver/pg/etc/driver-pg.api.md b/js/driver/pg/etc/driver-pg.api.md
new file mode 100644
index 000000000..28e111272
--- /dev/null
+++ b/js/driver/pg/etc/driver-pg.api.md
@@ -0,0 +1,45 @@
+# API report: `@riverqueue/driver-pg`
+
+
+
+This report contains declarations and TSDoc for names exported by the
+package entry point. Private members, unexported implementation
+declarations, and file layout are omitted.
+
+## `PgDriver`
+
+```ts
+/**
+ * River's complete node-postgres backend.
+ *
+ * The pool/client is caller-owned, and `PgDriver` never closes it. Keep
+ * your own reference to it: the driver exposes nothing but its
+ * construction. Passing `{ tx }` keeps the entire operation on that exact
+ * checked-out client, and River never ends that transaction. An insertion
+ * without `{ tx }` runs in a transaction River begins on a connection it
+ * leases from the pool, like River for Go, so a driver constructed from a
+ * single client requires `{ tx }` for insertions.
+ */
+export declare class PgDriver {
+ /**
+ * Type-only marker: a full runtime driver whose transactions are any
+ * node-postgres client (`pg.Client` or a `PoolClient` checked out from a
+ * pool) after `BEGIN`.
+ */
+ readonly "~river"?: {
+ readonly capability: "runtime";
+ readonly transaction: ClientBase;
+ };
+ constructor(client: Pool | PoolClient | PgClient, options?: PgDriverOptions);
+}
+```
+
+## `PgDriverOptions`
+
+```ts
+/** Construction options owned by the PostgreSQL backend. */
+export interface PgDriverOptions {
+ /** PostgreSQL schema containing River's tables and functions. */
+ schema?: string;
+}
+```
diff --git a/js/driver/pg/package.json b/js/driver/pg/package.json
new file mode 100644
index 000000000..ca51690a9
--- /dev/null
+++ b/js/driver/pg/package.json
@@ -0,0 +1,72 @@
+{
+ "name": "@riverqueue/driver-pg",
+ "version": "0.50.0-alpha.1",
+ "description": "Complete node-postgres backend for River's TypeScript runtime.",
+ "type": "module",
+ "sideEffects": false,
+ "main": "./dist/index.js",
+ "types": "./dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
+ "engines": {
+ "node": ">=26"
+ },
+ "files": [
+ "dist",
+ "src",
+ "!src/**/*.test.ts",
+ "README.md",
+ "LICENSE"
+ ],
+ "scripts": {
+ "build": "node ../../node_modules/typescript/bin/tsc && node ../../scripts/copy-license.mjs",
+ "clean": "rm -rf dist",
+ "prepack": "pnpm run clean && pnpm run build"
+ },
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/riverqueue/river.git",
+ "directory": "js/driver/pg"
+ },
+ "contributors": [
+ "Brandur Leach",
+ "Blake Gentry"
+ ],
+ "license": "LGPL-3.0-or-later",
+ "publishConfig": {
+ "access": "public",
+ "provenance": true
+ },
+ "dependencies": {
+ "postgres-array": "^3.0.4"
+ },
+ "peerDependencies": {
+ "@types/pg": ">=8",
+ "pg": ">=8.0.0",
+ "riverqueue": "workspace:0.50.0-alpha.1"
+ },
+ "peerDependenciesMeta": {
+ "@types/pg": {
+ "optional": true
+ }
+ },
+ "devDependencies": {
+ "@types/pg": "^8.20.0",
+ "pg": "^8.22.0",
+ "riverqueue": "workspace:0.50.0-alpha.1",
+ "typescript": "^6.0.3"
+ },
+ "keywords": [
+ "river",
+ "job-queue",
+ "postgresql",
+ "pg",
+ "node-postgres",
+ "driver"
+ ]
+}
diff --git a/js/driver/pg/src/database.ts b/js/driver/pg/src/database.ts
new file mode 100644
index 000000000..9e19460bc
--- /dev/null
+++ b/js/driver/pg/src/database.ts
@@ -0,0 +1,398 @@
+/**
+ * Schema-qualified naming and query execution for one caller-owned
+ * node-postgres pool or client. The SQL modules under `sql/` run every
+ * statement through a {@link PgDatabase}.
+ */
+import {
+ POSTGRES_CAPABILITIES_SQL,
+ postgresCapabilitiesFromRow,
+ quoteIdentifier,
+ type PostgresCapabilities,
+} from "riverqueue/unstable-driver";
+import type {
+ Client as PgClient,
+ ClientBase,
+ Pool,
+ PoolClient,
+ QueryConfig,
+ QueryResult,
+ QueryResultRow,
+} from "pg";
+import { RiverError } from "riverqueue";
+import type { InsertDriverOptions } from "riverqueue/unstable-driver";
+import {
+ backendMismatchError,
+ configurationError,
+ databaseError,
+} from "./errors.js";
+import { PG_EXACT_TYPES } from "./exact-types.js";
+import { abortablePromise, PgClientLease } from "./lease.js";
+import type { PgOperationOptions } from "./types.js";
+
+/** PostgreSQL's maximum identifier length (`NAMEDATALEN - 1`). */
+export const POSTGRES_IDENTIFIER_MAX_BYTES = 63;
+
+/** How long an abort waits for a pooled connection to cancel its statement. */
+const CANCEL_CONNECT_TIMEOUT_MS = 2_000;
+
+/** Anything River can send a query through: a pool, client, or pool client. */
+export type PgQueryable = Pick;
+
+/** A caller-owned pool or client together with River's schema naming. */
+export class PgDatabase {
+ /** The caller-owned pool, or null for a single client. */
+ readonly pool: Pool | null;
+ /** The sequence backing `river_job.id`, schema-qualified when configured. */
+ readonly qualifiedJobSequence: string;
+ /** The configured schema, or null to use the connection's `search_path`. */
+ readonly schemaName: string | null;
+ /** A quoted `schema.` prefix, or the empty string without a schema. */
+ readonly schemaPrefix: string;
+
+ /**
+ * The server's capabilities once detected. Like River for Go's drivers,
+ * River caches them for this database's lifetime.
+ */
+ #capabilities: PostgresCapabilities | undefined;
+ readonly #client: PgQueryable;
+
+ /** Wrap a validated pool or client; `schema` is already validated. */
+ constructor(client: Pool | PoolClient | PgClient, schema: string | null) {
+ this.#client = client;
+ this.pool = isPool(client) ? client : null;
+ this.schemaName = schema;
+ this.schemaPrefix = schema === null ? "" : `${quoteIdentifier(schema)}.`;
+ this.qualifiedJobSequence = `${this.schemaPrefix}${quoteIdentifier("river_job_id_seq")}`;
+ }
+
+ /**
+ * The server's capabilities, such as whether it delivers notifications
+ * and how a unique insert detects a conflict, detected on `options`'
+ * connection the first time. Concurrent first callers may each detect;
+ * the first result stored wins, and nothing is cached after a failure.
+ */
+ async capabilities(
+ options?: PgOperationOptions | InsertDriverOptions
+ ): Promise {
+ if (this.#capabilities !== undefined) return this.#capabilities;
+ const result = await this.query<{
+ date_style: unknown;
+ product: unknown;
+ version_num: unknown;
+ yb_listen_notify_enabled: unknown;
+ }>("detectCapabilities", POSTGRES_CAPABILITIES_SQL, [], options);
+ const row = result.rows[0];
+ if (row === undefined) {
+ throw databaseError(
+ "detectCapabilities",
+ "PostgreSQL returned no server capabilities"
+ );
+ }
+ // node-postgres reads timestamps as text, which River parses in the ISO
+ // format only; River for Go reads them in binary, whatever the style.
+ if (typeof row.date_style !== "string" || !/^ISO\b/.test(row.date_style)) {
+ throw configurationError(
+ "detectCapabilities",
+ `River needs PostgreSQL's DateStyle to be ISO, not ${JSON.stringify(row.date_style)}; ` +
+ "set it for River's connections, for example with the Pool option " +
+ "options: \"-c DateStyle=ISO\", or with ALTER ROLE ... SET DateStyle = 'ISO'"
+ );
+ }
+ this.#capabilities ??= postgresCapabilitiesFromRow(row);
+ return this.#capabilities;
+ }
+
+ /**
+ * Best-effort server-side cancellation of the statement an abandoned leased
+ * connection is running. The request runs on another pooled connection
+ * and only signals a backend that is still active with a statement starting
+ * with `statementPrefix`. Failures are ignored: the leased socket is
+ * destroyed regardless, and River's attempt identity guard keeps a
+ * statement that commits anyway from overwriting newer work.
+ */
+ cancelBackend(client: PoolClient, statementPrefix: string): void {
+ const pool = this.pool;
+ const processID = (client as { readonly processID?: unknown }).processID;
+ if (
+ pool === null ||
+ typeof processID !== "number" ||
+ !Number.isSafeInteger(processID) ||
+ processID <= 0
+ ) {
+ return;
+ }
+ void (async () => {
+ const acquiring = pool.connect();
+ const timeout = AbortSignal.timeout(CANCEL_CONNECT_TIMEOUT_MS);
+ let canceller: PoolClient;
+ try {
+ canceller = await abortablePromise(acquiring, timeout);
+ } catch {
+ void acquiring.then((late) => late.release()).catch(() => undefined);
+ return;
+ }
+ const lease = new PgClientLease(canceller);
+ try {
+ await lease.race(
+ lease.client.query({
+ text: `
+ SELECT pg_cancel_backend(pid)
+ FROM pg_catalog.pg_stat_activity
+ WHERE pid = $1::int
+ AND state = 'active'
+ AND left(query, length($2::text)) = $2::text
+ `,
+ types: PG_EXACT_TYPES,
+ values: [processID, statementPrefix],
+ })
+ );
+ lease.release();
+ } catch {
+ lease.destroy();
+ }
+ })();
+ }
+
+ /** Schema-qualify and quote a River SQL function name. */
+ function(name: string): string {
+ return `${this.schemaPrefix}${quoteIdentifier(name)}`;
+ }
+
+ /**
+ * Run one parameterized statement with River's exact type parsers, on
+ * `options.tx` when given. Driver failures become {@link databaseError}s
+ * naming `operation`; River's own errors pass through unchanged.
+ */
+ async query(
+ operation: string,
+ text: string,
+ values: unknown[],
+ options?: PgOperationOptions | InsertDriverOptions
+ ): Promise> {
+ const queryable = this.resolveQueryable(operation, options);
+ const config: QueryConfig = {
+ text,
+ types: PG_EXACT_TYPES,
+ values,
+ };
+
+ try {
+ return await queryable.query(config);
+ } catch (cause) {
+ if (cause instanceof RiverError) throw cause;
+ throw databaseError(
+ operation,
+ `PostgreSQL operation ${operation} failed`,
+ cause
+ );
+ }
+ }
+
+ /**
+ * Like {@link PgDatabase.query}, but stop waiting for a pool connection when
+ * `signal` aborts. Once a connection is leased the statement always runs to
+ * completion, so work such as a claim is never abandoned half done.
+ */
+ async queryAfterAcquire(
+ signal: AbortSignal | undefined,
+ operation: string,
+ text: string,
+ values: unknown[]
+ ): Promise> {
+ return this.withConnection(signal, (options) =>
+ this.query(operation, text, values, options)
+ );
+ }
+
+ /**
+ * Run `run` on a pool connection leased for it, stopping only the wait for
+ * that connection when `signal` aborts. Once leased, `run`'s statements
+ * always finish, so writes such as a claim or heartbeat are never abandoned
+ * half done. Without a pool or signal, `run` uses the configured client.
+ */
+ async withConnection(
+ signal: AbortSignal | undefined,
+ run: (options: { readonly tx?: ClientBase }) => Promise
+ ): Promise {
+ if (signal === undefined || this.pool === null) return run({});
+ const acquiring = this.pool.connect();
+ let lease: PgClientLease;
+ try {
+ lease = new PgClientLease(await abortablePromise(acquiring, signal));
+ } catch (error: unknown) {
+ void acquiring.then((lateClient) => lateClient.release()).catch(() => {});
+ throw error;
+ }
+ try {
+ return await lease.race(run({ tx: lease.client }));
+ } finally {
+ lease.release();
+ }
+ }
+
+ /**
+ * Like {@link PgDatabase.query}, but stop waiting when `options.signal`
+ * aborts. Without a caller transaction the statement runs on its own
+ * leased pool connection, which an abort destroys, first asking PostgreSQL
+ * to cancel a statement that starts with `cancelPrefix`.
+ */
+ async queryAbortable(
+ operation: string,
+ text: string,
+ values: unknown[],
+ options: { readonly signal?: AbortSignal; readonly tx?: ClientBase } = {},
+ cancelPrefix?: string
+ ): Promise> {
+ const signal = options.signal;
+ if (signal === undefined) {
+ return this.query(
+ operation,
+ text,
+ values,
+ options.tx === undefined ? undefined : { tx: options.tx }
+ );
+ }
+ if (options.tx !== undefined || this.pool === null) {
+ // River cannot destroy a caller-owned connection. Cancellation still
+ // bounds the caller's wait; pool-backed runtime queries below also end
+ // their PostgreSQL session so blocked work cannot continue in the pool.
+ return abortablePromise(
+ this.query(
+ operation,
+ text,
+ values,
+ options.tx === undefined ? undefined : { tx: options.tx }
+ ),
+ signal
+ );
+ }
+
+ const acquiring = this.pool.connect();
+ let lease: PgClientLease;
+ try {
+ lease = new PgClientLease(await abortablePromise(acquiring, signal));
+ } catch (error: unknown) {
+ void acquiring.then((lateClient) => lateClient.release()).catch(() => {});
+ throw error;
+ }
+ if (signal.aborted) {
+ lease.release();
+ throw signal.reason;
+ }
+
+ const client = lease.client;
+ let queryActive = true;
+ const abort = (): void => {
+ if (!queryActive) return;
+ // Ask PostgreSQL to stop the statement as well: a destroyed socket is
+ // only noticed once the statement finishes, so a lock wait would keep
+ // running and could still commit.
+ if (cancelPrefix !== undefined) this.cancelBackend(client, cancelPrefix);
+ lease.destroy();
+ };
+ signal.addEventListener("abort", abort, { once: true });
+ try {
+ return await lease.race(
+ abortablePromise(
+ this.query(operation, text, values, { tx: client }),
+ signal
+ )
+ );
+ } finally {
+ queryActive = false;
+ signal.removeEventListener("abort", abort);
+ lease.release();
+ }
+ }
+
+ /**
+ * Run `callback` in a transaction on a pool connection leased for it:
+ * `BEGIN`, then `COMMIT` once `callback` resolves, or `ROLLBACK` when it
+ * rejects. `signal` stops the wait for a connection, and once it has
+ * aborted when `callback` resolves the transaction rolls back instead of
+ * committing. `callback` runs at most once. A failed `COMMIT` rejects
+ * without claiming the transaction rolled back.
+ */
+ async transaction(
+ operation: string,
+ pool: Pool,
+ signal: AbortSignal | undefined,
+ callback: (client: PoolClient) => PromiseLike | T
+ ): Promise {
+ const acquiring = pool.connect();
+ let lease: PgClientLease;
+ try {
+ lease = new PgClientLease(await abortablePromise(acquiring, signal));
+ } catch (error: unknown) {
+ void acquiring.then((late) => late.release()).catch(() => undefined);
+ throw error;
+ }
+ const client = lease.client;
+ let transactionStarted = false;
+ try {
+ signal?.throwIfAborted();
+ await lease.race(
+ this.query(`${operation}Begin`, "BEGIN", [], { tx: client })
+ );
+ transactionStarted = true;
+ const result = await lease.race(callback(client));
+ signal?.throwIfAborted();
+ await lease.race(
+ this.query(`${operation}Commit`, "COMMIT", [], { tx: client })
+ );
+ transactionStarted = false;
+ return result;
+ } catch (cause: unknown) {
+ if (transactionStarted && !lease.failed) {
+ try {
+ await lease.race(client.query("ROLLBACK"));
+ } catch {
+ lease.destroy();
+ }
+ }
+ throw cause;
+ } finally {
+ lease.release();
+ }
+ }
+
+ /** The caller's transaction client when given, else the configured pool or client. */
+ resolveQueryable(
+ operation: string,
+ options?: PgOperationOptions | InsertDriverOptions
+ ): PgQueryable {
+ if (options?.tx === undefined) return this.#client;
+ if (!isQueryable(options.tx)) {
+ throw backendMismatchError(
+ operation,
+ "the transaction is not a node-postgres client"
+ );
+ }
+ return options.tx;
+ }
+
+ /** Schema-qualify and quote a River table name. */
+ table(name: string): string {
+ return `${this.schemaPrefix}${quoteIdentifier(name)}`;
+ }
+
+ /** Schema-qualify and quote a River type name. */
+ type(name: string): string {
+ return `${this.schemaPrefix}${quoteIdentifier(name)}`;
+ }
+}
+
+/** Whether a node-postgres queryable is a `Pool` rather than a client. */
+function isPool(value: Pool | PoolClient | PgClient): value is Pool {
+ return "totalCount" in value && "idleCount" in value;
+}
+
+/** Whether a value can run node-postgres queries. */
+export function isQueryable(value: unknown): value is PgQueryable {
+ return (
+ (typeof value === "object" || typeof value === "function") &&
+ value !== null &&
+ "query" in value &&
+ typeof value.query === "function"
+ );
+}
diff --git a/js/driver/pg/src/driver.integration.test.ts b/js/driver/pg/src/driver.integration.test.ts
new file mode 100644
index 000000000..33cc4203c
--- /dev/null
+++ b/js/driver/pg/src/driver.integration.test.ts
@@ -0,0 +1,3172 @@
+import { readdir, readFile } from "node:fs/promises";
+import { join } from "node:path";
+import { fileURLToPath } from "node:url";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+import pg from "pg";
+import {
+ Client,
+ defineJob,
+ isExactJsonNumber,
+ type JobRow,
+ type JobState,
+ type JsonObject,
+ periodicJob,
+ ValidationError,
+ Workers,
+} from "riverqueue";
+import type {
+ JobInsertParams,
+ JobListCursorValue,
+} from "riverqueue/unstable-driver";
+import {
+ decodeJobListCursor,
+ encodeJobListCursor,
+ jobListCursorValue,
+} from "riverqueue/unstable-driver";
+import { PgDriver, type PgRuntime, testPgDriver } from "./driver.js";
+import type { PgJobRescue } from "./types.js";
+
+const TEST_DATABASE_URL =
+ process.env.TEST_DATABASE_URL ??
+ "postgres://localhost:5432/river_test?sslmode=disable";
+const filePrefix = `js_pg_${Math.random().toString(36).slice(2, 10)}`;
+const migrationDirectory = fileURLToPath(
+ new URL("../../../migrate/migrations/postgres/main/", import.meta.url)
+);
+
+async function migrateSchema(pool: pg.Pool, schema: string): Promise {
+ const migrationFiles = (await readdir(migrationDirectory))
+ .filter((name) => name.endsWith(".up.sql"))
+ .sort();
+ const schemaPrefix = `"${schema}".`;
+
+ for (const migrationFile of migrationFiles) {
+ const migration = await readFile(
+ join(migrationDirectory, migrationFile),
+ "utf8"
+ );
+ await pool.query(
+ migration.replaceAll("/* TEMPLATE: schema */", schemaPrefix)
+ );
+ }
+}
+
+function insertParams(
+ kind: string,
+ overrides: Partial = {}
+): JobInsertParams {
+ return {
+ args: { value: kind },
+ encodedArgs: JSON.stringify({ value: kind }),
+ kind,
+ maxAttempts: 25,
+ metadata: {},
+ priority: 1,
+ queue: "default",
+ scheduledAt: Temporal.Now.instant(),
+ state: "available",
+ tags: [],
+ uniqueKey: null,
+ uniqueStates: null,
+ ...overrides,
+ };
+}
+
+describe("PgDriver integration", () => {
+ let driver: PgRuntime;
+ let pool: pg.Pool;
+
+ beforeAll(async () => {
+ pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ driver = testPgDriver(pool);
+ // Maintenance operations act on every row, not just this file's kinds,
+ // so start from empty River tables whatever ran against the database
+ // before (such as the packed PostgreSQL examples).
+ await pool.query("TRUNCATE river_job, river_leader, river_queue");
+ });
+
+ afterAll(async () => {
+ await pool.end();
+ });
+
+ afterEach(async () => {
+ await pool.query("DELETE FROM river_job WHERE kind LIKE $1", [
+ `${filePrefix}%`,
+ ]);
+ await pool.query("DELETE FROM river_queue WHERE name LIKE $1", [
+ `${filePrefix}%`,
+ ]);
+ });
+
+ it("inserts and decodes exact IDs and microsecond timestamps", async () => {
+ const scheduledAt = Temporal.Instant.from("2026-08-30T12:00:00.123456Z");
+
+ const result = await driver.jobInsert(
+ insertParams(`${filePrefix}_exact`, {
+ metadata: { source: "javascript" },
+ scheduledAt,
+ state: "scheduled",
+ })
+ );
+
+ expect(result.status).toBe("inserted");
+ expect(typeof result.job.id).toBe("bigint");
+ expect(result.job.scheduledAt.toString()).toBe(
+ "2026-08-30T12:00:00.123456Z"
+ );
+ expect(result.job.metadata).toEqual({ source: "javascript" });
+
+ const fetched = await driver.jobGet(result.job.id);
+ expect(fetched!.id).toBe(result.job.id);
+ expect(fetched!.scheduledAt.toString()).toBe("2026-08-30T12:00:00.123456Z");
+ });
+
+ it("leaves an unscheduled job's times to the database like Go", async () => {
+ const { scheduledAt: _unused, ...unscheduled } = insertParams(
+ `${filePrefix}_db_time`
+ );
+ void _unused;
+ const plain = new Client(driver);
+
+ const [direct, viaClient] = await Promise.all([
+ driver.jobInsert(unscheduled),
+ plain.insert(defineJob({ kind: `${filePrefix}_db_time_client` }), {}),
+ ]);
+
+ for (const { job } of [direct, viaClient]) {
+ const row = (
+ await pool.query<{ same: boolean }>(
+ "SELECT created_at = scheduled_at AS same FROM river_job WHERE id = $1",
+ [job.id.toString(10)]
+ )
+ ).rows[0];
+ expect(row?.same).toBe(true);
+ expect(job.scheduledAt.equals(job.createdAt)).toBe(true);
+ }
+ });
+
+ it("truncates sub-microsecond timestamps like Go instead of rounding", async () => {
+ // PostgreSQL would round `.0000019` up to `.000002`; Go's pgx truncates.
+ const scheduledAt = Temporal.Instant.from("2026-08-30T12:00:00.0000019Z");
+ const createdAt = Temporal.Instant.from("2026-08-30T11:00:00.0000015Z");
+
+ const inserted = await driver.jobInsertMany([
+ insertParams(`${filePrefix}_truncate`, {
+ createdAt,
+ scheduledAt,
+ state: "scheduled",
+ }),
+ ]);
+
+ expect(inserted[0]!.job.scheduledAt.toString()).toBe(
+ "2026-08-30T12:00:00.000001Z"
+ );
+ expect(inserted[0]!.job.createdAt.toString()).toBe(
+ "2026-08-30T11:00:00.000001Z"
+ );
+ });
+
+ it("keeps a reinserted job's creation time and encoded arguments", async () => {
+ const createdAt = Temporal.Instant.from("2026-01-02T03:04:05.123456Z");
+ // PostgreSQL's clock rounds to microseconds.
+ const before = Temporal.Now.instant().subtract({ seconds: 1 });
+
+ const params = [
+ insertParams(`${filePrefix}_reinserted`, {
+ args: { b: 1, a: 2 },
+ createdAt,
+ encodedArgs: '{"b": 1, "a": 2}',
+ }),
+ insertParams(`${filePrefix}_new`),
+ ];
+ const inserted = await driver.jobInsertMany(params);
+
+ for (const results of [inserted]) {
+ expect(results[0]!.job.createdAt).toEqual(createdAt);
+ expect(results[0]!.job.args).toEqual({ a: 2, b: 1 });
+ expect(
+ Temporal.Instant.compare(results[1]!.job.createdAt, before)
+ ).toBeGreaterThanOrEqual(0);
+ expect(results[1]!.job).toMatchObject({ attemptedBy: [], errors: [] });
+ }
+ const { rows } = await pool.query<{ nulls: boolean }>(
+ `SELECT attempted_by IS NULL AND errors IS NULL AS nulls
+ FROM river_job WHERE kind LIKE $1`,
+ [`${filePrefix}_%`]
+ );
+ expect(rows.map(({ nulls }) => nulls)).toEqual([true, true]);
+ });
+
+ it("gets IDs outside JavaScript's safe-number range exactly", async () => {
+ const exactID = 9_007_199_254_740_993n;
+ await pool.query(
+ `
+ INSERT INTO river_job (id, args, kind, max_attempts)
+ VALUES ($1::bigint, '{}'::jsonb, $2::text, 25)
+ `,
+ [exactID.toString(10), `${filePrefix}_large_id`]
+ );
+
+ const fetched = await driver.jobGet(exactID);
+
+ expect(fetched!.id).toBe(exactID);
+ expect(typeof fetched!.id).toBe("bigint");
+ });
+
+ it("preserves exact JSON numbers inserted by other engines", async () => {
+ const result = await pool.query<{ id: string }>(
+ `
+ INSERT INTO river_job (args, kind, max_attempts, metadata)
+ VALUES ($1::jsonb, $2::text, 25, $3::jsonb)
+ RETURNING id::text AS id
+ `,
+ [
+ '{"decimal":0.1234567890123456789,"integer":9223372036854775807}',
+ `${filePrefix}_exact_json`,
+ '{"underflow":1e-400}',
+ ]
+ );
+ const fetched = await driver.jobGet(BigInt(result.rows[0]!.id));
+
+ expect(isExactJsonNumber(fetched!.args.decimal)).toBe(true);
+ expect(isExactJsonNumber(fetched!.args.integer)).toBe(true);
+ expect(isExactJsonNumber(fetched!.metadata.underflow)).toBe(true);
+ expect(JSON.stringify(fetched!.args)).toBe(
+ '{"decimal":0.1234567890123456789,"integer":9223372036854775807}'
+ );
+ });
+
+ it("decodes exact timestamps nested in PostgreSQL jsonb arrays", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_attempt_error`)
+ );
+ await pool.query(
+ `
+ UPDATE river_job
+ SET errors = ARRAY[$2::jsonb]
+ WHERE id = $1::bigint
+ `,
+ [
+ inserted.job.id.toString(10),
+ JSON.stringify({
+ at: "2026-08-30T12:00:00.123456Z",
+ attempt: 1,
+ error: "failed",
+ trace: "trace",
+ }),
+ ]
+ );
+
+ const fetched = await driver.jobGet(inserted.job.id);
+
+ expect(fetched!.errors[0]!.at.toString()).toBe(
+ "2026-08-30T12:00:00.123456Z"
+ );
+ });
+
+ it("fails a batch repeating an active unique key without writing it", async () => {
+ const uniqueKey = Uint8Array.from([1, 2, 3, 4, 5, 6]);
+ const unique = {
+ uniqueKey,
+ uniqueStates: [
+ "available",
+ "completed",
+ "pending",
+ "retryable",
+ "running",
+ "scheduled",
+ ],
+ } as const;
+ await expect(
+ driver.jobInsertMany([
+ insertParams(`${filePrefix}_batch_unique`, unique),
+ insertParams(`${filePrefix}_other`),
+ insertParams(`${filePrefix}_batch_unique`, unique),
+ ])
+ ).rejects.toMatchObject({
+ // PostgreSQL's cardinality_violation: ON CONFLICT DO UPDATE can't
+ // affect a row twice, as for River for Go's batch.
+ cause: expect.objectContaining({ code: "21000" }),
+ });
+
+ const count = await pool.query<{ count: string }>(
+ "SELECT count(*) FROM river_job WHERE kind = ANY($1)",
+ [[`${filePrefix}_batch_unique`, `${filePrefix}_other`]]
+ );
+ expect(count.rows[0]!.count).toBe("0");
+ });
+
+ it("rejects a client batch repeating an active unique key", async () => {
+ const job = defineJob<{ value: string }>()({
+ kind: `${filePrefix}_client_batch_unique`,
+ });
+ const client = new Client(driver);
+ const item = {
+ args: { value: "same" },
+ job,
+ options: { unique: { byArgs: true } },
+ } as const;
+
+ await expect(client.insertMany([item, item])).rejects.toThrow(
+ new ValidationError("unique key appears more than once in batch")
+ );
+
+ const count = await pool.query<{ count: string }>(
+ "SELECT count(*) FROM river_job WHERE kind = $1",
+ [job.kind]
+ );
+ expect(count.rows[0]!.count).toBe("0");
+ });
+
+ it("keeps the existing job's kind on a unique skip of another kind", async () => {
+ const jobA = defineJob<{ value: string }>()({
+ kind: `${filePrefix}_unique_kind_a`,
+ });
+ const jobB = defineJob<{ value: string }>()({
+ kind: `${filePrefix}_unique_kind_b`,
+ });
+ const client = new Client(driver);
+ const options = { unique: { byArgs: true, excludeKind: true } } as const;
+
+ const first = await client.insert(jobA, { value: "same" }, options);
+ const single = await client.insert(jobB, { value: "same" }, options);
+ const [batched] = await client.insertMany([
+ { args: { value: "same" }, job: jobB, options },
+ ]);
+
+ expect(first.status).toBe("inserted");
+ for (const result of [single, batched]) {
+ expect(result.status).toBe("duplicate");
+ expect(result.job.id).toBe(first.job.id);
+ expect(result.job.kind).toBe(jobA.kind);
+ }
+ const stored = await pool.query<{ kind: string }>(
+ "SELECT kind FROM river_job WHERE kind = ANY($1)",
+ [[jobA.kind, jobB.kind]]
+ );
+ expect(stored.rows).toEqual([{ kind: jobA.kind }]);
+ });
+
+ it("ordinarily inserts beyond PostgreSQL's bind-parameter limit", async () => {
+ const params = Array.from({ length: 6_000 }, (_, index) =>
+ insertParams(`${filePrefix}_ordinary_bulk`, {
+ args: { index },
+ encodedArgs: JSON.stringify({ index }),
+ tags: [`bulk-${index % 3}`],
+ })
+ );
+
+ const results = await driver.jobInsertMany(params);
+
+ expect(results).toHaveLength(params.length);
+ expect(results.every(({ status }) => status === "inserted")).toBe(true);
+ expect(results[5_999]!.job.args).toEqual({ index: 5_999 });
+ expect(results[5_999]!.job.tags).toEqual(["bulk-2"]);
+ // Inserting and decoding 6,000 rows takes several seconds on a busy CI
+ // runner.
+ }, 30_000);
+
+ it("does not conflate active and inactive uniqueness masks", async () => {
+ const uniqueKey = Uint8Array.from([7, 7, 7, 7]);
+ const results = await driver.jobInsertMany([
+ insertParams(`${filePrefix}_mixed_unique`, {
+ uniqueKey,
+ uniqueStates: [
+ "available",
+ "completed",
+ "pending",
+ "retryable",
+ "running",
+ "scheduled",
+ ],
+ }),
+ insertParams(`${filePrefix}_mixed_unique`, {
+ uniqueKey,
+ uniqueStates: ["scheduled"],
+ }),
+ ]);
+
+ expect(results.map(({ status }) => status)).toEqual([
+ "inserted",
+ "inserted",
+ ]);
+ expect(results[0]!.job.id).not.toBe(results[1]!.job.id);
+ });
+
+ it("keeps transaction operations on the caller-owned connection", async () => {
+ const transaction = await pool.connect();
+ let insertedID: bigint | undefined;
+ try {
+ await transaction.query("BEGIN");
+ const result = await driver.jobInsert(
+ insertParams(`${filePrefix}_transaction`),
+ { tx: transaction }
+ );
+ insertedID = result.job.id;
+
+ const inside = await driver.jobGet(result.job.id, { tx: transaction });
+ const outside = await driver.jobGet(result.job.id);
+ expect(inside).not.toBeNull();
+ expect(outside).toBeNull();
+
+ await transaction.query("ROLLBACK");
+ } finally {
+ transaction.release();
+ }
+
+ expect(insertedID).toBeDefined();
+ await expect(driver.jobGet(insertedID)).resolves.toBeNull();
+ await expect(pool.query("SELECT 1")).resolves.toBeDefined();
+ });
+
+ it("rolls a non-transactional insert back when middleware or a hook throws", async () => {
+ const definition = defineJob({ kind: `${filePrefix}_scope_rollback` });
+ const failure = new Error("fails after the write");
+ let failIn: "afterInsert" | "middleware" | null = null;
+ const client = new Client(driver, {
+ hooks: {
+ afterInsert: () => {
+ if (failIn === "afterInsert") throw failure;
+ },
+ },
+ insertMiddleware: [
+ async (_context, next) => {
+ const results = await next();
+ if (failIn === "middleware") throw failure;
+ return results;
+ },
+ ],
+ });
+ const count = async () =>
+ (
+ await pool.query<{ count: string }>(
+ "SELECT count(*)::text AS count FROM river_job WHERE kind = $1",
+ [definition.kind]
+ )
+ ).rows[0]?.count;
+
+ for (const where of ["middleware", "afterInsert"] as const) {
+ failIn = where;
+ await expect(client.insert(definition, {})).rejects.toBe(failure);
+ await expect(
+ client.insertMany([{ args: {}, job: definition }])
+ ).rejects.toBe(failure);
+ }
+ expect(await count()).toBe("0");
+
+ failIn = null;
+ await client.insert(definition, {});
+ expect(await count()).toBe("1");
+ await expect(pool.query("SELECT 1")).resolves.toBeDefined();
+ });
+
+ it("cancels queued and running jobs with canonical semantics", async () => {
+ const queued = await driver.jobInsert(
+ insertParams(`${filePrefix}_cancel_queued`)
+ );
+ const running = await driver.jobInsert(
+ insertParams(`${filePrefix}_cancel_running`)
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'running' WHERE id = $1::bigint",
+ [running.job.id.toString(10)]
+ );
+ const cancelAttemptedAt = Temporal.Instant.from(
+ "2026-08-30T12:00:00.123456Z"
+ );
+ const now = Temporal.Instant.from("2026-08-30T12:01:00.654321Z");
+
+ const cancelled = await driver.jobCancelWithOptions({
+ cancelAttemptedAt,
+ controlTopic: "river_control",
+ id: queued.job.id,
+ now,
+ });
+ const marked = await driver.jobCancelWithOptions({
+ cancelAttemptedAt,
+ controlTopic: "river_control",
+ id: running.job.id,
+ now,
+ });
+
+ expect(cancelled!.state).toBe("cancelled");
+ expect(cancelled!.finalizedAt!.toString()).toBe(
+ "2026-08-30T12:01:00.654321Z"
+ );
+ expect(cancelled!.metadata.cancel_attempted_at).toBe(
+ "2026-08-30T12:00:00.123456Z"
+ );
+ expect(marked!.state).toBe("running");
+ expect(marked!.finalizedAt).toBeNull();
+ expect(marked!.metadata.cancel_attempted_at).toBe(
+ "2026-08-30T12:00:00.123456Z"
+ );
+
+ // A client without notifications finds the running one by polling.
+ const unmarked = await driver.jobInsert(
+ insertParams(`${filePrefix}_cancel_unmarked`)
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'running' WHERE id = $1::bigint",
+ [unmarked.job.id.toString(10)]
+ );
+ await expect(
+ driver.jobGetCancelRequested([
+ queued.job.id,
+ running.job.id,
+ unmarked.job.id,
+ ])
+ ).resolves.toEqual([running.job.id]);
+ await expect(driver.jobGetCancelRequested([])).resolves.toEqual([]);
+ });
+
+ it("retries non-running jobs but leaves running jobs unchanged", async () => {
+ const scheduledAt = Temporal.Instant.from("2026-08-30T15:00:00Z");
+ const now = Temporal.Instant.from("2026-08-30T12:00:00.123456Z");
+ const retryable = await driver.jobInsert(
+ insertParams(`${filePrefix}_retry`, {
+ scheduledAt,
+ state: "retryable",
+ })
+ );
+ const running = await driver.jobInsert(
+ insertParams(`${filePrefix}_retry_running`)
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'running' WHERE id = $1::bigint",
+ [running.job.id.toString(10)]
+ );
+
+ const retried = await driver.jobRetryWithOptions({
+ id: retryable.job.id,
+ now,
+ });
+ const unchanged = await driver.jobRetryWithOptions({
+ id: running.job.id,
+ now,
+ });
+
+ expect(retried!.state).toBe("available");
+ expect(retried!.scheduledAt.toString()).toBe("2026-08-30T12:00:00.123456Z");
+ expect(unchanged!.state).toBe("running");
+ });
+
+ it("returns the committed row to the loser of a cancel or retry race", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_race`, {
+ scheduledAt: Temporal.Now.instant().add({ hours: 1 }),
+ state: "scheduled",
+ })
+ );
+ const id = inserted.job.id;
+ const observer = new pg.Client({ connectionString: TEST_DATABASE_URL });
+ await observer.connect();
+ try {
+ for (const [operation, lockedCte] of [
+ ["jobCancel", "locked_job"],
+ ["jobRetry", "job_to_update"],
+ ] as const) {
+ const winner = await pool.connect();
+ try {
+ await winner.query("BEGIN");
+ const won = await driver[operation](id, { tx: winner });
+ // The loser's statement starts while the winner holds the row lock.
+ const losing = driver[operation](id);
+ await waitFor(async () => {
+ const waiting = await observer.query(
+ `SELECT 1 FROM pg_stat_activity
+ WHERE wait_event_type = 'Lock' AND query LIKE $1`,
+ [`%${lockedCte}%`]
+ );
+ return (waiting.rowCount ?? 0) > 0;
+ });
+ await winner.query("COMMIT");
+
+ // Like River for Go, the loser returns the winner's committed row,
+ // not the row as its statement first saw it.
+ const lost = await losing;
+ expect(lost).toEqual(won);
+ expect(await driver.jobGet(id)).toEqual(won);
+ } finally {
+ await winner.query("ROLLBACK").catch(() => undefined);
+ winner.release();
+ }
+ }
+ } finally {
+ await observer.end();
+ }
+ });
+
+ it("protects running jobs from deletion", async () => {
+ const deletable = await driver.jobInsert(
+ insertParams(`${filePrefix}_delete`)
+ );
+ const running = await driver.jobInsert(
+ insertParams(`${filePrefix}_delete_running`)
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'running' WHERE id = $1::bigint",
+ [running.job.id.toString(10)]
+ );
+
+ await expect(driver.jobDelete(deletable.job.id)).resolves.toMatchObject({
+ status: "deleted",
+ });
+ await expect(driver.jobDelete(running.job.id)).resolves.toMatchObject({
+ status: "running",
+ });
+ await expect(driver.jobDelete(9_223_372_036_854_775_807n)).resolves.toEqual(
+ {
+ status: "not_found",
+ }
+ );
+ });
+
+ it("acts on rows River can't fully read by ID and lists them like Go", async () => {
+ const kind = `${filePrefix}_poison`;
+ const uniqueKey = new Uint8Array(32).fill(13);
+ const cancelled = await driver.jobInsert(insertParams(kind));
+ const retried = await driver.jobInsert(
+ insertParams(kind, {
+ scheduledAt: Temporal.Now.instant().add({ hours: 1 }),
+ state: "retryable",
+ })
+ );
+ const deleted = await driver.jobInsert(insertParams(kind));
+ const duplicate = await driver.jobInsert(
+ insertParams(kind, { uniqueKey, uniqueStates: ["available"] })
+ );
+ // Another engine stored array arguments, which Go keeps as raw bytes.
+ await pool.query(
+ "UPDATE river_job SET args = '[1, 2]'::jsonb WHERE kind = $1",
+ [kind]
+ );
+
+ await expect(driver.jobGet(cancelled.job.id)).rejects.toThrow("args");
+ const listed = await driver.jobList({
+ after: null,
+ ids: [cancelled.job.id, deleted.job.id],
+ kinds: [],
+ limit: 10,
+ metadata: null,
+ priorities: [],
+ queues: [],
+ sortDirection: "asc",
+ sortField: "id",
+ states: [],
+ tagsAll: [],
+ tagsAny: [],
+ });
+ expect(listed.map(({ args, id }) => [id, args])).toEqual([
+ [cancelled.job.id, {}],
+ [deleted.job.id, {}],
+ ]);
+ expect((await driver.jobCancel(cancelled.job.id))?.state).toBe("cancelled");
+ expect((await driver.jobRetry(retried.job.id))?.state).toBe("available");
+ await expect(driver.jobDelete(deleted.job.id)).resolves.toMatchObject({
+ job: { id: deleted.job.id },
+ status: "deleted",
+ });
+ const reinserted = await driver.jobInsertMany([
+ insertParams(kind, { uniqueKey, uniqueStates: ["available"] }),
+ ]);
+ expect(reinserted.map(({ job, status }) => [job.id, status])).toEqual([
+ [duplicate.job.id, "duplicate"],
+ ]);
+ });
+
+ it("bulk deletes matching non-running jobs and preserves running races", async () => {
+ const queue = `${filePrefix}_bulk_delete`;
+ const matching = await driver.jobInsert(
+ insertParams(`${filePrefix}_bulk_kind`, { priority: 1, queue })
+ );
+ const wrongPriority = await driver.jobInsert(
+ insertParams(`${filePrefix}_bulk_kind`, { priority: 2, queue })
+ );
+ const wrongKind = await driver.jobInsert(
+ insertParams(`${filePrefix}_bulk_other`, { priority: 1, queue })
+ );
+ const running = await driver.jobInsert(
+ insertParams(`${filePrefix}_bulk_kind`, { priority: 1, queue })
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'running', attempt = 1, attempted_at = now(), attempted_by = ARRAY[$2::text] WHERE id = $1::bigint",
+ [running.job.id.toString(10), `${filePrefix}_bulk_worker`]
+ );
+
+ const deleted = await driver.jobDeleteMany({
+ all: false,
+ ids: [],
+ kinds: [`${filePrefix}_bulk_kind`],
+ limit: 100,
+ priorities: [1],
+ queues: [queue],
+ states: ["available", "running"],
+ });
+
+ expect(deleted.map(({ id }) => id)).toEqual([matching.job.id]);
+ await expect(driver.jobGet(wrongPriority.job.id)).resolves.not.toBeNull();
+ await expect(driver.jobGet(wrongKind.job.id)).resolves.not.toBeNull();
+ await expect(driver.jobGet(running.job.id)).resolves.toMatchObject({
+ state: "running",
+ });
+ });
+
+ it("gets, lists, pauses, resumes, and updates queues", async () => {
+ const names = [`${filePrefix}_a`, `${filePrefix}_b`];
+ await pool.query(
+ `
+ INSERT INTO river_queue (name, metadata, updated_at)
+ VALUES
+ ($1::text, '{}'::jsonb, now()),
+ ($2::text, '{}'::jsonb, now())
+ `,
+ names
+ );
+ const pauseAt = Temporal.Instant.from("2026-08-30T12:00:00.123456Z");
+ const resumeAt = Temporal.Instant.from("2026-08-30T13:00:00.654321Z");
+
+ expect((await driver.queueGet(names[0]!))!.name).toBe(names[0]);
+ const listed = await driver.queueList({ limit: 10_000, nameAfter: null });
+ expect(
+ listed.filter(({ name }) => names.includes(name)).map(({ name }) => name)
+ ).toEqual(names);
+
+ await expect(
+ driver.queuePauseWithOptions({ name: names[0]!, now: pauseAt })
+ ).resolves.toBe(1);
+ // Like Go, pausing an already paused queue matches it but keeps its
+ // original pause and update times.
+ await expect(
+ driver.queuePauseWithOptions({ name: names[0]!, now: resumeAt })
+ ).resolves.toBe(1);
+ const paused = await driver.queueGet(names[0]!);
+ expect(paused!.pausedAt!.toString()).toBe("2026-08-30T12:00:00.123456Z");
+ expect(paused!.updatedAt.toString()).toBe("2026-08-30T12:00:00.123456Z");
+ await expect(driver.queuePause(names[0]!)).resolves.toMatchObject({
+ name: names[0],
+ pausedAt: pauseAt,
+ });
+
+ await expect(
+ driver.queueResumeWithOptions({ name: names[0]!, now: resumeAt })
+ ).resolves.toBe(1);
+ await expect(
+ driver.queueResumeWithOptions({ name: names[0]!, now: pauseAt })
+ ).resolves.toBe(1);
+ const resumed = await driver.queueGet(names[0]!);
+ expect(resumed!.pausedAt).toBeNull();
+ expect(resumed!.updatedAt.toString()).toBe("2026-08-30T13:00:00.654321Z");
+
+ const updated = await driver.queueUpdate(names[0]!, {
+ metadata: { owner: "javascript" },
+ });
+ expect(updated!.metadata).toEqual({ owner: "javascript" });
+
+ await expect(
+ driver.queuePauseWithOptions({ name: `${filePrefix}_missing` })
+ ).resolves.toBe(0);
+ await expect(
+ driver.queueResumeWithOptions({ name: `${filePrefix}_missing` })
+ ).resolves.toBe(0);
+ await expect(
+ driver.queueUpdate(`${filePrefix}_missing`, {})
+ ).resolves.toBeNull();
+ });
+
+ it("pauses every queue with one row each and one notification", async () => {
+ const names = [1, 2, 3].map((index) => `${filePrefix}_all_${index}`);
+ for (const name of names) {
+ await pool.query(
+ "INSERT INTO river_queue (name, metadata) VALUES ($1::text, '{}')",
+ [name]
+ );
+ }
+ const abort = new AbortController();
+ const notifications = driver.listen(["river_control"], abort.signal);
+ const first = notifications.next();
+ await waitForListenerPID(pool, "river_control");
+
+ const paused = await driver.queuePauseWithOptions({ name: "*" });
+ const all = await pool.query<{ count: number }>(
+ "SELECT count(*)::int AS count FROM river_queue"
+ );
+ // Previously every queue row was repeated once per notification.
+ expect(paused).toBe(all.rows[0]!.count);
+ expect(JSON.parse((await first).value!.payload)).toEqual({
+ action: "pause",
+ queue: "*",
+ });
+ // Resuming everything sends exactly one more notification.
+ const second = notifications.next();
+ await driver.queueResumeWithOptions({ name: "*" });
+ expect(JSON.parse((await second).value!.payload)).toEqual({
+ action: "resume",
+ queue: "*",
+ });
+ abort.abort();
+ await notifications.return(undefined);
+ });
+
+ it("notifies producers only for inserted available jobs", async () => {
+ const queue = `${filePrefix}_insert_notify`;
+ const job = defineJob({ kind: `${filePrefix}_insert_notify` });
+ const client = new Client(driver);
+ const abort = new AbortController();
+ const notifications = driver.listen(["river_insert"], abort.signal);
+ const next = notifications.next();
+ await waitForListenerPID(pool, "river_insert");
+
+ // Inserting through the driver alone notifies nobody.
+ await driver.jobInsert(
+ insertParams(`${filePrefix}_insert_notify`, {
+ queue: `${queue}_driver`,
+ })
+ );
+ const [scheduled] = await client.insertMany([
+ {
+ args: {},
+ job,
+ options: {
+ queue: `${queue}_scheduled`,
+ scheduledAt: Temporal.Now.instant().add({ hours: 1 }),
+ },
+ },
+ { args: {}, job, options: { pending: true, queue: `${queue}_pending` } },
+ ]);
+ await client.insert(job, {}, { queue });
+
+ // Notifications arrive in commit order, so the first one proves the
+ // earlier inserts sent none.
+ expect(JSON.parse((await next).value!.payload)).toEqual({ queue });
+
+ // Like River for Go, a retry that makes the scheduled job available
+ // notifies nothing, so the next notification is a later insert's.
+ const later = notifications.next();
+ await client.jobs.retry(scheduled.job.id);
+ await client.insert(job, {}, { queue: `${queue}_later` });
+ expect(JSON.parse((await later).value!.payload)).toEqual({
+ queue: `${queue}_later`,
+ });
+ abort.abort();
+ await notifications.return(undefined);
+ });
+
+ it("announces queue metadata changes", async () => {
+ const name = `${filePrefix}_metadata_notify`;
+ await pool.query(
+ "INSERT INTO river_queue (name, metadata) VALUES ($1::text, '{}')",
+ [name]
+ );
+ const abort = new AbortController();
+ const notifications = driver.listen(["river_control"], abort.signal);
+ const next = notifications.next();
+ await waitForListenerPID(pool, "river_control");
+
+ await driver.queueUpdate(name, {
+ metadata: { b: " & y", a: [1, 2.5] },
+ });
+
+ expect(JSON.parse((await next).value!.payload)).toEqual({
+ action: "metadata_changed",
+ metadata: { a: [1, 2.5], b: " & y" },
+ queue: name,
+ });
+
+ abort.abort();
+ await notifications.return(undefined);
+ });
+
+ it("claims by queue capacity and rejects stale attempt completions", async () => {
+ const first = await driver.jobInsert(
+ insertParams(`${filePrefix}_claim_first`)
+ );
+ const second = await driver.jobInsert(
+ insertParams(`${filePrefix}_claim_second`)
+ );
+
+ const claimed = (
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_worker`,
+ kinds: [`${filePrefix}_claim_first`, `${filePrefix}_claim_second`],
+ queues: [{ limit: 2, name: "default" }],
+ })
+ ).jobs;
+ const ours = claimed.filter(({ id }) =>
+ [first.job.id, second.job.id].includes(id)
+ );
+ expect(ours).toHaveLength(2);
+ expect(
+ ours.every(({ attempt, state }) => attempt === 1 && state === "running")
+ ).toBe(true);
+
+ const stale = await driver.jobCompleteMany([
+ {
+ attempt: 2,
+ attemptedBy: `${filePrefix}_worker`,
+ error: null,
+ id: first.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_other_worker`,
+ error: null,
+ id: second.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ]);
+ expect(stale.map(({ status }) => status)).toEqual(["stale", "stale"]);
+ expect((await driver.jobGet(first.job.id))!.state).toBe("running");
+ expect((await driver.jobGet(second.job.id))!.state).toBe("running");
+
+ const capturedFinalizedAt = Temporal.Instant.from(
+ "2026-08-30T18:30:01.123456789Z"
+ );
+ const applied = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_worker`,
+ error: null,
+ id: first.job.id,
+ kind: "complete",
+ finalizedAt: capturedFinalizedAt,
+ output: { engine: "javascript" },
+ outputSet: true,
+ scheduledAt: null,
+ },
+ ]);
+ expect(applied[0]).toMatchObject({ status: "applied" });
+ expect(applied[0]!.job!.state).toBe("completed");
+ // Truncated to microseconds like Go's pgx, not rounded.
+ expect(applied[0]!.job!.finalizedAt?.toString()).toBe(
+ "2026-08-30T18:30:01.123456Z"
+ );
+ expect(applied[0]!.job!.metadata.output).toEqual({
+ engine: "javascript",
+ });
+
+ await pool.query(
+ "UPDATE river_job SET state = 'discarded', finalized_at = now() WHERE id = $1::bigint",
+ [second.job.id.toString(10)]
+ );
+ const raced = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_worker`,
+ error: null,
+ id: second.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ]);
+ expect(raced[0]).toMatchObject({ status: "stale" });
+ expect(raced[0]!.job!.state).toBe("discarded");
+ });
+
+ it("claims by priority, then scheduled time, then ID, like Go", async () => {
+ const queue = `${filePrefix}_claim_order`;
+ const base = Temporal.Instant.from("2026-01-01T00:00:00Z");
+ const insert = async (priority: number, seconds: number) =>
+ (
+ await driver.jobInsert(
+ insertParams(`${filePrefix}_claim_order`, {
+ priority,
+ queue,
+ scheduledAt: base.add({ seconds }),
+ })
+ )
+ ).job.id;
+ // Inserted so that ID order disagrees with both other orders.
+ const late = await insert(1, 2);
+ const lowPriority = await insert(2, 0);
+ const early = await insert(1, 1);
+ const tiedFirst = await insert(1, 3);
+ const tiedSecond = await insert(1, 3);
+
+ const claimed: bigint[] = [];
+ for (let index = 0; index < 5; index++) {
+ const result = await driver.jobClaim({
+ attemptedBy: "claim-order",
+ kinds: [],
+ queues: [{ limit: 1, name: queue }],
+ });
+ claimed.push(...result.jobs.map(({ id }) => id));
+ }
+
+ expect(claimed).toEqual([early, late, tiedFirst, tiedSecond, lowPriority]);
+ });
+
+ it("filters claims by kind before the limit", async () => {
+ const queue = `${filePrefix}_claim_kinds`;
+ const other = await driver.jobInsert(
+ insertParams(`${filePrefix}_claim_other`, { queue })
+ );
+ const known = await driver.jobInsert(
+ insertParams(`${filePrefix}_claim_known`, { queue })
+ );
+
+ const claimed = await driver.jobClaim({
+ attemptedBy: "kind-filter",
+ kinds: [`${filePrefix}_claim_known`],
+ queues: [{ limit: 1, name: queue }],
+ });
+
+ expect(claimed.jobs.map(({ id }) => id)).toEqual([known.job.id]);
+ expect(await driver.jobGet(other.job.id)).toMatchObject({
+ attempt: 0,
+ state: "available",
+ });
+ });
+
+ it("keeps the last 100 attempted_by entries on claim like Go", async () => {
+ const queue = `${filePrefix}_history`;
+ const history = Array.from(
+ { length: 101 },
+ (_, index) => `worker-${index.toString().padStart(3, "0")}`
+ );
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_history`, { queue })
+ );
+ await pool.query(
+ "UPDATE river_job SET attempted_by = $2::text[] WHERE id = $1::bigint",
+ [inserted.job.id.toString(10), history]
+ );
+
+ const claimed = await driver.jobClaim({
+ attemptedBy: "worker-101",
+ kinds: [`${filePrefix}_history`],
+ queues: [{ limit: 1, name: queue }],
+ });
+
+ expect(claimed.jobs.map(({ id }) => id)).toEqual([inserted.job.id]);
+ expect(claimed.jobs[0]?.attemptedBy).toEqual([
+ ...history.slice(2),
+ "worker-101",
+ ]);
+ expect((await driver.jobGet(inserted.job.id))?.attemptedBy).toHaveLength(
+ 100
+ );
+ });
+
+ it("persists captured completion times for every outcome kind", async () => {
+ const finish = Temporal.Instant.from("2026-08-30T18:31:00.123456789Z");
+ const scheduledAt = Temporal.Instant.from("2026-08-30T19:00:00Z");
+ const kinds = [
+ "cancel",
+ "complete",
+ "discard",
+ "interrupt",
+ "retry",
+ "snooze",
+ ] as const;
+ const jobs = await Promise.all(
+ kinds.map(
+ async (kind) =>
+ (
+ await driver.jobInsert(
+ insertParams(`${filePrefix}_completion_${kind}`)
+ )
+ ).job
+ )
+ );
+ const claimed = (
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_completion_timing_worker`,
+ kinds: kinds.map((kind) => `${filePrefix}_completion_${kind}`),
+ queues: [{ limit: kinds.length, name: "default" }],
+ })
+ ).jobs;
+ expect(claimed).toHaveLength(kinds.length);
+
+ const results = await driver.jobCompleteMany(
+ jobs.map((job, index) => {
+ const kind = kinds[index]!;
+ const terminal =
+ kind === "cancel" || kind === "complete" || kind === "discard";
+ return {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_completion_timing_worker`,
+ error: null,
+ finalizedAt: terminal ? finish : null,
+ id: job.id,
+ kind,
+ output: null,
+ outputSet: false,
+ scheduledAt: terminal ? null : scheduledAt,
+ };
+ })
+ );
+
+ expect(results.map(({ job }) => job?.state)).toEqual([
+ "cancelled",
+ "completed",
+ "discarded",
+ "available",
+ "retryable",
+ "scheduled",
+ ]);
+ expect(
+ results.map(({ job }) => job?.finalizedAt?.toString() ?? null)
+ ).toEqual([
+ "2026-08-30T18:31:00.123456Z",
+ "2026-08-30T18:31:00.123456Z",
+ "2026-08-30T18:31:00.123456Z",
+ null,
+ null,
+ null,
+ ]);
+ });
+
+ it("merges completion metadata after exact-attempt external finalization", async () => {
+ const queue = `${filePrefix}_external_finalization`;
+ const attemptedBy = `${filePrefix}_external_worker`;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_external_finalization`, {
+ metadata: { existing: true },
+ queue,
+ })
+ );
+ const [claimed] = (
+ await driver.jobClaim({
+ attemptedBy,
+ kinds: [`${filePrefix}_external_finalization`],
+ queues: [{ limit: 1, name: queue }],
+ })
+ ).jobs;
+ const finalizedAt = Temporal.Instant.from("2026-08-30T14:15:16.123456Z");
+ await pool.query(
+ `
+ UPDATE river_job
+ SET finalized_at = $2::timestamptz, state = 'discarded'
+ WHERE id = $1::bigint
+ `,
+ [inserted.job.id.toString(10), finalizedAt.toString()]
+ );
+
+ const raced = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ metadata: { checkpoint: "finished" },
+ output: { engine: "javascript" },
+ outputSet: true,
+ scheduledAt: Temporal.Instant.from("2026-09-30T00:00:00Z"),
+ },
+ ]);
+
+ expect(raced[0]).toMatchObject({
+ job: {
+ attempt: 1,
+ errors: [],
+ metadata: {
+ checkpoint: "finished",
+ existing: true,
+ output: { engine: "javascript" },
+ },
+ state: "discarded",
+ },
+ status: "stale",
+ });
+ expect(raced[0]!.job!.finalizedAt!.toString()).toBe(finalizedAt.toString());
+ expect(raced[0]!.job!.scheduledAt.toString()).toBe(
+ claimed!.scheduledAt.toString()
+ );
+
+ const metadataOnly = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ metadata: { resumed: true },
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ]);
+ expect(metadataOnly[0]).toMatchObject({
+ job: {
+ metadata: {
+ checkpoint: "finished",
+ existing: true,
+ output: { engine: "javascript" },
+ resumed: true,
+ },
+ state: "discarded",
+ },
+ status: "stale",
+ });
+
+ const explicitNull = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: true,
+ scheduledAt: null,
+ },
+ ]);
+ expect(explicitNull[0]).toMatchObject({
+ job: {
+ metadata: {
+ checkpoint: "finished",
+ existing: true,
+ output: null,
+ resumed: true,
+ },
+ state: "discarded",
+ },
+ status: "stale",
+ });
+ });
+
+ it("never merges a stale completion into a newer attempt", async () => {
+ const queue = `${filePrefix}_newer_attempt`;
+ const oldWorker = `${filePrefix}_old_worker`;
+ const newWorker = `${filePrefix}_new_worker`;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_newer_attempt`, {
+ metadata: { generation: "new" },
+ queue,
+ })
+ );
+ await driver.jobClaim({
+ attemptedBy: oldWorker,
+ kinds: [`${filePrefix}_newer_attempt`],
+ queues: [{ limit: 1, name: queue }],
+ });
+ await pool.query(
+ `
+ UPDATE river_job
+ SET
+ attempt = 2,
+ attempted_by = array_append(attempted_by, $2::text),
+ state = 'running'
+ WHERE id = $1::bigint
+ `,
+ [inserted.job.id.toString(10), newWorker]
+ );
+
+ const raced = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: oldWorker,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ metadata: { stale_checkpoint: true },
+ output: { stale: true },
+ outputSet: true,
+ scheduledAt: null,
+ },
+ ]);
+
+ expect(raced[0]).toMatchObject({
+ job: {
+ attempt: 2,
+ attemptedBy: [oldWorker, newWorker],
+ metadata: { generation: "new" },
+ state: "running",
+ },
+ status: "stale",
+ });
+ expect(raced[0]!.job!.metadata).not.toHaveProperty("output");
+ expect(raced[0]!.job!.metadata).not.toHaveProperty("stale_checkpoint");
+ });
+
+ it("merges a rescued attempt's output without changing its state", async () => {
+ const queue = `${filePrefix}_rescued_output`;
+ const worker = `${filePrefix}_rescued_worker`;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_rescued_output`, { queue })
+ );
+ await driver.jobClaim({
+ attemptedBy: worker,
+ kinds: [`${filePrefix}_rescued_output`],
+ queues: [{ limit: 1, name: queue }],
+ });
+ // The rescuer retried the attempt before its completion arrived.
+ await pool.query(
+ "UPDATE river_job SET state = 'retryable' WHERE id = $1::bigint",
+ [inserted.job.id.toString(10)]
+ );
+
+ const [result] = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: worker,
+ error: null,
+ finalizedAt: Temporal.Now.instant(),
+ id: inserted.job.id,
+ kind: "complete",
+ metadata: { checkpoint: 3 },
+ output: { rows: 7 },
+ outputSet: true,
+ scheduledAt: null,
+ },
+ ]);
+
+ // Like River for Go, the output and metadata still merge.
+ expect(result).toMatchObject({
+ job: {
+ finalizedAt: null,
+ metadata: { checkpoint: 3, output: { rows: 7 } },
+ state: "retryable",
+ },
+ status: "stale",
+ });
+ });
+
+ it("snoozes by returning the claim attempt to the scheduler", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_snooze`, { queue: `${filePrefix}_snooze` })
+ );
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_snooze_worker`,
+ kinds: [`${filePrefix}_snooze`],
+ queues: [{ limit: 1, name: `${filePrefix}_snooze` }],
+ });
+ const scheduledAt = Temporal.Instant.from("2026-08-31T12:00:00.123456Z");
+
+ const snoozed = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_snooze_worker`,
+ error: null,
+ id: inserted.job.id,
+ kind: "snooze",
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt,
+ },
+ ]);
+
+ expect(snoozed[0]).toMatchObject({
+ job: { attempt: 0, state: "scheduled" },
+ status: "applied",
+ });
+ expect(snoozed[0]!.job!.scheduledAt.toString()).toBe(
+ "2026-08-31T12:00:00.123456Z"
+ );
+ });
+
+ it("persists near-future retries and snoozes as available", async () => {
+ const queue = `${filePrefix}_fast_path`;
+ const attemptedBy = `${filePrefix}_fast_path_worker`;
+ const snoozing = await driver.jobInsert(
+ insertParams(`${filePrefix}_fast_path`, { queue })
+ );
+ const failing = await driver.jobInsert(
+ insertParams(`${filePrefix}_fast_path`, { queue })
+ );
+ await driver.jobClaim({
+ attemptedBy,
+ kinds: [`${filePrefix}_fast_path`],
+ queues: [{ limit: 2, name: queue }],
+ });
+ const scheduledAt = Temporal.Now.instant()
+ .round({ roundingMode: "floor", smallestUnit: "microsecond" })
+ .add({ seconds: 1 });
+ const common = {
+ attempt: 1,
+ attemptedBy,
+ available: true,
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt,
+ };
+
+ const results = await driver.jobCompleteMany([
+ { ...common, error: null, id: snoozing.job.id, kind: "snooze" },
+ {
+ ...common,
+ error: { at: Temporal.Now.instant(), error: "boom", trace: "" },
+ id: failing.job.id,
+ kind: "retry",
+ },
+ ]);
+
+ // Like River's `JobSetStateSnoozedAvailable`, a snooze refunds the attempt.
+ expect(results[0]).toMatchObject({
+ job: { attempt: 0, errors: [], state: "available" },
+ status: "applied",
+ });
+ // Like `JobSetStateErrorAvailable`, an error keeps it.
+ expect(results[1]).toMatchObject({
+ job: { attempt: 1, errors: [{ error: "boom" }], state: "available" },
+ status: "applied",
+ });
+ expect(results[1]!.job!.scheduledAt.toString()).toBe(
+ scheduledAt.toString()
+ );
+ // The row is not claimable before its scheduled time.
+ await expect(
+ driver.jobClaim({
+ attemptedBy,
+ kinds: [`${filePrefix}_fast_path`],
+ queues: [{ limit: 2, name: queue }],
+ })
+ ).resolves.toEqual({ jobs: [] });
+ });
+
+ it("cancels an interrupted attempt whose cancellation never reached it", async () => {
+ const kind = `${filePrefix}_interrupt_cancel`;
+ const inserted = await driver.jobInsert(
+ insertParams(kind, { queue: kind })
+ );
+ await driver.jobClaim({
+ attemptedBy: `${kind}_worker`,
+ kinds: [kind],
+ queues: [{ limit: 1, name: kind }],
+ });
+ // A cancellation requested without its notification being delivered.
+ await pool.query(
+ `UPDATE river_job SET metadata = jsonb_set(metadata, '{cancel_attempted_at}', '"2026-01-02T03:04:05Z"') WHERE id = $1`,
+ [inserted.job.id.toString()]
+ );
+
+ const [result] = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${kind}_worker`,
+ error: null,
+ finalizedAt: null,
+ id: inserted.job.id,
+ kind: "interrupt",
+ output: null,
+ outputSet: false,
+ scheduledAt: Temporal.Now.instant(),
+ },
+ ]);
+
+ expect(result).toMatchObject({
+ job: { state: "cancelled" },
+ status: "applied",
+ });
+ expect(result!.job!.finalizedAt).not.toBeNull();
+ });
+
+ it("returns undecodable claimed rows with their errors and completes them", async () => {
+ const kind = `${filePrefix}_undecodable`;
+ const ordinary = await driver.jobInsert(
+ insertParams(kind, { queue: kind })
+ );
+ const corrupt = await driver.jobInsert(insertParams(kind, { queue: kind }));
+ const sparse = await driver.jobInsert(insertParams(kind, { queue: kind }));
+ // Array metadata is valid for Go but not a JSON object River can decode.
+ await pool.query(
+ `UPDATE river_job SET metadata = '[1]'::jsonb WHERE id = $1`,
+ [corrupt.job.id.toString()]
+ );
+ // Go's encoding/json tolerates missing and unknown attempt error fields.
+ await pool.query(
+ `UPDATE river_job SET errors = ARRAY['{"error": "sparse", "extra": true}'::jsonb] WHERE id = $1`,
+ [sparse.job.id.toString()]
+ );
+
+ const claimed = await driver.jobClaim({
+ attemptedBy: `${kind}_worker`,
+ kinds: [kind],
+ queues: [{ limit: 10, name: kind }],
+ });
+
+ // Every claimed row comes back, in claim order, with the decode error of
+ // the one that couldn't be decoded alongside.
+ expect(claimed.jobs.map((job) => job.id).sort()).toEqual(
+ [ordinary.job.id, corrupt.job.id, sparse.job.id].sort()
+ );
+ expect([...(claimed.decodeErrors?.keys() ?? [])]).toEqual([corrupt.job.id]);
+ expect(
+ claimed.jobs.find((job) => job.id === sparse.job.id)?.errors
+ ).toEqual([
+ {
+ at: Temporal.Instant.from("0001-01-01T00:00:00Z"),
+ attempt: 0,
+ error: "sparse",
+ trace: "",
+ },
+ ]);
+ expect(claimed.jobs.find((job) => job.id === corrupt.job.id)).toMatchObject(
+ {
+ attempt: 1,
+ metadata: {},
+ state: "running",
+ }
+ );
+ expect(claimed.decodeErrors?.get(corrupt.job.id)?.message).toContain(
+ "metadata"
+ );
+
+ // Failing the attempt appends the error without touching the bad value,
+ // and the completion still returns the row.
+ const [failed] = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${kind}_worker`,
+ error: {
+ at: Temporal.Now.instant(),
+ error: "job row couldn't be decoded",
+ trace: "",
+ },
+ finalizedAt: null,
+ id: corrupt.job.id,
+ kind: "retry",
+ output: null,
+ outputSet: false,
+ scheduledAt: Temporal.Now.instant().add({ hours: 1 }),
+ },
+ ]);
+ expect(failed).toMatchObject({
+ job: { metadata: {}, state: "retryable" },
+ status: "applied",
+ });
+ const row = await pool.query<{ error: string; metadata: unknown }>(
+ `SELECT metadata, errors[array_length(errors, 1)] ->> 'error' AS error
+ FROM river_job WHERE id = $1`,
+ [corrupt.job.id.toString()]
+ );
+ expect(row.rows[0]).toEqual({
+ error: "job row couldn't be decoded",
+ metadata: [1],
+ });
+ });
+
+ it("interrupts without consuming an attempt or recording an error", async () => {
+ const queue = `${filePrefix}_interrupt`;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_interrupt`, { queue })
+ );
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_interrupt_worker`,
+ kinds: [`${filePrefix}_interrupt`],
+ queues: [{ limit: 1, name: queue }],
+ });
+ const now = Temporal.Instant.from("2026-08-30T14:00:00.123456Z");
+
+ const interrupted = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_interrupt_worker`,
+ error: null,
+ id: inserted.job.id,
+ kind: "interrupt",
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt: now,
+ },
+ ]);
+
+ expect(interrupted[0]).toMatchObject({
+ job: { attempt: 0, errors: [], state: "available" },
+ status: "applied",
+ });
+ expect(interrupted[0]!.job!.scheduledAt.toString()).toBe(now.toString());
+ });
+
+ it("lets persisted cancellation win a concurrent snooze", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_cancel_snooze`, {
+ queue: `${filePrefix}_cancel_snooze`,
+ })
+ );
+ const [claimed] = (
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_cancel_snooze_worker`,
+ kinds: [`${filePrefix}_cancel_snooze`],
+ queues: [{ limit: 1, name: `${filePrefix}_cancel_snooze` }],
+ })
+ ).jobs;
+ await driver.jobCancel(inserted.job.id);
+
+ const result = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_cancel_snooze_worker`,
+ error: null,
+ id: inserted.job.id,
+ kind: "snooze",
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt: Temporal.Instant.from("2026-09-01T00:00:00Z"),
+ },
+ ]);
+
+ expect(result[0]).toMatchObject({
+ job: { attempt: 1, state: "cancelled" },
+ status: "applied",
+ });
+ expect(result[0]!.job!.scheduledAt.toString()).toBe(
+ claimed!.scheduledAt.toString()
+ );
+ });
+
+ it("does not claim work from a persisted paused queue", async () => {
+ const queue = `${filePrefix}_paused_claim`;
+ await driver.queueUpsert({ name: queue });
+ await driver.queuePause(queue);
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_paused_claim`, { queue })
+ );
+
+ await expect(
+ driver.jobClaim({
+ attemptedBy: `${filePrefix}_paused_worker`,
+ kinds: [`${filePrefix}_paused_claim`],
+ queues: [{ limit: 1, name: queue }],
+ })
+ ).resolves.toEqual({ jobs: [] });
+
+ await driver.queueResume(queue);
+ const claimed = (
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_paused_worker`,
+ kinds: [`${filePrefix}_paused_claim`],
+ queues: [{ limit: 1, name: queue }],
+ })
+ ).jobs;
+ expect(claimed.map(({ id }) => id)).toEqual([inserted.job.id]);
+ });
+
+ it("cancels a blocked completion server-side when cancellation fires", async () => {
+ const kind = `${filePrefix}_cancel_blocked_completion`;
+ const attemptedBy = `${filePrefix}_cancel_blocked_worker`;
+ const inserted = await driver.jobInsert(insertParams(kind));
+ const [claimed] = (
+ await driver.jobClaim({
+ attemptedBy,
+ kinds: [kind],
+ queues: [{ limit: 1, name: "default" }],
+ })
+ ).jobs;
+ expect(claimed?.id).toBe(inserted.job.id);
+
+ const locker = await pool.connect();
+ try {
+ await locker.query("BEGIN");
+ await locker.query("SELECT id FROM river_job WHERE id = $1 FOR UPDATE", [
+ inserted.job.id.toString(10),
+ ]);
+ const cancellation = new AbortController();
+ const reason = new Error("completion cancelled");
+ const completion = driver.jobCompleteMany(
+ [
+ {
+ attempt: 1,
+ attemptedBy,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ],
+ { signal: cancellation.signal }
+ );
+ await new Promise((resolve) => setTimeout(resolve, 50));
+ const abortedAt = Date.now();
+ cancellation.abort(reason);
+
+ await expect(completion).rejects.toBe(reason);
+ expect(Date.now() - abortedAt).toBeLessThan(1_000);
+ await expect(pool.query("SELECT 1 AS healthy")).resolves.toMatchObject({
+ rows: [{ healthy: 1 }],
+ });
+ // Destroying the socket alone would leave the UPDATE waiting on the
+ // row lock, and it would commit as soon as the lock is released.
+ await waitFor(async () => {
+ const active = await pool.query(
+ `
+ SELECT count(*)::int AS count
+ FROM pg_stat_activity
+ WHERE state = 'active'
+ AND query LIKE '/* river:jobCompleteMany */%'
+ `
+ );
+ return active.rows[0]?.count === 0;
+ });
+ } finally {
+ await locker.query("ROLLBACK");
+ locker.release();
+ }
+ expect((await driver.jobGet(inserted.job.id))?.state).toBe("running");
+ });
+
+ it("lists with stable cursors and applies semantic job patches", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_list`, {
+ metadata: { keep: true, output: { old: true } },
+ priority: 2,
+ queue: `${filePrefix}_queue`,
+ tags: ["beta", "shared"],
+ })
+ );
+ await driver.jobInsert(
+ insertParams(`${filePrefix}_list`, {
+ metadata: { keep: false },
+ priority: 2,
+ queue: `${filePrefix}_queue`,
+ tags: ["beta", "shared"],
+ })
+ );
+ const updated = await driver.jobUpdate(inserted.job.id, {
+ metadata: { added: true, output: { ignored: true } },
+ output: { new: true },
+ });
+ expect(updated).toMatchObject({
+ metadata: { added: true, keep: true, output: { new: true } },
+ });
+ await expect(
+ driver.jobUpdate(123_456_789_012n, { output: 1 })
+ ).resolves.toBeNull();
+
+ const listParams = {
+ after: null,
+ ids: [],
+ kinds: [`${filePrefix}_list`],
+ limit: 10,
+ metadata: { keep: true },
+ priorities: [2],
+ queues: [`${filePrefix}_queue`],
+ sortDirection: "asc",
+ sortField: "id",
+ states: ["available"],
+ tagsAll: ["shared"],
+ tagsAny: ["beta"],
+ } as const;
+ const rows = await driver.jobList(listParams);
+ expect(rows.map(({ id }) => id)).toEqual([inserted.job.id]);
+
+ const after = await driver.jobList({
+ ...listParams,
+ after: {
+ id: inserted.job.id,
+ kind: inserted.job.kind,
+ queue: updated!.queue,
+ sortField: "id",
+ time: null,
+ },
+ });
+ expect(after).toEqual([]);
+
+ const transaction = await pool.connect();
+ try {
+ await transaction.query("BEGIN");
+ await driver.jobUpdate(
+ inserted.job.id,
+ { metadata: { keep: false } },
+ { tx: transaction }
+ );
+ await expect(
+ driver.jobList(listParams, { tx: transaction })
+ ).resolves.toEqual([]);
+ expect((await driver.jobList(listParams)).map(({ id }) => id)).toEqual([
+ inserted.job.id,
+ ]);
+ } finally {
+ await transaction.query("ROLLBACK");
+ transaction.release();
+ }
+ });
+
+ it("paginates one finalized state across tied timestamps", async () => {
+ const kind = `${filePrefix}_finalized_pages`;
+ const base = Temporal.Instant.from("2026-08-30T12:00:00.000001Z");
+ const offsets = [0, 1, 1, 1, 2];
+ const ids: bigint[] = [];
+ for (const offset of offsets) {
+ const result = await pool.query<{ id: string }>(
+ `
+ INSERT INTO river_job (args, finalized_at, kind, max_attempts, state)
+ VALUES ('{}', $1::timestamptz, $2::text, 25, 'completed')
+ RETURNING id::text
+ `,
+ [base.add({ seconds: offset }).toString(), kind]
+ );
+ ids.push(BigInt(result.rows[0]!.id));
+ }
+ const expectedAsc = ids
+ .map((id, index) => ({ id, offset: offsets[index] ?? 0 }))
+ .sort(
+ (left, right) =>
+ left.offset - right.offset || (left.id < right.id ? -1 : 1)
+ )
+ .map(({ id }) => id);
+
+ for (const direction of ["asc", "desc"] as const) {
+ const seen: bigint[] = [];
+ let after: JobRow | undefined;
+ for (;;) {
+ const page = await driver.jobList({
+ after:
+ after === undefined
+ ? null
+ : {
+ id: after.id,
+ kind: after.kind,
+ queue: after.queue,
+ sortField: "time",
+ time: after.finalizedAt,
+ },
+ ids: [],
+ kinds: [kind],
+ limit: 2,
+ metadata: null,
+ priorities: [],
+ queues: [],
+ sortDirection: direction,
+ sortField: "time",
+ states: ["completed"],
+ tagsAll: [],
+ tagsAny: [],
+ });
+ seen.push(...page.map(({ id }) => id));
+ after = page.at(-1);
+ if (page.length < 2) break;
+ }
+ expect(seen).toEqual(
+ direction === "asc" ? expectedAsc : [...expectedAsc].reverse()
+ );
+ }
+ });
+
+ it("pages mixed states by the first state's time field like Go", async () => {
+ const kind = `${filePrefix}_mixed_time_list`;
+ const at = (minutes: number) =>
+ Temporal.Instant.from("2026-08-30T10:00:00.123456Z").add({ minutes });
+ // Inserted so that ID order differs from each time order.
+ const firstAvailable = await driver.jobInsert(
+ insertParams(kind, { scheduledAt: at(30) })
+ );
+ const laterCompleted = await driver.jobInsert(
+ insertParams(kind, { scheduledAt: at(10) })
+ );
+ const secondAvailable = await driver.jobInsert(
+ insertParams(kind, { scheduledAt: at(30) })
+ );
+ const earlierCompleted = await driver.jobInsert(
+ insertParams(kind, { scheduledAt: at(20) })
+ );
+ for (const [job, finalizedAt] of [
+ [laterCompleted, at(50)],
+ [earlierCompleted, at(40)],
+ ] as const) {
+ await pool.query(
+ `UPDATE river_job
+ SET state = 'completed', finalized_at = $2::timestamptz
+ WHERE id = $1::bigint`,
+ [job.job.id.toString(10), finalizedAt.toString()]
+ );
+ }
+ const base = {
+ ids: [],
+ kinds: [kind],
+ limit: 1,
+ metadata: null,
+ priorities: [],
+ queues: [],
+ sortField: "time",
+ tagsAll: [],
+ tagsAny: [],
+ } as const;
+
+ /** Page one job at a time, alternating encoded and row cursors. */
+ const pageIds = async (
+ sortDirection: "asc" | "desc",
+ states: readonly JobState[]
+ ): Promise => {
+ const params = { ...base, sortDirection, states };
+ const ids: bigint[] = [];
+ let after: JobListCursorValue | null = null;
+ for (let page = 0; page < 10; page++) {
+ const [job] = await driver.jobList({ ...params, after });
+ if (job === undefined) break;
+ ids.push(job.id);
+ after =
+ page % 2 === 0
+ ? decodeJobListCursor(encodeJobListCursor(job, params))
+ : jobListCursorValue(job, params);
+ }
+ return ids;
+ };
+
+ const available = [firstAvailable.job.id, secondAvailable.job.id];
+ const completed = [earlierCompleted.job.id, laterCompleted.job.id];
+ // Finalized time, which the available jobs lack: nulls sort last
+ // ascending and first descending, by ID.
+ expect(await pageIds("asc", ["completed", "available"])).toEqual([
+ ...completed,
+ ...available,
+ ]);
+ expect(await pageIds("desc", ["completed", "available"])).toEqual(
+ [...completed, ...available].reverse()
+ );
+ // Scheduled time for every job, including the completed ones.
+ const byScheduledAt = [
+ laterCompleted.job.id,
+ earlierCompleted.job.id,
+ ...available,
+ ];
+ expect(await pageIds("asc", ["available", "completed"])).toEqual(
+ byScheduledAt
+ );
+ expect(await pageIds("desc", ["available", "completed"])).toEqual(
+ byScheduledAt.toReversed()
+ );
+ // No state filter orders by scheduled time too.
+ expect(await pageIds("asc", [])).toEqual(byScheduledAt);
+ // Like Go, a cursor without a time for a field that can't be null
+ // resumes after its ID alone.
+ expect(
+ (
+ await driver.jobList({
+ ...base,
+ after: {
+ id: laterCompleted.job.id,
+ kind,
+ queue: "default",
+ sortField: "time",
+ time: null,
+ },
+ limit: 10,
+ sortDirection: "asc",
+ states: ["available", "completed"],
+ })
+ ).map(({ id }) => id)
+ ).toEqual([earlierCompleted.job.id, secondAvailable.job.id]);
+ });
+
+ it("elects, renews, and resigns exact leadership terms", async () => {
+ const now = Temporal.Instant.from("2026-08-30T12:00:00.123456Z");
+ await driver.leaderDeleteExpired(
+ Temporal.Instant.from("9999-12-31T23:59:59Z")
+ );
+
+ const elected = await driver.leaderElect({
+ leaderId: `${filePrefix}_leader`,
+ now,
+ ttlSeconds: 30,
+ });
+ expect(elected!.electedAt.toString()).toBe(now.toString());
+ await expect(
+ driver.leaderElect({ leaderId: "competitor", now, ttlSeconds: 30 })
+ ).resolves.toBeNull();
+
+ const renewed = await driver.leaderReelect({
+ electedAt: elected!.electedAt,
+ leaderId: `${filePrefix}_leader`,
+ now,
+ ttlSeconds: 60,
+ });
+ expect(renewed!.expiresAt.epochNanoseconds).toBeGreaterThan(
+ elected!.expiresAt.epochNanoseconds
+ );
+ await expect(
+ driver.leaderResign({
+ electedAt: elected!.electedAt,
+ leaderId: "competitor",
+ leadershipTopic: "river_leadership",
+ ttlSeconds: 30,
+ })
+ ).resolves.toBe(false);
+ await expect(
+ driver.leaderResign({
+ electedAt: elected!.electedAt,
+ leaderId: `${filePrefix}_leader`,
+ leadershipTopic: "river_leadership",
+ ttlSeconds: 30,
+ })
+ ).resolves.toBe(true);
+ await expect(driver.leaderGet()).resolves.toBeNull();
+ });
+
+ it("renews only the held term and never adopts a same-ID term", async () => {
+ const leaderId = `${filePrefix}_same_identity`;
+ const now = Temporal.Now.instant();
+ const first = (await driver.maintenanceLeaderAcquire(
+ leaderId,
+ now,
+ 60_000,
+ null
+ ))!;
+ try {
+ // A live term is renewed only by its holder, like Go's elector.
+ await expect(
+ driver.maintenanceLeaderAcquire(leaderId, now, 60_000, null)
+ ).resolves.toBeNull();
+ await expect(
+ driver.maintenanceLeaderAcquire(leaderId, now, 60_000, first)
+ ).resolves.toMatchObject({ electedAt: first.electedAt, leaderId });
+
+ // Another process with the same client ID takes over with a newer term.
+ await pool.query(
+ "UPDATE river_leader SET elected_at = elected_at + interval '1 second'"
+ );
+ await expect(
+ driver.maintenanceLeaderAcquire(leaderId, now, 60_000, first)
+ ).resolves.toBeNull();
+ await expect(driver.leaderGet()).resolves.toMatchObject({
+ electedAt: first.electedAt.add({ seconds: 1 }),
+ leaderId,
+ });
+ } finally {
+ await pool.query("DELETE FROM river_leader WHERE leader_id = $1", [
+ leaderId,
+ ]);
+ }
+ });
+
+ it("fences maintenance mutations to the exact current term", async () => {
+ const now = Temporal.Now.instant();
+ const first = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_maintenance_one`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_fenced_scheduler`, {
+ scheduledAt: now,
+ state: "scheduled",
+ })
+ );
+ await expect(driver.maintenanceLeaderResign(first)).resolves.toBe(true);
+ const second = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_maintenance_two`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const params = {
+ allowInsertNotifications: allowEveryQueue,
+ limit: 10,
+ notificationHorizon: now,
+ now,
+ scheduledAtHorizon: now,
+ };
+
+ await expect(driver.maintenanceSchedule(first, params)).resolves.toBe(0);
+ await expect(driver.jobGet(inserted.job.id)).resolves.toMatchObject({
+ state: "scheduled",
+ });
+ await expect(driver.maintenanceSchedule(second, params)).resolves.toBe(1);
+ await expect(driver.jobGet(inserted.job.id)).resolves.toMatchObject({
+ state: "available",
+ });
+ await expect(driver.maintenanceLeaderResign(second)).resolves.toBe(true);
+ });
+
+ it("cleans finalized jobs except in excluded queues", async () => {
+ const now = Temporal.Now.instant();
+ const leader = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_cleaner_excluded`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const ids: Record = {};
+ try {
+ for (const queue of [`${filePrefix}_kept`, `${filePrefix}_cleaned`]) {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_cleaner_excluded_job`, { queue })
+ );
+ ids[queue] = inserted.job.id;
+ }
+ await pool.query(
+ `UPDATE river_job
+ SET finalized_at = $2::timestamptz, state = 'completed'
+ WHERE id = ANY($1::bigint[])`,
+ [
+ Object.values(ids).map((id) => id.toString(10)),
+ now.subtract({ hours: 2 }).toString(),
+ ]
+ );
+
+ await driver.maintenanceCleanJobs(
+ leader,
+ {
+ cancelledBefore: now,
+ completedBefore: now,
+ discardedBefore: now,
+ limit: 1_000,
+ queuesExcluded: [`${filePrefix}_kept`],
+ },
+ null,
+ new AbortController().signal
+ );
+ expect(
+ await driver.jobGet(ids[`${filePrefix}_kept`] as bigint)
+ ).not.toBeNull();
+ expect(
+ await driver.jobGet(ids[`${filePrefix}_cleaned`] as bigint)
+ ).toBeNull();
+ } finally {
+ await driver.maintenanceLeaderResign(leader);
+ }
+ });
+
+ it("bounds each job-cleaner query with a database timeout", async () => {
+ const now = Temporal.Now.instant();
+ const leader = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_cleaner_timeout`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_cleaner_timeout_job`)
+ );
+ await pool.query(
+ `UPDATE river_job
+ SET finalized_at = $2::timestamptz, state = 'completed'
+ WHERE id = $1::bigint`,
+ [inserted.job.id.toString(10), now.subtract({ hours: 2 }).toString()]
+ );
+ const blocker = await pool.connect();
+ try {
+ await blocker.query("BEGIN");
+ await blocker.query("LOCK TABLE river_job IN ACCESS EXCLUSIVE MODE");
+ const startedAt = performance.now();
+
+ await expect(
+ driver.maintenanceCleanJobs(
+ leader,
+ {
+ cancelledBefore: now,
+ completedBefore: now,
+ discardedBefore: now,
+ limit: 10,
+ },
+ 20,
+ new AbortController().signal
+ )
+ ).rejects.toMatchObject({
+ cause: expect.objectContaining({ code: "57014" }),
+ });
+ expect(performance.now() - startedAt).toBeLessThan(1_000);
+ } finally {
+ await blocker.query("ROLLBACK");
+ blocker.release();
+ await driver.maintenanceLeaderResign(leader);
+ }
+ });
+
+ it("bounds scheduler, rescuer, and queue cleaner batches with a database timeout", async () => {
+ const now = Temporal.Now.instant();
+ const leader = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_batch_timeout`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const batch = { signal: new AbortController().signal, timeoutMs: 20 };
+ const blocker = await pool.connect();
+ try {
+ await blocker.query("BEGIN");
+ await blocker.query("LOCK TABLE river_job IN ACCESS EXCLUSIVE MODE");
+ await blocker.query("LOCK TABLE river_queue IN ACCESS EXCLUSIVE MODE");
+ const timedOut = { cause: expect.objectContaining({ code: "57014" }) };
+ const startedAt = performance.now();
+
+ await expect(
+ driver.maintenanceSchedule(
+ leader,
+ {
+ allowInsertNotifications: allowEveryQueue,
+ limit: 10,
+ notificationHorizon: now,
+ now,
+ scheduledAtHorizon: now,
+ },
+ batch
+ )
+ ).rejects.toMatchObject(timedOut);
+ await expect(
+ driver.maintenanceGetStuck(leader, now, 0n, 10, batch)
+ ).rejects.toMatchObject(timedOut);
+ await expect(
+ driver.maintenanceCleanQueues(leader, now, 10, batch)
+ ).rejects.toMatchObject(timedOut);
+ expect(performance.now() - startedAt).toBeLessThan(3_000);
+ } finally {
+ await blocker.query("ROLLBACK");
+ blocker.release();
+ await driver.maintenanceLeaderResign(leader);
+ }
+ });
+
+ it("renews the held term while leader maintenance is blocked", async () => {
+ const leaderId = `${filePrefix}_blocked_renewal`;
+ const now = Temporal.Now.instant();
+ const leader = (await driver.maintenanceLeaderAcquire(
+ leaderId,
+ now,
+ 60_000,
+ null
+ ))!;
+ const blocker = await pool.connect();
+ let cleaning: Promise | undefined;
+ try {
+ await blocker.query("BEGIN");
+ await blocker.query("LOCK TABLE river_job IN ACCESS EXCLUSIVE MODE");
+ cleaning = driver.maintenanceCleanJobs(
+ leader,
+ {
+ cancelledBefore: now,
+ completedBefore: now,
+ discardedBefore: now,
+ limit: 10,
+ },
+ null,
+ new AbortController().signal
+ );
+ // Wait until the cleaner's transaction is blocked on the job table.
+ for (;;) {
+ const waiting = await pool.query(
+ `SELECT 1 FROM pg_locks l JOIN pg_class c ON c.oid = l.relation
+ WHERE NOT l.granted AND c.relname = 'river_job'`
+ );
+ if ((waiting.rowCount ?? 0) > 0) break;
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+
+ // Like Go River, renewing the lease never waits for maintenance.
+ const renewed = await Promise.race([
+ driver.maintenanceLeaderAcquire(
+ leaderId,
+ Temporal.Now.instant(),
+ 60_000,
+ leader
+ ),
+ new Promise<"blocked">((resolve) =>
+ setTimeout(resolve, 2_000, "blocked")
+ ),
+ ]);
+ expect(renewed).toMatchObject({ electedAt: leader.electedAt, leaderId });
+ } finally {
+ await blocker.query("ROLLBACK");
+ blocker.release();
+ await cleaning;
+ await driver.maintenanceLeaderResign(leader);
+ }
+ });
+
+ it("promotes ahead without prematurely notifying workers", async () => {
+ const now = Temporal.Now.instant();
+ const leader = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_lookahead`,
+ now,
+ 60_000,
+ null
+ ))!;
+ const scheduledAt = now.add({ milliseconds: 100 });
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_scheduler_lookahead`, {
+ scheduledAt,
+ state: "scheduled",
+ })
+ );
+ const abort = new AbortController();
+ let listening!: () => void;
+ const ready = new Promise((resolve) => {
+ listening = resolve;
+ });
+ const iterator = driver.listen(["river_insert"], abort.signal, listening);
+ const next = iterator.next();
+ await ready;
+
+ await expect(
+ driver.maintenanceSchedule(leader, {
+ allowInsertNotifications: allowEveryQueue,
+ limit: 10,
+ notificationHorizon: now.add({ milliseconds: 5 }),
+ now,
+ scheduledAtHorizon: now.add({ seconds: 5 }),
+ })
+ ).resolves.toBe(1);
+ await expect(driver.jobGet(inserted.job.id)).resolves.toMatchObject({
+ state: "available",
+ });
+ await expect(
+ Promise.race([
+ next.then(() => "notification"),
+ new Promise((resolve) =>
+ setTimeout(() => resolve("quiet"), 50)
+ ),
+ ])
+ ).resolves.toBe("quiet");
+
+ abort.abort();
+ await iterator.return(undefined);
+ await expect(driver.maintenanceLeaderResign(leader)).resolves.toBe(true);
+ });
+
+ it("delivers LISTEN notifications as polling hints", async () => {
+ const abort = new AbortController();
+ let listening!: () => void;
+ const ready = new Promise((resolve) => {
+ listening = resolve;
+ });
+ const iterator = driver.listen(["river_insert"], abort.signal, listening);
+ const next = iterator.next();
+ await ready;
+
+ await driver.notifyMany("river_insert", ["payload"]);
+ await expect(next).resolves.toEqual({
+ done: false,
+ value: { payload: "payload", topic: "river_insert" },
+ });
+ abort.abort();
+ await iterator.return(undefined);
+ });
+
+ it("decodes exact bigint IDs from cancellation notifications", async () => {
+ const exactID = 9_007_199_254_741_001n;
+ await pool.query(
+ `INSERT INTO river_job (id, args, kind, max_attempts)
+ VALUES ($1::bigint, '{}'::jsonb, $2::text, 25)`,
+ [exactID.toString(10), `${filePrefix}_large_cancel_id`]
+ );
+ const abort = new AbortController();
+ const iterator = driver.jobCancellationSubscribe(
+ `${filePrefix}_cancel_owner`,
+ abort.signal
+ );
+ const next = iterator.next();
+ await waitForListenerPID(pool, "river_control");
+
+ await driver.jobCancel(exactID);
+ await expect(next).resolves.toEqual({
+ done: false,
+ value: {
+ attemptedBy: `${filePrefix}_cancel_owner`,
+ id: exactID,
+ },
+ });
+ abort.abort();
+ await iterator.return(undefined);
+ });
+
+ it("notifies listeners of a committed insert notification", async () => {
+ const abort = new AbortController();
+ let listening!: () => void;
+ const ready = new Promise((resolve) => {
+ listening = resolve;
+ });
+ const iterator = driver.listen(["river_insert"], abort.signal, listening);
+ const next = iterator.next();
+ await ready;
+
+ await driver.notifyInsert([`${filePrefix}_notification_queue`]);
+ const notification = await next;
+ expect(notification.done).toBe(false);
+ expect(notification.value!.topic).toBe("river_insert");
+ expect(JSON.parse(notification.value!.payload)).toEqual({
+ queue: `${filePrefix}_notification_queue`,
+ });
+ abort.abort();
+ await iterator.return(undefined);
+ });
+
+ it("fails a forcibly terminated listener without leaking leases", async () => {
+ const topic = `${filePrefix}_fault_listener`;
+ const baselineConnections = pool.totalCount;
+ const abort = new AbortController();
+ const iterator = driver.listen([topic], abort.signal);
+ const failed = iterator.next().catch((error: unknown) => error);
+ const pid = await waitForListenerPID(pool, topic);
+
+ await pool.query("SELECT pg_terminate_backend($1::int)", [pid]);
+ // The runtime's notification pump logs the failure and subscribes again.
+ expect(await failed).toBeInstanceOf(Error);
+ await waitFor(() => pool.waitingCount === 0);
+ expect(pool.totalCount).toBeLessThanOrEqual(baselineConnections);
+ });
+
+ describe("stale rescue snapshots", () => {
+ // Mirrors River's riverdrivertest `JobRescueMany_*` coverage for upstream
+ // "guard job rescue against stale snapshots": a rescue computed from a
+ // fetched snapshot must not touch a job completed, released, or claimed
+ // again before the rescue write.
+ const now = Temporal.Instant.from("2025-04-30T13:26:39.123400Z");
+ const horizon = now.subtract({ hours: 1 });
+
+ async function insertRunning(
+ label: string,
+ attemptedAt: Temporal.Instant,
+ metadata: JsonObject = { "river:rescue_count": 5, something: "else" }
+ ): Promise {
+ const result = await pool.query<{ id: string }>(
+ `
+ INSERT INTO river_job (
+ args, attempt, attempted_at, attempted_by, kind, max_attempts,
+ metadata, queue, scheduled_at, state
+ ) VALUES (
+ '{}', 1, $1::timestamptz, ARRAY['old-worker'], $2::text, 25,
+ $3::jsonb, $2::text, $1::timestamptz, 'running'
+ )
+ RETURNING id::text
+ `,
+ [attemptedAt.toString(), `${filePrefix}_${label}`, metadata]
+ );
+ return BigInt(result.rows[0]!.id);
+ }
+
+ function snapshot(job: JobRow | null): string {
+ return JSON.stringify(job, (_key, value: unknown) =>
+ typeof value === "bigint" ? value.toString() : value
+ );
+ }
+
+ function rescue(
+ id: bigint,
+ state: "cancelled" | "discarded" | "retryable",
+ error = "stuck job rescued"
+ ): PgJobRescue {
+ const rescueAt = now.add({ minutes: 1 });
+ return {
+ error: { at: now, attempt: 1, error, trace: "" },
+ ...(state === "retryable" ? {} : { finalizedAt: rescueAt }),
+ id,
+ scheduledAt: rescueAt,
+ state,
+ };
+ }
+
+ for (const state of ["cancelled", "discarded", "retryable"] as const) {
+ it(`leaves a job completed after fetch untouched (${state})`, async () => {
+ const completed = await insertRunning(
+ `rescue_done_${state}`,
+ now.subtract({ hours: 2 })
+ );
+ const stillRunning = await insertRunning(
+ `rescue_done_${state}`,
+ now.subtract({ hours: 2 })
+ );
+ const stuck = await driver.jobGetStuck({
+ afterId: 0n,
+ max: 10_000,
+ stuckHorizon: horizon,
+ });
+ expect(stuck.map(({ id }) => id)).toEqual(
+ expect.arrayContaining([completed, stillRunning])
+ );
+
+ // The worker completes after the rescuer fetched the job, but before
+ // the rescue write.
+ const [done] = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: "old-worker",
+ error: null,
+ finalizedAt: now,
+ id: completed,
+ kind: "complete",
+ metadata: { worker: "finished" },
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ]);
+ expect(done!.job!.state).toBe("completed");
+ const before = snapshot(await driver.jobGet(completed));
+
+ await expect(
+ driver.jobRescueMany({
+ items: [
+ rescue(completed, state, "stale rescue"),
+ rescue(stillRunning, state),
+ ],
+ stuckHorizon: horizon,
+ })
+ ).resolves.toBe(1);
+
+ expect(snapshot(await driver.jobGet(completed))).toBe(before);
+ const rescued = await driver.jobGet(stillRunning);
+ expect(rescued).toMatchObject({
+ errors: [{ error: "stuck job rescued" }],
+ metadata: { "river:rescue_count": 6, something: "else" },
+ state,
+ });
+ expect(rescued!.scheduledAt.toString()).toBe(
+ now.add({ minutes: 1 }).toString()
+ );
+ expect(rescued!.finalizedAt?.toString()).toBe(
+ state === "retryable" ? undefined : now.add({ minutes: 1 }).toString()
+ );
+ });
+ }
+
+ for (const release of ["failed", "interrupted"] as const) {
+ it(`leaves a job claimed again after fetch untouched (${release})`, async () => {
+ const queue = `${filePrefix}_rescue_reclaim_${release}`;
+ const id = await insertRunning(
+ `rescue_reclaim_${release}`,
+ now.subtract({ hours: 2 }),
+ { "river:rescue_count": 5 }
+ );
+ const stuck = await driver.jobGetStuck({
+ afterId: id - 1n,
+ max: 1,
+ stuckHorizon: horizon,
+ });
+ expect(stuck.map((job) => job.id)).toEqual([id]);
+
+ // The old worker releases the job and a new worker claims it before
+ // the stale rescue arrives; its state alone still matches.
+ const [released] = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: "old-worker",
+ ...(release === "failed" ? { available: true } : {}),
+ error:
+ release === "failed"
+ ? { at: now, error: "worker failed", trace: "" }
+ : null,
+ finalizedAt: null,
+ id,
+ kind: release === "failed" ? "retry" : "interrupt",
+ output: null,
+ outputSet: false,
+ scheduledAt: now,
+ },
+ ]);
+ expect(released!.job!.state).toBe("available");
+ const [claimed] = (
+ await driver.jobClaim({
+ attemptedBy: "new-worker",
+ kinds: [],
+ queues: [{ limit: 1, name: queue }],
+ })
+ ).jobs;
+ expect(claimed?.id).toBe(id);
+ const before = snapshot(await driver.jobGet(id));
+
+ await expect(
+ driver.jobRescueMany({
+ items: [rescue(id, "retryable", "stale rescue")],
+ stuckHorizon: horizon,
+ })
+ ).resolves.toBe(0);
+
+ expect(snapshot(await driver.jobGet(id))).toBe(before);
+ });
+ }
+
+ it("rescues only attempts strictly before the horizon", async () => {
+ const ids = await Promise.all(
+ [-1, 0, 1].map((offset) =>
+ insertRunning(
+ `rescue_horizon_${offset + 1}`,
+ horizon.add({ microseconds: offset })
+ )
+ )
+ );
+ const before = await Promise.all(ids.map((id) => driver.jobGet(id)));
+
+ await expect(
+ driver.jobRescueMany({
+ items: ids.map((id) => rescue(id, "retryable")),
+ stuckHorizon: horizon,
+ })
+ ).resolves.toBe(1);
+
+ const after = await Promise.all(ids.map((id) => driver.jobGet(id)));
+ expect(after[0]).toMatchObject({ errors: [{}], state: "retryable" });
+ // As in `jobGetStuck`, attempts at or after the horizon are ineligible.
+ expect(snapshot(after[1]!)).toBe(snapshot(before[1]!));
+ expect(snapshot(after[2]!)).toBe(snapshot(before[2]!));
+ });
+ });
+
+ it("schedules, rescues, cleans, and introspects maintenance artifacts", async () => {
+ const now = Temporal.Instant.from("2026-08-30T12:00:00Z");
+ const uniqueKey = Uint8Array.from([9, 8, 7, 6, 5, 4]);
+ await driver.jobInsert(
+ insertParams(`${filePrefix}_schedule_active`, {
+ uniqueKey,
+ uniqueStates: ["available"],
+ })
+ );
+ const conflicting = await driver.jobInsert(
+ insertParams(`${filePrefix}_schedule_conflict`, {
+ scheduledAt: now,
+ state: "scheduled",
+ uniqueKey,
+ uniqueStates: ["available"],
+ })
+ );
+ const due = await driver.jobInsert(
+ insertParams(`${filePrefix}_schedule_due`, {
+ scheduledAt: now,
+ state: "scheduled",
+ })
+ );
+
+ const scheduled = await driver.jobSchedule({ max: 10, now });
+ const scheduledByID = new Map(
+ scheduled.map((result) => [result.job.id, result])
+ );
+ expect(scheduledByID.get(conflicting.job.id)).toMatchObject({
+ conflictDiscarded: true,
+ job: { state: "discarded" },
+ });
+ expect(scheduledByID.get(due.job.id)).toMatchObject({
+ conflictDiscarded: false,
+ job: { state: "available" },
+ });
+
+ const rescueCandidate = await driver.jobInsert(
+ insertParams(`${filePrefix}_rescue`, { queue: `${filePrefix}_rescue` })
+ );
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_rescuer_worker`,
+ kinds: [`${filePrefix}_rescue`],
+ queues: [{ limit: 1, name: `${filePrefix}_rescue` }],
+ });
+ const stuckHorizon = Temporal.Instant.from("9999-12-31T23:59:59Z");
+ const stuck = await driver.jobGetStuck({
+ max: 10,
+ stuckHorizon,
+ });
+ expect(stuck.map(({ id }) => id)).toContain(rescueCandidate.job.id);
+ await expect(
+ driver.jobRescueMany({
+ items: [
+ {
+ error: {
+ at: now,
+ attempt: 1,
+ error: "stuck",
+ trace: "trace",
+ },
+ id: rescueCandidate.job.id,
+ scheduledAt: now,
+ state: "retryable",
+ },
+ ],
+ stuckHorizon,
+ })
+ ).resolves.toBe(1);
+ expect((await driver.jobGet(rescueCandidate.job.id))!.state).toBe(
+ "retryable"
+ );
+
+ const cleanQueue = `${filePrefix}_clean`;
+ const clean = await driver.jobInsert(
+ insertParams(`${filePrefix}_clean`, { queue: cleanQueue })
+ );
+ await pool.query(
+ "UPDATE river_job SET state = 'completed', finalized_at = $2::timestamptz WHERE id = $1::bigint",
+ [clean.job.id.toString(10), now.toString()]
+ );
+ await expect(
+ driver.jobDeleteBefore({
+ completedFinalizedAt: Temporal.Instant.from("2026-08-30T13:00:00Z"),
+ max: 10,
+ queuesIncluded: [cleanQueue],
+ })
+ ).resolves.toBe(1);
+
+ const expiredQueue = `${filePrefix}_expired_queue`;
+ await driver.queueUpsert({
+ name: expiredQueue,
+ now,
+ updatedAt: now,
+ });
+ const expired = await driver.queueDeleteExpired({
+ max: 10,
+ updatedAtHorizon: Temporal.Instant.from("2026-08-30T13:00:00Z"),
+ });
+ expect(expired.map(({ name }) => name)).toContain(expiredQueue);
+
+ const artifacts = await driver.indexReindexArtifacts(
+ "river_job_args_index"
+ );
+ expect(artifacts).toEqual([]);
+ await pool.query(
+ "CREATE INDEX river_job_args_index_ccnew1 ON river_job (id)"
+ );
+ await expect(
+ driver.indexReindexArtifacts("river_job_args_index")
+ ).resolves.toEqual(["river_job_args_index_ccnew1"]);
+ await pool.query("DROP INDEX river_job_args_index_ccnew1");
+
+ await expect(
+ driver.indexesExist(["river_job_args_index", "river_job_does_not_exist"])
+ ).resolves.toEqual(
+ new Map([
+ ["river_job_args_index", true],
+ ["river_job_does_not_exist", false],
+ ])
+ );
+ const reindexLeader = (await driver.maintenanceLeaderAcquire(
+ `${filePrefix}_reindexer`,
+ Temporal.Now.instant(),
+ 60_000,
+ null
+ ))!;
+ await expect(
+ driver.maintenanceReindex(
+ reindexLeader,
+ ["river_job_args_index", "river_job_does_not_exist"],
+ 60_000,
+ new AbortController().signal
+ )
+ ).resolves.toBe(1);
+ await expect(driver.maintenanceLeaderResign(reindexLeader)).resolves.toBe(
+ true
+ );
+ });
+});
+
+describe("PgDriver custom-schema integration", () => {
+ const schema = `${filePrefix}_schema`;
+ let driver: PgRuntime;
+ let pool: pg.Pool;
+
+ beforeAll(async () => {
+ pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ await pool.query(`CREATE SCHEMA "${schema}"`);
+ await migrateSchema(pool, schema);
+ driver = testPgDriver(pool, { schema });
+ });
+
+ afterAll(async () => {
+ await pool.query(`DROP SCHEMA "${schema}" CASCADE`);
+ await pool.end();
+ });
+
+ // Mirrors River's driver tests for `NotificationDeleteBefore`: rows are
+ // inserted out of age order, and one sits exactly at the horizon.
+ async function insertNotifications(): Promise {
+ await pool.query(`DELETE FROM "${schema}".river_notification`);
+ const now = Temporal.Now.instant()
+ .round({ roundingMode: "floor", smallestUnit: "second" })
+ .add({ milliseconds: 120 });
+ await pool.query(
+ `INSERT INTO "${schema}".river_notification (created_at, payload, topic)
+ VALUES ($1, 'old_payload', 'topic'), ($2, 'oldest_payload', 'topic'),
+ ($3, 'horizon_payload', 'topic'), ($4, 'new_payload', 'topic')`,
+ [
+ now.subtract({ minutes: 61 }).toString(),
+ now.subtract({ hours: 2 }).toString(),
+ now.subtract({ hours: 1 }).toString(),
+ now.subtract({ minutes: 30 }).toString(),
+ ]
+ );
+ return now.subtract({ hours: 1 });
+ }
+
+ async function notificationPayloads(): Promise {
+ const result = await pool.query<{ payload: string }>(
+ `SELECT payload FROM "${schema}".river_notification ORDER BY created_at`
+ );
+ return result.rows.map(({ payload }) => payload);
+ }
+
+ it("deletes notifications before a horizon", async () => {
+ const createdAtHorizon = await insertNotifications();
+
+ await expect(
+ driver.notificationDeleteBefore({ createdAtHorizon, max: 10 })
+ ).resolves.toBe(2);
+ expect(await notificationPayloads()).toEqual([
+ "horizon_payload",
+ "new_payload",
+ ]);
+ });
+
+ it("deletes at most max notifications before a horizon, oldest first", async () => {
+ const createdAtHorizon = await insertNotifications();
+ const params = { createdAtHorizon, max: 1 };
+
+ await expect(driver.notificationDeleteBefore(params)).resolves.toBe(1);
+ // Delete by age, even when the oldest notification was inserted later.
+ expect((await notificationPayloads())[0]).toBe("old_payload");
+ await expect(driver.notificationDeleteBefore(params)).resolves.toBe(1);
+ await expect(driver.notificationDeleteBefore(params)).resolves.toBe(0);
+ // Keeps the notification exactly at the horizon.
+ expect(await notificationPayloads()).toEqual([
+ "horizon_payload",
+ "new_payload",
+ ]);
+ });
+
+ it("runs claim, completion, queues, and leadership in the configured schema", async () => {
+ const inserted = await driver.jobInsert(
+ insertParams(`${filePrefix}_custom`)
+ );
+ const claimed = (
+ await driver.jobClaim({
+ attemptedBy: `${filePrefix}_custom_worker`,
+ kinds: [`${filePrefix}_custom`],
+ queues: [{ limit: 1, name: "default" }],
+ })
+ ).jobs;
+ expect(claimed.map(({ id }) => id)).toEqual([inserted.job.id]);
+
+ const completed = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: `${filePrefix}_custom_worker`,
+ error: null,
+ id: inserted.job.id,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: true,
+ scheduledAt: null,
+ },
+ ]);
+ expect(completed[0]).toMatchObject({ status: "applied" });
+ expect(completed[0]!.job!.metadata).toMatchObject({ output: null });
+
+ const queue = await driver.queueUpsert({
+ metadata: { schema },
+ name: `${filePrefix}_custom_queue`,
+ });
+ expect(queue.metadata).toEqual({ schema });
+
+ const leader = await driver.leaderElect({
+ leaderId: `${filePrefix}_custom_leader`,
+ ttlSeconds: 30,
+ });
+ expect(leader!.leaderId).toBe(`${filePrefix}_custom_leader`);
+
+ const publicCount = await pool.query<{ count: string }>(
+ "SELECT count(*) FROM river_job WHERE kind = $1::text",
+ [`${filePrefix}_custom`]
+ );
+ expect(publicCount.rows[0]!.count).toBe("0");
+ });
+});
+
+describe("PgDriver client stop", () => {
+ const schema = `${filePrefix}_stop`;
+ let pool: pg.Pool;
+
+ beforeAll(async () => {
+ pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ await pool.query(`CREATE SCHEMA "${schema}"`);
+ await migrateSchema(pool, schema);
+ });
+
+ afterAll(async () => {
+ await pool.query(`DROP SCHEMA "${schema}" CASCADE`);
+ await pool.end();
+ });
+
+ it("waits for a maintenance transaction in flight and leaves none open", async () => {
+ const definition = defineJob({ kind: `${filePrefix}_stop` });
+ const inserted = await pool.query<{ id: string }>(
+ `INSERT INTO "${schema}".river_job
+ (args, finalized_at, kind, max_attempts, queue, state)
+ VALUES ('{}', now() - interval '1 hour', $1, 25, 'default', 'completed')
+ RETURNING id`,
+ [definition.kind]
+ );
+ // A lock on the completed job blocks the job cleaner's delete inside
+ // its leader-fenced transaction.
+ const blocker = await pool.connect();
+ const leaseClient = new pg.Client({ connectionString: TEST_DATABASE_URL });
+ await leaseClient.connect();
+ try {
+ await blocker.query("BEGIN");
+ await blocker.query(
+ `SELECT id FROM "${schema}".river_job WHERE id = $1 FOR UPDATE`,
+ [inserted.rows[0]?.id]
+ );
+ const client = new Client(new PgDriver(pool, { schema }), {
+ clientId: `${filePrefix}_stop`,
+ maintenance: {
+ completedJobRetention: { milliseconds: 1 },
+ electionInterval: { milliseconds: 50 },
+ jobCleanerInterval: { milliseconds: 10 },
+ },
+ queues: { default: { maxWorkers: 1 } },
+ workers: new Workers().add(definition, () => undefined),
+ });
+ const run = await client.start();
+ await waitFor(async () => {
+ const waiting = await leaseClient.query(
+ `SELECT 1 FROM pg_stat_activity
+ WHERE wait_event_type = 'Lock' AND query ILIKE '%river_job%'
+ AND query ILIKE '%DELETE%'`
+ );
+ return (waiting.rowCount ?? 0) > 0;
+ });
+
+ let stopped = false;
+ const stopping = run.stop().then(() => {
+ stopped = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ // Like River for Go, a stop waits for maintenance already running.
+ expect(stopped).toBe(false);
+ await blocker.query("ROLLBACK");
+ await stopping;
+
+ const open = await leaseClient.query(
+ `SELECT pid, query FROM pg_stat_activity
+ WHERE state LIKE 'idle in transaction%' AND pid <> pg_backend_pid()
+ AND datname = current_database()`
+ );
+ expect(open.rows).toEqual([]);
+ } finally {
+ await blocker.query("ROLLBACK").catch(() => undefined);
+ blocker.release();
+ await leaseClient.end();
+ }
+ });
+
+ it("waits for a maintenance transaction blocked at its leader fence", async () => {
+ const definition = defineJob({ kind: `${filePrefix}_stop` });
+ const inserted = await pool.query<{ id: string }>(
+ `INSERT INTO "${schema}".river_job
+ (args, finalized_at, kind, max_attempts, queue, state)
+ VALUES ('{}', now() - interval '1 hour', $1, 25, 'default', 'completed')
+ RETURNING id`,
+ [definition.kind]
+ );
+ // A lock on the completed job blocks the job cleaner's delete inside
+ // its leader-fenced transaction.
+ const blocker = await pool.connect();
+ const leaseClient = new pg.Client({ connectionString: TEST_DATABASE_URL });
+ await leaseClient.connect();
+ try {
+ void inserted;
+ const client = new Client(new PgDriver(pool, { schema }), {
+ clientId: `${filePrefix}_stop`,
+ maintenance: {
+ completedJobRetention: { milliseconds: 1 },
+ electionInterval: { milliseconds: 50 },
+ jobCleanerInterval: { milliseconds: 10 },
+ },
+ queues: { default: { maxWorkers: 1 } },
+ workers: new Workers().add(definition, () => undefined),
+ });
+ const run = await client.start();
+ await waitFor(async () => {
+ const leaders = await pool.query(
+ `SELECT 1 FROM "${schema}".river_leader`
+ );
+ return (leaders.rowCount ?? 0) > 0;
+ });
+ await blocker.query("BEGIN");
+ await blocker.query(`SELECT 1 FROM "${schema}".river_leader FOR UPDATE`);
+ await waitFor(async () => {
+ const waiting = await leaseClient.query(
+ `SELECT 1 FROM pg_stat_activity
+ WHERE wait_event_type = 'Lock' AND query ILIKE '%KEY SHARE%'`
+ );
+ return (waiting.rowCount ?? 0) > 0;
+ });
+
+ let stopped = false;
+ const stopping = run.stop().then(() => {
+ stopped = true;
+ });
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ // Like River for Go, a stop waits for maintenance already running.
+ expect(stopped).toBe(false);
+ await blocker.query("ROLLBACK");
+ await stopping;
+
+ const open = await leaseClient.query(
+ `SELECT pid, query FROM pg_stat_activity
+ WHERE state LIKE 'idle in transaction%' AND pid <> pg_backend_pid()
+ AND datname = current_database()`
+ );
+ expect(open.rows).toEqual([]);
+ } finally {
+ await blocker.query("ROLLBACK").catch(() => undefined);
+ blocker.release();
+ await leaseClient.end();
+ }
+ });
+});
+
+describe("PgDriver clients with leader election disabled", () => {
+ const schema = `${filePrefix}_no_leader`;
+ let driver: PgRuntime;
+ let pool: pg.Pool;
+
+ beforeAll(async () => {
+ pool = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ await pool.query(`CREATE SCHEMA "${schema}"`);
+ await migrateSchema(pool, schema);
+ driver = testPgDriver(pool, { schema });
+ });
+
+ afterAll(async () => {
+ await pool.query(`DROP SCHEMA "${schema}" CASCADE`);
+ await pool.end();
+ });
+
+ afterEach(async () => {
+ await pool.query(
+ `TRUNCATE "${schema}".river_job, "${schema}".river_leader`
+ );
+ });
+
+ const queueSettings = {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 5 },
+ };
+
+ it.each([
+ ["with notifications", false],
+ ["poll only", true],
+ ])("works jobs without leader election, %s", async (_name, pollOnly) => {
+ const definition = defineJob({ kind: `${filePrefix}_no_leader` });
+ const client = new Client(driver, {
+ clientId: `${filePrefix}_no_leader`,
+ completionFlushInterval: { milliseconds: 1 },
+ leaderElectionDisabled: true,
+ pollOnly,
+ queues: { default: queueSettings },
+ workers: new Workers().add(definition, () => undefined),
+ });
+ const run = await client.start();
+ try {
+ const inserted = await client.insert(definition, {});
+ await expect
+ .poll(async () => (await client.jobs.get(inserted.job.id))?.state)
+ .toBe("completed");
+ await expect(driver.leaderGet()).resolves.toBeNull();
+ expect(run.diagnostics.maintenance).toBeNull();
+ } finally {
+ await run.stop();
+ }
+ await expect(driver.leaderGet()).resolves.toBeNull();
+ });
+
+ it("stays ineligible to lead after the leader stops", async () => {
+ const definition = defineJob({ kind: `${filePrefix}_no_leader_periodic` });
+ const workers = new Workers().add(definition, () => undefined);
+ const workerId = `${filePrefix}_no_leader_worker`;
+ const worker = new Client(driver, {
+ clientId: workerId,
+ completionFlushInterval: { milliseconds: 1 },
+ leaderElectionDisabled: true,
+ queues: { default: queueSettings },
+ workers,
+ });
+ const workerRun = await worker.start();
+ try {
+ const leaderId = `${filePrefix}_no_leader_leader`;
+ const leader = new Client(driver, {
+ clientId: leaderId,
+ periodicJobs: [
+ periodicJob({
+ args: {},
+ every: { hours: 1 },
+ job: definition,
+ runOnStart: true,
+ }),
+ ],
+ queues: { [`${filePrefix}_leader`]: queueSettings },
+ workers,
+ });
+ const leaderRun = await leader.start();
+ try {
+ // The leader inserts a periodic job on the default queue, which only
+ // the client with leader election disabled works.
+ await expect
+ .poll(
+ async () =>
+ (
+ await worker.jobs.list({
+ kinds: [definition.kind],
+ states: ["completed"],
+ })
+ ).jobs.map(({ attemptedBy }) => attemptedBy),
+ { timeout: 10_000 }
+ )
+ .toEqual([[workerId]]);
+ await expect(driver.leaderGet()).resolves.toMatchObject({ leaderId });
+ } finally {
+ await leaderRun.stop();
+ }
+
+ const inserted = await worker.insert(definition, {});
+ await expect
+ .poll(async () => (await worker.jobs.get(inserted.job.id))?.state)
+ .toBe("completed");
+ await expect(driver.leaderGet()).resolves.toBeNull();
+ } finally {
+ await workerRun.stop();
+ }
+ });
+});
+
+async function waitFor(
+ predicate: () => boolean | Promise
+): Promise {
+ const deadline = Date.now() + 2_000;
+ while (!(await predicate())) {
+ if (Date.now() > deadline) throw new Error("condition was not reached");
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+}
+
+async function waitForListenerPID(
+ pool: pg.Pool,
+ topic: string,
+ excludedPID?: number
+): Promise {
+ const deadline = Date.now() + 2_000;
+ while (true) {
+ const result = await pool.query<{ pid: number }>(
+ `SELECT pid
+ FROM pg_stat_activity
+ WHERE datname = current_database()
+ AND query LIKE $1::text
+ AND ($2::int IS NULL OR pid != $2::int)
+ ORDER BY backend_start DESC
+ LIMIT 1`,
+ [`LISTEN %${topic}%`, excludedPID ?? null]
+ );
+ const pid = result.rows[0]?.pid;
+ if (pid !== undefined) return pid;
+ if (Date.now() > deadline) {
+ throw new Error(`listener for ${topic} was not found`);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 5));
+ }
+}
+
+/** Let every queue's insert notification through, with no limiter. */
+function allowEveryQueue(queues: readonly string[]): readonly string[] {
+ return [...new Set(queues)];
+}
diff --git a/js/driver/pg/src/driver.test.ts b/js/driver/pg/src/driver.test.ts
new file mode 100644
index 000000000..1f6d149ea
--- /dev/null
+++ b/js/driver/pg/src/driver.test.ts
@@ -0,0 +1,1790 @@
+import { Buffer } from "node:buffer";
+import { EventEmitter } from "node:events";
+import type { Client, ClientBase, Pool, PoolClient, QueryConfig } from "pg";
+import { types as globalPgTypes } from "pg";
+import { beforeEach, describe, expect, expectTypeOf, it, vi } from "vitest";
+import {
+ Client as RiverClient,
+ ConfigurationError,
+ defineJob,
+ isExactJsonNumber,
+ type Client as RiverClientType,
+ Workers,
+} from "riverqueue";
+import type { JobInsertParams } from "riverqueue/unstable-driver";
+import { PgDriver, type PgRuntime, testPgDriver } from "./driver.js";
+import { PG_EXACT_TYPES } from "./exact-types.js";
+import { databaseError } from "./errors.js";
+
+const CREATED_AT = Temporal.Instant.from("2026-08-30T12:00:00.123456Z");
+const SCHEDULED_AT = Temporal.Instant.from("2026-08-30T13:00:00.654321Z");
+
+function fakePgRow(overrides: Record = {}) {
+ return {
+ args: { strings: ["a", "b"] },
+ attempt: 0,
+ attempted_at: null,
+ attempted_by: null,
+ created_at: CREATED_AT,
+ errors: null,
+ finalized_at: null,
+ id: 9_007_199_254_740_993n,
+ kind: "sort",
+ max_attempts: 25,
+ metadata: {},
+ priority: 1,
+ queue: "default",
+ scheduled_at: SCHEDULED_AT,
+ state: "available",
+ tags: ["tag1", "tag2"],
+ unique_key: null,
+ unique_states: null,
+ unique_skipped_as_duplicate: false,
+ ...overrides,
+ };
+}
+
+function fakeInsertParams(
+ overrides: Partial = {}
+): JobInsertParams {
+ return {
+ args: { strings: ["a", "b"] },
+ encodedArgs: '{"strings":["a","b"]}',
+ kind: "sort",
+ maxAttempts: 25,
+ metadata: { source: "test" },
+ priority: 1,
+ queue: "default",
+ scheduledAt: SCHEDULED_AT,
+ state: "available",
+ tags: [],
+ uniqueKey: null,
+ uniqueStates: null,
+ ...overrides,
+ };
+}
+
+function mockPgClient() {
+ const state = {
+ configs: [] as QueryConfig[],
+ rowCount: null as number | null,
+ rows: [] as Record[],
+ };
+ const query = vi.fn(async (config: QueryConfig) => {
+ // Answer the driver's one-time server detection as PostgreSQL 17,
+ // outside the statements each test inspects.
+ // LISTEN and ping queries are plain SQL strings, not query configs.
+ if (
+ (config.text as string | undefined)?.includes("yb_listen_notify_enabled")
+ ) {
+ return {
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: 1,
+ rows: [
+ {
+ date_style: "ISO, MDY",
+ product: "PostgreSQL 17.4",
+ version_num: 170_004,
+ yb_listen_notify_enabled: false,
+ },
+ ],
+ };
+ }
+ state.configs.push(config);
+ return {
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: state.rowCount ?? state.rows.length,
+ rows: state.rows,
+ };
+ });
+ return { query, state };
+}
+
+function mockLeasedPgClient() {
+ const base = mockPgClient();
+ const events = new EventEmitter();
+ return {
+ ...base,
+ emit: events.emit.bind(events),
+ listenerCount: events.listenerCount.bind(events),
+ off: events.off.bind(events),
+ on: events.on.bind(events),
+ once: events.once.bind(events),
+ release: vi.fn(),
+ };
+}
+
+function asPool(client: ReturnType): Pool {
+ return client as unknown as Pool;
+}
+
+function asPoolClient(client: ReturnType): PoolClient {
+ return client as unknown as PoolClient;
+}
+
+describe("PgDriver client typing", () => {
+ it("infers a full client that accepts any node-postgres client as tx", () => {
+ const pool = {} as Pool;
+ const typed = (): void => {
+ const client = new RiverClient(new PgDriver(pool));
+ expectTypeOf(client).toEqualTypeOf>();
+ const pooled = {} as PoolClient;
+ const standalone = {} as Client;
+ const job = defineJob({ kind: "typed" });
+ void client.insert(job, {}, { tx: pooled });
+ void client.insert(job, {}, { tx: standalone });
+ // @ts-expect-error -- transactions must be node-postgres clients.
+ void client.insert(job, {}, { tx: 12_345 });
+ void client.jobs.get(1n, { tx: standalone });
+ new Workers().add(job, async ({ client, completeTx }) => {
+ await completeTx(pooled);
+ await client.insert(job, {}, { tx: standalone });
+ // @ts-expect-error -- not a transaction of any installed driver.
+ await completeTx(12_345);
+ });
+ };
+ expect(typed).toBeTypeOf("function");
+ });
+});
+
+describe("PgDriver surface", () => {
+ it("exposes nothing but its construction", () => {
+ const driver = new PgDriver(asPool(mockPgClient()));
+
+ expect(Reflect.ownKeys(driver)).toEqual([]);
+ expect(Reflect.ownKeys(PgDriver.prototype)).toEqual(["constructor"]);
+ expect(new RiverClient(driver)).toBeInstanceOf(RiverClient);
+ });
+});
+
+describe("PostgreSQL exact type parsing", () => {
+ it("preserves exact JSON and JSONB numbers", () => {
+ const parseJson = PG_EXACT_TYPES.getTypeParser(114, "text");
+
+ const object = parseJson(
+ '{"decimal":0.1234567890123456789,"integer":9223372036854775807}'
+ ) as Record;
+ expect(isExactJsonNumber(object.decimal)).toBe(true);
+ expect(isExactJsonNumber(object.integer)).toBe(true);
+ expect(JSON.stringify(object)).toBe(
+ '{"decimal":0.1234567890123456789,"integer":9223372036854775807}'
+ );
+ });
+
+ it("leaves JSONB array elements as their text", () => {
+ const parseJsonbArray = PG_EXACT_TYPES.getTypeParser(
+ 3807 as Parameters[0],
+ "text"
+ );
+
+ expect(
+ parseJsonbArray(
+ '{"{\\"attempt\\": 1, \\"value\\": 9223372036854775807}",NULL}'
+ )
+ ).toEqual(['{"attempt": 1, "value": 9223372036854775807}', null]);
+ });
+
+ it("decodes text int8 values without Number coercion", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(20, "text");
+
+ expect(parser("9223372036854775807")).toBe(9_223_372_036_854_775_807n);
+ expect(globalPgTypes.getTypeParser(20, "text")("9223372036854775807")).toBe(
+ "9223372036854775807"
+ );
+ });
+
+ it("decodes binary int8 values", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(20, "binary");
+ const encoded = Buffer.alloc(8);
+ encoded.writeBigInt64BE(-9_223_372_036_854_775_808n);
+
+ expect(parser(encoded)).toBe(-9_223_372_036_854_775_808n);
+ });
+
+ it("preserves timestamptz microseconds and offsets from text", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(1184, "text");
+
+ expect(parser("2026-08-30 07:00:00.123456-05").toString()).toBe(
+ "2026-08-30T12:00:00.123456Z"
+ );
+ });
+
+ it("decodes binary timestamptz values at PostgreSQL's epoch", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(1184, "binary");
+ const encoded = Buffer.alloc(8);
+ encoded.writeBigInt64BE(1n);
+
+ expect(parser(encoded).toString()).toBe("2000-01-01T00:00:00.000001Z");
+ });
+
+ it("reads timestamptz offsets with seconds", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(1184, "text");
+
+ // A session time zone's historical local mean time, like Asia/Kolkata's.
+ expect(parser("1900-01-01 05:53:28+05:53:28").toString()).toBe(
+ "1900-01-01T00:00:00Z"
+ );
+ expect(parser("2026-08-30 07:00:00-0530").toString()).toBe(
+ "2026-08-30T12:30:00Z"
+ );
+ });
+
+ it("owns the parsers for every built-in type River reads", () => {
+ const parser = (oid: number) =>
+ PG_EXACT_TYPES.getTypeParser(oid, "text") as (value: string) => unknown;
+ const overridden = [16, 17, 19, 21, 23, 25, 1009, 1015, 1043, 1560, 1562];
+ const originals = overridden.map(
+ (oid) => [oid, globalPgTypes.getTypeParser(oid, "text")] as const
+ );
+ // An application may replace node-postgres's global parsers.
+ for (const oid of overridden) {
+ globalPgTypes.setTypeParser(oid, "text", () => "application parser");
+ }
+ try {
+ expect(parser(16)("t")).toBe(true);
+ expect(parser(16)("f")).toBe(false);
+ expect(parser(17)("\\x00ff41")).toEqual(Buffer.from([0, 255, 65]));
+ // `bytea_output = escape`.
+ expect(parser(17)("\\000\\377A\\\\")).toEqual(
+ Buffer.from([0, 255, 65, 92])
+ );
+ expect(parser(19)("river_job")).toBe("river_job");
+ expect(parser(21)("-4")).toBe(-4);
+ expect(parser(23)("2147483647")).toBe(2_147_483_647);
+ expect(parser(25)("text")).toBe("text");
+ expect(parser(1009)('{a,"b,c",NULL}')).toEqual(["a", "b,c", null]);
+ expect(parser(1015)("{x}")).toEqual(["x"]);
+ expect(parser(1043)("varchar")).toBe("varchar");
+ expect(parser(1560)("00000101")).toBe("00000101");
+ expect(parser(1562)("101")).toBe("101");
+ } finally {
+ for (const [oid, original] of originals) {
+ globalPgTypes.setTypeParser(oid, "text", original);
+ }
+ }
+ });
+
+ it("rejects PostgreSQL timestamp infinity", () => {
+ const parser = PG_EXACT_TYPES.getTypeParser(1184, "text");
+
+ expect(() => parser("infinity")).toThrow(/infinite timestamps/);
+ });
+});
+
+describe("PgDriver", () => {
+ let pgClient: ReturnType;
+ let driver: PgRuntime;
+
+ beforeEach(() => {
+ pgClient = mockPgClient();
+ driver = testPgDriver(asPool(pgClient));
+ });
+
+ it("classifies only explicit transient database failures as retryable", () => {
+ const failure = (message: string, code?: string) =>
+ code === undefined
+ ? new Error(message)
+ : Object.assign(new Error(message), { code });
+ const transient: readonly [string, Error][] = [
+ ["connection exception", failure("connection failure", "08006")],
+ ["too many connections", failure("too many clients", "53300")],
+ ["out of memory", failure("out of memory", "53200")],
+ ["disk full", failure("could not extend file", "53100")],
+ ["serialization failure", failure("could not serialize", "40001")],
+ ["deadlock", failure("deadlock detected", "40P01")],
+ ["lock timeout", failure("could not obtain lock", "55P03")],
+ ["statement timeout", failure("canceling statement", "57014")],
+ ["administrator shutdown", failure("terminating", "57P01")],
+ ["crash shutdown", failure("terminating", "57P02")],
+ ["cannot connect now", failure("starting up", "57P03")],
+ ["idle session timeout", failure("idle session", "57P05")],
+ ["idle transaction timeout", failure("idle transaction", "25P03")],
+ ["connection reset", failure("read ECONNRESET", "ECONNRESET")],
+ ["DNS failure", failure("getaddrinfo ENOTFOUND db", "ENOTFOUND")],
+ ["DNS retry", failure("getaddrinfo EAI_AGAIN db", "EAI_AGAIN")],
+ ["ended connection", failure("Connection terminated unexpectedly")],
+ [
+ "pool connection timeout",
+ failure("timeout exceeded when trying to connect"),
+ ],
+ [
+ "nested cause",
+ new Error("wrapped", { cause: failure("timeout", "57014") }),
+ ],
+ ];
+ for (const [label, cause] of transient) {
+ expect(databaseError("jobClaim", "failed", cause).retryable, label).toBe(
+ true
+ );
+ }
+
+ const permanent: readonly [string, Error][] = [
+ ["unique violation", failure("constraint", "23505")],
+ ["syntax error", failure("syntax", "42601")],
+ ["undefined table", failure("missing relation", "42P01")],
+ ["invalid input", failure("invalid input syntax", "22P02")],
+ ["no code", failure("something else")],
+ ];
+ for (const [label, cause] of permanent) {
+ expect(databaseError("jobClaim", "failed", cause).retryable, label).toBe(
+ false
+ );
+ }
+ expect(databaseError("jobClaim", "failed").retryable).toBe(false);
+ });
+
+ it("does not issue a query for an empty insert batch", async () => {
+ await expect(driver.jobInsertMany([])).resolves.toEqual([]);
+ expect(pgClient.query).not.toHaveBeenCalled();
+ });
+
+ it("inserts exact values and uses query-scoped parsers", async () => {
+ pgClient.state.rows = [fakePgRow()];
+
+ const result = await driver.jobInsert(fakeInsertParams());
+
+ expect(result.status).toBe("inserted");
+ expect(result.job.id).toBe(9_007_199_254_740_993n);
+ expect(result.job.createdAt.toString()).toBe("2026-08-30T12:00:00.123456Z");
+ const config = pgClient.state.configs[0]!;
+ expect(config.types).toBe(PG_EXACT_TYPES);
+ expect(config.text).toContain('INSERT INTO "river_job"');
+ expect(config.text).toContain(
+ "inserted_jobs.conflicted AS unique_skipped_as_duplicate"
+ );
+ expect(config.text).toContain("FROM unnest(");
+ // Insertion notifies nobody; the client notifies producers afterward.
+ expect(config.text).not.toContain("pg_notify");
+ expect(config.values).toHaveLength(13);
+ expect(config.values![3]).toEqual(['{"source":"test"}']);
+ expect(config.values![6]).toEqual(["2026-08-30T13:00:00.654321Z"]);
+ expect(config.values![11]).toBe('"river_job_id_seq"');
+ // No creation time: the database's current time is used.
+ expect(config.values![12]).toEqual([null]);
+ });
+
+ it("preserves batch input order and duplicate status", async () => {
+ pgClient.state.rows = [
+ fakePgRow({ id: 101n, kind: "first" }),
+ fakePgRow({
+ id: 101n,
+ kind: "first",
+ unique_skipped_as_duplicate: true,
+ }),
+ fakePgRow({ id: 102n, kind: "third" }),
+ ];
+
+ const results = await driver.jobInsertMany([
+ fakeInsertParams({ kind: "first" }),
+ fakeInsertParams({
+ createdAt: Temporal.Instant.from("2026-08-01T00:00:00.123456Z"),
+ kind: "second",
+ }),
+ fakeInsertParams({ kind: "third" }),
+ ]);
+
+ expect(results.map(({ job }) => job.id)).toEqual([101n, 101n, 102n]);
+ expect(results.map(({ status }) => status)).toEqual([
+ "inserted",
+ "duplicate",
+ "inserted",
+ ]);
+ const values = pgClient.state.configs[0]!.values!;
+ expect(values).toHaveLength(13);
+ expect(values[1]).toEqual(["first", "second", "third"]);
+ expect(values[12]).toEqual([null, "2026-08-01T00:00:00.123456Z", null]);
+ });
+
+ it("maps nullable database arrays to empty arrays", async () => {
+ pgClient.state.rows = [
+ fakePgRow({ attempted_by: null, errors: null, tags: null }),
+ ];
+
+ const result = await driver.jobInsert(fakeInsertParams());
+
+ expect(result.job.attemptedBy).toEqual([]);
+ expect(result.job.errors).toEqual([]);
+ expect(result.job.tags).toEqual([]);
+ expect(result.job.uniqueStates).toBeNull();
+ });
+
+ it("maps exact errors, unique keys, and unique states", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ errors: [
+ '{"at": "2026-08-30T12:30:00.000002Z", "attempt": 1, "error": "something broke", "trace": "stack trace"}',
+ ],
+ unique_key: Buffer.from([0xde, 0xad, 0xbe, 0xef]),
+ unique_states: "10000001",
+ }),
+ ];
+
+ const result = await driver.jobInsert(fakeInsertParams());
+
+ expect(result.job.errors[0]!.at.toString()).toBe(
+ "2026-08-30T12:30:00.000002Z"
+ );
+ expect(result.job.uniqueKey).toEqual(
+ new Uint8Array([0xde, 0xad, 0xbe, 0xef])
+ );
+ expect(result.job.uniqueStates).toEqual(["available", "scheduled"]);
+ });
+
+ it("uses a portable schema and parameterizes the sequence name", async () => {
+ const schema = "river_custom";
+ driver = testPgDriver(asPool(pgClient), { schema });
+ pgClient.state.rows = [fakePgRow()];
+
+ await driver.jobInsert(fakeInsertParams());
+
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain('"river_custom"."river_job"');
+ expect(config.text).not.toContain("river_job_id_seq'::regclass");
+ expect(config.values![11]).toBe('"river_custom"."river_job_id_seq"');
+ });
+
+ it("rejects invalid schema identifiers before querying", () => {
+ expect(() => testPgDriver(asPool(pgClient), { schema: "" })).toThrow(
+ ConfigurationError
+ );
+ expect(() =>
+ testPgDriver(asPool(pgClient), { schema: "bad\0schema" })
+ ).toThrow(ConfigurationError);
+ expect(() =>
+ testPgDriver(asPool(pgClient), { schema: "a".repeat(47) })
+ ).toThrow(/46 bytes/);
+ expect(() =>
+ testPgDriver(asPool(pgClient), { schema: `odd"schema` })
+ ).toThrow(ConfigurationError);
+ expect(() =>
+ testPgDriver(asPool(pgClient), { schema: "a".repeat(46) })
+ ).not.toThrow();
+ expect(pgClient.query).not.toHaveBeenCalled();
+ });
+
+ it("uses the exact transaction client for an operation", async () => {
+ const transaction = mockPgClient();
+ transaction.state.rows = [fakePgRow()];
+
+ await driver.jobGet(42n, { tx: asPoolClient(transaction) });
+
+ expect(transaction.query).toHaveBeenCalledOnce();
+ expect(pgClient.query).not.toHaveBeenCalled();
+ });
+
+ it("requires a Pool for runtime startup without taking ownership", async () => {
+ const poolClient = mockPgClient();
+ poolClient.state.rows = [fakePgRow()];
+ const release = vi.fn();
+ const clientDriver = testPgDriver({
+ ...poolClient,
+ release,
+ } as unknown as PoolClient);
+ expect(() => clientDriver.runtimeStartPreflight()).toThrow(
+ /requires PgDriver to be constructed with a Pool/
+ );
+ await clientDriver.jobGet(1n);
+ expect(release).not.toHaveBeenCalled();
+
+ const plainClient = mockPgClient();
+ plainClient.state.rows = [fakePgRow()];
+ const endClient = vi.fn();
+ const plainClientDriver = testPgDriver({
+ ...plainClient,
+ end: endClient,
+ } as unknown as Client);
+ expect(() => plainClientDriver.runtimeStartPreflight()).toThrow(
+ /requires PgDriver to be constructed with a Pool/
+ );
+ await plainClientDriver.jobGet(1n);
+ expect(endClient).not.toHaveBeenCalled();
+
+ const fullPool = mockPgClient();
+ fullPool.state.rows = [fakePgRow()];
+ const endPool = vi.fn();
+ const poolDriver = testPgDriver({
+ ...fullPool,
+ connect: vi.fn(),
+ end: endPool,
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool);
+ expect(() => poolDriver.runtimeStartPreflight()).not.toThrow();
+ await poolDriver.jobGet(1n);
+ expect(endPool).not.toHaveBeenCalled();
+
+ const oneConnectionPool = testPgDriver({
+ ...fullPool,
+ connect: vi.fn(),
+ idleCount: 1,
+ options: { max: 1 },
+ totalCount: 1,
+ } as unknown as Pool);
+ expect(() => oneConnectionPool.runtimeStartPreflight()).toThrow(
+ /Pool max to be at least 4/
+ );
+ expect(() =>
+ oneConnectionPool.runtimeStartPreflight({
+ maintenance: false,
+ notifications: false,
+ reindex: false,
+ })
+ ).not.toThrow();
+ });
+
+ it("stops waiting for a connection to claim when aborted, but never abandons a started claim", async () => {
+ const leased = mockLeasedPgClient();
+ let connected!: (client: PoolClient) => void;
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ connected = resolve;
+ })
+ ),
+ idleCount: 0,
+ options: { max: 1 },
+ totalCount: 1,
+ };
+ const pgDriver = testPgDriver(pool as unknown as Pool);
+ const params = {
+ attemptedBy: "worker",
+ kinds: [],
+ queues: [{ limit: 1, name: "default" }],
+ };
+ const controller = new AbortController();
+
+ const claim = pgDriver.jobClaim(params, { signal: controller.signal });
+ controller.abort(new Error("stopping"));
+ await expect(claim).rejects.toThrow("stopping");
+ // A connection that arrives after the stop goes back to the pool unused.
+ connected(leased as unknown as PoolClient);
+ await vi.waitFor(() => expect(leased.release).toHaveBeenCalledTimes(1));
+ expect(leased.query).not.toHaveBeenCalled();
+
+ // Once a connection is leased the claim runs even if the stop arrives.
+ const running = new AbortController();
+ const started = pgDriver.jobClaim(params, { signal: running.signal });
+ connected(leased as unknown as PoolClient);
+ await vi.waitFor(() => expect(leased.query).toHaveBeenCalledTimes(1));
+ running.abort(new Error("stopping"));
+ await expect(started).resolves.toEqual({ jobs: [] });
+ });
+
+ it("stops waiting for a maintenance connection when its batch aborts", async () => {
+ const leased = mockLeasedPgClient();
+ let connected!: (client: PoolClient) => void;
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(
+ () =>
+ new Promise((resolve) => {
+ connected = resolve;
+ })
+ ),
+ idleCount: 0,
+ options: { max: 1 },
+ totalCount: 1,
+ };
+ const pgDriver = testPgDriver(pool as unknown as Pool);
+ const controller = new AbortController();
+ const now = Temporal.Now.instant();
+
+ const cleaning = pgDriver.maintenanceCleanQueues(
+ { electedAt: now, expiresAt: now, leaderId: "leader" },
+ now,
+ 10,
+ { signal: controller.signal, timeoutMs: 1_000 }
+ );
+ controller.abort(new Error("stopping"));
+ await expect(cleaning).rejects.toThrow("stopping");
+ connected(leased as unknown as PoolClient);
+ await vi.waitFor(() => expect(leased.release).toHaveBeenCalledTimes(1));
+ expect(leased.query).not.toHaveBeenCalled();
+ });
+
+ it("rolls back an insertion's leased transaction when middleware throws", async () => {
+ const leased = mockLeasedPgClient();
+ leased.state.rows = [fakePgRow()];
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(async () => leased as unknown as PoolClient),
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ };
+ const failure = new Error("after next");
+ const client = new RiverClient(testPgDriver(pool as unknown as Pool), {
+ insertMiddleware: [
+ async (_context, next) => {
+ await next();
+ throw failure;
+ },
+ ],
+ });
+
+ await expect(
+ client.insert(defineJob({ kind: "sort" }), { strings: [] })
+ ).rejects.toBe(failure);
+
+ const statements = leased.query.mock.calls.map(([config]) =>
+ typeof config === "string" ? config : config.text.trim().split(/\s/)[0]
+ );
+ expect(statements[0]).toBe("BEGIN");
+ expect(statements.at(-1)).toBe("ROLLBACK");
+ expect(statements).not.toContain("COMMIT");
+ expect(pool.query).not.toHaveBeenCalled();
+ expect(leased.release).toHaveBeenCalledOnce();
+ });
+
+ it("inserts without a transaction only when it can lease a connection", async () => {
+ const plainClient = mockPgClient();
+ plainClient.state.rows = [fakePgRow()];
+ const client = new RiverClient(
+ testPgDriver(plainClient as unknown as Client)
+ );
+ const job = defineJob({ kind: "pool_less" });
+
+ await expect(client.insert(job, {})).rejects.toMatchObject({
+ code: "configuration",
+ message: expect.stringContaining("pass { tx }"),
+ });
+ await expect(client.insertMany([{ args: {}, job }])).rejects.toBeInstanceOf(
+ ConfigurationError
+ );
+ expect(plainClient.query).not.toHaveBeenCalled();
+
+ const transaction = mockPgClient();
+ transaction.state.rows = [fakePgRow()];
+ await client.insert(job, {}, { tx: asPoolClient(transaction) });
+ // The insertion, then its queue's insert notification.
+ expect(transaction.state.configs).toHaveLength(2);
+ expect(transaction.state.configs[1]?.text).toContain("pg_notify");
+ expect(transaction.state.configs[1]?.values).toEqual([
+ null,
+ "river_insert",
+ ['{"queue": "default"}'],
+ ]);
+ });
+
+ it("supervises leased transaction failures and removes normal listeners", async () => {
+ const normal = mockLeasedPgClient();
+ normal.state.rows = [fakePgRow()];
+ const failed = mockLeasedPgClient();
+ const connect = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(normal as unknown as PoolClient)
+ .mockResolvedValueOnce(failed as unknown as PoolClient);
+ const pool = {
+ ...mockPgClient(),
+ connect,
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const client = new RiverClient(testPgDriver(pool));
+ const job = defineJob({ kind: "leased" });
+
+ await client.insert(job, {});
+ expect(normal.listenerCount("error")).toBe(0);
+ expect(normal.release).toHaveBeenCalledOnce();
+ expect(normal.release).toHaveBeenCalledWith();
+
+ let releaseQuery!: () => void;
+ failed.query
+ .mockResolvedValueOnce({
+ command: "BEGIN",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ releaseQuery = () =>
+ resolve({
+ command: "INSERT",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ });
+ })
+ );
+ const failure = new Error("leased connection terminated");
+ const operation = client.insert(job, {});
+ await vi.waitFor(() => expect(releaseQuery).toBeTypeOf("function"));
+ failed.emit("error", failure);
+
+ await expect(operation).rejects.toBe(failure);
+ expect(failed.release).toHaveBeenCalledWith(true);
+ expect(failed.listenerCount("error")).toBe(1);
+ failed.emit("end");
+ expect(failed.listenerCount("error")).toBe(0);
+ releaseQuery();
+ });
+
+ it("supervises abortable completion query lease failures", async () => {
+ const leased = mockLeasedPgClient();
+ let releaseQuery!: () => void;
+ leased.query.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ releaseQuery = () =>
+ resolve({
+ command: "UPDATE",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ });
+ })
+ );
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(async () => leased as unknown as PoolClient),
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const leaseDriver = testPgDriver(pool);
+ const failure = new Error("completion connection terminated");
+ const operation = leaseDriver.jobCompleteMany(
+ [
+ {
+ attempt: 1,
+ attemptedBy: "worker",
+ error: null,
+ finalizedAt: CREATED_AT,
+ id: 1n,
+ kind: "complete",
+ metadata: {},
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ],
+ { signal: new AbortController().signal }
+ );
+ await vi.waitFor(() => expect(releaseQuery).toBeTypeOf("function"));
+ leased.emit("error", failure);
+
+ await expect(operation).rejects.toBe(failure);
+ expect(leased.release).toHaveBeenCalledWith(true);
+ expect(leased.listenerCount("error")).toBe(1);
+ leased.emit("end");
+ expect(leased.listenerCount("error")).toBe(0);
+ releaseQuery();
+ });
+
+ it("cancels an aborted completion statement on another connection", async () => {
+ const leased = Object.assign(mockLeasedPgClient(), { processID: 4242 });
+ leased.query.mockImplementation(() => new Promise(() => undefined));
+ const canceller = mockLeasedPgClient();
+ const connect = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(leased as unknown as PoolClient)
+ .mockResolvedValueOnce(canceller as unknown as PoolClient);
+ const pool = {
+ ...mockPgClient(),
+ connect,
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const leaseDriver = testPgDriver(pool);
+ const controller = new AbortController();
+ const reason = new Error("completion timed out");
+ const completion = leaseDriver.jobCompleteMany(
+ [
+ {
+ attempt: 1,
+ attemptedBy: "worker",
+ error: null,
+ finalizedAt: CREATED_AT,
+ id: 1n,
+ kind: "complete",
+ metadata: {},
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ],
+ { signal: controller.signal }
+ );
+ await vi.waitFor(() => expect(leased.query).toHaveBeenCalledOnce());
+
+ controller.abort(reason);
+
+ await expect(completion).rejects.toBe(reason);
+ expect(leased.release).toHaveBeenCalledWith(true);
+ await vi.waitFor(() => expect(canceller.release).toHaveBeenCalledWith());
+ const [cancel] = canceller.state.configs;
+ expect(cancel?.text).toContain("pg_cancel_backend(pid)");
+ expect(cancel?.values).toEqual([4242, "/* river:jobCompleteMany */"]);
+ expect(leased.query.mock.calls[0]?.[0]).toMatchObject({
+ text: expect.stringMatching(/^\/\* river:jobCompleteMany \*\/\n/),
+ });
+ });
+
+ it("pings an idle listener and fails when a ping never answers", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ try {
+ const emptyResult = {
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ };
+ const halfOpen = mockLeasedPgClient();
+ const queries: string[] = [];
+ // LISTEN and ping queries are plain SQL strings, not query configs.
+ halfOpen.query.mockImplementation(((text: string) => {
+ queries.push(text);
+ return queries.length > 1
+ ? new Promise(() => undefined)
+ : Promise.resolve(emptyResult);
+ }) as never);
+ const connect = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(halfOpen as unknown as PoolClient);
+ const pool = {
+ ...mockPgClient(),
+ connect,
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const listenDriver = testPgDriver(pool, { schema: "river" });
+ const abort = new AbortController();
+ let readies = 0;
+ const iterator = listenDriver.listen(
+ ["river_insert"],
+ abort.signal,
+ () => {
+ readies++;
+ },
+ { pingIntervalMs: 1_000 }
+ );
+ const next = iterator.next();
+ const failed = next.catch((error: unknown) => error);
+
+ await vi.advanceTimersByTimeAsync(0);
+ expect(readies).toBe(1);
+ expect(queries).toEqual(['LISTEN "river.river_insert"']);
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ // The ping re-issues the idempotent LISTEN so the session still shows
+ // as a listener in pg_stat_activity.
+ expect(queries).toEqual([
+ 'LISTEN "river.river_insert"',
+ 'LISTEN "river.river_insert"',
+ ]);
+ expect(halfOpen.release).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1_000);
+ // The caller resubscribes; the listener doesn't reconnect on its own.
+ await expect(failed).resolves.toMatchObject({
+ message: "PostgreSQL LISTEN connection did not answer a ping",
+ name: "DatabaseOperationError",
+ });
+ expect(halfOpen.release).toHaveBeenCalledWith(true);
+ expect(connect).toHaveBeenCalledOnce();
+ expect(readies).toBe(1);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("bounds a listener connection whose setup never answers", async () => {
+ vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] });
+ try {
+ // A pooled connection with a half-open socket accepts the LISTEN but
+ // never answers it.
+ const halfOpen = mockLeasedPgClient();
+ halfOpen.query.mockImplementation(() => new Promise(() => undefined));
+ const connect = vi
+ .fn<() => Promise>()
+ .mockResolvedValueOnce(halfOpen as unknown as PoolClient);
+ const pool = {
+ ...mockPgClient(),
+ connect,
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const listenDriver = testPgDriver(pool, { schema: "river" });
+ const abort = new AbortController();
+ let readies = 0;
+ const iterator = listenDriver.listen(
+ ["river_insert"],
+ abort.signal,
+ () => {
+ readies++;
+ },
+ { setupTimeoutMs: 1_000 }
+ );
+ const failed = iterator.next().catch((error: unknown) => error);
+
+ await vi.advanceTimersByTimeAsync(999);
+ expect(readies).toBe(0);
+ expect(halfOpen.release).not.toHaveBeenCalled();
+
+ await vi.advanceTimersByTimeAsync(1);
+ await expect(failed).resolves.toMatchObject({
+ message:
+ "PostgreSQL LISTEN connection setup did not finish within 1000 ms",
+ name: "DatabaseOperationError",
+ });
+ expect(halfOpen.release).toHaveBeenCalledWith(true);
+ expect(connect).toHaveBeenCalledOnce();
+ expect(readies).toBe(0);
+ } finally {
+ vi.useRealTimers();
+ }
+ });
+
+ it("stops a listener as soon as it aborts while connecting", async () => {
+ const connection = Promise.withResolvers();
+ const late = mockLeasedPgClient();
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(() => connection.promise),
+ idleCount: 0,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const listenDriver = testPgDriver(pool, { schema: "river" });
+ const abort = new AbortController();
+ const iterator = listenDriver.listen(["river_insert"], abort.signal);
+ const next = iterator.next();
+ await vi.waitFor(() => expect(pool.connect).toHaveBeenCalledOnce());
+
+ abort.abort();
+ await expect(next).resolves.toEqual({ done: true, value: undefined });
+
+ // A connection the pool hands out after the listener gave up is discarded.
+ connection.resolve(late as unknown as PoolClient);
+ await vi.waitFor(() => expect(late.release).toHaveBeenCalledWith(true));
+ });
+
+ it("supervises forcibly terminated leader-fenced maintenance leases", async () => {
+ const leased = mockLeasedPgClient();
+ let releaseFence!: () => void;
+ leased.query
+ .mockResolvedValueOnce({
+ command: "BEGIN",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ })
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ releaseFence = () =>
+ resolve({
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: 1,
+ rows: [{ held: true }],
+ });
+ })
+ );
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(async () => leased as unknown as PoolClient),
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const leaseDriver = testPgDriver(pool);
+ const failure = new Error("maintenance connection terminated");
+ const maintenance = leaseDriver.maintenanceSchedule(
+ {
+ electedAt: CREATED_AT,
+ expiresAt: CREATED_AT.add({ minutes: 1 }),
+ leaderId: "leader",
+ },
+ {
+ allowInsertNotifications: allowEveryQueue,
+ limit: 10,
+ notificationHorizon: CREATED_AT,
+ now: CREATED_AT,
+ scheduledAtHorizon: CREATED_AT,
+ }
+ );
+ await vi.waitFor(() => expect(releaseFence).toBeTypeOf("function"));
+ leased.emit("error", failure);
+
+ await expect(maintenance).rejects.toBe(failure);
+ expect(leased.release).toHaveBeenCalledWith(true);
+ expect(leased.listenerCount("error")).toBe(1);
+ leased.emit("end");
+ expect(leased.listenerCount("error")).toBe(0);
+ releaseFence();
+ });
+
+ it("destroys a leader-fenced maintenance lease when its batch aborts", async () => {
+ const leased = mockLeasedPgClient();
+ // The connection went half-open after BEGIN: nothing else answers.
+ leased.query
+ .mockResolvedValueOnce({
+ command: "BEGIN",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ })
+ .mockImplementation(() => new Promise(() => undefined));
+ const pool = {
+ ...mockPgClient(),
+ connect: vi.fn(async () => leased as unknown as PoolClient),
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const leaseDriver = testPgDriver(pool);
+ const abort = new AbortController();
+ const maintenance = leaseDriver.maintenanceSchedule(
+ {
+ electedAt: CREATED_AT,
+ expiresAt: CREATED_AT.add({ minutes: 1 }),
+ leaderId: "leader",
+ },
+ {
+ allowInsertNotifications: allowEveryQueue,
+ limit: 10,
+ notificationHorizon: CREATED_AT,
+ now: CREATED_AT,
+ scheduledAtHorizon: CREATED_AT,
+ },
+ { signal: abort.signal, timeoutMs: null }
+ );
+ await vi.waitFor(() => expect(leased.query).toHaveBeenCalledTimes(2));
+ const reason = new Error("client is stopping");
+ abort.abort(reason);
+
+ await expect(maintenance).rejects.toBe(reason);
+ expect(leased.release).toHaveBeenCalledWith(true);
+ expect(leased.query).toHaveBeenCalledTimes(2);
+ });
+
+ it("supervises forcibly terminated reindex leases", async () => {
+ const base = mockPgClient();
+ base.query.mockImplementation(async (config: QueryConfig) => {
+ const rows = config.text.includes("AS index_name")
+ ? [{ exists: true, index_name: "river_job_kind" }]
+ : config.text.includes("AS artifact_name")
+ ? []
+ : [{ held: true }];
+ return {
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: rows.length,
+ rows,
+ };
+ });
+ const leased = mockLeasedPgClient();
+ let releaseReindex!: () => void;
+ leased.query.mockImplementation((config: QueryConfig) => {
+ const text = typeof config === "string" ? config : config.text;
+ if (text.includes("set_config")) {
+ return Promise.resolve({
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: 1,
+ rows: [],
+ });
+ }
+ return new Promise((resolve) => {
+ releaseReindex = () =>
+ resolve({
+ command: "REINDEX",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ });
+ });
+ });
+ const pool = {
+ ...base,
+ connect: vi.fn(async () => leased as unknown as PoolClient),
+ idleCount: 1,
+ options: { max: 4 },
+ totalCount: 1,
+ } as unknown as Pool;
+ const leaseDriver = testPgDriver(pool);
+ const failure = new Error("reindex connection terminated");
+ const reindex = leaseDriver.maintenanceReindex(
+ {
+ electedAt: CREATED_AT,
+ expiresAt: CREATED_AT.add({ minutes: 1 }),
+ leaderId: "leader",
+ },
+ ["river_job_kind"],
+ 60_000,
+ new AbortController().signal
+ );
+ await vi.waitFor(() => expect(releaseReindex).toBeTypeOf("function"));
+ leased.emit("error", failure);
+
+ await expect(reindex).rejects.toBe(failure);
+ expect(leased.release).toHaveBeenCalledWith(true);
+ leased.emit("end");
+ expect(leased.listenerCount("error")).toBe(0);
+ releaseReindex();
+ });
+
+ it("rejects a mismatched transaction before querying", async () => {
+ const invalidTransaction = {} as PoolClient;
+
+ await expect(
+ driver.jobGet(42n, { tx: invalidTransaction })
+ ).rejects.toMatchObject({
+ backend: "postgres",
+ code: "backend_mismatch",
+ details: { backend: "postgres", operation: "jobGet" },
+ });
+ expect(pgClient.query).not.toHaveBeenCalled();
+ });
+
+ it("gets jobs by decimal bigint and returns null for absence", async () => {
+ pgClient.state.rows = [fakePgRow()];
+
+ const job = await driver.jobGet(9_223_372_036_854_775_807n);
+
+ expect(job!.id).toBe(9_007_199_254_740_993n);
+ expect(pgClient.state.configs[0]!.values).toEqual(["9223372036854775807"]);
+
+ pgClient.state.rows = [];
+ await expect(driver.jobGet(1n)).resolves.toBeNull();
+ });
+
+ it("atomically merges metadata and replaces metadata output", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ metadata: { keep: true, output: null, tenant: "acme" },
+ }),
+ ];
+
+ await driver.jobUpdate(42n, {
+ metadata: { tenant: "acme" },
+ output: null,
+ });
+
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain("jsonb_set");
+ expect(config.values).toEqual([
+ "42",
+ true,
+ '{"tenant":"acme"}',
+ true,
+ "null",
+ ]);
+ });
+
+ it("cancels with exact metadata, schema, notification, and now", async () => {
+ driver = testPgDriver(asPool(pgClient), { schema: "river_custom" });
+ pgClient.state.rows = [fakePgRow({ state: "cancelled" })];
+ const cancelAttemptedAt = Temporal.Instant.from(
+ "2026-08-30T12:00:00.123456789Z"
+ );
+ const now = Temporal.Instant.from("2026-08-30T12:01:00.000001Z");
+
+ await driver.jobCancelWithOptions({
+ cancelAttemptedAt,
+ controlTopic: "river_control",
+ id: 42n,
+ now,
+ });
+
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain('UPDATE "river_custom"."river_job"');
+ expect(config.text).toContain("pg_notify");
+ expect(config.values).toEqual([
+ "42",
+ "river_control",
+ '"2026-08-30T12:00:00.123456789Z"',
+ "river_custom",
+ "2026-08-30T12:01:00.000001Z",
+ true,
+ ]);
+ });
+
+ it("returns discriminated delete results", async () => {
+ pgClient.state.rows = [fakePgRow({ was_deleted: true })];
+ await expect(driver.jobDelete(1n)).resolves.toMatchObject({
+ status: "deleted",
+ });
+
+ pgClient.state.rows = [fakePgRow({ state: "running", was_deleted: false })];
+ await expect(driver.jobDelete(1n)).resolves.toMatchObject({
+ status: "running",
+ });
+
+ pgClient.state.rows = [];
+ await expect(driver.jobDelete(1n)).resolves.toEqual({
+ status: "not_found",
+ });
+ });
+
+ it("bulk deletes only an explicitly authorized filtered set on the supplied transaction", async () => {
+ const transaction = mockPgClient();
+ transaction.state.rows = [fakePgRow({ id: 5n })];
+
+ const deleted = await driver.jobDeleteMany(
+ {
+ all: false,
+ ids: [5n],
+ kinds: [],
+ limit: 100,
+ priorities: [],
+ queues: [],
+ states: [],
+ },
+ { tx: asPoolClient(transaction) }
+ );
+
+ expect(deleted.map(({ id }) => id)).toEqual([5n]);
+ expect(pgClient.query).not.toHaveBeenCalled();
+ const config = transaction.state.configs[0]!;
+ expect(config.text).toContain("FOR UPDATE SKIP LOCKED");
+ expect(config.text).toContain("state != 'running'");
+ expect(config.text).toContain("ORDER BY id ASC");
+ expect(config.values).toEqual([["5"], [], [], [], [], 100]);
+
+ await expect(
+ driver.jobDeleteMany({
+ all: false,
+ ids: [],
+ kinds: [],
+ limit: 100,
+ priorities: [],
+ queues: [],
+ states: [],
+ })
+ ).rejects.toThrow(/requires a filter or all=true/);
+ await expect(
+ driver.jobDeleteMany({
+ all: true,
+ ids: [5n],
+ kinds: [],
+ limit: 100,
+ priorities: [],
+ queues: [],
+ states: [],
+ })
+ ).rejects.toThrow(/cannot be combined with filters/);
+ });
+
+ it("retries with an exact optional now value", async () => {
+ pgClient.state.rows = [fakePgRow()];
+ const now = Temporal.Instant.from("2026-08-30T12:01:00.000001Z");
+
+ await driver.jobRetryWithOptions({ id: 42n, now });
+
+ expect(pgClient.state.configs[0]!.values).toEqual([
+ "42",
+ "2026-08-30T12:01:00.000001Z",
+ ]);
+ });
+
+ it("gets, lists, pauses, resumes, and updates queues", async () => {
+ const queueRow = {
+ created_at: CREATED_AT,
+ metadata: { tenant: "acme" },
+ metadata_text: '{"tenant": "acme"}',
+ name: "email",
+ paused_at: null,
+ updated_at: SCHEDULED_AT,
+ };
+ pgClient.state.rows = [queueRow];
+
+ await expect(driver.queueGet("email")).resolves.toMatchObject({
+ name: "email",
+ });
+ await expect(
+ driver.queueList({ limit: 10, nameAfter: null })
+ ).resolves.toHaveLength(1);
+
+ const pauseAt = Temporal.Instant.from("2026-08-30T14:00:00.000001Z");
+ pgClient.state.rows = [{ ...queueRow, paused_at: pauseAt }];
+ await expect(driver.queuePause("email")).resolves.toMatchObject({
+ name: "email",
+ pausedAt: pauseAt,
+ });
+
+ pgClient.state.rows = [
+ { ...queueRow, paused_at: pauseAt },
+ { ...queueRow, name: "reports", paused_at: pauseAt },
+ ];
+ await expect(
+ driver.queuePauseWithOptions({
+ name: "*",
+ now: pauseAt,
+ })
+ ).resolves.toBe(2);
+ // With no matching queue, the notification row still anchors the join.
+ pgClient.state.rows = [
+ Object.fromEntries(Object.keys(queueRow).map((key) => [key, null])),
+ ];
+ await expect(driver.queueResumeWithOptions({ name: "*" })).resolves.toBe(0);
+
+ pgClient.state.rows = [queueRow];
+ pgClient.state.rowCount = 1;
+ await expect(
+ driver.queueUpdate("email", { metadata: { owner: "workers" } })
+ ).resolves.toMatchObject({ name: "email" });
+
+ const configs = pgClient.state.configs;
+ expect(configs[0]!.text).toContain("WHERE name = $1::text");
+ expect(configs[1]!.text).toContain("ORDER BY name ASC");
+ expect(configs[2]!.text).toContain("RETURNING *");
+ // One aggregated notification: never a row per notified queue.
+ expect(configs[2]!.text).toContain(
+ "count(CASE WHEN $6::boolean THEN pg_notify("
+ );
+ expect(configs[2]!.text).toContain("FROM notification");
+ expect(configs[2]!.text).toContain("LEFT JOIN updated ON true");
+ expect(configs[2]!.values).toEqual([
+ null,
+ "email",
+ null,
+ "river_control",
+ true,
+ true,
+ ]);
+ expect(configs[3]!.values).toEqual([
+ "2026-08-30T14:00:00.000001Z",
+ "*",
+ null,
+ "river_control",
+ true,
+ true,
+ ]);
+ expect(configs[4]!.values).toEqual([
+ null,
+ "*",
+ null,
+ "river_control",
+ false,
+ true,
+ ]);
+ expect(configs[5]!.values).toEqual([
+ true,
+ '{"owner":"workers"}',
+ "email",
+ null,
+ "river_control",
+ '{"action":"metadata_changed","metadata":{"owner":"workers"},"queue":"email"}',
+ true,
+ ]);
+ });
+
+ it("claims per-queue capacities and records exact attempt ownership", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ attempt: 1,
+ attempted_by: ["worker-a"],
+ state: "running",
+ }),
+ ];
+
+ const jobs = (
+ await driver.jobClaim({
+ attemptedBy: "worker-a",
+ kinds: ["sort"],
+ queues: [
+ { limit: 2, name: "default" },
+ { limit: 1, name: "priority" },
+ ],
+ })
+ ).jobs;
+
+ expect(jobs[0]).toMatchObject({
+ attempt: 1,
+ attemptedBy: ["worker-a"],
+ state: "running",
+ });
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain("FOR UPDATE SKIP LOCKED");
+ expect(config.text).toContain("CROSS JOIN LATERAL");
+ expect(config.values).toEqual([
+ ["default", "priority"],
+ [2, 1],
+ ["sort"],
+ "worker-a",
+ ]);
+ });
+
+ it("guards completions by both attempt number and attempt owner", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ attempt: 2,
+ attempted_by: ["worker-a"],
+ id: 41n,
+ state: "completed",
+ transition_applied: true,
+ }),
+ fakePgRow({
+ attempt: 3,
+ attempted_by: ["worker-b"],
+ id: 42n,
+ state: "running",
+ transition_applied: false,
+ }),
+ ];
+ const errorAt = Temporal.Instant.from("2026-08-30T14:00:00.000001Z");
+ const finalizedAt = Temporal.Instant.from("2026-08-30T14:00:01.123456789Z");
+
+ const results = await driver.jobCompleteMany([
+ {
+ attempt: 2,
+ attemptedBy: "worker-a",
+ error: null,
+ id: 41n,
+ kind: "complete",
+ finalizedAt,
+ metadata: { checkpoint: "finished" },
+ output: { value: 1 },
+ outputSet: true,
+ scheduledAt: null,
+ },
+ {
+ attempt: 2,
+ attemptedBy: "worker-a",
+ error: { at: errorAt, error: "failed", trace: "trace" },
+ id: 42n,
+ kind: "retry",
+ finalizedAt: null,
+ metadata: { checkpoint: "retrying" },
+ output: null,
+ outputSet: false,
+ scheduledAt: SCHEDULED_AT,
+ },
+ {
+ attempt: 1,
+ attemptedBy: "worker-a",
+ error: null,
+ id: 43n,
+ kind: "discard",
+ finalizedAt,
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ]);
+
+ expect(results.map(({ key, status }) => ({ key, status }))).toEqual([
+ { key: "41:2:worker-a", status: "applied" },
+ { key: "42:2:worker-a", status: "stale" },
+ { key: "43:1:worker-a", status: "stale" },
+ ]);
+ expect(results[2]!.job).toBeNull();
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain(
+ "river_job.attempt = job_input.expected_attempt"
+ );
+ expect(config.text).toContain("array_length(river_job.attempted_by, 1)");
+ expect(config.text).toContain("river_job.state <> 'running'");
+ expect(config.text).toContain(
+ "SET metadata = river_job.metadata || job_input.metadata_updates"
+ );
+ expect(config.values![4]).toEqual([
+ null,
+ JSON.stringify({
+ at: errorAt.toString(),
+ attempt: 2,
+ error: "failed",
+ trace: "trace",
+ }),
+ null,
+ ]);
+ // Truncated to PostgreSQL's microseconds like Go's pgx.
+ expect(config.values![5]).toEqual([
+ "2026-08-30T14:00:01.123456Z",
+ null,
+ "2026-08-30T14:00:01.123456Z",
+ ]);
+ expect(config.values![6]).toEqual([
+ JSON.stringify({ checkpoint: "finished", output: { value: 1 } }),
+ JSON.stringify({ checkpoint: "retrying" }),
+ "{}",
+ ]);
+ });
+
+ it("stops awaiting an in-flight completion when aborted", async () => {
+ let releaseQuery!: () => void;
+ pgClient.query.mockImplementationOnce(async () => {
+ await new Promise((resolve) => {
+ releaseQuery = resolve;
+ });
+ return {
+ command: "SELECT",
+ fields: [],
+ oid: 0,
+ rowCount: 0,
+ rows: [],
+ };
+ });
+ const controller = new AbortController();
+ const reason = new Error("runtime stopping");
+
+ const completion = driver.jobCompleteMany(
+ [
+ {
+ attempt: 1,
+ attemptedBy: "worker-a",
+ error: null,
+ id: 44n,
+ kind: "complete",
+ finalizedAt: Temporal.Now.instant(),
+ output: null,
+ outputSet: false,
+ scheduledAt: null,
+ },
+ ],
+ { signal: controller.signal }
+ );
+ controller.abort(reason);
+
+ await expect(completion).rejects.toBe(reason);
+ releaseQuery();
+ });
+
+ it("decrements the attempt only for an owned snooze transition", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ attempt: 0,
+ attempted_by: ["worker-a"],
+ id: 44n,
+ state: "scheduled",
+ transition_applied: true,
+ }),
+ ];
+
+ const result = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: "worker-a",
+ error: null,
+ id: 44n,
+ kind: "snooze",
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt: SCHEDULED_AT,
+ },
+ ]);
+
+ expect(result[0]).toMatchObject({
+ job: { attempt: 0, state: "scheduled" },
+ status: "applied",
+ });
+ const sql = pgClient.state.configs[0]!.text;
+ expect(sql).toContain("greatest(river_job.attempt - 1, 0)");
+ expect(sql).toContain("NOT (river_job.metadata ? 'cancel_attempted_at')");
+ });
+
+ it("returns interrupted work immediately without recording an error", async () => {
+ pgClient.state.rows = [
+ fakePgRow({
+ attempt: 0,
+ attempted_by: ["worker-a"],
+ id: 45n,
+ state: "available",
+ transition_applied: true,
+ }),
+ ];
+
+ const result = await driver.jobCompleteMany([
+ {
+ attempt: 1,
+ attemptedBy: "worker-a",
+ error: null,
+ id: 45n,
+ kind: "interrupt",
+ finalizedAt: null,
+ output: null,
+ outputSet: false,
+ scheduledAt: SCHEDULED_AT,
+ },
+ ]);
+
+ expect(result[0]).toMatchObject({
+ job: { attempt: 0, errors: [], state: "available" },
+ status: "applied",
+ });
+ expect(pgClient.state.configs[0]!.values![3]).toEqual(["available"]);
+ expect(pgClient.state.configs[0]!.values![4]).toEqual([null]);
+ });
+
+ it("uses safe stable list ordering and keyset parameters", async () => {
+ pgClient.state.rows = [fakePgRow()];
+
+ const listParams = {
+ after: {
+ id: 40n,
+ kind: "sort",
+ queue: "default",
+ sortField: "scheduledAt",
+ time: SCHEDULED_AT,
+ },
+ ids: [],
+ kinds: ["sort"],
+ limit: 20,
+ metadata: { tenant: "acme" },
+ priorities: [1],
+ queues: ["default"],
+ sortDirection: "desc",
+ sortField: "scheduledAt",
+ states: ["available"],
+ tagsAll: ["alpha"],
+ tagsAny: ["beta"],
+ } as const;
+ await driver.jobList(listParams);
+
+ const config = pgClient.state.configs[0]!;
+ expect(config.text).toContain("scheduled_at DESC, id DESC");
+ expect(config.text).toContain("metadata @> $8::jsonb");
+ expect(config.text).toContain("scheduled_at < $9::timestamptz");
+ expect(config.values![7]).toBe('{"tenant":"acme"}');
+ expect(config.values![8]).toBe(SCHEDULED_AT.toString());
+ expect(config.values![9]).toBe("40");
+ });
+
+ it("filters one state by equality and finalized lists by the partial index", async () => {
+ const base = {
+ after: null,
+ ids: [],
+ kinds: [],
+ limit: 20,
+ metadata: null,
+ priorities: [],
+ queues: [],
+ sortDirection: "desc",
+ tagsAll: [],
+ tagsAny: [],
+ } as const;
+
+ for (const state of ["cancelled", "completed", "discarded"] as const) {
+ await driver.jobList({ ...base, sortField: "time", states: [state] });
+ }
+ await driver.jobList({ ...base, sortField: "time", states: ["running"] });
+ await driver.jobList({ ...base, sortField: "id", states: ["completed"] });
+ await driver.jobList({
+ ...base,
+ sortField: "time",
+ states: ["completed", "discarded"],
+ });
+ await driver.jobList({ ...base, sortField: "id", states: [] });
+
+ const [cancelled, completed, discarded, running, byId, many, none] =
+ pgClient.state.configs;
+ for (const [config, state] of [
+ [cancelled, "cancelled"],
+ [completed, "completed"],
+ [discarded, "discarded"],
+ ] as const) {
+ expect(config!.text).toContain(
+ 'state = $4::"river_job_state" AND finalized_at IS NOT NULL'
+ );
+ expect(config!.text).toContain("ORDER BY finalized_at DESC, id DESC");
+ expect(config!.values![3]).toBe(state);
+ }
+ expect(running!.text).toContain('state = $4::"river_job_state"\n');
+ expect(running!.text).not.toContain("finalized_at IS NOT NULL");
+ expect(byId!.text).toContain('state = $4::"river_job_state"\n');
+ expect(byId!.text).not.toContain("finalized_at IS NOT NULL");
+ for (const config of [many, none]) {
+ expect(config!.text).toContain(
+ 'state = ANY($4::text[]::"river_job_state"[])'
+ );
+ }
+ expect(many!.values![3]).toEqual(["completed", "discarded"]);
+ expect(none!.values![3]).toEqual([]);
+ });
+
+ it("chooses time ordering from the first requested state", async () => {
+ await driver.jobList({
+ after: null,
+ ids: [],
+ kinds: [],
+ limit: 20,
+ metadata: null,
+ priorities: [],
+ queues: [],
+ sortDirection: "asc",
+ sortField: "time",
+ states: ["running", "completed"],
+ tagsAll: [],
+ tagsAny: [],
+ });
+
+ // `attempted_at` may be null, so nulls explicitly sort last like Go.
+ expect(pgClient.state.configs[0]!.text).toContain(
+ "ORDER BY attempted_at ASC NULLS LAST, id ASC"
+ );
+ });
+
+ it("uses exact leader terms for lease renewal", async () => {
+ pgClient.state.rows = [
+ {
+ elected_at: CREATED_AT,
+ expires_at: SCHEDULED_AT,
+ leader_id: "leader-a",
+ },
+ ];
+
+ const leader = await driver.leaderReelect({
+ electedAt: CREATED_AT,
+ leaderId: "leader-a",
+ now: CREATED_AT,
+ ttlSeconds: 30,
+ });
+
+ expect(leader).toEqual({
+ electedAt: CREATED_AT,
+ expiresAt: SCHEDULED_AT,
+ leaderId: "leader-a",
+ });
+ expect(pgClient.state.configs[0]!.values).toEqual([
+ CREATED_AT.toString(),
+ 30,
+ CREATED_AT.toString(),
+ "leader-a",
+ ]);
+ });
+
+ it("wraps database failures without exposing SQL", async () => {
+ pgClient.query.mockRejectedValueOnce(new Error("password=secret"));
+
+ const promise = driver.jobGet(1n);
+
+ await expect(promise).rejects.toMatchObject({
+ backend: "postgres",
+ code: "database",
+ message: "PostgreSQL operation jobGet failed",
+ operation: "jobGet",
+ });
+ await expect(promise).rejects.not.toHaveProperty("sql");
+ });
+});
+
+/** Let every queue's insert notification through, with no limiter. */
+function allowEveryQueue(queues: readonly string[]): readonly string[] {
+ return [...new Set(queues)];
+}
diff --git a/js/driver/pg/src/driver.ts b/js/driver/pg/src/driver.ts
new file mode 100644
index 000000000..385c0beb9
--- /dev/null
+++ b/js/driver/pg/src/driver.ts
@@ -0,0 +1,941 @@
+import { Buffer } from "node:buffer";
+import type { Client as PgClient, ClientBase, Pool, PoolClient } from "pg";
+import type { JobRow } from "riverqueue";
+import type {
+ DriverInsertResult,
+ DriverRecord,
+ InsertDriverOptions,
+ JobClaimOptions,
+ JobClaimParams,
+ JobClaimResult,
+ JobCompletionCommand,
+ JobCompletionResult,
+ JobDeleteManyParams,
+ JobDeleteResult,
+ JobInsertParams,
+ JobListParams,
+ JobUpdateParams,
+ QueueListParams,
+ QueueRow,
+ QueueUpdateParams,
+ RuntimeJobCleanupParams,
+ RuntimeJobRescue,
+ RuntimeLeader,
+ RuntimeMaintenanceBatch,
+ RuntimeNotification,
+ RuntimeScheduleParams,
+ RuntimeDriver,
+ RuntimeWaitOptions,
+} from "riverqueue/unstable-driver";
+import { registerDriver } from "riverqueue/unstable-driver";
+import { isQueryable, PgDatabase } from "./database.js";
+import { configurationError, unsupportedError } from "./errors.js";
+import { PgPilotDatabase } from "./pilot.js";
+import type {
+ PgDriverOptions,
+ PgJobCancelParams,
+ PgJobDeleteBeforeParams,
+ PgJobRescueManyParams,
+ PgJobRetryParams,
+ PgJobScheduleResult,
+ PgLeader,
+ PgLeaderElectParams,
+ PgLeaderTermParams,
+ PgNotification,
+ PgOperationOptions,
+ PgQueueControlParams,
+ PgQueueRow,
+ PgQueueUpsertParams,
+} from "./types.js";
+import * as jobSql from "./sql/jobs.js";
+import * as leaderSql from "./sql/leader.js";
+import * as maintenanceSql from "./sql/maintenance.js";
+import * as notifySql from "./sql/notify.js";
+import * as queueSql from "./sql/queues.js";
+
+const RIVER_SCHEMA_MAX_BYTES = 46;
+const RIVER_SCHEMA_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
+
+/**
+ * River's complete node-postgres backend.
+ *
+ * The pool/client is caller-owned, and `PgDriver` never closes it. Keep
+ * your own reference to it: the driver exposes nothing but its
+ * construction. Passing `{ tx }` keeps the entire operation on that exact
+ * checked-out client, and River never ends that transaction. An insertion
+ * without `{ tx }` runs in a transaction River begins on a connection it
+ * leases from the pool, like River for Go, so a driver constructed from a
+ * single client requires `{ tx }` for insertions.
+ */
+export class PgDriver {
+ /**
+ * Type-only marker: a full runtime driver whose transactions are any
+ * node-postgres client (`pg.Client` or a `PoolClient` checked out from a
+ * pool) after `BEGIN`.
+ */
+ declare readonly "~river"?: {
+ readonly capability: "runtime";
+ readonly transaction: ClientBase;
+ };
+
+ constructor(
+ client: Pool | PoolClient | PgClient,
+ options: PgDriverOptions = {}
+ ) {
+ const runtime = new PgRuntime(client, options);
+ registerDriver(this, runtime.driverRecord());
+ }
+}
+
+/**
+ * @internal A registered driver whose operations are callable, for this
+ * package's tests.
+ */
+export function testPgDriver(
+ client: Pool | PoolClient | PgClient,
+ options: PgDriverOptions = {}
+): PgRuntime {
+ const runtime = new PgRuntime(client, options);
+ registerDriver(runtime, runtime.driverRecord());
+ return runtime;
+}
+
+/**
+ * @internal The operations behind a {@link PgDriver}, which River reaches
+ * through its private driver registry.
+ */
+export class PgRuntime implements RuntimeDriver {
+ declare readonly "~river"?: {
+ readonly capability: "runtime";
+ readonly transaction: ClientBase;
+ };
+
+ readonly backend = "postgres" as const;
+
+ /** The caller-owned pool, or null for a driver of a single client. */
+ readonly pool: Pool | null;
+
+ /** The pool or client this driver was constructed with. */
+ readonly #connection: Pool | PoolClient | PgClient;
+
+ /** Naming and execution shared by the query modules this class delegates to. */
+ readonly #db: PgDatabase;
+
+ constructor(
+ client: Pool | PoolClient | PgClient,
+ options: PgDriverOptions = {}
+ ) {
+ if (!isQueryable(client)) {
+ throw configurationError(
+ "construct",
+ "PgDriver requires a node-postgres Pool or Client"
+ );
+ }
+
+ const schema = options.schema;
+ if (schema !== undefined) validateSchema(schema);
+
+ this.#connection = client;
+ this.#db = new PgDatabase(client, schema ?? null);
+ this.pool = this.#db.pool;
+ }
+
+ /** What River's private registry records about this driver. */
+ driverRecord(): DriverRecord {
+ return {
+ backend: this.backend,
+ capability: "runtime",
+ migration:
+ this.pool === null
+ ? { client: this.#connection, schema: this.schema }
+ : { pool: this.pool, schema: this.schema },
+ operations: this,
+ ...(this.pool === null
+ ? {}
+ : { database: new PgPilotDatabase(this.#db, this.pool) }),
+ };
+ }
+
+ /**
+ * The configured PostgreSQL schema containing River's tables, or undefined
+ * when River uses the connection's `search_path`.
+ */
+ get schema(): string | undefined {
+ return this.#db.schemaName ?? undefined;
+ }
+
+ /**
+ * Reject worker startup unless River can lease independent connections.
+ *
+ * @internal
+ */
+ runtimeStartPreflight(
+ options = { maintenance: true, notifications: true, reindex: true }
+ ): void {
+ if (this.pool === null) {
+ throw unsupportedError(
+ "runtime",
+ "the PostgreSQL worker runtime requires PgDriver to be constructed with a Pool"
+ );
+ }
+ const minimumPoolSize =
+ 1 +
+ (options.notifications ? 1 : 0) +
+ (options.maintenance ? 1 : 0) +
+ (options.reindex ? 1 : 0);
+ if (this.pool.options.max < minimumPoolSize) {
+ throw configurationError(
+ "runtimeStartPreflight",
+ `the PostgreSQL worker runtime requires Pool max to be at least ${minimumPoolSize} for the enabled worker, notification, maintenance, and reindex services`
+ );
+ }
+ }
+
+ /**
+ * Cancel a job using River's canonical persisted cancellation semantics.
+ *
+ * @internal
+ */
+ jobCancel(id: bigint, options?: PgOperationOptions): Promise {
+ return jobSql.jobCancel(this.#db, id, options);
+ }
+
+ /**
+ * Backend test hook for deterministic cancellation clocks and topics.
+ *
+ * @internal
+ */
+ jobCancelWithOptions(
+ params: PgJobCancelParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobCancelWithOptions(this.#db, params, options);
+ }
+
+ /**
+ * Delete a job unless it is currently running.
+ *
+ * @internal
+ */
+ jobDelete(
+ id: bigint,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobDelete(this.#db, id, options);
+ }
+
+ /**
+ * Delete a bounded, explicitly authorized set of non-running jobs.
+ *
+ * @internal
+ */
+ jobDeleteMany(
+ params: JobDeleteManyParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobDeleteMany(this.#db, params, options);
+ }
+
+ /**
+ * Get a job by exact 64-bit ID.
+ *
+ * @internal
+ */
+ jobGet(id: bigint, options?: PgOperationOptions): Promise {
+ return jobSql.jobGet(this.#db, id, options);
+ }
+
+ /**
+ * Atomically claim runnable jobs using River's priority order and SKIP LOCKED.
+ *
+ * @internal
+ */
+ jobClaim(
+ params: JobClaimParams,
+ options?: JobClaimOptions
+ ): Promise {
+ return jobSql.jobClaim(this.#db, params, options);
+ }
+
+ /**
+ * Persist attempt-conditional worker outcomes in one bounded query.
+ *
+ * @internal
+ */
+ jobCompleteMany(
+ commands: readonly JobCompletionCommand[],
+ options?: { readonly signal?: AbortSignal; readonly tx?: ClientBase }
+ ): Promise {
+ return jobSql.jobCompleteMany(this.#db, commands, options);
+ }
+
+ /**
+ * List jobs through a fixed parameterized filter grammar.
+ *
+ * @internal
+ */
+ jobList(
+ params: JobListParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobList(this.#db, params, options);
+ }
+
+ /**
+ * Merge metadata into a job while locking the target row.
+ *
+ * @internal
+ */
+ jobUpdate(
+ id: bigint,
+ params: JobUpdateParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobUpdate(this.#db, id, params, options);
+ }
+
+ /**
+ * Insert one job, or return the existing job that holds its unique key.
+ *
+ * Part of River's unstable driver interface, which `Client` calls;
+ * applications insert through the client instead. Its parameter and result
+ * types come from `riverqueue/unstable-driver` and may change in any
+ * release.
+ */
+ jobInsert(
+ params: JobInsertParams,
+ options?: InsertDriverOptions
+ ): Promise {
+ return jobSql.jobInsert(this.#db, params, options);
+ }
+
+ /**
+ * Insert an ordered batch of jobs atomically.
+ *
+ * Part of River's unstable driver interface, which `Client` calls;
+ * applications insert through the client instead. Its parameter and result
+ * types come from `riverqueue/unstable-driver` and may change in any
+ * release.
+ */
+ jobInsertMany(
+ params: readonly JobInsertParams[],
+ options?: InsertDriverOptions
+ ): Promise {
+ return jobSql.jobInsertMany(this.#db, params, options);
+ }
+
+ /**
+ * The IDs among `ids` of running jobs with a cancellation request, which a
+ * runtime without notifications polls for.
+ *
+ * @internal
+ */
+ jobGetCancelRequested(
+ ids: readonly bigint[],
+ options?: RuntimeWaitOptions
+ ): Promise {
+ return jobSql.jobGetCancelRequested(this.#db, ids, options);
+ }
+
+ /**
+ * Retry a non-running job immediately using River's canonical transition.
+ *
+ * @internal
+ */
+ jobRetry(id: bigint, options?: PgOperationOptions): Promise {
+ return jobSql.jobRetry(this.#db, id, options);
+ }
+
+ /**
+ * Backend test hook for deterministic retry clocks.
+ *
+ * @internal
+ */
+ jobRetryWithOptions(
+ params: PgJobRetryParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return jobSql.jobRetryWithOptions(this.#db, params, options);
+ }
+
+ /**
+ * Delete terminal jobs below configured retention horizons.
+ *
+ * @internal
+ */
+ jobDeleteBefore(
+ params: PgJobDeleteBeforeParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.jobDeleteBefore(this.#db, params, options);
+ }
+
+ /**
+ * Read running jobs old enough for rescuer inspection.
+ *
+ * @internal
+ */
+ jobGetStuck(
+ params: { afterId?: bigint; max: number; stuckHorizon: Temporal.Instant },
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.jobGetStuck(this.#db, params, options);
+ }
+
+ /**
+ * Rescue still-stuck running jobs without overwriting concurrent completion.
+ *
+ * @internal
+ */
+ jobRescueMany(
+ params: PgJobRescueManyParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.jobRescueMany(this.#db, params, options);
+ }
+
+ /**
+ * Move due scheduled/retryable jobs into the runnable state.
+ *
+ * @internal
+ */
+ jobSchedule(
+ params: {
+ max: number;
+ now?: Temporal.Instant;
+ scheduledAtHorizon?: Temporal.Instant;
+ },
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.jobSchedule(this.#db, params, options);
+ }
+
+ /**
+ * Get a persisted queue by name.
+ *
+ * @internal
+ */
+ queueGet(
+ name: string,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueGet(this.#db, name, options);
+ }
+
+ /**
+ * Create a queue or refresh its liveness timestamp without erasing metadata.
+ *
+ * @internal
+ */
+ queueUpsert(
+ params: PgQueueUpsertParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueUpsert(this.#db, params, options);
+ }
+
+ /**
+ * Delete stale queue rows in stable name order.
+ *
+ * @internal
+ */
+ queueDeleteExpired(
+ params: { max: number; updatedAtHorizon: Temporal.Instant },
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueDeleteExpired(this.#db, params, options);
+ }
+
+ /**
+ * List persisted queues in canonical name order.
+ *
+ * @internal
+ */
+ queueList(
+ params: QueueListParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueList(this.#db, params, options);
+ }
+
+ /**
+ * Pause one queue, or all queues with the `"*"` sentinel.
+ *
+ * @internal
+ */
+ queuePause(
+ name: string,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queuePause(this.#db, name, options);
+ }
+
+ /**
+ * Backend test hook for deterministic queue pause clocks.
+ *
+ * @internal
+ */
+ queuePauseWithOptions(
+ params: PgQueueControlParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queuePauseWithOptions(this.#db, params, options);
+ }
+
+ /**
+ * Resume one queue, or all queues with the `"*"` sentinel.
+ *
+ * @internal
+ */
+ queueResume(
+ name: string,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueResume(this.#db, name, options);
+ }
+
+ /**
+ * Backend test hook for deterministic queue resume clocks.
+ *
+ * @internal
+ */
+ queueResumeWithOptions(
+ params: PgQueueControlParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueResumeWithOptions(this.#db, params, options);
+ }
+
+ /**
+ * Update the mutable fields of a persisted queue.
+ *
+ * @internal
+ */
+ queueUpdate(
+ name: string,
+ params: QueueUpdateParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return queueSql.queueUpdate(this.#db, name, params, options);
+ }
+
+ /**
+ * Attempt to acquire the singleton River leadership lease.
+ *
+ * @internal
+ */
+ leaderElect(
+ params: PgLeaderElectParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return leaderSql.leaderElect(this.#db, params, options);
+ }
+
+ /**
+ * Renew a leadership lease only for the exact current election term.
+ *
+ * @internal
+ */
+ leaderReelect(
+ params: PgLeaderTermParams,
+ options?: PgOperationOptions
+ ): Promise {
+ return leaderSql.leaderReelect(this.#db, params, options);
+ }
+
+ /**
+ * Read the currently persisted leader, whether or not its lease is expired.
+ *
+ * @internal
+ */
+ leaderGet(options?: PgOperationOptions): Promise {
+ return leaderSql.leaderGet(this.#db, options);
+ }
+
+ /**
+ * Remove expired leadership rows so a new election can proceed.
+ *
+ * @internal
+ */
+ leaderDeleteExpired(
+ now?: Temporal.Instant,
+ options?: PgOperationOptions
+ ): Promise {
+ return leaderSql.leaderDeleteExpired(this.#db, now, options);
+ }
+
+ /**
+ * Resign only the exact election term and notify leadership observers.
+ *
+ * @internal
+ */
+ leaderResign(
+ params: PgLeaderTermParams & { leadershipTopic: string },
+ options?: PgOperationOptions
+ ): Promise {
+ return leaderSql.leaderResign(this.#db, params, options);
+ }
+
+ /**
+ * Notify producers of new jobs in each of `queues`.
+ *
+ * Part of River's unstable driver interface, which `Client` calls after
+ * inserting jobs. Its parameter types come from
+ * `riverqueue/unstable-driver` and may change in any release.
+ */
+ notifyInsert(
+ queues: readonly string[],
+ options?: InsertDriverOptions
+ ): Promise {
+ return notifySql.notifyInsert(this.#db, queues, options);
+ }
+
+ /**
+ * Send one or more PostgreSQL notifications on a River topic.
+ *
+ * @internal
+ */
+ notifyMany(
+ topic: string,
+ payloads: readonly string[],
+ options?: PgOperationOptions
+ ): Promise {
+ return notifySql.notifyMany(this.#db, topic, payloads, options);
+ }
+
+ /**
+ * Yield namespaced PostgreSQL notifications with reconnect recovery.
+ *
+ * Notifications are hints only: callers must retain polling because NOTIFY
+ * is not durable and a connection may be between reconnect attempts. An
+ * idle connection is pinged every five seconds, like River's Go notifier,
+ * so a half-open socket is detected and replaced instead of silently
+ * dropping every later notification. Connecting and subscribing are
+ * bounded by a ten-second timeout and stop as soon as `signal` aborts.
+ * `ready` runs after each successful (re)connection so callers can poll
+ * for anything missed meanwhile.
+ *
+ * @internal
+ */
+ listen(
+ topics: readonly string[],
+ signal: AbortSignal,
+ ready?: () => void,
+ options: {
+ readonly pingIntervalMs?: number;
+ readonly setupTimeoutMs?: number;
+ } = {}
+ ): AsyncGenerator {
+ return notifySql.listen(this.#db, topics, signal, ready, options);
+ }
+
+ /**
+ * Whether the server delivers notifications, detected the first time and
+ * cached for this driver's lifetime. YugabyteDB without
+ * `yb_enable_listen_notify` doesn't, so its runtimes poll instead.
+ *
+ * @internal
+ */
+ async runtimeDeliversNotifications(
+ options?: RuntimeWaitOptions
+ ): Promise {
+ const capabilities = await this.#db.withConnection(
+ options?.signal,
+ (connection) => this.#db.capabilities(connection)
+ );
+ return capabilities.supportsListenNotify;
+ }
+
+ /**
+ * Adapt namespaced backend hints to the common runtime notification SPI.
+ *
+ * @internal
+ */
+ runtimeNotificationSubscribe(
+ topics: readonly RuntimeNotification["topic"][],
+ signal: AbortSignal,
+ ready: () => void
+ ): AsyncGenerator {
+ return notifySql.runtimeNotificationSubscribe(
+ this.#db,
+ topics,
+ signal,
+ ready
+ );
+ }
+
+ /**
+ * Run one River operation in a transaction, like River for Go's
+ * `dbutil.WithTxV`. With `tx`, the operation joins that caller-owned
+ * transaction. Otherwise River leases a pool connection and runs `BEGIN`,
+ * then commits when `callback` resolves and rolls back when it rejects.
+ *
+ * Like River for Go without a pool, a driver constructed from a single
+ * client can't open transactions of its own.
+ *
+ * @internal
+ */
+ async operationScope(
+ tx: ClientBase | undefined,
+ callback: (tx: ClientBase) => Promise
+ ): Promise {
+ if (tx !== undefined) return callback(tx);
+ if (this.pool === null) {
+ throw configurationError(
+ "operation_scope",
+ "PgDriver was constructed with a single client, so River can't open " +
+ "a transaction of its own for this operation; pass { tx } or " +
+ "construct PgDriver with a Pool"
+ );
+ }
+ return this.#db.transaction(
+ "operationScope",
+ this.pool,
+ undefined,
+ callback
+ );
+ }
+
+ /**
+ * Broadcast a request for whichever runtime currently leads to resign.
+ *
+ * @internal
+ */
+ runtimeRequestLeadershipResignation(
+ options?: PgOperationOptions
+ ): Promise {
+ return this.notifyMany(
+ "river_leadership",
+ // River for Go's payload names no leader.
+ ['{"action":"request_resign","leader_id":""}'],
+ options
+ );
+ }
+
+ /**
+ * Refresh a configured runtime queue without replacing controls/metadata.
+ *
+ * @internal
+ */
+ runtimeQueueUpsert(
+ name: string,
+ now: Temporal.Instant,
+ options?: RuntimeWaitOptions
+ ): Promise {
+ return this.#db.withConnection(options?.signal, (connection) =>
+ queueSql.queueUpsert(this.#db, { name, now }, connection)
+ );
+ }
+
+ /**
+ * Acquire or renew one exact leadership lease after expiring stale terms.
+ *
+ * @internal
+ */
+ maintenanceLeaderAcquire(
+ leaderId: string,
+ _now: Temporal.Instant,
+ ttlMs: number,
+ held: RuntimeLeader | null,
+ options?: RuntimeWaitOptions
+ ): Promise {
+ return maintenanceSql.maintenanceLeaderAcquire(
+ this.#db,
+ leaderId,
+ ttlMs,
+ held,
+ options?.signal
+ );
+ }
+
+ /** @internal */
+ maintenanceLeaderResign(leader: RuntimeLeader): Promise {
+ return maintenanceSql.maintenanceLeaderResign(this.#db, leader);
+ }
+
+ /** @internal */
+ maintenanceSchedule(
+ leader: RuntimeLeader,
+ params: RuntimeScheduleParams,
+ batch?: RuntimeMaintenanceBatch
+ ): Promise {
+ return maintenanceSql.maintenanceSchedule(this.#db, leader, params, batch);
+ }
+
+ /** @internal */
+ maintenanceGetStuck(
+ leader: RuntimeLeader,
+ attemptedBefore: Temporal.Instant,
+ afterId: bigint,
+ limit: number,
+ batch?: RuntimeMaintenanceBatch
+ ): Promise {
+ return maintenanceSql.maintenanceGetStuck(
+ this.#db,
+ leader,
+ attemptedBefore,
+ afterId,
+ limit,
+ batch
+ );
+ }
+
+ /** @internal */
+ maintenanceRescue(
+ leader: RuntimeLeader,
+ attemptedBefore: Temporal.Instant,
+ jobs: readonly RuntimeJobRescue[],
+ options?: InsertDriverOptions
+ ): Promise {
+ return maintenanceSql.maintenanceRescue(
+ this.#db,
+ leader,
+ attemptedBefore,
+ jobs,
+ options?.tx
+ );
+ }
+
+ /** @internal */
+ maintenanceCleanJobs(
+ leader: RuntimeLeader,
+ params: RuntimeJobCleanupParams,
+ timeoutMs: number | null,
+ signal: AbortSignal
+ ): Promise {
+ return maintenanceSql.maintenanceCleanJobs(
+ this.#db,
+ leader,
+ params,
+ timeoutMs,
+ signal
+ );
+ }
+
+ /** @internal */
+ maintenanceCleanQueues(
+ leader: RuntimeLeader,
+ updatedBefore: Temporal.Instant,
+ limit: number,
+ batch?: RuntimeMaintenanceBatch
+ ): Promise {
+ return maintenanceSql.maintenanceCleanQueues(
+ this.#db,
+ leader,
+ updatedBefore,
+ limit,
+ batch
+ );
+ }
+
+ /**
+ * Convert control-topic notifications into attempt-owner cancellation hints.
+ *
+ * @internal
+ */
+ jobCancellationSubscribe(
+ attemptedBy: string,
+ signal: AbortSignal,
+ ready?: () => void
+ ): AsyncGenerator<{ attemptedBy: string; id: bigint }> {
+ return notifySql.jobCancellationSubscribe(
+ this.#db,
+ attemptedBy,
+ signal,
+ ready
+ );
+ }
+
+ /**
+ * Delete up to `max` durable SQLite-style notification rows retained by
+ * migration v7 from before a horizon, oldest first.
+ *
+ * @internal
+ */
+ notificationDeleteBefore(
+ params: { createdAtHorizon: Temporal.Instant; max: number },
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.notificationDeleteBefore(this.#db, params, options);
+ }
+
+ /**
+ * Discover `_ccnew`/`_ccold` artifacts from an interrupted reindex.
+ *
+ * @internal
+ */
+ indexReindexArtifacts(
+ index: string,
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.indexReindexArtifacts(this.#db, index, options);
+ }
+
+ /**
+ * Reindex one allow-listed River index using a safely quoted identifier.
+ *
+ * @internal
+ */
+ indexReindex(index: string, options?: PgOperationOptions): Promise {
+ return maintenanceSql.indexReindex(this.#db, index, options);
+ }
+
+ /**
+ * Drop a concurrent-reindex artifact using a safely quoted identifier.
+ *
+ * @internal
+ */
+ indexDropIfExists(
+ index: string,
+ options?: PgOperationOptions
+ ): Promise {
+ return maintenanceSql.indexDropIfExists(this.#db, index, options);
+ }
+
+ /**
+ * Return exact existence results for indexes in the configured schema.
+ *
+ * @internal
+ */
+ indexesExist(
+ indexes: readonly string[],
+ options?: PgOperationOptions
+ ): Promise> {
+ return maintenanceSql.indexesExist(this.#db, indexes, options);
+ }
+
+ /**
+ * Rebuild existing configured indexes with River's artifact safeguards.
+ *
+ * @internal
+ */
+ maintenanceReindex(
+ leader: RuntimeLeader,
+ indexes: readonly string[],
+ timeoutMs: number | null,
+ signal: AbortSignal
+ ): Promise {
+ return maintenanceSql.maintenanceReindex(
+ this.#db,
+ leader,
+ indexes,
+ timeoutMs,
+ signal
+ );
+ }
+}
+
+function validateSchema(value: string): void {
+ if (!RIVER_SCHEMA_RE.test(value)) {
+ throw configurationError(
+ "construct",
+ "PostgreSQL schema must start with a letter or underscore and contain only letters, numbers, and underscores"
+ );
+ }
+ if (Buffer.byteLength(value, "utf8") > RIVER_SCHEMA_MAX_BYTES) {
+ throw configurationError(
+ "construct",
+ `PostgreSQL schema must not exceed ${RIVER_SCHEMA_MAX_BYTES} bytes so River notification topics remain valid`
+ );
+ }
+}
diff --git a/js/driver/pg/src/errors.ts b/js/driver/pg/src/errors.ts
new file mode 100644
index 000000000..654dfbaa8
--- /dev/null
+++ b/js/driver/pg/src/errors.ts
@@ -0,0 +1,145 @@
+import {
+ BackendMismatchError,
+ ConfigurationError,
+ DatabaseOperationError,
+ UnsupportedCapabilityError,
+} from "riverqueue";
+
+/** Backend name recorded on PostgreSQL errors. */
+const POSTGRES_BACKEND = "postgres";
+
+/** A failed PostgreSQL operation, retryable when the cause is transient. */
+export function databaseError(
+ operation: string,
+ message: string,
+ cause?: unknown
+): DatabaseOperationError {
+ return new DatabaseOperationError(message, {
+ backend: POSTGRES_BACKEND,
+ ...(cause === undefined ? {} : { cause }),
+ operation,
+ retryable: cause !== undefined && isRetryablePostgresError(cause),
+ });
+}
+
+/** Invalid PostgreSQL driver configuration or input. */
+export function configurationError(
+ operation: string,
+ message: string
+): ConfigurationError {
+ return new ConfigurationError(message, {
+ details: { backend: POSTGRES_BACKEND, operation },
+ });
+}
+
+/** A PostgreSQL operation unavailable for the configured connection. */
+export function unsupportedError(
+ capability: string,
+ message: string
+): UnsupportedCapabilityError {
+ return new UnsupportedCapabilityError(POSTGRES_BACKEND, capability, {
+ message,
+ });
+}
+
+/** A transaction value that does not belong to node-postgres. */
+export function backendMismatchError(
+ operation: string,
+ message: string
+): BackendMismatchError {
+ return new BackendMismatchError(POSTGRES_BACKEND, message, {
+ details: { operation },
+ });
+}
+
+/**
+ * Network error codes reported by Node's socket layer that describe a
+ * connection-level failure rather than a rejected statement.
+ */
+const TRANSIENT_NETWORK_CODES: ReadonlySet = new Set([
+ "EAI_AGAIN",
+ "ECONNABORTED",
+ "ECONNREFUSED",
+ "ECONNRESET",
+ "EHOSTUNREACH",
+ "ENETDOWN",
+ "ENETUNREACH",
+ "ENOTFOUND",
+ "EPIPE",
+ "ETIMEDOUT",
+]);
+
+/**
+ * SQLSTATE codes whose failures are transient: the statement was rejected
+ * because of contention, resource pressure, or a server-side timeout, so the
+ * same River operation can safely be attempted again.
+ */
+const TRANSIENT_SQLSTATES: ReadonlySet = new Set([
+ // idle_in_transaction_session_timeout ends the session.
+ "25P03",
+ // serialization_failure and deadlock_detected.
+ "40001",
+ "40P01",
+ // lock_not_available, including `lock_timeout`.
+ "55P03",
+ // query_canceled, including `statement_timeout`.
+ "57014",
+ // Administrator, crash, startup, and idle-session shutdowns.
+ "57P01",
+ "57P02",
+ "57P03",
+ "57P05",
+]);
+
+/**
+ * Message fragments node-postgres and pg-pool use for code-less connection
+ * failures, such as a pool `connectionTimeoutMillis` expiring.
+ */
+const TRANSIENT_MESSAGE =
+ /connection (?:ended|terminated)(?: unexpectedly)?|timeout exceeded when trying to connect/i;
+
+/**
+ * Whether a node-postgres failure is transient, so retrying the whole River
+ * operation is safe.
+ *
+ * Transient failures are connection exceptions (SQLSTATE class 08),
+ * insufficient resources (class 53), serialization failures and deadlocks,
+ * lock and statement timeouts, server shutdowns, socket and DNS errors, and
+ * pg-pool connection timeouts. The error and its `cause` chain are inspected.
+ */
+function isRetryablePostgresError(value: unknown): boolean {
+ const seen = new Set();
+ let current = value;
+ for (let depth = 0; depth < 8 && current !== null; depth++) {
+ if (
+ (typeof current !== "object" && typeof current !== "function") ||
+ seen.has(current)
+ ) {
+ return false;
+ }
+ seen.add(current);
+ const error = current as {
+ readonly cause?: unknown;
+ readonly code?: unknown;
+ readonly message?: unknown;
+ };
+ if (typeof error.code === "string") {
+ if (
+ error.code.startsWith("08") ||
+ error.code.startsWith("53") ||
+ TRANSIENT_SQLSTATES.has(error.code) ||
+ TRANSIENT_NETWORK_CODES.has(error.code)
+ ) {
+ return true;
+ }
+ }
+ if (
+ typeof error.message === "string" &&
+ TRANSIENT_MESSAGE.test(error.message)
+ ) {
+ return true;
+ }
+ current = error.cause;
+ }
+ return false;
+}
diff --git a/js/driver/pg/src/exact-types.ts b/js/driver/pg/src/exact-types.ts
new file mode 100644
index 000000000..90f97e740
--- /dev/null
+++ b/js/driver/pg/src/exact-types.ts
@@ -0,0 +1,229 @@
+import { Buffer } from "node:buffer";
+
+import { types as defaultPgTypes } from "pg";
+import type { CustomTypesConfig } from "pg";
+import { parse as parsePostgresArray } from "postgres-array";
+import { parseJson } from "riverqueue";
+
+const PG_EPOCH_UNIX_MICROSECONDS = 946_684_800_000_000n;
+const PG_BIT_OID = 1560;
+const PG_BOOL_OID = 16;
+const PG_BYTEA_OID = 17;
+const PG_INT2_OID = 21;
+const PG_INT4_OID = 23;
+const PG_INT8_OID = 20;
+const PG_JSON_ARRAY_OID = 199;
+const PG_JSON_OID = 114;
+const PG_JSONB_ARRAY_OID = 3807;
+const PG_JSONB_OID = 3802;
+const PG_NAME_OID = 19;
+const PG_TEXT_ARRAY_OID = 1009;
+const PG_TEXT_OID = 25;
+const PG_TIMESTAMPTZ_OID = 1184;
+const PG_VARBIT_OID = 1562;
+const PG_VARCHAR_ARRAY_OID = 1015;
+const PG_VARCHAR_OID = 1043;
+
+/**
+ * River's own text parsers for the other built-in types its queries return,
+ * so an application that changes node-postgres's process-global parsers
+ * (`pg.types.setTypeParser`) can't change how River reads its rows.
+ */
+const TEXT_PARSERS: ReadonlyMap unknown> = new Map<
+ number,
+ (value: string) => unknown
+>([
+ [PG_BIT_OID, identity],
+ [PG_BOOL_OID, parseTextBool],
+ [PG_BYTEA_OID, parseTextBytea],
+ [PG_INT2_OID, parseTextInt4],
+ [PG_INT4_OID, parseTextInt4],
+ [PG_NAME_OID, identity],
+ [PG_TEXT_ARRAY_OID, parseTextArray],
+ [PG_TEXT_OID, identity],
+ [PG_VARBIT_OID, identity],
+ [PG_VARCHAR_ARRAY_OID, parseTextArray],
+ [PG_VARCHAR_OID, identity],
+]);
+
+/**
+ * Query-scoped PostgreSQL parsers for River's exact persisted values.
+ *
+ * This object delegates unknown OIDs to node-postgres and never mutates its
+ * process-global parser registry. It is safe to use with caller-owned pools.
+ */
+export const PG_EXACT_TYPES: CustomTypesConfig = {
+ getTypeParser(oid, format = "text") {
+ const numericOid: number = oid;
+ if (numericOid === PG_INT8_OID) {
+ return format === "binary" ? parseBinaryInt8 : parseTextInt8;
+ }
+ if (numericOid === PG_TIMESTAMPTZ_OID) {
+ return format === "binary"
+ ? parseBinaryTimestamptz
+ : parseTextTimestamptz;
+ }
+ if (
+ format === "text" &&
+ (numericOid === PG_JSON_OID || numericOid === PG_JSONB_OID)
+ ) {
+ return parseJson;
+ }
+ // River's only array of JSON is a job's `errors`, whose elements it
+ // decodes from their text like River for Go, so they're left as text.
+ if (
+ format === "text" &&
+ (numericOid === PG_JSON_ARRAY_OID || numericOid === PG_JSONB_ARRAY_OID)
+ ) {
+ return parseTextArray;
+ }
+ const parser = format === "text" ? TEXT_PARSERS.get(numericOid) : undefined;
+ if (parser !== undefined) return parser;
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-return -- node-postgres types every parser's result as any
+ return defaultPgTypes.getTypeParser(oid, format);
+ },
+};
+
+function parseBinaryInt8(value: Buffer): bigint {
+ if (value.byteLength !== 8) {
+ throw new RangeError(
+ `invalid PostgreSQL int8 binary length: ${value.byteLength}`
+ );
+ }
+ return value.readBigInt64BE();
+}
+
+function parseBinaryTimestamptz(value: Buffer): Temporal.Instant {
+ const postgresMicroseconds = parseBinaryInt8(value);
+ if (
+ postgresMicroseconds === 9_223_372_036_854_775_807n ||
+ postgresMicroseconds === -9_223_372_036_854_775_808n
+ ) {
+ throw new RangeError(
+ "PostgreSQL infinite timestamps are not River instants"
+ );
+ }
+
+ const unixNanoseconds =
+ (postgresMicroseconds + PG_EPOCH_UNIX_MICROSECONDS) * 1_000n;
+ return Temporal.Instant.fromEpochNanoseconds(unixNanoseconds);
+}
+
+function identity(value: string): string {
+ return value;
+}
+
+function parseTextArray(value: string): string[] {
+ return parsePostgresArray(value, identity);
+}
+
+function parseTextBool(value: string): boolean {
+ if (value === "t") return true;
+ if (value === "f") return false;
+ throw new RangeError(
+ `invalid PostgreSQL bool text: ${JSON.stringify(value)}`
+ );
+}
+
+/** Decode `bytea` in either `bytea_output` format, `hex` or `escape`. */
+function parseTextBytea(value: string): Buffer {
+ if (value.startsWith("\\x")) {
+ const hex = value.slice(2);
+ if (!/^(?:[0-9a-fA-F]{2})*$/.test(hex)) {
+ throw new RangeError("invalid PostgreSQL bytea hex text");
+ }
+ return Buffer.from(hex, "hex");
+ }
+ const bytes: number[] = [];
+ for (let index = 0; index < value.length; index++) {
+ const character = value[index];
+ if (character !== "\\") {
+ const code = value.charCodeAt(index);
+ if (code > 0xff) throw new RangeError("invalid PostgreSQL bytea text");
+ bytes.push(code);
+ } else if (value[index + 1] === "\\") {
+ bytes.push(0x5c);
+ index++;
+ } else {
+ const octal = value.slice(index + 1, index + 4);
+ if (!/^[0-3][0-7]{2}$/.test(octal)) {
+ throw new RangeError("invalid PostgreSQL bytea escape text");
+ }
+ bytes.push(Number.parseInt(octal, 8));
+ index += 3;
+ }
+ }
+ return Buffer.from(bytes);
+}
+
+function parseTextInt4(value: string): number {
+ if (!/^-?(0|[1-9]\d*)$/.test(value)) {
+ throw new RangeError(
+ `invalid PostgreSQL integer text: ${JSON.stringify(value)}`
+ );
+ }
+ return Number.parseInt(value, 10);
+}
+
+function parseTextInt8(value: string): bigint {
+ if (!/^-?(0|[1-9]\d*)$/.test(value)) {
+ throw new RangeError(
+ `invalid PostgreSQL int8 text: ${JSON.stringify(value)}`
+ );
+ }
+ return BigInt(value);
+}
+
+function parseTextTimestamptz(value: string): Temporal.Instant {
+ if (value === "infinity" || value === "-infinity") {
+ throw new RangeError(
+ "PostgreSQL infinite timestamps are not River instants"
+ );
+ }
+
+ const match =
+ /^(\d{4,6})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(\.\d{1,9})?([+-]\d{2}(?::?\d{2}(?::?\d{2})?)?)( BC)?$/.exec(
+ value
+ );
+ if (match === null) {
+ throw new RangeError(
+ `invalid PostgreSQL timestamptz text: ${JSON.stringify(value)}`
+ );
+ }
+
+ const [
+ ,
+ pgYear,
+ month,
+ day,
+ hour,
+ minute,
+ second,
+ fraction = "",
+ rawOffset,
+ bc,
+ ] = match;
+ const year = bc === undefined ? pgYear : isoYearFromBc(pgYear as string);
+ const offset = normalizeOffset(rawOffset as string);
+
+ return Temporal.Instant.from(
+ `${year}-${month}-${day}T${hour}:${minute}:${second}${fraction}${offset}`
+ );
+}
+
+function isoYearFromBc(value: string): string {
+ const isoYear = 1 - Number.parseInt(value, 10);
+ if (isoYear >= 0) return isoYear.toString(10).padStart(4, "0");
+ return `-${Math.abs(isoYear).toString(10).padStart(6, "0")}`;
+}
+
+/**
+ * Write a PostgreSQL offset as `±HH:MM` or `±HH:MM:SS`. Historical local
+ * mean times, such as `+05:53:28`, have seconds.
+ */
+function normalizeOffset(value: string): string {
+ const digits = value.slice(1).replaceAll(":", "");
+ const parts = [digits.slice(0, 2), digits.slice(2, 4) || "00"];
+ if (digits.length > 4) parts.push(digits.slice(4));
+ return `${value[0] ?? "+"}${parts.join(":")}`;
+}
diff --git a/js/driver/pg/src/index.ts b/js/driver/pg/src/index.ts
new file mode 100644
index 000000000..e7df90648
--- /dev/null
+++ b/js/driver/pg/src/index.ts
@@ -0,0 +1,11 @@
+import type { ClientBase } from "pg";
+
+export { PgDriver } from "./driver.js";
+export type { PgDriverOptions } from "./types.js";
+
+declare module "riverqueue" {
+ interface RiverTransactionRegistry {
+ /** node-postgres clients (`pg.Client` or a pool's `PoolClient`). */
+ "@riverqueue/driver-pg": ClientBase;
+ }
+}
diff --git a/js/driver/pg/src/lease.ts b/js/driver/pg/src/lease.ts
new file mode 100644
index 000000000..cf8aceb15
--- /dev/null
+++ b/js/driver/pg/src/lease.ts
@@ -0,0 +1,106 @@
+/**
+ * Leased pool connections that fail fast once node-postgres reports a broken
+ * socket, plus the abort helper the driver's pooled operations share.
+ */
+import type { Notification as NodePgNotification, PoolClient } from "pg";
+
+/** A pool client with the listener overloads River attaches while leasing it. */
+export type PgListeningClient = PoolClient & {
+ off(event: "error", listener: (error: Error) => void): PgListeningClient;
+ off(
+ event: "notification",
+ listener: (message: NodePgNotification) => void
+ ): PgListeningClient;
+ on(event: "error", listener: (error: Error) => void): PgListeningClient;
+ on(
+ event: "notification",
+ listener: (message: NodePgNotification) => void
+ ): PgListeningClient;
+ once(event: "end", listener: () => void): PgListeningClient;
+};
+
+/**
+ * A pool client checked out for one operation. Once node-postgres reports a
+ * connection error, pending {@link PgClientLease.race} calls reject and the
+ * socket is destroyed instead of being returned to the pool.
+ */
+export class PgClientLease {
+ readonly client: PgListeningClient;
+ readonly #failure: Promise;
+ #failureError: Error | undefined;
+ #failureReject!: (error: Error) => void;
+ #released = false;
+
+ /** Lease `client`, calling `onFailure` once on its first connection error. */
+ constructor(client: PoolClient, onFailure?: (error: Error) => void) {
+ this.client = client;
+ this.#failure = new Promise((_resolve, reject) => {
+ this.#failureReject = reject;
+ });
+ void this.#failure.catch(() => undefined);
+ this.#onFailure = (error: Error) => {
+ if (this.#failureError !== undefined) return;
+ this.#failureError = error;
+ onFailure?.(error);
+ this.destroy();
+ this.#failureReject(error);
+ };
+ this.client.on("error", this.#onFailure);
+ }
+
+ /** Whether the connection has reported an error. */
+ get failed(): boolean {
+ return this.#failureError !== undefined;
+ }
+
+ /** Discard the connection instead of returning it to the pool. */
+ destroy(): void {
+ if (this.#released) return;
+ this.#released = true;
+ // Keep the listener through forced socket teardown: node-postgres may emit
+ // a second connection error after `release(true)`.
+ this.client.once("end", () => this.client.off("error", this.#onFailure));
+ this.client.release(true);
+ }
+
+ /** Settle with `operation`, or reject as soon as the connection fails. */
+ async race(operation: PromiseLike | T): Promise {
+ if (this.#failureError !== undefined) throw this.#failureError;
+ return Promise.race([Promise.resolve(operation), this.#failure]);
+ }
+
+ /** Return a healthy connection to the pool. */
+ release(): void {
+ if (this.#released) return;
+ this.#released = true;
+ this.client.off("error", this.#onFailure);
+ this.client.release();
+ }
+
+ readonly #onFailure: (error: Error) => void;
+}
+
+/**
+ * Settle with `promise`, or reject with `signal.reason` once the signal
+ * aborts. The underlying work keeps running; only the wait ends.
+ */
+export async function abortablePromise(
+ promise: Promise,
+ signal: AbortSignal | undefined
+): Promise {
+ if (signal === undefined) return promise;
+ if (signal.aborted) throw signal.reason;
+
+ let rejectAborted: ((reason: unknown) => void) | undefined;
+ const aborted = new Promise((_resolve, reject) => {
+ rejectAborted = reject;
+ });
+ const abort = (): void => rejectAborted?.(signal.reason);
+ signal.addEventListener("abort", abort, { once: true });
+
+ try {
+ return await Promise.race([promise, aborted]);
+ } finally {
+ signal.removeEventListener("abort", abort);
+ }
+}
diff --git a/js/driver/pg/src/pilot.integration.test.ts b/js/driver/pg/src/pilot.integration.test.ts
new file mode 100644
index 000000000..edafc720c
--- /dev/null
+++ b/js/driver/pg/src/pilot.integration.test.ts
@@ -0,0 +1,1857 @@
+import pg from "pg";
+import type { ClientBase } from "pg";
+import {
+ Client,
+ DatabaseOperationError,
+ defineJob,
+ ExtensionError,
+ JobCancelledError,
+ LifecycleError,
+ UnsupportedCapabilityError,
+ ValidationError,
+ Workers,
+ type ClientOptions,
+ type JobRow,
+ type WorkContext,
+} from "riverqueue";
+import {
+ createJobArgsTransformPlugin,
+ PilotClient,
+ type PreparedInsertParams,
+ type Pilot,
+ type PilotDatabase,
+ type PilotHost,
+} from "riverqueue/unstable-driver";
+import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
+
+import { PgDriver, testPgDriver } from "./driver.js";
+
+const TEST_DATABASE_URL =
+ process.env.TEST_DATABASE_URL ??
+ "postgres://localhost:5432/river_test?sslmode=disable";
+const prefix = `js_pilot_${Math.random().toString(36).slice(2, 10)}`;
+const companionTable = `${prefix}_companion`;
+
+class CompanionClient extends PilotClient {}
+
+describe("PostgreSQL pilot", () => {
+ let admin: pg.Pool;
+
+ beforeAll(async () => {
+ admin = new pg.Pool({ connectionString: TEST_DATABASE_URL });
+ await admin.query(
+ `CREATE TABLE ${companionTable} (id bigserial PRIMARY KEY, job_id bigint, note text NOT NULL)`
+ );
+ });
+
+ afterAll(async () => {
+ await admin.query(`DROP TABLE IF EXISTS ${companionTable}`);
+ await admin.end();
+ });
+
+ afterEach(async () => {
+ await admin.query(`DELETE FROM ${companionTable}`);
+ await admin.query("DELETE FROM river_job WHERE kind LIKE $1", [
+ `${prefix}%`,
+ ]);
+ await admin.query("DELETE FROM river_queue WHERE name LIKE $1", [
+ `${prefix}%`,
+ ]);
+ });
+
+ const job = defineJob({ kind: `${prefix}_job` });
+
+ const note = async (
+ tx: ClientBase,
+ text: string,
+ jobId: bigint | null = null
+ ): Promise => {
+ await tx.query(
+ `INSERT INTO ${companionTable} (job_id, note) VALUES ($1, $2)`,
+ [jobId?.toString(10) ?? null, text]
+ );
+ };
+
+ const notes = async (): Promise =>
+ (
+ await admin.query<{ note: string }>(
+ `SELECT note FROM ${companionTable} ORDER BY id`
+ )
+ ).rows.map((row) => row.note);
+
+ const jobCount = async (): Promise =>
+ Number(
+ (
+ await admin.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE kind LIKE $1",
+ [`${prefix}%`]
+ )
+ ).rows[0]?.count
+ );
+
+ const setup = (
+ createPilot: (
+ database: PilotDatabase
+ ) => Pilot = () => ({}),
+ options: ClientOptions = {},
+ pool: pg.Pool = admin
+ ) => {
+ let database: PilotDatabase | undefined;
+ let host: PilotHost | undefined;
+ const client = new CompanionClient(new PgDriver(pool), options, (db) => {
+ database = db;
+ const pilot = createPilot(db);
+ return {
+ ...pilot,
+ init(pilotHost) {
+ host = pilotHost;
+ },
+ };
+ });
+ return {
+ client,
+ database: database as unknown as PilotDatabase,
+ host: host as unknown as PilotHost,
+ };
+ };
+
+ it("commits and rolls back pilot transactions with River's writes", async () => {
+ const { client, database } = setup();
+
+ expect(database.backend).toBe("postgres");
+ await database.transaction(async (tx) => {
+ await note(tx, "committed");
+ await client.insert(job, {}, { tx });
+ });
+ await expect(
+ database.transaction(async (tx) => {
+ await note(tx, "rolled back");
+ await client.insert(job, {}, { tx });
+ throw new Error("companion failed");
+ })
+ ).rejects.toThrow("companion failed");
+ const controller = new AbortController();
+ await expect(
+ database.transaction(
+ async (tx) => {
+ await note(tx, "aborted");
+ controller.abort(new Error("stopped"));
+ },
+ { signal: controller.signal }
+ )
+ ).rejects.toThrow("stopped");
+
+ expect(await notes()).toEqual(["committed"]);
+ expect(await jobCount()).toBe(1);
+ });
+
+ it("runs directly in a caller's transaction and never ends it", async () => {
+ const { client, database } = setup();
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await note(tx, "application");
+ await database.transaction(
+ async (inner) => {
+ expect(inner).toBe(tx);
+ await note(inner, "kept");
+ },
+ { tx }
+ );
+ await expect(
+ database.transaction(
+ async (inner) => {
+ await client.insert(job, {}, { tx: inner });
+ await inner.query("SELECT 1/0");
+ },
+ { tx }
+ )
+ ).rejects.toThrow();
+ // Like River for Go, River opens no savepoint, so the failed
+ // statement aborts the caller's transaction, which can only roll
+ // back, along with its earlier work.
+ await expect(tx.query("SELECT 1")).rejects.toMatchObject({
+ code: "25P02",
+ });
+ await tx.query("ROLLBACK");
+ } finally {
+ tx.release();
+ }
+
+ expect(await notes()).toEqual([]);
+ expect(await jobCount()).toBe(0);
+ });
+
+ it("never runs the callback when no connection can be leased", async () => {
+ const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL, max: 1 });
+ try {
+ const { database } = setup(() => ({}), {}, pool);
+ const held = await pool.connect();
+ let calls = 0;
+ try {
+ const controller = new AbortController();
+ setTimeout(() => {
+ controller.abort(new Error("gave up"));
+ }, 20);
+ await expect(
+ database.transaction(
+ () => {
+ calls++;
+ },
+ { signal: controller.signal }
+ )
+ ).rejects.toThrow("gave up");
+ await expect(
+ database.connection(
+ () => {
+ calls++;
+ },
+ { signal: AbortSignal.abort(new Error("gave up")) }
+ )
+ ).rejects.toThrow("gave up");
+ } finally {
+ held.release();
+ }
+ expect(calls).toBe(0);
+ await expect(
+ database.connection(async (handle) =>
+ Number((await handle.query("SELECT 1 AS one")).rows[0].one)
+ )
+ ).resolves.toBe(1);
+ } finally {
+ await pool.end();
+ }
+ });
+
+ it("rolls back and rejects a connection callback that leaves a transaction open", async () => {
+ const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL, max: 1 });
+ try {
+ const { client, database } = setup(() => ({}), {}, pool);
+ await expect(
+ database.connection(async (handle) => {
+ await handle.query("BEGIN");
+ await note(handle, "leaked");
+ })
+ ).rejects.toMatchObject({ reason: "nested" });
+ await expect(
+ database.connection(async (handle) => {
+ await handle.query("BEGIN");
+ await handle.query("SELECT 1/0");
+ })
+ ).rejects.toThrow();
+ // The one pooled connection is usable and outside any transaction.
+ await client.insert(job, {});
+ await database.connection(async (handle) => {
+ await note(handle, "autocommit");
+ });
+ } finally {
+ await pool.end();
+ }
+
+ expect(await notes()).toEqual(["autocommit"]);
+ expect(await jobCount()).toBe(1);
+ });
+
+ it("claims and loads jobs in a pilot transaction", async () => {
+ const queue = `${prefix}_claim`;
+ const driver = testPgDriver(admin);
+ const { client, database } = setup();
+ await client.insertMany([
+ { args: {}, job, options: { queue } },
+ { args: {}, job, options: { queue } },
+ ]);
+ const claim = (tx: ClientBase) =>
+ driver.jobClaim(
+ {
+ attemptedBy: "pilot-client",
+ kinds: [],
+ queues: [{ limit: 2, name: queue }],
+ },
+ { tx }
+ );
+
+ await expect(
+ database.transaction(async (tx) => {
+ expect((await claim(tx)).jobs).toHaveLength(2);
+ throw new Error("roll the claim back");
+ })
+ ).rejects.toThrow("roll the claim back");
+ const states = await admin.query<{ state: string }>(
+ "SELECT state::text AS state FROM river_job WHERE queue = $1",
+ [queue]
+ );
+ expect(states.rows.map(({ state }) => state)).toEqual([
+ "available",
+ "available",
+ ]);
+
+ const [claimed, loaded] = await database.transaction(async (tx) => {
+ const result = await claim(tx);
+ const ids = result.jobs.map(({ id }) => id).reverse();
+ return [result, await database.loadClaimed(ids, { tx })] as const;
+ });
+ expect(loaded.jobs.map(({ id }) => id)).toEqual(
+ claimed.jobs.map(({ id }) => id).reverse()
+ );
+ expect(loaded.jobs.every(({ state }) => state === "running")).toBe(true);
+
+ const id = claimed.jobs[0]?.id ?? 0n;
+ await database.transaction(async (tx) => {
+ await expect(
+ database.loadClaimed([id, id], { tx })
+ ).rejects.toBeInstanceOf(ValidationError);
+ await expect(
+ database.loadClaimed([id + 1_000_000n], { tx })
+ ).rejects.toThrow("has no row");
+ });
+ });
+
+ it("sends notifications only when the transaction commits", async () => {
+ const { database } = setup();
+ const listener = new pg.Client({ connectionString: TEST_DATABASE_URL });
+ await listener.connect();
+ try {
+ const schema = (
+ await listener.query<{ schema: string }>(
+ "SELECT current_schema() AS schema"
+ )
+ ).rows[0]?.schema;
+ const received: string[] = [];
+ listener.on("notification", (message) => {
+ received.push(message.payload ?? "");
+ });
+ await listener.query(`LISTEN "${schema}.river_control"`);
+
+ await expect(
+ database.transaction(async (tx) => {
+ await database.notify("control", ['{"rolled":"back"}'], { tx });
+ throw new Error("rolled back");
+ })
+ ).rejects.toThrow("rolled back");
+ await database.transaction((tx) =>
+ database.notify("control", [`{"${prefix}":1}`], { tx })
+ );
+ await waitFor(() => received.length > 0);
+
+ expect(received).toEqual([`{"${prefix}":1}`]);
+ } finally {
+ await listener.end();
+ }
+ });
+
+ it("deletes finalized jobs by state cutoff and queue, lowest IDs first", async () => {
+ const { client, database } = setup();
+ const deleted1 = `${prefix}_deleted1`;
+ const deleted2 = `${prefix}_deleted2`;
+ const kept = `${prefix}_kept`;
+ const ids: bigint[] = [];
+ // Jobs in `kept` hold the lowest IDs, so a batch limiting candidates
+ // before filtering queues would select only them and stall.
+ for (const [queue, state, finalizedAt] of [
+ [kept, "cancelled", "1990-01-01T00:00:00Z"],
+ [kept, "discarded", "1990-01-01T00:00:00Z"],
+ [deleted1, "cancelled", "1990-01-01T00:00:00Z"],
+ [deleted2, "completed", "1990-01-01T00:00:00Z"],
+ [deleted1, "discarded", "1990-01-03T00:00:00Z"],
+ [deleted2, "available", null],
+ [deleted1, "discarded", "1990-01-01T00:00:00Z"],
+ [deleted2, "cancelled", "1990-01-01T00:00:00Z"],
+ ] as const) {
+ const { job: row } = await client.insert(job, {}, { queue });
+ await admin.query(
+ "UPDATE river_job SET state = $1, finalized_at = $2 WHERE id = $3",
+ [state, finalizedAt, row.id.toString(10)]
+ );
+ ids.push(row.id);
+ }
+ const cutoff = Temporal.Instant.from("1990-01-02T00:00:00Z");
+ const params = {
+ cancelledBefore: cutoff,
+ completedBefore: null,
+ discardedBefore: cutoff,
+ limit: 2,
+ // `kept` is in both lists; exclusion wins.
+ queuesExcluded: [kept],
+ queuesIncluded: [deleted1, deleted2, kept],
+ };
+ const remaining = async (): Promise =>
+ (
+ await admin.query<{ id: string }>(
+ "SELECT id FROM river_job WHERE kind = $1 ORDER BY id",
+ [job.kind]
+ )
+ ).rows.map((row) => BigInt(row.id));
+
+ expect(await database.deleteFinalizedJobs(params)).toBe(2);
+ expect(await remaining()).toEqual([
+ ids[0],
+ ids[1],
+ ids[3],
+ ids[4],
+ ids[5],
+ ids[7],
+ ]);
+ expect(
+ await database.deleteFinalizedJobs({ ...params, queuesIncluded: [] })
+ ).toBe(0);
+ expect(await database.deleteFinalizedJobs(params)).toBe(1);
+ expect(await database.deleteFinalizedJobs(params)).toBe(0);
+ expect(await remaining()).toEqual([ids[0], ids[1], ids[3], ids[4], ids[5]]);
+ });
+
+ it("deletes finalized jobs in a pilot transaction, rolling back with it", async () => {
+ const { client, database } = setup();
+ const queue = `${prefix}_cleaned`;
+ for (let index = 0; index < 3; index++) {
+ const { job: row } = await client.insert(job, {}, { queue });
+ await admin.query(
+ "UPDATE river_job SET state = 'completed', finalized_at = '1990-01-01T00:00:00Z' WHERE id = $1",
+ [row.id.toString(10)]
+ );
+ }
+ const params = {
+ cancelledBefore: null,
+ completedBefore: Temporal.Instant.from("1990-01-02T00:00:00Z"),
+ discardedBefore: null,
+ limit: 2,
+ queuesIncluded: [queue],
+ };
+
+ await expect(
+ database.transaction(async (tx) => {
+ expect(await database.deleteFinalizedJobs(params, { tx })).toBe(2);
+ throw new Error("rolled back");
+ })
+ ).rejects.toThrow("rolled back");
+ expect(await jobCount()).toBe(3);
+
+ expect(
+ await database.transaction((tx) =>
+ database.deleteFinalizedJobs(params, { tx })
+ )
+ ).toBe(2);
+ expect(await jobCount()).toBe(1);
+ });
+
+ it("leaves an interceptor's failed writes in the caller transaction until it rolls back", async () => {
+ let fail = true;
+ const { client } = setup(() => ({
+ intercept: {
+ async cancel(context, next) {
+ const row = await next();
+ await note(context.tx, "cancel", row?.id ?? null);
+ if (fail) throw new Error("cancel companion failed");
+ return row;
+ },
+ async insert(context, next) {
+ const results = await next();
+ await note(context.tx, "insert", results[0]?.job.id ?? null);
+ if (fail) throw new Error("insert companion failed");
+ return results;
+ },
+ async retry(context, next) {
+ const row = await next();
+ await note(context.tx, "retry", row?.id ?? null);
+ if (fail) throw new Error("retry companion failed");
+ return row;
+ },
+ },
+ }));
+ const notesIn = async (tx: ClientBase): Promise =>
+ (
+ await tx.query<{ note: string }>(
+ `SELECT note FROM ${companionTable} ORDER BY id`
+ )
+ ).rows.map((row) => row.note);
+ const stateIn = async (tx: ClientBase, id: bigint): Promise =>
+ (
+ await tx.query<{ state: string }>(
+ "SELECT state FROM river_job WHERE id = $1",
+ [id.toString(10)]
+ )
+ ).rows[0]?.state ?? "missing";
+
+ // Without a caller transaction, River's own transaction rolls the
+ // whole insertion back.
+ await expect(client.insert(job, {})).rejects.toThrow(
+ "insert companion failed"
+ );
+ expect(await notes()).toEqual([]);
+ expect(await jobCount()).toBe(0);
+
+ // In a caller's transaction River opens no savepoint, like River for
+ // Go: the failed insertions and their interceptor's writes stay in it,
+ // next to the caller's own work, until the caller rolls back.
+ let tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await note(tx, "application");
+ await expect(client.insert(job, {}, { tx })).rejects.toThrow(
+ "insert companion failed"
+ );
+ await expect(
+ client.insertMany(
+ [
+ { args: {}, job },
+ { args: {}, job },
+ ],
+ { tx }
+ )
+ ).rejects.toThrow("insert companion failed");
+ expect(await notesIn(tx)).toEqual(["application", "insert", "insert"]);
+ expect(
+ Number(
+ (
+ await tx.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE kind LIKE $1",
+ [`${prefix}%`]
+ )
+ ).rows[0]?.count
+ )
+ ).toBe(3);
+ await tx.query("ROLLBACK");
+ } finally {
+ tx.release();
+ }
+ expect(await notes()).toEqual([]);
+ expect(await jobCount()).toBe(0);
+
+ fail = false;
+ const available = await client.insert(job, {});
+ const scheduled = await client.insert(
+ job,
+ {},
+ { scheduledAt: Temporal.Now.instant().add({ hours: 1 }) }
+ );
+ await admin.query(`DELETE FROM ${companionTable}`);
+ fail = true;
+ tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await expect(
+ client.jobs.cancel(available.job.id, { tx })
+ ).rejects.toThrow("cancel companion failed");
+ await expect(client.jobs.retry(scheduled.job.id, { tx })).rejects.toThrow(
+ "retry companion failed"
+ );
+ expect(await stateIn(tx, available.job.id)).toBe("cancelled");
+ expect(await stateIn(tx, scheduled.job.id)).toBe("available");
+ expect(await notesIn(tx)).toEqual(["cancel", "retry"]);
+ await tx.query("ROLLBACK");
+ } finally {
+ tx.release();
+ }
+ expect((await client.jobs.get(available.job.id))?.state).toBe("available");
+ expect((await client.jobs.get(scheduled.job.id))?.state).toBe("scheduled");
+ expect(await notes()).toEqual([]);
+ });
+
+ it("intercepts a caller's transaction on a one-connection pool without deadlock", async () => {
+ const pool = new pg.Pool({ connectionString: TEST_DATABASE_URL, max: 1 });
+ try {
+ const { client } = setup(
+ () => ({
+ intercept: {
+ async cancel(context, next) {
+ const row = await next();
+ await note(context.tx, "cancel", row?.id ?? null);
+ return row;
+ },
+ async insert(context, next) {
+ const results = await next();
+ await note(context.tx, "insert");
+ return results;
+ },
+ async retry(context, next) {
+ const row = await next();
+ await note(context.tx, "retry", row?.id ?? null);
+ return row;
+ },
+ },
+ }),
+ {},
+ pool
+ );
+ const tx = await pool.connect();
+ try {
+ await tx.query("BEGIN");
+ const inserted = await client.insert(job, {}, { tx });
+ await client.jobs.cancel(inserted.job.id, { tx });
+ await client.jobs.retry(inserted.job.id, { tx });
+ await tx.query("COMMIT");
+ } finally {
+ tx.release();
+ }
+ } finally {
+ await pool.end();
+ }
+ expect(await notes()).toEqual(["insert", "cancel", "retry"]);
+ });
+
+ it("runs overlapping operations on one caller transaction in turn", async () => {
+ const { client } = setup(() => ({
+ intercept: {
+ async insert(context, next) {
+ const results = await next();
+ await note(context.tx, "insert");
+ return results;
+ },
+ },
+ }));
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ const results = await Promise.allSettled([
+ client.insert(job, { n: 1 }, { tx }),
+ client.insert(job, { n: 2 }, { tx }),
+ client.insertMany([{ args: { n: 3 }, job }], { tx }),
+ ]);
+ expect(results.map(({ status }) => status)).toEqual([
+ "fulfilled",
+ "fulfilled",
+ "fulfilled",
+ ]);
+ expect((await tx.query("COMMIT")).command).toBe("COMMIT");
+ } finally {
+ tx.release();
+ }
+
+ expect(await jobCount()).toBe(3);
+ expect(await notes()).toEqual(["insert", "insert", "insert"]);
+ });
+
+ it("queues ordinary operations on a caller transaction behind an intercepted one", async () => {
+ const reached = Promise.withResolvers();
+ const proceed = Promise.withResolvers();
+ let fail = false;
+ const { client } = setup(() => ({
+ intercept: {
+ async insert(_context, next) {
+ const results = await next();
+ if (fail) {
+ reached.resolve(undefined);
+ await proceed.promise;
+ throw new Error("insert companion failed");
+ }
+ return results;
+ },
+ },
+ }));
+ const other = await client.insert(job, {});
+ fail = true;
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ const failing = client.insert(job, {}, { tx });
+ await reached.promise;
+ const cancel = client.jobs.cancel(other.job.id, { tx });
+ // Without waiting its turn, the cancellation would run now,
+ // interleaved with the failing insertion's statements.
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ proceed.resolve(undefined);
+ await expect(failing).rejects.toThrow("insert companion failed");
+ await expect(cancel).resolves.toMatchObject({ state: "cancelled" });
+ await tx.query("COMMIT");
+ } finally {
+ tx.release();
+ }
+
+ expect((await client.jobs.get(other.job.id))?.state).toBe("cancelled");
+ });
+
+ it("intercepts background and transactional completion alike", async () => {
+ const queue = `${prefix}_complete`;
+ const txJob = defineJob({ kind: `${prefix}_tx_complete` });
+ const fabricateJob = defineJob({ kind: `${prefix}_fabricate` });
+ let fabricate = false;
+ let secondCompletion: unknown;
+ let fabricated: unknown;
+ const inTransaction = async (
+ run: (tx: ClientBase) => Promise
+ ): Promise => {
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await run(tx);
+ await tx.query("COMMIT");
+ } finally {
+ tx.release();
+ }
+ };
+ const { client } = setup(
+ () => ({
+ intercept: {
+ async complete(context, next) {
+ const results = await next();
+ for (const result of results) {
+ if (result.status === "applied" && result.job !== null) {
+ await note(context.tx, `completed ${result.job.kind}`);
+ }
+ }
+ return fabricate
+ ? results.map((result) => ({ ...result }))
+ : results;
+ },
+ },
+ }),
+ {
+ completionBatchSize: 1,
+ leaderElectionDisabled: true,
+ queues: {
+ [queue]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 5 },
+ },
+ },
+ workers: new Workers()
+ .add(job, () => undefined)
+ .add(txJob, async ({ completeTx }) => {
+ await inTransaction(async (tx) => {
+ await completeTx(tx);
+ secondCompletion = await completeTx(tx).catch(
+ (error: unknown) => error
+ );
+ });
+ })
+ .add(fabricateJob, async ({ completeTx }) => {
+ fabricate = true;
+ try {
+ await inTransaction(async (tx) => {
+ fabricated = await completeTx(tx).catch(
+ (error: unknown) => error
+ );
+ });
+ } finally {
+ fabricate = false;
+ }
+ }),
+ }
+ );
+ const run = await client.start();
+ try {
+ await client.insert(job, {}, { queue });
+ await client.insert(txJob, {}, { queue });
+ await client.insert(fabricateJob, {}, { queue });
+ await waitFor(async () => {
+ const result = await admin.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE queue = $1 AND state = 'completed'",
+ [queue]
+ );
+ return Number(result.rows[0]?.count) === 3;
+ }, 10_000);
+ } finally {
+ await run.stop();
+ }
+
+ expect(secondCompletion).toBeInstanceOf(LifecycleError);
+ expect(fabricated).toBeInstanceOf(ExtensionError);
+ expect(await notes()).toEqual([
+ `completed ${prefix}_job`,
+ `completed ${prefix}_tx_complete`,
+ `completed ${prefix}_fabricate`,
+ ]);
+ });
+
+ it("inserts prepared rows like an ordinary insertion of them", async () => {
+ const calls: string[] = [];
+ const { host } = setup(
+ () => ({
+ intercept: {
+ insert(context, next) {
+ calls.push(`pilot ${context.operation}`);
+ return next();
+ },
+ },
+ }),
+ {
+ insertMiddleware: [
+ (_context, next) => {
+ calls.push("middleware");
+ return next();
+ },
+ ],
+ plugins: [
+ createJobArgsTransformPlugin({
+ name: "args",
+ onInsert: ({ args, encodedArgs }) => {
+ calls.push(`args transform ${encodedArgs}`);
+ return { args, encodedArgs };
+ },
+ onRead: ({ args }) => args,
+ }),
+ ],
+ }
+ );
+ const createdAt = Temporal.Instant.from("2020-01-02T03:04:05.678901Z");
+ const uniqueKey = new Uint8Array(32).fill(9);
+ const prepared: PreparedInsertParams = {
+ createdAt,
+ encodedArgs: '{"stored":"encoded"}',
+ kind: `${prefix}_stored`,
+ maxAttempts: 3,
+ metadata: { stored: true },
+ priority: 2,
+ queue: "default",
+ scheduledAt: createdAt,
+ state: "available",
+ tags: ["kept"],
+ uniqueKey,
+ uniqueStates: ["available", "running"],
+ };
+
+ const [result] = await host.insertPrepared([prepared]);
+
+ expect(calls).toEqual([
+ 'args transform {"stored":"encoded"}',
+ "middleware",
+ "pilot insertMany",
+ ]);
+ expect(result?.status).toBe("inserted");
+ const row = (
+ await admin.query<{
+ args: unknown;
+ created_us: string;
+ metadata: unknown;
+ unique_key: Buffer;
+ }>(
+ `SELECT args, (extract(epoch FROM created_at) * 1000000)::bigint::text AS created_us,
+ metadata, unique_key
+ FROM river_job WHERE kind = $1`,
+ [`${prefix}_stored`]
+ )
+ ).rows[0];
+ expect(row?.args).toEqual({ stored: "encoded" });
+ expect(row?.metadata).toEqual({ stored: true });
+ expect(new Uint8Array(row?.unique_key ?? [])).toEqual(uniqueKey);
+ expect(row?.created_us).toBe(
+ (createdAt.epochNanoseconds / 1000n).toString()
+ );
+ const [duplicate] = await host.insertPrepared([prepared]);
+ expect(duplicate?.status).toBe("duplicate");
+
+ // Arguments that aren't a JSON object, as another River client may
+ // store, go back in as they are.
+ const [array] = await host.insertPrepared([
+ {
+ ...prepared,
+ encodedArgs: '[1,"a"]',
+ kind: `${prefix}_array`,
+ uniqueKey: null,
+ uniqueStates: null,
+ },
+ ]);
+ expect(array?.status).toBe("inserted");
+ const stored = await admin.query<{ args: unknown }>(
+ "SELECT args FROM river_job WHERE kind = $1",
+ [`${prefix}_array`]
+ );
+ expect(stored.rows[0]?.args).toEqual([1, "a"]);
+ });
+
+ it("rescues in a pilot transaction, fenced by the leader", async () => {
+ const queue = `${prefix}_rescue`;
+ const driver = testPgDriver(admin);
+ const { client, database } = setup();
+ const inserted = await client.insert(job, {}, { queue });
+ await driver.jobClaim({
+ attemptedBy: "stuck-client",
+ kinds: [],
+ queues: [{ limit: 1, name: queue }],
+ });
+ await admin.query("DELETE FROM river_leader");
+ const now = Temporal.Now.instant();
+ const leader = await driver.maintenanceLeaderAcquire(
+ `${prefix}_leader`,
+ now,
+ 30_000,
+ null
+ );
+ if (leader === null) throw new Error("expected leadership");
+ try {
+ const rescue = {
+ error: { at: now, attempt: 1, error: "stuck", trace: "" },
+ finalizedAt: null,
+ id: inserted.job.id,
+ scheduledAt: now,
+ state: "retryable" as const,
+ };
+ const before = Temporal.Now.instant().add({ seconds: 1 });
+
+ await expect(
+ database.transaction(async (tx) => {
+ expect(
+ await driver.maintenanceRescue(leader, before, [rescue], { tx })
+ ).toBe(1);
+ throw new Error("roll the rescue back");
+ })
+ ).rejects.toThrow("roll the rescue back");
+ expect((await client.jobs.get(inserted.job.id))?.state).toBe("running");
+ await database.transaction((tx) =>
+ driver.maintenanceRescue(leader, before, [rescue], { tx })
+ );
+ expect((await client.jobs.get(inserted.job.id))?.state).toBe("retryable");
+ } finally {
+ await driver.maintenanceLeaderResign(leader);
+ }
+ });
+
+ it("runs producer sessions and services through PostgreSQL transactions", async () => {
+ const own = `${prefix}_own_claim`;
+ const standard = `${prefix}_standard_claim`;
+ const finished = new Map();
+ const log: string[] = [];
+ const { client } = setup(
+ () => ({
+ maintenanceServices: () => [
+ {
+ name: "term",
+ run: ({ signal, term }) =>
+ new Promise((resolve) => {
+ log.push(`term started ${term.leaderId}`);
+ signal.addEventListener("abort", () => {
+ log.push("term ended");
+ resolve();
+ });
+ }),
+ },
+ ],
+ services: () => [
+ {
+ name: "runtime",
+ run: ({ signal }) =>
+ new Promise((resolve) => {
+ log.push("service started");
+ signal.addEventListener("abort", () => {
+ log.push("service ended");
+ resolve();
+ });
+ }),
+ },
+ ],
+ startProducer: (context) =>
+ Promise.resolve({
+ async claim(claim, next) {
+ if (claim.queue === standard) {
+ return claim.database.transaction(async (tx) => {
+ const result = await next({ tx });
+ for (const row of result.jobs) {
+ await note(tx, `claimed ${claim.queue}`, row.id);
+ }
+ return result;
+ });
+ }
+ // Claim at most two jobs in the session's own transaction
+ // and load the rows River works.
+ return claim.database.transaction(async (tx) => {
+ const claimed = await tx.query<{ id: string }>(
+ `UPDATE river_job
+ SET attempt = attempt + 1,
+ attempted_at = now(),
+ attempted_by = array_append(attempted_by, $1),
+ state = 'running'
+ WHERE id IN (
+ SELECT id FROM river_job
+ WHERE queue = $2 AND state = 'available'
+ AND scheduled_at <= now()
+ ORDER BY priority, scheduled_at, id
+ LIMIT $3
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING id`,
+ [claim.attemptedBy, claim.queue, Math.min(claim.limit, 2)]
+ );
+ const ids = claimed.rows.map(({ id }) => BigInt(id));
+ for (const id of ids) {
+ await note(tx, `claimed ${claim.queue}`, id);
+ }
+ return claim.database.loadClaimed(ids, { tx });
+ });
+ },
+ jobFinished(row) {
+ finished.set(row.id, (finished.get(row.id) ?? 0) + 1);
+ },
+ shutdown() {
+ log.push(`shutdown ${context.queue.name}`);
+ return Promise.resolve();
+ },
+ }),
+ }),
+ {
+ clientId: `${prefix}_sessions`,
+ maintenance: { electionInterval: { milliseconds: 50 } },
+ queues: {
+ [own]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 5,
+ pollInterval: { milliseconds: 20 },
+ },
+ [standard]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 5,
+ pollInterval: { milliseconds: 20 },
+ },
+ },
+ workers: new Workers().add(job, () => undefined),
+ }
+ );
+ const ids: bigint[] = [];
+ for (let index = 0; index < 10; index++) {
+ const inserted = await client.insert(
+ job,
+ {},
+ { queue: index % 2 === 0 ? own : standard }
+ );
+ ids.push(inserted.job.id);
+ }
+
+ const run = await client.start();
+ try {
+ await waitFor(async () => {
+ const result = await admin.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE queue = ANY($1) AND state = 'completed'",
+ [[own, standard]]
+ );
+ return Number(result.rows[0]?.count) === 10;
+ }, 10_000);
+ await waitFor(
+ () => log.includes(`term started ${prefix}_sessions`),
+ 10_000
+ );
+ } finally {
+ await run.stop();
+ }
+
+ expect([...finished.keys()].toSorted((a, b) => (a < b ? -1 : 1))).toEqual(
+ ids
+ );
+ expect([...finished.values()].every((count) => count === 1)).toBe(true);
+ expect((await notes()).toSorted()).toEqual(
+ [
+ ...Array.from({ length: 5 }, () => `claimed ${own}`),
+ ...Array.from({ length: 5 }, () => `claimed ${standard}`),
+ ].toSorted()
+ );
+ expect(log.slice(0, 1)).toEqual(["service started"]);
+ expect(log).toEqual(
+ expect.arrayContaining([
+ "term ended",
+ `shutdown ${own}`,
+ `shutdown ${standard}`,
+ "service ended",
+ ])
+ );
+ expect(log.at(-1)).toBe("service ended");
+ });
+
+ it("claims peers in a pilot transaction and completes them through the pilot", async () => {
+ const coordinatorQueue = `${prefix}_coordinator`;
+ const peerQueue = `${prefix}_peers`;
+ const rejected: string[] = [];
+ /** Claim every available job of the peer queue in `tx`. */
+ const claimPeers = async (tx: ClientBase, attemptedBy: string) => {
+ const claimed = await tx.query<{ id: string }>(
+ `UPDATE river_job
+ SET attempt = attempt + 1, attempted_at = now(),
+ attempted_by = array_append(attempted_by, $1), state = 'running'
+ WHERE id IN (
+ SELECT id FROM river_job
+ WHERE queue = $2 AND state = 'available'
+ ORDER BY id
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING id`,
+ [attemptedBy, peerQueue]
+ );
+ await note(tx, `claimed ${claimed.rows.length}`);
+ return claimed.rows
+ .map(({ id }) => BigInt(id))
+ .toSorted((left, right) => (left < right ? -1 : 1));
+ };
+ const { client, database, host } = setup(
+ () => ({
+ intercept: {
+ async complete(context, next) {
+ const results = await next();
+ for (const result of results) {
+ if (result.job?.queue === peerQueue) {
+ await note(context.tx, "completed peer", result.job.id);
+ }
+ }
+ return results;
+ },
+ },
+ }),
+ {
+ clientId: `${prefix}_peers`,
+ leaderElectionDisabled: true,
+ queues: {
+ [coordinatorQueue]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 20 },
+ },
+ },
+ workers: new Workers().add(job, async (context) => {
+ const peerAttempts = pilot.host.attempts;
+ const peerDatabase = pilot.database;
+ // A claim returning another client's job rolls back entirely.
+ await peerAttempts
+ .claim(context, async ({ tx }) =>
+ peerDatabase.loadClaimed(
+ await claimPeers(tx, `${prefix}_someone_else`),
+ { tx }
+ )
+ )
+ .catch((error: unknown) => {
+ expect(error).toBeInstanceOf(ExtensionError);
+ rejected.push((error as Error).message);
+ });
+ const peers = await peerAttempts.claim(context, async ({ tx }) =>
+ peerDatabase.loadClaimed(
+ await claimPeers(tx, context.execution.attemptedBy),
+ { tx }
+ )
+ );
+ // The attempt completes the first peer only.
+ await peerAttempts.complete(context, [
+ { job: peers[0] as JobRow, result: { status: "succeeded" } },
+ ]);
+ }),
+ }
+ );
+ // The worker reaches the pilot's host once the client is constructed.
+ const pilot = { database, host };
+ const first = await client.insert(job, {}, { queue: peerQueue });
+ const second = await client.insert(job, {}, { queue: peerQueue });
+ const coordinator = await client.insert(
+ job,
+ {},
+ { queue: coordinatorQueue }
+ );
+
+ const run = await client.start();
+ try {
+ await waitFor(async () => {
+ const result = await admin.query<{ state: string }>(
+ "SELECT state FROM river_job WHERE id = $1",
+ [coordinator.job.id.toString(10)]
+ );
+ return result.rows[0]?.state === "completed";
+ }, 4_000);
+ } finally {
+ await run.stop();
+ }
+
+ expect(rejected).toEqual([
+ `a peer claim returned job ${first.job.id}, which another client claimed`,
+ ]);
+ // The rolled-back claim left no note.
+ expect(await notes()).toEqual([
+ "claimed 2",
+ "completed peer",
+ "completed peer",
+ ]);
+ const { rows } = await admin.query<{
+ attempt: number;
+ errors: unknown[] | null;
+ id: string;
+ state: string;
+ }>(
+ "SELECT attempt, errors, id, state FROM river_job WHERE id = ANY($1) ORDER BY id",
+ [[first.job.id.toString(10), second.job.id.toString(10)]]
+ );
+ expect(rows.map(({ attempt, state }) => ({ attempt, state }))).toEqual([
+ { attempt: 1, state: "completed" },
+ { attempt: 1, state: "available" },
+ ]);
+ expect(JSON.stringify(rows[1]?.errors)).toContain(
+ "ended without an outcome for this job"
+ );
+ });
+
+ describe("peer claims while stopping", () => {
+ const coordinatorQueue = `${prefix}_stop_coordinator`;
+ const peerQueue = `${prefix}_stop_peers`;
+
+ /** Claim every available job of the peer queue for `attemptedBy`, in `tx`. */
+ const claimAllPeers = async (
+ database: PilotDatabase,
+ tx: ClientBase,
+ attemptedBy: string
+ ) => {
+ const claimed = await tx.query<{ id: string }>(
+ `UPDATE river_job
+ SET attempt = attempt + 1, attempted_at = now(),
+ attempted_by = array_append(attempted_by, $1), state = 'running'
+ WHERE queue = $2 AND state = 'available'
+ RETURNING id`,
+ [attemptedBy, peerQueue]
+ );
+ return database.loadClaimed(
+ claimed.rows.map(({ id }) => BigInt(id)),
+ { tx }
+ );
+ };
+
+ /** A client working `coordinatorQueue` with `work`, and its pilot. */
+ const setupCoordinator = (
+ work: (
+ context: WorkContext,
+ pilot: {
+ readonly database: PilotDatabase;
+ readonly host: PilotHost;
+ }
+ ) => Promise
+ ) => {
+ const { client, database, host } = setup(() => ({}), {
+ clientId: `${prefix}_stop_peers`,
+ leaderElectionDisabled: true,
+ queues: {
+ [coordinatorQueue]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 20 },
+ },
+ },
+ workers: new Workers().add(job, (context) => work(context, pilot)),
+ });
+ // The worker reaches the pilot once the client is constructed.
+ const pilot = { database, host };
+ return client;
+ };
+
+ const peerRows = async () =>
+ (
+ await admin.query<{ attempt: number; queue: string; state: string }>(
+ "SELECT attempt, queue, state FROM river_job WHERE queue = ANY($1) ORDER BY id",
+ [[peerQueue, coordinatorQueue]]
+ )
+ ).rows;
+
+ it("keeps a coordinator's peer claims open through a graceful stop", async () => {
+ const running = Promise.withResolvers();
+ const gate = Promise.withResolvers();
+ const client = setupCoordinator(async (context, pilot) => {
+ running.resolve(undefined);
+ await gate.promise;
+ const peers = await pilot.host.attempts.claim(context, async ({ tx }) =>
+ claimAllPeers(pilot.database, tx, context.execution.attemptedBy)
+ );
+ await pilot.host.attempts.complete(
+ context,
+ peers.map((peer) => ({ job: peer, result: { status: "succeeded" } }))
+ );
+ });
+ await client.insert(job, {}, { queue: peerQueue });
+ await client.insert(job, {}, { queue: peerQueue });
+ await client.insert(job, {}, { queue: coordinatorQueue });
+
+ const run = await client.start();
+ await running.promise;
+ // The producer stops claiming at once; only then does the coordinator
+ // claim its peers.
+ const stopping = run.stop();
+ gate.resolve(undefined);
+ await stopping;
+
+ // The stop resolved only after the peers and the coordinator persisted.
+ expect(await peerRows()).toEqual([
+ { attempt: 1, queue: peerQueue, state: "completed" },
+ { attempt: 1, queue: peerQueue, state: "completed" },
+ { attempt: 1, queue: coordinatorQueue, state: "completed" },
+ ]);
+ });
+
+ it("refuses peer claims once a stop or cancellation cancels the coordinator", async () => {
+ for (const cancellation of ["cancelling stop", "job cancellation"]) {
+ const running = Promise.withResolvers();
+ const refused = Promise.withResolvers();
+ const client = setupCoordinator(async (context, pilot) => {
+ running.resolve(undefined);
+ await new Promise((resolve) => {
+ context.signal.addEventListener("abort", resolve, { once: true });
+ });
+ refused.resolve(
+ await pilot.host.attempts
+ .claim(context, async ({ tx }) =>
+ claimAllPeers(pilot.database, tx, context.execution.attemptedBy)
+ )
+ .then(
+ () => undefined,
+ (error: unknown) => error
+ )
+ );
+ });
+ await client.insert(job, {}, { queue: peerQueue });
+ const coordinator = await client.insert(
+ job,
+ {},
+ { queue: coordinatorQueue }
+ );
+
+ const run = await client.start();
+ try {
+ await running.promise;
+ if (cancellation === "cancelling stop") {
+ await run.stop({ mode: "cancel" });
+ await expect(refused.promise).resolves.toBeInstanceOf(
+ LifecycleError
+ );
+ } else {
+ await client.jobs.cancel(coordinator.job.id);
+ await expect(refused.promise).resolves.toBeInstanceOf(
+ JobCancelledError
+ );
+ }
+ } finally {
+ await run.stop();
+ }
+
+ // The refused claim left the peer untouched.
+ expect(
+ (await peerRows()).filter(({ queue }) => queue === peerQueue)
+ ).toEqual([{ attempt: 0, queue: peerQueue, state: "available" }]);
+ await admin.query("DELETE FROM river_job WHERE queue = ANY($1)", [
+ [peerQueue, coordinatorQueue],
+ ]);
+ }
+ });
+
+ it("settles each peer's own outcome after the coordinator is cancelled remotely", async () => {
+ const running = Promise.withResolvers();
+ const client = setupCoordinator(async (context, pilot) => {
+ const peers = await pilot.host.attempts.claim(context, async ({ tx }) =>
+ claimAllPeers(pilot.database, tx, context.execution.attemptedBy)
+ );
+ const cancelled = new Promise((resolve) => {
+ context.signal.addEventListener("abort", resolve, { once: true });
+ });
+ running.resolve(undefined);
+ await cancelled;
+ // Like River for Go, the cancellation applies to the coordinator
+ // alone; each peer keeps its own outcome.
+ await pilot.host.attempts.complete(context, [
+ { job: peers[0] as JobRow, result: { status: "succeeded" } },
+ {
+ job: peers[1] as JobRow,
+ result: { error: new Error("peer failed"), status: "failed" },
+ },
+ ]);
+ context.signal.throwIfAborted();
+ });
+ await client.insert(job, {}, { queue: peerQueue });
+ await client.insert(job, {}, { queue: peerQueue });
+ const coordinator = await client.insert(
+ job,
+ {},
+ { queue: coordinatorQueue }
+ );
+
+ const run = await client.start();
+ try {
+ await running.promise;
+ await client.jobs.cancel(coordinator.job.id);
+ await waitFor(
+ async () => (await peerRows()).at(-1)?.state === "cancelled",
+ 4_000
+ );
+ } finally {
+ await run.stop();
+ }
+
+ const rows = await peerRows();
+ expect(rows.map(({ attempt, state }) => ({ attempt, state }))).toEqual([
+ { attempt: 1, state: "completed" },
+ { attempt: 1, state: expect.stringMatching(/^(available|retryable)$/) },
+ { attempt: 1, state: "cancelled" },
+ ]);
+ });
+ });
+
+ it("cancels a job whose cancellation arrives while its claim is in flight", async () => {
+ const queue = `${prefix}_claim_cancel`;
+ const claimed = Promise.withResolvers();
+ const release = Promise.withResolvers();
+ // Like River for Go, the worker starts with its cancellation applied.
+ let startedCancelled: boolean | undefined;
+ const { client } = setup(
+ (database) => ({
+ startProducer: () =>
+ Promise.resolve({
+ claim: async (_context, next) => {
+ const result = await database.transaction((tx) => next({ tx }));
+ if (result.jobs.length > 0) {
+ // Committed, but not yet handed to River.
+ claimed.resolve(undefined);
+ await release.promise;
+ }
+ return result;
+ },
+ }),
+ }),
+ {
+ clientId: `${prefix}_claim_cancel`,
+ leaderElectionDisabled: true,
+ queues: {
+ [queue]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 20 },
+ },
+ },
+ workers: new Workers().add(job, ({ signal }) => {
+ startedCancelled = signal.aborted;
+ signal.throwIfAborted();
+ }),
+ }
+ );
+ const inserted = await client.insert(job, {}, { queue });
+
+ const run = await client.start();
+ try {
+ await claimed.promise;
+ await client.jobs.cancel(inserted.job.id);
+ release.resolve(undefined);
+ await waitFor(async () => {
+ const result = await admin.query<{ state: string }>(
+ "SELECT state FROM river_job WHERE id = $1",
+ [inserted.job.id.toString(10)]
+ );
+ return result.rows[0]?.state === "cancelled";
+ }, 4_000);
+ } finally {
+ release.resolve(undefined);
+ await run.stop();
+ }
+
+ expect(startedCancelled).toBe(true);
+ });
+
+ it("hands a producer session its queue metadata as stored", async () => {
+ const queue = `${prefix}_metadata_text`;
+ const texts: string[] = [];
+ await admin.query(
+ `INSERT INTO river_queue (name, metadata, created_at, updated_at)
+ VALUES ($1, '{"retries": 1.0, "scale": 1e2}'::jsonb, now(), now())`,
+ [queue]
+ );
+ const { client } = setup(
+ () => ({
+ startProducer: (context) => {
+ texts.push(context.metadataText);
+ return Promise.resolve({});
+ },
+ }),
+ {
+ clientId: `${prefix}_metadata_text`,
+ leaderElectionDisabled: true,
+ queues: { [queue]: { maxWorkers: 1 } },
+ workers: new Workers().add(job, () => undefined),
+ }
+ );
+
+ const run = await client.start();
+ await run.stop();
+
+ // PostgreSQL's rendering of the stored JSONB, which keeps `1.0` and
+ // orders keys by length.
+ expect(texts).toEqual(['{"scale": 100, "retries": 1.0}']);
+ });
+
+ it("offers a queue's metadata change as its notification arrives", async () => {
+ const queue = `${prefix}_metadata_notification`;
+ const texts: string[] = [];
+ const { client } = setup(
+ () => ({
+ startProducer: () =>
+ Promise.resolve({
+ configurationChanged: (configuration) => {
+ texts.push(configuration.metadataText);
+ },
+ }),
+ }),
+ {
+ clientId: `${prefix}_metadata_notification`,
+ leaderElectionDisabled: true,
+ // Only the notification can deliver the change in time.
+ queueControlPollInterval: { hours: 1 },
+ queues: { [queue]: { maxWorkers: 1 } },
+ workers: new Workers().add(job, () => undefined),
+ }
+ );
+
+ const run = await client.start();
+ try {
+ // Another client changes the queue's metadata.
+ await new Client(new PgDriver(admin)).queues.update(queue, {
+ metadata: { retries: 2 },
+ });
+ await waitFor(() => texts.length === 1, 4_000);
+ } finally {
+ await run.stop();
+ }
+
+ expect(texts).toEqual(['{"retries": 2}']);
+ });
+
+ it("requires a pool-backed driver", async () => {
+ const single = new pg.Client({ connectionString: TEST_DATABASE_URL });
+ expect(
+ () => new CompanionClient(new PgDriver(single), {}, () => ({}))
+ ).toThrow(UnsupportedCapabilityError);
+ expect(new Client(new PgDriver(single))).toBeInstanceOf(Client);
+ });
+
+ it("shows the insert interceptor each row's arguments from before the argument transforms", async () => {
+ const seen: { original: readonly string[]; stored: readonly string[] }[] =
+ [];
+ const { client, host } = setup(
+ () => ({
+ intercept: {
+ async insert(context, next) {
+ seen.push({
+ original: context.originalEncodedArgs,
+ stored: context.params.map(({ encodedArgs }) => encodedArgs),
+ });
+ return next();
+ },
+ },
+ }),
+ {
+ plugins: [
+ createJobArgsTransformPlugin({
+ name: "wrap",
+ // Rewrites the arguments, as an encrypting transform would.
+ onInsert: ({ encodedArgs }) => ({
+ args: { wrapped: encodedArgs },
+ encodedArgs: JSON.stringify({ wrapped: encodedArgs }),
+ }),
+ onRead: ({ args }) =>
+ JSON.parse(args.wrapped as string) as Record,
+ }),
+ ],
+ }
+ );
+ const wrapped = (encodedArgs: string) =>
+ JSON.stringify({ wrapped: encodedArgs });
+
+ await client.insert(job, { n: 1 });
+ await client.insertMany([
+ { args: { n: 2 }, job },
+ { args: { n: 3 }, job },
+ ]);
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await client.insert(job, { n: 4 }, { tx });
+ await tx.query("COMMIT");
+ } finally {
+ tx.release();
+ }
+ await host.insertPrepared([
+ {
+ encodedArgs: '{"n":5}',
+ kind: job.kind,
+ maxAttempts: 25,
+ metadata: {},
+ priority: 1,
+ queue: "default",
+ state: "available",
+ tags: [],
+ uniqueKey: null,
+ uniqueStates: null,
+ },
+ ]);
+
+ expect(seen).toEqual([
+ { original: ['{"n":1}'], stored: [wrapped('{"n":1}')] },
+ {
+ original: ['{"n":2}', '{"n":3}'],
+ stored: [wrapped('{"n":2}'), wrapped('{"n":3}')],
+ },
+ { original: ['{"n":4}'], stored: [wrapped('{"n":4}')] },
+ { original: ['{"n":5}'], stored: [wrapped('{"n":5}')] },
+ ]);
+ // River stores the transformed arguments, as without an interceptor.
+ const stored = await admin.query<{ args: unknown }>(
+ "SELECT args FROM river_job WHERE kind = $1 ORDER BY id",
+ [job.kind]
+ );
+ expect(stored.rows.map(({ args }) => args)).toEqual(
+ [1, 2, 3, 4, 5].map((n) => ({ wrapped: `{"n":${n}}` }))
+ );
+ });
+
+ // Like River for Go and Rust, River opens no savepoint or nested
+ // transaction in a caller's transaction: an operation's statements run
+ // directly in it, and when the operation fails after writing, its writes
+ // stay there until the caller rolls back. Without a caller transaction,
+ // River's own transaction rolls the whole operation back.
+ describe("caller transactions", () => {
+ /** Run `callback` in a caller's transaction, then roll it back. */
+ const rolledBack = async (
+ callback: (tx: ClientBase) => Promise
+ ): Promise => {
+ const tx = await admin.connect();
+ try {
+ await tx.query("BEGIN");
+ await callback(tx);
+ } finally {
+ await tx.query("ROLLBACK");
+ tx.release();
+ }
+ };
+ const jobCountIn = async (tx: ClientBase): Promise =>
+ Number(
+ (
+ await tx.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE kind LIKE $1",
+ [`${prefix}%`]
+ )
+ ).rows[0]?.count
+ );
+
+ it("leaves an insertion that fails after its write in the caller transaction", async () => {
+ let failMiddleware = false;
+ let failHook = false;
+ const { client } = setup(() => ({}), {
+ hooks: {
+ afterInsert() {
+ if (failHook) throw new Error("hook failed");
+ },
+ },
+ insertMiddleware: [
+ async (_context, next) => {
+ const results = await next();
+ if (failMiddleware) throw new Error("middleware failed");
+ return results;
+ },
+ ],
+ });
+
+ failMiddleware = true;
+ await expect(client.insert(job, {})).rejects.toThrow("middleware failed");
+ failMiddleware = false;
+ failHook = true;
+ await expect(client.insert(job, {})).rejects.toThrow("hook failed");
+ expect(await jobCount()).toBe(0);
+
+ await rolledBack(async (tx) => {
+ failMiddleware = true;
+ failHook = false;
+ await expect(client.insert(job, {}, { tx })).rejects.toThrow(
+ "middleware failed"
+ );
+ failMiddleware = false;
+ failHook = true;
+ await expect(
+ client.insertMany(
+ [
+ { args: {}, job },
+ { args: {}, job },
+ ],
+ { tx }
+ )
+ ).rejects.toThrow("hook failed");
+ expect(await jobCountIn(tx)).toBe(3);
+ });
+ expect(await jobCount()).toBe(0);
+ });
+
+ it("validates an insertion before writing any of it", async () => {
+ const { client } = setup();
+
+ await rolledBack(async (tx) => {
+ await expect(
+ client.insertMany(
+ [
+ { args: {}, job },
+ { args: {}, job, options: { priority: 99 } },
+ ],
+ { tx }
+ )
+ ).rejects.toBeInstanceOf(ValidationError);
+ // Nothing failed in the database, so the transaction is usable.
+ expect(await jobCountIn(tx)).toBe(0);
+ });
+ });
+
+ it("lets a database error abort the caller transaction", async () => {
+ const failing = defineJob({ kind: `${prefix}_failing` });
+ const trigger = `${prefix}_fail_insert`;
+ await admin.query(
+ `CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$
+ BEGIN RAISE EXCEPTION 'insert failed'; END $$`
+ );
+ await admin.query(
+ `CREATE TRIGGER ${trigger} BEFORE INSERT ON river_job FOR EACH ROW
+ WHEN (NEW.kind = '${prefix}_failing') EXECUTE FUNCTION ${trigger}()`
+ );
+ try {
+ const { client } = setup();
+
+ // River doesn't hide the error behind a savepoint, so the caller's
+ // transaction can only be rolled back, with its earlier work.
+ await rolledBack(async (tx) => {
+ await client.insert(job, {}, { tx });
+ await expect(
+ client.insert(failing, {}, { tx })
+ ).rejects.toBeInstanceOf(DatabaseOperationError);
+ await expect(tx.query("SELECT 1")).rejects.toMatchObject({
+ code: "25P02",
+ });
+ });
+ expect(await jobCount()).toBe(0);
+ } finally {
+ await admin.query(`DROP TRIGGER ${trigger} ON river_job`);
+ await admin.query(`DROP FUNCTION ${trigger}()`);
+ }
+ });
+
+ it("leaves a failed transactional completion in the caller transaction until it rolls back", async () => {
+ const queue = `${prefix}_caller_complete`;
+ const txJob = defineJob({ kind: `${prefix}_tx_complete` });
+ let fail = true;
+ let failure: unknown;
+ let inTransaction: unknown;
+ const { client } = setup(
+ () => ({
+ intercept: {
+ async complete(context, next) {
+ const results = await next();
+ await note(context.tx, `completed ${context.commands.length}`);
+ if (fail) throw new Error("complete companion failed");
+ return results;
+ },
+ },
+ }),
+ {
+ completionBatchSize: 1,
+ leaderElectionDisabled: true,
+ queues: {
+ [queue]: {
+ fetchCooldown: { milliseconds: 1 },
+ maxWorkers: 1,
+ pollInterval: { milliseconds: 5 },
+ },
+ },
+ workers: new Workers().add(
+ txJob,
+ async ({ completeTx, job: row }) => {
+ await rolledBack(async (tx) => {
+ failure = await completeTx(tx).catch((error: unknown) => error);
+ inTransaction = {
+ notes: (
+ await tx.query<{ note: string }>(
+ `SELECT note FROM ${companionTable} ORDER BY id`
+ )
+ ).rows.map(({ note }) => note),
+ state: (
+ await tx.query<{ state: string }>(
+ "SELECT state FROM river_job WHERE id = $1",
+ [row.id.toString(10)]
+ )
+ ).rows[0]?.state,
+ };
+ });
+ // The rolled-back completion left the job running, so River
+ // completes it once the handler returns.
+ fail = false;
+ }
+ ),
+ }
+ );
+ const run = await client.start();
+ try {
+ await client.insert(txJob, {}, { queue });
+ await waitFor(async () => {
+ const result = await admin.query<{ count: string }>(
+ "SELECT count(*) AS count FROM river_job WHERE queue = $1 AND state = 'completed'",
+ [queue]
+ );
+ return Number(result.rows[0]?.count) === 1;
+ }, 10_000);
+ } finally {
+ await run.stop();
+ }
+
+ expect(failure).toMatchObject({
+ message: expect.stringContaining("complete companion failed"),
+ });
+ expect(inTransaction).toEqual({
+ notes: ["completed 1"],
+ state: "completed",
+ });
+ expect(await notes()).toEqual(["completed 1"]);
+ });
+
+ it("writes everything in the caller transaction's own transaction ID", async () => {
+ const { client } = setup(() => ({
+ intercept: {
+ async cancel(context, next) {
+ const row = await next();
+ await note(context.tx, "cancel", row?.id ?? null);
+ return row;
+ },
+ async insert(context, next) {
+ const results = await next();
+ await note(context.tx, "insert");
+ return results;
+ },
+ async retry(context, next) {
+ const row = await next();
+ await note(context.tx, "retry", row?.id ?? null);
+ return row;
+ },
+ },
+ }));
+
+ await rolledBack(async (tx) => {
+ // A write directly in the caller's transaction, so that even one
+ // savepoint around all of River's writes would be detected.
+ await note(tx, "application");
+ // More than PostgreSQL's cached subtransaction ID limit.
+ const ids: bigint[] = [];
+ for (let index = 0; index < 70; index++) {
+ ids.push((await client.insert(job, {}, { tx })).job.id);
+ }
+ const many = await client.insertMany(
+ Array.from({ length: 5 }, () => ({ args: {}, job })),
+ { tx }
+ );
+ await client.jobs.cancel(ids[0] as bigint, { tx });
+ await client.jobs.retry(ids[0] as bigint, { tx });
+
+ const result = await tx.query<{
+ rows: string;
+ transactions: string;
+ }>(
+ `SELECT count(*) AS rows, count(DISTINCT xmin::text) AS transactions
+ FROM (
+ SELECT xmin FROM river_job WHERE kind LIKE $1
+ UNION ALL
+ SELECT xmin FROM ${companionTable}
+ ) AS written`,
+ [`${prefix}%`]
+ );
+ expect(many).toHaveLength(5);
+ // 75 jobs, the application's row, an effect row for each of 71
+ // intercepted insertions, and the cancellation and retry.
+ expect(Number(result.rows[0]?.rows)).toBe(75 + 1 + 71 + 2);
+ expect(Number(result.rows[0]?.transactions)).toBe(1);
+ });
+ });
+ });
+});
+
+async function waitFor(
+ predicate: () => boolean | Promise,
+ timeoutMs = 2_000
+): Promise {
+ const deadline = Date.now() + timeoutMs;
+ while (!(await predicate())) {
+ if (Date.now() > deadline) throw new Error("condition was not reached");
+ await new Promise((resolve) => setTimeout(resolve, 10));
+ }
+}
diff --git a/js/driver/pg/src/pilot.ts b/js/driver/pg/src/pilot.ts
new file mode 100644
index 000000000..8b1f6895d
--- /dev/null
+++ b/js/driver/pg/src/pilot.ts
@@ -0,0 +1,185 @@
+/**
+ * The PostgreSQL database River gives a client's pilot: pool connections,
+ * transactions, and the claim and notification statements a
+ * companion runs inside its own transactions.
+ */
+import type { ClientBase, Pool } from "pg";
+import { TransactionScopeError, ValidationError } from "riverqueue";
+import type {
+ FinalizedJobDeleteParams,
+ JobClaimResult,
+ PilotDatabase,
+} from "riverqueue/unstable-driver";
+
+import { isQueryable, type PgDatabase } from "./database.js";
+import { backendMismatchError } from "./errors.js";
+import { abortablePromise, PgClientLease } from "./lease.js";
+import { jobLoadClaimed } from "./sql/jobs.js";
+import { jobDeleteFinalized } from "./sql/maintenance.js";
+import { notifyMany } from "./sql/notify.js";
+
+/** A pilot's view of a pool-backed `PgDriver`. */
+export class PgPilotDatabase implements PilotDatabase {
+ readonly backend = "postgres";
+ readonly schema: string | null;
+
+ readonly #db: PgDatabase;
+ readonly #pool: Pool;
+
+ constructor(db: PgDatabase, pool: Pool) {
+ this.#db = db;
+ this.#pool = pool;
+ this.schema = db.schemaName;
+ }
+
+ async connection(
+ callback: (handle: ClientBase) => PromiseLike | Result,
+ options: { readonly signal?: AbortSignal } = {}
+ ): Promise {
+ const acquiring = this.#pool.connect();
+ let lease: PgClientLease;
+ try {
+ lease = new PgClientLease(
+ await abortablePromise(acquiring, options.signal)
+ );
+ } catch (error: unknown) {
+ void acquiring.then((late) => late.release()).catch(() => undefined);
+ throw error;
+ }
+ try {
+ let result: Result;
+ try {
+ result = await lease.race(callback(lease.client));
+ } catch (error: unknown) {
+ await this.#closeTransaction(lease);
+ throw error;
+ }
+ if (await this.#closeTransaction(lease)) {
+ throw new TransactionScopeError(
+ "nested",
+ "a companion's connection callback left a transaction open; " +
+ "River rolled it back. Use the companion database's " +
+ "transaction() instead",
+ { details: { backend: "postgres", operation: "pilotConnection" } }
+ );
+ }
+ return result;
+ } finally {
+ lease.release();
+ }
+ }
+
+ /**
+ * Roll back a transaction a connection callback left open, so the pooled
+ * connection never returns to the pool inside one, and report whether
+ * there was one. Within a transaction block a later statement's start
+ * time differs from the transaction's; a single autocommit statement's
+ * doesn't. A statement rejected because the transaction is aborted means
+ * one is open too.
+ */
+ async #closeTransaction(lease: PgClientLease): Promise {
+ if (lease.failed) return false;
+ let open: boolean;
+ try {
+ const result = await lease.race(
+ lease.client.query<{ open: boolean }>(
+ "SELECT now() <> statement_timestamp() AS open"
+ )
+ );
+ open = result.rows[0]?.open === true;
+ } catch {
+ open = true;
+ }
+ if (!open) return false;
+ try {
+ await lease.race(lease.client.query("ROLLBACK"));
+ } catch {
+ lease.destroy();
+ }
+ return true;
+ }
+
+ async deleteFinalizedJobs(
+ params: FinalizedJobDeleteParams,
+ options: { readonly tx?: ClientBase } = {}
+ ): Promise {
+ return jobDeleteFinalized(
+ this.#db,
+ params,
+ options.tx === undefined
+ ? {}
+ : { tx: requireTransaction(options, "deleteFinalizedJobs") }
+ );
+ }
+
+ async loadClaimed(
+ ids: readonly bigint[],
+ options: { readonly tx: ClientBase }
+ ): Promise